Files
hyperframes/packages/studio/src/components/editor/MotionPathOverlay.tsx
T
Miguel Angel Simon Sierra e317f1fbe3 refactor(studio): double-cast test fixtures and split three dense functions
CONTRIBUTING.md allows `as unknown as T` with a justification, not a bare
`as T`; the gsapShared fixtures only carry the fields under test.

The fallow complexity gate flagged three functions on this branch. Each is
split at its natural seam rather than suppressed: the auto-expand scan moves
out of the effect, the four repeated attribute guards in
nodeMatchesManifestClip collapse into one table-driven check, and the
segment-% interpolation moves out of onPathDown.
2026-07-28 00:40:46 +02:00

545 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { memo, useEffect, useRef, useState, type RefObject } from "react";
import type { DomEditSelection } from "./domEditing";
import { useDomEditContext } from "../../contexts/DomEditContext";
import { usePlayerStore } from "../../player/store/playerStore";
import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
import { editableAnimationId, selectorFor } from "./motionPathSelection";
import { ACCENT, MotionPathNode } from "./MotionPathNode";
import {
KeyframeDiamondContextMenu,
type KeyframeDiamondContextMenuState,
} from "../../player/components/KeyframeDiamondContextMenu";
import {
commitAddKeyframe,
commitAddWaypoint,
commitCreatePath,
commitNode,
commitRemoveWaypoint,
} from "./motionPathCommit";
import {
elementHome,
hasMotionPathPlugin,
isPreviewHtmlElement,
transformWDivisor,
useMotionPathData,
} from "./useMotionPathData";
interface MotionPathOverlayProps {
iframeRef: RefObject<HTMLIFrameElement | null>;
selection: DomEditSelection | null;
compositionSize: { width: number; height: number } | null;
isPlaying: boolean;
}
type Draft = { index: number; x: number; y: number };
type DragState = {
index: number;
startX: number;
startY: number;
initX: number;
initY: number;
scale: number;
pScale: number;
ref: MotionNodeRef;
};
/**
* Tween-% for a stop inserted at fraction `t` along the segment between two
* nodes. null when either end is not a keyframe (arc waypoints carry no %).
*/
function interpolatedKeyframePct(
a: MotionNodeRef | undefined,
b: MotionNodeRef | undefined,
t: number,
): number | null {
if (a?.type !== "keyframe" || b?.type !== "keyframe") return null;
return Math.round((a.pct + (b.pct - a.pct) * t) * 1000) / 1000;
}
const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
// Click-vs-drag cutoff in SCREEN pixels. Below this the pointer-up is a click
// (select the keyframe); at or above it the gesture commits a move. Screen-space
// (not composition px) so it behaves identically at any zoom.
const DRAG_THRESHOLD_PX = 3;
/**
* Draws the selected element's GSAP motion path over the canvas — a dashed
* polyline through its x/y keyframes (or motionPath waypoints) with a draggable
* node at each. Dragging an x/y node rewrites the keyframe; dragging a waypoint
* rewrites the motionPath point; both commit to source (undoable). Renders in
* declared composition coordinates so the path doesn't drift under GSAP
* transforms. Read-only (no drag) while playing or when the tween isn't
* statically editable. Nothing renders when the selection has no positional
* motion.
*/
// fallow-ignore-next-line complexity
export const MotionPathOverlay = memo(function MotionPathOverlay({
iframeRef,
selection,
compositionSize,
isPlaying,
}: MotionPathOverlayProps) {
const {
commitMutation,
selectedGsapAnimations,
handleGsapRemoveKeyframe,
handleGsapRemoveAllKeyframes,
handleGsapMoveKeyframeToPlayhead,
} = useDomEditContext();
const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData(
iframeRef,
selectorFor(selection),
);
const [draft, setDraft] = useState<Draft | null>(null);
const [ghost, setGhost] = useState<{ x: number; y: number; segIndex: number } | null>(null);
const [hoverNode, setHoverNode] = useState<number | null>(null);
// Right-click context menu on a keyframe node — same delete actions as the
// timeline keyframe diamond.
const [kfMenu, setKfMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
// The keyframe % selected by clicking its node — highlighted, and the next drag
// modifies it rather than adding a keyframe.
const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
const timelineElement = usePlayerStore((state) => {
if (!selection) return undefined;
const sourceScopedId = `${selection.sourceFile || "index.html"}#${selection.id}`;
return state.elements.find(
(element) => (element.key ?? element.id) === sourceScopedId || element.id === selection.id,
);
});
// Set-destination mode is armed from the preview toolbar (replaces the old
// double-click-on-canvas UX). See createMode effects below.
const armed = usePlayerStore((s) => s.motionPathArmed);
const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
const setMotionPathCreateAvailable = usePlayerStore((s) => s.setMotionPathCreateAvailable);
const dragRef = useRef<DragState | null>(null);
// Park-on-click is debounced so a double-click cancels the seek (see onUp).
const parkTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// The animation id whose path is currently editable. Computed at hook level (not
// just in render, after the early returns) so the park-timer cleanup can key on
// it: a pending park seek belongs to the OLD animation, so firing it after the
// active animation changed would jump the playhead onto a stale keyframe.
const animId = editableAnimationId(selectedGsapAnimations ?? [], geometry?.kind ?? "linear");
// Clear the debounced park timer on unmount AND whenever the active animation id
// changes — not unmount-only, or a queued seek from the previous selection still
// fires against the new one.
useEffect(() => () => clearTimeout(parkTimerRef.current), [animId]);
// Create mode: a selected element with no positional motion can be given a new
// motionPath. Gated on `geometryResolved` so a fresh selection never counts as
// "no path" before the first runtime read confirms it (see useMotionPathData).
const createMode = geometryResolved && !geometry && Boolean(selection?.element) && !isPlaying;
const createSelector = createMode ? selectorFor(selection) : null;
const compW = compositionSize?.width ?? null;
const canCreate = createMode && hasMotionPathPlugin(iframeRef.current);
// Publish whether the selected element can take a path so the preview toolbar
// shows its "set destination" toggle. Drops to false when this overlay unmounts
// or the context changes, so the button never lingers for a stale selection.
useEffect(() => {
setMotionPathCreateAvailable(Boolean(canCreate));
return () => setMotionPathCreateAvailable(false);
}, [canCreate, setMotionPathCreateAvailable]);
// Disarm when set-destination is no longer possible (element gains a path, gets
// deselected, or playback starts) so a toggle left on can't fire later.
useEffect(() => {
if (armed && !canCreate) setMotionPathArmed(false);
}, [armed, canCreate, setMotionPathArmed]);
// While armed, the next canvas press sets the destination (replaces the old
// double-click). Scoped to the preview pan-surface in the CAPTURE phase, on
// pointerdown, so it fires before the selection/drag handler underneath — a
// press on empty canvas would otherwise deselect (and disarm) before a later
// click could land. stopPropagation keeps that handler from also running.
// fallow-ignore-next-line complexity
useEffect(() => {
if (!armed || !createSelector || !compW) return;
const surface =
(iframeRef.current?.ownerDocument?.querySelector(
"[data-preview-pan-surface]",
) as HTMLElement | null) ?? null;
if (!surface) return;
const prevCursor = surface.style.cursor;
surface.style.cursor = "crosshair";
// fallow-ignore-next-line complexity
const onDown = (e: PointerEvent) => {
if (e.button !== 0) return; // primary press only
const frame = iframeRef.current;
if (!frame || !hasMotionPathPlugin(frame)) return;
const r = frame.getBoundingClientRect();
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) {
return;
}
// Resolve the element LIVE from the current iframe document — the selected
// node may be detached after a soft-reload, which would skew home.
const live = frame.contentDocument?.querySelector(createSelector);
if (!isPreviewHtmlElement(live, frame)) return;
e.stopPropagation();
e.preventDefault();
const sc = r.width / compW;
const elHome = elementHome(live);
// De-magnify: the click lands on the projected (1/m44-magnified) path, so
// divide the home-relative offset by the perspective factor to recover the
// stored composition offset (inverse of the `* pScale` applied at draw).
const ps = 1 / transformWDivisor(live);
const px = Math.round(((e.clientX - r.left) / sc - elHome.x) / ps);
const py = Math.round(((e.clientY - r.top) / sc - elHome.y) / ps);
const t = Math.round(usePlayerStore.getState().currentTime * 100) / 100;
void commitCreatePath(createSelector, t, px, py, commitMutation);
setMotionPathArmed(false);
};
surface.addEventListener("pointerdown", onDown, true);
return () => {
surface.removeEventListener("pointerdown", onDown, true);
surface.style.cursor = prevCursor;
};
}, [armed, createSelector, compW, iframeRef, commitMutation, setMotionPathArmed]);
if (!rect || rect.width <= 0 || !compositionSize || compositionSize.width <= 0) return null;
// Hide the whole overlay (path + create hint) when the element isn't painted —
// same "what you see in the preview" rule as the selection box.
if (!visibleInPreview) return null;
// No live anchor (element not in the current document) → can't place the path.
if (!home) return null;
if (!geometry) {
// Create mode draws nothing by default — the destination is set via the
// preview toolbar's "set destination" toggle (no text sprawled over the
// canvas). Only while armed do we show a faint ring at the element as a
// "click to place" cue (the surface cursor is also crosshair, set above).
if (!armed || !canCreate) return null;
const sc = rect.width / compositionSize.width;
const hr = (NODE_PX / sc) * 1.6;
return (
<svg
className="pointer-events-none absolute z-40"
style={{
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
// Don't clip the ring that extends past the canvas into the gray margin
// — only the preview viewport (`[data-preview-pan-surface]`,
// overflow-hidden) clips, so overlays reach the edge but never the panels.
overflow: "visible",
}}
viewBox={`0 0 ${compositionSize.width} ${compositionSize.height}`}
>
<circle
cx={home.x}
cy={home.y}
r={hr}
fill="none"
strokeWidth={1.5}
strokeDasharray="3 3"
vectorEffect="non-scaling-stroke"
style={{ stroke: ACCENT }}
opacity={0.85}
/>
</svg>
);
}
const scale = rect.width / compositionSize.width;
const nodeR = NODE_PX / scale;
const interactive = Boolean(animId) && !isPlaying;
// The × "quick remove" badge applies to non-cubic motionPath arcs only (cubic
// anchors carry control points we don't synthesize; keyframe paths remove via
// the right-click menu instead).
const structural = geometry.kind === "arc" && interactive;
const removable = structural && geometry.nodes.length > 2;
// Click-on-path to insert a node works for both kinds: a motionPath waypoint
// (arc paths, including cubic — GSAP recomputes curves around the new point),
// or an x/y keyframe (linear paths) at the projected tween-%.
const addable = interactive;
const nodes = draft
? geometry.nodes.map((n, i) => (i === draft.index ? { ...n, x: draft.x, y: draft.y } : n))
: geometry.nodes;
// ax/ay = absolute composition position (home + offset) for drawing; n.x/n.y
// stay offsets so the drag commit writes the right tween values.
// Magnify the animated offsets by the element's perspective factor (1/m44, via
// pScale) so the path tracks the *projected* element. `home` is the projection
// pivot (transform-origin), so it stays put; only the offsets foreshorten. 2D
// elements have pScale = 1 (no change). Inverse (de-magnify) applied wherever a
// pointer position is mapped back to a stored offset (create + node drag).
const abs = nodes.map((n) => ({
...n,
ax: home.x + n.x * pScale,
ay: home.y + n.y * pScale,
}));
const points = abs.map((p) => `${p.ax},${p.ay}`).join(" ");
// Map a VIEWPORT pointer to composition space. Use the iframe's LIVE viewport
// rect, not `rect` — `rect.left/top` are stored pan-surface-relative (for the
// absolute-positioned SVG), so subtracting them from a viewport clientX/Y would
// offset the projection by the surface's gutter (panel/toolbar), and the add-
// ghost wouldn't track the cursor. `scale` is unaffected (width is stored raw).
const clientToComp = (e: React.PointerEvent) => {
const vr = iframeRef.current?.getBoundingClientRect();
const left = vr ? vr.left : rect.left;
const top = vr ? vr.top : rect.top;
return { x: (e.clientX - left) / scale, y: (e.clientY - top) / scale };
};
const onDown = (
e: React.PointerEvent,
index: number,
x: number,
y: number,
ref: MotionNodeRef,
) => {
if (!interactive) return;
if (e.button !== 0) return;
e.stopPropagation();
(e.target as Element).setPointerCapture(e.pointerId);
dragRef.current = {
index,
startX: e.clientX,
startY: e.clientY,
initX: x,
initY: y,
scale,
pScale,
ref,
};
setDraft({ index, x, y });
};
const onMove = (e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
setDraft({
index: d.index,
x: d.initX + (e.clientX - d.startX) / d.scale / d.pScale,
y: d.initY + (e.clientY - d.startY) / d.scale / d.pScale,
});
};
// fallow-ignore-next-line complexity
const onUp = (e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
dragRef.current = null;
setDraft(null);
if (!animId) return;
const screenDx = e.clientX - d.startX;
const screenDy = e.clientY - d.startY;
const x = Math.round(d.initX + screenDx / d.scale / d.pScale);
const y = Math.round(d.initY + screenDy / d.scale / d.pScale);
// Click-vs-drag is decided in SCREEN space, not composition px: the old guard
// compared rounded comp-px, which at high zoom (scale ≫ 1) swallowed real
// multi-px screen drags whose sub-comp-px delta rounds to 0 → the node would
// never move. A screen-distance threshold registers any genuine pointer drag
// at any zoom; below it the gesture is a click (select + park the playhead).
const movedScreenPx = Math.hypot(screenDx, screenDy);
if (movedScreenPx < DRAG_THRESHOLD_PX) {
// No drag → treat as a click: select this keyframe and park the playhead on
// it. Selecting it makes the next drag MODIFY this keyframe (honored via
// activeKeyframePct) instead of creating a new one.
if (d.ref.type === "keyframe") {
usePlayerStore.getState().setActiveKeyframePct(d.ref.pct);
const ref = d.ref;
// Debounce the playhead seek: a double-click cancels it (e.detail >= 2),
// so only a lone single-click parks the playhead on the keyframe.
clearTimeout(parkTimerRef.current);
if (e.detail < 2) {
parkTimerRef.current = setTimeout(() => {
const anim = selectedGsapAnimations?.find((a) => a.id === animId);
if (anim) parkPlayheadOnKeyframe(anim, ref.pct);
}, 250);
}
}
return; // no commit
}
// A real drag that still rounds to the same integer comp-px (sub-px move at
// high zoom) would commit an identical value — a no-op undo entry. Skip the
// commit, but don't treat it as a click either (the user did drag).
if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
// With auto-keyframe off (#1808), dragging a keyframe's node on the motion
// path (the common way to nudge a KEYFRAMED element's position on canvas,
// since the element renders exactly at its current keyframe) shifts the
// whole path instead of moving just that one keyframe.
const anim =
d.ref.type === "keyframe" ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
if (
d.ref.type === "keyframe" &&
anim &&
selection &&
!usePlayerStore.getState().autoKeyframeEnabled
) {
void commitWholePropertyOffset(
selection,
anim,
{ x, y },
d.ref.pct,
iframeRef.current,
{ commitMutation: (_sel, mutation, options) => commitMutation(mutation, options) },
"Move animation path",
);
} else {
void commitNode(d.ref, x, y, animId, commitMutation);
}
// Park the playhead on the edited keyframe's time so the element previews AT
// that keyframe. Without it, a playhead sitting before the tween renders the
// element's base pose — the edit (correct on the path) looks like it vanished.
if (d.ref.type === "keyframe" && anim) {
parkPlayheadOnKeyframe(anim, d.ref.pct);
}
};
// Ghost "add" affordance: project the cursor onto the path; click inserts.
const onPathHover = (e: React.PointerEvent) => {
const c = clientToComp(e);
const np = nearestPointOnPath(
c.x,
c.y,
abs.map((p) => ({ x: p.ax, y: p.ay })),
);
setGhost(np ? { x: np.x, y: np.y, segIndex: np.segIndex } : null);
};
const onPathDown = (e: React.PointerEvent) => {
if (!animId) return;
// Compute the insertion point from the event directly so a click works
// without (or faster than) a preceding hover.
const c = clientToComp(e);
const np = nearestPointOnPath(
c.x,
c.y,
abs.map((p) => ({ x: p.ax, y: p.ay })),
);
if (!np) return;
const x = Math.round(np.x - home.x);
const y = Math.round(np.y - home.y);
if (geometry.kind === "arc") {
e.stopPropagation();
void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation);
} else {
// Linear keyframe path: interpolate the new stop's tween-% from the two
// keyframes bounding the clicked segment, then insert it. Lands ON the
// current line, so the dot doesn't jump — drag it after to bend the path.
const pct = interpolatedKeyframePct(abs[np.segIndex]?.ref, abs[np.segIndex + 1]?.ref, np.t);
if (pct === null) return;
e.stopPropagation();
void commitAddKeyframe(animId, pct, x, y, commitMutation);
}
setGhost(null);
};
const onRemove = (e: React.PointerEvent, index: number) => {
e.stopPropagation();
if (!animId) return;
setHoverNode(null);
void commitRemoveWaypoint(animId, index, commitMutation);
};
const elementId = selection?.id ?? null;
// Right-click a keyframe node → the timeline's keyframe context menu (delete
// this keyframe / delete all), so motion-path keyframes are removable in place.
const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
if (ref.type !== "keyframe" || !animId || !elementId || !timelineElement) return;
e.preventDefault();
e.stopPropagation();
setKfMenu({
x: e.clientX,
y: e.clientY,
element: timelineElement,
elementId,
percentage: ref.pct,
tweenPercentage: ref.pct,
});
};
return (
<>
<svg
className="pointer-events-none absolute z-40"
style={{
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
// Don't clip nodes/path past the canvas into the gray margin — only the
// preview viewport (overflow-hidden) clips, so overlays reach the edge
// but never the side panels.
overflow: "visible",
}}
viewBox={`0 0 ${compositionSize.width} ${compositionSize.height}`}
>
{/* Wide transparent hit path drives the add-ghost; drawn under the nodes.
Renders for keyframe paths and non-cubic arcs (see `addable`). */}
{addable && (
<polyline
points={points}
fill="none"
stroke="transparent"
strokeWidth={14 / scale}
className="pointer-events-auto"
style={{ cursor: "copy" }}
onPointerMove={onPathHover}
onPointerLeave={() => setGhost(null)}
onPointerDown={onPathDown}
/>
)}
<polyline
points={points}
fill="none"
style={{ stroke: ACCENT }}
strokeWidth={1.5}
strokeDasharray="5 5"
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
opacity={0.85}
/>
{ghost && (
<rect
x={ghost.x - nodeR * 0.707}
y={ghost.y - nodeR * 0.707}
width={nodeR * 1.414}
height={nodeR * 1.414}
rx={nodeR * 0.24}
transform={`rotate(45 ${ghost.x} ${ghost.y})`}
fill="none"
strokeWidth={1.5}
strokeDasharray="2 2"
vectorEffect="non-scaling-stroke"
className="pointer-events-none"
style={{ stroke: ACCENT }}
/>
)}
{abs.map((p, i) => (
<MotionPathNode
key={i}
cx={p.ax}
cy={p.ay}
r={nodeR}
interactive={interactive}
removable={removable && hoverNode === i}
grabbing={draft?.index === i}
selected={p.ref.type === "keyframe" && p.ref.pct === activeKeyframePct}
onEnter={() => setHoverNode(i)}
onLeave={() => setHoverNode((h) => (h === i ? null : h))}
onPointerDown={(e) => onDown(e, i, p.x, p.y, p.ref)}
onPointerMove={onMove}
onPointerUp={onUp}
onRemove={(e) => onRemove(e, i)}
onContextMenu={(e) => onNodeContextMenu(e, p.ref)}
/>
))}
</svg>
{kfMenu && (
<KeyframeDiamondContextMenu
state={kfMenu}
onClose={() => setKfMenu(null)}
onDelete={(_elId, keyframe) =>
animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
}
onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
onMoveToPlayhead={(_element, keyframe) =>
animId && handleGsapMoveKeyframeToPlayhead(animId, keyframe.percentage)
}
/>
)}
</>
);
});