mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user