@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.
- package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
- package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
- package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +15 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5954 -4474
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +143 -78
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +174 -81
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +302 -154
- package/src/hooks/useTimelineEditing.ts +97 -93
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +35 -3
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/dist/assets/index-_pqzyxB1.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /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.
|
|
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.
|
|
50
|
-
"@hyperframes/parsers": "0.7.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/studio-server": "0.7.
|
|
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.
|
|
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
|
-
|
|
194
|
-
|
|
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">
|
|
183
|
+
<span className="text-neutral-500 flex-shrink-0">Recommend HyperFrames?</span>
|
|
184
184
|
<div className="flex items-center gap-0.5">
|
|
185
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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?: (
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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 } =
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
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
|