feat(studio): add snap guide overlay, toolbar, grid, and target collection (#1228)

React components and DOM utilities for the snap system:

- SnapGuideOverlay: pre-allocated div pool (6 guides + 4 spacing)
  for ref-driven guide line rendering during drag
- SnapToolbar: magnet/grid toggle with S/G keyboard shortcuts,
  right-click grid popover for spacing config
- GridOverlay: CSS repeating-linear-gradient grid, GPU composited
- snapTargetCollection: walks iframe DOM tree to collect visible
  elements as snap targets, cross-iframe safe (nodeType check)
This commit is contained in:
Miguel Ángel
2026-06-05 18:07:43 -04:00
committed by GitHub
parent 1863831a4d
commit f8bf039a09
5 changed files with 1000 additions and 0 deletions
@@ -0,0 +1,50 @@
// fallow-ignore-file unused-file
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 (
<div
aria-hidden="true"
className="pointer-events-none absolute"
style={{
left: compositionLeft,
top: compositionTop,
width: compositionWidth,
height: compositionHeight,
backgroundImage: [
`repeating-linear-gradient(90deg, rgba(255,255,255,0.12) 0px, rgba(255,255,255,0.12) 1px, transparent 1px, transparent ${overlaySpacingX}px)`,
`repeating-linear-gradient(0deg, rgba(255,255,255,0.12) 0px, rgba(255,255,255,0.12) 1px, transparent 1px, transparent ${overlaySpacingY}px)`,
].join(", "),
backgroundSize: `${overlaySpacingX}px ${overlaySpacingY}px`,
}}
/>
);
});
@@ -0,0 +1,166 @@
// fallow-ignore-file unused-file
import { memo, useRef, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import type { SnapGuide, SpacingGuide } from "./snapEngine";
export interface SnapGuidesState {
guides: SnapGuide[];
spacingGuides: SpacingGuide[];
}
const MAX_GUIDES = 6;
const MAX_SPACING_GUIDES = 4;
const GUIDE_COLOR = "rgba(255, 68, 204, 0.85)";
const SPACING_COLOR = "rgba(255, 68, 204, 0.6)";
const SPACING_BG = "rgba(255, 68, 204, 0.15)";
interface SnapGuideOverlayProps {
snapGuidesRef: RefObject<SnapGuidesState | null>;
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 (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
{Array.from({ length: MAX_GUIDES }, (_, i) => (
<div
key={`guide-${i}`}
ref={(el) => {
guideElsRef.current[i] = el;
}}
style={{
display: "none",
position: "absolute",
backgroundColor: GUIDE_COLOR,
zIndex: 50,
}}
/>
))}
{Array.from({ length: MAX_SPACING_GUIDES }, (_, i) => (
<div
key={`spacing-${i}`}
ref={(el) => {
spacingElsRef.current[i] = el;
}}
style={{
display: "none",
position: "absolute",
zIndex: 50,
}}
>
<span
ref={(el) => {
spacingLabelElsRef.current[i] = el;
}}
style={{
fontSize: "10px",
fontFamily: "monospace",
color: GUIDE_COLOR,
backgroundColor: SPACING_BG,
padding: "0 3px",
borderRadius: "2px",
lineHeight: "14px",
whiteSpace: "nowrap",
}}
/>
</div>
))}
</div>
);
});
@@ -0,0 +1,163 @@
// fallow-ignore-file unused-file
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { MagnetStraight, GridFour } from "@phosphor-icons/react";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
const SNAP_DEFAULTS = {
snapEnabled: true,
gridVisible: false,
gridSpacing: 50,
snapToGrid: false,
};
// 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);
const [gridPopoverOpen, setGridPopoverOpen] = useState(false);
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;
});
},
[onSnapChange],
);
const toggleSnap = useCallback(() => {
updatePrefs({ snapEnabled: !prefs.snapEnabled });
}, [prefs.snapEnabled, updatePrefs]);
const toggleGrid = useCallback(() => {
updatePrefs({ gridVisible: !prefs.gridVisible });
}, [prefs.gridVisible, updatePrefs]);
useEffect(() => {
// fallow-ignore-next-line complexity
const handleKeyDown = (e: KeyboardEvent) => {
const t = e.target;
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return;
if (t instanceof HTMLElement && t.isContentEditable) return;
if (t instanceof HTMLIFrameElement) return;
if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
updatePrefs({ snapEnabled: !readSnapPrefs().snapEnabled });
}
if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
updatePrefs({ gridVisible: !readSnapPrefs().gridVisible });
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [updatePrefs]);
useEffect(() => {
if (!gridPopoverOpen) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (popoverRef.current?.contains(target) || gridButtonRef.current?.contains(target)) return;
setGridPopoverOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [gridPopoverOpen]);
return (
<div className="absolute top-2 right-2 z-50 flex items-center gap-1">
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${
prefs.snapEnabled
? "bg-studio-accent/20 text-studio-accent"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
}`}
onClick={toggleSnap}
title={prefs.snapEnabled ? "Snap enabled (S)" : "Snap disabled (S)"}
aria-label="Toggle snap"
>
<MagnetStraight size={16} weight={prefs.snapEnabled ? "fill" : "regular"} />
</button>
<div className="relative">
<button
ref={gridButtonRef}
type="button"
className={`rounded-md p-1.5 transition-colors ${
prefs.gridVisible
? "bg-studio-accent/20 text-studio-accent"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
}`}
onClick={toggleGrid}
onContextMenu={(e) => {
e.preventDefault();
setGridPopoverOpen((v) => !v);
}}
title={prefs.gridVisible ? "Grid visible (G)" : "Grid hidden (G)"}
aria-label="Toggle grid"
>
<GridFour size={16} weight={prefs.gridVisible ? "fill" : "regular"} />
</button>
{gridPopoverOpen && (
<div
ref={popoverRef}
className="absolute right-0 top-full mt-1 rounded-lg bg-neutral-800 border border-neutral-700 p-3 shadow-xl min-w-[180px]"
>
<label className="flex items-center justify-between text-xs text-white/80 mb-2">
<span>Grid spacing</span>
<input
type="number"
min={10}
max={500}
step={10}
value={prefs.gridSpacing}
onChange={(e) => {
const val = Number.parseInt(e.target.value, 10);
if (Number.isFinite(val) && val >= 10 && val <= 500) {
updatePrefs({ gridSpacing: val });
}
}}
className="w-16 rounded bg-neutral-900 border border-neutral-600 px-1.5 py-0.5 text-xs text-white text-right tabular-nums outline-none focus:border-studio-accent"
/>
</label>
<label className="flex items-center gap-2 text-xs text-white/80 cursor-pointer">
<input
type="checkbox"
checked={prefs.snapToGrid}
onChange={() => updatePrefs({ snapToGrid: !prefs.snapToGrid })}
className="accent-studio-accent"
/>
<span>Snap to grid</span>
</label>
</div>
)}
</div>
</div>
);
});
@@ -0,0 +1,147 @@
// fallow-ignore-file unused-file
// fallow-ignore-file code-duplication
import type { DomEditSelection } from "./domEditing";
import {
isElementVisibleForOverlay,
toOverlayRect,
type OverlayRect,
} from "./domEditOverlayGeometry";
import {
extractSnapTargets,
buildCompositionSnapTarget,
buildGridSnapEdges,
type SnapTarget,
type SnapEdge,
} from "./snapEngine";
import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
export interface SnapContext {
targets: SnapTarget[];
compositionTarget: SnapTarget | null;
gridEdges: { x: SnapEdge[]; y: SnapEdge[] } | null;
snapEnabled: boolean;
}
function readPositiveDimension(value: string | null): number | null {
if (!value) return null;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
const IGNORED_TAGS = new Set(["script", "style", "link", "meta", "base", "template", "br", "wbr"]);
function isHtmlElement(node: Node): node is HTMLElement {
return node.nodeType === 1;
}
function collectVisibleElements(
root: HTMLElement,
excludeElements: Set<HTMLElement>,
maxItems: number,
): HTMLElement[] {
const result: HTMLElement[] = [];
// fallow-ignore-next-line complexity
const visit = (el: HTMLElement) => {
if (result.length >= maxItems) return;
for (const child of Array.from(el.children)) {
if (!isHtmlElement(child)) continue;
if (IGNORED_TAGS.has(child.tagName.toLowerCase())) continue;
if (child.hasAttribute("data-composition-id")) continue;
if (excludeElements.has(child)) continue;
if (!isElementVisibleForOverlay(child)) continue;
result.push(child);
visit(child);
}
};
visit(root);
return result;
}
// fallow-ignore-next-line complexity
export function collectSnapContext(input: {
overlayEl: HTMLDivElement;
iframe: HTMLIFrameElement;
excludeElements: Set<HTMLElement>;
}): SnapContext {
const prefs = readStudioUiPreferences();
const snapEnabled = prefs.snapEnabled ?? true;
const doc = input.iframe.contentDocument;
if (!doc) {
return { targets: [], compositionTarget: null, gridEdges: null, snapEnabled };
}
const root =
doc.querySelector<HTMLElement>("[data-composition-id]") ?? (doc.documentElement as HTMLElement);
const rootRect = root?.getBoundingClientRect();
const declaredWidth = readPositiveDimension(root?.getAttribute("data-width") ?? null);
const declaredHeight = readPositiveDimension(root?.getAttribute("data-height") ?? null);
const rootWidth = declaredWidth ?? rootRect?.width;
const rootHeight = declaredHeight ?? rootRect?.height;
if (!rootWidth || !rootHeight || !rootRect) {
return { targets: [], compositionTarget: null, gridEdges: null, snapEnabled };
}
const iframeRect = input.iframe.getBoundingClientRect();
const overlayRect = input.overlayEl.getBoundingClientRect();
const rootScaleX = iframeRect.width / rootWidth;
const rootScaleY = iframeRect.height / rootHeight;
const compositionOverlayRect: OverlayRect = {
left: iframeRect.left - overlayRect.left,
top: iframeRect.top - overlayRect.top,
width: iframeRect.width,
height: iframeRect.height,
editScaleX: rootScaleX,
editScaleY: rootScaleY,
};
const compositionTarget = buildCompositionSnapTarget(compositionOverlayRect);
const MAX_SNAP_TARGETS = 80;
const elements = collectVisibleElements(root, input.excludeElements, MAX_SNAP_TARGETS);
if (elements.length >= MAX_SNAP_TARGETS) {
console.warn(
`[snap] Target cap reached (${MAX_SNAP_TARGETS}). Elements beyond this limit are excluded from snap alignment.`,
);
}
const entries: Array<{
rect: { left: number; top: number; width: number; height: number };
id: string;
}> = [];
for (let i = 0; i < elements.length; i++) {
const rect = toOverlayRect(input.overlayEl, input.iframe, elements[i]);
if (rect) entries.push({ rect, id: `snap-target-${i}` });
}
const targets = extractSnapTargets(entries);
let gridEdges: { x: SnapEdge[]; y: SnapEdge[] } | null = null;
const gridSpacing = prefs.gridSpacing ?? 50;
const snapToGrid = prefs.snapToGrid ?? false;
if (snapToGrid && gridSpacing > 0) {
gridEdges = buildGridSnapEdges(compositionOverlayRect, gridSpacing, rootScaleX);
}
return { targets, compositionTarget, gridEdges, snapEnabled };
}
// fallow-ignore-next-line complexity
export function buildExcludeElements(input: {
iframe: HTMLIFrameElement;
selection?: DomEditSelection | null;
groupSelections?: DomEditSelection[];
}): Set<HTMLElement> {
const elements = new Set<HTMLElement>();
const sel = input.selection;
if (sel?.element) {
elements.add(sel.element);
}
if (input.groupSelections) {
for (const gs of input.groupSelections) {
if (gs.element) elements.add(gs.element);
}
}
return elements;
}