@hyperframes/studio 0.8.54 → 0.8.56
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-Cy_AwUa8.js → hyperframes-player-k8LzMtXX.js} +1 -1
- package/dist/assets/{index-DPUJoO8n.js → index-BlkJ_Axe.js} +192 -192
- package/dist/assets/{index-CTa-6Kso.js → index-CxR7F2YQ.js} +1 -1
- package/dist/assets/{index-DkAzKuPZ.js → index-DFlILHJe.js} +1 -1
- package/dist/assets/{index-DrHQAuGO.css → index-ZmBtVrEm.css} +1 -1
- package/dist/index.d.ts +16 -8
- package/dist/index.html +2 -2
- package/dist/index.js +3152 -3010
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +14 -1
- package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
- package/src/components/EditorShell.selectionSync.test.tsx +57 -67
- package/src/components/EditorShell.tsx +59 -50
- package/src/components/EditorShellTestMocks.ts +46 -0
- package/src/components/editor/DomEditOverlay.tsx +19 -40
- package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
- package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
- package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
- package/src/components/editor/PropertyPanel.tsx +4 -3
- package/src/components/editor/SnapToolbar.test.tsx +1 -0
- package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
- package/src/components/editor/previewReadOnlyContext.tsx +59 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
- package/src/components/editor/useDomEditNudge.ts +9 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
- package/src/components/editor/useOffCanvasIndicators.ts +50 -0
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
- package/src/components/nle/PreviewOverlays.tsx +15 -8
- package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
- package/src/components/nle/TimelinePane.tsx +5 -1
- package/src/components/nle/previewReadOnly.test.tsx +128 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
- package/src/components/nle/usePreviewBlockDrop.ts +3 -1
- package/src/hooks/appHotkeysDispatch.ts +16 -1
- package/src/hooks/gsapDragCommit.ts +1 -1
- package/src/hooks/gsapEditOutcome.test.ts +115 -0
- package/src/hooks/gsapEditOutcome.ts +39 -10
- package/src/hooks/gsapResizeIntercept.test.ts +42 -3
- package/src/hooks/gsapResizeIntercept.ts +12 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +10 -2
- package/src/hooks/gsapRuntimeBridge.ts +14 -3
- package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
- package/src/hooks/timelineAudioGroupCreate.ts +3 -4
- package/src/hooks/timelineTrackVisibility.ts +7 -8
- package/src/hooks/useAppHotkeys.test.ts +71 -3
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +4 -0
- package/src/hooks/useDomEditCommits.test.tsx +1 -0
- package/src/hooks/useDomEditCommits.ts +3 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -0
- package/src/hooks/useDomEditSession.ts +7 -4
- package/src/hooks/useDomEditWiring.ts +40 -40
- package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
- package/src/hooks/useDomGeometryCommits.ts +26 -20
- package/src/hooks/useGestureCommit.test.tsx +29 -0
- package/src/hooks/useGestureCommit.ts +23 -0
- package/src/hooks/useInlineTextEdit.test.tsx +23 -2
- package/src/hooks/useInlineTextEdit.ts +11 -2
- package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
- package/src/hooks/useTimelineAssetDropOps.ts +1 -6
- package/src/hooks/useTimelineEditing.test.tsx +10 -7
- package/src/hooks/useTimelineEditing.ts +2 -7
- package/src/player/components/Timeline.tsx +22 -23
- package/src/player/components/TimelineClip.test.tsx +52 -0
- package/src/player/components/TimelineClip.tsx +13 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/timelineMotionStyles.test.ts +3 -3
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +34 -30
- package/src/player/components/timelineTrackDisplay.ts +1 -4
- package/src/player/components/useTimelineTrackDerivations.ts +2 -2
- package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
- package/src/player/hooks/timelineSyncHydration.ts +0 -23
- package/src/player/hooks/useTimelineSyncCallbacks.ts +0 -3
- package/src/player/store/playerStore.ts +0 -4
- package/src/player/store/timelineElement.ts +2 -4
- package/src/player/store/timelineResetState.ts +0 -1
- package/src/styles/hex-baseline.json +3 -4
- package/src/styles/studio.css +7 -7
- package/src/styles/theme.css +29 -0
- package/src/utils/studioSaveDiagnostics.ts +12 -0
- package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
- package/src/player/hooks/useTimelineRowElements.ts +0 -17
|
@@ -167,6 +167,15 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
167
167
|
const commitSessionRef = useRef(commitSession);
|
|
168
168
|
commitSessionRef.current = commitSession;
|
|
169
169
|
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (params.allowCanvasMovement || !sessionRef.current) return;
|
|
172
|
+
const session = sessionRef.current;
|
|
173
|
+
sessionRef.current = null;
|
|
174
|
+
if (session.timer) clearTimeout(session.timer);
|
|
175
|
+
restoreManualOffsetDragMembers(session.members);
|
|
176
|
+
endManualOffsetDragMembers(session.members);
|
|
177
|
+
}, [params.allowCanvasMovement]);
|
|
178
|
+
|
|
170
179
|
// Build drag members for the current target set — the same member snapshot a
|
|
171
180
|
// pointer drag starts from (startGesture / startGroupDrag), so the nudge
|
|
172
181
|
// commit converts offsets → GSAP x/y with identical math.
|
|
@@ -532,6 +532,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
532
532
|
restoreGestureOverlayRect(g);
|
|
533
533
|
}
|
|
534
534
|
if (g?.mode === "rotation" && sel) {
|
|
535
|
+
applyRotationDraftViaGsap(
|
|
536
|
+
sel.element,
|
|
537
|
+
g.actualRotation - (Number.parseFloat(g.initialRotation.studioRotation) || 0),
|
|
538
|
+
);
|
|
535
539
|
restoreStudioRotation(sel.element, g.initialRotation);
|
|
536
540
|
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
|
537
541
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type RefObject } from "react";
|
|
2
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
+
import type { DomEditCompositionRect } from "./useDomEditCompositionRect";
|
|
4
|
+
import { type OffCanvasRect } from "./OffCanvasIndicators";
|
|
5
|
+
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
|
|
6
|
+
|
|
7
|
+
export function useOffCanvasIndicators({
|
|
8
|
+
iframeRef,
|
|
9
|
+
overlayRef,
|
|
10
|
+
compRectRef,
|
|
11
|
+
activeCompositionPathRef,
|
|
12
|
+
activeCompositionPath,
|
|
13
|
+
}: {
|
|
14
|
+
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
15
|
+
overlayRef: RefObject<HTMLDivElement | null>;
|
|
16
|
+
compRectRef: React.MutableRefObject<DomEditCompositionRect>;
|
|
17
|
+
activeCompositionPathRef: React.MutableRefObject<string | null>;
|
|
18
|
+
activeCompositionPath: string | null;
|
|
19
|
+
}): {
|
|
20
|
+
offCanvasRects: OffCanvasRect[];
|
|
21
|
+
offCanvasElementsRef: React.MutableRefObject<Map<string, HTMLElement>>;
|
|
22
|
+
} {
|
|
23
|
+
const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
|
|
24
|
+
const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
|
|
25
|
+
const offCanvasDirtyRef = useRef(true);
|
|
26
|
+
const offCanvasSigRef = useRef("");
|
|
27
|
+
const offCanvasObserverRef = useRef<MutationObserver | null>(null);
|
|
28
|
+
const offCanvasObservedDocRef = useRef<Document | null>(null);
|
|
29
|
+
|
|
30
|
+
useMountEffect(() =>
|
|
31
|
+
startOffCanvasIndicatorRefresh({
|
|
32
|
+
iframeRef,
|
|
33
|
+
overlayRef,
|
|
34
|
+
compRectRef,
|
|
35
|
+
activeCompositionPathRef,
|
|
36
|
+
dirtyRef: offCanvasDirtyRef,
|
|
37
|
+
sigRef: offCanvasSigRef,
|
|
38
|
+
observerRef: offCanvasObserverRef,
|
|
39
|
+
observedDocRef: offCanvasObservedDocRef,
|
|
40
|
+
elementsRef: offCanvasElementsRef,
|
|
41
|
+
setRects: setOffCanvasRects,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
offCanvasDirtyRef.current = true;
|
|
47
|
+
}, [activeCompositionPath]);
|
|
48
|
+
|
|
49
|
+
return { offCanvasRects, offCanvasElementsRef };
|
|
50
|
+
}
|
|
@@ -3,21 +3,13 @@
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { getPreviewState, iframeRef } from "./PreviewOverlaysTestMocks";
|
|
6
7
|
import { PreviewOverlays } from "./PreviewOverlays";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
const previewState = getPreviewState();
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
const iframeRef = { current: null as HTMLIFrameElement | null };
|
|
11
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
12
|
|
|
13
|
-
vi.mock("../../contexts/StudioContext", () => ({
|
|
14
|
-
useStudioShellContext: () => ({ activeCompPath: "index.html", previewIframeRef: iframeRef }),
|
|
15
|
-
useStudioPlaybackContext: () => ({
|
|
16
|
-
captionEditMode: previewState.captionEditMode,
|
|
17
|
-
compositionLoading: false,
|
|
18
|
-
isPlaying: false,
|
|
19
|
-
}),
|
|
20
|
-
}));
|
|
21
13
|
vi.mock("../../contexts/DomEditContext", () => ({
|
|
22
14
|
useDomEditSelectionContext: () => ({
|
|
23
15
|
domEditHoverSelection: null,
|
|
@@ -41,30 +33,7 @@ vi.mock("../../contexts/DomEditContext", () => ({
|
|
|
41
33
|
handleDomZIndexReorderCommit: vi.fn(),
|
|
42
34
|
}),
|
|
43
35
|
}));
|
|
44
|
-
|
|
45
|
-
const state = {
|
|
46
|
-
model: null,
|
|
47
|
-
dismissed: false,
|
|
48
|
-
syncError: null,
|
|
49
|
-
clearSelection: vi.fn(),
|
|
50
|
-
setDismissed: vi.fn(),
|
|
51
|
-
setEditMode: vi.fn(),
|
|
52
|
-
setSyncError: vi.fn(),
|
|
53
|
-
};
|
|
54
|
-
return {
|
|
55
|
-
useCaptionStore: Object.assign(
|
|
56
|
-
(selector: (value: typeof state) => unknown) => selector(state),
|
|
57
|
-
{ getState: () => state },
|
|
58
|
-
),
|
|
59
|
-
};
|
|
60
|
-
});
|
|
61
|
-
vi.mock("../../hooks/useCompositionDimensions", () => ({
|
|
62
|
-
useCompositionDimensions: () => null,
|
|
63
|
-
}));
|
|
64
|
-
vi.mock("../../utils/studioUiPreferences", () => ({ readStudioUiPreferences: () => ({}) }));
|
|
65
|
-
vi.mock("./useCanvasZOrderTimelineMirror", () => ({
|
|
66
|
-
useCanvasZOrderTimelineMirror: () => vi.fn(),
|
|
67
|
-
}));
|
|
36
|
+
|
|
68
37
|
vi.mock("../editor/TopologyLens", async () => {
|
|
69
38
|
const { createElement } = await import("react");
|
|
70
39
|
return {
|
|
@@ -5,6 +5,7 @@ import { DomEditOverlay } from "../editor/DomEditOverlay";
|
|
|
5
5
|
import { TopologyLens } from "../editor/TopologyLens";
|
|
6
6
|
import { MotionPathOverlay } from "../editor/MotionPathOverlay";
|
|
7
7
|
import { SnapToolbar } from "../editor/SnapToolbar";
|
|
8
|
+
import { usePreviewReadOnly } from "../editor/previewReadOnlyContext";
|
|
8
9
|
import { useCompositionDimensions } from "../../hooks/useCompositionDimensions";
|
|
9
10
|
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
|
|
10
11
|
import {
|
|
@@ -140,6 +141,8 @@ export function PreviewOverlays({
|
|
|
140
141
|
const { activeCompPath, previewIframeRef } = useStudioShellContext();
|
|
141
142
|
const { captionEditMode, compositionLoading, isPlaying } = useStudioPlaybackContext();
|
|
142
143
|
const compositionDimensions = useCompositionDimensions(previewIframeRef);
|
|
144
|
+
const readOnly = usePreviewReadOnly();
|
|
145
|
+
const previewCaptionEditMode = captionEditMode && !readOnly;
|
|
143
146
|
|
|
144
147
|
// Caption edit mode is entered automatically when captions are detected;
|
|
145
148
|
// these give the author an explicit way OUT (and back in). Without them the
|
|
@@ -216,7 +219,7 @@ export function PreviewOverlays({
|
|
|
216
219
|
);
|
|
217
220
|
}
|
|
218
221
|
|
|
219
|
-
if (
|
|
222
|
+
if (previewCaptionEditMode) {
|
|
220
223
|
return (
|
|
221
224
|
<>
|
|
222
225
|
<TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
|
|
@@ -267,7 +270,9 @@ export function PreviewOverlays({
|
|
|
267
270
|
iframeRef={previewIframeRef}
|
|
268
271
|
activeCompositionPath={activeCompPath}
|
|
269
272
|
hoverSelection={
|
|
270
|
-
!
|
|
273
|
+
!previewCaptionEditMode && !compositionLoading && !isPlaying
|
|
274
|
+
? domEditHoverSelection
|
|
275
|
+
: null
|
|
271
276
|
}
|
|
272
277
|
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
|
273
278
|
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
|
@@ -327,12 +332,14 @@ export function PreviewOverlays({
|
|
|
327
332
|
onMarqueeSelect={applyMarqueeSelection}
|
|
328
333
|
/>
|
|
329
334
|
<SnapToolbar onSnapChange={setSnapPrefs} />
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
335
|
+
{!readOnly && (
|
|
336
|
+
<MotionPathOverlay
|
|
337
|
+
iframeRef={previewIframeRef}
|
|
338
|
+
selection={shouldShowMotionPath ? domEditSelection : null}
|
|
339
|
+
compositionSize={compositionDimensions}
|
|
340
|
+
isPlaying={isPlaying}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
336
343
|
{gestureOverlay}
|
|
337
344
|
{captionModelPresent && captionDismissed && (
|
|
338
345
|
<button
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const previewState = vi.hoisted(() => ({ captionEditMode: false }));
|
|
4
|
+
export function getPreviewState() {
|
|
5
|
+
return previewState;
|
|
6
|
+
}
|
|
7
|
+
export const iframeRef = { current: null as HTMLIFrameElement | null };
|
|
8
|
+
|
|
9
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
10
|
+
useStudioShellContext: () => ({ activeCompPath: "index.html", previewIframeRef: iframeRef }),
|
|
11
|
+
useStudioPlaybackContext: () => ({
|
|
12
|
+
captionEditMode: previewState.captionEditMode,
|
|
13
|
+
compositionLoading: false,
|
|
14
|
+
isPlaying: false,
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
vi.mock("../../captions/store", () => {
|
|
18
|
+
const state = {
|
|
19
|
+
model: null,
|
|
20
|
+
dismissed: false,
|
|
21
|
+
syncError: null,
|
|
22
|
+
clearSelection: vi.fn(),
|
|
23
|
+
setDismissed: vi.fn(),
|
|
24
|
+
setEditMode: vi.fn(),
|
|
25
|
+
setSyncError: vi.fn(),
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
useCaptionStore: Object.assign(
|
|
29
|
+
(selector: (value: typeof state) => unknown) => selector(state),
|
|
30
|
+
{ getState: () => state },
|
|
31
|
+
),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
vi.mock("../../hooks/useCompositionDimensions", () => ({
|
|
35
|
+
useCompositionDimensions: () => null,
|
|
36
|
+
}));
|
|
37
|
+
vi.mock("../../utils/studioUiPreferences", () => ({ readStudioUiPreferences: () => ({}) }));
|
|
38
|
+
vi.mock("./useCanvasZOrderTimelineMirror", () => ({
|
|
39
|
+
useCanvasZOrderTimelineMirror: () => vi.fn(),
|
|
40
|
+
}));
|
|
@@ -255,7 +255,11 @@ export function TimelinePane({
|
|
|
255
255
|
);
|
|
256
256
|
|
|
257
257
|
return (
|
|
258
|
-
<div
|
|
258
|
+
<div
|
|
259
|
+
className="relative flex h-full flex-col"
|
|
260
|
+
data-studio-timeline="true"
|
|
261
|
+
aria-disabled={timelineDisabled || undefined}
|
|
262
|
+
>
|
|
259
263
|
<div
|
|
260
264
|
className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
|
|
261
265
|
onDoubleClick={(e) => {
|
|
@@ -0,0 +1,128 @@
|
|
|
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 "./PreviewOverlaysTestMocks";
|
|
7
|
+
import { PreviewOverlays } from "./PreviewOverlays";
|
|
8
|
+
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
|
|
9
|
+
import { PreviewReadOnlyProvider } from "../editor/previewReadOnlyContext";
|
|
10
|
+
import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
11
|
+
|
|
12
|
+
const previewState = getPreviewState();
|
|
13
|
+
|
|
14
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
|
+
|
|
16
|
+
vi.mock("../../contexts/DomEditContext", () => ({
|
|
17
|
+
useDomEditSelectionContext: () => ({
|
|
18
|
+
domEditHoverSelection: null,
|
|
19
|
+
domEditSelection: null,
|
|
20
|
+
domEditGroupSelections: [],
|
|
21
|
+
}),
|
|
22
|
+
useDomEditActionsContext: () => ({}),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock("../editor/TopologyLens", () => ({ TopologyLens: () => null }));
|
|
25
|
+
vi.mock("../../captions/components/CaptionOverlay", () => ({
|
|
26
|
+
CaptionOverlay: () => <i data-testid="caption-overlay" />,
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("../editor/DomEditOverlay", () => ({
|
|
29
|
+
DomEditOverlay: () => <i data-testid="dom-edit-overlay" />,
|
|
30
|
+
}));
|
|
31
|
+
vi.mock("../editor/MotionPathOverlay", () => ({
|
|
32
|
+
MotionPathOverlay: () => <i data-testid="motion-path" />,
|
|
33
|
+
}));
|
|
34
|
+
vi.mock("../editor/SnapToolbar", () => ({ SnapToolbar: () => null }));
|
|
35
|
+
|
|
36
|
+
let root: Root;
|
|
37
|
+
let host: HTMLDivElement;
|
|
38
|
+
|
|
39
|
+
function mount(node: React.ReactNode, readOnly = false): void {
|
|
40
|
+
host = document.createElement("div");
|
|
41
|
+
iframeRef.current = document.createElement("iframe");
|
|
42
|
+
document.body.append(host, iframeRef.current);
|
|
43
|
+
root = createRoot(host);
|
|
44
|
+
act(() =>
|
|
45
|
+
root.render(<PreviewReadOnlyProvider readOnly={readOnly}>{node}</PreviewReadOnlyProvider>),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
act(() => root.unmount());
|
|
51
|
+
previewState.captionEditMode = false;
|
|
52
|
+
document.body.replaceChildren();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const overlays = () => (
|
|
56
|
+
<PreviewOverlays shouldShowMotionPath={true} shouldShowSelectedDomBounds={true} />
|
|
57
|
+
);
|
|
58
|
+
const has = (id: string) => host.querySelector(`[data-testid="${id}"]`) !== null;
|
|
59
|
+
|
|
60
|
+
describe("PreviewOverlays with the preview read-only", () => {
|
|
61
|
+
it("control: with the flag off the motion path can be edited", () => {
|
|
62
|
+
mount(overlays());
|
|
63
|
+
expect(has("motion-path")).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("does not mount the motion path editor", () => {
|
|
67
|
+
mount(overlays(), true);
|
|
68
|
+
expect(has("motion-path")).toBe(false);
|
|
69
|
+
expect(has("dom-edit-overlay")).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("control: with the flag off caption mode mounts the caption editor", () => {
|
|
73
|
+
previewState.captionEditMode = true;
|
|
74
|
+
mount(overlays());
|
|
75
|
+
expect(has("caption-overlay")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("does not mount the caption editor, and selection stays on the canvas overlay", () => {
|
|
79
|
+
previewState.captionEditMode = true;
|
|
80
|
+
mount(overlays(), true);
|
|
81
|
+
expect(has("caption-overlay")).toBe(false);
|
|
82
|
+
expect(has("dom-edit-overlay")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("usePreviewBlockDrop with the preview read-only", () => {
|
|
87
|
+
function dropOnPreview(
|
|
88
|
+
onBlockDrop: (name: string, pos: { left: number; top: number }) => void,
|
|
89
|
+
readOnly = false,
|
|
90
|
+
) {
|
|
91
|
+
const stage = document.createElement("div");
|
|
92
|
+
stage.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 100 }) as DOMRect;
|
|
93
|
+
let handlers: ReturnType<typeof usePreviewBlockDrop> | null = null;
|
|
94
|
+
function Probe() {
|
|
95
|
+
handlers = usePreviewBlockDrop({
|
|
96
|
+
stageRef: { current: stage },
|
|
97
|
+
compositionSize: { width: 1000, height: 1000 },
|
|
98
|
+
onBlockDrop,
|
|
99
|
+
});
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
mount(<Probe />, readOnly);
|
|
103
|
+
const event = {
|
|
104
|
+
clientX: 50,
|
|
105
|
+
clientY: 50,
|
|
106
|
+
preventDefault: vi.fn(),
|
|
107
|
+
dataTransfer: {
|
|
108
|
+
types: [TIMELINE_BLOCK_MIME],
|
|
109
|
+
getData: () => JSON.stringify({ name: "lower-third" }),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
act(() => handlers!.handleDrop(event as never));
|
|
113
|
+
return event;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it("control: with the flag off a dropped block is placed", () => {
|
|
117
|
+
const onBlockDrop = vi.fn();
|
|
118
|
+
dropOnPreview(onBlockDrop);
|
|
119
|
+
expect(onBlockDrop).toHaveBeenCalledWith("lower-third", { left: 500, top: 500 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("does not place a dropped block", () => {
|
|
123
|
+
const onBlockDrop = vi.fn();
|
|
124
|
+
const event = dropOnPreview(onBlockDrop, true);
|
|
125
|
+
expect(onBlockDrop).not.toHaveBeenCalled();
|
|
126
|
+
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
afterEach(() => {
|
|
25
25
|
document.body.innerHTML = "";
|
|
26
26
|
// Wrapped in act: mounted harnesses subscribe to the store via
|
|
27
|
-
//
|
|
27
|
+
// the store timeline elements, so the reset re-renders them.
|
|
28
28
|
act(() => usePlayerStore.getState().setElements([]));
|
|
29
29
|
});
|
|
30
30
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
2
|
import { usePlayerStore } from "../../player";
|
|
3
|
-
import { useTimelineRowElements } from "../../player/hooks/useTimelineRowElements";
|
|
4
3
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
5
4
|
import {
|
|
6
5
|
displayTrackOrder,
|
|
@@ -41,10 +40,8 @@ export interface MirrorZOrderInput {
|
|
|
41
40
|
* commitZMirrorLaneMove) — the shared key is unique per gesture
|
|
42
41
|
* (zReorderCoalesceKey's gesture seq), so the fold stays gesture-scoped.
|
|
43
42
|
*
|
|
44
|
-
* Element source:
|
|
45
|
-
*
|
|
46
|
-
* lanes, expanded sub-comp children on their synthetic rows). No new expansion
|
|
47
|
-
* is built here.
|
|
43
|
+
* Element source: the store's timeline elements, the same set the Timeline
|
|
44
|
+
* renders and the resolver expects. No alternate row expansion is built here.
|
|
48
45
|
*
|
|
49
46
|
* The mirror persists through the SAME machinery as a timeline lane drag
|
|
50
47
|
* (commitZMirrorLaneMove → persistMoveEdits → onMoveElements, with expanded
|
|
@@ -141,7 +138,7 @@ function useMirrorLaneMoveCommit(): (
|
|
|
141
138
|
coalesceKey: string,
|
|
142
139
|
resolveMove: (element: TimelineElement, elements: TimelineElement[]) => ZMirrorLaneMove,
|
|
143
140
|
) => Promise<boolean> {
|
|
144
|
-
const elements =
|
|
141
|
+
const elements = usePlayerStore((state) => state.elements);
|
|
145
142
|
const elementsRef = useRef(elements);
|
|
146
143
|
elementsRef.current = elements;
|
|
147
144
|
const { onMoveElements } = useTimelineEditContextOptional();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useRef, useState, type RefObject } from "react";
|
|
2
2
|
import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
3
|
+
import { usePreviewReadOnly } from "../editor/previewReadOnlyContext";
|
|
3
4
|
|
|
4
5
|
interface UsePreviewBlockDropOptions {
|
|
5
6
|
portrait?: boolean;
|
|
@@ -67,8 +68,9 @@ export function usePreviewBlockDrop({
|
|
|
67
68
|
portrait,
|
|
68
69
|
compositionSize,
|
|
69
70
|
stageRef,
|
|
70
|
-
onBlockDrop,
|
|
71
|
+
onBlockDrop: onBlockDropProp,
|
|
71
72
|
}: UsePreviewBlockDropOptions) {
|
|
73
|
+
const onBlockDrop = usePreviewReadOnly() ? undefined : onBlockDropProp;
|
|
72
74
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
73
75
|
// dragenter/dragleave fire for every internal element boundary; a depth
|
|
74
76
|
// counter keeps the drop indicator steady instead of flickering.
|
|
@@ -54,6 +54,11 @@ export interface HotkeyCallbacks {
|
|
|
54
54
|
onUngroupSelection?: () => void;
|
|
55
55
|
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
56
56
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
57
|
+
readOnlyPreview: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function timelineOwnsKey(event: KeyboardEvent): boolean {
|
|
61
|
+
return event.target instanceof Element && event.target.closest("[data-studio-timeline]") !== null;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/** Exported for tests, like dispatchPlainKey below: lets the Cmd+C/Cmd+V
|
|
@@ -95,6 +100,7 @@ export function dispatchModifierKey(
|
|
|
95
100
|
|
|
96
101
|
if (key === "g" && !event.altKey && !isTypingTarget(event.target)) {
|
|
97
102
|
event.preventDefault();
|
|
103
|
+
if (cb.readOnlyPreview) return true;
|
|
98
104
|
if (event.shiftKey) cb.onUngroupSelection?.();
|
|
99
105
|
else cb.onGroupSelection?.();
|
|
100
106
|
return true;
|
|
@@ -113,6 +119,12 @@ export function dispatchModifierKey(
|
|
|
113
119
|
}
|
|
114
120
|
return true;
|
|
115
121
|
}
|
|
122
|
+
const previewOwnsMutation =
|
|
123
|
+
cb.readOnlyPreview && cb.domEditSelectionRef.current !== null && !timelineOwnsKey(event);
|
|
124
|
+
if (previewOwnsMutation && ["v", "x", "d"].includes(key)) {
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
116
128
|
if (key === "v") {
|
|
117
129
|
event.preventDefault();
|
|
118
130
|
trackStudioEvent("keyboard_shortcut", { action: "paste" });
|
|
@@ -158,6 +170,7 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
|
|
158
170
|
// Reserve bare `s` for Split even when the current selection cannot split,
|
|
159
171
|
// so secondary listeners do not reinterpret the same key as Snap toggle.
|
|
160
172
|
event.preventDefault();
|
|
173
|
+
if (cb.readOnlyPreview) return;
|
|
161
174
|
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
|
162
175
|
if (selectedElementId) {
|
|
163
176
|
const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
|
|
@@ -240,8 +253,10 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
|
|
240
253
|
// the timeline left other selected elements behind. Timeline stays as the
|
|
241
254
|
// fallback for rows with no canvas node (audio, an inactive comp).
|
|
242
255
|
const domSel = cb.domEditSelectionRef.current;
|
|
243
|
-
|
|
256
|
+
const timelineOwnsDelete = timelineOwnsKey(event);
|
|
257
|
+
if (domSel && !timelineOwnsDelete) {
|
|
244
258
|
event.preventDefault();
|
|
259
|
+
if (cb.readOnlyPreview) return;
|
|
245
260
|
// The whole marquee group, not just the primary the ref holds.
|
|
246
261
|
void cb.handleDomEditElementDelete(domSel, { expandGroup: true });
|
|
247
262
|
return;
|
|
@@ -135,7 +135,7 @@ export async function materializeIfDynamic(
|
|
|
135
135
|
void iframe;
|
|
136
136
|
void commitMutation;
|
|
137
137
|
void selection;
|
|
138
|
-
throw new GsapEditBlockedError("source-uneditable");
|
|
138
|
+
throw new GsapEditBlockedError("source-uneditable", "geometry-unresolved-source");
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// ── Drag → GSAP position math ──────────────────────────────────────────────
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { buildStudioSaveFailureProperties } from "../utils/studioSaveDiagnostics";
|
|
4
|
+
import {
|
|
5
|
+
GsapEditBlockedError,
|
|
6
|
+
assertGsapEditPersisted,
|
|
7
|
+
directEditOutcomeForProperties,
|
|
8
|
+
} from "./gsapEditOutcome";
|
|
9
|
+
|
|
10
|
+
function blockDetailFor(error: unknown): unknown {
|
|
11
|
+
return buildStudioSaveFailureProperties({ source: "gsap_commit", error }).block_detail;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Minimal animation that writes `x`, so the editability check actually runs. */
|
|
15
|
+
function animation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
|
16
|
+
return {
|
|
17
|
+
id: "a1",
|
|
18
|
+
targetSelector: "#box",
|
|
19
|
+
method: "to",
|
|
20
|
+
position: 0,
|
|
21
|
+
properties: { x: 100 },
|
|
22
|
+
...overrides,
|
|
23
|
+
} as GsapAnimation;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const POSITION = new Set(["x"]);
|
|
27
|
+
|
|
28
|
+
describe("a blocked GSAP edit names which of the nine causes it hit", () => {
|
|
29
|
+
// The three causes inside assertGsapAnimationDirectlyEditable share one
|
|
30
|
+
// message, so only the detail distinguishes them. Asserted through the
|
|
31
|
+
// exported wrapper because that is the path a real edit takes — and the
|
|
32
|
+
// wrapper used to rebuild the outcome from `reason` alone, dropping it.
|
|
33
|
+
it("reports a runtime-dynamic provenance", () => {
|
|
34
|
+
const outcome = directEditOutcomeForProperties(
|
|
35
|
+
[animation({ provenance: { kind: "runtime-dynamic" } })],
|
|
36
|
+
POSITION,
|
|
37
|
+
);
|
|
38
|
+
expect(outcome).toEqual({
|
|
39
|
+
status: "blocked",
|
|
40
|
+
reason: "source-uneditable",
|
|
41
|
+
detail: "provenance-runtime-dynamic",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reports unresolved keyframes separately from provenance", () => {
|
|
46
|
+
const outcome = directEditOutcomeForProperties(
|
|
47
|
+
[animation({ hasUnresolvedKeyframes: true })],
|
|
48
|
+
POSITION,
|
|
49
|
+
);
|
|
50
|
+
expect(outcome).toEqual({
|
|
51
|
+
status: "blocked",
|
|
52
|
+
reason: "source-uneditable",
|
|
53
|
+
detail: "unresolved-keyframes",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("reports an unresolved selector separately from provenance", () => {
|
|
58
|
+
const outcome = directEditOutcomeForProperties(
|
|
59
|
+
[animation({ hasUnresolvedSelector: true })],
|
|
60
|
+
POSITION,
|
|
61
|
+
);
|
|
62
|
+
expect(outcome).toEqual({
|
|
63
|
+
status: "blocked",
|
|
64
|
+
reason: "source-uneditable",
|
|
65
|
+
detail: "unresolved-selector",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("still routes a helper or loop to unroll, which has its own message", () => {
|
|
70
|
+
const outcome = directEditOutcomeForProperties(
|
|
71
|
+
[animation({ provenance: { kind: "loop" } })],
|
|
72
|
+
POSITION,
|
|
73
|
+
);
|
|
74
|
+
expect(outcome).toEqual({ status: "blocked", reason: "unroll-required", detail: undefined });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("leaves a literal tween editable", () => {
|
|
78
|
+
expect(directEditOutcomeForProperties([animation()], POSITION)).toEqual({
|
|
79
|
+
status: "persisted",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("carries the detail through a returned outcome, not just a thrown one", () => {
|
|
84
|
+
// gsapRuntimeBridge returns blocked outcomes rather than throwing.
|
|
85
|
+
expect(() =>
|
|
86
|
+
assertGsapEditPersisted({
|
|
87
|
+
status: "blocked",
|
|
88
|
+
reason: "source-uneditable",
|
|
89
|
+
detail: "no-position-tween",
|
|
90
|
+
}),
|
|
91
|
+
).toThrow(
|
|
92
|
+
expect.objectContaining({ name: "GsapEditBlockedError", detail: "no-position-tween" }),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("surfaces the detail on the telemetry payload", () => {
|
|
97
|
+
expect(
|
|
98
|
+
blockDetailFor(
|
|
99
|
+
new GsapEditBlockedError("source-uneditable", "live-rotation-no-source-tween"),
|
|
100
|
+
),
|
|
101
|
+
).toBe("live-rotation-no-source-tween");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("omits the detail for a block that has no specific cause", () => {
|
|
105
|
+
expect(blockDetailFor(new GsapEditBlockedError("no-selector"))).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("ignores a detail-shaped field on an error that is not a block", () => {
|
|
109
|
+
// Guards the name check: any error can carry a `detail`, and reporting a
|
|
110
|
+
// save failure's detail as a block cause would be a silent mislabel.
|
|
111
|
+
const impostor = Object.assign(new Error("network down"), { detail: "unresolved-selector" });
|
|
112
|
+
expect(blockDetailFor(impostor)).toBeUndefined();
|
|
113
|
+
expect(blockDetailFor("not an error")).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
});
|