Skip to content

Commit 12be7e4

Browse files
committed
feat(studio): three-way z sync — layers drags mirror timeline lanes, panel tracks live z edits
Completes the layers/canvas/timeline sync triangle: the Layers panel was the one surface whose reorders never reached the timeline, and the one that went stale when the other two wrote z flashlessly. - Layers drag -> minimal z + equal-jump lane mirror. handleReorder now uses the canvas menu's realization core via resolveZOrderReposition (one between-z write when a strict gap exists, band-safe scoped renumber otherwise) instead of computeReorderZValues' all-sibling stamp — that helper is deleted, completing the #2347 unification follow-up. The drop then mirrors into a timeline lane move through the same machinery as the canvas menu (new resolveRepositionLaneMove: the clip lands on a free lane strictly between its NEW paint neighbors' lanes — nearest clip siblings in the desired render order, decorations skipped — else a track insert at that boundary; audio zone never crossed). Both writes share one per-gesture zReorderCoalesceKey with an unbounded fold window, so a drag is exactly ONE undo entry; useCanvasZOrderTimelineMirror's plumbing is factored into useMirrorLaneMoveCommit and reused by the new useLayerReorderTimelineMirror. A same-slot drop is a hard no-op (new order-equality guard in resolveZOrderReposition). - Panel staleness fix: flashless z commits (skipReload) reload nothing and bump no refreshKey, so the panel's z-sorted order went stale while paused. handleDomZIndexReorderCommit now bumps a store zEditVersion on apply AND rollback; the panel re-collects on it. Verified live: the panel re-sorts the instant a drag commits and again on undo. - Layer click reveal (useLayerRevealOverride): clicking a layer that stays hidden at the current frame (animation-parked opacity, non-clip display/visibility hides, hidden ancestors) temporarily forces the chain visible with live inline styles — exact priors restored on deselect, on another reveal, on play, and on unmount; never persisted (file diff == 0 verified live). Clips keep the existing seek-into-window behavior; the override applies on a short defer so a seek-revealed clip needs none. - layerOrdering's unused hasExplicitZIndex probe (zero callers) removed. Live-verified on a bed copy: a 2-position layers drag wrote exactly one element (z 6->23 + data-track-index 15->2), the timeline lane moved without a reload, and a single Cmd+Z restored the file byte-identically.
1 parent 5f6e18d commit 12be7e4

11 files changed

Lines changed: 655 additions & 94 deletions

packages/studio/src/components/editor/LayersPanel.tsx

Lines changed: 105 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,13 @@ import {
1414
} from "../../utils/studioHelpers";
1515
import { Layers } from "../../icons/SystemIcons";
1616
import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
17-
import { computeReorderZValues, getElementZIndex } from "../../player/lib/layerOrdering";
17+
import { getElementZIndex } from "../../player/lib/layerOrdering";
1818
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
19+
import { resolveZOrderReposition } from "./canvasContextMenuZOrder";
20+
import { buildStableSelector } from "./domEditingDom";
21+
import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
22+
import { useLayerReorderTimelineMirror } from "../nle/useCanvasZOrderTimelineMirror";
23+
import { useLayerRevealOverride } from "./useLayerRevealOverride";
1924

2025
const TAG_ICONS: Record<string, string> = {
2126
video: "Vi",
@@ -85,8 +90,13 @@ interface CollapsedState {
8590
// fallow-ignore-next-line complexity
8691
export const LayersPanel = memo(function LayersPanel() {
8792
const { previewIframeRef, activeCompPath, showToast } = useStudioShellContext();
88-
const { refreshKey, compositionLoading, timelineElements } = useStudioPlaybackContext();
93+
const { refreshKey, compositionLoading, timelineElements, isPlaying } =
94+
useStudioPlaybackContext();
8995
const currentTime = usePlayerStore((s) => s.currentTime);
96+
// Flashless z commits (canvas menu, timeline lane-drag z-sync) mutate iframe
97+
// z-indexes with no reload and no refreshKey bump — while paused, nothing
98+
// else re-collects, so the panel's z-sorted order would go stale.
99+
const zEditVersion = usePlayerStore((s) => s.zEditVersion);
90100
const {
91101
domEditSelection,
92102
activeGroupElement,
@@ -100,6 +110,15 @@ export const LayersPanel = memo(function LayersPanel() {
100110
const [collapsed, setCollapsed] = useState<CollapsedState>({});
101111
const prevDocVersionRef = useRef(0);
102112
const scrollContainerRef = useRef<HTMLDivElement>(null);
113+
const mirrorLayerReorderToTimeline = useLayerReorderTimelineMirror();
114+
const { reveal, restoreReveal, revealedBase } = useLayerRevealOverride({ isPlaying });
115+
116+
// Drop the temporary reveal when the selection leaves the revealed layer
117+
// (deselect, or a selection made anywhere else in the studio).
118+
useEffect(() => {
119+
const base = revealedBase();
120+
if (base && domEditSelection?.element !== base) restoreReveal();
121+
}, [domEditSelection, revealedBase, restoreReveal]);
103122

104123
const isMasterView = !activeCompPath || activeCompPath === "index.html";
105124

@@ -131,7 +150,7 @@ export const LayersPanel = memo(function LayersPanel() {
131150

132151
useEffect(() => {
133152
collectLayers();
134-
}, [collectLayers, refreshKey]);
153+
}, [collectLayers, refreshKey, zEditVersion]);
135154

136155
useEffect(() => {
137156
const iframe = previewIframeRef.current;
@@ -224,15 +243,25 @@ export const LayersPanel = memo(function LayersPanel() {
224243
[currentTime, resolveSelection, timelineElements],
225244
);
226245

246+
const revealTimerRef = useRef(0);
227247
const handleSelectLayer = useCallback(
228248
async (layer: DomEditLayerItem) => {
229249
const selection = await resolveSelection(layer);
230250
if (!selection) return;
231251
applyDomSelection(selection);
232252
await seekToLayer(layer);
253+
// Force-reveal AFTER the seek's runtime visibility sync has had a beat:
254+
// a clip made active by the seek shows naturally and needs no override,
255+
// so the reveal only touches nodes that REMAIN hidden (animation-parked
256+
// opacity, non-clip display/visibility hides, hidden ancestors).
257+
window.clearTimeout(revealTimerRef.current);
258+
revealTimerRef.current = window.setTimeout(() => {
259+
if (selection.element.isConnected) reveal(selection.element);
260+
}, 150);
233261
},
234-
[resolveSelection, applyDomSelection, seekToLayer],
262+
[resolveSelection, applyDomSelection, seekToLayer, reveal],
235263
);
264+
useEffect(() => () => window.clearTimeout(revealTimerRef.current), []);
236265

237266
// Double-click a group row → drill into it; any other row → select it.
238267
const handleLayerDoubleClick = useCallback(
@@ -264,36 +293,86 @@ export const LayersPanel = memo(function LayersPanel() {
264293
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
265294
}, []);
266295

296+
// fallow-ignore-next-line complexity
267297
const handleReorder = useCallback(
268298
(event: LayerReorderEvent) => {
269299
const { siblingLayers, fromIndex, toIndex } = event;
270300
const reordered = [...siblingLayers];
271301
const [moved] = reordered.splice(fromIndex, 1);
272302
reordered.splice(toIndex, 0, moved);
273303

274-
const existingValues = siblingLayers.map((l) => getElementZIndex(l.element));
275-
const zValues = computeReorderZValues(existingValues, fromIndex, toIndex);
276-
277-
const entries = reordered.map((layer, i) => ({
278-
element: layer.element,
279-
zIndex: zValues[i],
280-
id: layer.id,
281-
selector: layer.selector,
282-
selectorIndex: layer.selectorIndex,
283-
sourceFile: layer.sourceFile,
284-
key: deriveTimelineStoreKey({
285-
domId: layer.id,
286-
selector: layer.selector,
287-
selectorIndex: layer.selectorIndex,
288-
sourceFile: layer.sourceFile,
289-
}),
290-
}));
291-
292-
// "layer-drag" keeps consecutive drops of the same sibling set coalescing
293-
// into one undo step, without merging with a context-menu z action.
294-
handleDomZIndexReorderCommit(entries, undefined, "layer-drag");
304+
// Panel order is top-first (sortLayersByZIndex: z desc, later-DOM-first),
305+
// so the desired RENDER order (bottom→top) is the reverse. The minimal
306+
// resolver (shared with the canvas z-menu) then writes one between-z
307+
// value when a strict gap exists, band-safe renumber otherwise — instead
308+
// of the old computeReorderZValues stamp of every sibling.
309+
const desiredBottomToTop = [...reordered].reverse();
310+
const patches = resolveZOrderReposition(
311+
moved.element,
312+
desiredBottomToTop.map((l) => l.element),
313+
);
314+
if (!patches || patches.length === 0) return; // paint order unchanged
315+
316+
const layerByElement = new Map(siblingLayers.map((l) => [l.element, l]));
317+
const entries = [];
318+
for (const patch of patches) {
319+
// The renumber fallback can patch a painting sibling the panel didn't
320+
// list (non-collected family member): derive its identity from the DOM
321+
// node, exactly like the canvas menu's siblingZIndexEntry. Un-targetable
322+
// nodes get a live-only style write (reverts on reload).
323+
const layer = layerByElement.get(patch.element);
324+
const id = layer?.id ?? (patch.element.id || undefined);
325+
const selector = layer?.selector ?? buildStableSelector(patch.element);
326+
if (!id && !selector) {
327+
patch.element.style.zIndex = String(patch.zIndex);
328+
continue;
329+
}
330+
const sourceFile = layer?.sourceFile ?? moved.sourceFile;
331+
entries.push({
332+
element: patch.element,
333+
zIndex: patch.zIndex,
334+
id,
335+
selector,
336+
selectorIndex: layer?.selectorIndex,
337+
sourceFile,
338+
key: deriveTimelineStoreKey({
339+
domId: id,
340+
selector,
341+
selectorIndex: layer?.selectorIndex,
342+
sourceFile,
343+
}),
344+
});
345+
}
346+
if (entries.length === 0) return;
347+
348+
// ONE undo entry for the whole gesture: the z persist and the timeline
349+
// lane mirror below share this per-gesture-unique key (same contract as
350+
// the canvas menu's wiring in PreviewOverlays).
351+
const coalesceKey = zReorderCoalesceKey(entries, "layer-drag");
352+
const desiredOrderKeys = desiredBottomToTop.map(
353+
(l) =>
354+
deriveTimelineStoreKey({
355+
domId: l.id,
356+
selector: l.selector,
357+
selectorIndex: l.selectorIndex,
358+
sourceFile: l.sourceFile,
359+
}) ?? null,
360+
);
361+
const movedKey = deriveTimelineStoreKey({
362+
domId: moved.id,
363+
selector: moved.selector,
364+
selectorIndex: moved.selectorIndex,
365+
sourceFile: moved.sourceFile,
366+
});
367+
// Mirror AFTER the z persist resolves — the two writes patch the same
368+
// source file, so they are serialized (see useCanvasZOrderTimelineMirror).
369+
handleDomZIndexReorderCommit(entries, coalesceKey, "layer-drag")
370+
.then(() =>
371+
mirrorLayerReorderToTimeline({ selectionKey: movedKey, desiredOrderKeys, coalesceKey }),
372+
)
373+
.catch(() => undefined);
295374
},
296-
[handleDomZIndexReorderCommit],
375+
[handleDomZIndexReorderCommit, mirrorLayerReorderToTimeline],
297376
);
298377

299378
const selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null;

packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
parseZIndex,
77
resolveCrossedNeighbor,
88
resolveZOrderChange,
9+
resolveZOrderReposition,
910
type ZOrderAction,
1011
type ZOrderPatch,
1112
} from "./canvasContextMenuZOrder";
@@ -617,3 +618,59 @@ describe("isZOrderActionEnabled", () => {
617618
}
618619
});
619620
});
621+
622+
describe("resolveZOrderReposition (Layers-panel arbitrary drop)", () => {
623+
it("multi-position jump with distinct z resolves to ONE between-z write", () => {
624+
// Render order bottom→top today: target(1), a(3), b(5). Drop target between
625+
// a and b → single write: z strictly between 3 and 5.
626+
const { target, byId } = makeFamily("1", [
627+
["a", "3"],
628+
["b", "5"],
629+
]);
630+
const patches = resolveZOrderReposition(target, [byId.a, target, byId.b]);
631+
expect(patches).toEqual([{ element: target, zIndex: 4 }]);
632+
});
633+
634+
it("jump to the very top writes one z above the previous top", () => {
635+
const { target, byId } = makeFamily("1", [
636+
["a", "3"],
637+
["b", "5"],
638+
]);
639+
const patches = resolveZOrderReposition(target, [byId.a, byId.b, target]);
640+
expect(patches).toEqual([{ element: target, zIndex: 6 }]);
641+
});
642+
643+
it("no-op drop (unchanged order) returns null", () => {
644+
const { target, byId } = makeFamily("1", [
645+
["a", "3"],
646+
["b", "5"],
647+
]);
648+
expect(resolveZOrderReposition(target, [target, byId.a, byId.b])).toBeNull();
649+
});
650+
651+
it("tied z values renumber the scoped set minimally (band-safe)", () => {
652+
const { target, byId } = makeFamily("2", [
653+
["a", "2"],
654+
["b", "2"],
655+
]);
656+
// All tied at 2; DOM order target,a,b → render bottom→top target,a,b.
657+
// Move target to the top: scoped renumber within the band.
658+
const patches = resolveZOrderReposition(target, [byId.a, byId.b, target]);
659+
expect(patches).not.toBeNull();
660+
const z = new Map(patches!.map((p) => [(p.element as HTMLElement).id, p.zIndex]));
661+
const zOf = (id: string) => z.get(id) ?? 2;
662+
expect(zOf("a")).toBeLessThan(zOf("b"));
663+
expect(zOf("b")).toBeLessThan(zOf("target"));
664+
});
665+
666+
it("rejects elements that are not painting siblings of the target", () => {
667+
const { target, byId } = makeFamily("1", [["a", "3"]]);
668+
const stranger = makeEl("stranger", "2");
669+
expect(resolveZOrderReposition(target, [stranger, target, byId.a])).toBeNull();
670+
});
671+
672+
it("returns null for sets too small to reorder", () => {
673+
const { target } = makeFamily("1", []);
674+
expect(resolveZOrderReposition(target, [target])).toBeNull();
675+
});
676+
});

packages/studio/src/components/editor/canvasContextMenuZOrder.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,41 @@ export function resolveZOrderChange(
439439
return realizeOrder(order, desired, target, entries);
440440
}
441441

442+
/**
443+
* Realize an ARBITRARY repositioning of `target` within a scoped sibling set —
444+
* the Layers-panel drag, which can jump several siblings in one drop, unlike
445+
* the menu's four fixed actions. `desiredOrderBottomToTop` is the scoped set
446+
* (target included at its new slot) in the intended render order. Reuses the
447+
* menu's minimal-write realization (realizeOrder): one between-z write when a
448+
* strict gap exists, band-safe scoped renumber otherwise — replacing the old
449+
* LayersPanel computeReorderZValues path that stamped EVERY sibling.
450+
*
451+
* Null when nothing changes, the set is too small, or an element in the
452+
* desired order is not actually a painting sibling of `target`.
453+
*/
454+
export function resolveZOrderReposition(
455+
target: HTMLElement,
456+
desiredOrderBottomToTop: readonly HTMLElement[],
457+
): ZOrderPatch[] | null {
458+
const { entries } = getFamily(target);
459+
if (entries.length < 2) return null;
460+
const byElement = new Map(entries.map((entry) => [entry.element, entry]));
461+
const desired: RenderEntry[] = [];
462+
for (const el of desiredOrderBottomToTop) {
463+
const entry = byElement.get(el);
464+
if (!entry) return null;
465+
desired.push(entry);
466+
}
467+
if (desired.length < 2 || !desired.some((entry) => entry.element === target)) return null;
468+
const currentOrder = toRenderOrder(desired);
469+
// A drop back into the same slot is a no-op. The menu actions guard this via
470+
// their position checks before realizeOrder; an arbitrary reposition must
471+
// compare the orders itself — realizeOrder would otherwise "normalize" an
472+
// end-of-set target to a fresh z value it doesn't need.
473+
if (currentOrder.every((entry, i) => entry.element === desired[i].element)) return null;
474+
return realizeOrder(currentOrder, desired, target, entries);
475+
}
476+
442477
/**
443478
* The sibling a forward/backward step crosses: the visible overlapping
444479
* neighbor directly above (bring-forward) or below (send-backward) the target

0 commit comments

Comments
 (0)