@hyperframes/studio 0.7.56 → 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-CtTDO63S.js +459 -0
- package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
- package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-C4csZims.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 +41 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7436 -5423
- 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 +166 -2
- package/src/components/editor/CanvasContextMenu.tsx +100 -20
- package/src/components/editor/DomEditOverlay.tsx +34 -62
- package/src/components/editor/LayersPanel.tsx +111 -91
- package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
- 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/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +61 -16
- 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/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +29 -2
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +183 -1
- package/src/hooks/timelineEditingHelpers.ts +63 -259
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +7 -4
- package/src/hooks/timelineTimingSync.test.ts +820 -0
- package/src/hooks/timelineTimingSync.ts +597 -0
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +392 -23
- package/src/hooks/useDomEditCommits.ts +180 -53
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
- package/src/hooks/useElementLifecycleOps.ts +175 -69
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +591 -277
- package/src/hooks/useTimelineEditing.ts +202 -282
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +187 -134
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +105 -18
- package/src/player/components/Timeline.tsx +69 -98
- package/src/player/components/TimelineCanvas.tsx +47 -7
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +70 -47
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +370 -11
- package/src/player/components/timelineClipDragCommit.ts +267 -68
- 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 +119 -30
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +102 -1
- package/src/player/components/timelineStackingSync.ts +45 -5
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- 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/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
- package/src/player/components/useTimelineStackingSync.ts +17 -4
- 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 +19 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/lib/timelineElementHelpers.ts +54 -11
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +49 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -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/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.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/
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
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)}
|
|
@@ -3,7 +3,12 @@ import React, { act } from "react";
|
|
|
3
3
|
import { createRoot, type Root } from "react-dom/client";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
|
|
6
|
+
import { resolveZIndexEntries } from "../nle/PreviewOverlays";
|
|
7
|
+
import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
|
|
8
|
+
import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils";
|
|
9
|
+
import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
|
|
6
10
|
import { CanvasContextMenu } from "./CanvasContextMenu";
|
|
11
|
+
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
|
7
12
|
import type { DomEditSelection } from "./domEditing";
|
|
8
13
|
|
|
9
14
|
installReactActEnvironment();
|
|
@@ -24,7 +29,8 @@ afterEach(() => {
|
|
|
24
29
|
|
|
25
30
|
function renderMenu(props: {
|
|
26
31
|
selection: DomEditSelection;
|
|
27
|
-
onApplyZIndex?: () => void;
|
|
32
|
+
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
|
|
33
|
+
onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
|
|
28
34
|
onDelete?: (selection: DomEditSelection) => void;
|
|
29
35
|
}) {
|
|
30
36
|
root = createRoot(host);
|
|
@@ -36,6 +42,7 @@ function renderMenu(props: {
|
|
|
36
42
|
selection: props.selection,
|
|
37
43
|
onClose: () => {},
|
|
38
44
|
onApplyZIndex: props.onApplyZIndex,
|
|
45
|
+
onZOrderCrossed: props.onZOrderCrossed,
|
|
39
46
|
onDelete: props.onDelete,
|
|
40
47
|
}),
|
|
41
48
|
);
|
|
@@ -96,9 +103,26 @@ describe("CanvasContextMenu — handler gating", () => {
|
|
|
96
103
|
|
|
97
104
|
renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() });
|
|
98
105
|
|
|
99
|
-
|
|
106
|
+
const buttons = zOrderButtons();
|
|
107
|
+
expect(buttons).toHaveLength(4);
|
|
100
108
|
expect(hasDeleteItem()).toBe(false);
|
|
101
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
|
+
}
|
|
102
126
|
});
|
|
103
127
|
|
|
104
128
|
it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => {
|
|
@@ -113,3 +137,143 @@ describe("CanvasContextMenu — handler gating", () => {
|
|
|
113
137
|
expect(document.body.querySelector(".border-t")).toBeNull();
|
|
114
138
|
});
|
|
115
139
|
});
|
|
140
|
+
|
|
141
|
+
// ── Menu z-action → commit path (wired the way PreviewOverlays wires the app) ──
|
|
142
|
+
|
|
143
|
+
function pressMenuItem(label: string) {
|
|
144
|
+
const button = zOrderButtons().find((b) => b.textContent === label);
|
|
145
|
+
expect(button).toBeDefined();
|
|
146
|
+
act(() => {
|
|
147
|
+
button!.dispatchEvent(
|
|
148
|
+
new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Target (static, earlier in DOM) below an equal-z sibling — z action must renumber. */
|
|
154
|
+
function makeStaticFamily() {
|
|
155
|
+
const parent = document.createElement("div");
|
|
156
|
+
const target = document.createElement("div");
|
|
157
|
+
target.id = "target";
|
|
158
|
+
// In happy-dom an unset computed position is "" (not "static"), which would
|
|
159
|
+
// skip the commit hook's static-position injection; declare it explicitly so
|
|
160
|
+
// the test exercises the browser default.
|
|
161
|
+
target.style.position = "static";
|
|
162
|
+
const other = document.createElement("div");
|
|
163
|
+
other.id = "other";
|
|
164
|
+
parent.append(target, other);
|
|
165
|
+
document.body.append(parent);
|
|
166
|
+
return { parent, target, other };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface CapturedBatchCall {
|
|
170
|
+
batches: DomEditPatchBatch[];
|
|
171
|
+
options: { label: string; coalesceKey: string };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Mount the REAL commit hook (persist layer mocked at commitDomEditPatchBatches). */
|
|
175
|
+
function renderCommitHook(captured: CapturedBatchCall[]) {
|
|
176
|
+
type Commit = ReturnType<typeof useElementLifecycleOps>["handleDomZIndexReorderCommit"];
|
|
177
|
+
let commit: Commit | undefined;
|
|
178
|
+
function Harness() {
|
|
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
|
+
));
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const hookHost = document.createElement("div");
|
|
190
|
+
document.body.append(hookHost);
|
|
191
|
+
const hookRoot = createRoot(hookHost);
|
|
192
|
+
act(() => hookRoot.render(<Harness />));
|
|
193
|
+
return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
describe("CanvasContextMenu — z-action commit path", () => {
|
|
197
|
+
it("never mutates live styles itself and persists the position patch for a static element", async () => {
|
|
198
|
+
const { target } = makeStaticFamily();
|
|
199
|
+
const selection = makeSelection("Target", target);
|
|
200
|
+
const captured: CapturedBatchCall[] = [];
|
|
201
|
+
const { commit, cleanup } = renderCommitHook(captured);
|
|
202
|
+
|
|
203
|
+
// Wire onApplyZIndex the way the app does (PreviewOverlays → the commit
|
|
204
|
+
// hook), asserting the menu has NOT touched the DOM when it fires — the
|
|
205
|
+
// hook must capture true pre-change styles for its rollback.
|
|
206
|
+
const stylesAtApply: Array<{ zIndex: string; position: string }> = [];
|
|
207
|
+
renderMenu({
|
|
208
|
+
selection,
|
|
209
|
+
onApplyZIndex: (patches, action) => {
|
|
210
|
+
stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position });
|
|
211
|
+
const { entries } = resolveZIndexEntries(selection, patches);
|
|
212
|
+
void commit(entries, undefined, action);
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await act(async () => pressMenuItem("Bring forward"));
|
|
217
|
+
|
|
218
|
+
// The menu left the element pristine; only the commit hook wrote styles.
|
|
219
|
+
expect(stylesAtApply).toEqual([{ zIndex: "", position: "static" }]);
|
|
220
|
+
expect(target.style.zIndex).toBe("1");
|
|
221
|
+
expect(target.style.position).toBe("relative");
|
|
222
|
+
|
|
223
|
+
// The persisted payload carries BOTH the z-index and the injected position,
|
|
224
|
+
// so the reorder survives the post-commit reloadPreview().
|
|
225
|
+
expect(captured).toHaveLength(1);
|
|
226
|
+
const targetPatch = captured[0]?.batches
|
|
227
|
+
.flatMap((batch) => batch.patches)
|
|
228
|
+
.find((patch) => patch.target.id === "target");
|
|
229
|
+
expect(targetPatch?.operations).toEqual(
|
|
230
|
+
expect.arrayContaining([
|
|
231
|
+
{ type: "inline-style", property: "z-index", value: "1" },
|
|
232
|
+
{ type: "inline-style", property: "position", value: "relative" },
|
|
233
|
+
]),
|
|
234
|
+
);
|
|
235
|
+
// F7: the action kind is part of the default undo coalesce key, so two
|
|
236
|
+
// different menu actions never merge into one undo step.
|
|
237
|
+
expect(captured[0]?.options.coalesceKey).toContain("bring-forward");
|
|
238
|
+
|
|
239
|
+
cleanup();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("reports the crossed sibling to onZOrderCrossed for a forward step (resolved pre-mutation)", async () => {
|
|
243
|
+
// target (earlier in DOM) and other are tied — bring-forward steps over
|
|
244
|
+
// `other`, and the flash callback must receive exactly that element, after
|
|
245
|
+
// onApplyZIndex ran (call order lets the host measure post-commit rects).
|
|
246
|
+
const { target, other } = makeStaticFamily();
|
|
247
|
+
const selection = makeSelection("Target", target);
|
|
248
|
+
const calls: Array<{ kind: string; crossed?: HTMLElement }> = [];
|
|
249
|
+
|
|
250
|
+
renderMenu({
|
|
251
|
+
selection,
|
|
252
|
+
onApplyZIndex: () => calls.push({ kind: "apply" }),
|
|
253
|
+
onZOrderCrossed: (crossed, action) => {
|
|
254
|
+
expect(action).toBe("bring-forward");
|
|
255
|
+
calls.push({ kind: "crossed", crossed });
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await act(async () => pressMenuItem("Bring forward"));
|
|
260
|
+
|
|
261
|
+
expect(calls.map((c) => c.kind)).toEqual(["apply", "crossed"]);
|
|
262
|
+
expect(calls[1]?.crossed).toBe(other);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("does not call onZOrderCrossed for bring-to-front", async () => {
|
|
266
|
+
const { target } = makeStaticFamily();
|
|
267
|
+
const onZOrderCrossed = vi.fn();
|
|
268
|
+
|
|
269
|
+
renderMenu({
|
|
270
|
+
selection: makeSelection("Target", target),
|
|
271
|
+
onApplyZIndex: vi.fn(),
|
|
272
|
+
onZOrderCrossed,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await act(async () => pressMenuItem("Bring to front"));
|
|
276
|
+
|
|
277
|
+
expect(onZOrderCrossed).not.toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
* useContextMenuDismiss.
|
|
8
8
|
*
|
|
9
9
|
* ── Wiring (z-order persistence) ─────────────────────────────────────────────
|
|
10
|
-
* Z-index changes are
|
|
10
|
+
* Z-index changes are resolved against the live iframe DOM via
|
|
11
11
|
* `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
|
|
12
12
|
* moving a target past an equal-z sibling can require renumbering the affected
|
|
13
|
-
* set). The patches are surfaced through the `onApplyZIndex` prop
|
|
13
|
+
* set). The patches are surfaced through the `onApplyZIndex` prop; the menu
|
|
14
|
+
* itself never mutates element styles — handleDomZIndexReorderCommit applies
|
|
15
|
+
* the live z-index (and injects position when needed) in the same synchronous
|
|
16
|
+
* flow, and captures the TRUE prior styles for its failure rollback.
|
|
14
17
|
*
|
|
15
18
|
* The prop MUST be wired at the call site to route through the full persist
|
|
16
19
|
* path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
|
|
@@ -26,7 +29,9 @@ import type { DomEditSelection } from "./domEditing";
|
|
|
26
29
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
27
30
|
import {
|
|
28
31
|
isZOrderActionEnabled,
|
|
32
|
+
resolveCrossedNeighbor,
|
|
29
33
|
resolveZOrderChange,
|
|
34
|
+
type ZOrderAction,
|
|
30
35
|
type ZOrderPatch,
|
|
31
36
|
} from "./canvasContextMenuZOrder";
|
|
32
37
|
|
|
@@ -38,12 +43,31 @@ interface CanvasContextMenuProps {
|
|
|
38
43
|
selection: DomEditSelection;
|
|
39
44
|
onClose: () => void;
|
|
40
45
|
/**
|
|
41
|
-
* Called with the resolved z-order patch list
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
46
|
+
* Called with the resolved z-order patch list and the menu action that
|
|
47
|
+
* produced it (the action feeds the undo coalesce key, so two DIFFERENT
|
|
48
|
+
* actions never merge into one undo step). Each patch is an
|
|
49
|
+
* { element, zIndex } pair (the target and, when a renumber is needed,
|
|
50
|
+
* affected siblings). The menu does NOT touch the live DOM — wire to
|
|
51
|
+
* handleDomZIndexReorderCommit, which applies the live styles itself
|
|
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).
|
|
45
58
|
*/
|
|
46
|
-
onApplyZIndex?: (
|
|
59
|
+
onApplyZIndex?: (
|
|
60
|
+
patches: ZOrderPatch[],
|
|
61
|
+
action: ZOrderAction,
|
|
62
|
+
crossed: HTMLElement | null,
|
|
63
|
+
) => void;
|
|
64
|
+
/**
|
|
65
|
+
* Called after a successful bring-forward / send-backward with the sibling
|
|
66
|
+
* the target stepped over (resolved from the SAME pre-mutation state as the
|
|
67
|
+
* patches), so the host can flash a highlight on it in the studio overlay.
|
|
68
|
+
* Never called for front/back or no-op actions.
|
|
69
|
+
*/
|
|
70
|
+
onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
|
|
47
71
|
/**
|
|
48
72
|
* Delete the selected element. Wire to handleDomEditElementDelete from
|
|
49
73
|
* useDomEditActionsContext — same path as the Delete/Backspace hotkey.
|
|
@@ -55,10 +79,61 @@ interface CanvasContextMenuProps {
|
|
|
55
79
|
|
|
56
80
|
type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
|
|
57
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
|
+
|
|
58
133
|
const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [
|
|
134
|
+
{ action: "bring-to-front", label: "Bring to front" },
|
|
59
135
|
{ action: "bring-forward", label: "Bring forward" },
|
|
60
136
|
{ action: "send-backward", label: "Send backward" },
|
|
61
|
-
{ action: "bring-to-front", label: "Bring to front" },
|
|
62
137
|
{ action: "send-to-back", label: "Send to back" },
|
|
63
138
|
];
|
|
64
139
|
|
|
@@ -68,6 +143,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
|
68
143
|
selection,
|
|
69
144
|
onClose,
|
|
70
145
|
onApplyZIndex,
|
|
146
|
+
onZOrderCrossed,
|
|
71
147
|
onDelete,
|
|
72
148
|
}: CanvasContextMenuProps) {
|
|
73
149
|
const menuRef = useContextMenuDismiss(onClose);
|
|
@@ -93,20 +169,21 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
|
93
169
|
const el = selection.element;
|
|
94
170
|
|
|
95
171
|
function handleZAction(action: ZAction) {
|
|
96
|
-
// No persist handler → do NOT touch the live iframe DOM. An optimistic
|
|
97
|
-
// write with nothing to persist just reverts on the next reload.
|
|
98
172
|
if (!onApplyZIndex) return;
|
|
99
173
|
const patches = resolveZOrderChange(el, action);
|
|
100
174
|
if (patches === null) return;
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
175
|
+
// Resolve the crossed neighbor BEFORE the commit path mutates live styles —
|
|
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);
|
|
180
|
+
// Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
|
|
181
|
+
// live z-index (and injects position:relative for static elements) in the
|
|
182
|
+
// same synchronous flow, so feedback is still instant — and it must read
|
|
183
|
+
// the PRE-change styles itself, both to capture true rollback values and
|
|
184
|
+
// to detect a static position that needs persisting.
|
|
185
|
+
onApplyZIndex(patches, action, crossed);
|
|
186
|
+
if (crossed && onZOrderCrossed) onZOrderCrossed(crossed, action);
|
|
110
187
|
onClose();
|
|
111
188
|
}
|
|
112
189
|
|
|
@@ -170,7 +247,10 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
|
170
247
|
if (enabled) handleZAction(action);
|
|
171
248
|
}}
|
|
172
249
|
>
|
|
173
|
-
{
|
|
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>
|
|
174
254
|
</button>
|
|
175
255
|
);
|
|
176
256
|
})}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { memo,
|
|
1
|
+
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
|
2
2
|
import { type DomEditSelection } from "./domEditing";
|
|
3
3
|
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
|
|
4
4
|
import { useMarqueeGestures } from "./marqueeCommit";
|
|
5
5
|
import { MarqueeOverlay } from "./MarqueeOverlay";
|
|
6
6
|
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
|
|
7
|
+
import { useZOrderCrossedFlash, ZOrderCrossedFlash } from "./useZOrderCrossedFlash";
|
|
8
|
+
import { useCanvasContextMenuState } from "./useCanvasContextMenuState";
|
|
7
9
|
import {
|
|
8
10
|
type BlockedMoveState,
|
|
9
11
|
type DomEditGroupPathOffsetCommit,
|
|
@@ -27,7 +29,7 @@ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
|
|
27
29
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
28
30
|
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
|
|
29
31
|
import { CanvasContextMenu } from "./CanvasContextMenu";
|
|
30
|
-
import type { ZOrderPatch } from "./canvasContextMenuZOrder";
|
|
32
|
+
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
|
31
33
|
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
|
|
32
34
|
|
|
33
35
|
// Re-exports for external consumers — preserving existing import paths.
|
|
@@ -91,12 +93,20 @@ interface DomEditOverlayProps {
|
|
|
91
93
|
*/
|
|
92
94
|
onDeleteSelection?: (selection: DomEditSelection) => void;
|
|
93
95
|
/**
|
|
94
|
-
* Called with the resolved z-order patch list
|
|
95
|
-
* The patch list is tie-aware and
|
|
96
|
-
* canvasContextMenuZOrder)
|
|
96
|
+
* Called with the resolved z-order patch list and the menu action that
|
|
97
|
+
* produced it (feeds the undo coalesce key). The patch list is tie-aware and
|
|
98
|
+
* may include sibling elements (see canvasContextMenuZOrder); the live DOM is
|
|
99
|
+
* NOT yet mutated. Wire to handleDomZIndexReorderCommit from
|
|
97
100
|
* useDomEditActionsContext. See CanvasContextMenu.tsx module comment.
|
|
98
101
|
*/
|
|
99
|
-
onApplyZIndex?: (
|
|
102
|
+
onApplyZIndex?: (
|
|
103
|
+
selection: DomEditSelection,
|
|
104
|
+
patches: ZOrderPatch[],
|
|
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,
|
|
109
|
+
) => void;
|
|
100
110
|
}
|
|
101
111
|
|
|
102
112
|
// fallow-ignore-next-line complexity
|
|
@@ -139,30 +149,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
139
149
|
const snapGuidesRef = useRef<SnapGuidesState | null>(null);
|
|
140
150
|
const rafPausedRef = useRef(false);
|
|
141
151
|
|
|
142
|
-
// Context menu state: position of the right-click that opened it.
|
|
143
|
-
// contextMenuSelection is the element the menu targets — captured at right-click
|
|
144
|
-
// time so the menu can open even before the React selection state settles.
|
|
145
|
-
const [contextMenu, setContextMenu] = useState<{
|
|
146
|
-
x: number;
|
|
147
|
-
y: number;
|
|
148
|
-
sel: DomEditSelection;
|
|
149
|
-
} | null>(null);
|
|
150
|
-
|
|
151
152
|
const selectionRef = useRef(selection);
|
|
152
153
|
selectionRef.current = selection;
|
|
153
154
|
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
// over a stale target after the underlying element is gone. A right-click that
|
|
158
|
-
// OPENS the menu also selects its target, so the common open path keeps the
|
|
159
|
-
// menu (same element) rather than immediately dismissing it.
|
|
160
|
-
useEffect(() => {
|
|
161
|
-
if (!contextMenu) return;
|
|
162
|
-
if (!selection || selection.element !== contextMenu.sel.element) {
|
|
163
|
-
setContextMenu(null);
|
|
164
|
-
}
|
|
165
|
-
}, [selection, contextMenu]);
|
|
155
|
+
// Brief highlight on the sibling a forward/backward z step crossed — drawn
|
|
156
|
+
// in this studio overlay, never in the iframe DOM (see useZOrderCrossedFlash).
|
|
157
|
+
const { zOrderFlashRect, handleZOrderCrossed } = useZOrderCrossedFlash({ overlayRef, iframeRef });
|
|
166
158
|
|
|
167
159
|
const activeCompositionPathRef = useRef(activeCompositionPath);
|
|
168
160
|
activeCompositionPathRef.current = activeCompositionPath;
|
|
@@ -428,37 +420,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
428
420
|
e.stopPropagation();
|
|
429
421
|
};
|
|
430
422
|
|
|
431
|
-
// Right-click
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
|
|
441
|
-
const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
|
|
442
|
-
if (!resolved) return; // Nothing under the cursor — skip menu.
|
|
443
|
-
onSelectionChangeRef.current(resolved, { revealPanel: true });
|
|
444
|
-
// Use `resolved` directly: React state (and therefore selectionRef) won't
|
|
445
|
-
// update synchronously after onSelectionChange — we'd be reading stale null.
|
|
446
|
-
activeSel = resolved;
|
|
447
|
-
} else {
|
|
448
|
-
// Check if the user right-clicked on an unselected element (hover target).
|
|
449
|
-
const hover = hoverSelectionRef.current;
|
|
450
|
-
if (hover && hover.element !== currentSel.element) {
|
|
451
|
-
onSelectionChangeRef.current(hover, { revealPanel: true });
|
|
452
|
-
activeSel = hover;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
if (!activeSel) return;
|
|
457
|
-
setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
|
|
458
|
-
},
|
|
459
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
460
|
-
[],
|
|
461
|
-
);
|
|
423
|
+
// Right-click state + handler: select the element under the pointer (if
|
|
424
|
+
// needed), then open the menu; closes when the selection moves off-target.
|
|
425
|
+
const { contextMenu, closeContextMenu, handleContextMenu } = useCanvasContextMenuState({
|
|
426
|
+
selection,
|
|
427
|
+
selectionRef,
|
|
428
|
+
hoverSelectionRef,
|
|
429
|
+
onCanvasPointerMoveRef,
|
|
430
|
+
onSelectionChangeRef,
|
|
431
|
+
});
|
|
462
432
|
|
|
463
433
|
return (
|
|
464
434
|
<div
|
|
@@ -551,24 +521,26 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
551
521
|
x={contextMenu.x}
|
|
552
522
|
y={contextMenu.y}
|
|
553
523
|
selection={contextMenu.sel}
|
|
554
|
-
onClose={
|
|
524
|
+
onClose={closeContextMenu}
|
|
555
525
|
onDelete={
|
|
556
526
|
onDeleteSelection
|
|
557
527
|
? (sel) => {
|
|
558
|
-
|
|
528
|
+
closeContextMenu();
|
|
559
529
|
onDeleteSelection(sel);
|
|
560
530
|
}
|
|
561
531
|
: undefined
|
|
562
532
|
}
|
|
563
533
|
onApplyZIndex={
|
|
564
534
|
onApplyZIndex
|
|
565
|
-
? (patches) => {
|
|
566
|
-
onApplyZIndex(contextMenu.sel, patches);
|
|
535
|
+
? (patches, action, crossed) => {
|
|
536
|
+
onApplyZIndex(contextMenu.sel, patches, action, crossed);
|
|
567
537
|
}
|
|
568
538
|
: undefined
|
|
569
539
|
}
|
|
540
|
+
onZOrderCrossed={handleZOrderCrossed}
|
|
570
541
|
/>
|
|
571
542
|
)}
|
|
543
|
+
<ZOrderCrossedFlash rect={zOrderFlashRect} />
|
|
572
544
|
<GridOverlay
|
|
573
545
|
visible={gridVisible}
|
|
574
546
|
spacing={gridSpacing}
|