feat(studio): wire snap engine into preview drag and resize gestures (#1223)

Connect snap engine and UI components to the preview canvas gesture
system. Dragging or resizing elements now shows Figma-style alignment
guides with snap-to-edge, snap-to-center, and grid snap.

- Collect snap targets once at gesture start, reuse per frame
- resolveSnapAdjustment called per pointermove during drag
- resolveResizeSnapAdjustment for resize gestures
- lastSnappedDx/Dy stored on GestureState for consistent drop
- Alt/Option key temporarily disables snap
- SnapToolbar rendered in preview area with snap prefs state
This commit is contained in:
Miguel Ángel
2026-06-05 18:11:25 -04:00
committed by GitHub
parent f8bf039a09
commit 0a965dc4ad
5 changed files with 285 additions and 37 deletions
@@ -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 { usePlayerStore } from "../player/store/playerStore";
@@ -15,6 +16,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;
@@ -112,6 +114,16 @@ export function StudioPreviewArea({
handleGsapDeleteAnimation,
} = useDomEditContext();
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 (
<div className="flex-1 flex flex-col relative min-w-0">
<div className="flex-1 min-h-0 relative">
@@ -215,31 +227,36 @@ export function StudioPreviewArea({
) : captionEditMode ? (
<CaptionOverlay iframeRef={previewIframeRef} />
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
<DomEditOverlay
iframeRef={previewIframeRef}
activeCompositionPath={activeCompPath}
hoverSelection={
STUDIO_PREVIEW_SELECTION_ENABLED &&
!captionEditMode &&
!compositionLoading &&
!isPlaying
? domEditHoverSelection
: null
}
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
onCanvasMouseDown={handlePreviewCanvasMouseDown}
onCanvasPointerMove={handlePreviewCanvasPointerMove}
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
onSelectionChange={applyDomSelection}
onBlockedMove={handleBlockedDomMove}
onManualDragStart={handleDomManualDragStart}
onPathOffsetCommit={handleDomPathOffsetCommit}
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
onBoxSizeCommit={handleDomBoxSizeCommit}
onRotationCommit={handleDomRotationCommit}
/>
<>
<DomEditOverlay
iframeRef={previewIframeRef}
activeCompositionPath={activeCompPath}
hoverSelection={
STUDIO_PREVIEW_SELECTION_ENABLED &&
!captionEditMode &&
!compositionLoading &&
!isPlaying
? domEditHoverSelection
: null
}
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
onCanvasMouseDown={handlePreviewCanvasMouseDown}
onCanvasPointerMove={handlePreviewCanvasPointerMove}
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
onSelectionChange={applyDomSelection}
onBlockedMove={handleBlockedDomMove}
onManualDragStart={handleDomManualDragStart}
onPathOffsetCommit={handleDomPathOffsetCommit}
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
onBoxSizeCommit={handleDomBoxSizeCommit}
onRotationCommit={handleDomRotationCommit}
gridVisible={snapPrefs.gridVisible}
gridSpacing={snapPrefs.gridSpacing}
/>
<SnapToolbar onSnapChange={setSnapPrefs} />
</>
) : null
}
timelineFooter={
@@ -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> | void;
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | 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<SnapGuidesState | null>(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<HTMLElement>("[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<HTMLDivElement>) => {
if (!allowCanvasMovement || event.button !== 0) return;
if (event.shiftKey) {
@@ -387,6 +441,21 @@ export const DomEditOverlay = memo(function DomEditOverlay({
</div>
</>
)}
<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}
overlayWidth={compRect.width}
overlayHeight={compRect.height}
/>
</div>
);
});
@@ -6,6 +6,7 @@ import type {
} from "./manualEdits";
import type { ManualOffsetDragMember } from "./manualOffsetDrag";
import type { GroupOverlayItem } from "./domEditOverlayGeometry";
import type { SnapContext } from "./snapTargetCollection";
export type GestureKind = "drag" | "resize" | "rotate";
@@ -36,6 +37,9 @@ export interface GestureState {
editScaleX: number;
editScaleY: number;
manualEditDragToken?: string;
snapContext?: SnapContext;
lastSnappedDx?: number;
lastSnappedDy?: number;
}
export interface GroupGestureState {
@@ -43,6 +47,9 @@ export interface GroupGestureState {
startY: number;
originItems: GroupOverlayItem[];
members: ManualOffsetDragMember[];
snapContext?: SnapContext;
lastSnappedDx?: number;
lastSnappedDy?: number;
}
export interface BlockedMoveState {
@@ -23,6 +23,7 @@ import {
} from "./domEditOverlayGeometry";
import { type GestureKind, type GestureState } from "./domEditOverlayGestures";
import type { UseDomEditOverlayGesturesOptions } from "./useDomEditOverlayGestures";
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
export function startGroupDrag(
e: React.PointerEvent<HTMLElement>,
@@ -61,6 +62,20 @@ export function startGroupDrag(
members.push(result.member);
}
const overlayEl = opts.overlayRef.current;
const iframe = opts.iframeRef.current;
const snapContext =
overlayEl && iframe
? collectSnapContext({
overlayEl,
iframe,
excludeElements: buildExcludeElements({
iframe,
groupSelections: items.map((i) => i.selection),
}),
})
: undefined;
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
@@ -70,10 +85,12 @@ export function startGroupDrag(
startY: e.clientY,
originItems: items,
members,
snapContext,
};
return true;
}
// fallow-ignore-next-line complexity
export function startGesture(
kind: GestureKind,
e: React.PointerEvent<HTMLElement>,
@@ -124,6 +141,16 @@ export function startGesture(
const overlayBounds = overlayEl?.getBoundingClientRect();
const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
const iframe = opts.iframeRef.current;
const snapContext =
(kind === "drag" || kind === "resize") && overlayEl && iframe
? collectSnapContext({
overlayEl,
iframe,
excludeElements: buildExcludeElements({ iframe, selection: sel }),
})
: undefined;
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
@@ -150,6 +177,7 @@ export function startGesture(
editScaleX: rect.editScaleX,
editScaleY: rect.editScaleY,
manualEditDragToken,
snapContext,
};
return true;
}
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
/**
* Gesture handling for DomEditOverlay.
* Owns: onPointerMove, onPointerUp, clearPointerState.
@@ -23,7 +24,12 @@ import {
restoreStudioPathOffset,
restoreStudioRotation,
} from "./manualEdits";
import { type GroupOverlayItem, type OverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
import {
type GroupOverlayItem,
type OverlayRect,
resolveDomEditGroupOverlayRect,
toOverlayRect,
} from "./domEditOverlayGeometry";
import {
BLOCKED_MOVE_THRESHOLD_PX,
type BlockedMoveState,
@@ -39,6 +45,13 @@ import {
startGesture as _startGesture,
startGroupDrag as _startGroupDrag,
} from "./domEditOverlayStartGesture";
import {
resolveSnapAdjustment,
resolveResizeSnapAdjustment,
resolveEquidistanceGuides,
SNAP_THRESHOLD_PX,
} from "./snapEngine";
import type { SnapGuidesState } from "./SnapGuideOverlay";
// Refs are stable across renders; values are read via .current.
export type UseDomEditOverlayGesturesOptions = {
@@ -79,6 +92,7 @@ export type UseDomEditOverlayGesturesOptions = {
e: React.MouseEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => void;
snapGuidesRef: RefObject<SnapGuidesState | null>;
};
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
@@ -111,6 +125,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
options?: { selection?: DomEditSelection; rect?: OverlayRect | null },
) => _startGesture(kind, e, opts, options);
// fallow-ignore-next-line complexity
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const g = opts.gestureRef.current;
const groupG = opts.groupGestureRef.current;
@@ -133,8 +148,48 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
}
if (groupG) {
const dx = e.clientX - groupG.startX;
const dy = e.clientY - groupG.startY;
let dx = e.clientX - groupG.startX;
let dy = e.clientY - groupG.startY;
const sc = groupG.snapContext;
if (sc?.snapEnabled && sc.targets.length > 0) {
const groupBounds = resolveDomEditGroupOverlayRect(
groupG.originItems.map((item) => item.rect),
);
if (groupBounds) {
const allTargets = sc.compositionTarget
? [...sc.targets, sc.compositionTarget]
: sc.targets;
const snap = resolveSnapAdjustment({
movingRect: groupBounds,
proposedDx: dx,
proposedDy: dy,
targets: allTargets,
gridEdges: sc.gridEdges ?? undefined,
threshold: SNAP_THRESHOLD_PX,
disabled: e.altKey,
});
dx = snap.dx;
dy = snap.dy;
const movedRect = {
left: groupBounds.left + dx,
top: groupBounds.top + dy,
width: groupBounds.width,
height: groupBounds.height,
};
const spacingGuides = e.altKey
? []
: resolveEquidistanceGuides({
movingRect: movedRect,
targets: allTargets,
threshold: SNAP_THRESHOLD_PX,
});
opts.snapGuidesRef.current = { guides: snap.guides, spacingGuides };
}
}
groupG.lastSnappedDx = dx;
groupG.lastSnappedDy = dy;
setDraftGroupOverlayItems(
groupG.originItems.map((item) => ({
...item,
@@ -146,8 +201,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
}
if (!g || !sel) return;
const dx = e.clientX - g.startX;
const dy = e.clientY - g.startY;
let dx = e.clientX - g.startX;
let dy = e.clientY - g.startY;
if (g.kind === "rotate") {
applyStudioRotationDraft(
@@ -167,6 +222,46 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
}
if (g.kind === "drag") {
const sc = g.snapContext;
if (sc?.snapEnabled && sc.targets.length > 0) {
const movingRect = {
left: g.originLeft,
top: g.originTop,
width: g.originWidth,
height: g.originHeight,
};
const allTargets = sc.compositionTarget
? [...sc.targets, sc.compositionTarget]
: sc.targets;
const snap = resolveSnapAdjustment({
movingRect,
proposedDx: dx,
proposedDy: dy,
targets: allTargets,
gridEdges: sc.gridEdges ?? undefined,
threshold: SNAP_THRESHOLD_PX,
disabled: e.altKey,
});
dx = snap.dx;
dy = snap.dy;
const movedRect = {
left: movingRect.left + dx,
top: movingRect.top + dy,
width: movingRect.width,
height: movingRect.height,
};
const spacingGuides = e.altKey
? []
: resolveEquidistanceGuides({
movingRect: movedRect,
targets: allTargets,
threshold: SNAP_THRESHOLD_PX,
});
opts.snapGuidesRef.current = { guides: snap.guides, spacingGuides };
}
g.lastSnappedDx = dx;
g.lastSnappedDy = dy;
const nextBoxLeft = g.originLeft + dx;
const nextBoxTop = g.originTop + dy;
setDraftOverlayRect({
@@ -184,6 +279,32 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
if (g.pathOffsetMember) applyManualOffsetDragDraft(g.pathOffsetMember, dx, dy);
} else {
if (!box) return;
const sc = g.snapContext;
if (sc?.snapEnabled && sc.targets.length > 0) {
const movingRect = {
left: g.originLeft,
top: g.originTop,
width: g.originWidth,
height: g.originHeight,
};
const allTargets = sc.compositionTarget
? [...sc.targets, sc.compositionTarget]
: sc.targets;
const snap = resolveResizeSnapAdjustment({
movingRect,
proposedDx: dx,
proposedDy: dy,
targets: allTargets,
gridEdges: sc.gridEdges ?? undefined,
threshold: SNAP_THRESHOLD_PX,
disabled: e.altKey,
});
dx = snap.dx;
dy = snap.dy;
opts.snapGuidesRef.current = { guides: snap.guides, spacingGuides: [] };
}
const nextSize = resolveDomEditResizeGesture({
originWidth: g.originWidth,
originHeight: g.originHeight,
@@ -223,7 +344,9 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
}
};
// fallow-ignore-next-line complexity
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
opts.snapGuidesRef.current = null;
const g = opts.gestureRef.current;
const groupG = opts.groupGestureRef.current;
const sel = g?.selection ?? opts.selectionRef.current;
@@ -233,13 +356,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
if (groupG) {
opts.groupGestureRef.current = null;
opts.rafPausedRef.current = false;
const dx = e.clientX - groupG.startX;
const dy = e.clientY - groupG.startY;
if (Math.hypot(dx, dy) < BLOCKED_MOVE_THRESHOLD_PX) {
const rawDx = e.clientX - groupG.startX;
const rawDy = e.clientY - groupG.startY;
if (Math.hypot(rawDx, rawDy) < BLOCKED_MOVE_THRESHOLD_PX) {
restoreGroupPathOffsets(groupG);
opts.suppressNextBoxClickRef.current = true;
return;
}
const dx = groupG.lastSnappedDx ?? rawDx;
const dy = groupG.lastSnappedDy ?? rawDy;
setDraftGroupOverlayItems(
groupG.originItems.map((item) => ({
...item,
@@ -327,8 +452,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
})
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
} else if (g.kind === "drag") {
const dx = e.clientX - g.startX;
const dy = e.clientY - g.startY;
const dx = g.lastSnappedDx ?? e.clientX - g.startX;
const dy = g.lastSnappedDy ?? e.clientY - g.startY;
if (!g.pathOffsetMember) return;
const finalOffset = applyManualOffsetDragCommit(g.pathOffsetMember, dx, dy);
const nextBoxLeft = g.originLeft + dx;
@@ -372,7 +497,9 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
}
};
// fallow-ignore-next-line complexity
const clearPointerState = (selectionRef: RefObject<DomEditSelection | null>) => {
opts.snapGuidesRef.current = null;
const groupG = opts.groupGestureRef.current;
if (groupG) restoreGroupPathOffsets(groupG);
const g = opts.gestureRef.current;