feat(studio): instant, flicker-free manual editing (#1605)

* chore(producer): shim __filename/__dirname in the CJS banner

Bundled CJS deps like wawoff2 call __dirname; without the shim they throw
"__dirname is not defined in ES module" at render time. Also ignore .zed/.

* chore(producer): use a template literal for the CJS banner (review nit)

* feat(core): add GSAP keyframe + motion-path source mutations

Array-form keyframe removal in both the recast and acorn writers, plus
update/add/remove-motion-path-point and add-motion-path. Exclude _auto and
data from tween property-group classification.

* fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks

- Regression test for the `data` GSAP-key exclusion (parallel to _auto).
- splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the
  from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests.
- addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated.
- Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring
  removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug —
  documented as it.skip with a ready assertion (acorn cutover follow-up).

* feat(core): route motion-path mutations through studio-api + fix clip stamping

Wire the new mutations into the file save route. Only authored clips suppress
descendant stamping, so auto-stamped animated scenes can inline-expand.

Hide in-flow timed clips with `display:none` only when they are LEAF clips (no
nested timed clips). `display:none` on a container removes its whole subtree,
hiding descendants that are still inside their own visibility window — e.g. an
in-flow composition root whose effective window clamps to the timeline end would
black out a child video that should still show (the hdr-hlg regression).
Containers keep `visibility:hidden`, which a visible descendant can override; only
leaves leave the flow, which is all the split-overlap case needs.

* feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule

A position or rotation add/set mutation makes the GSAP timeline the single source of
truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS
var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both
channels, and the add-strip fires for the position AND rotation property groups.

Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio
cannot save drag/resize edits to elements in a registered timeline — the exact premise
the single-source work inverts (the timeline is now the edit target). Removed the rule,
its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests.

* fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel

- HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript
  authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta,
  shift-positions, scale-positions, split-animations. (add stays out: flat tweens
  only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.)
- init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change;
  visible/hidden branches both go through isTimedClipInFlow (was .get() by accident).
- keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set
  strips the stale --hf-studio-rotation channel.

* feat(studio): GSAP runtime read layer + shared helpers

* fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests

- useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold
  (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once.
- Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent.
- parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior
  ease is stripped without shifting the other keyframes' percentages.

* feat(studio): GSAP drag/commit/bridge editing infra

* fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore

BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the
commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the
server save, fixing both consumers (useEnableKeyframes + useGestureCommit's
showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation
return type widened void→Promise<void> (fire-and-forget consumers ignore it).
- stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live).
- commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek,
  so a failed commit leaves the dropped pose, not a cleared element.

* feat(studio): motion-path geometry + commit helpers

* docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics

* feat(studio): on-canvas motion-path overlay

* fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold

* feat(studio): keyframes flag, gesture recording + timeline/selection refinements

* fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens

- useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's
  empty parse isn't overridden by a stale selectedGsapAnimations cache.
- useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick).
- useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent).
- playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV).
- useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per
  group, so a mixed gesture no longer yields an untagged legacy tween.

* feat(studio): single-source manual offset + rotation via the GSAP timeline

Dragging or rotating an element writes into the GSAP timeline (the single source of
truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static
elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and
the live preview moves via gsap.set so what you see equals what is written and renders.
Removes the dual-channel CSS-var/transform reconciliation behind the
fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position
and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/
keyframe commit, dropped the handleDom*Commit CSS fallbacks).

Subcompositions edit the same single-source way, which surfaced and fixes:
- resolve a subcomp element's source file via the composition-id map (the runtime drops
  the source linkage when inlining the subcomposition);
- a selected element's selection box AND motion path use basic visibility, not the
  occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover);
- soft reload rebuilds ONLY the committed composition's timeline, leaving other
  compositions' timelines intact (no cross-composition revert);
- read keyframes from the element's OWN composition timeline (scan all timelines, not
  the first unstable key);
- delete-all uses a soft reload too, so editing no longer hard-reloads the iframe.

* fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures

- DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only
  position/rotation channel; the false branch silently killed drag+rotate (and let GSAP
  elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests.
- gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller
  full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script.
- gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops
  partial reads (no phantom identity) and re-applies the drag override in finally.
- MotionPathOverlay: park-timer cleanup keyed on animId change.
- useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review).

* feat(studio): patchRuntimeTweenInPlace — update a tween's values in place

Defensive runtime helper: locate the element's tween in window.__timelines via the
shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek
the playhead — without re-running the whole composition. Returns false (caller falls
back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed
keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant,
flicker-free manual edits.

* fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches

- resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose
  vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write
  {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props.
- patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch],
  instead of silently dropping the computed expression.

* feat(studio): instantPatch fast path in runCommit

A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on
success the preview updates in place with NO reload (instant), on false it falls back
to the existing soft reload. Extracts the preview-sync tail into a testable
applyPreviewSync helper. No behavior change when instantPatch is absent.

* feat(studio): route static position/rotation set drags through instantPatch

Static-element position and rotation set commits now attach instantPatch{selector,
change:{kind:set}} so the drag updates in place with no reload. Structural ops (new
tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit
it and keep the soft reload — keyframe instant-patch needs object-form keyframe support
in patchRuntimeTweenInPlace (deferred).

* fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure

- commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual
  update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated
  values flow into the patch; can't drift).
- Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y},
  so a second-POST failure still leaves the preview patched for what persisted.
- applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload);
  per U4 the synchronous false still does NOT escalate.
- (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.)

* feat(studio): no full iframe remount for soft-reloadable edits

A softReload edit (and the SDK single-script refresh) no longer escalates to a full
reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set
already shows the value, and a remount is the worst flash + re-inlines subcomps
(reverting their keyframes). verifyTimelinesPopulated now checks the expected target
keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty.
Full reload stays only for the structural (no-softReload) and ambiguous-script paths.

* feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash

ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout
onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the
timeline. So when a user adds a motion path to a composition that didn't originally
use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN
async path (the flash). Idempotent + defensive; the existing async fallback stays for
genuine cold-start/CDN-failure.

* fix(studio): don't re-save + reload when source editor syncs externally

The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged —
including the programmatic dispatch that syncs external content (e.g. a manual-edit
commit writing the source back into the open editor). That made the editor re-save the
file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe
edit — defeating the in-place instant patch and causing the flash. Annotate the
programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save.

* fix(core): inject MotionPathPlugin into preview when a composition uses motionPath

A studio-created motion path writes a gsap motionPath tween into the single-source
timeline, but the preview HTML only loaded gsap core — so the first render threw
"Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject
MotionPathPlugin right after the composition's gsap script, version-matched to it.

* fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact)

* fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard

BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' |
'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full
reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script,
or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the
TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome
(origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted.
- SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an
  in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused).
- Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append).

* fix(core): align __clipTree and __clipManifest ids via stableClipId

Timeline inline expansion was dead for nested children inside index.html:
the tree keyed id-less elements by a synthetic __clip-N while the manifest
keyed them null, so parent<->child never joined. Both now resolve identity
through stableClipId (id || data-hf-id), which every generated element has.

* fix(core): strip baked runtime + tag comp root in preview assembly

Comps that ship a baked inline runtime were double-loaded (preview injects
its own) and the baked copy failed to parse inline (Unexpected token '<').
Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also
tag the comp root with data-composition-file so the studio resolves a comp's
top-level elements to the right source file instead of defaulting to
index.html (which made the GSAP panel parse the wrong, multi-timeline file).

* feat(studio): set motion-path destination from a toolbar toggle

Replaces the double-click-on-canvas UX (which painted text over the preview)
with a 'Set motion destination' toggle next to Snap/Grid, shown only when the
selected element can take a path. While armed, one canvas press places the
destination. Also removes the dead TimelinePropertyRows component.

* fix(studio): center timeline keyframe diamonds on their percentage

Dropped clampDiamondLeft, which forced boundary keyframes fully inside the
clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's
midpoint now sits exactly on its % (the clip is overflow-visible).

* fix(studio): resize static elements via tl.set, not a single-stop keyframes tween

Resizing an element with no size animation wrote keyframes:{ <playhead%>:
{width,height} } — one mid-point stop GSAP can't interpolate, so it rendered
NaN/0 dimensions at every other frame and the element vanished (worst off 0%).
Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize
now writes tl.set({width,height}), held at all frames; re-resizing updates it
in place.

* fix(studio): negative-cache failed media probes

Only successful probes were cached, so CORS/404 cross-origin media was
re-probed every rAF-driven timeline re-derive, flooding the console. Remember
failed URLs and skip them.

* fix(studio): type window.setTimeout handle as number

ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is
present and clashes with the DOM number the call returns. Type it number.

* fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps

- Fix soft-reload clearProps destroying element inline styles — save cssText,
  clear, restore, strip only transform
- Fix resize no-op on re-resize: delete+add instead of two update-property
- Route set tweens through static resize path (convertToKeyframes skips sets)
- Re-fetch animation ID before drag commit to prevent stale-ID duplicates
- Guard editDebugLog for Node test environments
- Fix NLELayout setState-during-render (move reset to useEffect)
- Stop SnapToolbar pointer events propagating to canvas deselect handler
- Enable click-to-add waypoints on cubic motion paths
- Add whole-path drag offset (Alt+drag shifts all keyframes together)
- Add Canvas shortcuts section to ShortcutsPanel
- Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance)
- Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports)
This commit is contained in:
Miguel Ángel
2026-06-22 01:21:18 -04:00
committed by GitHub
parent e0822c6e85
commit 091137e3c3
103 changed files with 8012 additions and 1217 deletions
@@ -282,7 +282,6 @@ describe("DomEditOverlay", () => {
};
let currentSelection: DomEditSelection | null = selection;
const onToggleRecording = vi.fn();
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
HTMLDivElement.prototype.setPointerCapture = () => {};
@@ -298,8 +297,6 @@ describe("DomEditOverlay", () => {
hoverSelection: null,
onSelectionChange: (next: DomEditSelection) => setSelected(next),
}),
recordingState: "idle",
onToggleRecording,
});
}
@@ -340,16 +337,6 @@ describe("DomEditOverlay", () => {
"drag",
expect.objectContaining({ button: 0 }),
);
const recordButton = host.querySelector(
'[aria-label="Record gesture (R)"]',
) as HTMLButtonElement;
expect(recordButton).toBeTruthy();
act(() => {
recordButton.click();
});
expect(onToggleRecording).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
@@ -14,7 +14,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
import { GridOverlay } from "./GridOverlay";
import { GestureRecordBadge, type GestureRecordingState } from "./GestureRecordControl";
import type { GestureRecordingState } from "./GestureRecordControl";
// Re-exports for external consumers — preserving existing import paths.
export {
@@ -55,6 +55,7 @@ interface DomEditOverlayProps {
onPathOffsetCommit: (
selection: DomEditSelection,
next: { x: number; y: number },
modifiers?: { altKey?: boolean },
) => Promise<void> | void;
onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void;
onBoxSizeCommit: (
@@ -87,8 +88,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onGroupPathOffsetCommit,
onBoxSizeCommit,
onRotationCommit,
recordingState,
onToggleRecording,
}: DomEditOverlayProps) {
const overlayRef = useRef<HTMLDivElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
@@ -243,6 +242,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
if (!selection) return "none";
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
}, [selection]);
const groupBounds = useMemo(
() => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)),
[groupOverlayItems],
@@ -433,13 +433,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
/>
</div>
)}
{onToggleRecording && (
<GestureRecordBadge
rect={overlayRect}
recordingState={recordingState}
onToggleRecording={onToggleRecording}
/>
)}
<div
key={selectionKey}
ref={boxRef}
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { clipToTweenPercentage } from "./KeyframeNavigation";
/**
* Regression: keyframe add/remove are keyed by TWEEN-relative percentage (what the
* GSAP writer + runtime use), NOT the clip-relative playhead used for display/seek.
* The Layout-panel diamond used to emit clip-relative %, so the mutation missed
* every keyframe (off by the tween's offset/scale) → a silent no-op on disk that
* the optimistic cache hid, so the motion path never refreshed.
*/
// A tween that starts partway through the element's lifetime and is shorter than
// it: the clip→tween map is linear with tween% = (clip% - 20) * 2.5 over [20, 60].
const KEYFRAMES = [
{ percentage: 20, tweenPercentage: 0, properties: { x: 0 } },
{ percentage: 30, tweenPercentage: 25, properties: { x: -180 } },
{ percentage: 50, tweenPercentage: 75, properties: { x: -320 } },
{ percentage: 60, tweenPercentage: 100, properties: { x: -460 } },
];
describe("clipToTweenPercentage", () => {
it("maps anchor keyframes to their tween-relative percentages", () => {
expect(clipToTweenPercentage(KEYFRAMES, 20)).toBeCloseTo(0, 5);
expect(clipToTweenPercentage(KEYFRAMES, 60)).toBeCloseTo(100, 5);
});
it("linearly interpolates a clip-relative playhead into tween space", () => {
// clip 40% is the midpoint of the tween's clip span [20, 60] → tween 50%.
expect(clipToTweenPercentage(KEYFRAMES, 40)).toBeCloseTo(50, 5);
});
it("falls back to the input when there's no usable mapping", () => {
expect(clipToTweenPercentage([], 40)).toBe(40);
expect(clipToTweenPercentage([{ percentage: 10 }], 40)).toBe(40);
});
});
@@ -3,9 +3,12 @@ import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond";
interface KeyframeNavigationProps {
property: string;
/** All keyframes for this element's tween, or null if no keyframes exist */
/** All keyframes for this element's tween, or null if no keyframes exist.
* `percentage` is clip-relative (element lifetime) for display/seek;
* `tweenPercentage` is the tween-relative value the writer/runtime key on. */
keyframes: Array<{
percentage: number;
tweenPercentage?: number;
properties: Record<string, number | string>;
ease?: string;
}> | null;
@@ -19,6 +22,26 @@ interface KeyframeNavigationProps {
const TOLERANCE = 0.5;
/**
* Convert a clip-relative percentage (element lifetime, used for display/seek) to
* the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween
* map is linear, recovered from the keyframes' own (percentage, tweenPercentage)
* pairs. Falls back to the input when there's no usable mapping (e.g. parser
* keyframes that are already tween-relative, or fewer than two anchors).
*/
export function clipToTweenPercentage(
keyframes: ReadonlyArray<{ percentage: number; tweenPercentage?: number }>,
clipPct: number,
): number {
const mapped = keyframes.filter((kf) => typeof kf.tweenPercentage === "number");
if (mapped.length < 2) return clipPct;
const a = mapped[0]!;
const b = mapped[mapped.length - 1]!;
if (b.percentage === a.percentage) return a.tweenPercentage!;
const slope = (b.tweenPercentage! - a.tweenPercentage!) / (b.percentage - a.percentage);
return a.tweenPercentage! + (clipPct - a.percentage) * slope;
}
function ArrowLeft({ disabled }: { disabled: boolean }) {
return (
<svg
@@ -94,13 +117,20 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
diamondState = "ghost";
}
// Keyframe add/remove are keyed by TWEEN-relative percentage (what the GSAP
// writer + runtime use), not the clip-relative `currentPercentage` used for
// display/seek. Removing on an existing keyframe uses its own tweenPercentage;
// adding converts the clip-relative playhead through the keyframes' own
// clip→tween linear mapping. Passing clip-relative % made the mutation miss
// every keyframe (off by the tween's offset/scale) → a silent no-op on disk
// while the optimistic cache hid it, so the motion path never refreshed.
const handleDiamondClick = () => {
if (diamondState === "ghost") {
onConvertToKeyframes();
} else if (diamondState === "active") {
onRemoveKeyframe(currentPercentage);
} else if (diamondState === "active" && atCurrent) {
onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage);
} else {
onAddKeyframe(currentPercentage);
onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage));
}
};
@@ -0,0 +1,98 @@
import type React from "react";
// Editor primary color (themeable via --hf-accent). Applied through inline
// style because CSS var() isn't valid in SVG presentation attributes.
export const ACCENT = "var(--hf-accent, #3CE6AC)";
/** One path node: a diamond (matching the timeline keyframe), a wider transparent
* grab target (when editable), and a hover-revealed × delete badge (when removable). */
export function MotionPathNode(props: {
cx: number;
cy: number;
r: number;
interactive: boolean;
removable: boolean;
grabbing: boolean;
selected: boolean;
onEnter: () => void;
onLeave: () => void;
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: (e: React.PointerEvent) => void;
onRemove: (e: React.PointerEvent) => void;
onContextMenu?: (e: React.MouseEvent) => void;
}) {
const { cx, cy, r, interactive, removable, grabbing, selected } = props;
const bx = cx + r * 1.8;
const by = cy - r * 1.8;
const k = r * 0.55;
// Diamond matching the timeline keyframe (a 45°-rotated rounded square).
// `side` is chosen so the diamond's points reach ~`r` from center, matching the
// old dot's footprint; selection is shown by enlarging it (no extra shape).
const side = (selected ? r * 1.5 : r) * 1.414;
return (
<g onPointerEnter={props.onEnter} onPointerLeave={props.onLeave}>
<rect
x={cx - side / 2}
y={cy - side / 2}
width={side}
height={side}
rx={side * 0.17}
transform={`rotate(45 ${cx} ${cy})`}
stroke="#0b0f1a"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
style={{ fill: ACCENT }}
/>
{interactive && (
<circle
cx={cx}
cy={cy}
r={r * 2.4}
fill="transparent"
className="pointer-events-auto"
style={{ cursor: grabbing ? "grabbing" : "grab" }}
onPointerDown={props.onPointerDown}
onPointerMove={props.onPointerMove}
onPointerUp={props.onPointerUp}
onContextMenu={props.onContextMenu}
/>
)}
{removable && (
<g
className="pointer-events-auto"
style={{ cursor: "pointer" }}
onPointerDown={props.onRemove}
>
<circle
cx={bx}
cy={by}
r={r * 1.3}
stroke="#0b0f1a"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
style={{ fill: ACCENT }}
/>
<line
x1={bx - k}
y1={by - k}
x2={bx + k}
y2={by + k}
stroke="#0b0f1a"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
<line
x1={bx + k}
y1={by - k}
x2={bx - k}
y2={by + k}
stroke="#0b0f1a"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
</g>
)}
</g>
);
}
@@ -0,0 +1,481 @@
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 { 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,
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;
ref: MotionNodeRef;
};
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,
handleGsapDeleteAllForElement,
} = useDomEditContext();
const { rect, geometry, geometryResolved, visibleInPreview, home } = 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);
// 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);
const px = Math.round((e.clientX - r.left) / sc - elHome.x);
const py = Math.round((e.clientY - r.top) / sc - elHome.y);
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.
const abs = nodes.map((n) => ({ ...n, ax: home.x + n.x, ay: home.y + n.y }));
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; // primary button only — right-click is the context menu
e.stopPropagation();
(e.target as Element).setPointerCapture(e.pointerId);
dragRef.current = {
index,
startX: e.clientX,
startY: e.clientY,
initX: x,
initY: y,
scale,
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,
y: d.initY + (e.clientY - d.startY) / d.scale,
});
};
// 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);
const y = Math.round(d.initY + screenDy / d.scale);
// 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;
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") {
const anim = selectedGsapAnimations?.find((a) => a.id === animId);
if (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 (np.t = fraction along it), then
// insert it. Lands ON the current line, so the dot doesn't jump — drag it
// after to bend the path.
const a = abs[np.segIndex]?.ref;
const b = abs[np.segIndex + 1]?.ref;
if (a?.type !== "keyframe" || b?.type !== "keyframe") return;
const pct = Math.round((a.pct + (b.pct - a.pct) * np.t) * 1000) / 1000;
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) return;
e.preventDefault();
e.stopPropagation();
setKfMenu({
x: e.clientX,
y: e.clientY,
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, pct) => animId && handleGsapRemoveKeyframe(animId, pct)}
onDeleteAll={(elId) => handleGsapDeleteAllForElement(`#${elId}`)}
/>
)}
</>
);
});
@@ -1,7 +1,7 @@
// fallow-ignore-file unused-file
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { MagnetStraight, GridFour } from "@phosphor-icons/react";
import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
import { usePlayerStore } from "../../player/store/playerStore";
const SNAP_DEFAULTS = {
snapEnabled: true,
@@ -34,6 +34,11 @@ interface SnapToolbarProps {
export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolbarProps) {
const [prefs, setPrefs] = useState(readSnapPrefs);
const [gridPopoverOpen, setGridPopoverOpen] = useState(false);
// Motion-path "set destination" toggle — shown only when the selected element
// can take a path; arms a single canvas click to place it (MotionPathOverlay).
const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
const popoverRef = useRef<HTMLDivElement>(null);
const gridButtonRef = useRef<HTMLButtonElement>(null);
@@ -89,7 +94,27 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
}, [gridPopoverOpen]);
return (
<div className="absolute top-2 right-2 z-50 flex items-center gap-1">
<div
className="absolute top-2 right-2 z-50 flex items-center gap-1"
onPointerDown={(e) => e.stopPropagation()}
>
{motionPathCreateAvailable && (
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${
motionPathArmed
? "bg-studio-accent/20 text-studio-accent"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
}`}
onClick={() => setMotionPathArmed(!motionPathArmed)}
title={
motionPathArmed ? "Click the canvas to set the destination" : "Set motion destination"
}
aria-label="Set motion destination"
>
<Path size={16} weight={motionPathArmed ? "fill" : "regular"} />
</button>
)}
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${
@@ -6,7 +6,7 @@ import {
highlightActiveLine,
highlightActiveLineGutter,
} from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { EditorState, Annotation } from "@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { bracketMatching, foldGutter, indentOnInput } from "@codemirror/language";
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
@@ -18,6 +18,11 @@ import { css } from "@codemirror/lang-css";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
// Marks a programmatic doc sync (external content push — e.g. a manual-edit
// commit writing the source) so the update listener doesn't mistake it for a
// user keystroke and trigger a re-save + preview reload.
const ExternalSync = Annotation.define<boolean>();
const LANGUAGE_EXTENSIONS: Record<string, () => Extension> = {
html: () => html(),
css: () => css(),
@@ -89,9 +94,10 @@ export const SourceEditor = memo(function SourceEditor({
const lang = language ?? (filePath ? detectLanguage(filePath) : "html");
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged && onChangeRef.current) {
onChangeRef.current(update.state.doc.toString());
}
if (!update.docChanged || !onChangeRef.current) return;
// Ignore programmatic external syncs — only real user edits should save.
if (update.transactions.some((tr) => tr.annotation(ExternalSync))) return;
onChangeRef.current(update.state.doc.toString());
});
const state = EditorState.create({
@@ -130,11 +136,17 @@ export const SourceEditor = memo(function SourceEditor({
const view = editorRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current !== content) {
view.dispatch({
changes: { from: 0, to: current.length, insert: content },
});
}
if (current === content) return;
// If the user is actively typing (editor focused), a programmatic replace
// would clobber their in-flight keystrokes — the ExternalSync annotation
// suppresses onChange, so those edits would be silently lost. Skip the
// external sync while focused; it re-runs on the next `content` change after
// they blur (or when a later commit lands with the editor unfocused).
if (view.hasFocus) return;
view.dispatch({
changes: { from: 0, to: current.length, insert: content },
annotations: [ExternalSync.of(true)],
});
}, [content]);
useEffect(() => {
@@ -25,6 +25,18 @@ export function isElementVisibleForOverlay(el: HTMLElement): boolean {
return isElementVisibleThroughAncestors(el);
}
// Sample points (as fractions of the element box) for the occlusion hit-test:
// the four inner corners plus the center. This is a coarse approximation of the
// element's painted area — we assume a sampled point that lands inside the box also
// lands on something the element actually paints.
//
// LIMITATION: a donut/ring-shaped element (a hole in the middle, content only around
// the edges) breaks that assumption — the center sample, and even the corner samples,
// can fall in the transparent hole and hit-test through to whatever is behind, so the
// element could read as occluded (or as covering) incorrectly. Today's scene element
// shapes (rectangular cards, text, full-bleed media) don't have interior holes, so this
// doesn't bite. If ring/cutout shapes become editable targets, sample more densely or
// hit-test against the element's actual painted geometry instead of its bounding box.
function readPositiveDimension(value: string | null): number | null {
if (!value) return null;
const parsed = Number.parseFloat(value);
@@ -173,7 +173,11 @@ export type UseDomEditOverlayGesturesOptions = {
onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>;
onManualDragStartRef: RefObject<(() => void) | undefined>;
onPathOffsetCommitRef: RefObject<
(s: DomEditSelection, n: { x: number; y: number }) => Promise<void> | void
(
s: DomEditSelection,
n: { x: number; y: number },
m?: { altKey?: boolean },
) => Promise<void> | void
>;
onGroupPathOffsetCommitRef: RefObject<
(updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
@@ -5,6 +5,7 @@
import { type DomEditSelection } from "./domEditing";
import {
createManualOffsetDragMember,
readGsapRotation,
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
@@ -115,7 +116,10 @@ export function startGesture(
return false;
const size = readStudioBoxSize(sel.element);
const rotation = readStudioRotation(sel.element);
// Single-source rotation base = the live GSAP transform rotation plus any legacy
// `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
// element's actual visual angle and commits an absolute angle to the timeline.
const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
const actualWidth = size.width > 0 ? size.width : rect.width / rect.editScaleX;
const actualHeight = size.height > 0 ? size.height : rect.height / rect.editScaleY;
let initialPathOffset = captureStudioPathOffset(sel.element);
@@ -128,18 +128,32 @@ export function findClosestByAttribute(
}
return null;
}
// ─── Composition source resolution ───────────────────────────────────────────
export function getElementDepth(el: HTMLElement): number {
let depth = 0;
let current = el.parentElement;
while (current) {
depth += 1;
current = current.parentElement;
}
return depth;
// The runtime INLINES subcompositions and strips the source-file linkage from the
// mounted root (it keeps `data-composition-id` but drops `data-composition-src`/
// `-file`), so a subcomp element's DOM ancestors no longer say which file it came
// from. This project-global map (composition-id → source file, built once from
// index.html's clips — see NLELayout) recovers it. The studio loads one project at a
// time, so module scope is the right lifetime; it's empty until set, in which case
// resolution falls back to the historical attribute-only behavior.
let compositionSourceMap: Map<string, string> = new Map();
export function setCompositionSourceMap(map: Map<string, string>): void {
compositionSourceMap = map;
}
// ─── Composition source resolution ───────────────────────────────────────────
function sourceFromCompositionId(ownerRoot: HTMLElement | null): string | undefined {
if (!ownerRoot || compositionSourceMap.size === 0) return undefined;
// The runtime may rename the mounted id to a runtime-unique one, preserving the
// authored id on `data-hf-original-composition-id` — prefer that, then the current id.
const authored = ownerRoot.getAttribute("data-hf-original-composition-id");
const current = ownerRoot.getAttribute("data-composition-id");
return (
(authored ? compositionSourceMap.get(authored) : undefined) ??
(current ? compositionSourceMap.get(current) : undefined)
);
}
export function getSourceFileForElement(
el: HTMLElement,
@@ -152,6 +166,7 @@ export function getSourceFileForElement(
sourceHost?.getAttribute("data-composition-src") ??
ownerRoot?.getAttribute("data-composition-file") ??
ownerRoot?.getAttribute("data-composition-src") ??
sourceFromCompositionId(ownerRoot) ??
activeCompositionPath ??
"index.html";
@@ -24,11 +24,6 @@ describe("manual editing availability", () => {
expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true);
});
it("enables GSAP drag intercept by default", async () => {
const availability = await loadAvailabilityWithEnv({});
expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(true);
});
it("keeps color grading off by default", async () => {
const availability = await loadAvailabilityWithEnv({});
expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(false);
@@ -41,13 +36,6 @@ describe("manual editing availability", () => {
expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(true);
});
it("disables GSAP drag intercept when env var is false", async () => {
const availability = await loadAvailabilityWithEnv({
VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT: "false",
});
expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(false);
});
it("disables preview selection when the inspector panel flag is explicitly off", async () => {
const availability = await loadAvailabilityWithEnv({
VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "0",
@@ -92,16 +92,6 @@ export const STUDIO_STORYBOARD_ENABLED = resolveStudioBooleanEnvFlag(
false,
);
// When disabled (the default), drag/resize/rotate commits always take the CSS
// persist path instead of being intercepted into GSAP script keyframe
// mutations. The keyframe intercept rewrites timeline tweens from drag
// gestures and is opt-in until its recording path is hardened.
export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"],
true,
);
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
// Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
@@ -16,6 +16,7 @@ export {
endStudioManualEditGesture,
isStudioManualEditGestureCurrent,
readStudioPathOffset,
readAppliedStudioPathOffset,
readStudioBoxSize,
readStudioRotation,
applyStudioPathOffset,
@@ -70,6 +70,19 @@ export function readStudioPathOffset(element: HTMLElement): { x: number; y: numb
};
}
/**
* The path offset ACTUALLY applied right now. The `--hf-studio-offset` vars can
* linger after GSAP re-bakes the element's transform (`translate:"none"`), so the
* raw var isn't a safe drag base using it re-commits a phantom offset and flings
* the element off-screen. The offset only counts when the inline `translate` is the
* studio var-translate; otherwise it's dormant and the applied offset is zero.
*/
export function readAppliedStudioPathOffset(element: HTMLElement): { x: number; y: number } {
return (element.style.translate || "").includes(STUDIO_OFFSET_X_PROP)
? readStudioPathOffset(element)
: { x: 0, y: 0 };
}
export function readStudioBoxSize(element: HTMLElement): { width: number; height: number } {
return {
width: readPxCustomProperty(element, STUDIO_WIDTH_PROP),
@@ -250,6 +263,50 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
}
}
// GSAP owns the element's `transform` (it bakes x/y into a matrix and writes
// `translate: none` every tick). Folding the drag offset into a CSS `translate`
// — as the non-GSAP path does — composes ON TOP of GSAP's transform, and the
// subsequent strip/reapply math compounds into a runaway matrix that flings the
// element off-canvas. So for GSAP-animated elements we keep `translate: none`
// and push the offset straight into GSAP's x/y via gsap.set; the var() offset is
// still persisted (buildPathOffsetPatches), and GSAP re-reads it at init on
// reload. Returns true when handled as GSAP (caller must skip the CSS path).
function applyStudioPathOffsetViaGsap(
element: HTMLElement,
offset: { x: number; y: number },
): boolean {
if (!gsapAnimatesProperty(element, "x", "y")) return false;
element.style.setProperty("translate", "none");
const win = element.ownerDocument.defaultView as
| (Window & {
gsap?: {
set: (el: Element, vars: Record<string, unknown>) => void;
getProperty: (el: Element, prop: string) => number;
};
})
| null;
if (win?.gsap) {
const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "");
const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "");
const gsapBaseX = Number.isFinite(baseX)
? baseX
: (win.gsap.getProperty(element, "x") as number);
const gsapBaseY = Number.isFinite(baseY)
? baseY
: (win.gsap.getProperty(element, "y") as number);
if (!Number.isFinite(baseX))
element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX));
if (!Number.isFinite(baseY))
element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY));
const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0);
const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0);
win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY });
}
return true;
}
export function applyStudioPathOffset(
element: HTMLElement,
offset: { x: number; y: number },
@@ -257,6 +314,10 @@ export function applyStudioPathOffset(
): void {
promoteInlineForTransform(element);
writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true });
// GSAP elements: route through gsap.set, NOT a CSS translate (would corrupt the
// matrix). Symmetrical with applyStudioPathOffsetDraft — the commit path used to
// skip this branch, which is what flung dragged GSAP elements off-canvas.
if (applyStudioPathOffsetViaGsap(element, offset)) return;
element.style.setProperty(
"translate",
composeTranslateValue(
@@ -274,45 +335,13 @@ export function applyStudioPathOffsetDraft(
): void {
promoteInlineForTransform(element);
writeStudioPathOffsetVars(element, offset, { updateBase: false });
const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
if (isGsapAnimated) {
element.style.setProperty("translate", "none");
const win = element.ownerDocument.defaultView as
| (Window & {
gsap?: {
set: (el: Element, vars: Record<string, unknown>) => void;
getProperty: (el: Element, prop: string) => number;
};
})
| null;
if (win?.gsap) {
const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "");
const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "");
const gsapBaseX = Number.isFinite(baseX)
? baseX
: (win.gsap.getProperty(element, "x") as number);
const gsapBaseY = Number.isFinite(baseY)
? baseY
: (win.gsap.getProperty(element, "y") as number);
if (!Number.isFinite(baseX))
element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX));
if (!Number.isFinite(baseY))
element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY));
const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0);
const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0);
win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY });
}
} else {
// Non-GSAP elements: use CSS translate as before.
element.style.setProperty(
"translate",
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
);
stripGsapTranslateFromTransform(element);
}
if (applyStudioPathOffsetViaGsap(element, offset)) return;
// Non-GSAP elements: use CSS translate as before.
element.style.setProperty(
"translate",
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
);
stripGsapTranslateFromTransform(element);
}
/* ── Box size apply ───────────────────────────────────────────────── */
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { applyStudioPathOffset, applyStudioPathOffsetDraft } from "./manualEditsDom";
/**
* Regression: dragging a GSAP-animated element (e.g. a flat `to(#el, {x})` tween)
* must NOT fold the offset into a CSS `translate`. GSAP owns `style.transform`, so
* a CSS translate composes on top of it and the strip/reapply math compounds into
* a runaway matrix that flings the element off-canvas. Both the live draft and the
* commit must instead push the offset into GSAP's x/y via gsap.set and keep
* `translate: none`. Before the fix, the commit (applyStudioPathOffset) skipped the
* GSAP branch the draft already had that asymmetry caused the off-canvas jump.
*/
function makeGsapWindow(
el: HTMLElement,
gsapSet: (e: Element, v: Record<string, unknown>) => void,
) {
const win = el.ownerDocument.defaultView as unknown as {
__timelines?: Record<string, unknown>;
gsap?: unknown;
};
win.__timelines = {
playground: {
getChildren: () => [{ targets: () => [el], vars: { x: -260 } }],
},
};
win.gsap = {
set: gsapSet,
getProperty: () => 0,
};
}
afterEach(() => {
const win = window as unknown as { __timelines?: unknown; gsap?: unknown };
delete win.__timelines;
delete win.gsap;
});
describe("applyStudioPathOffset — GSAP-owned transform", () => {
it("non-GSAP element folds the offset into a CSS translate var()", () => {
const el = document.createElement("div");
document.body.appendChild(el);
applyStudioPathOffset(el, { x: -120, y: 40 });
expect(el.style.translate).toContain("var(--hf-studio-offset-x");
expect(el.style.getPropertyValue("--hf-studio-offset-x")).toBe("-120px");
expect(el.style.getPropertyValue("--hf-studio-offset-y")).toBe("40px");
});
it("GSAP element keeps translate:none and routes the offset through gsap.set", () => {
const el = document.createElement("div");
el.id = "puck-a";
document.body.appendChild(el);
const gsapSet = vi.fn();
makeGsapWindow(el, gsapSet);
applyStudioPathOffset(el, { x: -409, y: 398 });
// No CSS translate to collide with GSAP's transform.
expect(el.style.translate).toBe("none");
expect(el.style.translate).not.toContain("var(");
// Offset pushed into GSAP's x/y (gsapBase 0 + delta = the offset itself here).
expect(gsapSet).toHaveBeenCalledWith(el, { x: -409, y: 398 });
});
it("draft and commit treat a GSAP element identically (translate:none)", () => {
const el = document.createElement("div");
el.id = "puck-a";
document.body.appendChild(el);
makeGsapWindow(el, vi.fn());
applyStudioPathOffsetDraft(el, { x: -50, y: 10 });
const draftTranslate = el.style.translate;
applyStudioPathOffset(el, { x: -50, y: 10 });
const commitTranslate = el.style.translate;
expect(draftTranslate).toBe("none");
expect(commitTranslate).toBe("none");
});
});
@@ -193,6 +193,12 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px");
element.style.setProperty(STUDIO_OFFSET_Y_PROP, "10px");
// Old projects bake the offset by referencing the vars in the inline
// `translate` longhand — that's what makes the offset "applied" and thus the
// valid drag base (readAppliedStudioPathOffset). A raw var with no applied
// translate is dormant and reads as zero. Assign the typed `.translate`
// accessor (happy-dom doesn't surface it via setProperty).
element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
element.style.setProperty("transform", "translate(50px, -15px)");
element.getBoundingClientRect = () => {
@@ -228,6 +234,12 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
// Simulate GSAP baking a translate into transform each cycle
for (let cycle = 0; cycle < 3; cycle++) {
element.style.setProperty("transform", `translate(${50 * (cycle + 1)}px, 0px)`);
// Mark the offset as APPLIED (the inline translate references the studio
// vars, the form an old project bakes) so readAppliedStudioPathOffset reads
// the var, not zero. Without this the var is dormant and reads as zero.
// Assign the typed `.translate` accessor (happy-dom doesn't surface it via
// setProperty).
element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
const result = createManualOffsetDragMember({
key: "test",
@@ -5,10 +5,71 @@ import {
beginStudioManualEditGesture,
captureStudioPathOffset,
endStudioManualEditGesture,
readStudioPathOffset,
readAppliedStudioPathOffset,
restoreStudioPathOffset,
type StudioPathOffsetSnapshot,
} from "./manualEdits";
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
interface OffsetDragGsap {
set: (el: Element, vars: Record<string, number | string>) => void;
getProperty: (el: Element, prop: string) => number;
}
function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
const win = element.ownerDocument.defaultView as
| (Window & { gsap?: Partial<OffsetDragGsap> })
| null;
const gsap = win?.gsap;
return gsap?.set && gsap.getProperty ? (gsap as OffsetDragGsap) : null;
}
/**
* Live drag preview through the GSAP channel the SAME channel the commit
* lands in (a `tl.set`/keyframe on the timeline), so what the user sees while
* dragging equals what gets written (plan R3/R4). Reuses the commit's
* base+delta+rotation math so preview and commit agree by construction. Returns
* true when handled via gsap; false when gsap is unavailable (caller falls back
* to the CSS draft).
*/
function applyOffsetDragDraftViaGsap(
element: HTMLElement,
offset: { x: number; y: number },
): boolean {
const gsap = getOffsetDragGsap(element);
if (!gsap) return false;
// GSAP owns the transform; neutralize the CSS translate longhand so the two
// channels can't compose into a doubled position.
element.style.setProperty("translate", "none");
const fallbackBase = {
x: Number(gsap.getProperty(element, "x")) || 0,
y: Number(gsap.getProperty(element, "y")) || 0,
};
const { newX, newY } = computeDraggedGsapPosition(element, offset, fallbackBase);
gsap.set(element, { x: newX, y: newY });
return true;
}
/**
* Live rotation preview through the GSAP channel the SAME channel the commit
* lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraftViaGsap`.
* GSAP owns the transform rotation, so neutralize the CSS `rotate` longhand to keep
* the two channels from composing. `angle` is the absolute target rotation. Returns
* false when gsap is unavailable (caller falls back to the CSS draft).
*/
export function applyRotationDraftViaGsap(element: HTMLElement, angle: number): boolean {
const gsap = getOffsetDragGsap(element);
if (!gsap) return false;
element.style.setProperty("rotate", "none");
gsap.set(element, { rotation: angle });
return true;
}
/** Current GSAP transform rotation — the single-source rotation base. 0 if gsap is unavailable. */
export function readGsapRotation(element: HTMLElement): number {
const gsap = getOffsetDragGsap(element);
return gsap ? Number(gsap.getProperty(element, "rotation")) || 0 : 0;
}
const DEFAULT_OFFSET_PROBE_PX = 100;
const MIN_PROBE_VECTOR_LENGTH_PX = 0.01;
@@ -241,7 +302,10 @@ export function createManualOffsetDragMember(input: {
element: HTMLElement;
rect: ManualOffsetDragRect;
}): ManualOffsetDragMemberResult {
const initialOffset = readStudioPathOffset(input.element);
// Base the drag on the offset ACTUALLY applied, never the raw (possibly dormant)
// var — see readAppliedStudioPathOffset. This keeps the commit purely relative
// (applied + delta) so a stale offset can't fling the element off-screen.
const initialOffset = readAppliedStudioPathOffset(input.element);
input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
@@ -335,7 +399,12 @@ export function applyManualOffsetDragDraft(
dy: number,
): { x: number; y: number } {
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
applyStudioPathOffsetDraft(member.element, offset);
// Position is single-sourced on the GSAP timeline; preview through gsap.set so
// the live draft matches the committed `tl.set`/keyframe. CSS draft only when
// gsap is unavailable (no preview iframe runtime).
if (!applyOffsetDragDraftViaGsap(member.element, offset)) {
applyStudioPathOffsetDraft(member.element, offset);
}
return offset;
}
@@ -345,7 +414,13 @@ export function applyManualOffsetDragCommit(
dy: number,
): { x: number; y: number } {
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
applyStudioPathOffset(member.element, offset);
// Optimistic visual through the GSAP channel (same as the live draft and the
// committed `tl.set`), so the element holds its dropped position until the
// source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
// CSS apply only when gsap is unavailable.
if (!applyOffsetDragDraftViaGsap(member.element, offset)) {
applyStudioPathOffset(member.element, offset);
}
return offset;
}
@@ -0,0 +1,130 @@
import { describe, it, expect, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { editableAnimationId } from "./motionPathSelection";
import {
commitNode,
commitAddWaypoint,
commitAddKeyframe,
commitRemoveWaypoint,
commitCreatePath,
} from "./motionPathCommit";
const anim = (over: Partial<GsapAnimation>): GsapAnimation =>
({
id: "a1",
targetSelector: "#el",
method: "to",
position: 0,
properties: {},
...over,
}) as GsapAnimation;
describe("editableAnimationId", () => {
it("picks the arc animation for an arc path", () => {
const arc = anim({ id: "arc1", arcPath: { enabled: true, autoRotate: false, segments: [] } });
expect(editableAnimationId([anim({ id: "other" }), arc], "arc")).toBe("arc1");
});
it("picks a position-keyframe animation for a linear path", () => {
const kf = anim({
id: "kf1",
propertyGroup: "position",
keyframes: {
format: "percentage",
keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }],
} as never,
});
expect(editableAnimationId([kf], "linear")).toBe("kf1");
});
it("returns null for dynamic (unresolved) tweens — read-only", () => {
const dyn = anim({
id: "dyn",
arcPath: { enabled: true, autoRotate: false, segments: [] },
hasUnresolvedKeyframes: true,
});
expect(editableAnimationId([dyn], "arc")).toBeNull();
});
it("returns null for non-literal (helper) provenance — read-only", () => {
const helper = anim({
id: "h",
arcPath: { enabled: true, autoRotate: false, segments: [] },
provenance: { kind: "helper" } as never,
});
expect(editableAnimationId([helper], "arc")).toBeNull();
});
it("returns null when nothing matches", () => {
expect(editableAnimationId([anim({ id: "x" })], "linear")).toBeNull();
});
});
describe("commitNode", () => {
it("routes a keyframe node to update-keyframe by percentage", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitNode({ type: "keyframe", pct: 50 }, 120, 30, "a1", commit);
expect(commit).toHaveBeenCalledWith(
{ type: "update-keyframe", animationId: "a1", percentage: 50, properties: { x: 120, y: 30 } },
expect.objectContaining({ softReload: true }),
);
});
it("routes a waypoint node to update-motion-path-point by index", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitNode({ type: "waypoint", index: 2 }, 80, 40, "a1", commit);
expect(commit).toHaveBeenCalledWith(
{ type: "update-motion-path-point", animationId: "a1", pointIndex: 2, x: 80, y: 40 },
expect.objectContaining({ softReload: true }),
);
});
});
describe("commitAddWaypoint / commitRemoveWaypoint", () => {
it("adds a waypoint at an index with coordinates", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitAddWaypoint("a1", 1, 120, -40, commit);
expect(commit).toHaveBeenCalledWith(
{ type: "add-motion-path-point", animationId: "a1", index: 1, x: 120, y: -40 },
expect.objectContaining({ softReload: true }),
);
});
it("removes a waypoint by index", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitRemoveWaypoint("a1", 2, commit);
expect(commit).toHaveBeenCalledWith(
{ type: "remove-motion-path-point", animationId: "a1", index: 2 },
expect.objectContaining({ softReload: true }),
);
});
});
describe("commitAddKeyframe", () => {
it("inserts an x/y keyframe at a tween-relative percentage", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitAddKeyframe("a1", 42.5, 80, -20, commit);
expect(commit).toHaveBeenCalledWith(
{ type: "add-keyframe", animationId: "a1", percentage: 42.5, properties: { x: 80, y: -20 } },
expect.objectContaining({ softReload: true }),
);
});
});
describe("commitCreatePath", () => {
it("authors a new motionPath to a destination at a given time", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitCreatePath("#title", 2.0, 300, -120, commit);
expect(commit).toHaveBeenCalledWith(
{
type: "add-motion-path",
targetSelector: "#title",
position: 2.0,
duration: 1.5,
x: 300,
y: -120,
},
expect.objectContaining({ softReload: true }),
);
});
});
@@ -0,0 +1,83 @@
/**
* Commit helpers for the motion-path overlay. Each maps a canvas gesture to a
* GSAP source mutation routed through the (selection-bound) commit facade, which
* handles the soft reload, undo snapshot, and save-failure feedback.
*/
import type { MotionNodeRef } from "./motionPathGeometry";
export type CommitFn = (
mutation: Record<string, unknown>,
options: { label: string; softReload?: boolean },
) => Promise<void>;
const NEW_PATH_DURATION = 1.5;
export function commitNode(
ref: MotionNodeRef,
x: number,
y: number,
animationId: string,
commit: CommitFn,
): Promise<void> {
const mutation: Record<string, unknown> =
ref.type === "keyframe"
? { type: "update-keyframe", animationId, percentage: ref.pct, properties: { x, y } }
: { type: "update-motion-path-point", animationId, pointIndex: ref.index, x, y };
return commit(mutation, {
label: ref.type === "keyframe" ? "Move keyframe" : "Move waypoint",
softReload: true,
});
}
export function commitAddWaypoint(
animationId: string,
index: number,
x: number,
y: number,
commit: CommitFn,
): Promise<void> {
return commit(
{ type: "add-motion-path-point", animationId, index, x, y },
{ label: "Add waypoint", softReload: true },
);
}
export function commitAddKeyframe(
animationId: string,
percentage: number,
x: number,
y: number,
commit: CommitFn,
): Promise<void> {
// percentage is tween-relative (matches MotionNodeRef.keyframe.pct). The parser's
// addKeyframeToScript inserts a new "P%": { x, y } stop (or merges if one exists
// at that pct) and converts a flat tween to keyframes form when needed.
return commit(
{ type: "add-keyframe", animationId, percentage, properties: { x, y } },
{ label: "Add keyframe", softReload: true },
);
}
export function commitRemoveWaypoint(
animationId: string,
index: number,
commit: CommitFn,
): Promise<void> {
return commit(
{ type: "remove-motion-path-point", animationId, index },
{ label: "Remove waypoint", softReload: true },
);
}
export function commitCreatePath(
targetSelector: string,
position: number,
x: number,
y: number,
commit: CommitFn,
): Promise<void> {
return commit(
{ type: "add-motion-path", targetSelector, position, duration: NEW_PATH_DURATION, x, y },
{ label: "Create motion path", softReload: true },
);
}
@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import { buildMotionPathGeometry, nearestPointOnPath } from "./motionPathGeometry";
import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
const kf = (percentage: number, x: number, y: number) => ({ percentage, properties: { x, y } });
describe("buildMotionPathGeometry", () => {
it("builds a linear path with keyframe-ref nodes from an x/y tween", () => {
const read: ReadTween = { keyframes: [kf(0, 10, 20), kf(100, 200, 80)] };
const geo = buildMotionPathGeometry(read);
expect(geo).not.toBeNull();
expect(geo!.kind).toBe("linear");
expect(geo!.points).toBe("10,20 200,80");
expect(geo!.nodes).toEqual([
{ x: 10, y: 20, ref: { type: "keyframe", pct: 0 } },
{ x: 200, y: 80, ref: { type: "keyframe", pct: 100 } },
]);
});
it("preserves order and percentages for intermediate keyframes", () => {
const read: ReadTween = { keyframes: [kf(0, 0, 0), kf(50, 50, 90), kf(100, 100, 0)] };
const geo = buildMotionPathGeometry(read);
expect(geo!.nodes.map((n) => n.ref)).toEqual([
{ type: "keyframe", pct: 0 },
{ type: "keyframe", pct: 50 },
{ type: "keyframe", pct: 100 },
]);
});
it("builds an arc path with waypoint-index refs when arcPath is present", () => {
const read: ReadTween = {
keyframes: [kf(0, 0, 0), kf(50, 60, 40), kf(100, 120, 10)],
arcPath: { enabled: true, autoRotate: false, segments: [{ curviness: 1 }, { curviness: 1 }] },
};
const geo = buildMotionPathGeometry(read);
expect(geo!.kind).toBe("arc");
expect(geo!.nodes.map((n) => n.ref)).toEqual([
{ type: "waypoint", index: 0 },
{ type: "waypoint", index: 1 },
{ type: "waypoint", index: 2 },
]);
});
it("returns null for a tween with no positional keyframes", () => {
const read: ReadTween = {
keyframes: [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
],
};
expect(buildMotionPathGeometry(read)).toBeNull();
});
it("draws a single-axis (x-only) tween, defaulting the missing axis to 0", () => {
// Regression: an `x`-only tween (e.g. `to({ x: -260 })`) carries no `y`, so the
// builder used to skip every node → no path until the user added the 2nd axis.
const read: ReadTween = {
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: -260 } },
],
};
const geo = buildMotionPathGeometry(read);
expect(geo).not.toBeNull();
expect(geo!.points).toBe("0,0 -260,0"); // y defaults to 0 → horizontal path
});
it("draws a y-only tween too (x defaults to 0)", () => {
const read: ReadTween = {
keyframes: [
{ percentage: 0, properties: { y: 0 } },
{ percentage: 100, properties: { y: 500 } },
],
};
expect(buildMotionPathGeometry(read)!.points).toBe("0,0 0,500");
});
it("excludes keyframes missing a coordinate without throwing", () => {
const read: ReadTween = {
keyframes: [kf(0, 10, 20), { percentage: 50, properties: { x: 100 } }, kf(100, 200, 80)],
};
const geo = buildMotionPathGeometry(read);
expect(geo!.nodes).toHaveLength(2);
expect(geo!.points).toBe("10,20 200,80");
});
it("returns null when fewer than two valid nodes remain", () => {
const read: ReadTween = { keyframes: [kf(0, 10, 20)] };
expect(buildMotionPathGeometry(read)).toBeNull();
});
it("returns null for null input", () => {
expect(buildMotionPathGeometry(null)).toBeNull();
});
});
describe("nearestPointOnPath", () => {
const nodes = [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
];
it("projects onto the nearest segment and reports its index + fraction", () => {
const p = nearestPointOnPath(50, 20, nodes);
expect(p).toEqual({ x: 50, y: 0, segIndex: 0, t: 0.5, dist: 20 });
});
it("reports t at the segment endpoints (0 at start, clamps to 1 past the end)", () => {
expect(nearestPointOnPath(0, 5, nodes)).toMatchObject({ segIndex: 0, t: 0 });
expect(nearestPointOnPath(110, 0, nodes)).toMatchObject({ segIndex: 0, t: 1 });
});
it("picks the second segment when closer to it", () => {
const p = nearestPointOnPath(120, 50, nodes);
expect(p).toMatchObject({ x: 100, y: 50, segIndex: 1 });
});
it("clamps to an endpoint when the projection falls past the segment", () => {
const p = nearestPointOnPath(-40, -10, nodes);
expect(p).toMatchObject({ x: 0, y: 0, segIndex: 0 });
});
it("returns null for fewer than two nodes", () => {
expect(nearestPointOnPath(0, 0, [{ x: 0, y: 0 }])).toBeNull();
});
});
@@ -0,0 +1,116 @@
/**
* Convert a live tween (from `readRuntimeKeyframes`) into renderable motion-path
* geometry for the on-canvas overlay. Pure no React/DOM so it unit-tests in
* isolation. Coordinates are in composition space (the same space the overlay's
* viewBox uses), so the caller renders nodes/points directly.
*/
import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
/** Which source edit a dragged node maps to. */
export type MotionNodeRef =
| { type: "keyframe"; pct: number } // x/y position keyframe at this tween-relative %
| { type: "waypoint"; index: number }; // motionPath waypoint (anchor) at this index
export interface MotionPathNode {
x: number;
y: number;
ref: MotionNodeRef;
}
export interface MotionPathGeometry {
/** "linear" = x/y keyframes; "arc" = motionPath tween. */
kind: "linear" | "arc";
/** SVG polyline points: "x,y x,y ...". */
points: string;
nodes: MotionPathNode[];
}
/**
* Build motion-path geometry, or null when the tween carries no positional path
* (fewer than two keyframes with both x and y). For motionPath tweens the
* keyframes are the arc waypoints (anchors), index-aligned with the source path
* so a waypoint node at index `i` rewrites source waypoint `i`.
*
* ponytail: the arc is drawn as a polyline through its waypoints (matching the
* angular dotted look of the reference), not GSAP's resolved curve. Dense
* curve sampling is a later refinement if the straight-segment preview proves
* insufficient.
*/
/**
* Nearest point on a polyline to (px, py), with the index of the segment it
* lies on and `t` = how far along that segment the returned point sits.
*
* `t` semantics: clamped to the inclusive range [0, 1].
* - `t === 0` the point is at (or projects before) the segment's start node
* (`segIndex`); a perpendicular dropped from (px, py) falls at or behind `a`.
* - `0 < t < 1` the point is strictly interior to the segment.
* - `t === 1` the point is at (or projects PAST) the segment's end node
* (`segIndex + 1`); past-the-end projections are clamped back onto the endpoint,
* so the returned (x, y) is exactly `nodes[segIndex + 1]`. Callers can read
* `t === 1` as "snapped to the end anchor of this segment" (equivalently, the
* start anchor of the next segment).
* A degenerate zero-length segment (`a === b`) yields `t === 0`.
*
* Used to position the ghost "add" node and decide where a new node goes: a
* motionPath waypoint inserts between `segIndex`/`segIndex + 1`, a keyframe
* interpolates its tween-% from the two adjacent keyframes via `t`.
* Coordinates are whatever space the caller passes (overlay uses absolute px).
*/
export function nearestPointOnPath(
px: number,
py: number,
nodes: Array<{ x: number; y: number }>,
): { x: number; y: number; segIndex: number; t: number; dist: number } | null {
if (nodes.length < 2) return null;
let best: { x: number; y: number; segIndex: number; t: number; dist: number } | null = null;
for (let i = 0; i < nodes.length - 1; i++) {
const a = nodes[i]!;
const b = nodes[i + 1]!;
const dx = b.x - a.x;
const dy = b.y - a.y;
const len2 = dx * dx + dy * dy;
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / len2));
const cx = a.x + t * dx;
const cy = a.y + t * dy;
const dist = Math.hypot(px - cx, py - cy);
if (!best || dist < best.dist) best = { x: cx, y: cy, segIndex: i, t, dist };
}
return best;
}
export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeometry | null {
if (!read) return null;
const isArc = Boolean(read.arcPath);
const nodes: MotionPathNode[] = [];
// Index by source position so a waypoint node maps to the matching source
// anchor. Arc waypoints always carry x/y (never filtered), so source index
// and node order stay aligned.
// Which axes does the tween animate at all? A single-axis tween (e.g.
// `to({ x: -260 })`) only carries x; its y stays at the base (0, the GSAP
// transform identity), so we default it and still draw a path. But if the tween
// DOES animate an axis and a given keyframe omits it, that value is interpolated
// (not 0) and can't be placed here → skip that node (the prior behavior).
const finite = (v: unknown): v is number => typeof v === "number" && isFinite(v);
const tweenHasX = read.keyframes.some((kf) => finite(kf.properties.x));
const tweenHasY = read.keyframes.some((kf) => finite(kf.properties.y));
if (!tweenHasX && !tweenHasY) return null; // no positional motion (opacity/scale only)
read.keyframes.forEach((kf, i) => {
if (tweenHasX && !finite(kf.properties.x)) return;
if (tweenHasY && !finite(kf.properties.y)) return;
nodes.push({
x: tweenHasX ? (kf.properties.x as number) : 0,
y: tweenHasY ? (kf.properties.y as number) : 0,
ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage },
});
});
if (nodes.length < 2) return null;
return {
kind: isArc ? "arc" : "linear",
points: nodes.map((n) => `${n.x},${n.y}`).join(" "),
nodes,
};
}
@@ -0,0 +1,33 @@
/**
* Resolving the selected element and the animation whose path is editable.
* Shared by the overlay and its diagnostics (kept here to avoid a circular
* import between the two).
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./domEditing";
export function selectorFor(sel: DomEditSelection | null): string | null {
if (!sel) return null;
if (sel.id) return `#${CSS.escape(sel.id)}`;
return sel.selector ?? null;
}
/** The animation whose path is editable on-canvas: literal, statically resolved,
* and matching the rendered geometry kind. Returns null when the path can only
* be displayed (dynamic/helper tweens) those nodes stay read-only. */
export function editableAnimationId(
animations: GsapAnimation[],
kind: "linear" | "arc",
): string | null {
const ok = (a: GsapAnimation) =>
!a.hasUnresolvedKeyframes && !a.hasUnresolvedSelector && !a.provenance;
if (kind === "arc") return animations.find((a) => a.arcPath?.enabled && ok(a))?.id ?? null;
const a = animations.find(
(anim) =>
anim.keyframes &&
ok(anim) &&
(anim.propertyGroup === "position" ||
anim.keyframes.keyframes.some((k) => "x" in k.properties || "y" in k.properties)),
);
return a?.id ?? null;
}
@@ -9,6 +9,7 @@ import { type DomEditSelection } from "./domEditing";
import {
applyManualOffsetDragCommit,
applyManualOffsetDragDraft,
applyRotationDraftViaGsap,
endManualOffsetDragMembers,
restoreManualOffsetDragMembers,
resumeGsapTimelines,
@@ -161,19 +162,21 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
let dy = e.clientY - g.startY;
if (g.kind === "rotate") {
applyStudioRotationDraft(
sel.element,
resolveDomEditRotationGesture({
centerX: g.centerX,
centerY: g.centerY,
startX: g.startX,
startY: g.startY,
currentX: e.clientX,
currentY: e.clientY,
actualAngle: g.actualRotation,
snap: e.shiftKey,
}),
);
// Single source of truth: preview the rotation through the GSAP channel (the
// same channel the commit lands in), not the `--hf-studio-rotation` CSS var.
const rotated = resolveDomEditRotationGesture({
centerX: g.centerX,
centerY: g.centerY,
startX: g.startX,
startY: g.startY,
currentX: e.clientX,
currentY: e.clientY,
actualAngle: g.actualRotation,
snap: e.shiftKey,
});
if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
applyStudioRotationDraft(sel.element, rotated);
}
return;
}
@@ -393,25 +396,38 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
actualAngle: g.actualRotation,
snap: e.shiftKey,
});
const restoreRotation = () => {
// Single source of truth: snap the GSAP rotation back to the gesture's base
// angle; fall back to the legacy CSS-var restore when gsap is unavailable.
if (!applyRotationDraftViaGsap(sel.element, g.actualRotation)) {
restoreStudioRotation(sel.element, g.initialRotation);
}
};
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
restoreStudioRotation(sel.element, g.initialRotation);
restoreRotation();
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
return;
}
applyStudioRotation(sel.element, finalRotation);
// Keep the preview at the final angle through the GSAP channel (NOT the CSS var)
// while the commit lands a `tl.set`/keyframe rotation on the timeline.
if (!applyRotationDraftViaGsap(sel.element, finalRotation.angle)) {
applyStudioRotation(sel.element, finalRotation);
}
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
.catch(() => {
if (
g.manualEditDragToken &&
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
)
restoreStudioRotation(sel.element, g.initialRotation);
restoreRotation();
})
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
} else if (g.kind === "drag") {
const dx = g.lastSnappedDx ?? e.clientX - g.startX;
const dy = g.lastSnappedDy ?? e.clientY - g.startY;
if (!g.pathOffsetMember) return;
if (!g.pathOffsetMember) {
return;
}
const finalOffset = applyManualOffsetDragCommit(g.pathOffsetMember, dx, dy);
const nextBoxLeft = g.originLeft + dx;
const nextBoxTop = g.originTop + dy;
@@ -427,7 +443,9 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
box.style.left = `${nextBoxLeft}px`;
box.style.top = `${nextBoxTop}px`;
}
void Promise.resolve(opts.onPathOffsetCommitRef.current(sel, finalOffset))
void Promise.resolve(
opts.onPathOffsetCommitRef.current(sel, finalOffset, { altKey: e.altKey }),
)
.catch(() => {
if (
g.pathOffsetMember?.gestureToken &&
@@ -148,6 +148,12 @@ export function useDomEditOverlayRects({
activeCompositionPathRef.current,
resolvedElementRef as ResolvedElementRef,
);
// An explicitly-selected element's overlay must track it whenever it's laid
// out and not display:none/visibility:hidden/opacity:0 — use basic visibility,
// NOT the occlusion heuristic. Occlusion (isElementVisibleInPreview) treats any
// opacity:1 ancestor as an opaque cover even when it paints nothing (e.g. a
// backgroundless full-bleed scene above a subcomposition), which would wrongly
// hide the selection box. Occlusion stays for hover, where a false hide is cheap.
if (el && isElementVisibleForOverlay(el)) {
const nextRect = toOverlayRect(overlayEl, iframe, el);
setOverlayRect(nextRect);
@@ -0,0 +1,135 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGeometry";
type Rect = { left: number; top: number; width: number; height: number };
export function elementHome(el: HTMLElement): { x: number; y: number } {
let left = 0;
let top = 0;
let node: HTMLElement | null = el;
while (node) {
left += node.offsetLeft;
top += node.offsetTop;
const parent = node.offsetParent as HTMLElement | null;
if (!parent || parent.hasAttribute("data-composition-id")) break;
node = parent;
}
let x = left + el.offsetWidth / 2;
let y = top + el.offsetHeight / 2;
if ((el.style.translate ?? "").includes("var(")) {
x += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
y += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
}
return { x, y };
}
export function isPreviewHtmlElement(
node: Element | null | undefined,
iframe: HTMLIFrameElement | null,
): node is HTMLElement {
const Ctor = (iframe?.contentWindow as unknown as { HTMLElement?: typeof HTMLElement } | null)
?.HTMLElement;
return Boolean(node && Ctor && node instanceof Ctor);
}
function rectsClose(a: Rect, b: Rect): boolean {
return (
Math.abs(a.left - b.left) < 0.5 &&
Math.abs(a.top - b.top) < 0.5 &&
Math.abs(a.width - b.width) < 0.5 &&
Math.abs(a.height - b.height) < 0.5
);
}
export function hasMotionPathPlugin(iframe: HTMLIFrameElement | null): boolean {
try {
return Boolean(
(iframe?.contentWindow as unknown as { MotionPathPlugin?: unknown })?.MotionPathPlugin,
);
} catch {
return false;
}
}
export function useMotionPathData(
iframeRef: RefObject<HTMLIFrameElement | null>,
selector: string | null,
): {
rect: Rect | null;
geometry: MotionPathGeometry | null;
geometryResolved: boolean;
visibleInPreview: boolean;
home: { x: number; y: number } | null;
} {
const [rect, setRect] = useState<Rect | null>(null);
const [geometry, setGeometry] = useState<MotionPathGeometry | null>(null);
const resolvedForRef = useRef<string | null>(null);
const geometryResolved = resolvedForRef.current === selector;
const [visibleInPreview, setVisibleInPreview] = useState(true);
const [home, setHome] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!selector) {
setRect(null);
setHome(null);
return;
}
setHome(null);
let raf = 0;
const tick = () => {
const el = iframeRef.current;
if (el) {
const r = el.getBoundingClientRect();
const surface = el.ownerDocument?.querySelector("[data-preview-pan-surface]");
const sRect = surface?.getBoundingClientRect();
const next = {
left: sRect ? r.left - sRect.left : r.left,
top: sRect ? r.top - sRect.top : r.top,
width: r.width,
height: r.height,
};
setRect((prev) => (prev && rectsClose(prev, next) ? prev : next));
let target: Element | null = null;
try {
target = el.contentDocument?.querySelector(selector) ?? null;
} catch {
/* cross-origin guard */
}
const live = isPreviewHtmlElement(target, el) ? target : null;
const vis = live ? isElementVisibleForOverlay(live) : true;
setVisibleInPreview((prev) => (prev === vis ? prev : vis));
if (live) {
const h = elementHome(live);
setHome((prev) =>
prev && Math.abs(prev.x - h.x) < 0.5 && Math.abs(prev.y - h.y) < 0.5 ? prev : h,
);
}
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [selector, iframeRef]);
useEffect(() => {
if (!selector) {
setGeometry(null);
return;
}
const recompute = () => {
const read = readRuntimeKeyframes(iframeRef.current, selector);
const next = buildMotionPathGeometry(read);
setGeometry((prev) =>
prev?.points === next?.points && prev?.kind === next?.kind ? prev : next,
);
resolvedForRef.current = selector;
};
recompute();
const id = window.setInterval(recompute, 250);
return () => window.clearInterval(id);
}, [selector, iframeRef]);
return { rect, geometry, geometryResolved, visibleInPreview, home };
}