@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,90 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { GridOverlay } from "./GridOverlay";
7
+ import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
8
+ import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = "";
14
+ window.localStorage.clear();
15
+ usePreviewIframeStore.getState().setIframe(null);
16
+ });
17
+
18
+ function compositionFrame() {
19
+ const iframe = document.createElement("iframe");
20
+ iframe.getBoundingClientRect = () => new DOMRect(12, 18, 640, 360);
21
+ const root = iframe.contentDocument?.createElement("div");
22
+ root?.setAttribute("data-composition-id", "main");
23
+ root?.setAttribute("data-width", "1280");
24
+ root?.setAttribute("data-height", "720");
25
+ if (root) iframe.contentDocument?.body.append(root);
26
+ document.body.append(iframe);
27
+ return iframe;
28
+ }
29
+
30
+ describe("PreviewOverlayProvider", () => {
31
+ it("derives grid geometry after the iframe prop becomes available", async () => {
32
+ window.localStorage.setItem(
33
+ "hf-studio-ui-preferences",
34
+ JSON.stringify({ gridVisible: true, gridSpacing: 20 }),
35
+ );
36
+ const host = document.createElement("div");
37
+ document.body.append(host);
38
+ const root = createRoot(host);
39
+
40
+ act(() => {
41
+ root.render(
42
+ <PreviewOverlayProvider iframe={null}>
43
+ <GridOverlay />
44
+ </PreviewOverlayProvider>,
45
+ );
46
+ });
47
+ expect(host.querySelector('[aria-hidden="true"]')).not.toBeNull();
48
+
49
+ const iframe = compositionFrame();
50
+ act(() => {
51
+ root.render(
52
+ <PreviewOverlayProvider iframe={iframe}>
53
+ <GridOverlay />
54
+ </PreviewOverlayProvider>,
55
+ );
56
+ });
57
+ await act(async () => {
58
+ await new Promise((resolve) => setTimeout(resolve, 400));
59
+ });
60
+
61
+ const grid = host.querySelector<HTMLElement>('[aria-hidden="true"]');
62
+ expect([grid?.style.left, grid?.style.top, grid?.style.width, grid?.style.height]).toEqual([
63
+ "12px",
64
+ "18px",
65
+ "640px",
66
+ "360px",
67
+ ]);
68
+ expect(grid?.style.backgroundSize).toBe("20px 20px");
69
+ act(() => root.unmount());
70
+ });
71
+
72
+ it("does not clear an iframe store entry owned elsewhere on unmount", () => {
73
+ const ownerIframe = compositionFrame();
74
+ usePreviewIframeStore.getState().setIframe(ownerIframe);
75
+ const host = document.createElement("div");
76
+ document.body.append(host);
77
+ const root = createRoot(host);
78
+
79
+ act(() => {
80
+ root.render(
81
+ <PreviewOverlayProvider iframe={null}>
82
+ <GridOverlay />
83
+ </PreviewOverlayProvider>,
84
+ );
85
+ });
86
+ act(() => root.unmount());
87
+
88
+ expect(usePreviewIframeStore.getState().iframe).toBe(ownerIframe);
89
+ });
90
+ });
@@ -0,0 +1,86 @@
1
+ import { createContext, useContext, useRef, useState, type ReactNode, type RefObject } from "react";
2
+ import {
3
+ usePreviewCompositionRect,
4
+ type PreviewCompositionRect,
5
+ } from "./usePreviewCompositionRect";
6
+ import { usePreviewGuidesStore } from "./previewGuidesStore";
7
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
8
+ import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
9
+
10
+ export interface PreviewSnapPreferences {
11
+ snapEnabled: boolean;
12
+ gridVisible: boolean;
13
+ gridSpacing: number;
14
+ snapToGrid: boolean;
15
+ }
16
+
17
+ interface PreviewOverlayContextValue {
18
+ state: {
19
+ snapPrefs: PreviewSnapPreferences;
20
+ rulerVisible: boolean;
21
+ safeMarginsVisible: boolean;
22
+ iframeRef: RefObject<HTMLIFrameElement | null>;
23
+ compositionRect: PreviewCompositionRect;
24
+ };
25
+ actions: {
26
+ setSnapPrefs: (patch: Partial<PreviewSnapPreferences>) => void;
27
+ toggleRulers: () => void;
28
+ toggleSafeMargins: () => void;
29
+ };
30
+ }
31
+
32
+ const PreviewOverlayContext = createContext<PreviewOverlayContextValue | null>(null);
33
+
34
+ function readSnapPrefs(): PreviewSnapPreferences {
35
+ const prefs = readStudioUiPreferences();
36
+ return {
37
+ snapEnabled: prefs.snapEnabled ?? true,
38
+ gridVisible: prefs.gridVisible ?? false,
39
+ gridSpacing: prefs.gridSpacing ?? 50,
40
+ snapToGrid: prefs.snapToGrid ?? false,
41
+ };
42
+ }
43
+
44
+ export interface PreviewOverlayProviderProps {
45
+ iframe?: HTMLIFrameElement | null;
46
+ children: ReactNode;
47
+ }
48
+
49
+ export function PreviewOverlayProvider({ iframe, children }: PreviewOverlayProviderProps) {
50
+ const overlayRef = useRef<HTMLDivElement | null>(null);
51
+ const [snapPrefs, setSnapPrefs] = useState(readSnapPrefs);
52
+ const rulerVisible = usePreviewGuidesStore((state) => state.rulerVisible);
53
+ const safeMarginsVisible = usePreviewGuidesStore((state) => state.safeMarginsVisible);
54
+ const liveIframe = useLivePreviewIframe();
55
+ const resolvedIframe = iframe ?? liveIframe;
56
+ const iframeRef = useRef<HTMLIFrameElement | null>(resolvedIframe);
57
+ iframeRef.current = resolvedIframe;
58
+ const compositionRect = usePreviewCompositionRect(overlayRef, resolvedIframe);
59
+
60
+ const contextValue: PreviewOverlayContextValue = {
61
+ state: { snapPrefs, rulerVisible, safeMarginsVisible, iframeRef, compositionRect },
62
+ actions: {
63
+ setSnapPrefs: (patch) => {
64
+ writeStudioUiPreferences(patch);
65
+ setSnapPrefs((current) => ({ ...current, ...patch }));
66
+ },
67
+ toggleRulers: () => usePreviewGuidesStore.getState().toggle("rulerVisible"),
68
+ toggleSafeMargins: () => usePreviewGuidesStore.getState().toggle("safeMarginsVisible"),
69
+ },
70
+ };
71
+
72
+ return (
73
+ <PreviewOverlayContext.Provider value={contextValue}>
74
+ <div ref={overlayRef} className="absolute inset-0">
75
+ {children}
76
+ </div>
77
+ </PreviewOverlayContext.Provider>
78
+ );
79
+ }
80
+
81
+ export function usePreviewOverlayContext(): PreviewOverlayContextValue {
82
+ const context = useContext(PreviewOverlayContext);
83
+ if (!context)
84
+ throw new Error("usePreviewOverlayContext must be used within PreviewOverlayProvider");
85
+ return context;
86
+ }
@@ -0,0 +1,71 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { PropertyPanel } from "./PropertyPanel";
7
+ import type { PropertyPanelProps } from "./propertyPanelHelpers";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ vi.mock("../../contexts/StudioContext", async () => {
12
+ const actual = await vi.importActual<typeof import("../../contexts/StudioContext")>(
13
+ "../../contexts/StudioContext",
14
+ );
15
+ return { ...actual, useStudioShellContext: () => ({ showToast: vi.fn() }) };
16
+ });
17
+
18
+ function selectedElement(): NonNullable<PropertyPanelProps["element"]> {
19
+ return {
20
+ element: document.createElement("div"),
21
+ id: "el",
22
+ selector: "#el",
23
+ label: "El",
24
+ tagName: "div",
25
+ sourceFile: "index.html",
26
+ compositionPath: "index.html",
27
+ isCompositionHost: false,
28
+ isInsideLockedComposition: false,
29
+ boundingBox: { x: 0, y: 0, width: 10, height: 10 },
30
+ textContent: "",
31
+ dataAttributes: {},
32
+ inlineStyles: {},
33
+ computedStyles: {},
34
+ textFields: [],
35
+ capabilities: {
36
+ canSelect: true,
37
+ canEditStyles: true,
38
+ canCrop: true,
39
+ canMove: true,
40
+ canResize: true,
41
+ canApplyManualOffset: true,
42
+ canApplyManualSize: true,
43
+ canApplyManualRotation: true,
44
+ },
45
+ };
46
+ }
47
+
48
+ describe("PropertyPanel deselect", () => {
49
+ it("re-renders to the empty state on deselect without a hooks-order crash", () => {
50
+ const props = {
51
+ element: selectedElement(),
52
+ assets: [],
53
+ onSetStyle: vi.fn(),
54
+ onSetText: vi.fn(),
55
+ onSetAttributeLive: vi.fn(),
56
+ onClearSelection: vi.fn(),
57
+ } as unknown as PropertyPanelProps;
58
+ const host = document.createElement("div");
59
+ document.body.append(host);
60
+ const root = createRoot(host);
61
+ act(() => root.render(<PropertyPanel {...props} />));
62
+
63
+ // A same-instance re-render with no element — Escape, clicking empty canvas,
64
+ // or "Clear selection" — must not change which hooks ran.
65
+ expect(() => {
66
+ act(() => root.render(<PropertyPanel {...props} element={null} />));
67
+ }).not.toThrow();
68
+
69
+ act(() => root.unmount());
70
+ });
71
+ });
@@ -37,6 +37,7 @@ import { GestureRecordPanelButton } from "./GestureRecordControl";
37
37
  import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
38
38
  import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
39
39
  import { isAudioDomElement } from "../../utils/timelineInspector";
40
+ import { useManualEditDisabledFlags } from "./previewReadOnlyContext";
40
41
 
41
42
  // Re-export helpers that external consumers import from this module
42
43
  export {
@@ -175,6 +176,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
175
176
  scale: 1,
176
177
  transformPerspective: 0,
177
178
  };
179
+ // Unconditional like the hooks above: must not sit behind the `!element` return below.
180
+ const { manualOffsetEditingDisabled, manualSizeEditingDisabled, manualRotationEditingDisabled } =
181
+ useManualEditDisabledFlags(element?.capabilities);
178
182
 
179
183
  if (!element) {
180
184
  return (
@@ -189,9 +193,6 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
189
193
  );
190
194
  }
191
195
 
192
- const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
193
- const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
194
- const manualRotationEditingDisabled = !element.capabilities.canApplyManualRotation;
195
196
  const sourceLabel = element.id ? `#${element.id}` : (element.selector ?? "");
196
197
  // Capabilities are already resolved on the selection; recompute only sections,
197
198
  // feeding the live GSAP tween count (arrives on the gsapAnimations prop, not the
@@ -7,6 +7,7 @@ import { useAppHotkeys } from "../../hooks/useAppHotkeys";
7
7
  import { usePlayerStore } from "../../player/store/playerStore";
8
8
  import type { DomEditSelection } from "./domEditing";
9
9
  import { SnapToolbar } from "./SnapToolbar";
10
+ import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
10
11
  import { usePreviewGuidesStore } from "./previewGuidesStore";
11
12
 
12
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -17,14 +18,18 @@ afterEach(() => {
17
18
  usePlayerStore.getState().reset();
18
19
  });
19
20
 
20
- function renderToolbar(onSnapChange = vi.fn()) {
21
+ function renderToolbar() {
21
22
  const host = document.createElement("div");
22
23
  document.body.append(host);
23
24
  const root = createRoot(host);
24
25
  act(() => {
25
- root.render(<SnapToolbar onSnapChange={onSnapChange} />);
26
+ root.render(
27
+ <PreviewOverlayProvider>
28
+ <SnapToolbar />
29
+ </PreviewOverlayProvider>,
30
+ );
26
31
  });
27
- return { root, onSnapChange };
32
+ return { root };
28
33
  }
29
34
 
30
35
  function AppHotkeyHarness() {
@@ -54,12 +59,13 @@ function AppHotkeyHarness() {
54
59
  handleDuplicate: vi.fn(async () => false),
55
60
  onResetKeyframes: vi.fn(() => false),
56
61
  onDeleteSelectedKeyframes: vi.fn(),
62
+ readOnlyPreview: false,
57
63
  });
58
64
 
59
65
  return null;
60
66
  }
61
67
 
62
- function renderToolbarWithAppHotkeys(onSnapChange = vi.fn()) {
68
+ function renderToolbarWithAppHotkeys() {
63
69
  const host = document.createElement("div");
64
70
  document.body.append(host);
65
71
  const root = createRoot(host);
@@ -67,16 +73,18 @@ function renderToolbarWithAppHotkeys(onSnapChange = vi.fn()) {
67
73
  root.render(
68
74
  <>
69
75
  <AppHotkeyHarness />
70
- <SnapToolbar onSnapChange={onSnapChange} />
76
+ <PreviewOverlayProvider>
77
+ <SnapToolbar />
78
+ </PreviewOverlayProvider>
71
79
  </>,
72
80
  );
73
81
  });
74
- return { root, onSnapChange };
82
+ return { root };
75
83
  }
76
84
 
77
85
  describe("SnapToolbar keyboard shortcuts", () => {
78
86
  it("toggles snap on an unclaimed S keypress", () => {
79
- const { root, onSnapChange } = renderToolbar();
87
+ const { root } = renderToolbar();
80
88
 
81
89
  act(() => {
82
90
  document.dispatchEvent(
@@ -84,12 +92,14 @@ describe("SnapToolbar keyboard shortcuts", () => {
84
92
  );
85
93
  });
86
94
 
87
- expect(onSnapChange).toHaveBeenCalledWith(expect.objectContaining({ snapEnabled: false }));
95
+ expect(
96
+ JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}").snapEnabled,
97
+ ).toBe(false);
88
98
  act(() => root.unmount());
89
99
  });
90
100
 
91
101
  it("does not toggle snap when another handler already prevented S", () => {
92
- const { root, onSnapChange } = renderToolbar();
102
+ const { root } = renderToolbar();
93
103
  const event = new KeyboardEvent("keydown", {
94
104
  key: "s",
95
105
  bubbles: true,
@@ -101,12 +111,14 @@ describe("SnapToolbar keyboard shortcuts", () => {
101
111
  document.dispatchEvent(event);
102
112
  });
103
113
 
104
- expect(onSnapChange).not.toHaveBeenCalled();
114
+ expect(
115
+ JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}").snapEnabled,
116
+ ).not.toBe(false);
105
117
  act(() => root.unmount());
106
118
  });
107
119
 
108
120
  it("does not toggle snap when the app split shortcut claims S without a selected clip", () => {
109
- const { root, onSnapChange } = renderToolbarWithAppHotkeys();
121
+ const { root } = renderToolbarWithAppHotkeys();
110
122
 
111
123
  act(() => {
112
124
  document.dispatchEvent(
@@ -114,7 +126,9 @@ describe("SnapToolbar keyboard shortcuts", () => {
114
126
  );
115
127
  });
116
128
 
117
- expect(onSnapChange).not.toHaveBeenCalled();
129
+ expect(
130
+ JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}").snapEnabled,
131
+ ).not.toBe(false);
118
132
  act(() => root.unmount());
119
133
  });
120
134
  });
@@ -1,59 +1,26 @@
1
1
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
2
  import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
3
- import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
4
3
  import { usePlayerStore } from "../../player/store/playerStore";
5
- import { usePreviewGuidesStore } from "./previewGuidesStore";
6
-
7
- const SNAP_DEFAULTS = {
8
- snapEnabled: true,
9
- gridVisible: false,
10
- gridSpacing: 50,
11
- snapToGrid: false,
12
- };
13
-
14
- // fallow-ignore-next-line complexity
15
- function readSnapPrefs() {
16
- const prefs = readStudioUiPreferences();
17
- return {
18
- snapEnabled: prefs.snapEnabled ?? SNAP_DEFAULTS.snapEnabled,
19
- gridVisible: prefs.gridVisible ?? SNAP_DEFAULTS.gridVisible,
20
- gridSpacing: prefs.gridSpacing ?? SNAP_DEFAULTS.gridSpacing,
21
- snapToGrid: prefs.snapToGrid ?? SNAP_DEFAULTS.snapToGrid,
22
- };
23
- }
24
-
25
- interface SnapToolbarProps {
26
- onSnapChange?: (prefs: {
27
- snapEnabled: boolean;
28
- gridVisible: boolean;
29
- gridSpacing: number;
30
- snapToGrid: boolean;
31
- }) => void;
32
- }
4
+ import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
33
5
 
34
6
  // fallow-ignore-next-line complexity
35
- export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolbarProps) {
36
- const [prefs, setPrefs] = useState(readSnapPrefs);
7
+ export const SnapToolbar = memo(function SnapToolbar() {
37
8
  const [gridPopoverOpen, setGridPopoverOpen] = useState(false);
9
+ const { state, actions } = usePreviewOverlayContext();
10
+ const { snapPrefs: prefs, rulerVisible, safeMarginsVisible } = state;
38
11
  // Motion-path "set destination" toggle — shown only when the selected element
39
12
  // can take a path; arms a single canvas click to place it (MotionPathOverlay).
40
13
  const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
41
14
  const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
42
15
  const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
43
- const guides = usePreviewGuidesStore();
44
16
  const popoverRef = useRef<HTMLDivElement>(null);
45
17
  const gridButtonRef = useRef<HTMLButtonElement>(null);
46
18
 
47
19
  const updatePrefs = useCallback(
48
20
  (patch: Partial<typeof prefs>) => {
49
- setPrefs((prev) => {
50
- const next = { ...prev, ...patch };
51
- writeStudioUiPreferences(patch);
52
- onSnapChange?.(next);
53
- return next;
54
- });
21
+ actions.setSnapPrefs(patch);
55
22
  },
56
- [onSnapChange],
23
+ [actions],
57
24
  );
58
25
 
59
26
  const toggleSnap = useCallback(() => {
@@ -74,16 +41,16 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
74
41
  if (t instanceof HTMLIFrameElement) return;
75
42
  if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
76
43
  e.preventDefault();
77
- updatePrefs({ snapEnabled: !readSnapPrefs().snapEnabled });
44
+ updatePrefs({ snapEnabled: !prefs.snapEnabled });
78
45
  }
79
46
  if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
80
47
  e.preventDefault();
81
- updatePrefs({ gridVisible: !readSnapPrefs().gridVisible });
48
+ updatePrefs({ gridVisible: !prefs.gridVisible });
82
49
  }
83
50
  };
84
51
  document.addEventListener("keydown", handleKeyDown);
85
52
  return () => document.removeEventListener("keydown", handleKeyDown);
86
- }, [updatePrefs]);
53
+ }, [prefs.gridVisible, prefs.snapEnabled, updatePrefs]);
87
54
 
88
55
  useEffect(() => {
89
56
  if (!gridPopoverOpen) return;
@@ -123,23 +90,27 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
123
90
  ["rulerVisible", "Ruler", Ruler],
124
91
  ["safeMarginsVisible", "Safe margins", FrameCorners],
125
92
  ] as const
126
- ).map(([key, label, Icon]) => (
127
- <button
128
- key={key}
129
- type="button"
130
- className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
131
- guides[key]
132
- ? "bg-studio-accent/20 text-studio-accent"
133
- : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
134
- }`}
135
- onClick={() => guides.toggle(key)}
136
- title={`${label} ${guides[key] ? "on" : "off"}`}
137
- aria-label={`Toggle ${label.toLowerCase()}`}
138
- aria-pressed={guides[key]}
139
- >
140
- <Icon size={16} weight={guides[key] ? "fill" : "regular"} />
141
- </button>
142
- ))}
93
+ ).map(([key, label, Icon]) => {
94
+ const visible = key === "rulerVisible" ? rulerVisible : safeMarginsVisible;
95
+ const toggle = key === "rulerVisible" ? actions.toggleRulers : actions.toggleSafeMargins;
96
+ return (
97
+ <button
98
+ key={key}
99
+ type="button"
100
+ className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
101
+ visible
102
+ ? "bg-studio-accent/20 text-studio-accent"
103
+ : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
104
+ }`}
105
+ onClick={toggle}
106
+ title={`${label} ${visible ? "on" : "off"}`}
107
+ aria-label={`Toggle ${label.toLowerCase()}`}
108
+ aria-pressed={visible}
109
+ >
110
+ <Icon size={16} weight={visible ? "fill" : "regular"} />
111
+ </button>
112
+ );
113
+ })}
143
114
  <button
144
115
  type="button"
145
116
  className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
@@ -0,0 +1,16 @@
1
+ import { vi } from "vitest";
2
+
3
+ vi.mock("./useDomEditCompositionRect", () => ({
4
+ useDomEditCompositionRect: () => ({
5
+ left: 0,
6
+ top: 0,
7
+ width: 800,
8
+ height: 450,
9
+ scaleX: 1,
10
+ scaleY: 1,
11
+ }),
12
+ }));
13
+
14
+ vi.mock("./offCanvasIndicatorRefresh", () => ({
15
+ startOffCanvasIndicatorRefresh: () => () => undefined,
16
+ }));