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 ( + +
+
+ commitManualSize("width", next)} + /> +
+
+ commitManualSize("height", next)} + /> +
+ {element.capabilities.canApplyManualSize && ( + + )} +
+
commitManualRotation(next.replace("°", ""))} /> -
-
; + overlayWidth: number; + overlayHeight: number; +} + +export const SnapGuideOverlay = memo(function SnapGuideOverlay({ + snapGuidesRef, + overlayWidth, + overlayHeight, +}: SnapGuideOverlayProps) { + const guideElsRef = useRef<(HTMLDivElement | null)[]>([]); + const spacingElsRef = useRef<(HTMLDivElement | null)[]>([]); + const spacingLabelElsRef = useRef<(HTMLSpanElement | null)[]>([]); + const overlayWidthRef = useRef(overlayWidth); + overlayWidthRef.current = overlayWidth; + const overlayHeightRef = useRef(overlayHeight); + overlayHeightRef.current = overlayHeight; + + useMountEffect(() => { + let frame = 0; + + // fallow-ignore-next-line complexity + const update = () => { + frame = requestAnimationFrame(update); + + const state = snapGuidesRef.current; + const guides = state?.guides ?? []; + const spacingGuides = state?.spacingGuides ?? []; + const w = overlayWidthRef.current; + const h = overlayHeightRef.current; + + for (let i = 0; i < MAX_GUIDES; i++) { + const el = guideElsRef.current[i]; + if (!el) continue; + + const guide = guides[i]; + if (!guide) { + el.style.display = "none"; + continue; + } + + el.style.display = ""; + if (guide.axis === "x") { + el.style.left = `${guide.position}px`; + el.style.top = "0"; + el.style.width = "1px"; + el.style.height = `${h}px`; + } else { + el.style.left = "0"; + el.style.top = `${guide.position}px`; + el.style.width = `${w}px`; + el.style.height = "1px"; + } + } + + for (let i = 0; i < MAX_SPACING_GUIDES; i++) { + const el = spacingElsRef.current[i]; + const label = spacingLabelElsRef.current[i]; + if (!el) continue; + + const sg = spacingGuides[i]; + if (!sg) { + el.style.display = "none"; + continue; + } + + el.style.display = "flex"; + el.style.alignItems = "center"; + el.style.justifyContent = "center"; + if (sg.axis === "x") { + el.style.left = `${sg.position}px`; + el.style.top = `${sg.from}px`; + el.style.width = `${sg.size}px`; + el.style.height = `${sg.to - sg.from}px`; + el.style.borderLeft = `1px dashed ${SPACING_COLOR}`; + el.style.borderRight = `1px dashed ${SPACING_COLOR}`; + el.style.borderTop = "none"; + el.style.borderBottom = "none"; + } else { + el.style.left = `${sg.from}px`; + el.style.top = `${sg.position}px`; + el.style.width = `${sg.to - sg.from}px`; + el.style.height = `${sg.size}px`; + el.style.borderTop = `1px dashed ${SPACING_COLOR}`; + el.style.borderBottom = `1px dashed ${SPACING_COLOR}`; + el.style.borderLeft = "none"; + el.style.borderRight = "none"; + } + + if (label) { + label.textContent = `${Math.round(sg.size)}`; + } + } + }; + + frame = requestAnimationFrame(update); + return () => cancelAnimationFrame(frame); + }); + + return ( +