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
This commit is contained in:
Miguel Ángel
2026-09-20 14:37:50 -04:00
committed by GitHub
parent 85a1a8f789
commit 243ecd1d10
15 changed files with 335 additions and 163 deletions
+25
View File
@@ -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 (
<PreviewOverlayProvider iframe={iframe}>
<PreviewGuides />
<GridOverlay />
<SnapToolbar />
</PreviewOverlayProvider>
);
}
```
| You want to change | Start with |
| ----------------------------------------------- | -------------------------------- |
| Something visible in the current frame | Canvas, then Design or Layers |
@@ -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> | void;
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
onStyleCommit?: (property: string, value: string) => Promise<unknown> | 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({
/>
)}
<ZOrderCrossedFlash rect={zOrderFlashRect} />
<GridOverlay
visible={gridVisible}
spacing={gridSpacing}
scaleX={compRect.scaleX}
scaleY={compRect.scaleY}
compositionLeft={compRect.left}
compositionTop={compRect.top}
compositionWidth={compRect.width}
compositionHeight={compRect.height}
/>
<SnapGuideOverlay
snapGuidesRef={snapGuidesRef}
compositionLeft={compRect.left}
@@ -1,28 +1,17 @@
// fallow-ignore-file unused-file
import { memo } from "react";
import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
interface GridOverlayProps {
visible: boolean;
spacing: number;
scaleX: number;
scaleY: number;
compositionLeft: number;
compositionTop: number;
compositionWidth: number;
compositionHeight: number;
}
// fallow-ignore-next-line complexity
export const GridOverlay = memo(function GridOverlay({
visible,
spacing,
scaleX,
scaleY,
compositionLeft,
compositionTop,
compositionWidth,
compositionHeight,
}: GridOverlayProps) {
export const GridOverlay = memo(function GridOverlay() {
const { state } = usePreviewOverlayContext();
const { gridVisible: visible, gridSpacing: spacing } = state.snapPrefs;
const {
scaleX,
scaleY,
left: compositionLeft,
top: compositionTop,
width: compositionWidth,
height: compositionHeight,
} = state.compositionRect;
if (!visible || spacing <= 0) return null;
const overlaySpacingX = spacing * scaleX;
@@ -4,6 +4,7 @@ import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PreviewGuides } from "./PreviewGuides";
import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
import { usePreviewGuidesStore } from "./previewGuidesStore";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -26,7 +27,11 @@ function render(rulerVisible: boolean, safeMarginsVisible: boolean) {
const host = document.createElement("div");
document.body.append(host);
act(() => {
createRoot(host).render(<PreviewGuides iframeRef={{ current: null }} />);
createRoot(host).render(
<PreviewOverlayProvider>
<PreviewGuides />
</PreviewOverlayProvider>,
);
});
return host;
}
@@ -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<HTMLIFrameElement | null>;
}
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 (
<ActiveGuides
iframeRef={iframeRef}
iframeRef={state.iframeRef}
rulerVisible={rulerVisible}
safeMarginsVisible={safeMarginsVisible}
/>
);
});
interface ActiveGuidesProps extends PreviewGuidesProps {
interface ActiveGuidesProps {
iframeRef: RefObject<HTMLIFrameElement | null>;
rulerVisible: boolean;
safeMarginsVisible: boolean;
}
@@ -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(
<PreviewOverlayProvider iframe={null}>
<GridOverlay />
</PreviewOverlayProvider>,
);
});
expect(host.querySelector('[aria-hidden="true"]')).not.toBeNull();
const iframe = compositionFrame();
act(() => {
root.render(
<PreviewOverlayProvider iframe={iframe}>
<GridOverlay />
</PreviewOverlayProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 400));
});
const grid = host.querySelector<HTMLElement>('[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(
<PreviewOverlayProvider iframe={null}>
<GridOverlay />
</PreviewOverlayProvider>,
);
});
act(() => root.unmount());
expect(usePreviewIframeStore.getState().iframe).toBe(ownerIframe);
});
});
@@ -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<HTMLIFrameElement | null>;
compositionRect: PreviewCompositionRect;
};
actions: {
setSnapPrefs: (patch: Partial<PreviewSnapPreferences>) => void;
toggleRulers: () => void;
toggleSafeMargins: () => void;
};
}
const PreviewOverlayContext = createContext<PreviewOverlayContextValue | null>(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<HTMLDivElement | null>(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<HTMLIFrameElement | null>(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 (
<PreviewOverlayContext.Provider value={contextValue}>
<div ref={overlayRef} className="absolute inset-0">
{children}
</div>
</PreviewOverlayContext.Provider>
);
}
export function usePreviewOverlayContext(): PreviewOverlayContextValue {
const context = useContext(PreviewOverlayContext);
if (!context)
throw new Error("usePreviewOverlayContext must be used within PreviewOverlayProvider");
return context;
}
@@ -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(<SnapToolbar onSnapChange={onSnapChange} />);
root.render(
<PreviewOverlayProvider>
<SnapToolbar />
</PreviewOverlayProvider>,
);
});
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(
<>
<AppHotkeyHarness />
<SnapToolbar onSnapChange={onSnapChange} />
<PreviewOverlayProvider>
<SnapToolbar />
</PreviewOverlayProvider>
</>,
);
});
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());
});
});
@@ -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<HTMLDivElement>(null);
const gridButtonRef = useRef<HTMLButtonElement>(null);
const updatePrefs = useCallback(
(patch: Partial<typeof prefs>) => {
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]) => (
<button
key={key}
type="button"
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
guides[key]
? "bg-studio-accent/20 text-studio-accent"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
}`}
onClick={() => guides.toggle(key)}
title={`${label} ${guides[key] ? "on" : "off"}`}
aria-label={`Toggle ${label.toLowerCase()}`}
aria-pressed={guides[key]}
>
<Icon size={16} weight={guides[key] ? "fill" : "regular"} />
</button>
))}
).map(([key, label, Icon]) => {
const visible = key === "rulerVisible" ? rulerVisible : safeMarginsVisible;
const toggle = key === "rulerVisible" ? actions.toggleRulers : actions.toggleSafeMargins;
return (
<button
key={key}
type="button"
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
visible
? "bg-studio-accent/20 text-studio-accent"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
}`}
onClick={toggle}
title={`${label} ${visible ? "on" : "off"}`}
aria-label={`Toggle ${label.toLowerCase()}`}
aria-pressed={visible}
>
<Icon size={16} weight={visible ? "fill" : "regular"} />
</button>
);
})}
<button
type="button"
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
@@ -1,9 +1,9 @@
// @vitest-environment happy-dom
import { act, useRef } from "react";
import { act, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
import { resetOverlayFrameLoopForTests } from "./overlayFrameLoop";
import {
usePreviewCompositionRect,
type PreviewCompositionRect,
@@ -12,7 +12,7 @@ import {
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
usePreviewIframeStore.getState().setIframe(null);
resetOverlayFrameLoopForTests();
document.body.innerHTML = "";
});
@@ -24,9 +24,12 @@ function boxed<T extends HTMLElement>(el: T, box: [number, number, number, numbe
function mountProbe() {
let rect: PreviewCompositionRect | undefined;
let setIframe: (iframe: HTMLIFrameElement | null) => void = () => {};
function Probe() {
const ref = useRef<HTMLDivElement>(null);
rect = usePreviewCompositionRect(ref);
const [iframe, updateIframe] = useState<HTMLIFrameElement | null>(null);
setIframe = updateIframe;
rect = usePreviewCompositionRect(ref, iframe);
return (
<div
ref={(el) => {
@@ -38,8 +41,9 @@ function mountProbe() {
}
const host = document.createElement("div");
document.body.append(host);
act(() => createRoot(host).render(<Probe />));
return () => rect;
const root = createRoot(host);
act(() => root.render(<Probe />));
return { read: () => rect, setIframe, unmount: () => act(() => root.unmount()) };
}
function compositionFrame(box: [number, number, number, number], width: number, height: number) {
@@ -57,13 +61,13 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
describe("usePreviewCompositionRect", () => {
it("reports the live preview iframe's box relative to the overlay, scaled to the composition", async () => {
const read = mountProbe();
const probe = mountProbe();
const iframe = compositionFrame([110, 70, 960, 540], 1920, 1080);
await act(async () => {
usePreviewIframeStore.getState().setIframe(iframe);
probe.setIframe(iframe);
await settle();
});
expect(read()).toEqual({
expect(probe.read()).toEqual({
left: 100,
top: 50,
width: 960,
@@ -71,19 +75,20 @@ describe("usePreviewCompositionRect", () => {
scaleX: 0.5,
scaleY: 0.5,
});
probe.unmount();
});
it("follows the iframe when a reload replaces it", async () => {
const read = mountProbe();
const probe = mountProbe();
await act(async () => {
usePreviewIframeStore.getState().setIframe(compositionFrame([110, 70, 960, 540], 1920, 1080));
probe.setIframe(compositionFrame([110, 70, 960, 540], 1920, 1080));
await settle();
});
await act(async () => {
usePreviewIframeStore.getState().setIframe(compositionFrame([60, 40, 500, 500], 1000, 1000));
probe.setIframe(compositionFrame([60, 40, 500, 500], 1000, 1000));
await settle();
});
expect(read()).toEqual({
expect(probe.read()).toEqual({
left: 50,
top: 20,
width: 500,
@@ -91,5 +96,6 @@ describe("usePreviewCompositionRect", () => {
scaleX: 0.5,
scaleY: 0.5,
});
probe.unmount();
});
});
@@ -1,21 +1,21 @@
import { useMemo, type RefObject } from "react";
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
import { useEffect, useRef, type RefObject } from "react";
import { requestOverlayFrames } from "./overlayFrameLoop";
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
export type { DomEditCompositionRect as PreviewCompositionRect } from "./useDomEditCompositionRect";
/**
* Where the composition sits inside `overlayRef`, in overlay pixels, plus the scale from
* composition units to pixels. Follows the live preview iframe across reloads.
* composition units to pixels.
*/
export function usePreviewCompositionRect(overlayRef: RefObject<HTMLDivElement | null>) {
const iframeRef = useMemo<RefObject<HTMLIFrameElement | null>>(
() => ({
get current() {
return usePreviewIframeStore.getState().iframe;
},
}),
[],
);
export function usePreviewCompositionRect(
overlayRef: RefObject<HTMLDivElement | null>,
iframe: HTMLIFrameElement | null,
) {
const iframeRef = useRef<HTMLIFrameElement | null>(iframe);
iframeRef.current = iframe;
useEffect(() => {
requestOverlayFrames();
}, [iframe]);
return useDomEditCompositionRect({ iframeRef, overlayRef });
}
@@ -1,10 +1,11 @@
import { useCallback, useState } from "react";
import { useCallback } from "react";
import { CaptionOverlay } from "../../captions/components/CaptionOverlay";
import { useCaptionStore } from "../../captions/store";
import { DomEditOverlay } from "../editor/DomEditOverlay";
import { TopologyLens } from "../editor/TopologyLens";
import { MotionPathOverlay } from "../editor/MotionPathOverlay";
import { SnapToolbar } from "../editor/SnapToolbar";
import { GridOverlay } from "../editor/GridOverlay";
import { usePreviewReadOnly } from "../editor/previewReadOnlyContext";
import { useCompositionDimensions } from "../../hooks/useCompositionDimensions";
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
@@ -12,7 +13,6 @@ import {
useDomEditActionsContext,
useDomEditSelectionContext,
} from "../../contexts/DomEditContext";
import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
import { readHfId, type DomEditSelection } from "../editor/domEditing";
import { buildStableSelector } from "../editor/domEditingDom";
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
@@ -182,17 +182,6 @@ export function PreviewOverlays({
} = useDomEditActionsContext();
const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
// fallow-ignore-next-line complexity
const [snapPrefs, setSnapPrefs] = useState(() => {
const p = readStudioUiPreferences();
return {
snapEnabled: p.snapEnabled ?? true,
gridVisible: p.gridVisible ?? false,
gridSpacing: p.gridSpacing ?? 50,
snapToGrid: p.snapToGrid ?? false,
};
});
if (blockPreview) {
return (
<>
@@ -266,6 +255,7 @@ export function PreviewOverlays({
return (
<>
<TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
<GridOverlay />
<DomEditOverlay
iframeRef={previewIframeRef}
activeCompositionPath={activeCompPath}
@@ -325,13 +315,11 @@ export function PreviewOverlays({
}),
}).catch(() => undefined);
}}
gridVisible={snapPrefs.gridVisible}
gridSpacing={snapPrefs.gridSpacing}
recordingState={recordingState}
onToggleRecording={onToggleRecording}
onMarqueeSelect={applyMarqueeSelection}
/>
<SnapToolbar onSnapChange={setSnapPrefs} />
<SnapToolbar />
{!readOnly && (
<MotionPathOverlay
iframeRef={previewIframeRef}
@@ -7,6 +7,7 @@ import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
import { useNLEContext } from "./NLEContext";
import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
import { PreviewGuides } from "../editor/PreviewGuides";
import { PreviewOverlayProvider } from "../editor/PreviewOverlayProvider";
function subscribeFullscreen(cb: () => void) {
document.addEventListener("fullscreenchange", cb);
@@ -155,8 +156,10 @@ export function PreviewPane({
)}
<AssetPreviewOverlay />
</div>
<PreviewGuides iframeRef={iframeRef} />
{!isFullscreen && previewOverlay}
<PreviewOverlayProvider>
<PreviewGuides />
{!isFullscreen && previewOverlay}
</PreviewOverlayProvider>
</div>
{/* Transport row: no own background or border — the controls sit flat on
the preview panel's surface (CapCut-style). */}
@@ -32,6 +32,7 @@ vi.mock("../editor/MotionPathOverlay", () => ({
MotionPathOverlay: () => <i data-testid="motion-path" />,
}));
vi.mock("../editor/SnapToolbar", () => ({ SnapToolbar: () => null }));
vi.mock("../editor/GridOverlay", () => ({ GridOverlay: () => null }));
let root: Root;
let host: HTMLDivElement;
+12
View File
@@ -56,6 +56,18 @@ export type { TimelineElement, TimelineTimeRange } from "./player";
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
export type { PreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
export {
PreviewOverlayProvider,
usePreviewOverlayContext,
} from "./components/editor/PreviewOverlayProvider";
export type {
PreviewOverlayProviderProps,
PreviewSnapPreferences,
} from "./components/editor/PreviewOverlayProvider";
export { PreviewGuides } from "./components/editor/PreviewGuides";
export { GridOverlay } from "./components/editor/GridOverlay";
export { SnapToolbar } from "./components/editor/SnapToolbar";
export { usePreviewGuidesStore } from "./components/editor/previewGuidesStore";
// Editor
export { SourceEditor } from "./components/editor/SourceEditor";