@hyperframes/studio 0.8.81 → 0.8.83
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-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
- package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +834 -330
- package/dist/index.html +1 -1
- package/dist/index.js +26378 -28802
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +20 -31
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +37 -38
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +53 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/playerEntry.ts +1 -0
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useStudioShellContextOptional } from "../contexts/StudioContext";
|
|
2
2
|
import { RotateCcw, RotateCw } from "../icons/SystemIcons";
|
|
3
3
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
4
4
|
import { historyTooltipLabel } from "../utils/studioHelpers";
|
|
@@ -7,13 +7,14 @@ import { Tooltip } from "./ui";
|
|
|
7
7
|
|
|
8
8
|
interface HistoryButtonProps {
|
|
9
9
|
action: "undo" | "redo";
|
|
10
|
-
|
|
10
|
+
can: boolean;
|
|
11
11
|
label: string | undefined;
|
|
12
|
-
onClick: () => Promise<void> | void;
|
|
12
|
+
onClick: (() => Promise<void> | void) | undefined;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function HistoryButton({ action,
|
|
15
|
+
function HistoryButton({ action, can, label, onClick }: HistoryButtonProps) {
|
|
16
16
|
const Icon = action === "undo" ? RotateCcw : RotateCw;
|
|
17
|
+
const enabled = Boolean(onClick) && can;
|
|
17
18
|
return (
|
|
18
19
|
<Tooltip label={historyTooltipLabel(action, label)}>
|
|
19
20
|
<button
|
|
@@ -23,7 +24,7 @@ function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps)
|
|
|
23
24
|
className={enabled ? flatIdle : flatDisabled}
|
|
24
25
|
onClick={() => {
|
|
25
26
|
trackStudioEvent("toolbar_action", { action });
|
|
26
|
-
void onClick();
|
|
27
|
+
void onClick?.();
|
|
27
28
|
}}
|
|
28
29
|
>
|
|
29
30
|
<Icon size={16} />
|
|
@@ -32,22 +33,33 @@ function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps)
|
|
|
32
33
|
);
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
export interface TimelineHistoryButtonsProps {
|
|
37
|
+
canUndo?: boolean;
|
|
38
|
+
canRedo?: boolean;
|
|
39
|
+
undoLabel?: string;
|
|
40
|
+
redoLabel?: string;
|
|
41
|
+
onUndo?: () => Promise<void> | void;
|
|
42
|
+
onRedo?: () => Promise<void> | void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Undo and Redo: a host's props win, else the shell's edit history. */
|
|
46
|
+
export function TimelineHistoryButtons(props: TimelineHistoryButtonsProps) {
|
|
47
|
+
const shell = useStudioShellContextOptional();
|
|
48
|
+
const onUndo = props.onUndo ?? shell?.handleUndo;
|
|
49
|
+
const onRedo = props.onRedo ?? shell?.handleRedo;
|
|
38
50
|
return (
|
|
39
51
|
<>
|
|
40
52
|
<HistoryButton
|
|
41
53
|
action="undo"
|
|
42
|
-
|
|
43
|
-
label={editHistory.undoLabel}
|
|
44
|
-
onClick={
|
|
54
|
+
can={props.canUndo ?? shell?.editHistory.canUndo ?? false}
|
|
55
|
+
label={props.undoLabel ?? shell?.editHistory.undoLabel}
|
|
56
|
+
onClick={onUndo}
|
|
45
57
|
/>
|
|
46
58
|
<HistoryButton
|
|
47
59
|
action="redo"
|
|
48
|
-
|
|
49
|
-
label={editHistory.redoLabel}
|
|
50
|
-
onClick={
|
|
60
|
+
can={props.canRedo ?? shell?.editHistory.canRedo ?? false}
|
|
61
|
+
label={props.redoLabel ?? shell?.editHistory.redoLabel}
|
|
62
|
+
onClick={onRedo}
|
|
51
63
|
/>
|
|
52
64
|
</>
|
|
53
65
|
);
|
|
@@ -12,7 +12,7 @@ import { AudioMeterStrip } from "./nle/AudioMeterStrip";
|
|
|
12
12
|
import { TimelineToolbar } from "./TimelineToolbar";
|
|
13
13
|
|
|
14
14
|
vi.mock("../contexts/StudioContext", () => ({
|
|
15
|
-
|
|
15
|
+
useStudioShellContextOptional: () => ({
|
|
16
16
|
previewIframeRef: { current: null },
|
|
17
17
|
editHistory: { canUndo: false, canRedo: false },
|
|
18
18
|
handleUndo: vi.fn(),
|
|
@@ -214,3 +214,11 @@ describe("TimelineToolbar audio meters", () => {
|
|
|
214
214
|
}
|
|
215
215
|
});
|
|
216
216
|
});
|
|
217
|
+
|
|
218
|
+
describe("TimelineToolbar add beat", () => {
|
|
219
|
+
it("shows Add beat by default, as Studio does", () => {
|
|
220
|
+
const { host, root } = renderToolbar();
|
|
221
|
+
expect(host.querySelector('button[aria-label="Add beat at playhead"]')).not.toBeNull();
|
|
222
|
+
act(() => root.unmount());
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -19,7 +19,7 @@ import { Tooltip } from "./ui";
|
|
|
19
19
|
import { AudioMetersIcon } from "./icons/AudioMetersIcon";
|
|
20
20
|
import { RippleEditIcon } from "./icons/RippleEditIcon";
|
|
21
21
|
import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
|
|
22
|
-
import { TimelineHistoryButtons } from "./TimelineHistoryButtons";
|
|
22
|
+
import { TimelineHistoryButtons, type TimelineHistoryButtonsProps } from "./TimelineHistoryButtons";
|
|
23
23
|
import { Scissors } from "../icons/SystemIcons";
|
|
24
24
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
25
25
|
import type { DomEditSelection } from "./editor/domEditingTypes";
|
|
@@ -33,9 +33,11 @@ interface DomEditSessionSlice extends EnableKeyframesSession {
|
|
|
33
33
|
selectedGsapAnimations: GsapAnimation[];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
interface TimelineToolbarProps {
|
|
36
|
+
export interface TimelineToolbarProps {
|
|
37
37
|
domEditSession?: DomEditSessionSlice;
|
|
38
38
|
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
|
39
|
+
history?: TimelineHistoryButtonsProps;
|
|
40
|
+
showAddBeat?: boolean;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
interface KeyframeToggleState {
|
|
@@ -127,7 +129,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
// fallow-ignore-next-line complexity
|
|
130
|
-
export function TimelineToolbar({
|
|
132
|
+
export function TimelineToolbar({
|
|
133
|
+
domEditSession,
|
|
134
|
+
onSplitElement,
|
|
135
|
+
history,
|
|
136
|
+
showAddBeat = true,
|
|
137
|
+
}: TimelineToolbarProps) {
|
|
131
138
|
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
132
139
|
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
|
|
133
140
|
const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
|
|
@@ -195,7 +202,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
195
202
|
<div className="border-b border-neutral-800/60">
|
|
196
203
|
<div className="flex items-center justify-between px-2 py-0.5">
|
|
197
204
|
<div className="flex items-center gap-0.5">
|
|
198
|
-
<TimelineHistoryButtons />
|
|
205
|
+
<TimelineHistoryButtons {...history} />
|
|
199
206
|
<Tooltip label="Selection tool (V)">
|
|
200
207
|
<button
|
|
201
208
|
type="button"
|
|
@@ -416,7 +423,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
416
423
|
);
|
|
417
424
|
})()}
|
|
418
425
|
{(() => {
|
|
419
|
-
|
|
426
|
+
if (!showAddBeat) return null;
|
|
427
|
+
// Rendered whenever shown (CapCut-style): before beat analysis loads (or when
|
|
420
428
|
// the project has no analyzed music) the button fades to a disabled
|
|
421
429
|
// state instead of unmounting, so the toolbar layout never shifts.
|
|
422
430
|
const canAdd = beatAnalysisReady && canAddBeatAt(currentTime);
|
|
@@ -3,6 +3,7 @@ import type { BlockParam } from "@hyperframes/core/registry";
|
|
|
3
3
|
import { useFileManagerContextOptional } from "../../contexts/FileManagerContext";
|
|
4
4
|
import { useStudioPlaybackContext } from "../../contexts/StudioContext";
|
|
5
5
|
import { trackBlockParamCommit } from "../../telemetry/events";
|
|
6
|
+
import { serializeStudioFileMutation } from "../../utils/studioFileMutationCoordinator";
|
|
6
7
|
|
|
7
8
|
interface BlockParamsPanelProps {
|
|
8
9
|
blockName: string;
|
|
@@ -77,7 +78,11 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
|
|
77
78
|
trackBlockParamCommit({ tone: "error", blockName, key });
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
80
|
-
await fileManager.writeProjectFile(
|
|
81
|
+
await fileManager.writeProjectFile(
|
|
82
|
+
compositionPath,
|
|
83
|
+
content.replace(matcher, nextValue),
|
|
84
|
+
content,
|
|
85
|
+
);
|
|
81
86
|
appliedRef.current[key] = nextValue;
|
|
82
87
|
setCommitState({ tone: "saved" });
|
|
83
88
|
trackBlockParamCommit({ tone: "saved", blockName, key });
|
|
@@ -90,16 +95,15 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
|
|
90
95
|
[fileManager, compositionPath, setRefreshKey, blockName],
|
|
91
96
|
);
|
|
92
97
|
|
|
93
|
-
// Commits
|
|
94
|
-
// read-modify-write the same file and the second write would drop the first.
|
|
95
|
-
const commitChainRef = useRef<Promise<void>>(Promise.resolve());
|
|
98
|
+
// Commits join the file's queue: two read-modify-writes of one file must not interleave.
|
|
96
99
|
const commitParam = useCallback(
|
|
97
|
-
(key: string, nextValue: string) =>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
(key: string, nextValue: string) =>
|
|
101
|
+
fileManager
|
|
102
|
+
? serializeStudioFileMutation(fileManager.writeProjectFile, compositionPath, () =>
|
|
103
|
+
commitParamNow(key, nextValue),
|
|
104
|
+
)
|
|
105
|
+
: Promise.resolve(),
|
|
106
|
+
[commitParamNow, compositionPath, fileManager],
|
|
103
107
|
);
|
|
104
108
|
|
|
105
109
|
const handleChange = useCallback(
|
|
@@ -3,7 +3,7 @@ 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 "
|
|
6
|
+
import { resolveZIndexEntries } from "./ConnectedDomEditOverlay";
|
|
7
7
|
import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
|
|
8
8
|
import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils";
|
|
9
9
|
import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
|
|
@@ -138,7 +138,7 @@ describe("CanvasContextMenu — handler gating", () => {
|
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
// ── Menu z-action → commit path (wired the way
|
|
141
|
+
// ── Menu z-action → commit path (wired the way ConnectedDomEditOverlay wires the app) ──
|
|
142
142
|
|
|
143
143
|
function pressMenuItem(label: string) {
|
|
144
144
|
const button = zOrderButtons().find((b) => b.textContent === label);
|
|
@@ -200,7 +200,7 @@ describe("CanvasContextMenu — z-action commit path", () => {
|
|
|
200
200
|
const captured: CapturedBatchCall[] = [];
|
|
201
201
|
const { commit, cleanup } = renderCommitHook(captured);
|
|
202
202
|
|
|
203
|
-
// Wire onApplyZIndex the way the app does (
|
|
203
|
+
// Wire onApplyZIndex the way the app does (ConnectedDomEditOverlay → the commit
|
|
204
204
|
// hook), asserting the menu has NOT touched the DOM when it fires — the
|
|
205
205
|
// hook must capture true pre-change styles for its rollback.
|
|
206
206
|
const stylesAtApply: Array<{ zIndex: string; position: string }> = [];
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* flow, and captures the TRUE prior styles for its failure rollback.
|
|
17
17
|
*
|
|
18
18
|
* The prop MUST be wired at the call site to route through the full persist
|
|
19
|
-
* path.
|
|
19
|
+
* path. ConnectedDomEditOverlay.tsx builds the per-patch PatchTargets (the selected
|
|
20
20
|
* element carries its full selection identity; sibling elements are iframe DOM
|
|
21
21
|
* nodes, so their id / selector are derived from the node and they share the
|
|
22
22
|
* selection's sourceFile) and forwards them to handleDomZIndexReorderCommit.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { getPreviewState, iframeRef } from "../nle/PreviewOverlaysTestMocks";
|
|
7
|
+
import { PreviewOverlays } from "../nle/PreviewOverlays";
|
|
8
|
+
import { ConnectedDomEditOverlay } from "./ConnectedDomEditOverlay";
|
|
9
|
+
|
|
10
|
+
// Overlay prop -> session handler, as PreviewOverlays wired DomEditOverlay itself.
|
|
11
|
+
const WIRING = {
|
|
12
|
+
onCanvasMouseDown: "handlePreviewCanvasMouseDown",
|
|
13
|
+
onCanvasPointerMove: "handlePreviewCanvasPointerMove",
|
|
14
|
+
onCanvasPointerLeave: "handlePreviewCanvasPointerLeave",
|
|
15
|
+
onSelectionChange: "applyDomSelection",
|
|
16
|
+
onBlockedMove: "handleBlockedDomMove",
|
|
17
|
+
onManualDragStart: "handleDomManualDragStart",
|
|
18
|
+
onPathOffsetCommit: "handleDomPathOffsetCommit",
|
|
19
|
+
onGroupPathOffsetCommit: "handleDomGroupPathOffsetCommit",
|
|
20
|
+
onBoxSizeCommit: "handleDomBoxSizeCommit",
|
|
21
|
+
onRotationCommit: "handleDomRotationCommit",
|
|
22
|
+
onStyleCommit: "handleDomStyleCommit",
|
|
23
|
+
onDeleteSelection: "handleDomEditElementDelete",
|
|
24
|
+
onMarqueeSelect: "applyMarqueeSelection",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
const ctx = vi.hoisted(() => ({
|
|
28
|
+
overlayProps: null as Record<string, unknown> | null,
|
|
29
|
+
actions: {} as Record<string, unknown>,
|
|
30
|
+
selection: {
|
|
31
|
+
domEditHoverSelection: { label: "hover" },
|
|
32
|
+
domEditSelection: { label: "selected" },
|
|
33
|
+
domEditGroupSelections: [{ label: "member" }],
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock("../../contexts/DomEditContext", () => ({
|
|
38
|
+
useDomEditSelectionContext: () => ctx.selection,
|
|
39
|
+
useDomEditActionsContext: () => ctx.actions,
|
|
40
|
+
}));
|
|
41
|
+
vi.mock("./DomEditOverlay", () => ({
|
|
42
|
+
DomEditOverlay: (props: Record<string, unknown>) => {
|
|
43
|
+
ctx.overlayProps = props;
|
|
44
|
+
return null;
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
vi.mock("./TopologyLens", () => ({ TopologyLens: () => null }));
|
|
48
|
+
vi.mock("./MotionPathOverlay", () => ({ MotionPathOverlay: () => null }));
|
|
49
|
+
vi.mock("./SnapToolbar", () => ({ SnapToolbar: () => null }));
|
|
50
|
+
vi.mock("./GridOverlay", () => ({ GridOverlay: () => null }));
|
|
51
|
+
|
|
52
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
53
|
+
|
|
54
|
+
let root: Root;
|
|
55
|
+
|
|
56
|
+
function mount(node: React.ReactNode): Record<string, unknown> {
|
|
57
|
+
ctx.actions = {
|
|
58
|
+
previewIframeRef: iframeRef,
|
|
59
|
+
handleDomZIndexReorderCommit: vi.fn(() => Promise.resolve(undefined)),
|
|
60
|
+
};
|
|
61
|
+
for (const name of Object.values(WIRING)) ctx.actions[name] = vi.fn();
|
|
62
|
+
const host = document.createElement("div");
|
|
63
|
+
document.body.append(host);
|
|
64
|
+
root = createRoot(host);
|
|
65
|
+
act(() => root.render(node));
|
|
66
|
+
return ctx.overlayProps!;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
act(() => root.unmount());
|
|
71
|
+
ctx.overlayProps = null;
|
|
72
|
+
getPreviewState().isPlaying = false;
|
|
73
|
+
document.body.replaceChildren();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("ConnectedDomEditOverlay", () => {
|
|
77
|
+
it("is what PreviewOverlays renders, with every handler from the session", () => {
|
|
78
|
+
const props = mount(
|
|
79
|
+
<PreviewOverlays
|
|
80
|
+
shouldShowMotionPath={false}
|
|
81
|
+
shouldShowSelectedDomBounds={true}
|
|
82
|
+
recordingState={"idle" as never}
|
|
83
|
+
onToggleRecording={vi.fn()}
|
|
84
|
+
/>,
|
|
85
|
+
);
|
|
86
|
+
// The overlay never read these, so the connected wrapper does not pass them.
|
|
87
|
+
expect(props).not.toHaveProperty("recordingState");
|
|
88
|
+
expect(props).not.toHaveProperty("onToggleRecording");
|
|
89
|
+
for (const [prop, handler] of Object.entries(WIRING)) {
|
|
90
|
+
expect(props[prop], prop).toBe(ctx.actions[handler]);
|
|
91
|
+
}
|
|
92
|
+
expect(props).toMatchObject({
|
|
93
|
+
iframeRef,
|
|
94
|
+
activeCompositionPath: "index.html",
|
|
95
|
+
hoverSelection: ctx.selection.domEditHoverSelection,
|
|
96
|
+
selection: ctx.selection.domEditSelection,
|
|
97
|
+
groupSelections: ctx.selection.domEditGroupSelections,
|
|
98
|
+
allowCanvasMovement: true,
|
|
99
|
+
canvasInput: undefined,
|
|
100
|
+
onTextEditingChange: undefined,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("shows no hover box while playback runs", () => {
|
|
105
|
+
getPreviewState().isPlaying = true;
|
|
106
|
+
const props = mount(
|
|
107
|
+
<PreviewOverlays shouldShowMotionPath={false} shouldShowSelectedDomBounds={true} />,
|
|
108
|
+
);
|
|
109
|
+
expect(props.hoverSelection).toBeNull();
|
|
110
|
+
expect(props.selection).toBe(ctx.selection.domEditSelection);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("hides the selection and stops movement while a gesture records", () => {
|
|
114
|
+
const props = mount(
|
|
115
|
+
<PreviewOverlays
|
|
116
|
+
shouldShowMotionPath={false}
|
|
117
|
+
shouldShowSelectedDomBounds={false}
|
|
118
|
+
isGestureRecording={true}
|
|
119
|
+
/>,
|
|
120
|
+
);
|
|
121
|
+
expect(props).toMatchObject({
|
|
122
|
+
selection: null,
|
|
123
|
+
groupSelections: [],
|
|
124
|
+
allowCanvasMovement: false,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("persists a z-order change through the session's reorder commit", () => {
|
|
129
|
+
const props = mount(
|
|
130
|
+
<PreviewOverlays shouldShowMotionPath={false} shouldShowSelectedDomBounds={true} />,
|
|
131
|
+
);
|
|
132
|
+
const element = document.createElement("div");
|
|
133
|
+
element.id = "card";
|
|
134
|
+
const sel = { element, id: "card", selector: "#card", sourceFile: "index.html" };
|
|
135
|
+
const apply = props.onApplyZIndex as (...args: unknown[]) => void;
|
|
136
|
+
act(() => apply(sel, [{ element, zIndex: 4 }], "bring-forward", null));
|
|
137
|
+
expect(ctx.actions.handleDomZIndexReorderCommit).toHaveBeenCalledWith(
|
|
138
|
+
[expect.objectContaining({ element, zIndex: 4, id: "card", sourceFile: "index.html" })],
|
|
139
|
+
expect.stringContaining("z-reorder:bring-forward"),
|
|
140
|
+
"bring-forward",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("passes a host's canvas input, text-editing listener and hover gate to the overlay", () => {
|
|
145
|
+
const onTextEditingChange = vi.fn();
|
|
146
|
+
const props = mount(
|
|
147
|
+
<ConnectedDomEditOverlay
|
|
148
|
+
activeCompositionPath={null}
|
|
149
|
+
showHoverSelection={false}
|
|
150
|
+
shouldShowSelectedDomBounds={true}
|
|
151
|
+
canvasInput="host"
|
|
152
|
+
onTextEditingChange={onTextEditingChange}
|
|
153
|
+
/>,
|
|
154
|
+
);
|
|
155
|
+
expect(props).toMatchObject({
|
|
156
|
+
canvasInput: "host",
|
|
157
|
+
onTextEditingChange,
|
|
158
|
+
hoverSelection: null,
|
|
159
|
+
selection: ctx.selection.domEditSelection,
|
|
160
|
+
iframeRef,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { DomEditOverlay } from "./DomEditOverlay";
|
|
3
|
+
import {
|
|
4
|
+
useDomEditActionsContext,
|
|
5
|
+
useDomEditSelectionContext,
|
|
6
|
+
} from "../../contexts/DomEditContext";
|
|
7
|
+
import { readHfId, type DomEditSelection } from "./domEditing";
|
|
8
|
+
import { buildStableSelector } from "./domEditingDom";
|
|
9
|
+
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
|
|
10
|
+
import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
|
|
11
|
+
import { useCanvasZOrderTimelineMirror } from "../nle/useCanvasZOrderTimelineMirror";
|
|
12
|
+
import { runZLaneGesture } from "../nle/zLaneGesture";
|
|
13
|
+
|
|
14
|
+
type HostInputProps = Pick<
|
|
15
|
+
ComponentProps<typeof DomEditOverlay>,
|
|
16
|
+
"canvasInput" | "onSelectionBoxClick"
|
|
17
|
+
>;
|
|
18
|
+
|
|
19
|
+
export interface ConnectedDomEditOverlayProps extends HostInputProps {
|
|
20
|
+
activeCompositionPath: string | null;
|
|
21
|
+
showHoverSelection: boolean;
|
|
22
|
+
shouldShowSelectedDomBounds: boolean;
|
|
23
|
+
isGestureRecording?: boolean;
|
|
24
|
+
/** True when an in-place text edit opens its caret, false when it ends or unmounts. */
|
|
25
|
+
onTextEditingChange?: (editing: boolean) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ZIndexReorderEntry = {
|
|
29
|
+
element: HTMLElement;
|
|
30
|
+
zIndex: number;
|
|
31
|
+
id?: string;
|
|
32
|
+
selector?: string;
|
|
33
|
+
selectorIndex?: number;
|
|
34
|
+
sourceFile: string;
|
|
35
|
+
/** Timeline store key — lets the commit update the store zIndex synchronously. */
|
|
36
|
+
key?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Can this element be robustly re-targeted for a persisted z change? */
|
|
40
|
+
function canTargetZIndexElement(
|
|
41
|
+
element: HTMLElement,
|
|
42
|
+
id: string | undefined,
|
|
43
|
+
selector: string | undefined,
|
|
44
|
+
): boolean {
|
|
45
|
+
return Boolean(id || selector || readHfId(element));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The selected element carries its full selection identity. */
|
|
49
|
+
function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReorderEntry {
|
|
50
|
+
return {
|
|
51
|
+
element: sel.element,
|
|
52
|
+
zIndex,
|
|
53
|
+
id: sel.id ?? undefined,
|
|
54
|
+
selector: sel.selector,
|
|
55
|
+
selectorIndex: sel.selectorIndex,
|
|
56
|
+
sourceFile: sel.sourceFile,
|
|
57
|
+
key: deriveTimelineStoreKey({
|
|
58
|
+
domId: sel.id ?? undefined,
|
|
59
|
+
selector: sel.selector,
|
|
60
|
+
selectorIndex: sel.selectorIndex,
|
|
61
|
+
sourceFile: sel.sourceFile,
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A raw iframe sibling in the selection's file; null with no id or selector (z stays live). */
|
|
67
|
+
function siblingZIndexEntry(
|
|
68
|
+
element: HTMLElement,
|
|
69
|
+
zIndex: number,
|
|
70
|
+
sourceFile: string,
|
|
71
|
+
): ZIndexReorderEntry | null {
|
|
72
|
+
const id = element.id || undefined;
|
|
73
|
+
const selector = buildStableSelector(element);
|
|
74
|
+
if (!canTargetZIndexElement(element, id, selector)) return null;
|
|
75
|
+
return {
|
|
76
|
+
element,
|
|
77
|
+
zIndex,
|
|
78
|
+
id,
|
|
79
|
+
selector,
|
|
80
|
+
selectorIndex: undefined,
|
|
81
|
+
sourceFile,
|
|
82
|
+
key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Short human-readable label for a dropped sibling, for the console warning below. */
|
|
87
|
+
function describeZIndexElement(element: HTMLElement): string {
|
|
88
|
+
if (element.id) return `#${element.id}`;
|
|
89
|
+
const firstClass = element.classList.item(0);
|
|
90
|
+
return firstClass
|
|
91
|
+
? `${element.tagName.toLowerCase()}.${firstClass}`
|
|
92
|
+
: element.tagName.toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Resolve z-index patches into commit entries; a sibling with no stable
|
|
96
|
+
// id/selector can't be written to source, so it is returned as `dropped` for
|
|
97
|
+
// the revert-on-reload warning. Exported so tests can drive the menu → commit path.
|
|
98
|
+
export function resolveZIndexEntries(
|
|
99
|
+
sel: DomEditSelection,
|
|
100
|
+
patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
|
|
101
|
+
): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
|
|
102
|
+
const entries: ZIndexReorderEntry[] = [];
|
|
103
|
+
const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
|
|
104
|
+
for (const patch of patches) {
|
|
105
|
+
if (patch.element === sel.element) {
|
|
106
|
+
entries.push(selectedZIndexEntry(sel, patch.zIndex));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
|
|
110
|
+
if (entry) entries.push(entry);
|
|
111
|
+
else dropped.push(patch);
|
|
112
|
+
}
|
|
113
|
+
return { entries, dropped };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Studio's DomEditOverlay wired to the session in DomEditProvider, so a host
|
|
117
|
+
// outside EditorShell mounts the same canvas editing without its own callbacks.
|
|
118
|
+
export function ConnectedDomEditOverlay({
|
|
119
|
+
activeCompositionPath,
|
|
120
|
+
showHoverSelection,
|
|
121
|
+
shouldShowSelectedDomBounds,
|
|
122
|
+
isGestureRecording,
|
|
123
|
+
canvasInput,
|
|
124
|
+
onTextEditingChange,
|
|
125
|
+
onSelectionBoxClick,
|
|
126
|
+
}: ConnectedDomEditOverlayProps) {
|
|
127
|
+
const { domEditHoverSelection, domEditSelection, domEditGroupSelections } =
|
|
128
|
+
useDomEditSelectionContext();
|
|
129
|
+
const {
|
|
130
|
+
previewIframeRef,
|
|
131
|
+
handlePreviewCanvasMouseDown,
|
|
132
|
+
handlePreviewCanvasPointerMove,
|
|
133
|
+
handlePreviewCanvasPointerLeave,
|
|
134
|
+
applyDomSelection,
|
|
135
|
+
handleBlockedDomMove,
|
|
136
|
+
handleDomManualDragStart,
|
|
137
|
+
handleDomPathOffsetCommit,
|
|
138
|
+
handleDomGroupPathOffsetCommit,
|
|
139
|
+
handleDomBoxSizeCommit,
|
|
140
|
+
handleDomRotationCommit,
|
|
141
|
+
handleDomStyleCommit,
|
|
142
|
+
applyMarqueeSelection,
|
|
143
|
+
handleDomEditElementDelete,
|
|
144
|
+
handleDomZIndexReorderCommit,
|
|
145
|
+
} = useDomEditActionsContext();
|
|
146
|
+
const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<DomEditOverlay
|
|
150
|
+
iframeRef={previewIframeRef}
|
|
151
|
+
activeCompositionPath={activeCompositionPath}
|
|
152
|
+
hoverSelection={showHoverSelection ? domEditHoverSelection : null}
|
|
153
|
+
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
|
154
|
+
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
|
155
|
+
allowCanvasMovement={!isGestureRecording}
|
|
156
|
+
canvasInput={canvasInput}
|
|
157
|
+
onTextEditingChange={onTextEditingChange}
|
|
158
|
+
onSelectionBoxClick={onSelectionBoxClick}
|
|
159
|
+
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
160
|
+
onCanvasPointerMove={handlePreviewCanvasPointerMove}
|
|
161
|
+
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
|
|
162
|
+
onSelectionChange={applyDomSelection}
|
|
163
|
+
onBlockedMove={handleBlockedDomMove}
|
|
164
|
+
onManualDragStart={handleDomManualDragStart}
|
|
165
|
+
onPathOffsetCommit={handleDomPathOffsetCommit}
|
|
166
|
+
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
|
167
|
+
onBoxSizeCommit={handleDomBoxSizeCommit}
|
|
168
|
+
onRotationCommit={handleDomRotationCommit}
|
|
169
|
+
onStyleCommit={handleDomStyleCommit}
|
|
170
|
+
onDeleteSelection={handleDomEditElementDelete}
|
|
171
|
+
onApplyZIndex={(sel, patches, action, crossed) => {
|
|
172
|
+
const { entries, dropped } = resolveZIndexEntries(sel, patches);
|
|
173
|
+
if (dropped.length > 0) {
|
|
174
|
+
// These siblings can't be written to source. Apply their live z
|
|
175
|
+
// anyway so the resolved stacking order renders coherently — it
|
|
176
|
+
// just reverts to the prior order on the next reload.
|
|
177
|
+
for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
|
|
178
|
+
console.warn(
|
|
179
|
+
"[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
|
|
180
|
+
"(will revert on reload):",
|
|
181
|
+
dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (entries.length === 0) return;
|
|
185
|
+
// One coalesce key for the z persist AND the lane mirror folds both into one undo entry;
|
|
186
|
+
// passed explicitly so the mirror shares it by construction, not by formula duplication.
|
|
187
|
+
const coalesceKey = zReorderCoalesceKey(entries, action);
|
|
188
|
+
// One serialized z→lane transaction: the mirror runs only AFTER a durable z commit and
|
|
189
|
+
// no second gesture interleaves (see runZLaneGesture). A failed z commit has already
|
|
190
|
+
// toasted and rolled back, so the catch only keeps its rejection from going unhandled.
|
|
191
|
+
runZLaneGesture({
|
|
192
|
+
commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action),
|
|
193
|
+
mirror: () =>
|
|
194
|
+
mirrorZOrderToTimeline({
|
|
195
|
+
selectionKey: entries.find((e) => e.element === sel.element)?.key,
|
|
196
|
+
action,
|
|
197
|
+
crossed,
|
|
198
|
+
sourceFile: sel.sourceFile,
|
|
199
|
+
coalesceKey,
|
|
200
|
+
}),
|
|
201
|
+
}).catch(() => undefined);
|
|
202
|
+
}}
|
|
203
|
+
onMarqueeSelect={applyMarqueeSelection}
|
|
204
|
+
/>
|
|
205
|
+
);
|
|
206
|
+
}
|