@hyperframes/studio 0.8.55 → 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-BjAGc2ID.js → hyperframes-player-k8LzMtXX.js} +1 -1
- package/dist/assets/{index-TcBZyzNA.js → index-BlkJ_Axe.js} +191 -191
- package/dist/assets/{index-DaNpNFJC.js → index-CxR7F2YQ.js} +1 -1
- package/dist/assets/{index-BEIRytvV.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 +3111 -2998
- 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/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/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;
|
|
@@ -196,6 +196,7 @@ function renderGestureCommit(
|
|
|
196
196
|
previewIframeRef: { current: document.createElement("iframe") },
|
|
197
197
|
showToast: vi.fn(),
|
|
198
198
|
isGestureRecordingRef: { current: false },
|
|
199
|
+
readOnlyPreview: false,
|
|
199
200
|
});
|
|
200
201
|
toggle = handleToggleRecording;
|
|
201
202
|
return null;
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
|
|
9
9
|
import { useCallback } from "react";
|
|
10
10
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
11
|
-
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
12
11
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
13
12
|
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
14
13
|
import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
|
|
@@ -286,7 +285,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
286
285
|
groupId: string,
|
|
287
286
|
groupLabel?: string,
|
|
288
287
|
) => Promise<void> {
|
|
289
|
-
const
|
|
288
|
+
const timelineElements = usePlayerStore((state) => state.elements);
|
|
290
289
|
return useCallback(
|
|
291
290
|
async (clipIds: readonly string[], groupId: string, groupLabel?: string) => {
|
|
292
291
|
if (isRecordingRef?.current) {
|
|
@@ -299,7 +298,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
299
298
|
// timeline's group-pointer button) name clips the way the document does,
|
|
300
299
|
// because that is the only space `resolveAudioGroups` reads back.
|
|
301
300
|
const wanted = new Set(clipIds);
|
|
302
|
-
const elements =
|
|
301
|
+
const elements = timelineElements.filter((item) => {
|
|
303
302
|
const domId = runtimeAudioId(item);
|
|
304
303
|
return domId !== null && wanted.has(domId);
|
|
305
304
|
});
|
|
@@ -340,7 +339,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
340
339
|
},
|
|
341
340
|
[
|
|
342
341
|
activeCompPath,
|
|
343
|
-
|
|
342
|
+
timelineElements,
|
|
344
343
|
previewIframeRef,
|
|
345
344
|
writeProjectFile,
|
|
346
345
|
recordEdit,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
3
3
|
import { reseekPreviewAtTime } from "../player/hooks/timelineSyncHydration";
|
|
4
|
-
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
5
4
|
import { applySoftReloadFinalization } from "../utils/gsapSoftReload";
|
|
6
5
|
import {
|
|
7
6
|
timelineTrackOrder,
|
|
@@ -295,7 +294,7 @@ export function useTimelineTrackVisibilityEditing({
|
|
|
295
294
|
// virtual sub-comp children carry their own (display.track + idx) track numbers,
|
|
296
295
|
// so filtering the raw store list by a virtual track number would hide the wrong
|
|
297
296
|
// outer-scene sibling sharing that index.
|
|
298
|
-
const
|
|
297
|
+
const timelineElements = usePlayerStore((state) => state.elements);
|
|
299
298
|
return useCallback(
|
|
300
299
|
async (track: number, hidden: boolean, displayNumber?: number | null) => {
|
|
301
300
|
if (isRecordingRef?.current) {
|
|
@@ -308,7 +307,7 @@ export function useTimelineTrackVisibilityEditing({
|
|
|
308
307
|
await toggleTimelineTrackHidden({
|
|
309
308
|
projectId: pid,
|
|
310
309
|
activeCompPath,
|
|
311
|
-
timelineElements
|
|
310
|
+
timelineElements,
|
|
312
311
|
track,
|
|
313
312
|
hidden,
|
|
314
313
|
displayNumber,
|
|
@@ -327,7 +326,7 @@ export function useTimelineTrackVisibilityEditing({
|
|
|
327
326
|
},
|
|
328
327
|
[
|
|
329
328
|
activeCompPath,
|
|
330
|
-
|
|
329
|
+
timelineElements,
|
|
331
330
|
previewIframeRef,
|
|
332
331
|
writeProjectFile,
|
|
333
332
|
recordEdit,
|
|
@@ -354,7 +353,7 @@ export function useTimelineElementVisibilityEditing({
|
|
|
354
353
|
elementKey: string | readonly string[],
|
|
355
354
|
hidden: boolean,
|
|
356
355
|
) => Promise<void> {
|
|
357
|
-
const
|
|
356
|
+
const timelineElements = usePlayerStore((state) => state.elements);
|
|
358
357
|
return useCallback(
|
|
359
358
|
async (elementKey: string | readonly string[], hidden: boolean) => {
|
|
360
359
|
if (isRecordingRef?.current) {
|
|
@@ -364,7 +363,7 @@ export function useTimelineElementVisibilityEditing({
|
|
|
364
363
|
const pid = projectIdRef.current;
|
|
365
364
|
if (!pid) return;
|
|
366
365
|
const keys = typeof elementKey === "string" ? [elementKey] : elementKey;
|
|
367
|
-
if (!
|
|
366
|
+
if (!timelineElements.some((item) => keys.includes(item.key ?? item.id))) {
|
|
368
367
|
showToast("This element is inside a sub-composition and has no timeline row to hide.");
|
|
369
368
|
return;
|
|
370
369
|
}
|
|
@@ -372,7 +371,7 @@ export function useTimelineElementVisibilityEditing({
|
|
|
372
371
|
await toggleTimelineElementHidden({
|
|
373
372
|
projectId: pid,
|
|
374
373
|
activeCompPath,
|
|
375
|
-
timelineElements
|
|
374
|
+
timelineElements,
|
|
376
375
|
elementKey,
|
|
377
376
|
hidden,
|
|
378
377
|
previewIframe: previewIframeRef.current,
|
|
@@ -390,7 +389,7 @@ export function useTimelineElementVisibilityEditing({
|
|
|
390
389
|
},
|
|
391
390
|
[
|
|
392
391
|
activeCompPath,
|
|
393
|
-
|
|
392
|
+
timelineElements,
|
|
394
393
|
previewIframeRef,
|
|
395
394
|
writeProjectFile,
|
|
396
395
|
recordEdit,
|