From 243ecd1d1080e96d38c17e1189688aba64885312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 20 Sep 2026 14:37:50 -0400 Subject: [PATCH] feat(studio): compose preview overlay guides (#4220) * feat(studio): compose preview overlay guides * fix(studio): sync late preview iframe mounts * fix(studio): make preview iframe ownership explicit --- docs/studio/index.mdx | 25 ++++++ .../src/components/editor/DomEditOverlay.tsx | 15 ---- .../src/components/editor/GridOverlay.tsx | 35 +++----- .../components/editor/PreviewGuides.test.tsx | 7 +- .../src/components/editor/PreviewGuides.tsx | 18 ++-- .../editor/PreviewOverlayProvider.test.tsx | 90 +++++++++++++++++++ .../editor/PreviewOverlayProvider.tsx | 86 ++++++++++++++++++ .../components/editor/SnapToolbar.test.tsx | 37 +++++--- .../src/components/editor/SnapToolbar.tsx | 89 +++++++----------- .../editor/usePreviewCompositionRect.test.tsx | 32 ++++--- .../editor/usePreviewCompositionRect.ts | 24 ++--- .../src/components/nle/PreviewOverlays.tsx | 20 +---- .../studio/src/components/nle/PreviewPane.tsx | 7 +- .../components/nle/previewReadOnly.test.tsx | 1 + packages/studio/src/index.ts | 12 +++ 15 files changed, 335 insertions(+), 163 deletions(-) create mode 100644 packages/studio/src/components/editor/PreviewOverlayProvider.test.tsx create mode 100644 packages/studio/src/components/editor/PreviewOverlayProvider.tsx diff --git a/docs/studio/index.mdx b/docs/studio/index.mdx index b3dd2e905d..9cbebeb714 100644 --- a/docs/studio/index.mdx +++ b/docs/studio/index.mdx @@ -42,6 +42,31 @@ npx hyperframes preview You do not need to learn every panel. Choose the surface that matches the problem in front of you. +## Compose preview guides in a host app + +Host apps can compose the same rulers, safe margins, grid, and snap toolbar that +Studio uses over their own preview iframe. The provider owns the iframe geometry +and shared preferences, so the host supplies its current iframe element and chooses the +parts to render: + +```tsx +import { + GridOverlay, + PreviewGuides, + PreviewOverlayProvider, + SnapToolbar, +} from "@hyperframes/studio"; +function Preview({ iframe }: { iframe: HTMLIFrameElement | null }) { + return ( + + + + + + ); +} +``` + | You want to change | Start with | | ----------------------------------------------- | -------------------------------- | | Something visible in the current frame | Canvas, then Design or Layers | diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 2d68102d1d..d5e62a0c7b 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -20,7 +20,6 @@ import { ChildRectOutlines, OffCanvasIndicators } from "./OffCanvasIndicators"; import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures"; import { useDomEditNudge } from "./useDomEditNudge"; import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay"; -import { GridOverlay } from "./GridOverlay"; import type { GestureRecordingState } from "./GestureRecordControl"; import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome"; import { hugRectForElement } from "./domEditOverlayCrop"; @@ -84,8 +83,6 @@ interface DomEditOverlayProps { ) => Promise | void; onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise | void; onStyleCommit?: (property: string, value: string) => Promise | void; - gridVisible?: boolean; - gridSpacing?: number; recordingState?: GestureRecordingState; onToggleRecording?: () => void; onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void; @@ -125,8 +122,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onCanvasPointerLeave, onSelectionChange, onBlockedMove, - gridVisible = false, - gridSpacing = 50, onManualDragStart, onPathOffsetCommit, onGroupPathOffsetCommit, @@ -555,16 +550,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({ /> )} - { - createRoot(host).render(); + createRoot(host).render( + + + , + ); }); return host; } diff --git a/packages/studio/src/components/editor/PreviewGuides.tsx b/packages/studio/src/components/editor/PreviewGuides.tsx index 91f38ca7ab..01d156aa5f 100644 --- a/packages/studio/src/components/editor/PreviewGuides.tsx +++ b/packages/studio/src/components/editor/PreviewGuides.tsx @@ -1,30 +1,28 @@ import { memo, useRef, type RefObject } from "react"; import { useDomEditCompositionRect } from "./useDomEditCompositionRect"; -import { RULER_GUTTER_PX, usePreviewGuidesStore } from "./previewGuidesStore"; +import { RULER_GUTTER_PX } from "./previewGuidesStore"; import { SAFE_BOX_PERCENTS, safeBoxInsetPercent } from "../../utils/previewSafeMargins"; - -interface PreviewGuidesProps { - iframeRef: RefObject; -} +import { usePreviewOverlayContext } from "./PreviewOverlayProvider"; const TICKS = Array.from({ length: 11 }, (_, i) => i * 10); const INK = "color-mix(in srgb, white 70%, transparent)"; /** Ruler and safe-margin boxes drawn over the preview pane, never inside the composition. */ -export const PreviewGuides = memo(function PreviewGuides({ iframeRef }: PreviewGuidesProps) { - const rulerVisible = usePreviewGuidesStore((s) => s.rulerVisible); - const safeMarginsVisible = usePreviewGuidesStore((s) => s.safeMarginsVisible); +export const PreviewGuides = memo(function PreviewGuides() { + const { state } = usePreviewOverlayContext(); + const { rulerVisible, safeMarginsVisible } = state; if (!rulerVisible && !safeMarginsVisible) return null; return ( ); }); -interface ActiveGuidesProps extends PreviewGuidesProps { +interface ActiveGuidesProps { + iframeRef: RefObject; rulerVisible: boolean; safeMarginsVisible: boolean; } diff --git a/packages/studio/src/components/editor/PreviewOverlayProvider.test.tsx b/packages/studio/src/components/editor/PreviewOverlayProvider.test.tsx new file mode 100644 index 0000000000..5a21b80adb --- /dev/null +++ b/packages/studio/src/components/editor/PreviewOverlayProvider.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { GridOverlay } from "./GridOverlay"; +import { PreviewOverlayProvider } from "./PreviewOverlayProvider"; +import { usePreviewIframeStore } from "../../player/store/previewIframeStore"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; + window.localStorage.clear(); + usePreviewIframeStore.getState().setIframe(null); +}); + +function compositionFrame() { + const iframe = document.createElement("iframe"); + iframe.getBoundingClientRect = () => new DOMRect(12, 18, 640, 360); + const root = iframe.contentDocument?.createElement("div"); + root?.setAttribute("data-composition-id", "main"); + root?.setAttribute("data-width", "1280"); + root?.setAttribute("data-height", "720"); + if (root) iframe.contentDocument?.body.append(root); + document.body.append(iframe); + return iframe; +} + +describe("PreviewOverlayProvider", () => { + it("derives grid geometry after the iframe prop becomes available", async () => { + window.localStorage.setItem( + "hf-studio-ui-preferences", + JSON.stringify({ gridVisible: true, gridSpacing: 20 }), + ); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + act(() => { + root.render( + + + , + ); + }); + expect(host.querySelector('[aria-hidden="true"]')).not.toBeNull(); + + const iframe = compositionFrame(); + act(() => { + root.render( + + + , + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + }); + + const grid = host.querySelector('[aria-hidden="true"]'); + expect([grid?.style.left, grid?.style.top, grid?.style.width, grid?.style.height]).toEqual([ + "12px", + "18px", + "640px", + "360px", + ]); + expect(grid?.style.backgroundSize).toBe("20px 20px"); + act(() => root.unmount()); + }); + + it("does not clear an iframe store entry owned elsewhere on unmount", () => { + const ownerIframe = compositionFrame(); + usePreviewIframeStore.getState().setIframe(ownerIframe); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + act(() => { + root.render( + + + , + ); + }); + act(() => root.unmount()); + + expect(usePreviewIframeStore.getState().iframe).toBe(ownerIframe); + }); +}); diff --git a/packages/studio/src/components/editor/PreviewOverlayProvider.tsx b/packages/studio/src/components/editor/PreviewOverlayProvider.tsx new file mode 100644 index 0000000000..08edbec582 --- /dev/null +++ b/packages/studio/src/components/editor/PreviewOverlayProvider.tsx @@ -0,0 +1,86 @@ +import { createContext, useContext, useRef, useState, type ReactNode, type RefObject } from "react"; +import { + usePreviewCompositionRect, + type PreviewCompositionRect, +} from "./usePreviewCompositionRect"; +import { usePreviewGuidesStore } from "./previewGuidesStore"; +import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; +import { useLivePreviewIframe } from "../../player/store/previewIframeStore"; + +export interface PreviewSnapPreferences { + snapEnabled: boolean; + gridVisible: boolean; + gridSpacing: number; + snapToGrid: boolean; +} + +interface PreviewOverlayContextValue { + state: { + snapPrefs: PreviewSnapPreferences; + rulerVisible: boolean; + safeMarginsVisible: boolean; + iframeRef: RefObject; + compositionRect: PreviewCompositionRect; + }; + actions: { + setSnapPrefs: (patch: Partial) => void; + toggleRulers: () => void; + toggleSafeMargins: () => void; + }; +} + +const PreviewOverlayContext = createContext(null); + +function readSnapPrefs(): PreviewSnapPreferences { + const prefs = readStudioUiPreferences(); + return { + snapEnabled: prefs.snapEnabled ?? true, + gridVisible: prefs.gridVisible ?? false, + gridSpacing: prefs.gridSpacing ?? 50, + snapToGrid: prefs.snapToGrid ?? false, + }; +} + +export interface PreviewOverlayProviderProps { + iframe?: HTMLIFrameElement | null; + children: ReactNode; +} + +export function PreviewOverlayProvider({ iframe, children }: PreviewOverlayProviderProps) { + const overlayRef = useRef(null); + const [snapPrefs, setSnapPrefs] = useState(readSnapPrefs); + const rulerVisible = usePreviewGuidesStore((state) => state.rulerVisible); + const safeMarginsVisible = usePreviewGuidesStore((state) => state.safeMarginsVisible); + const liveIframe = useLivePreviewIframe(); + const resolvedIframe = iframe ?? liveIframe; + const iframeRef = useRef(resolvedIframe); + iframeRef.current = resolvedIframe; + const compositionRect = usePreviewCompositionRect(overlayRef, resolvedIframe); + + const contextValue: PreviewOverlayContextValue = { + state: { snapPrefs, rulerVisible, safeMarginsVisible, iframeRef, compositionRect }, + actions: { + setSnapPrefs: (patch) => { + writeStudioUiPreferences(patch); + setSnapPrefs((current) => ({ ...current, ...patch })); + }, + toggleRulers: () => usePreviewGuidesStore.getState().toggle("rulerVisible"), + toggleSafeMargins: () => usePreviewGuidesStore.getState().toggle("safeMarginsVisible"), + }, + }; + + return ( + +
+ {children} +
+
+ ); +} + +export function usePreviewOverlayContext(): PreviewOverlayContextValue { + const context = useContext(PreviewOverlayContext); + if (!context) + throw new Error("usePreviewOverlayContext must be used within PreviewOverlayProvider"); + return context; +} diff --git a/packages/studio/src/components/editor/SnapToolbar.test.tsx b/packages/studio/src/components/editor/SnapToolbar.test.tsx index 0abd1eee2a..5435f16dd3 100644 --- a/packages/studio/src/components/editor/SnapToolbar.test.tsx +++ b/packages/studio/src/components/editor/SnapToolbar.test.tsx @@ -7,6 +7,7 @@ import { useAppHotkeys } from "../../hooks/useAppHotkeys"; import { usePlayerStore } from "../../player/store/playerStore"; import type { DomEditSelection } from "./domEditing"; import { SnapToolbar } from "./SnapToolbar"; +import { PreviewOverlayProvider } from "./PreviewOverlayProvider"; import { usePreviewGuidesStore } from "./previewGuidesStore"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -17,14 +18,18 @@ afterEach(() => { usePlayerStore.getState().reset(); }); -function renderToolbar(onSnapChange = vi.fn()) { +function renderToolbar() { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { - root.render(); + root.render( + + + , + ); }); - return { root, onSnapChange }; + return { root }; } function AppHotkeyHarness() { @@ -60,7 +65,7 @@ function AppHotkeyHarness() { return null; } -function renderToolbarWithAppHotkeys(onSnapChange = vi.fn()) { +function renderToolbarWithAppHotkeys() { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); @@ -68,16 +73,18 @@ function renderToolbarWithAppHotkeys(onSnapChange = vi.fn()) { root.render( <> - + + + , ); }); - return { root, onSnapChange }; + return { root }; } describe("SnapToolbar keyboard shortcuts", () => { it("toggles snap on an unclaimed S keypress", () => { - const { root, onSnapChange } = renderToolbar(); + const { root } = renderToolbar(); act(() => { document.dispatchEvent( @@ -85,12 +92,14 @@ describe("SnapToolbar keyboard shortcuts", () => { ); }); - expect(onSnapChange).toHaveBeenCalledWith(expect.objectContaining({ snapEnabled: false })); + expect( + JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}").snapEnabled, + ).toBe(false); act(() => root.unmount()); }); it("does not toggle snap when another handler already prevented S", () => { - const { root, onSnapChange } = renderToolbar(); + const { root } = renderToolbar(); const event = new KeyboardEvent("keydown", { key: "s", bubbles: true, @@ -102,12 +111,14 @@ describe("SnapToolbar keyboard shortcuts", () => { document.dispatchEvent(event); }); - expect(onSnapChange).not.toHaveBeenCalled(); + expect( + JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}").snapEnabled, + ).not.toBe(false); act(() => root.unmount()); }); it("does not toggle snap when the app split shortcut claims S without a selected clip", () => { - const { root, onSnapChange } = renderToolbarWithAppHotkeys(); + const { root } = renderToolbarWithAppHotkeys(); act(() => { document.dispatchEvent( @@ -115,7 +126,9 @@ describe("SnapToolbar keyboard shortcuts", () => { ); }); - expect(onSnapChange).not.toHaveBeenCalled(); + expect( + JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}").snapEnabled, + ).not.toBe(false); act(() => root.unmount()); }); }); diff --git a/packages/studio/src/components/editor/SnapToolbar.tsx b/packages/studio/src/components/editor/SnapToolbar.tsx index b1fc55f539..a31bbfc058 100644 --- a/packages/studio/src/components/editor/SnapToolbar.tsx +++ b/packages/studio/src/components/editor/SnapToolbar.tsx @@ -1,59 +1,26 @@ import { memo, useCallback, useEffect, useRef, useState } from "react"; import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react"; -import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; import { usePlayerStore } from "../../player/store/playerStore"; -import { usePreviewGuidesStore } from "./previewGuidesStore"; - -const SNAP_DEFAULTS = { - snapEnabled: true, - gridVisible: false, - gridSpacing: 50, - snapToGrid: false, -}; +import { usePreviewOverlayContext } from "./PreviewOverlayProvider"; // fallow-ignore-next-line complexity -function readSnapPrefs() { - const prefs = readStudioUiPreferences(); - return { - snapEnabled: prefs.snapEnabled ?? SNAP_DEFAULTS.snapEnabled, - gridVisible: prefs.gridVisible ?? SNAP_DEFAULTS.gridVisible, - gridSpacing: prefs.gridSpacing ?? SNAP_DEFAULTS.gridSpacing, - snapToGrid: prefs.snapToGrid ?? SNAP_DEFAULTS.snapToGrid, - }; -} - -interface SnapToolbarProps { - onSnapChange?: (prefs: { - snapEnabled: boolean; - gridVisible: boolean; - gridSpacing: number; - snapToGrid: boolean; - }) => void; -} - -// fallow-ignore-next-line complexity -export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolbarProps) { - const [prefs, setPrefs] = useState(readSnapPrefs); +export const SnapToolbar = memo(function SnapToolbar() { const [gridPopoverOpen, setGridPopoverOpen] = useState(false); + const { state, actions } = usePreviewOverlayContext(); + const { snapPrefs: prefs, rulerVisible, safeMarginsVisible } = state; // Motion-path "set destination" toggle — shown only when the selected element // can take a path; arms a single canvas click to place it (MotionPathOverlay). const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable); const motionPathArmed = usePlayerStore((s) => s.motionPathArmed); const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed); - const guides = usePreviewGuidesStore(); const popoverRef = useRef(null); const gridButtonRef = useRef(null); const updatePrefs = useCallback( (patch: Partial) => { - setPrefs((prev) => { - const next = { ...prev, ...patch }; - writeStudioUiPreferences(patch); - onSnapChange?.(next); - return next; - }); + actions.setSnapPrefs(patch); }, - [onSnapChange], + [actions], ); const toggleSnap = useCallback(() => { @@ -74,16 +41,16 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb if (t instanceof HTMLIFrameElement) return; if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); - updatePrefs({ snapEnabled: !readSnapPrefs().snapEnabled }); + updatePrefs({ snapEnabled: !prefs.snapEnabled }); } if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); - updatePrefs({ gridVisible: !readSnapPrefs().gridVisible }); + updatePrefs({ gridVisible: !prefs.gridVisible }); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [updatePrefs]); + }, [prefs.gridVisible, prefs.snapEnabled, updatePrefs]); useEffect(() => { if (!gridPopoverOpen) return; @@ -123,23 +90,27 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb ["rulerVisible", "Ruler", Ruler], ["safeMarginsVisible", "Safe margins", FrameCorners], ] as const - ).map(([key, label, Icon]) => ( - - ))} + ).map(([key, label, Icon]) => { + const visible = key === "rulerVisible" ? rulerVisible : safeMarginsVisible; + const toggle = key === "rulerVisible" ? actions.toggleRulers : actions.toggleSafeMargins; + return ( + + ); + })}