@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.
Files changed (145) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
  6. package/dist/index.d.ts +111 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21534 -20683
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +18 -1
  13. package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
  14. package/src/components/EditorShell.selectionSync.test.tsx +57 -67
  15. package/src/components/EditorShell.tsx +62 -51
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  18. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  19. package/src/components/editor/DomEditOverlay.tsx +19 -55
  20. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  21. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  22. package/src/components/editor/GridOverlay.tsx +12 -23
  23. package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  25. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  26. package/src/components/editor/PreviewGuides.tsx +8 -10
  27. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  28. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  29. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  30. package/src/components/editor/PropertyPanel.tsx +4 -3
  31. package/src/components/editor/SnapToolbar.test.tsx +26 -12
  32. package/src/components/editor/SnapToolbar.tsx +30 -59
  33. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  34. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  35. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  36. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  37. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  38. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  39. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  40. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  41. package/src/components/editor/useDomEditNudge.ts +9 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  43. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  44. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  45. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  46. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  47. package/src/components/editor/useVolumeAutomation.ts +57 -16
  48. package/src/components/nle/NLEContext.tsx +40 -4
  49. package/src/components/nle/NLEPreview.test.ts +66 -1
  50. package/src/components/nle/NLEPreview.tsx +88 -44
  51. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  52. package/src/components/nle/PreviewOverlays.tsx +19 -24
  53. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  54. package/src/components/nle/PreviewPane.tsx +17 -2
  55. package/src/components/nle/TimelinePane.tsx +5 -1
  56. package/src/components/nle/previewReadOnly.test.tsx +129 -0
  57. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  58. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  59. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  60. package/src/hooks/appHotkeysDispatch.ts +16 -1
  61. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  62. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  63. package/src/hooks/timelineTrackVisibility.ts +7 -8
  64. package/src/hooks/useAppHotkeys.test.ts +71 -3
  65. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  66. package/src/hooks/useAppHotkeys.ts +4 -0
  67. package/src/hooks/useCompositionDimensions.test.tsx +38 -0
  68. package/src/hooks/useCompositionDimensions.ts +17 -1
  69. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  70. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  71. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  72. package/src/hooks/useDomEditCommits.ts +3 -0
  73. package/src/hooks/useDomEditSession.test.tsx +1 -0
  74. package/src/hooks/useDomEditSession.ts +7 -4
  75. package/src/hooks/useDomEditWiring.ts +40 -40
  76. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  77. package/src/hooks/useDomGeometryCommits.ts +26 -20
  78. package/src/hooks/useFileTree.test.tsx +56 -0
  79. package/src/hooks/useFileTree.ts +8 -1
  80. package/src/hooks/useGestureCommit.test.tsx +29 -0
  81. package/src/hooks/useGestureCommit.ts +23 -0
  82. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  83. package/src/hooks/useInlineTextEdit.ts +11 -2
  84. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  85. package/src/hooks/useSdkSession.ts +46 -10
  86. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  87. package/src/hooks/useServerConnection.ts +81 -8
  88. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  89. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  90. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  91. package/src/hooks/useTimelineEditing.ts +2 -7
  92. package/src/index.ts +12 -0
  93. package/src/player/components/Player.test.ts +141 -13
  94. package/src/player/components/Player.tsx +58 -2
  95. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  96. package/src/player/components/Timeline.tsx +23 -573
  97. package/src/player/components/TimelineCanvas.tsx +9 -30
  98. package/src/player/components/TimelineClip.test.tsx +52 -0
  99. package/src/player/components/TimelineClip.tsx +13 -2
  100. package/src/player/components/TimelineOverlays.test.ts +67 -32
  101. package/src/player/components/TimelineOverlays.tsx +46 -107
  102. package/src/player/components/TimelineProvider.tsx +191 -0
  103. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  104. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  105. package/src/player/components/testRootHarness.ts +18 -0
  106. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  107. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  108. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  109. package/src/player/components/timelineTheme.test.ts +4 -4
  110. package/src/player/components/timelineTheme.ts +34 -30
  111. package/src/player/components/timelineTrackDisplay.ts +1 -4
  112. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  113. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  114. package/src/player/components/useTimelineProviderState.tsx +583 -0
  115. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  116. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  117. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  118. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  119. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  120. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  121. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  122. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  123. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  124. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
  126. package/src/player/store/playerStore.ts +0 -4
  127. package/src/player/store/timelineElement.ts +2 -4
  128. package/src/player/store/timelineResetState.ts +0 -1
  129. package/src/styles/hex-baseline.json +3 -4
  130. package/src/styles/studio.css +7 -7
  131. package/src/styles/theme.css +29 -0
  132. package/src/telemetry/events.test.ts +18 -0
  133. package/src/telemetry/events.ts +10 -0
  134. package/src/telemetry/system.test.ts +49 -0
  135. package/src/telemetry/system.ts +6 -0
  136. package/src/telemetry/tabId.test.ts +43 -0
  137. package/src/telemetry/tabId.ts +44 -0
  138. package/src/utils/previewCompositionSize.ts +26 -0
  139. package/src/utils/reloadDebug.ts +2 -4
  140. package/src/utils/sourcePatcher.ts +1 -1
  141. package/src/utils/studioTelemetry.test.ts +14 -0
  142. package/src/utils/studioTelemetry.ts +4 -0
  143. package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
  144. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  145. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -0,0 +1,113 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { ProjectUnreachableBanner } from "./ProjectUnreachableBanner";
6
+
7
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
+
9
+ let root: Root | null = null;
10
+
11
+ afterEach(() => {
12
+ if (root) act(() => root?.unmount());
13
+ root = null;
14
+ document.body.replaceChildren();
15
+ vi.restoreAllMocks();
16
+ vi.unstubAllGlobals();
17
+ });
18
+
19
+ function stubProjects(projects: unknown): void {
20
+ vi.stubGlobal(
21
+ "fetch",
22
+ vi.fn(async () => new Response(JSON.stringify({ projects }), { status: 200 })),
23
+ );
24
+ }
25
+
26
+ async function renderBanner(projectId: string): Promise<string> {
27
+ const host = document.createElement("div");
28
+ document.body.append(host);
29
+ root = createRoot(host);
30
+ await act(async () => {
31
+ root?.render(<ProjectUnreachableBanner projectId={projectId} />);
32
+ });
33
+ await act(async () => {
34
+ await new Promise((resolve) => setTimeout(resolve, 0));
35
+ });
36
+ return host.textContent ?? "";
37
+ }
38
+
39
+ describe("ProjectUnreachableBanner", () => {
40
+ // The production case: the CLI host serves exactly one project, and it is not
41
+ // this tab's. Naming both sides is the whole point — the user needs to know
42
+ // their project still exists and which one this Studio actually opened.
43
+ it("names both projects when this Studio serves a different one", async () => {
44
+ stubProjects([{ id: "out-video1", title: "out-video1" }]);
45
+ const text = await renderBanner("out-fb1");
46
+
47
+ expect(text).toContain("out-video1");
48
+ expect(text).toContain("out-fb1");
49
+ expect(text).toContain("This Studio is serving");
50
+ expect(text).not.toContain("renamed");
51
+ });
52
+
53
+ // With several projects a 404 cannot be told apart from a rename or a
54
+ // deletion, so the banner must not claim a cause. "Could be gone" is the
55
+ // honest ceiling, and it is a weaker claim than "is gone".
56
+ it("does not claim a cause when several projects are served", async () => {
57
+ stubProjects([
58
+ { id: "a", title: "a" },
59
+ { id: "b", title: "b" },
60
+ ]);
61
+ const text = await renderBanner("out-fb1");
62
+
63
+ expect(text).toContain("renamed, moved, or deleted");
64
+ expect(text).not.toContain("This Studio is serving");
65
+ });
66
+
67
+ // A server that answers with this tab's own project while the read 404s is
68
+ // not the stale-tab case, so the single-project wording would be false.
69
+ it("falls back to the general wording when the served project is this one", async () => {
70
+ stubProjects([{ id: "out-fb1", title: "out-fb1" }]);
71
+ const text = await renderBanner("out-fb1");
72
+
73
+ expect(text).toContain("renamed, moved, or deleted");
74
+ expect(text).not.toContain("This Studio is serving");
75
+ });
76
+
77
+ it("says nothing it cannot back up when the project list cannot be read", async () => {
78
+ vi.stubGlobal(
79
+ "fetch",
80
+ vi.fn(async () => new Response("boom", { status: 500 })),
81
+ );
82
+ const text = await renderBanner("out-fb1");
83
+
84
+ expect(text).toContain("renamed, moved, or deleted");
85
+ expect(text).not.toContain("This Studio is serving");
86
+ });
87
+
88
+ it("renders nothing until the project list resolves", async () => {
89
+ let resolveList: ((value: Response) => void) | undefined;
90
+ vi.stubGlobal(
91
+ "fetch",
92
+ vi.fn(
93
+ () =>
94
+ new Promise<Response>((resolve) => {
95
+ resolveList = resolve;
96
+ }),
97
+ ),
98
+ );
99
+ const host = document.createElement("div");
100
+ document.body.append(host);
101
+ root = createRoot(host);
102
+ await act(async () => {
103
+ root?.render(<ProjectUnreachableBanner projectId="out-fb1" />);
104
+ });
105
+ expect(host.textContent).toBe("");
106
+
107
+ await act(async () => {
108
+ resolveList?.(new Response(JSON.stringify({ projects: [{ id: "other" }] }), { status: 200 }));
109
+ await new Promise((resolve) => setTimeout(resolve, 0));
110
+ });
111
+ expect(host.textContent).toContain("This Studio is serving");
112
+ });
113
+ });
@@ -0,0 +1,89 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ interface ServedProject {
4
+ id: string;
5
+ title?: string;
6
+ }
7
+
8
+ interface ProjectUnreachableBannerProps {
9
+ /** The project this tab was opened for and the server cannot resolve. */
10
+ projectId: string;
11
+ }
12
+
13
+ /**
14
+ * Shown when the running Studio cannot resolve this tab's project id.
15
+ *
16
+ * Why this exists: a tab whose project the server does not serve fails every
17
+ * read with a 404, so every edit silently takes the server path and nothing
18
+ * lands. The user sees a normal-looking editor that does not save. In the
19
+ * CLI-embedded host — the only host that reports telemetry, since
20
+ * `telemetry/policy.ts` suppresses Vite dev — the cause is almost always that
21
+ * this Studio is serving a *different* project: `hyperframes preview` reuses
22
+ * port 3002, so starting it on another folder takes the port from under an
23
+ * open tab, and the project it was pointed at is untouched on disk.
24
+ *
25
+ * The wording is deliberately narrower than "this project is gone". We can see
26
+ * which project this server serves; we cannot see why it does not serve this
27
+ * one, and nothing here is lost. See
28
+ * `docs/hyperframes/plans/sdk/2026-09-20-stale-project-id-remaining-doors-plan.md` §C.
29
+ */
30
+ export function ProjectUnreachableBanner({ projectId }: ProjectUnreachableBannerProps) {
31
+ const [served, setServed] = useState<ServedProject[] | null>(null);
32
+
33
+ useEffect(() => {
34
+ let cancelled = false;
35
+ // A list we could not read is not evidence of anything, so a failed fetch
36
+ // leaves `served` empty and the banner falls back to the vague wording
37
+ // rather than claiming this Studio serves nothing.
38
+ fetch("/api/projects")
39
+ .then((res) => (res.ok ? res.json() : { projects: [] }))
40
+ .then((data: { projects?: ServedProject[] }) => {
41
+ if (!cancelled) setServed(data.projects ?? []);
42
+ })
43
+ .catch(() => {
44
+ if (!cancelled) setServed([]);
45
+ });
46
+ return () => {
47
+ cancelled = true;
48
+ };
49
+ }, [projectId]);
50
+
51
+ // Withheld until the list arrives: which wording applies is a claim about
52
+ // what this server serves, and guessing it would flash the wrong one.
53
+ if (served === null) return null;
54
+
55
+ // Exactly one project served, and it is not this tab's. Under the CLI host
56
+ // that is the whole truth, and it names both sides. With several projects we
57
+ // cannot tell a rename from a deletion, so we do not pretend to.
58
+ const soleProject = served.length === 1 && served[0].id !== projectId ? served[0] : null;
59
+ const servedLabel = soleProject?.title ?? soleProject?.id;
60
+
61
+ return (
62
+ <div
63
+ role="alert"
64
+ className="hf-backdrop-in absolute left-1/2 top-14 z-92 flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-amber-500/30 bg-amber-950/85 px-4 py-2 text-[12px] font-medium text-amber-100 shadow-lg shadow-black/30"
65
+ >
66
+ {soleProject ? (
67
+ <>
68
+ <span>
69
+ This Studio is serving <strong>{servedLabel}</strong>. This tab was opened for{" "}
70
+ <strong>{projectId}</strong>.
71
+ </span>
72
+ {/* Reloading re-runs the mount-time hash check, which rewrites the hash
73
+ to the project this server actually serves. That is the same
74
+ fallback a fresh load performs, so the button promises nothing
75
+ beyond what a reload already does. */}
76
+ <button
77
+ type="button"
78
+ onClick={() => window.location.reload()}
79
+ className="shrink-0 rounded-sm border border-amber-300/20 px-2 py-1 text-[11px] text-amber-100 transition-colors hover:bg-amber-400/10 active:scale-[0.98]"
80
+ >
81
+ Open {servedLabel}
82
+ </button>
83
+ </>
84
+ ) : (
85
+ <span>Couldn&apos;t open this project — it may have been renamed, moved, or deleted.</span>
86
+ )}
87
+ </div>
88
+ );
89
+ }
@@ -1,4 +1,4 @@
1
- import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
1
+ import { memo, useEffect, useMemo, useRef, type RefObject } from "react";
2
2
  import { type DomEditSelection } from "./domEditing";
3
3
  import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
4
4
  import { useMarqueeGestures } from "./marqueeCommit";
@@ -16,24 +16,23 @@ import {
16
16
  resolveShiftClickCandidate,
17
17
  } from "./domEditOverlayGestures";
18
18
  import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
19
- import { ChildRectOutlines, OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
19
+ import { ChildRectOutlines, OffCanvasIndicators } from "./OffCanvasIndicators";
20
20
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
21
21
  import { useDomEditNudge } from "./useDomEditNudge";
22
22
  import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
23
- import { GridOverlay } from "./GridOverlay";
24
23
  import type { GestureRecordingState } from "./GestureRecordControl";
25
24
  import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome";
26
25
  import { hugRectForElement } from "./domEditOverlayCrop";
27
26
  import { useCropOverlay } from "../../hooks/useCropOverlay";
28
27
  import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
29
28
  import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
30
- import { useMountEffect } from "../../hooks/useMountEffect";
31
- import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
32
29
  import { CanvasContextMenu } from "./CanvasContextMenu";
33
30
  import { useInlineTextEditing } from "./useInlineTextEditing";
31
+ import { usePreviewReadOnly } from "./previewReadOnlyContext";
34
32
  import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
35
33
  import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
36
34
  import { logSelect } from "../../utils/selectDebug";
35
+ import { useOffCanvasIndicators } from "./useOffCanvasIndicators";
37
36
 
38
37
  // Re-exports for external consumers — preserving existing import paths.
39
38
  export {
@@ -84,8 +83,6 @@ interface DomEditOverlayProps {
84
83
  ) => Promise<void> | void;
85
84
  onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
86
85
  onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
87
- gridVisible?: boolean;
88
- gridSpacing?: number;
89
86
  recordingState?: GestureRecordingState;
90
87
  onToggleRecording?: () => void;
91
88
  onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
@@ -125,8 +122,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
125
122
  onCanvasPointerLeave,
126
123
  onSelectionChange,
127
124
  onBlockedMove,
128
- gridVisible = false,
129
- gridSpacing = 50,
130
125
  onManualDragStart,
131
126
  onPathOffsetCommit,
132
127
  onGroupPathOffsetCommit,
@@ -137,6 +132,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
137
132
  onDeleteSelection,
138
133
  onApplyZIndex,
139
134
  }: DomEditOverlayProps) {
135
+ const readOnly = usePreviewReadOnly();
140
136
  const overlayRef = useRef<HTMLDivElement | null>(null);
141
137
  const boxRef = useRef<HTMLDivElement | null>(null);
142
138
  const onMarqueeSelectRef = useRef(onMarqueeSelect);
@@ -220,39 +216,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
220
216
  const boxClipPath = hasCropInsets ? undefined : selectionShapeStyles.clipPath;
221
217
  const boxChromeClass = resolveBoxChromeClass(Boolean(cropOutlineInsetPx), boxClipPath);
222
218
 
223
- // Off-canvas element indicators — dashed outlines for elements positioned
224
- // outside the composition bounds so users can find them.
225
- const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
226
- const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
227
- const offCanvasDirtyRef = useRef(true);
228
- const offCanvasSigRef = useRef("");
229
- const offCanvasObserverRef = useRef<MutationObserver | null>(null);
230
- const offCanvasObservedDocRef = useRef<Document | null>(null);
231
-
232
- // Positions depend on live iframe layout, not selection — the selected-element
233
- // suppression is a render-time filter, so selection/groupSelections stay out
234
- // of the geometry walk.
235
- useMountEffect(() =>
236
- startOffCanvasIndicatorRefresh({
237
- iframeRef,
238
- overlayRef,
239
- compRectRef,
240
- activeCompositionPathRef,
241
- dirtyRef: offCanvasDirtyRef,
242
- sigRef: offCanvasSigRef,
243
- observerRef: offCanvasObserverRef,
244
- observedDocRef: offCanvasObservedDocRef,
245
- elementsRef: offCanvasElementsRef,
246
- setRects: setOffCanvasRects,
247
- }),
248
- );
249
-
250
- // Switching compositions may not swap the iframe document (so the observer's
251
- // doc-swap detection wouldn't fire) yet changes which elements are off-canvas.
252
- // Force a recompute explicitly on comp change.
253
- useEffect(() => {
254
- offCanvasDirtyRef.current = true;
255
- }, [activeCompositionPath]);
219
+ const { offCanvasRects, offCanvasElementsRef } = useOffCanvasIndicators({
220
+ iframeRef,
221
+ overlayRef,
222
+ compRectRef,
223
+ activeCompositionPathRef,
224
+ activeCompositionPath,
225
+ });
256
226
 
257
227
  const gestures = createDomEditOverlayGestureHandlers({
258
228
  overlayRef,
@@ -280,12 +250,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
280
250
  snapGuidesRef,
281
251
  });
282
252
 
253
+ useEffect(() => {
254
+ if (readOnly) gestures.clearPointerState(selectionRef);
255
+ }, [gestures, readOnly, selectionRef]);
256
+
283
257
  // Arrow-key nudge (1px, Shift = 10px) — commits through the same
284
258
  // path-offset callbacks as a drag, one undo entry per key burst.
285
259
  const { flushNudge } = useDomEditNudge({
286
260
  selection,
287
261
  groupSelections,
288
- allowCanvasMovement,
262
+ allowCanvasMovement: allowCanvasMovement && !readOnly,
289
263
  selectionRef,
290
264
  overlayRectRef,
291
265
  groupOverlayItemsRef,
@@ -558,7 +532,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
558
532
  selection={contextMenu.sel}
559
533
  onClose={closeContextMenu}
560
534
  onDelete={
561
- onDeleteSelection
535
+ onDeleteSelection && !readOnly
562
536
  ? (sel) => {
563
537
  closeContextMenu();
564
538
  onDeleteSelection(sel);
@@ -566,7 +540,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
566
540
  : undefined
567
541
  }
568
542
  onApplyZIndex={
569
- onApplyZIndex
543
+ onApplyZIndex && !readOnly
570
544
  ? (patches, action, crossed) => {
571
545
  onApplyZIndex(contextMenu.sel, patches, action, crossed);
572
546
  }
@@ -576,16 +550,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
576
550
  />
577
551
  )}
578
552
  <ZOrderCrossedFlash rect={zOrderFlashRect} />
579
- <GridOverlay
580
- visible={gridVisible}
581
- spacing={gridSpacing}
582
- scaleX={compRect.scaleX}
583
- scaleY={compRect.scaleY}
584
- compositionLeft={compRect.left}
585
- compositionTop={compRect.top}
586
- compositionWidth={compRect.width}
587
- compositionHeight={compRect.height}
588
- />
589
553
  <SnapGuideOverlay
590
554
  snapGuidesRef={snapGuidesRef}
591
555
  compositionLeft={compRect.left}
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { expect, it, vi } from "vitest";
6
6
  import type { DomEditSelection } from "./domEditing";
7
+ import "./domEditOverlayTestMocks";
7
8
  import { DomEditOverlay } from "./DomEditOverlay";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -33,21 +34,6 @@ vi.mock("./useDomEditOverlayRects", () => ({
33
34
  }),
34
35
  }));
35
36
 
36
- vi.mock("./useDomEditCompositionRect", () => ({
37
- useDomEditCompositionRect: () => ({
38
- left: 0,
39
- top: 0,
40
- width: 800,
41
- height: 450,
42
- scaleX: 1,
43
- scaleY: 1,
44
- }),
45
- }));
46
-
47
- vi.mock("./offCanvasIndicatorRefresh", () => ({
48
- startOffCanvasIndicatorRefresh: () => () => undefined,
49
- }));
50
-
51
37
  function renderHover(angle: number): string {
52
38
  hoverState.angle = angle;
53
39
  const host = document.createElement("div");
@@ -6,6 +6,7 @@ import type { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGes
6
6
  import { DomEditCropHandles } from "./DomEditCropHandles";
7
7
  import { DomEditRotateHandle } from "./DomEditRotateHandle";
8
8
  import { resolveRotatedResizeCursor } from "./domEditResizeLocal";
9
+ import { usePreviewReadOnly } from "./previewReadOnlyContext";
9
10
 
10
11
  // Corner resize handles, Canva-style: one per corner, diagonal cursors.
11
12
  // Corners scale about the element center; the translate keeps the center
@@ -75,6 +76,7 @@ export function DomEditGroupChrome({
75
76
  onBoxMouseDown,
76
77
  onBoxClick,
77
78
  }: DomEditGroupChromeProps) {
79
+ const canManipulate = allowCanvasMovement && !usePreviewReadOnly();
78
80
  return (
79
81
  <>
80
82
  {groupOverlayItems.map((item) => (
@@ -98,10 +100,10 @@ export function DomEditGroupChrome({
98
100
  top: groupBounds.top,
99
101
  width: groupBounds.width,
100
102
  height: groupBounds.height,
101
- cursor: allowCanvasMovement && groupCanMove ? "move" : "default",
103
+ cursor: canManipulate && groupCanMove ? "move" : "default",
102
104
  }}
103
105
  onPointerDown={(e) => {
104
- if (!allowCanvasMovement || !groupCanMove || e.shiftKey) return;
106
+ if (!canManipulate || !groupCanMove || e.shiftKey) return;
105
107
  gestures.startGroupDrag(e);
106
108
  }}
107
109
  onMouseDown={onBoxMouseDown}
@@ -164,6 +166,8 @@ export function DomEditSelectionChrome({
164
166
  // into: left interactive, it swallows every press, so the caret can never be
165
167
  // moved and characters can never be selected by dragging.
166
168
  const editing = inlineText?.editing ?? false;
169
+ const readOnly = usePreviewReadOnly();
170
+ const canManipulate = allowCanvasMovement && !readOnly;
167
171
 
168
172
  return (
169
173
  <>
@@ -174,7 +178,7 @@ export function DomEditSelectionChrome({
174
178
  transform: overlayRect.angle ? `rotate(${overlayRect.angle}deg)` : undefined,
175
179
  }}
176
180
  >
177
- {allowCanvasMovement && !editing && selection.capabilities.canApplyManualRotation && (
181
+ {canManipulate && !editing && selection.capabilities.canApplyManualRotation && (
178
182
  <DomEditRotateHandle
179
183
  overlayRect={overlayRect}
180
184
  cropOutlineInsetPx={cropOutlineInsetPx}
@@ -196,9 +200,7 @@ export function DomEditSelectionChrome({
196
200
  height: overlayRect.height,
197
201
  clipPath: boxClipPath,
198
202
  cursor:
199
- allowCanvasMovement && selection.capabilities.canApplyManualOffset
200
- ? "move"
201
- : "default",
203
+ canManipulate && selection.capabilities.canApplyManualOffset ? "move" : "default",
202
204
  }}
203
205
  onPointerDown={(e) => {
204
206
  // A second press opens the element's text for editing, and must be
@@ -211,7 +213,7 @@ export function DomEditSelectionChrome({
211
213
  e.stopPropagation();
212
214
  return;
213
215
  }
214
- if (!allowCanvasMovement || e.shiftKey) return;
216
+ if (!canManipulate || e.shiftKey) return;
215
217
  if (selection.capabilities.canApplyManualOffset) {
216
218
  gestures.startGesture("drag", e);
217
219
  return;
@@ -245,7 +247,7 @@ export function DomEditSelectionChrome({
245
247
  children, so they paint strictly above the box border. Each handle
246
248
  is positioned relative to the overlay container using the
247
249
  overlayRect origin, matching the old child-relative offsets. */}
248
- {allowCanvasMovement &&
250
+ {canManipulate &&
249
251
  !editing &&
250
252
  selection.capabilities.canApplyManualSize &&
251
253
  RESIZE_HANDLE_DEFS.map((def) =>
@@ -271,7 +273,7 @@ export function DomEditSelectionChrome({
271
273
  </div>
272
274
  {/* Crop owns its element-local oriented frame. Keep it outside the chrome's
273
275
  rotated plane or a rotated selection applies the angle twice. */}
274
- {selection.capabilities.canCrop && !editing && groupSelectionCount <= 1 && (
276
+ {selection.capabilities.canCrop && !editing && !readOnly && groupSelectionCount <= 1 && (
275
277
  <DomEditCropHandles
276
278
  selection={selection}
277
279
  overlayRect={overlayRect}
@@ -1,28 +1,17 @@
1
- // fallow-ignore-file unused-file
2
1
  import { memo } from "react";
2
+ import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
3
3
 
4
- interface GridOverlayProps {
5
- visible: boolean;
6
- spacing: number;
7
- scaleX: number;
8
- scaleY: number;
9
- compositionLeft: number;
10
- compositionTop: number;
11
- compositionWidth: number;
12
- compositionHeight: number;
13
- }
14
-
15
- // fallow-ignore-next-line complexity
16
- export const GridOverlay = memo(function GridOverlay({
17
- visible,
18
- spacing,
19
- scaleX,
20
- scaleY,
21
- compositionLeft,
22
- compositionTop,
23
- compositionWidth,
24
- compositionHeight,
25
- }: GridOverlayProps) {
4
+ export const GridOverlay = memo(function GridOverlay() {
5
+ const { state } = usePreviewOverlayContext();
6
+ const { gridVisible: visible, gridSpacing: spacing } = state.snapPrefs;
7
+ const {
8
+ scaleX,
9
+ scaleY,
10
+ left: compositionLeft,
11
+ top: compositionTop,
12
+ width: compositionWidth,
13
+ height: compositionHeight,
14
+ } = state.compositionRect;
26
15
  if (!visible || spacing <= 0) return null;
27
16
 
28
17
  const overlaySpacingX = spacing * scaleX;
@@ -0,0 +1,66 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { expect, it, vi } from "vitest";
5
+ import { MotionPathOverlay } from "./MotionPathOverlay";
6
+ import { usePlayerStore } from "../../player/store/playerStore";
7
+ import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
8
+ import type { DomEditSelection } from "./domEditing";
9
+
10
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
11
+
12
+ vi.mock("../../contexts/DomEditContext", () => ({
13
+ useDomEditContext: () => ({ selectedGsapAnimations: [] }),
14
+ }));
15
+ vi.mock("./motionPathSelection", () => ({
16
+ selectorFor: () => "#box",
17
+ editableAnimationId: () => null,
18
+ }));
19
+ vi.mock("./useMotionPathData", async (importOriginal) => ({
20
+ ...(await importOriginal<typeof import("./useMotionPathData")>()),
21
+ useMotionPathData: () => ({
22
+ rect: null,
23
+ geometry: null,
24
+ geometryResolved: true,
25
+ visibleInPreview: true,
26
+ home: null,
27
+ pScale: 1,
28
+ }),
29
+ }));
30
+
31
+ it("re-evaluates set-destination availability when a reload swaps the live iframe", () => {
32
+ const host = document.createElement("div");
33
+ const before = document.createElement("iframe");
34
+ const after = document.createElement("iframe");
35
+ document.body.append(host, before, after);
36
+ Reflect.set(after.contentWindow as object, "MotionPathPlugin", {});
37
+ const ref = { current: before };
38
+ const root = createRoot(host);
39
+ const selection = { element: document.createElement("div") } as unknown as DomEditSelection;
40
+ try {
41
+ act(() => usePreviewIframeStore.getState().setIframe(before));
42
+ act(() =>
43
+ root.render(
44
+ <MotionPathOverlay
45
+ iframeRef={ref}
46
+ selection={selection}
47
+ compositionSize={{ width: 1920, height: 1080 }}
48
+ isPlaying={false}
49
+ />,
50
+ ),
51
+ );
52
+ expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(false);
53
+
54
+ act(() => {
55
+ ref.current = after;
56
+ usePreviewIframeStore.getState().setIframe(after);
57
+ });
58
+ expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(true);
59
+ } finally {
60
+ act(() => root.unmount());
61
+ usePreviewIframeStore.setState({ iframe: null });
62
+ host.remove();
63
+ before.remove();
64
+ after.remove();
65
+ }
66
+ });
@@ -3,6 +3,7 @@ import { memo, useEffect, useRef, useState, type RefObject } from "react";
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import { useDomEditContext } from "../../contexts/DomEditContext";
5
5
  import { usePlayerStore } from "../../player/store/playerStore";
6
+ import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
6
7
  import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
7
8
  import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
8
9
  import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
@@ -141,7 +142,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
141
142
  // No one-element selector means the path could only be authored onto the
142
143
  // element's class siblings, so the toolbar toggle stays hidden instead of
143
144
  // arming a press that the effect below would silently drop.
144
- const canCreate = createMode && !!createSelector && hasMotionPathPlugin(iframeRef.current);
145
+ const livePreviewIframe = useLivePreviewIframe();
146
+ const canCreate = createMode && !!createSelector && hasMotionPathPlugin(livePreviewIframe);
145
147
 
146
148
  // Publish whether the selected element can take a path so the preview toolbar
147
149
  // shows its "set destination" toggle. Drops to false when this overlay unmounts
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { PreviewGuides } from "./PreviewGuides";
7
+ import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
7
8
  import { usePreviewGuidesStore } from "./previewGuidesStore";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -26,7 +27,11 @@ function render(rulerVisible: boolean, safeMarginsVisible: boolean) {
26
27
  const host = document.createElement("div");
27
28
  document.body.append(host);
28
29
  act(() => {
29
- createRoot(host).render(<PreviewGuides iframeRef={{ current: null }} />);
30
+ createRoot(host).render(
31
+ <PreviewOverlayProvider>
32
+ <PreviewGuides />
33
+ </PreviewOverlayProvider>,
34
+ );
30
35
  });
31
36
  return host;
32
37
  }
@@ -1,30 +1,28 @@
1
1
  import { memo, useRef, type RefObject } from "react";
2
2
  import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
3
- import { RULER_GUTTER_PX, usePreviewGuidesStore } from "./previewGuidesStore";
3
+ import { RULER_GUTTER_PX } from "./previewGuidesStore";
4
4
  import { SAFE_BOX_PERCENTS, safeBoxInsetPercent } from "../../utils/previewSafeMargins";
5
-
6
- interface PreviewGuidesProps {
7
- iframeRef: RefObject<HTMLIFrameElement | null>;
8
- }
5
+ import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
9
6
 
10
7
  const TICKS = Array.from({ length: 11 }, (_, i) => i * 10);
11
8
  const INK = "color-mix(in srgb, white 70%, transparent)";
12
9
 
13
10
  /** Ruler and safe-margin boxes drawn over the preview pane, never inside the composition. */
14
- export const PreviewGuides = memo(function PreviewGuides({ iframeRef }: PreviewGuidesProps) {
15
- const rulerVisible = usePreviewGuidesStore((s) => s.rulerVisible);
16
- const safeMarginsVisible = usePreviewGuidesStore((s) => s.safeMarginsVisible);
11
+ export const PreviewGuides = memo(function PreviewGuides() {
12
+ const { state } = usePreviewOverlayContext();
13
+ const { rulerVisible, safeMarginsVisible } = state;
17
14
  if (!rulerVisible && !safeMarginsVisible) return null;
18
15
  return (
19
16
  <ActiveGuides
20
- iframeRef={iframeRef}
17
+ iframeRef={state.iframeRef}
21
18
  rulerVisible={rulerVisible}
22
19
  safeMarginsVisible={safeMarginsVisible}
23
20
  />
24
21
  );
25
22
  });
26
23
 
27
- interface ActiveGuidesProps extends PreviewGuidesProps {
24
+ interface ActiveGuidesProps {
25
+ iframeRef: RefObject<HTMLIFrameElement | null>;
28
26
  rulerVisible: boolean;
29
27
  safeMarginsVisible: boolean;
30
28
  }