@hyperframes/studio 0.7.57 → 0.7.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
  2. package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
  3. package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +15 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5954 -4474
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  17. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  18. package/src/components/editor/DomEditOverlay.tsx +5 -2
  19. package/src/components/editor/LayersPanel.tsx +110 -99
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  21. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  26. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  27. package/src/components/nle/PreviewOverlays.tsx +27 -2
  28. package/src/components/nle/TimelinePane.test.ts +3 -0
  29. package/src/components/nle/TimelinePane.tsx +11 -1
  30. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  32. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  33. package/src/components/nle/zLaneGesture.test.ts +176 -0
  34. package/src/components/nle/zLaneGesture.ts +77 -0
  35. package/src/hooks/domEditCommitTypes.ts +17 -1
  36. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  37. package/src/hooks/fetchStubTestUtils.ts +16 -0
  38. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  39. package/src/hooks/timelineEditingHelpers.ts +32 -15
  40. package/src/hooks/timelineMoveAdapter.ts +2 -1
  41. package/src/hooks/timelineTimingSync.test.ts +703 -33
  42. package/src/hooks/timelineTimingSync.ts +282 -97
  43. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  44. package/src/hooks/timelineTrackVisibility.ts +4 -3
  45. package/src/hooks/useClipboard.ts +19 -17
  46. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  47. package/src/hooks/useDomEditCommits.ts +143 -78
  48. package/src/hooks/useDomEditSession.test.tsx +3 -1
  49. package/src/hooks/useDomEditSession.ts +1 -1
  50. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  51. package/src/hooks/useElementLifecycleOps.ts +174 -81
  52. package/src/hooks/usePreviewPersistence.ts +2 -2
  53. package/src/hooks/useTimelineEditing.test.tsx +302 -154
  54. package/src/hooks/useTimelineEditing.ts +97 -93
  55. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  56. package/src/hooks/useTimelineGroupEditing.ts +35 -3
  57. package/src/player/components/Timeline.test.ts +31 -21
  58. package/src/player/components/Timeline.tsx +62 -91
  59. package/src/player/components/TimelineCanvas.tsx +36 -5
  60. package/src/player/components/TimelineLanes.tsx +32 -10
  61. package/src/player/components/TimelineOverlays.tsx +36 -0
  62. package/src/player/components/TimelineRuler.tsx +18 -5
  63. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  64. package/src/player/components/timelineCallbacks.ts +4 -1
  65. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  66. package/src/player/components/timelineClipDragCommit.ts +173 -71
  67. package/src/player/components/timelineGapCommit.test.ts +163 -0
  68. package/src/player/components/timelineGapCommit.ts +103 -0
  69. package/src/player/components/timelineGaps.test.ts +221 -0
  70. package/src/player/components/timelineGaps.ts +182 -0
  71. package/src/player/components/timelineLayout.test.ts +2 -1
  72. package/src/player/components/timelineLayout.ts +22 -6
  73. package/src/player/components/timelineMarquee.test.ts +20 -10
  74. package/src/player/components/timelineMarquee.ts +9 -2
  75. package/src/player/components/timelineStackingSync.test.ts +42 -1
  76. package/src/player/components/timelineStackingSync.ts +31 -16
  77. package/src/player/components/timelineZMirror.test.ts +667 -0
  78. package/src/player/components/timelineZMirror.ts +393 -0
  79. package/src/player/components/timelineZoom.test.ts +2 -2
  80. package/src/player/components/timelineZoom.ts +7 -3
  81. package/src/player/components/useTimelineClipDrag.ts +1 -1
  82. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  83. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  84. package/src/player/components/useTimelinePlayhead.ts +4 -3
  85. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  86. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  87. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  88. package/src/player/components/useTimelineStackingSync.ts +5 -3
  89. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  90. package/src/player/components/useTrackGapMenu.ts +155 -0
  91. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  92. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  93. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  94. package/src/player/lib/layerOrdering.test.ts +1 -15
  95. package/src/player/lib/layerOrdering.ts +5 -28
  96. package/src/player/lib/timelineDOM.test.ts +72 -0
  97. package/src/player/lib/timelineDOM.ts +3 -0
  98. package/src/player/lib/timelineElementHelpers.ts +35 -11
  99. package/src/player/store/playerStore.ts +12 -1
  100. package/src/telemetry/events.test.ts +10 -0
  101. package/src/telemetry/events.ts +1 -0
  102. package/src/utils/domEditSaveQueue.ts +4 -3
  103. package/src/utils/editHistory.test.ts +82 -0
  104. package/src/utils/editHistory.ts +14 -2
  105. package/src/utils/gsapSoftReload.test.ts +54 -115
  106. package/src/utils/gsapSoftReload.ts +42 -152
  107. package/src/utils/gsapUndoRestore.test.ts +269 -0
  108. package/src/utils/gsapUndoRestore.ts +269 -0
  109. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  110. package/dist/assets/index-_pqzyxB1.css +0 -1
  111. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  112. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.57",
3
+ "version": "0.7.58",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.57",
50
- "@hyperframes/parsers": "0.7.57",
51
- "@hyperframes/sdk": "0.7.57",
52
- "@hyperframes/player": "0.7.57",
53
- "@hyperframes/studio-server": "0.7.57"
49
+ "@hyperframes/core": "0.7.58",
50
+ "@hyperframes/parsers": "0.7.58",
51
+ "@hyperframes/player": "0.7.58",
52
+ "@hyperframes/sdk": "0.7.58",
53
+ "@hyperframes/studio-server": "0.7.58"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.57"
68
+ "@hyperframes/producer": "0.7.58"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -189,10 +189,10 @@ export function StudioApp() {
189
189
  }>,
190
190
  coalesceKey?: string,
191
191
  operation: TimelineMoveOperation = "timing",
192
+ coalesceMs?: number,
192
193
  ) => {
193
- await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, {
194
- handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove,
195
- });
194
+ const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove };
195
+ await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
196
196
  },
197
197
  [timelineEditing.handleTimelineGroupMove],
198
198
  );
@@ -180,9 +180,9 @@ export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
180
180
  </>
181
181
  ) : (
182
182
  <>
183
- <span className="text-neutral-500 flex-shrink-0">How's the Studio experience?</span>
183
+ <span className="text-neutral-500 flex-shrink-0">Recommend HyperFrames?</span>
184
184
  <div className="flex items-center gap-0.5">
185
- {[1, 2, 3, 4, 5].map((n) => (
185
+ {Array.from({ length: 11 }, (_, n) => n).map((n) => (
186
186
  <button
187
187
  key={n}
188
188
  onClick={() => handleRating(n)}
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
6
6
  import { resolveZIndexEntries } from "../nle/PreviewOverlays";
7
7
  import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
8
+ import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils";
8
9
  import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
9
10
  import { CanvasContextMenu } from "./CanvasContextMenu";
10
11
  import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
@@ -102,9 +103,26 @@ describe("CanvasContextMenu — handler gating", () => {
102
103
 
103
104
  renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() });
104
105
 
105
- expect(zOrderButtons()).toHaveLength(4);
106
+ const buttons = zOrderButtons();
107
+ expect(buttons).toHaveLength(4);
106
108
  expect(hasDeleteItem()).toBe(false);
107
109
  expect(document.body.querySelector(".border-t")).toBeNull();
110
+
111
+ // Labels stay the exact industry-standard names (the icons add no text)...
112
+ expect(buttons.map((b) => b.textContent)).toEqual([
113
+ "Bring to front",
114
+ "Bring forward",
115
+ "Send backward",
116
+ "Send to back",
117
+ ]);
118
+ // ...and each item leads with a stroke icon that inherits the item's text
119
+ // color (currentColor), so the disabled muted tone applies to it too.
120
+ for (const button of buttons) {
121
+ const svg = button.firstElementChild;
122
+ expect(svg?.tagName.toLowerCase()).toBe("svg");
123
+ expect(svg?.getAttribute("stroke")).toBe("currentColor");
124
+ expect(svg?.getAttribute("aria-hidden")).toBe("true");
125
+ }
108
126
  });
109
127
 
110
128
  it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => {
@@ -158,19 +176,14 @@ function renderCommitHook(captured: CapturedBatchCall[]) {
158
176
  type Commit = ReturnType<typeof useElementLifecycleOps>["handleDomZIndexReorderCommit"];
159
177
  let commit: Commit | undefined;
160
178
  function Harness() {
161
- ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps({
162
- activeCompPath: "index.html",
163
- showToast: vi.fn(),
164
- writeProjectFile: vi.fn(async () => {}),
165
- domEditSaveTimestampRef: { current: 0 },
166
- editHistory: { recordEdit: vi.fn(async () => {}) },
167
- projectIdRef: { current: null },
168
- reloadPreview: vi.fn(),
169
- clearDomSelection: vi.fn(),
170
- commitDomEditPatchBatches: async (batches, options) => {
171
- captured.push({ batches, options });
172
- },
173
- }));
179
+ ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps(
180
+ makeLifecycleOpsParams({
181
+ commitDomEditPatchBatches: async (batches, options) => {
182
+ captured.push({ batches, options });
183
+ return { durable: true, allMatched: true, changed: true };
184
+ },
185
+ }),
186
+ ));
174
187
  return null;
175
188
  }
176
189
  const hookHost = document.createElement("div");
@@ -50,8 +50,17 @@ interface CanvasContextMenuProps {
50
50
  * affected siblings). The menu does NOT touch the live DOM — wire to
51
51
  * handleDomZIndexReorderCommit, which applies the live styles itself
52
52
  * (see module-level wiring comment).
53
+ *
54
+ * `crossed` is the sibling a forward/backward step moved past, resolved from
55
+ * the SAME pre-mutation render order as the patches (null for front/back or
56
+ * when there is no neighbor). The host uses it to mirror the z action into a
57
+ * timeline lane move (resolveZMirrorLaneMove's crossedKey).
53
58
  */
54
- onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
59
+ onApplyZIndex?: (
60
+ patches: ZOrderPatch[],
61
+ action: ZOrderAction,
62
+ crossed: HTMLElement | null,
63
+ ) => void;
55
64
  /**
56
65
  * Called after a successful bring-forward / send-backward with the sibling
57
66
  * the target stepped over (resolved from the SAME pre-mutation state as the
@@ -70,10 +79,61 @@ interface CanvasContextMenuProps {
70
79
 
71
80
  type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
72
81
 
82
+ // Stacked-layer + arrow glyphs, one per z action (16px, stroke, currentColor —
83
+ // matches the studio's inline-SVG conventions: fill="none", 1.2 stroke, round
84
+ // caps/joins). Single actions show ONE layer diamond with the arrow stepping
85
+ // one way; front/back show a TWO-diamond stack with the arrow piercing through
86
+ // and beyond it. `paths` are the d attributes, drawn in order.
87
+ const Z_ACTION_ICONS: Record<ZAction, string[]> = {
88
+ "bring-forward": [
89
+ "M3 11 L8 8.5 L13 11 L8 13.5 Z", // layer diamond (bottom)
90
+ "M8 8.5 L8 2", // arrow shaft up
91
+ "M5.5 4.5 L8 2 L10.5 4.5", // arrow head
92
+ ],
93
+ "send-backward": [
94
+ "M3 5 L8 2.5 L13 5 L8 7.5 Z", // layer diamond (top)
95
+ "M8 7.5 L8 14", // arrow shaft down
96
+ "M5.5 11.5 L8 14 L10.5 11.5", // arrow head
97
+ ],
98
+ "bring-to-front": [
99
+ "M3 9.5 L8 7 L13 9.5 L8 12 Z", // upper layer of the stack
100
+ "M3 12.5 L8 10 L13 12.5 L8 15 Z", // lower layer of the stack
101
+ "M8 12.5 L8 2", // arrow piercing up through/above the stack
102
+ "M5.5 4.5 L8 2 L10.5 4.5", // arrow head
103
+ ],
104
+ "send-to-back": [
105
+ "M3 4 L8 1.5 L13 4 L8 6.5 Z", // upper layer of the stack
106
+ "M3 7 L8 4.5 L13 7 L8 9.5 Z", // lower layer of the stack
107
+ "M8 3.5 L8 14", // arrow piercing down through/below the stack
108
+ "M5.5 11.5 L8 14 L10.5 11.5", // arrow head
109
+ ],
110
+ };
111
+
112
+ function ZActionIcon({ action }: { action: ZAction }) {
113
+ return (
114
+ <svg
115
+ width="16"
116
+ height="16"
117
+ viewBox="0 0 16 16"
118
+ fill="none"
119
+ stroke="currentColor"
120
+ strokeWidth="1.2"
121
+ strokeLinecap="round"
122
+ strokeLinejoin="round"
123
+ className="mr-2 shrink-0"
124
+ aria-hidden="true"
125
+ >
126
+ {Z_ACTION_ICONS[action].map((d) => (
127
+ <path key={d} d={d} />
128
+ ))}
129
+ </svg>
130
+ );
131
+ }
132
+
73
133
  const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [
134
+ { action: "bring-to-front", label: "Bring to front" },
74
135
  { action: "bring-forward", label: "Bring forward" },
75
136
  { action: "send-backward", label: "Send backward" },
76
- { action: "bring-to-front", label: "Bring to front" },
77
137
  { action: "send-to-back", label: "Send to back" },
78
138
  ];
79
139
 
@@ -113,14 +173,16 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
113
173
  const patches = resolveZOrderChange(el, action);
114
174
  if (patches === null) return;
115
175
  // Resolve the crossed neighbor BEFORE the commit path mutates live styles —
116
- // both resolvers must read the same pre-change render order.
117
- const crossed = onZOrderCrossed ? resolveCrossedNeighbor(el, action) : null;
176
+ // both resolvers must read the same pre-change render order. Always resolved
177
+ // (not only for the flash): onApplyZIndex forwards it so the host can mirror
178
+ // the z step into a timeline lane move.
179
+ const crossed = resolveCrossedNeighbor(el, action);
118
180
  // Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
119
181
  // live z-index (and injects position:relative for static elements) in the
120
182
  // same synchronous flow, so feedback is still instant — and it must read
121
183
  // the PRE-change styles itself, both to capture true rollback values and
122
184
  // to detect a static position that needs persisting.
123
- onApplyZIndex(patches, action);
185
+ onApplyZIndex(patches, action, crossed);
124
186
  if (crossed && onZOrderCrossed) onZOrderCrossed(crossed, action);
125
187
  onClose();
126
188
  }
@@ -185,7 +247,10 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
185
247
  if (enabled) handleZAction(action);
186
248
  }}
187
249
  >
188
- {label}
250
+ {/* Icon inherits the item's text color via currentColor, so the
251
+ disabled muted tone applies to both icon and label. */}
252
+ <ZActionIcon action={action} />
253
+ <span>{label}</span>
189
254
  </button>
190
255
  );
191
256
  })}
@@ -103,6 +103,9 @@ interface DomEditOverlayProps {
103
103
  selection: DomEditSelection,
104
104
  patches: ZOrderPatch[],
105
105
  action: ZOrderAction,
106
+ /** Sibling a forward/backward step moved past (pre-mutation render order);
107
+ * null for front/back. Feeds the timeline z-mirror's crossedKey. */
108
+ crossed: HTMLElement | null,
106
109
  ) => void;
107
110
  }
108
111
 
@@ -529,8 +532,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
529
532
  }
530
533
  onApplyZIndex={
531
534
  onApplyZIndex
532
- ? (patches, action) => {
533
- onApplyZIndex(contextMenu.sel, patches, action);
535
+ ? (patches, action, crossed) => {
536
+ onApplyZIndex(contextMenu.sel, patches, action, crossed);
534
537
  }
535
538
  : undefined
536
539
  }
@@ -14,8 +14,14 @@ import {
14
14
  } from "../../utils/studioHelpers";
15
15
  import { Layers } from "../../icons/SystemIcons";
16
16
  import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
17
- import { computeReorderZValues, getElementZIndex } from "../../player/lib/layerOrdering";
17
+ import { getVisibleLayers, sortLayersByZIndex } from "./layersPanelSort";
18
18
  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 { runZLaneGesture } from "../nle/zLaneGesture";
24
+ import { useLayerRevealOverride } from "./useLayerRevealOverride";
19
25
 
20
26
  const TAG_ICONS: Record<string, string> = {
21
27
  video: "Vi",
@@ -85,8 +91,13 @@ interface CollapsedState {
85
91
  // fallow-ignore-next-line complexity
86
92
  export const LayersPanel = memo(function LayersPanel() {
87
93
  const { previewIframeRef, activeCompPath, showToast } = useStudioShellContext();
88
- const { refreshKey, compositionLoading, timelineElements } = useStudioPlaybackContext();
94
+ const { refreshKey, compositionLoading, timelineElements, isPlaying } =
95
+ useStudioPlaybackContext();
89
96
  const currentTime = usePlayerStore((s) => s.currentTime);
97
+ // Flashless z commits (canvas menu, timeline lane-drag z-sync) mutate iframe
98
+ // z-indexes with no reload and no refreshKey bump — while paused, nothing
99
+ // else re-collects, so the panel's z-sorted order would go stale.
100
+ const zEditVersion = usePlayerStore((s) => s.zEditVersion);
90
101
  const {
91
102
  domEditSelection,
92
103
  activeGroupElement,
@@ -100,6 +111,11 @@ export const LayersPanel = memo(function LayersPanel() {
100
111
  const [collapsed, setCollapsed] = useState<CollapsedState>({});
101
112
  const prevDocVersionRef = useRef(0);
102
113
  const scrollContainerRef = useRef<HTMLDivElement>(null);
114
+ const mirrorLayerReorderToTimeline = useLayerReorderTimelineMirror();
115
+ const { scheduleReveal } = useLayerRevealOverride({
116
+ isPlaying,
117
+ selectedElement: domEditSelection?.element ?? null,
118
+ });
103
119
 
104
120
  const isMasterView = !activeCompPath || activeCompPath === "index.html";
105
121
 
@@ -131,7 +147,7 @@ export const LayersPanel = memo(function LayersPanel() {
131
147
 
132
148
  useEffect(() => {
133
149
  collectLayers();
134
- }, [collectLayers, refreshKey]);
150
+ }, [collectLayers, refreshKey, zEditVersion]);
135
151
 
136
152
  useEffect(() => {
137
153
  const iframe = previewIframeRef.current;
@@ -230,8 +246,13 @@ export const LayersPanel = memo(function LayersPanel() {
230
246
  if (!selection) return;
231
247
  applyDomSelection(selection);
232
248
  await seekToLayer(layer);
249
+ // Force-reveal AFTER the seek's runtime visibility sync has had a beat:
250
+ // a clip made active by the seek shows naturally and needs no override,
251
+ // so the reveal only touches nodes that REMAIN hidden (animation-parked
252
+ // opacity, non-clip display/visibility hides, hidden ancestors).
253
+ scheduleReveal(selection.element, 150);
233
254
  },
234
- [resolveSelection, applyDomSelection, seekToLayer],
255
+ [resolveSelection, applyDomSelection, seekToLayer, scheduleReveal],
235
256
  );
236
257
 
237
258
  // Double-click a group row → drill into it; any other row → select it.
@@ -264,6 +285,7 @@ export const LayersPanel = memo(function LayersPanel() {
264
285
  setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
265
286
  }, []);
266
287
 
288
+ // fallow-ignore-next-line complexity
267
289
  const handleReorder = useCallback(
268
290
  (event: LayerReorderEvent) => {
269
291
  const { siblingLayers, fromIndex, toIndex } = event;
@@ -271,29 +293,88 @@ export const LayersPanel = memo(function LayersPanel() {
271
293
  const [moved] = reordered.splice(fromIndex, 1);
272
294
  reordered.splice(toIndex, 0, moved);
273
295
 
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");
296
+ // Panel order is top-first (sortLayersByZIndex: z desc, later-DOM-first),
297
+ // so the desired RENDER order (bottom→top) is the reverse. The minimal
298
+ // resolver (shared with the canvas z-menu) then writes one between-z
299
+ // value when a strict gap exists, band-safe renumber otherwise — instead
300
+ // of the old computeReorderZValues stamp of every sibling.
301
+ const desiredBottomToTop = [...reordered].reverse();
302
+ const patches = resolveZOrderReposition(
303
+ moved.element,
304
+ desiredBottomToTop.map((l) => l.element),
305
+ );
306
+ if (!patches || patches.length === 0) return; // paint order unchanged
307
+
308
+ const layerByElement = new Map(siblingLayers.map((l) => [l.element, l]));
309
+ const entries: Array<{
310
+ element: HTMLElement;
311
+ zIndex: number;
312
+ id?: string;
313
+ selector?: string;
314
+ selectorIndex?: number;
315
+ sourceFile: string;
316
+ key?: string;
317
+ }> = [];
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
+ // One serialized z→lane transaction (shared queue with the canvas
368
+ // z-order menu): the mirror runs only after a DURABLE z persist, and
369
+ // rapid successive gestures cannot interleave phases — see
370
+ // runZLaneGesture.
371
+ runZLaneGesture({
372
+ commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, "layer-drag"),
373
+ mirror: () =>
374
+ mirrorLayerReorderToTimeline({ selectionKey: movedKey, desiredOrderKeys, coalesceKey }),
375
+ }).catch(() => undefined);
295
376
  },
296
- [handleDomZIndexReorderCommit],
377
+ [handleDomZIndexReorderCommit, mirrorLayerReorderToTimeline],
297
378
  );
298
379
 
299
380
  const selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null;
@@ -436,76 +517,6 @@ export const LayersPanel = memo(function LayersPanel() {
436
517
  );
437
518
  });
438
519
 
439
- // ── Pure helpers ──────────────────────────────────────────────────────
440
-
441
- // fallow-ignore-next-line complexity
442
- export function sortLayersByZIndex(layers: DomEditLayerItem[]): DomEditLayerItem[] {
443
- if (layers.length <= 1) return layers;
444
-
445
- const minDepth = layers[0].depth;
446
- for (let i = 1; i < layers.length; i++) {
447
- if (layers[i].depth < minDepth) return layers;
448
- }
449
-
450
- const chunks: Array<{ root: DomEditLayerItem; children: DomEditLayerItem[]; domIndex: number }> =
451
- [];
452
-
453
- for (let i = 0; i < layers.length; i++) {
454
- if (layers[i].depth === minDepth) {
455
- const children: DomEditLayerItem[] = [];
456
- let j = i + 1;
457
- while (j < layers.length && layers[j].depth > minDepth) {
458
- children.push(layers[j]);
459
- j++;
460
- }
461
- chunks.push({ root: layers[i], children, domIndex: chunks.length });
462
- }
463
- }
464
-
465
- if (chunks.length <= 1) {
466
- if (chunks.length === 1 && chunks[0].children.length > 0) {
467
- const sorted = sortLayersByZIndex(chunks[0].children);
468
- return [chunks[0].root, ...sorted];
469
- }
470
- return layers;
471
- }
472
-
473
- chunks.sort((a, b) => {
474
- const zA = getElementZIndex(a.root.element);
475
- const zB = getElementZIndex(b.root.element);
476
- if (zA !== zB) return zB - zA;
477
- return b.domIndex - a.domIndex;
478
- });
479
-
480
- const result: DomEditLayerItem[] = [];
481
- for (const chunk of chunks) {
482
- result.push(chunk.root);
483
- if (chunk.children.length > 0) {
484
- result.push(...sortLayersByZIndex(chunk.children));
485
- }
486
- }
487
- return result;
488
- }
489
-
490
- function getVisibleLayers(
491
- layers: DomEditLayerItem[],
492
- collapsed: CollapsedState,
493
- ): DomEditLayerItem[] {
494
- if (Object.keys(collapsed).length === 0) return layers;
495
-
496
- const result: DomEditLayerItem[] = [];
497
- let skipDepth = -1;
498
-
499
- for (const layer of layers) {
500
- if (skipDepth >= 0 && layer.depth > skipDepth) continue;
501
- skipDepth = -1;
502
-
503
- result.push(layer);
504
-
505
- if (collapsed[layer.key] && layer.childCount > 0) {
506
- skipDepth = layer.depth;
507
- }
508
- }
509
-
510
- return result;
511
- }
520
+ // The sort helper lives in layersPanelSort.ts (600-line studio cap);
521
+ // re-exported so existing imports from "./LayersPanel" still resolve.
522
+ export { sortLayersByZIndex } from "./layersPanelSort";
@@ -6,6 +6,7 @@ import {
6
6
  parseZIndex,
7
7
  resolveCrossedNeighbor,
8
8
  resolveZOrderChange,
9
+ resolveZOrderReposition,
9
10
  type ZOrderAction,
10
11
  type ZOrderPatch,
11
12
  } from "./canvasContextMenuZOrder";
@@ -617,3 +618,59 @@ describe("isZOrderActionEnabled", () => {
617
618
  }
618
619
  });
619
620
  });
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
+ });
@@ -42,6 +42,7 @@
42
42
  */
43
43
 
44
44
  import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
45
+ import { readLayerRevealPriorZ } from "../../player/lib/timelineElementHelpers";
45
46
 
46
47
  export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
47
48
 
@@ -107,8 +108,11 @@ export function parseZIndex(value: string | null | undefined): number {
107
108
  return Number.isFinite(n) ? n : 0;
108
109
  }
109
110
 
110
- /** Read the effective z-index for an element (inline style preferred). */
111
+ /** Read the effective z-index for an element (inline style preferred).
112
+ * Reveal-lift transparent: an active Layers-panel lift reports the TRUE z. */
111
113
  export function readEffectiveZIndex(el: HTMLElement): number {
114
+ const prior = readLayerRevealPriorZ(el);
115
+ if (prior != null) return prior;
112
116
  const inline = el.style.zIndex;
113
117
  if (inline && inline !== "auto") return parseZIndex(inline);
114
118
  try {
@@ -439,6 +443,41 @@ export function resolveZOrderChange(
439
443
  return realizeOrder(order, desired, target, entries);
440
444
  }
441
445
 
446
+ /**
447
+ * Realize an ARBITRARY repositioning of `target` within a scoped sibling set —
448
+ * the Layers-panel drag, which can jump several siblings in one drop, unlike
449
+ * the menu's four fixed actions. `desiredOrderBottomToTop` is the scoped set
450
+ * (target included at its new slot) in the intended render order. Reuses the
451
+ * menu's minimal-write realization (realizeOrder): one between-z write when a
452
+ * strict gap exists, band-safe scoped renumber otherwise — replacing the old
453
+ * LayersPanel computeReorderZValues path that stamped EVERY sibling.
454
+ *
455
+ * Null when nothing changes, the set is too small, or an element in the
456
+ * desired order is not actually a painting sibling of `target`.
457
+ */
458
+ export function resolveZOrderReposition(
459
+ target: HTMLElement,
460
+ desiredOrderBottomToTop: readonly HTMLElement[],
461
+ ): ZOrderPatch[] | null {
462
+ const { entries } = getFamily(target);
463
+ if (entries.length < 2) return null;
464
+ const byElement = new Map(entries.map((entry) => [entry.element, entry]));
465
+ const desired: RenderEntry[] = [];
466
+ for (const el of desiredOrderBottomToTop) {
467
+ const entry = byElement.get(el);
468
+ if (!entry) return null;
469
+ desired.push(entry);
470
+ }
471
+ if (desired.length < 2 || !desired.some((entry) => entry.element === target)) return null;
472
+ const currentOrder = toRenderOrder(desired);
473
+ // A drop back into the same slot is a no-op. The menu actions guard this via
474
+ // their position checks before realizeOrder; an arbitrary reposition must
475
+ // compare the orders itself — realizeOrder would otherwise "normalize" an
476
+ // end-of-set target to a fresh z value it doesn't need.
477
+ if (currentOrder.every((entry, i) => entry.element === desired[i].element)) return null;
478
+ return realizeOrder(currentOrder, desired, target, entries);
479
+ }
480
+
442
481
  /**
443
482
  * The sibling a forward/backward step crosses: the visible overlapping
444
483
  * neighbor directly above (bring-forward) or below (send-backward) the target