@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
|
@@ -109,46 +109,46 @@ export interface UseDomEditWiringParams {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
// fallow-ignore-next-line complexity
|
|
112
|
-
export function useDomEditWiring({
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
112
|
+
export function useDomEditWiring(params: UseDomEditWiringParams) {
|
|
113
|
+
const {
|
|
114
|
+
projectId,
|
|
115
|
+
activeCompPath,
|
|
116
|
+
domEditSelection,
|
|
117
|
+
domEditSelectionRef,
|
|
118
|
+
domEditGroupSelectionsRef,
|
|
119
|
+
refreshDomEditGroupSelectionsFromPreview,
|
|
120
|
+
previewIframeRef,
|
|
121
|
+
previewIframe,
|
|
122
|
+
captionEditMode,
|
|
123
|
+
refreshKey,
|
|
124
|
+
gsapCacheVersion,
|
|
125
|
+
bumpGsapCache,
|
|
126
|
+
showToast,
|
|
127
|
+
refreshPreviewDocumentVersion,
|
|
128
|
+
syncPreviewHotkeys,
|
|
129
|
+
applyStudioManualEditsToPreviewRef,
|
|
130
|
+
applyDomSelection,
|
|
131
|
+
buildDomSelectionFromTarget,
|
|
132
|
+
openSourceForSelection,
|
|
133
|
+
updateGsapProperty,
|
|
134
|
+
updateGsapMeta,
|
|
135
|
+
deleteGsapAnimation,
|
|
136
|
+
deleteAllForSelector,
|
|
137
|
+
addGsapAnimation,
|
|
138
|
+
addGsapProperty,
|
|
139
|
+
removeGsapProperty,
|
|
140
|
+
updateGsapFromProperty,
|
|
141
|
+
addGsapFromProperty,
|
|
142
|
+
removeGsapFromProperty,
|
|
143
|
+
addKeyframe,
|
|
144
|
+
addKeyframeBatch,
|
|
145
|
+
removeKeyframe,
|
|
146
|
+
moveKeyframe,
|
|
147
|
+
resizeKeyframedTween,
|
|
148
|
+
convertToKeyframes,
|
|
149
|
+
removeAllKeyframes,
|
|
150
|
+
handleDomManualEditsReset,
|
|
151
|
+
} = params;
|
|
152
152
|
// ── Click-to-source navigation ──
|
|
153
153
|
|
|
154
154
|
const onClickToSource = useCallback(
|
|
@@ -12,10 +12,30 @@ import {
|
|
|
12
12
|
readStudioPathOffset,
|
|
13
13
|
readStudioRotation,
|
|
14
14
|
} from "../components/editor/manualEdits";
|
|
15
|
-
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|
15
|
+
import { useDomGeometryCommits, type UseDomGeometryCommitsParams } from "./useDomGeometryCommits";
|
|
16
16
|
|
|
17
17
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
18
18
|
|
|
19
|
+
function mountCommits(
|
|
20
|
+
commitPositionPatchToHtml: UseDomGeometryCommitsParams["commitPositionPatchToHtml"],
|
|
21
|
+
readOnlyPreview = false,
|
|
22
|
+
) {
|
|
23
|
+
let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
|
|
24
|
+
function Probe() {
|
|
25
|
+
commits = useDomGeometryCommits({
|
|
26
|
+
previewIframeRef: { current: null },
|
|
27
|
+
showToast: vi.fn(),
|
|
28
|
+
commitPositionPatchToHtml,
|
|
29
|
+
readOnlyPreview,
|
|
30
|
+
});
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const host = document.createElement("div");
|
|
34
|
+
const root = createRoot(host);
|
|
35
|
+
act(() => root.render(<Probe />));
|
|
36
|
+
return { commits: () => commits!, unmount: () => act(() => root.unmount()) };
|
|
37
|
+
}
|
|
38
|
+
|
|
19
39
|
describe("useDomGeometryCommits rollback", () => {
|
|
20
40
|
it("restores every optimistic geometry mutation when persistence rejects", async () => {
|
|
21
41
|
const element = document.createElement("div");
|
|
@@ -30,7 +50,9 @@ describe("useDomGeometryCommits rollback", () => {
|
|
|
30
50
|
element,
|
|
31
51
|
} as unknown as DomEditSelection;
|
|
32
52
|
const failure = new Error("save failed");
|
|
33
|
-
const commitPositionPatchToHtml = vi
|
|
53
|
+
const commitPositionPatchToHtml = vi
|
|
54
|
+
.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
|
|
55
|
+
.mockRejectedValue(failure);
|
|
34
56
|
let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
|
|
35
57
|
const host = document.createElement("div");
|
|
36
58
|
const root = createRoot(host);
|
|
@@ -40,6 +62,7 @@ describe("useDomGeometryCommits rollback", () => {
|
|
|
40
62
|
previewIframeRef: { current: null },
|
|
41
63
|
showToast: vi.fn(),
|
|
42
64
|
commitPositionPatchToHtml,
|
|
65
|
+
readOnlyPreview: false,
|
|
43
66
|
});
|
|
44
67
|
return null;
|
|
45
68
|
}
|
|
@@ -60,3 +83,63 @@ describe("useDomGeometryCommits rollback", () => {
|
|
|
60
83
|
act(() => root.unmount());
|
|
61
84
|
});
|
|
62
85
|
});
|
|
86
|
+
|
|
87
|
+
describe("useDomGeometryCommits read-only preview", () => {
|
|
88
|
+
const selectionOn = (element: HTMLElement) =>
|
|
89
|
+
({ id: element.id, selector: `#${element.id}`, element }) as unknown as DomEditSelection;
|
|
90
|
+
|
|
91
|
+
it("refuses a manual offset commit: no write, no history entry", async () => {
|
|
92
|
+
const element = document.createElement("div");
|
|
93
|
+
element.id = "ro-offset";
|
|
94
|
+
document.body.append(element);
|
|
95
|
+
applyStudioPathOffset(element, { x: 1, y: 2 });
|
|
96
|
+
const commitPositionPatchToHtml =
|
|
97
|
+
vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
|
|
98
|
+
const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
|
|
99
|
+
await commits().handleDomPathOffsetCommit(selectionOn(element), { x: 99, y: 99 });
|
|
100
|
+
expect(readStudioPathOffset(element)).toEqual({ x: 1, y: 2 });
|
|
101
|
+
expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
|
|
102
|
+
unmount();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("refuses a manual box-size commit: no write, no history entry", async () => {
|
|
106
|
+
const element = document.createElement("div");
|
|
107
|
+
element.id = "ro-size";
|
|
108
|
+
document.body.append(element);
|
|
109
|
+
applyStudioBoxSize(element, { width: 10, height: 20 });
|
|
110
|
+
const commitPositionPatchToHtml =
|
|
111
|
+
vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
|
|
112
|
+
const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
|
|
113
|
+
await commits().handleDomBoxSizeCommit(selectionOn(element), { width: 999, height: 999 });
|
|
114
|
+
expect(readStudioBoxSize(element)).toEqual({ width: 10, height: 20 });
|
|
115
|
+
expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
|
|
116
|
+
unmount();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("refuses a manual rotation commit: no write, no history entry", async () => {
|
|
120
|
+
const element = document.createElement("div");
|
|
121
|
+
element.id = "ro-rotate";
|
|
122
|
+
document.body.append(element);
|
|
123
|
+
applyStudioRotation(element, { angle: 5 });
|
|
124
|
+
const commitPositionPatchToHtml =
|
|
125
|
+
vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
|
|
126
|
+
const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
|
|
127
|
+
await commits().handleDomRotationCommit(selectionOn(element), { angle: 350 });
|
|
128
|
+
expect(readStudioRotation(element)).toEqual({ angle: 5 });
|
|
129
|
+
expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
|
|
130
|
+
unmount();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("still commits an offset with the flag off", async () => {
|
|
134
|
+
const element = document.createElement("div");
|
|
135
|
+
element.id = "rw-offset";
|
|
136
|
+
document.body.append(element);
|
|
137
|
+
const commitPositionPatchToHtml = vi
|
|
138
|
+
.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
|
|
139
|
+
.mockResolvedValue(undefined);
|
|
140
|
+
const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
|
|
141
|
+
await commits().handleDomPathOffsetCommit(selectionOn(element), { x: 5, y: 6 });
|
|
142
|
+
expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(1);
|
|
143
|
+
unmount();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -28,9 +28,20 @@ import { isElementGsapTargeted } from "./gsapTargetCache";
|
|
|
28
28
|
const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
|
|
29
29
|
"This element is GSAP-animated — dragging via CSS would corrupt keyframes";
|
|
30
30
|
|
|
31
|
+
function rejectGsapCssFallback(
|
|
32
|
+
selection: DomEditSelection,
|
|
33
|
+
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
|
|
34
|
+
showToast: (message: string, tone?: "error" | "info") => void,
|
|
35
|
+
): Promise<never> | null {
|
|
36
|
+
if (!isElementGsapTargeted(previewIframeRef.current, selection.element)) return null;
|
|
37
|
+
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
38
|
+
showToast(error.message, "error");
|
|
39
|
+
return Promise.reject(error);
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
// ── Hook ──
|
|
32
43
|
|
|
33
|
-
interface UseDomGeometryCommitsParams {
|
|
44
|
+
export interface UseDomGeometryCommitsParams {
|
|
34
45
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
35
46
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
36
47
|
commitPositionPatchToHtml: (
|
|
@@ -38,25 +49,24 @@ interface UseDomGeometryCommitsParams {
|
|
|
38
49
|
patches: PatchOperation[],
|
|
39
50
|
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
|
|
40
51
|
) => Promise<void>;
|
|
52
|
+
readOnlyPreview: boolean;
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
export function useDomGeometryCommits({
|
|
44
56
|
previewIframeRef,
|
|
45
57
|
showToast,
|
|
46
58
|
commitPositionPatchToHtml,
|
|
59
|
+
readOnlyPreview,
|
|
47
60
|
}: UseDomGeometryCommitsParams) {
|
|
48
61
|
const handleDomPathOffsetCommit = useCallback(
|
|
49
62
|
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
63
|
+
if (readOnlyPreview) return Promise.resolve();
|
|
50
64
|
// ponytail: GSAP-targeted elements are blocked (no SDK position-in-script op); CSS-path
|
|
51
65
|
// elements fall through to commitPositionPatchToHtml → persistDomEditOperations →
|
|
52
66
|
// onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done).
|
|
53
67
|
// Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR.
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
57
|
-
showToast(error.message, "error");
|
|
58
|
-
return Promise.reject(error);
|
|
59
|
-
}
|
|
68
|
+
const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
|
|
69
|
+
if (gsapFallback) return gsapFallback;
|
|
60
70
|
const before = captureStudioPathOffset(selection.element);
|
|
61
71
|
applyStudioPathOffset(selection.element, next);
|
|
62
72
|
return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
|
@@ -67,7 +77,7 @@ export function useDomGeometryCommits({
|
|
|
67
77
|
throw error;
|
|
68
78
|
});
|
|
69
79
|
},
|
|
70
|
-
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
80
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
|
|
71
81
|
);
|
|
72
82
|
|
|
73
83
|
const handleDomBoxSizeCommit = useCallback(
|
|
@@ -76,11 +86,9 @@ export function useDomGeometryCommits({
|
|
|
76
86
|
next: { width: number; height: number },
|
|
77
87
|
offset?: { x: number; y: number },
|
|
78
88
|
) => {
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return Promise.reject(error);
|
|
83
|
-
}
|
|
89
|
+
if (readOnlyPreview) return Promise.resolve();
|
|
90
|
+
const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
|
|
91
|
+
if (gsapFallback) return gsapFallback;
|
|
84
92
|
const beforeSize = captureStudioBoxSize(selection.element);
|
|
85
93
|
const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
|
|
86
94
|
applyStudioBoxSize(selection.element, next);
|
|
@@ -104,16 +112,14 @@ export function useDomGeometryCommits({
|
|
|
104
112
|
throw error;
|
|
105
113
|
});
|
|
106
114
|
},
|
|
107
|
-
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
115
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
|
|
108
116
|
);
|
|
109
117
|
|
|
110
118
|
const handleDomRotationCommit = useCallback(
|
|
111
119
|
(selection: DomEditSelection, next: { angle: number }) => {
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return Promise.reject(error);
|
|
116
|
-
}
|
|
120
|
+
if (readOnlyPreview) return Promise.resolve();
|
|
121
|
+
const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
|
|
122
|
+
if (gsapFallback) return gsapFallback;
|
|
117
123
|
const before = captureStudioRotation(selection.element);
|
|
118
124
|
applyStudioRotation(selection.element, next);
|
|
119
125
|
return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
|
|
@@ -124,7 +130,7 @@ export function useDomGeometryCommits({
|
|
|
124
130
|
throw error;
|
|
125
131
|
});
|
|
126
132
|
},
|
|
127
|
-
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
133
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
|
|
128
134
|
);
|
|
129
135
|
|
|
130
136
|
const handleDomManualEditsReset = useCallback(
|
|
@@ -107,6 +107,7 @@ describe("useGestureCommit", () => {
|
|
|
107
107
|
previewIframeRef: { current: iframe },
|
|
108
108
|
showToast: vi.fn(),
|
|
109
109
|
isGestureRecordingRef: { current: false },
|
|
110
|
+
readOnlyPreview: false,
|
|
110
111
|
});
|
|
111
112
|
return null;
|
|
112
113
|
}
|
|
@@ -127,4 +128,32 @@ describe("useGestureCommit", () => {
|
|
|
127
128
|
expect(options[1]).toEqual(expect.objectContaining({ coalesceMs: Infinity, softReload: true }));
|
|
128
129
|
expect(options[1]).not.toHaveProperty("skipReload");
|
|
129
130
|
});
|
|
131
|
+
|
|
132
|
+
it("does not start a recording while the preview is read-only", () => {
|
|
133
|
+
const element = document.createElement("div");
|
|
134
|
+
const commitMutation = vi.fn(async () => {});
|
|
135
|
+
const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
|
|
136
|
+
function Probe() {
|
|
137
|
+
captured.hook = useGestureCommit({
|
|
138
|
+
domEditSessionRef: {
|
|
139
|
+
current: {
|
|
140
|
+
domEditSelection: makeSelection(element),
|
|
141
|
+
selectedGsapAnimations: [],
|
|
142
|
+
commitMutation,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
previewIframeRef: { current: document.createElement("iframe") },
|
|
146
|
+
showToast: vi.fn(),
|
|
147
|
+
isGestureRecordingRef: { current: false },
|
|
148
|
+
readOnlyPreview: true,
|
|
149
|
+
});
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const root = mountReactHarness(<Probe />);
|
|
153
|
+
cleanup = () => act(() => root.unmount());
|
|
154
|
+
|
|
155
|
+
act(() => captured.hook?.handleToggleRecording());
|
|
156
|
+
expect(gestureRecording.startRecording).not.toHaveBeenCalled();
|
|
157
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
130
159
|
});
|
|
@@ -87,6 +87,7 @@ interface UseGestureCommitParams {
|
|
|
87
87
|
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
88
88
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
89
89
|
isGestureRecordingRef: React.MutableRefObject<boolean>;
|
|
90
|
+
readOnlyPreview: boolean;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
export interface UseGestureCommitResult {
|
|
@@ -101,6 +102,7 @@ export function useGestureCommit({
|
|
|
101
102
|
previewIframeRef,
|
|
102
103
|
showToast,
|
|
103
104
|
isGestureRecordingRef,
|
|
105
|
+
readOnlyPreview,
|
|
104
106
|
}: UseGestureCommitParams): UseGestureCommitResult {
|
|
105
107
|
const gestureRecording = useGestureRecording();
|
|
106
108
|
const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
|
|
@@ -115,6 +117,20 @@ export function useGestureCommit({
|
|
|
115
117
|
// Unmount: clear auto-stop interval
|
|
116
118
|
useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
|
|
117
119
|
|
|
120
|
+
const cancelRecording = useCallback(() => {
|
|
121
|
+
clearInterval(recordingAutoStopRef.current);
|
|
122
|
+
gestureRecording.stopRecording();
|
|
123
|
+
gestureRecording.clearSamples();
|
|
124
|
+
gestureStateRef.current = "idle";
|
|
125
|
+
isGestureRecordingRef.current = false;
|
|
126
|
+
capturedSelectionRef.current = null;
|
|
127
|
+
setGestureState("idle");
|
|
128
|
+
}, [gestureRecording, isGestureRecordingRef]);
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (readOnlyPreview && gestureStateRef.current === "recording") cancelRecording();
|
|
132
|
+
}, [cancelRecording, readOnlyPreview]);
|
|
133
|
+
|
|
118
134
|
// fallow-ignore-next-line complexity
|
|
119
135
|
const stopAndCommitRecording = useCallback(async () => {
|
|
120
136
|
clearInterval(recordingAutoStopRef.current);
|
|
@@ -335,9 +351,14 @@ export function useGestureCommit({
|
|
|
335
351
|
// fallow-ignore-next-line complexity
|
|
336
352
|
const handleToggleRecording = useCallback(() => {
|
|
337
353
|
if (gestureStateRef.current === "recording") {
|
|
354
|
+
if (readOnlyPreview) {
|
|
355
|
+
cancelRecording();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
338
358
|
void stopAndCommitRecording();
|
|
339
359
|
return;
|
|
340
360
|
}
|
|
361
|
+
if (readOnlyPreview) return;
|
|
341
362
|
const sel = domEditSessionRef.current.domEditSelection;
|
|
342
363
|
if (!sel) {
|
|
343
364
|
showToast("Select an element first", "error");
|
|
@@ -373,9 +394,11 @@ export function useGestureCommit({
|
|
|
373
394
|
gestureRecording,
|
|
374
395
|
showToast,
|
|
375
396
|
stopAndCommitRecording,
|
|
397
|
+
cancelRecording,
|
|
376
398
|
previewIframeRef,
|
|
377
399
|
domEditSessionRef,
|
|
378
400
|
isGestureRecordingRef,
|
|
401
|
+
readOnlyPreview,
|
|
379
402
|
]);
|
|
380
403
|
|
|
381
404
|
return { gestureState, gestureRecording, handleToggleRecording };
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { PreviewReadOnlyProvider } from "../components/editor/previewReadOnlyContext";
|
|
6
7
|
import { useInlineTextEdit, type InlineTextEditControls } from "./useInlineTextEdit";
|
|
7
8
|
|
|
8
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -20,7 +21,7 @@ function heading(text = "Motion Playground"): HTMLElement {
|
|
|
20
21
|
return element;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
function mount(onCommit = vi.fn(), onPause = vi.fn()) {
|
|
24
|
+
function mount(onCommit = vi.fn(), onPause = vi.fn(), readOnly = false) {
|
|
24
25
|
const controls: { current: InlineTextEditControls | null } = { current: null };
|
|
25
26
|
function Probe() {
|
|
26
27
|
controls.current = useInlineTextEdit({ onCommit, onPause });
|
|
@@ -29,7 +30,13 @@ function mount(onCommit = vi.fn(), onPause = vi.fn()) {
|
|
|
29
30
|
const host = document.createElement("div");
|
|
30
31
|
document.body.append(host);
|
|
31
32
|
const root = createRoot(host);
|
|
32
|
-
act(() =>
|
|
33
|
+
act(() =>
|
|
34
|
+
root.render(
|
|
35
|
+
<PreviewReadOnlyProvider readOnly={readOnly}>
|
|
36
|
+
<Probe />
|
|
37
|
+
</PreviewReadOnlyProvider>,
|
|
38
|
+
),
|
|
39
|
+
);
|
|
33
40
|
return { controls: () => controls.current!, root, onCommit, onPause };
|
|
34
41
|
}
|
|
35
42
|
|
|
@@ -552,4 +559,18 @@ describe("useInlineTextEdit with styled runs", () => {
|
|
|
552
559
|
expect(insertText).toHaveBeenCalledWith("insertText", false, "plain words");
|
|
553
560
|
act(() => root.unmount());
|
|
554
561
|
});
|
|
562
|
+
|
|
563
|
+
it("refuses to open an edit while the preview is read-only", () => {
|
|
564
|
+
const element = heading();
|
|
565
|
+
const { controls, root, onCommit, onPause } = mount(vi.fn(), vi.fn(), true);
|
|
566
|
+
let opened = true;
|
|
567
|
+
act(() => {
|
|
568
|
+
opened = controls().start(element);
|
|
569
|
+
});
|
|
570
|
+
expect(opened).toBe(false);
|
|
571
|
+
expect(element.hasAttribute("contenteditable")).toBe(false);
|
|
572
|
+
expect(onPause).not.toHaveBeenCalled();
|
|
573
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
574
|
+
act(() => root.unmount());
|
|
575
|
+
});
|
|
555
576
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
|
|
3
|
+
import { usePreviewReadOnly } from "../components/editor/previewReadOnlyContext";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Editing an element's text where it sits, in the composition itself.
|
|
@@ -89,6 +90,7 @@ export function useInlineTextEdit({
|
|
|
89
90
|
/** Stop playback, so the element is not animating under the caret. */
|
|
90
91
|
onPause?: () => void;
|
|
91
92
|
}): InlineTextEditControls {
|
|
93
|
+
const readOnly = usePreviewReadOnly();
|
|
92
94
|
const [session, setSession] = useState<InlineTextEditSession | null>(null);
|
|
93
95
|
// The teardown reads this rather than the state, so an exit path that runs
|
|
94
96
|
// before React re-renders still sees the element it has to clean up.
|
|
@@ -123,7 +125,7 @@ export function useInlineTextEdit({
|
|
|
123
125
|
|
|
124
126
|
const start = useCallback(
|
|
125
127
|
(element: HTMLElement, caretAt?: { x: number; y: number }): boolean => {
|
|
126
|
-
if (openRef.current) return false;
|
|
128
|
+
if (openRef.current || readOnly) return false;
|
|
127
129
|
|
|
128
130
|
const open = {
|
|
129
131
|
element,
|
|
@@ -156,7 +158,7 @@ export function useInlineTextEdit({
|
|
|
156
158
|
framesRef.current = raf ?? null;
|
|
157
159
|
return true;
|
|
158
160
|
},
|
|
159
|
-
[onPause],
|
|
161
|
+
[onPause, readOnly],
|
|
160
162
|
);
|
|
161
163
|
|
|
162
164
|
const commit = useCallback(() => {
|
|
@@ -179,6 +181,13 @@ export function useInlineTextEdit({
|
|
|
179
181
|
teardown();
|
|
180
182
|
}, [teardown]);
|
|
181
183
|
|
|
184
|
+
// Read-only can be enabled while an edit is already open. Close that
|
|
185
|
+
// session as a cancellation so the preview cannot keep committing through
|
|
186
|
+
// the now-disabled editing surface.
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (readOnly && openRef.current) cancel();
|
|
189
|
+
}, [cancel, readOnly]);
|
|
190
|
+
|
|
182
191
|
// The keys belong to the element, not to the document: the element lives in
|
|
183
192
|
// the preview's own document, so a listener on Studio's would never see them.
|
|
184
193
|
useEffect(() => {
|
|
@@ -103,7 +103,7 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
103
103
|
expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
it("measures the insert row against
|
|
106
|
+
it("measures the insert row against every manifest timeline row", async () => {
|
|
107
107
|
const clip = (id: string, track: number): TimelineElement => ({
|
|
108
108
|
id,
|
|
109
109
|
key: id,
|
|
@@ -123,7 +123,6 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
123
123
|
}),
|
|
124
124
|
"</main>",
|
|
125
125
|
].join("\n");
|
|
126
|
-
usePlayerStore.getState().setTopLevelIds(new Set(["a", "b"]));
|
|
127
126
|
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
128
127
|
const getDrop = renderDropHook(source, writeProjectFile, [
|
|
129
128
|
clip("a", 0),
|
|
@@ -136,8 +135,8 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
136
135
|
});
|
|
137
136
|
|
|
138
137
|
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
|
|
139
|
-
expect(written).toContain('id="b" data-start="0" data-track-index="
|
|
140
|
-
expect(written).toContain('id="hidden" data-start="0" data-track-index="
|
|
138
|
+
expect(written).toContain('id="b" data-start="0" data-track-index="3"');
|
|
139
|
+
expect(written).toContain('id="hidden" data-start="0" data-track-index="2"');
|
|
141
140
|
expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
|
|
142
141
|
});
|
|
143
142
|
|
|
@@ -25,8 +25,6 @@ import { commitTimelineCompositionInsertion } from "../utils/timelineComposition
|
|
|
25
25
|
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
26
26
|
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
27
27
|
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
|
|
28
|
-
import { selectTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
29
|
-
import { usePlayerStore } from "../player/store/playerStore";
|
|
30
28
|
|
|
31
29
|
/** The first uploaded file opens the new track (if asked); the rest land on the lane it landed on. */
|
|
32
30
|
function fileDropPlacement(
|
|
@@ -127,10 +125,7 @@ export function useTimelineAssetDropOps({
|
|
|
127
125
|
const { source: sourceWithRoom, track } = resolveDropTrack({
|
|
128
126
|
source: originalContent,
|
|
129
127
|
// insertRow counts the rows the timeline shows, so plan against those.
|
|
130
|
-
elements:
|
|
131
|
-
relevantElements,
|
|
132
|
-
usePlayerStore.getState().topLevelIds,
|
|
133
|
-
),
|
|
128
|
+
elements: relevantElements,
|
|
134
129
|
placement,
|
|
135
130
|
dropped: {
|
|
136
131
|
id: newId,
|
|
@@ -1561,21 +1561,24 @@ describe("useTimelineEditing: canEdit gate", () => {
|
|
|
1561
1561
|
unmount();
|
|
1562
1562
|
});
|
|
1563
1563
|
|
|
1564
|
-
it("resolves toggle-track-hidden against the
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1564
|
+
it("resolves toggle-track-hidden against the store-owned timeline rows", async () => {
|
|
1565
|
+
// The visibility hook reads the single store-owned row source. The editing
|
|
1566
|
+
// guard must receive that same source so a blocked row cannot fall through
|
|
1567
|
+
// to the write path.
|
|
1568
1568
|
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
1569
1569
|
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
1570
1570
|
usePlayerStore.getState().setElements([clip]);
|
|
1571
1571
|
const showToast = vi.fn();
|
|
1572
1572
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
1573
|
+
const canEdit = vi.fn((element: TimelineElement) =>
|
|
1574
|
+
element.id === "clip" ? { blocked: true as const, reason: "Reserved by an agent" } : true,
|
|
1575
|
+
);
|
|
1573
1576
|
let hook: ReturnType<typeof useTimelineEditing> | null = null;
|
|
1574
1577
|
function Harness() {
|
|
1575
1578
|
hook = useTimelineEditing({
|
|
1576
1579
|
projectId: "p1",
|
|
1577
1580
|
activeCompPath: "index.html",
|
|
1578
|
-
timelineElements: [],
|
|
1581
|
+
timelineElements: [clip],
|
|
1579
1582
|
showToast,
|
|
1580
1583
|
writeProjectFile,
|
|
1581
1584
|
recordEdit: vi.fn(),
|
|
@@ -1583,8 +1586,7 @@ describe("useTimelineEditing: canEdit gate", () => {
|
|
|
1583
1586
|
previewIframeRef: { current: iframe },
|
|
1584
1587
|
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
1585
1588
|
uploadProjectFiles: vi.fn(),
|
|
1586
|
-
canEdit
|
|
1587
|
-
element.id === "clip" ? { blocked: true, reason: "Reserved by an agent" } : true,
|
|
1589
|
+
canEdit,
|
|
1588
1590
|
});
|
|
1589
1591
|
return null;
|
|
1590
1592
|
}
|
|
@@ -1596,6 +1598,7 @@ describe("useTimelineEditing: canEdit gate", () => {
|
|
|
1596
1598
|
await flushAsyncWork();
|
|
1597
1599
|
});
|
|
1598
1600
|
|
|
1601
|
+
expect(canEdit).toHaveBeenCalledWith(clip);
|
|
1599
1602
|
expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
|
|
1600
1603
|
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
1601
1604
|
unmount();
|
|
@@ -25,7 +25,6 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
|
25
25
|
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
26
26
|
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
|
27
27
|
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
28
|
-
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
29
28
|
import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
|
|
30
29
|
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
|
|
31
30
|
import {
|
|
@@ -77,10 +76,6 @@ export function useTimelineEditing({
|
|
|
77
76
|
const checkEditable = useTimelineEditGate(canEdit, showToast);
|
|
78
77
|
const checkEditableRef = useRef(checkEditable);
|
|
79
78
|
checkEditableRef.current = checkEditable;
|
|
80
|
-
// Same expanded-row source the hide handlers themselves resolve against
|
|
81
|
-
// (timelineTrackVisibility.ts) — a virtual sub-comp child's track/key
|
|
82
|
-
// only exists here, not in the raw store list canEdit would otherwise miss.
|
|
83
|
-
const timelineRowElements = useTimelineRowElements();
|
|
84
79
|
const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
|
|
85
80
|
// Refuses (no call, no write, no history entry) when any target is
|
|
86
81
|
// blocked; otherwise runs fn as before. Cached by fn identity — like
|
|
@@ -514,14 +509,14 @@ export function useTimelineEditing({
|
|
|
514
509
|
handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
|
|
515
510
|
handleToggleTrackHidden: track(
|
|
516
511
|
guard(
|
|
517
|
-
(trackIndex) =>
|
|
512
|
+
(trackIndex) => timelineElements.filter((el) => el.track === trackIndex),
|
|
518
513
|
handleToggleTrackHidden,
|
|
519
514
|
),
|
|
520
515
|
),
|
|
521
516
|
handleToggleElementHidden: track(
|
|
522
517
|
guard((elementKey) => {
|
|
523
518
|
const keys = new Set(Array.isArray(elementKey) ? elementKey : [elementKey]);
|
|
524
|
-
return
|
|
519
|
+
return timelineElements.filter((el) => keys.has(el.key ?? el.id));
|
|
525
520
|
}, handleToggleElementHidden),
|
|
526
521
|
),
|
|
527
522
|
handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),
|