diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx
index 63c161257..ccb309495 100644
--- a/packages/studio/src/components/StudioPreviewArea.tsx
+++ b/packages/studio/src/components/StudioPreviewArea.tsx
@@ -1,8 +1,9 @@
-import type { ReactNode } from "react";
+import { useState, type ReactNode } from "react";
import { NLELayout } from "./nle/NLELayout";
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
import { DomEditOverlay } from "./editor/DomEditOverlay";
+import { SnapToolbar } from "./editor/SnapToolbar";
import { StudioFeedbackBar } from "./StudioFeedbackBar";
import type { TimelineElement } from "../player";
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
@@ -14,6 +15,7 @@ import {
import { useStudioContext } from "../contexts/StudioContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
+import { readStudioUiPreferences } from "../utils/studioUiPreferences";
export interface StudioPreviewAreaProps {
timelineToolbar: ReactNode;
@@ -103,6 +105,17 @@ export function StudioPreviewArea({
handleDomRotationCommit,
} = useDomEditContext();
+ // 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,
+ };
+ });
+
return (
@@ -157,31 +170,36 @@ export function StudioPreviewArea({
) : captionEditMode ? (
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
-
+ <>
+
+
+ >
) : null
}
timelineFooter={
diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx
index 56d446c83..ec289fc78 100644
--- a/packages/studio/src/components/editor/DomEditOverlay.tsx
+++ b/packages/studio/src/components/editor/DomEditOverlay.tsx
@@ -1,4 +1,5 @@
-import { memo, useMemo, useRef, type RefObject } from "react";
+import { memo, useMemo, useRef, useState, type RefObject } from "react";
+import { useMountEffect } from "../../hooks/useMountEffect";
import { type DomEditSelection } from "./domEditing";
import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
import {
@@ -10,6 +11,8 @@ import {
} from "./domEditOverlayGestures";
import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
+import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
+import { GridOverlay } from "./GridOverlay";
// Re-exports for external consumers — preserving existing import paths.
export {
@@ -61,6 +64,8 @@ interface DomEditOverlayProps {
next: { width: number; height: number },
) => Promise | void;
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise | void;
+ gridVisible?: boolean;
+ gridSpacing?: number;
}
export const DomEditOverlay = memo(function DomEditOverlay({
@@ -75,6 +80,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onCanvasPointerLeave,
onSelectionChange,
onBlockedMove,
+ gridVisible = false,
+ gridSpacing = 50,
onManualDragStart,
onPathOffsetCommit,
onGroupPathOffsetCommit,
@@ -89,6 +96,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const suppressNextBoxClickRef = useRef(false);
const suppressNextBoxMouseDownRef = useRef(false);
const suppressNextOverlayMouseDownRef = useRef(false);
+ const snapGuidesRef = useRef(null);
const rafPausedRef = useRef(false);
const selectionRef = useRef(selection);
@@ -136,6 +144,50 @@ export const DomEditOverlay = memo(function DomEditOverlay({
rafPausedRef,
});
+ const [compRect, setCompRect] = useState({
+ left: 0,
+ top: 0,
+ width: 0,
+ height: 0,
+ scaleX: 1,
+ scaleY: 1,
+ });
+ useMountEffect(() => {
+ let frame = 0;
+ // fallow-ignore-next-line complexity
+ const update = () => {
+ frame = requestAnimationFrame(update);
+ const iframe = iframeRef.current;
+ const overlayEl = overlayRef.current;
+ if (!iframe || !overlayEl) return;
+ const iRect = iframe.getBoundingClientRect();
+ const oRect = overlayEl.getBoundingClientRect();
+ const left = iRect.left - oRect.left;
+ const top = iRect.top - oRect.top;
+ if (iRect.width <= 0 || iRect.height <= 0) return;
+ const doc = iframe.contentDocument;
+ const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement;
+ const dw = Number.parseFloat(root?.getAttribute("data-width") ?? "");
+ const dh = Number.parseFloat(root?.getAttribute("data-height") ?? "");
+ const scaleX = dw > 0 ? iRect.width / dw : 1;
+ const scaleY = dh > 0 ? iRect.height / dh : 1;
+ setCompRect((prev) => {
+ if (
+ Math.abs(prev.left - left) < 0.5 &&
+ Math.abs(prev.top - top) < 0.5 &&
+ Math.abs(prev.width - iRect.width) < 0.5 &&
+ Math.abs(prev.height - iRect.height) < 0.5 &&
+ Math.abs(prev.scaleX - scaleX) < 0.001 &&
+ Math.abs(prev.scaleY - scaleY) < 0.001
+ )
+ return prev;
+ return { left, top, width: iRect.width, height: iRect.height, scaleX, scaleY };
+ });
+ };
+ frame = requestAnimationFrame(update);
+ return () => cancelAnimationFrame(frame);
+ });
+
const gestures = createDomEditOverlayGestureHandlers({
overlayRef,
iframeRef,
@@ -158,6 +210,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onRotationCommitRef,
onCanvasPointerMoveRef,
onCanvasMouseDown,
+ snapGuidesRef,
});
const selectionKey = useMemo(() => {
@@ -192,6 +245,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
}
};
+ // fallow-ignore-next-line complexity
const handleOverlayPointerDown = (event: React.PointerEvent) => {
if (!allowCanvasMovement || event.button !== 0) return;
if (event.shiftKey) {
@@ -387,6 +441,21 @@ export const DomEditOverlay = memo(function DomEditOverlay({
>
)}
+
+
);
});
diff --git a/packages/studio/src/components/editor/GridOverlay.tsx b/packages/studio/src/components/editor/GridOverlay.tsx
new file mode 100644
index 000000000..2a65db6f1
--- /dev/null
+++ b/packages/studio/src/components/editor/GridOverlay.tsx
@@ -0,0 +1,49 @@
+import { memo } from "react";
+
+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) {
+ if (!visible || spacing <= 0) return null;
+
+ const overlaySpacingX = spacing * scaleX;
+ const overlaySpacingY = spacing * scaleY;
+
+ if (overlaySpacingX < 4 || overlaySpacingY < 4) return null;
+
+ return (
+