@hyperframes/studio 0.8.55 → 0.8.57
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-c23wnIfc.js +464 -0
- package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
- package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
- package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
- package/dist/index.d.ts +111 -17
- package/dist/index.html +2 -2
- package/dist/index.js +21534 -20683
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +18 -1
- package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
- package/src/components/EditorShell.selectionSync.test.tsx +57 -67
- package/src/components/EditorShell.tsx +62 -51
- package/src/components/EditorShellTestMocks.ts +46 -0
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +19 -55
- package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
- package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- 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 +26 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- 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/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- 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/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +40 -4
- package/src/components/nle/NLEPreview.test.ts +66 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
- package/src/components/nle/PreviewOverlays.tsx +19 -24
- package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/TimelinePane.tsx +5 -1
- package/src/components/nle/previewReadOnly.test.tsx +129 -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/useCompositionDimensions.test.tsx +38 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- 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/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- 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/useSdkSession.lifecycle.test.tsx +80 -0
- package/src/hooks/useSdkSession.ts +46 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- 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/index.ts +12 -0
- package/src/player/components/Player.test.ts +141 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -573
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineClip.test.tsx +52 -0
- package/src/player/components/TimelineClip.tsx +13 -2
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineMotionStyles.test.ts +3 -3
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- 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/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/components/useTimelineTrackDerivations.ts +2 -2
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
- package/src/player/hooks/timelineSyncHydration.ts +0 -23
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
- 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/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
- package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
- package/src/player/hooks/useTimelineRowElements.ts +0 -17
|
@@ -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(
|
|
@@ -5,6 +5,12 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { useFileTree } from "./useFileTree";
|
|
7
7
|
|
|
8
|
+
vi.mock("../components/feedback/projectProvenance", () => ({
|
|
9
|
+
captureProjectProvenance: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { captureProjectProvenance } from "../components/feedback/projectProvenance";
|
|
13
|
+
|
|
8
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
15
|
|
|
10
16
|
let root: Root | null = null;
|
|
@@ -52,6 +58,56 @@ async function renderHarness(projectId: string): Promise<{ current: Handle | nul
|
|
|
52
58
|
return handleRef;
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
describe("useFileTree unresolved project", () => {
|
|
62
|
+
const provenance = vi.mocked(captureProjectProvenance);
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
provenance.mockClear();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** Render the tree for `projectId` against one canned response. */
|
|
69
|
+
async function renderWithResponse(
|
|
70
|
+
projectId: string,
|
|
71
|
+
build: () => Response,
|
|
72
|
+
): Promise<{ current: Handle | null }> {
|
|
73
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => build());
|
|
74
|
+
const handleRef = await renderHarness(projectId);
|
|
75
|
+
fetchSpy.mockRestore();
|
|
76
|
+
return handleRef;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const notFound = () =>
|
|
80
|
+
new Response(JSON.stringify({ error: "not found" }), {
|
|
81
|
+
status: 404,
|
|
82
|
+
headers: { "content-type": "application/json" },
|
|
83
|
+
});
|
|
84
|
+
const emptyListing = () =>
|
|
85
|
+
new Response(JSON.stringify({ files: [], compositions: [] }), { status: 200 });
|
|
86
|
+
|
|
87
|
+
// An unresolvable project answers 404 with a JSON body. Without an `r.ok`
|
|
88
|
+
// check that parsed cleanly and took the SUCCESS branch, recording an empty
|
|
89
|
+
// tree and an empty provenance snapshot for a project that was never read —
|
|
90
|
+
// so a bug report filed from that tab described a project that does not
|
|
91
|
+
// exist. The tree itself stays empty either way; the provenance lie is the
|
|
92
|
+
// part that leaves the browser.
|
|
93
|
+
it("does not snapshot provenance for a project the server could not resolve", async () => {
|
|
94
|
+
const handleRef = await renderWithResponse("gone-project", notFound);
|
|
95
|
+
|
|
96
|
+
expect(handleRef.current?.compositions).toEqual([]);
|
|
97
|
+
expect(provenance).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// The counterpart, and the reason the check above cannot be collapsed into
|
|
101
|
+
// "skip provenance whenever the tree is empty": a project with no
|
|
102
|
+
// compositions yet is a real, successful read and must still be snapshotted.
|
|
103
|
+
it("still snapshots provenance when a resolved project has no compositions", async () => {
|
|
104
|
+
const handleRef = await renderWithResponse("empty-project", emptyListing);
|
|
105
|
+
|
|
106
|
+
expect(handleRef.current?.compositions).toEqual([]);
|
|
107
|
+
expect(provenance).toHaveBeenCalledWith("empty-project", [], []);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
55
111
|
describe("useFileTree.refreshFileTree", () => {
|
|
56
112
|
it("updates compositions, not just the raw file list, on refresh", async () => {
|
|
57
113
|
let call = 0;
|
package/src/hooks/useFileTree.ts
CHANGED
|
@@ -39,7 +39,14 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
|
|
|
39
39
|
let cancelled = false;
|
|
40
40
|
setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
|
|
41
41
|
fetch(buildProjectApiPath(projectId))
|
|
42
|
-
|
|
42
|
+
// An unresolvable project answers 404 with a JSON body, so without this
|
|
43
|
+
// the error path parsed cleanly and the success branch below recorded an
|
|
44
|
+
// empty tree — and an empty provenance snapshot — for a project that was
|
|
45
|
+
// never read. Throwing hands it to the catch instead.
|
|
46
|
+
.then((r) => {
|
|
47
|
+
if (!r.ok) throw new Error(`tree fetch failed: ${r.status}`);
|
|
48
|
+
return r.json();
|
|
49
|
+
})
|
|
43
50
|
.then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
|
|
44
51
|
if (cancelled) return;
|
|
45
52
|
setFetched({
|
|
@@ -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(() => {
|
|
@@ -147,6 +147,86 @@ describe("useSdkSession ownership", () => {
|
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
describe("useSdkSession unreachable project", () => {
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
openComposition.mockReset();
|
|
153
|
+
trackMock.mockClear();
|
|
154
|
+
class FakeEventSource {
|
|
155
|
+
addEventListener(): void {}
|
|
156
|
+
close(): void {}
|
|
157
|
+
}
|
|
158
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterEach(() => {
|
|
162
|
+
vi.unstubAllGlobals();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function probeHandle(projectId: string) {
|
|
166
|
+
const captured: { handle: SdkSessionHandle | null } = { handle: null };
|
|
167
|
+
function HandleProbe() {
|
|
168
|
+
captured.handle = useSdkSession(projectId, "index.html");
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return { captured, HandleProbe };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const failedRead = (status: number) => async () =>
|
|
175
|
+
({ ok: false, status, json: async () => ({}) }) as Response;
|
|
176
|
+
|
|
177
|
+
/** Render the hook for `projectId` against a stubbed read and let it settle. */
|
|
178
|
+
async function readOutcome(
|
|
179
|
+
projectId: string,
|
|
180
|
+
read: () => Promise<Response>,
|
|
181
|
+
): Promise<{ captured: { handle: SdkSessionHandle | null }; unmount: () => Promise<void> }> {
|
|
182
|
+
vi.stubGlobal("fetch", vi.fn(read));
|
|
183
|
+
const { captured, HandleProbe } = probeHandle(projectId);
|
|
184
|
+
const root = createRoot(document.createElement("div"));
|
|
185
|
+
await act(async () => root.render(<HandleProbe />));
|
|
186
|
+
await flushAsyncEffects();
|
|
187
|
+
return { captured, unmount: () => act(async () => root.unmount()) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A 404 on the composition read is the one failure that says something about
|
|
191
|
+
// the PROJECT rather than the request: under the CLI host it means this
|
|
192
|
+
// Studio serves a different one. Every edit then fails silently, so the UI
|
|
193
|
+
// needs the id to explain that.
|
|
194
|
+
it("names the project when the read is a 404", async () => {
|
|
195
|
+
const { captured, unmount } = await readOutcome("gone-project", failedRead(404));
|
|
196
|
+
|
|
197
|
+
expect(captured.handle?.unreachableProject).toBe("gone-project");
|
|
198
|
+
await unmount();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// A 500 says the request failed, not that the project is elsewhere. Claiming
|
|
202
|
+
// otherwise would tell a user their tab is pointed at the wrong project when
|
|
203
|
+
// the server is merely unwell.
|
|
204
|
+
it("stays quiet on a failure that says nothing about the project", async () => {
|
|
205
|
+
const { captured, unmount } = await readOutcome("project-a", failedRead(500));
|
|
206
|
+
|
|
207
|
+
expect(captured.handle?.unreachableProject).toBeNull();
|
|
208
|
+
await unmount();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("clears the state once the project resolves", async () => {
|
|
212
|
+
const { captured, unmount } = await readOutcome("gone-project", failedRead(404));
|
|
213
|
+
expect(captured.handle?.unreachableProject).toBe("gone-project");
|
|
214
|
+
|
|
215
|
+
openComposition.mockResolvedValue(fakeSession());
|
|
216
|
+
vi.stubGlobal(
|
|
217
|
+
"fetch",
|
|
218
|
+
vi.fn(async () => response("PROJECT_A")),
|
|
219
|
+
);
|
|
220
|
+
await act(async () => {
|
|
221
|
+
captured.handle?.forceReload();
|
|
222
|
+
});
|
|
223
|
+
await flushAsyncEffects();
|
|
224
|
+
|
|
225
|
+
expect(captured.handle?.unreachableProject).toBeNull();
|
|
226
|
+
await unmount();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
150
230
|
describe("useSdkSession unavailable telemetry", () => {
|
|
151
231
|
beforeEach(() => {
|
|
152
232
|
openComposition.mockReset();
|