fix(studio): persist canvas z-order actions correctly for static elements

An adversarial review of the canvas context-menu z-order pipeline (Bring to
Front / Forward / Backward / Send to Back) found the resolver math sound but
the glue between the menu and the commit hook broken:

- The menu optimistically wrote style.zIndex AND position: relative to the
  live elements BEFORE the commit hook ran. The hook decides whether to
  persist position by checking getComputedStyle(el).position === 'static' —
  always false after the pre-apply — so the position patch was never
  persisted on the menu path and the reorder silently reverted at the
  post-commit reload for any nested/static element (root clips survive only
  because the runtime forces position:absolute). The same pre-apply made the
  failure rollback capture the already-mutated values, restoring the broken
  state on persist errors. The menu no longer pre-applies; the hook owns the
  live writes (it already applied both synchronously) and now sees true
  priors. Siblings without a persistable identity still get their z applied
  live-only so a renumber stays visually coherent.
- The commit hook's entry.key store-sync plumbing had zero production
  callers; the store zIndex went stale until full reload. All three callers
  (canvas menu via PreviewOverlays, timeline lane z-sync, LayersPanel) now
  derive and pass the timeline store key (new deriveTimelineStoreKey helper).
- patchElementBatch discarded the server's per-patch matched[]; unresolvable
  siblings persisted partially and silently. Unmatched targets now warn and
  report save-failure telemetry (z-reorder-unmatched) without rolling back
  the matched subset.
- template/noscript elements counted as painting siblings, so renumber
  fallbacks wrote z-index/position into <template> tags in the source file.
  Excluded from the sibling family.
- The default undo coalesce key merged DISTINCT z actions within 300ms into
  one undo entry; the action kind is now part of the key (LayersPanel drags
  keep coalescing within a drag; explicit lane-move gesture keys untouched).
- rectsIntersect comment claimed touching rects intersect; the strict
  inequalities say otherwise — comment fixed.
This commit is contained in:
ukimsanov
2026-07-13 16:48:52 -07:00
parent 19139b91ed
commit 84963ea8ba
14 changed files with 491 additions and 47 deletions
@@ -36,6 +36,7 @@ type ReorderCommit = (
key?: string;
}>,
coalesceKeyOverride?: string,
actionKind?: string,
) => Promise<void>;
function renderReorderHook(
@@ -176,6 +177,150 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
act(() => root.unmount());
});
it("keeps distinct actions in distinct default coalesce keys", async () => {
const el = document.createElement("div");
el.id = "clip-a";
document.body.appendChild(el);
const captured: CapturedBatchCall[] = [];
let commit: ReorderCommit | undefined;
const root = renderReorderHook(captured, (fn) => (commit = fn));
await act(async () => {
await commit!(
[{ element: el, zIndex: 1, id: "clip-a", sourceFile: "index.html" }],
undefined,
"bring-forward",
);
await commit!(
[{ element: el, zIndex: 0, id: "clip-a", sourceFile: "index.html" }],
undefined,
"send-backward",
);
});
// Same element set, different actions — the keys must differ so the two
// edits never coalesce into one undo step within the coalesce window.
expect(captured).toHaveLength(2);
expect(captured[0]?.options.coalesceKey).toBe("z-reorder:bring-forward:clip-a");
expect(captured[1]?.options.coalesceKey).toBe("z-reorder:send-backward:clip-a");
act(() => root.unmount());
});
it("updates the store zIndex synchronously for entries that carry a store key", async () => {
const el = document.createElement("div");
el.id = "clip-a";
document.body.appendChild(el);
usePlayerStore.getState().setElements([
{
id: "clip-a",
key: "index.html#clip-a",
tag: "div",
start: 0,
duration: 1,
track: 0,
zIndex: 0,
hasExplicitZIndex: false,
},
]);
let commit: ReorderCommit | undefined;
let resolveBatch: (() => void) | undefined;
function Harness() {
const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
activeCompPath: "index.html",
showToast: vi.fn(),
writeProjectFile: vi.fn(async () => {}),
domEditSaveTimestampRef: { current: 0 },
editHistory: { recordEdit: vi.fn(async () => {}) },
projectIdRef: { current: null },
reloadPreview: vi.fn(),
clearDomSelection: vi.fn(),
// Persist stays pending so the assertion below can only be satisfied
// by the SYNCHRONOUS store update (the lane-sync path's requirement).
commitDomEditPatchBatches: () => new Promise((resolve) => (resolveBatch = resolve)),
});
commit = handleDomZIndexReorderCommit;
return null;
}
const root = mountReactHarness(<Harness />);
let pending: Promise<void> | undefined;
act(() => {
pending = commit!([
{
element: el,
zIndex: 5,
id: "clip-a",
sourceFile: "index.html",
key: "index.html#clip-a",
},
]);
});
expect(usePlayerStore.getState().elements[0]).toMatchObject({
zIndex: 5,
hasExplicitZIndex: true,
});
resolveBatch?.();
await act(async () => pending);
act(() => root.unmount());
});
// The canvas context-menu path: the menu no longer pre-applies styles, so the
// hook sees the PRISTINE element — prior styles are captured before any
// mutation and a failed persist restores them exactly (previously the menu's
// optimistic write made the "rollback" restore the already-mutated values,
// and the never-persisted position patch silently reverted on reload).
it("rolls back a static, inline-style-free element to pristine styles on failure", async () => {
const el = document.createElement("div");
el.id = "clip-a";
el.style.position = "static"; // happy-dom computes "" for unset position
document.body.appendChild(el);
const failure = new Error("persist failed");
let commit: ReorderCommit | undefined;
function Harness() {
const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
activeCompPath: "index.html",
showToast: vi.fn(),
writeProjectFile: vi.fn(async () => {}),
domEditSaveTimestampRef: { current: 0 },
editHistory: { recordEdit: vi.fn(async () => {}) },
projectIdRef: { current: null },
reloadPreview: vi.fn(),
clearDomSelection: vi.fn(),
commitDomEditPatchBatches: vi.fn(async () => {
// The live styles were applied by the hook before persist ran.
expect(el.style.zIndex).toBe("2");
expect(el.style.position).toBe("relative");
throw failure;
}),
});
commit = handleDomZIndexReorderCommit;
return null;
}
const root = mountReactHarness(<Harness />);
let rejection: unknown;
await act(async () => {
try {
await commit!(
[{ element: el, zIndex: 2, id: "clip-a", sourceFile: "index.html" }],
undefined,
"bring-forward",
);
} catch (error) {
rejection = error;
}
});
expect(rejection).toBe(failure);
expect(el.style.zIndex).toBe("");
expect(el.style.position).toBe("static");
act(() => root.unmount());
});
it("rolls back only live and store state after an atomic reorder failure", async () => {
const writeProjectFile = vi.fn(async () => {});
const recordEdit = vi.fn(async () => {});