mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
* fix(studio): delete every clip in the selection, not just the first Select all in the timeline, press Delete, and one clip disappeared while the rest stayed — still drawn as selected. The Delete hotkey built the selection set correctly and then called `elements.find(...)`, which stops at the first match, and handed that single element to a handler that deletes exactly one. The comment above it claimed the handler "expands a clip that is part of the multi-selection into an atomic delete of the whole selection (single undo)" — no such expansion existed anywhere; `useTimelineEditing` never read `selectedElementIds`. `handleTimelineElementsDelete` takes the whole selection and removes every element before saving once, so the delete is a single history entry and a single undo — what the comment already promised. The hotkey layer now takes only that plural handler, since it never deletes one element in isolation; the singular entry point stays for the context menu and clip chrome. The store drops every deleted key and clears the marquee set, rather than leaving a selection drawn around clips that no longer exist. Elements whose `sourceFile` is not the composition being edited are dropped from the pass rather than written to the wrong file. Also removes the preview's double-click-to-reset-zoom. It was a document-level capture listener, so any double-click anywhere over the viewport snapped the zoom back to fit — including double-clicks meant for the content under it. The explicit reset control beside the zoom HUD stays. Reproduced by test: restoring `elements.find` reds the new marquee case. * fix(studio): delete every canvas element in the selection, not just the primary Selecting several elements on the canvas and pressing Delete removed one of them and left the rest — still drawn as selected. The delete path only ever took the primary selection; the marquee group it belongs to was ignored. Expand the session-level delete through the group ref, the same way the other group commits already do, and let the lifecycle op remove every member under a single save so one Undo restores the whole selection. * fix(studio): let the canvas selection own Delete instead of its timeline mirror Marquee-selecting elements on the canvas and pressing Delete removed a fraction of them. The hotkey routed to the timeline delete whenever the timeline store held anything, and the timeline's copy of a canvas selection is derived and lossy by construction — a member with no timeline row of its own is dropped from it. Selecting 73 elements published 14 ids, so 14 went and 59 stayed, still drawn as selected. The canvas selection is what the user drew the marquee around, so it owns Delete whenever it holds something; the timeline path stays as the fallback for rows with no canvas node to select. Both paths already remove through the same endpoint, so this is one addressing scheme replacing two. That makes the canvas delete the path a Delete press normally takes, so it picks up the same mid-recording refusal the timeline delete has. * fix(studio): let the marquee see the whole document, not the first 80 elements Dragging a marquee over the entire canvas selected a fraction of what it covered, so Delete left most of the page behind. The hit test sourced its candidates from the layers-panel collector, which stops after 80 items — a budget for how many rows that panel is willing to render, silently reused as if it described the document. Everything past the 80th element in document order was unselectable no matter where the user dragged. The off-canvas indicators were reading the same truncated list. The cap now belongs to the panel that wants it; the collector returns everything. To pay for that, the marquee measures its candidates once when the drag passes the threshold instead of re-reading layout for every element on every pointer-move: unbounded plus per-move stalled the tab outright, and the iframe DOM does not mutate mid-drag, so one pass stays true for the gesture. On a captured page: one marquee, one Delete, 734 elements down to 81. * fix(studio): report a no-op delete instead of claiming the elements went A target the file no longer holds answers `changed: false`, which is normal for a member nested inside another member already removed. Every target answering that is not — it means the preview is describing a document the file does not have, so each removal misses and the file is written back untouched. The toast still said "Deleted 503 elements. Use Undo to restore them." That is how a delete that did nothing at all looked from the outside: press Delete, the page stays, nothing on screen explains it. Say the preview is out of date and reload it instead. * fix(studio): keep the canvas hotkeys alive across preview reloads Pressing Delete with a canvas selection did nothing at all — no removal, no toast, nothing on screen to explain it. A keypress goes to whichever document has focus, and clicking the canvas puts focus inside the preview iframe, so the app's hotkeys have to be forwarded there. They were, but only from the iframe element's ref callback, which fires when the element mounts. A preview reload keeps the same element, so the callback never runs again, and keeps the same WindowProxy, so the forwarder's identity check saw no change and skipped re-attaching — while the inner window holding the listeners had been replaced. After the first reload the canvas had no app hotkeys left. Undo and redo kept working because their forwarder re-attaches on every load, which is why this read as "only Delete is broken". Fold the app handler into that per-load forwarder so both attach in the same place, on every load, and drop the mount-only one. Window only: the history pair also listens on the document, and capture listeners on both would run the app handler twice per press. * perf(studio): stop re-probing every restored selection member on load The hash carries the whole canvas selection, and restoring it asked the server whether each member still exists in the source — one request per member, awaited one after another. A marquee over a captured page puts hundreds of members in the URL, so every later load of that URL spent hundreds of serial round trips rebuilding the selection before the canvas answered anything, keypresses included. The marquee that produced those members already skips the probe. Restoring them skips it too; only the primary, whose panel reads the flag, still pays for one. * fix(studio): delete a canvas selection in one pass and say the key landed Reproduced with a real, focus-routed keypress instead of a synthetic one: the press does reach the handler and the delete does run to completion, but at hundreds of members it takes seconds during which the canvas is unchanged and nothing acknowledges the key. Silence for that long is indistinguishable from Delete being broken, and pressing it again or reloading mid-flight lands in a worse state. Two things, one per cause. The removal now sends the whole selection in a single request against a new remove-elements route, which reads the file once, drops every member and writes once — it was a round trip AND a full rewrite of the file per element. And a multi-element delete announces itself before the work starts, so the press is visibly acknowledged instead of leaving the canvas looking untouched until it finishes. Measured on a captured page, 84 members: 933ms of serial round trips against 84 rewrites, down to 583ms and one. * refactor(studio): narrow the SDK delete targets instead of asserting them The batch SDK path guarded on every member having an hfId and then asserted it away per member. Narrow once into a string list so the guard and the values come from the same place, and drop a threaded content variable that never changed — the SDK owns the document it edits, so every member is removed against the same starting content. Also mounts the new forwarding test through the existing harness rather than repeating its setup. * fix(studio): stop Delete acting on a canvas selection the user replaced Two things the reordered Delete arbitration got wrong, both found in review. A clip with no canvas node left the canvas selection pointing at whatever was picked before it, and the canvas branch wins whenever that ref is non-null — so selecting an audio clip and pressing Delete removed the previously selected canvas element and left the clip, right after the toast said the clip was not in the preview. The timeline fallback the comment described could not be reached. Clearing that selection has to stay quiet: the clear is announced to the timeline, so echoing it would deselect the clip that was just picked. Expanding the primary to the marquee group also moved out of the delete handler and up to the Delete key. Cut copies the primary alone, so expanding for every caller put one element on the clipboard and removed every other member with it — undo brought them back, paste restored one. The rule is a named function now, so the two callers can differ without either guessing. Also throttles the off-canvas indicator rebuild, which the cap had been hiding. It walks every element in the preview and reads layout for each — measured at 6.5ms on an 825-element captured page against a 16.7ms frame — and what marks it dirty is a MutationObserver on inline style, which is how animation writes. * fix(studio): hold the canvas selection inside the timeline selection The stale-canvas-selection defect survived at the second writer. The store-driven sync bails when a member has not resolved yet and returned without touching the canvas, so a pick with no canvas node at all left the previous selection in place — and Delete acts on the canvas first, so it deleted that. Reachable from the sidebar audio and asset reveals and from an asset drop, none of which go through the handler already fixed. Clearing on every bail would be wrong: the bail exists for a member whose node is not ready, which a later run resolves, and clearing there would flicker. Only a canvas anchor that resolves OUTSIDE the current selection goes, which is the state that is dangerous rather than merely unfinished. Quietly, for the same reason as the first writer: announcing would deselect the clip just picked. The invariant is named now, since Delete depends on it: the canvas selection never points outside the current timeline selection. Also drops the x-hf-removed header, which nothing read and whose comment promised a partial-vs-no-op distinction the response cannot make, and pins the indicator throttle that was measured but uncovered.
404 lines
18 KiB
TypeScript
404 lines
18 KiB
TypeScript
import { useCallback } from "react";
|
|
import { usePlayerStore } from "../player";
|
|
import {
|
|
readProjectFileContent,
|
|
saveProjectFilesWithHistory,
|
|
type DomEditCommitBaseParams,
|
|
} from "../utils/studioFileHistory";
|
|
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
|
|
import {
|
|
buildDomEditPatchTarget,
|
|
readHfId,
|
|
type DomEditSelection,
|
|
} from "../components/editor/domEditing";
|
|
import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../player/lib/timelineElementHelpers";
|
|
import {
|
|
beginLayerRevealCommit,
|
|
beginLayerZPersist,
|
|
completeLayerRevealCommit,
|
|
rollbackLayerRevealCommit,
|
|
type LayerRevealCommitOwnership,
|
|
} from "../components/editor/useLayerRevealOverride";
|
|
import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
|
|
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
|
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
|
|
interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
|
|
/** Route delete through SDK when session resolves the hf-id. */
|
|
onTrySdkDelete?: (
|
|
hfId: string,
|
|
originalContent: string,
|
|
targetPath: string,
|
|
) => Promise<CutoverResult>;
|
|
/** Resolver-shadow tripwire for the reordered targets (telemetry-only, decoupled from cutover). */
|
|
onReorderShadow?: (targets: string[]) => void;
|
|
/** Resync the SDK session after a server-fallback delete. */
|
|
forceReloadSdkSession?: () => void;
|
|
commitDomEditPatchBatches: CommitDomEditPatchBatches;
|
|
/** Stage 7 Step 3b: called after a successful server-side element delete (shadow). */
|
|
onElementDeleted?: (selection: DomEditSelection) => void;
|
|
}
|
|
|
|
// One coalesce key per z-reorder gesture. A monotonic counter — NOT Date.now()
|
|
// / Math.random(), which the determinism rules forbid — matches the
|
|
// laneChangeGestureSeq precedent in timelineClipDragCommit.ts: the key only has
|
|
// to be unique per gesture and identical across the gesture's records.
|
|
let zReorderGestureSeq = 0;
|
|
|
|
/**
|
|
* Undo coalesce key for ONE z-reorder gesture — unique per call. The key
|
|
* carries the action kind + element ids for debuggability, plus a gesture
|
|
* sequence so two SEPARATE user actions (even the same action on the same
|
|
* selection) never share a key. That uniqueness is what makes the unbounded
|
|
* per-gesture coalesce window (see handleDomZIndexReorderCommit) safe: the
|
|
* fold can only ever merge records of the SAME gesture.
|
|
*
|
|
* Exported as THE single implementation of the key: the canvas z-order wiring
|
|
* (PreviewOverlays) mints it once per gesture and passes the same instance to
|
|
* both the z persist and the timeline lane mirror (useCanvasZOrderTimelineMirror)
|
|
* so editHistory folds the z write and the track write into one undo entry —
|
|
* recomputing the key per record would silently split the undo.
|
|
*/
|
|
export function zReorderCoalesceKey(
|
|
entries: ReadonlyArray<{ element: HTMLElement; id?: string; selector?: string }>,
|
|
actionKind?: string,
|
|
): string {
|
|
const ids = entries
|
|
.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el")
|
|
.join(":");
|
|
return `z-reorder:${actionKind ?? "reorder"}:${ids}:g${zReorderGestureSeq++}`;
|
|
}
|
|
|
|
export function useElementLifecycleOps({
|
|
activeCompPath,
|
|
showToast,
|
|
writeProjectFile,
|
|
domEditSaveTimestampRef,
|
|
editHistory,
|
|
projectIdRef,
|
|
reloadPreview,
|
|
clearDomSelection,
|
|
onTrySdkDelete,
|
|
onReorderShadow,
|
|
forceReloadSdkSession,
|
|
commitDomEditPatchBatches,
|
|
onElementDeleted,
|
|
}: UseElementLifecycleOpsParams) {
|
|
// fallow-ignore-next-line complexity
|
|
const handleDomEditElementsDelete = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
async (selections: DomEditSelection[]) => {
|
|
const pid = projectIdRef.current;
|
|
if (!pid) return;
|
|
const [selection] = selections;
|
|
if (!selection) return;
|
|
const label =
|
|
selections.length === 1
|
|
? selection.label || selection.id || selection.selector || selection.tagName
|
|
: `${selections.length} elements`;
|
|
// Say the press landed before doing the work. Deleting a marquee selection
|
|
// takes seconds — reading the file, removing every member, saving, then
|
|
// reloading the preview — and until it finishes the canvas looks exactly
|
|
// like it did before. With nothing acknowledging the key, that silence is
|
|
// indistinguishable from Delete being broken, which is how it got read.
|
|
if (selections.length > 1) showToast(`Deleting ${label}...`, "info");
|
|
|
|
// One file per pass; anything authored elsewhere is dropped rather than
|
|
// patched into the wrong document.
|
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
const sameFile = selections.filter(
|
|
(candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
|
|
);
|
|
try {
|
|
const originalContent = await readProjectFileContent(pid, targetPath);
|
|
|
|
const patchTargets = sameFile.map((member) => buildDomEditPatchTarget(member));
|
|
if (patchTargets.some((t) => !t.id && !t.selector && !t.hfId)) {
|
|
throw new Error("Selected element has no patchable target");
|
|
}
|
|
|
|
// The SDK path can take the whole selection only when every member is
|
|
// addressable in the SDK doc; otherwise fall through to REST for all of
|
|
// them rather than deleting a subset through each route.
|
|
const hfIds = sameFile
|
|
.map((member) => member.hfId)
|
|
.filter((hfId): hfId is string => Boolean(hfId));
|
|
if (onTrySdkDelete && hfIds.length === sameFile.length) {
|
|
let allHandled = true;
|
|
for (const hfId of hfIds) {
|
|
// The SDK owns the document it edits, so every member is removed
|
|
// against the same starting content rather than a threaded copy.
|
|
const handled = await onTrySdkDelete(hfId, originalContent, targetPath);
|
|
if (!cutoverCommittedOrThrow(handled)) {
|
|
allHandled = false;
|
|
break;
|
|
}
|
|
}
|
|
if (allHandled) {
|
|
clearDomSelection();
|
|
usePlayerStore.getState().setSelectedElementId(null);
|
|
showToast(
|
|
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
|
"info",
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
domEditSaveTimestampRef.current = Date.now();
|
|
// One request for the whole selection. Removing members one at a time
|
|
// cost a round trip and a rewrite of the file EACH, and a canvas
|
|
// selection runs to hundreds of members — the file ended up correct, but
|
|
// only after long enough that Delete looked like it had done nothing.
|
|
const removeResponse = await fetch(
|
|
`/api/projects/${pid}/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
body: JSON.stringify({ targets: patchTargets }),
|
|
},
|
|
);
|
|
if (!removeResponse.ok) {
|
|
throw await createStudioSaveHttpError(
|
|
removeResponse,
|
|
`Failed to delete element from ${targetPath}`,
|
|
);
|
|
}
|
|
const removeData = (await removeResponse.json()) as {
|
|
changed?: boolean;
|
|
content?: string;
|
|
};
|
|
if (!removeData.changed) {
|
|
// A member the file no longer holds simply does not match, which is
|
|
// normal for one nested inside another member already removed. Nothing
|
|
// matching at all means the preview is describing a document the file
|
|
// does not have — say so rather than reporting a delete that happened.
|
|
reloadPreview();
|
|
throw new Error("Nothing to delete — the preview was out of date. Try again.");
|
|
}
|
|
const patchedContent =
|
|
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
// ponytail: the server remove-element route (removeElementFromHtml) strips
|
|
// only the element node — it does NOT cascade-remove GSAP tweens targeting
|
|
// it, unlike the SDK path (removeElement → cascadeRemoveAnimations). This
|
|
// fallback runs only when the element isn't in the SDK doc (e.g. runtime-
|
|
// generated / unaddressable), where targeting tweens are unlikely. Upgrade
|
|
// path: cascade in removeElementFromHtml by selector/hf-id to fully match.
|
|
await saveProjectFilesWithHistory({
|
|
projectId: pid,
|
|
label: "Delete element",
|
|
kind: "timeline",
|
|
files: { [targetPath]: patchedContent },
|
|
readFile: async () => originalContent,
|
|
// remove-element already wrote the removal, so disk holds THAT — not
|
|
// the content read at the top. Undo still goes back to the original.
|
|
diskContent: { [targetPath]: patchedContent },
|
|
writeFile: writeProjectFile,
|
|
recordEdit: editHistory.recordEdit,
|
|
});
|
|
|
|
clearDomSelection();
|
|
usePlayerStore.getState().setSelectedElementId(null);
|
|
// Server wrote the file; resync the stale in-memory SDK doc so a later
|
|
// SDK edit doesn't resurrect the deleted element.
|
|
forceReloadSdkSession?.();
|
|
reloadPreview();
|
|
for (const member of sameFile) onElementDeleted?.(member);
|
|
showToast(
|
|
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
|
"info",
|
|
);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Failed to delete element";
|
|
showToast(message);
|
|
}
|
|
},
|
|
[
|
|
activeCompPath,
|
|
clearDomSelection,
|
|
domEditSaveTimestampRef,
|
|
editHistory.recordEdit,
|
|
onTrySdkDelete,
|
|
onElementDeleted,
|
|
forceReloadSdkSession,
|
|
projectIdRef,
|
|
reloadPreview,
|
|
showToast,
|
|
writeProjectFile,
|
|
],
|
|
);
|
|
|
|
// Z-index reorder folds patches by source file, then sends one aggregate cross-file
|
|
// patch-element-batches request. The server refuses the whole gesture on any unmatched
|
|
// target and rolls back earlier file writes if a later write fails.
|
|
// No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed.
|
|
const handleDomZIndexReorderCommit = useCallback(
|
|
(
|
|
entries: Array<{
|
|
element: HTMLElement;
|
|
zIndex: number;
|
|
id?: string;
|
|
selector?: string;
|
|
selectorIndex?: number;
|
|
sourceFile: string;
|
|
key?: string;
|
|
}>,
|
|
gestureCoalesceKey?: string,
|
|
actionKind?: string,
|
|
) => {
|
|
if (entries.length === 0) return Promise.resolve();
|
|
// One async owner must bracket reveal tokens, optimistic DOM/store state,
|
|
// atomic persistence, rollback, and the final persistence-count release.
|
|
// Splitting those phases would make transaction ownership less explicit.
|
|
// fallow-ignore-next-line complexity
|
|
return (async () => {
|
|
const releaseZPersists = entries.map((entry) => beginLayerZPersist(entry.element));
|
|
try {
|
|
// Resolver shadow (telemetry-only, decoupled from cutover): record whether
|
|
// the SDK resolves each reordered element — the reorderElements op's targets.
|
|
onReorderShadow?.(
|
|
entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
|
|
);
|
|
// The default key carries the action kind so two DIFFERENT actions on the
|
|
// same element set (e.g. "bring-forward" then "send-backward" within the
|
|
// coalesce window) never merge into one undo step. Callers that share a
|
|
// gesture (lane moves) pass an explicit gestureCoalesceKey instead.
|
|
const coalesceKey = gestureCoalesceKey ?? zReorderCoalesceKey(entries, actionKind);
|
|
const patchesBySourceFile = new Map<string, DomEditPatchBatch["patches"]>();
|
|
const rollbacks: Array<() => void> = [];
|
|
const revealCommits: Array<{
|
|
element: HTMLElement;
|
|
ownership: LayerRevealCommitOwnership;
|
|
}> = [];
|
|
for (const entry of entries) {
|
|
const priorZIndex = entry.element.style.zIndex;
|
|
const priorPosition = entry.element.style.position;
|
|
const priorStoreEntry = entry.key
|
|
? usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === entry.key)
|
|
: undefined;
|
|
let positionChanged = false;
|
|
// An active Layers-panel reveal lift on this element is consumed by
|
|
// this commit: the new z is the truth. Read the parked TRUE position
|
|
// for the static check below (the lift set a temporary
|
|
// position:relative that would otherwise mask the need to persist
|
|
// one), then drop the lift attributes so z readers stop reporting the
|
|
// stale prior (see useLayerRevealOverride / readLayerRevealPriorZ).
|
|
const liftPriorPosition = entry.element.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
|
|
const revealOwnership = beginLayerRevealCommit(entry.element);
|
|
if (revealOwnership) {
|
|
revealCommits.push({ element: entry.element, ownership: revealOwnership });
|
|
}
|
|
entry.element.style.zIndex = String(entry.zIndex);
|
|
const patches: Array<{ type: "inline-style"; property: string; value: string }> = [
|
|
{ type: "inline-style", property: "z-index", value: String(entry.zIndex) },
|
|
];
|
|
try {
|
|
const win = entry.element.ownerDocument?.defaultView;
|
|
const effectivePosition =
|
|
liftPriorPosition ??
|
|
(win ? win.getComputedStyle(entry.element).position : undefined);
|
|
if (effectivePosition === "static") {
|
|
entry.element.style.position = "relative";
|
|
positionChanged = true;
|
|
patches.push({ type: "inline-style", property: "position", value: "relative" });
|
|
}
|
|
} catch {
|
|
/* cross-origin or detached — skip */
|
|
}
|
|
if (entry.key) {
|
|
usePlayerStore
|
|
.getState()
|
|
.updateElement(entry.key, { zIndex: entry.zIndex, hasExplicitZIndex: true });
|
|
}
|
|
rollbacks.push(() => {
|
|
if (revealOwnership) {
|
|
rollbackLayerRevealCommit(entry.element, revealOwnership);
|
|
} else {
|
|
entry.element.style.zIndex = priorZIndex;
|
|
if (positionChanged) entry.element.style.position = priorPosition;
|
|
}
|
|
if (entry.key && priorStoreEntry) {
|
|
usePlayerStore.getState().updateElement(entry.key, {
|
|
zIndex: priorStoreEntry.zIndex,
|
|
hasExplicitZIndex: priorStoreEntry.hasExplicitZIndex,
|
|
});
|
|
}
|
|
});
|
|
const filePatches = patchesBySourceFile.get(entry.sourceFile) ?? [];
|
|
filePatches.push({
|
|
target: buildDomEditPatchTarget({
|
|
id: entry.id,
|
|
hfId: readHfId(entry.element),
|
|
selector: entry.selector,
|
|
selectorIndex: entry.selectorIndex,
|
|
}),
|
|
operations: patches,
|
|
});
|
|
patchesBySourceFile.set(entry.sourceFile, filePatches);
|
|
}
|
|
const batches = [...patchesBySourceFile].map(([sourceFile, patches]) => ({
|
|
sourceFile,
|
|
patches,
|
|
}));
|
|
// Live z state changed with NO reload coming (skipReload below) — nudge
|
|
// DOM-derived views (Layers panel z-sort) to re-read the iframe.
|
|
usePlayerStore.getState().bumpZEditVersion();
|
|
const rollbackOptimisticState = () => {
|
|
for (const rollback of rollbacks) rollback();
|
|
usePlayerStore.getState().bumpZEditVersion();
|
|
};
|
|
// Resolves once every source-file batch is persisted so a same-file timing write
|
|
// can be ordered after it (see applyTimelineStackingReorder callers).
|
|
//
|
|
// skipReload: the live iframe DOM and the player store already hold the
|
|
// final z state (applied synchronously above), and the persisted patch is
|
|
// inline-style-only — a full iframe remount would only blink the preview.
|
|
// commitDomEditPatchBatches still falls back to reloading whenever the
|
|
// server reports an unmatched patch target (live DOM ≠ disk).
|
|
try {
|
|
const result = await commitDomEditPatchBatches(batches, {
|
|
label: "Reorder layers",
|
|
coalesceKey,
|
|
// Unbounded window: every key this commit records under is unique per
|
|
// gesture (zReorderCoalesceKey's gesture seq, or the lane drag's
|
|
// clip-lane-move:<seq>), so the fold can only merge records of the SAME
|
|
// gesture — and those records are separated by a server round-trip
|
|
// (move→z on a lane drag, z→lane-mirror on a canvas action), which
|
|
// under real network latency exceeds the 300ms default window.
|
|
coalesceMs: Number.POSITIVE_INFINITY,
|
|
skipReload: true,
|
|
});
|
|
if (!result.durable) {
|
|
rollbackOptimisticState();
|
|
return result;
|
|
}
|
|
for (const { element, ownership } of revealCommits) {
|
|
completeLayerRevealCommit(element, ownership);
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
rollbackOptimisticState();
|
|
throw error;
|
|
}
|
|
} finally {
|
|
for (const release of releaseZPersists) release();
|
|
}
|
|
})();
|
|
},
|
|
[commitDomEditPatchBatches, onReorderShadow],
|
|
);
|
|
|
|
const handleDomEditElementDelete = useCallback(
|
|
async (selection: DomEditSelection) => {
|
|
await handleDomEditElementsDelete([selection]);
|
|
},
|
|
[handleDomEditElementsDelete],
|
|
);
|
|
|
|
return {
|
|
handleDomEditElementDelete,
|
|
handleDomEditElementsDelete,
|
|
handleDomZIndexReorderCommit,
|
|
};
|
|
}
|