diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index f1d08dfa71..e22c78a49f 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -39,6 +39,44 @@ describe("collectRuntimeTimelinePayload", () => { expect(result.clips[0].id).toBe("hf-headline"); }); + // Regression: the authored data-track-index must round-trip verbatim, even + // when clips of DIFFERENT kinds (video vs element) share a track. The old + // mixed-kind renumber split them onto separate tracks, which made the + // written track drift from the displayed one on every editor move. + it("honors authored track indices verbatim for mixed-kind tracks", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-duration", "20"); + document.body.appendChild(root); + + const video = document.createElement("video"); + video.id = "clip-video"; + video.setAttribute("data-start", "1"); + video.setAttribute("data-duration", "3"); + video.setAttribute("data-track-index", "1"); + root.appendChild(video); + + const caption = document.createElement("div"); + caption.id = "clip-caption"; + caption.setAttribute("data-start", "8"); + caption.setAttribute("data-duration", "3"); + caption.setAttribute("data-track-index", "1"); + root.appendChild(caption); + + const other = document.createElement("div"); + other.id = "clip-other"; + other.setAttribute("data-start", "0"); + other.setAttribute("data-duration", "3"); + other.setAttribute("data-track-index", "2"); + root.appendChild(other); + + const result = collectRuntimeTimelinePayload(defaultParams); + const trackOf = (id: string) => result.clips.find((c) => c.id === id)?.track; + expect(trackOf("clip-video")).toBe(1); + expect(trackOf("clip-caption")).toBe(1); + expect(trackOf("clip-other")).toBe(2); + }); + it("collects clips from elements with data-start and data-duration", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 0cd123fc0c..23e76d4b72 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -52,57 +52,15 @@ function maxDefinedNumber(...values: Array): number | null { } /** - * When multiple content kinds share the same track number, split them - * onto separate tracks so the timeline UI shows distinct rows. - * - * Preferred kind order (top → bottom): composition, video, image, element, audio. - * Tracks that contain only one kind are left untouched. + * Parse an authored track attribute, honoring 0 (a valid top-lane index). + * `parseInt(...) || fallback` silently replaced authored track 0 with the + * synthetic fallback, so track-0 clips drifted to the bottom of the timeline. */ -const KIND_ORDER: Record = { - composition: 0, - video: 1, - image: 2, - element: 3, - audio: 4, -}; - -function normalizeTrackAssignments(clips: RuntimeTimelineClip[]): void { - if (clips.length === 0) return; - - // Group clips by their raw track number and detect which tracks have mixed kinds - const trackKinds = new Map>(); - for (const clip of clips) { - const kinds = trackKinds.get(clip.track) ?? new Set(); - kinds.add(clip.kind); - trackKinds.set(clip.track, kinds); - } - - const hasMixedTracks = Array.from(trackKinds.values()).some((kinds) => kinds.size > 1); - if (!hasMixedTracks) return; - - // Build new contiguous track numbers, splitting mixed tracks by kind - let nextTrack = 0; - const newTrackMap = new Map(); // "origTrack:kind" → newTrack - - const sortedTracks = [...trackKinds.keys()].sort((a, b) => a - b); - for (const track of sortedTracks) { - const kinds = trackKinds.get(track)!; - if (kinds.size === 1) { - newTrackMap.set(`${track}:${[...kinds][0]}`, nextTrack++); - } else { - // Split by kind in preferred order - const sorted = [...kinds].sort((a, b) => (KIND_ORDER[a] ?? 99) - (KIND_ORDER[b] ?? 99)); - for (const kind of sorted) { - newTrackMap.set(`${track}:${kind}`, nextTrack++); - } - } - } - - for (const clip of clips) { - const key = `${clip.track}:${clip.kind}`; - const newTrack = newTrackMap.get(key); - if (newTrack != null) clip.track = newTrack; - } +function parseAuthoredTrack(el: Element, fallback: number): number { + const raw = el.getAttribute("data-track-index") ?? el.getAttribute("data-track"); + if (raw == null) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : fallback; } function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null { @@ -441,11 +399,7 @@ export function collectRuntimeTimelinePayload(params: { label: buildTimelineClipLabel(node, kind, clips.length), start, duration, - track: - Number.parseInt( - node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i), - 10, - ) || 0, + track: parseAuthoredTrack(node, i), zIndex: readInlineZIndex(node), stackingContextId: compositionContext.parentCompositionId ?? rootCompositionId, kind, @@ -554,11 +508,7 @@ export function collectRuntimeTimelinePayload(params: { el.id, start: range.start, duration: clampedDuration, - track: - Number.parseInt( - el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "", - 10, - ) || gsapTrack, + track: parseAuthoredTrack(el, gsapTrack), zIndex: readInlineZIndex(el), stackingContextId: rootCompositionIdForGsap, kind: "element", @@ -613,11 +563,7 @@ export function collectRuntimeTimelinePayload(params: { el.id, start: 0, duration: clampedDuration, - track: - Number.parseInt( - el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "", - 10, - ) || overlayTrack, + track: parseAuthoredTrack(el, overlayTrack), zIndex: readInlineZIndex(el), stackingContextId: rootCompositionIdForGsap, kind: "element", @@ -637,11 +583,12 @@ export function collectRuntimeTimelinePayload(params: { } } - // ── Track normalization ──────────────────────────────────────────────── - // When multiple content kinds (composition, audio, video, …) share the same - // data-track-index value, split them onto separate tracks so the timeline UI - // shows distinct rows for each kind. - normalizeTrackAssignments(clips); + // Track assignment honors the authored data-track-index verbatim: a clip stays + // on the track it was placed on, regardless of kind. (Previously mixed-kind + // tracks were split onto separate rows, but that renumbered tracks — breaking + // "drop a clip onto an existing track" and causing the written track to drift + // from the displayed one on every move. Track index is display-only; render + // never reads it, so honoring it verbatim is the correct NLE behavior.) for (const compositionNode of compositionNodes) { if (compositionNode === root) continue; diff --git a/packages/studio/src/components/editor/CanvasContextMenu.test.tsx b/packages/studio/src/components/editor/CanvasContextMenu.test.tsx index 9039863213..57a093e81b 100644 --- a/packages/studio/src/components/editor/CanvasContextMenu.test.tsx +++ b/packages/studio/src/components/editor/CanvasContextMenu.test.tsx @@ -3,7 +3,11 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness"; +import { resolveZIndexEntries } from "../nle/PreviewOverlays"; +import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps"; +import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes"; import { CanvasContextMenu } from "./CanvasContextMenu"; +import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder"; import type { DomEditSelection } from "./domEditing"; installReactActEnvironment(); @@ -24,7 +28,8 @@ afterEach(() => { function renderMenu(props: { selection: DomEditSelection; - onApplyZIndex?: () => void; + onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void; + onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void; onDelete?: (selection: DomEditSelection) => void; }) { root = createRoot(host); @@ -36,6 +41,7 @@ function renderMenu(props: { selection: props.selection, onClose: () => {}, onApplyZIndex: props.onApplyZIndex, + onZOrderCrossed: props.onZOrderCrossed, onDelete: props.onDelete, }), ); @@ -113,3 +119,148 @@ describe("CanvasContextMenu — handler gating", () => { expect(document.body.querySelector(".border-t")).toBeNull(); }); }); + +// ── Menu z-action → commit path (wired the way PreviewOverlays wires the app) ── + +function pressMenuItem(label: string) { + const button = zOrderButtons().find((b) => b.textContent === label); + expect(button).toBeDefined(); + act(() => { + button!.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }), + ); + }); +} + +/** Target (static, earlier in DOM) below an equal-z sibling — z action must renumber. */ +function makeStaticFamily() { + const parent = document.createElement("div"); + const target = document.createElement("div"); + target.id = "target"; + // In happy-dom an unset computed position is "" (not "static"), which would + // skip the commit hook's static-position injection; declare it explicitly so + // the test exercises the browser default. + target.style.position = "static"; + const other = document.createElement("div"); + other.id = "other"; + parent.append(target, other); + document.body.append(parent); + return { parent, target, other }; +} + +interface CapturedBatchCall { + batches: DomEditPatchBatch[]; + options: { label: string; coalesceKey: string }; +} + +/** Mount the REAL commit hook (persist layer mocked at commitDomEditPatchBatches). */ +function renderCommitHook(captured: CapturedBatchCall[]) { + type Commit = ReturnType["handleDomZIndexReorderCommit"]; + let commit: Commit | undefined; + function Harness() { + ({ handleDomZIndexReorderCommit: commit } = 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: async (batches, options) => { + captured.push({ batches, options }); + }, + })); + return null; + } + const hookHost = document.createElement("div"); + document.body.append(hookHost); + const hookRoot = createRoot(hookHost); + act(() => hookRoot.render()); + return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) }; +} + +describe("CanvasContextMenu — z-action commit path", () => { + it("never mutates live styles itself and persists the position patch for a static element", async () => { + const { target } = makeStaticFamily(); + const selection = makeSelection("Target", target); + const captured: CapturedBatchCall[] = []; + const { commit, cleanup } = renderCommitHook(captured); + + // Wire onApplyZIndex the way the app does (PreviewOverlays → the commit + // hook), asserting the menu has NOT touched the DOM when it fires — the + // hook must capture true pre-change styles for its rollback. + const stylesAtApply: Array<{ zIndex: string; position: string }> = []; + renderMenu({ + selection, + onApplyZIndex: (patches, action) => { + stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position }); + const { entries } = resolveZIndexEntries(selection, patches); + void commit(entries, undefined, action); + }, + }); + + await act(async () => pressMenuItem("Bring forward")); + + // The menu left the element pristine; only the commit hook wrote styles. + expect(stylesAtApply).toEqual([{ zIndex: "", position: "static" }]); + expect(target.style.zIndex).toBe("1"); + expect(target.style.position).toBe("relative"); + + // The persisted payload carries BOTH the z-index and the injected position, + // so the reorder survives the post-commit reloadPreview(). + expect(captured).toHaveLength(1); + const targetPatch = captured[0]?.batches + .flatMap((batch) => batch.patches) + .find((patch) => patch.target.id === "target"); + expect(targetPatch?.operations).toEqual( + expect.arrayContaining([ + { type: "inline-style", property: "z-index", value: "1" }, + { type: "inline-style", property: "position", value: "relative" }, + ]), + ); + // F7: the action kind is part of the default undo coalesce key, so two + // different menu actions never merge into one undo step. + expect(captured[0]?.options.coalesceKey).toContain("bring-forward"); + + cleanup(); + }); + + it("reports the crossed sibling to onZOrderCrossed for a forward step (resolved pre-mutation)", async () => { + // target (earlier in DOM) and other are tied — bring-forward steps over + // `other`, and the flash callback must receive exactly that element, after + // onApplyZIndex ran (call order lets the host measure post-commit rects). + const { target, other } = makeStaticFamily(); + const selection = makeSelection("Target", target); + const calls: Array<{ kind: string; crossed?: HTMLElement }> = []; + + renderMenu({ + selection, + onApplyZIndex: () => calls.push({ kind: "apply" }), + onZOrderCrossed: (crossed, action) => { + expect(action).toBe("bring-forward"); + calls.push({ kind: "crossed", crossed }); + }, + }); + + await act(async () => pressMenuItem("Bring forward")); + + expect(calls.map((c) => c.kind)).toEqual(["apply", "crossed"]); + expect(calls[1]?.crossed).toBe(other); + }); + + it("does not call onZOrderCrossed for bring-to-front", async () => { + const { target } = makeStaticFamily(); + const onZOrderCrossed = vi.fn(); + + renderMenu({ + selection: makeSelection("Target", target), + onApplyZIndex: vi.fn(), + onZOrderCrossed, + }); + + await act(async () => pressMenuItem("Bring to front")); + + expect(onZOrderCrossed).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/components/editor/CanvasContextMenu.tsx b/packages/studio/src/components/editor/CanvasContextMenu.tsx index a7a124128f..cf06d95487 100644 --- a/packages/studio/src/components/editor/CanvasContextMenu.tsx +++ b/packages/studio/src/components/editor/CanvasContextMenu.tsx @@ -7,10 +7,13 @@ * useContextMenuDismiss. * * ── Wiring (z-order persistence) ───────────────────────────────────────────── - * Z-index changes are applied optimistically to the live iframe element(s) via + * Z-index changes are resolved against the live iframe DOM via * `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware: * moving a target past an equal-z sibling can require renumbering the affected - * set). The patches are surfaced through the `onApplyZIndex` prop. + * set). The patches are surfaced through the `onApplyZIndex` prop; the menu + * itself never mutates element styles — handleDomZIndexReorderCommit applies + * the live z-index (and injects position when needed) in the same synchronous + * flow, and captures the TRUE prior styles for its failure rollback. * * The prop MUST be wired at the call site to route through the full persist * path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected @@ -26,7 +29,9 @@ import type { DomEditSelection } from "./domEditing"; import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss"; import { isZOrderActionEnabled, + resolveCrossedNeighbor, resolveZOrderChange, + type ZOrderAction, type ZOrderPatch, } from "./canvasContextMenuZOrder"; @@ -38,12 +43,22 @@ interface CanvasContextMenuProps { selection: DomEditSelection; onClose: () => void; /** - * Called with the resolved z-order patch list after an optimistic DOM update. - * Each patch is an { element, zIndex } pair (the target and, when a renumber - * is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see - * module-level wiring comment). + * Called with the resolved z-order patch list and the menu action that + * produced it (the action feeds the undo coalesce key, so two DIFFERENT + * actions never merge into one undo step). Each patch is an + * { element, zIndex } pair (the target and, when a renumber is needed, + * affected siblings). The menu does NOT touch the live DOM — wire to + * handleDomZIndexReorderCommit, which applies the live styles itself + * (see module-level wiring comment). */ - onApplyZIndex?: (patches: ZOrderPatch[]) => void; + onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void; + /** + * Called after a successful bring-forward / send-backward with the sibling + * the target stepped over (resolved from the SAME pre-mutation state as the + * patches), so the host can flash a highlight on it in the studio overlay. + * Never called for front/back or no-op actions. + */ + onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void; /** * Delete the selected element. Wire to handleDomEditElementDelete from * useDomEditActionsContext — same path as the Delete/Backspace hotkey. @@ -68,6 +83,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({ selection, onClose, onApplyZIndex, + onZOrderCrossed, onDelete, }: CanvasContextMenuProps) { const menuRef = useContextMenuDismiss(onClose); @@ -93,20 +109,19 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({ const el = selection.element; function handleZAction(action: ZAction) { - // No persist handler → do NOT touch the live iframe DOM. An optimistic - // write with nothing to persist just reverts on the next reload. if (!onApplyZIndex) return; const patches = resolveZOrderChange(el, action); if (patches === null) return; - // Optimistic update — visible immediately even before persist completes. - for (const patch of patches) { - patch.element.style.zIndex = String(patch.zIndex); - const view = patch.element.ownerDocument?.defaultView; - if (view && view.getComputedStyle(patch.element).position === "static") { - patch.element.style.position = "relative"; - } - } - onApplyZIndex(patches); + // Resolve the crossed neighbor BEFORE the commit path mutates live styles — + // both resolvers must read the same pre-change render order. + const crossed = onZOrderCrossed ? resolveCrossedNeighbor(el, action) : null; + // Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the + // live z-index (and injects position:relative for static elements) in the + // same synchronous flow, so feedback is still instant — and it must read + // the PRE-change styles itself, both to capture true rollback values and + // to detect a static position that needs persisting. + onApplyZIndex(patches, action); + if (crossed && onZOrderCrossed) onZOrderCrossed(crossed, action); onClose(); } diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index e410f2c9d6..18bfe32c17 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -1,9 +1,11 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { type DomEditSelection } from "./domEditing"; import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction"; import { useMarqueeGestures } from "./marqueeCommit"; import { MarqueeOverlay } from "./MarqueeOverlay"; import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry"; +import { useZOrderCrossedFlash, ZOrderCrossedFlash } from "./useZOrderCrossedFlash"; +import { useCanvasContextMenuState } from "./useCanvasContextMenuState"; import { type BlockedMoveState, type DomEditGroupPathOffsetCommit, @@ -27,7 +29,7 @@ import { useDomEditCompositionRect } from "./useDomEditCompositionRect"; import { useMountEffect } from "../../hooks/useMountEffect"; import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh"; import { CanvasContextMenu } from "./CanvasContextMenu"; -import type { ZOrderPatch } from "./canvasContextMenuZOrder"; +import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder"; import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers"; // Re-exports for external consumers — preserving existing import paths. @@ -91,12 +93,17 @@ interface DomEditOverlayProps { */ onDeleteSelection?: (selection: DomEditSelection) => void; /** - * Called with the resolved z-order patch list after an optimistic DOM update. - * The patch list is tie-aware and may include sibling elements (see - * canvasContextMenuZOrder). Wire to handleDomZIndexReorderCommit from + * Called with the resolved z-order patch list and the menu action that + * produced it (feeds the undo coalesce key). The patch list is tie-aware and + * may include sibling elements (see canvasContextMenuZOrder); the live DOM is + * NOT yet mutated. Wire to handleDomZIndexReorderCommit from * useDomEditActionsContext. See CanvasContextMenu.tsx module comment. */ - onApplyZIndex?: (selection: DomEditSelection, patches: ZOrderPatch[]) => void; + onApplyZIndex?: ( + selection: DomEditSelection, + patches: ZOrderPatch[], + action: ZOrderAction, + ) => void; } // fallow-ignore-next-line complexity @@ -139,30 +146,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const snapGuidesRef = useRef(null); const rafPausedRef = useRef(false); - // Context menu state: position of the right-click that opened it. - // contextMenuSelection is the element the menu targets — captured at right-click - // time so the menu can open even before the React selection state settles. - const [contextMenu, setContextMenu] = useState<{ - x: number; - y: number; - sel: DomEditSelection; - } | null>(null); - const selectionRef = useRef(selection); selectionRef.current = selection; - // Close the context menu whenever the selection moves off the element the menu - // targets (a click that reselects elsewhere, a deselect, or a preview reload - // that rebuilds the selection). Without this the menu can linger — orphaned — - // over a stale target after the underlying element is gone. A right-click that - // OPENS the menu also selects its target, so the common open path keeps the - // menu (same element) rather than immediately dismissing it. - useEffect(() => { - if (!contextMenu) return; - if (!selection || selection.element !== contextMenu.sel.element) { - setContextMenu(null); - } - }, [selection, contextMenu]); + // Brief highlight on the sibling a forward/backward z step crossed — drawn + // in this studio overlay, never in the iframe DOM (see useZOrderCrossedFlash). + const { zOrderFlashRect, handleZOrderCrossed } = useZOrderCrossedFlash({ overlayRef, iframeRef }); const activeCompositionPathRef = useRef(activeCompositionPath); activeCompositionPathRef.current = activeCompositionPath; @@ -428,37 +417,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({ e.stopPropagation(); }; - // Right-click: select element first (if not already selected), then open menu. - const handleContextMenu = useCallback( - async (event: React.MouseEvent) => { - event.preventDefault(); - - // If no element is selected yet, resolve it from the pointer position first. - const currentSel = selectionRef.current; - let activeSel: DomEditSelection | null = currentSel; - if (!currentSel) { - const pointerEvent = event as unknown as React.PointerEvent; - const resolved = await onCanvasPointerMoveRef.current(pointerEvent); - if (!resolved) return; // Nothing under the cursor — skip menu. - onSelectionChangeRef.current(resolved, { revealPanel: true }); - // Use `resolved` directly: React state (and therefore selectionRef) won't - // update synchronously after onSelectionChange — we'd be reading stale null. - activeSel = resolved; - } else { - // Check if the user right-clicked on an unselected element (hover target). - const hover = hoverSelectionRef.current; - if (hover && hover.element !== currentSel.element) { - onSelectionChangeRef.current(hover, { revealPanel: true }); - activeSel = hover; - } - } - - if (!activeSel) return; - setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); + // Right-click state + handler: select the element under the pointer (if + // needed), then open the menu; closes when the selection moves off-target. + const { contextMenu, closeContextMenu, handleContextMenu } = useCanvasContextMenuState({ + selection, + selectionRef, + hoverSelectionRef, + onCanvasPointerMoveRef, + onSelectionChangeRef, + }); return (
setContextMenu(null)} + onClose={closeContextMenu} onDelete={ onDeleteSelection ? (sel) => { - setContextMenu(null); + closeContextMenu(); onDeleteSelection(sel); } : undefined } onApplyZIndex={ onApplyZIndex - ? (patches) => { - onApplyZIndex(contextMenu.sel, patches); + ? (patches, action) => { + onApplyZIndex(contextMenu.sel, patches, action); } : undefined } + onZOrderCrossed={handleZOrderCrossed} /> )} + = { video: "Vi", @@ -280,9 +281,17 @@ export const LayersPanel = memo(function LayersPanel() { selector: layer.selector, selectorIndex: layer.selectorIndex, sourceFile: layer.sourceFile, + key: deriveTimelineStoreKey({ + domId: layer.id, + selector: layer.selector, + selectorIndex: layer.selectorIndex, + sourceFile: layer.sourceFile, + }), })); - handleDomZIndexReorderCommit(entries); + // "layer-drag" keeps consecutive drops of the same sibling set coalescing + // into one undo step, without merging with a context-menu z action. + handleDomZIndexReorderCommit(entries, undefined, "layer-drag"); }, [handleDomZIndexReorderCommit], ); diff --git a/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts b/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts index a0ae6b4f71..b663192cdd 100644 --- a/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts +++ b/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts @@ -1,8 +1,10 @@ // @vitest-environment jsdom import { describe, expect, it } from "vitest"; import { + isElementVisibleForZOrder, isZOrderActionEnabled, parseZIndex, + resolveCrossedNeighbor, resolveZOrderChange, type ZOrderAction, type ZOrderPatch, @@ -48,8 +50,12 @@ function makeFamily( } /** Resolve a z-order change and assert it produced patches (fails otherwise). */ -function resolveZOrderPatches(target: HTMLElement, action: ZOrderAction): ZOrderPatch[] { - const patches = resolveZOrderChange(target, action); +function resolveZOrderPatches( + target: HTMLElement, + action: ZOrderAction, + options?: { isVisible?: (el: HTMLElement) => boolean }, +): ZOrderPatch[] { + const patches = resolveZOrderChange(target, action, options); expect(patches).not.toBeNull(); if (!patches) throw new Error("expected z-order patches"); return patches; @@ -375,6 +381,27 @@ describe("resolveZOrderChange – excludes non-painting siblings", () => { expect(order.indexOf("target")).toBeLessThan(order.indexOf("a")); }); + it("ignores