mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user