mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
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:
@@ -8,7 +8,6 @@ import {
|
||||
import { getHistoryShortcutLabel } from "../utils/studioHelpers";
|
||||
import { useStudioShellContext } from "../contexts/StudioContext";
|
||||
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
||||
import { useDomEditActionsContext } from "../contexts/DomEditContext";
|
||||
import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
|
||||
@@ -194,7 +193,6 @@ export function StudioHeader({
|
||||
}: StudioHeaderProps) {
|
||||
const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext();
|
||||
const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
|
||||
const { clearDomSelection } = useDomEditActionsContext();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
|
||||
@@ -279,7 +277,8 @@ export function StudioHeader({
|
||||
return;
|
||||
}
|
||||
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
|
||||
clearDomSelection();
|
||||
// Keep the current selection when collapsing the Inspector — closing
|
||||
// the panel shouldn't deselect the element.
|
||||
setRightCollapsed(true);
|
||||
}}
|
||||
disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { NLELayout } from "./nle/NLELayout";
|
||||
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
|
||||
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
|
||||
import { DomEditOverlay } from "./editor/DomEditOverlay";
|
||||
import { MotionPathOverlay } from "./editor/MotionPathOverlay";
|
||||
import { useCompositionDimensions } from "../hooks/useCompositionDimensions";
|
||||
import { SnapToolbar } from "./editor/SnapToolbar";
|
||||
import { StudioFeedbackBar } from "./StudioFeedbackBar";
|
||||
import type { TimelineElement } from "../player";
|
||||
@@ -10,6 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore";
|
||||
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
|
||||
import {
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED,
|
||||
STUDIO_KEYFRAMES_ENABLED,
|
||||
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
|
||||
STUDIO_PREVIEW_SELECTION_ENABLED,
|
||||
} from "./editor/manualEditingAvailability";
|
||||
@@ -108,6 +111,7 @@ export function StudioPreviewArea({
|
||||
isPlaying,
|
||||
refreshPreviewDocumentVersion,
|
||||
} = useStudioPlaybackContext();
|
||||
const compositionDimensions = useCompositionDimensions();
|
||||
|
||||
const {
|
||||
domEditHoverSelection,
|
||||
@@ -337,6 +341,14 @@ export function StudioPreviewArea({
|
||||
onToggleRecording={onToggleRecording}
|
||||
/>
|
||||
<SnapToolbar onSnapChange={setSnapPrefs} />
|
||||
{STUDIO_KEYFRAMES_ENABLED && (
|
||||
<MotionPathOverlay
|
||||
iframeRef={previewIframeRef}
|
||||
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
||||
compositionSize={compositionDimensions}
|
||||
isPlaying={isPlaying}
|
||||
/>
|
||||
)}
|
||||
{gestureOverlay}
|
||||
</>
|
||||
) : null
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useRef } from "react";
|
||||
import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes";
|
||||
import {
|
||||
useEnableKeyframes,
|
||||
isPlayheadWithinTween,
|
||||
type EnableKeyframesSession,
|
||||
} from "../hooks/useEnableKeyframes";
|
||||
import { computeElementPercentage } from "../hooks/gsapShared";
|
||||
import {
|
||||
getNextTimelineZoomPercent,
|
||||
getTimelineZoomPercent,
|
||||
@@ -44,23 +49,25 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
||||
const anims = session.selectedGsapAnimations;
|
||||
const kfAnim = anims.find((a) => a.keyframes);
|
||||
|
||||
const computePct = (time: number) => {
|
||||
const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1;
|
||||
return elDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10))
|
||||
: 0;
|
||||
};
|
||||
|
||||
let state: "active" | "inactive" | "none" = "none";
|
||||
// Outside the tween, clicking extends the animation to the playhead rather than
|
||||
// toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
|
||||
let willExtend = false;
|
||||
if (kfAnim?.keyframes && sel) {
|
||||
const pct = computePct(currentTime);
|
||||
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
|
||||
? "active"
|
||||
: "inactive";
|
||||
if (!isPlayheadWithinTween(kfAnim, currentTime)) {
|
||||
state = "inactive";
|
||||
willExtend = true;
|
||||
} else {
|
||||
// Tween-relative percentage (not the clip range) so the button state matches
|
||||
// where the keyframe would actually land.
|
||||
const pct = computeElementPercentage(currentTime, sel, kfAnim);
|
||||
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
|
||||
? "active"
|
||||
: "inactive";
|
||||
}
|
||||
}
|
||||
|
||||
return { state, onToggle: sel ? onToggle : undefined };
|
||||
return { state, willExtend, onToggle: sel ? onToggle : undefined };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -76,7 +83,11 @@ export function TimelineToolbar({
|
||||
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
|
||||
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
||||
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
||||
const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
|
||||
const {
|
||||
state: keyframeState,
|
||||
willExtend: keyframeWillExtend,
|
||||
onToggle: onToggleKeyframe,
|
||||
} = useKeyframeToggle(domEditSession);
|
||||
|
||||
return (
|
||||
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
|
||||
@@ -124,7 +135,9 @@ export function TimelineToolbar({
|
||||
keyframeState === "active"
|
||||
? "Remove keyframe at playhead"
|
||||
: keyframeState === "inactive"
|
||||
? "Add keyframe at playhead"
|
||||
? keyframeWillExtend
|
||||
? "Add keyframe at playhead (extends animation)"
|
||||
: "Add keyframe at playhead"
|
||||
: "Enable keyframes"
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -16,11 +16,13 @@ import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
|
||||
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
|
||||
import { useCompositionStack } from "./useCompositionStack";
|
||||
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
||||
import { setCompositionSourceMap } from "../editor/domEditingDom";
|
||||
import { trackStudioExpandedClipEdit } from "../../telemetry/events";
|
||||
import {
|
||||
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
||||
getTimelineToggleTitle,
|
||||
} from "../../utils/timelineDiscovery";
|
||||
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
|
||||
|
||||
interface NLELayoutProps {
|
||||
projectId: string;
|
||||
@@ -122,12 +124,14 @@ export const NLELayout = memo(function NLELayout({
|
||||
refreshPlayer,
|
||||
} = useTimelinePlayer();
|
||||
|
||||
// Reset timeline state when the project changes
|
||||
const prevProjectIdRef = useRef(projectId);
|
||||
if (prevProjectIdRef.current !== projectId) {
|
||||
prevProjectIdRef.current = projectId;
|
||||
// Reset timeline state when the project changes. Done in an effect, not during
|
||||
// render: reset() updates the player store, and updating another store/component
|
||||
// mid-render triggers React's "Cannot update a component while rendering a
|
||||
// different component" warning. The effect runs right after commit, so the new
|
||||
// project's first frame may briefly show prior timeline state before it clears.
|
||||
useEffect(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const stageRefForDrop = useRef<HTMLDivElement | null>(null);
|
||||
const handleStageRef = useCallback((ref: React.RefObject<HTMLDivElement | null>) => {
|
||||
@@ -158,6 +162,10 @@ export const NLELayout = memo(function NLELayout({
|
||||
|
||||
const onIframeLoad = useCallback(() => {
|
||||
baseOnIframeLoad();
|
||||
// Pre-load + register MotionPathPlugin once so adding a motion path in the
|
||||
// studio doesn't take the async plugin-load flash path on the first soft
|
||||
// reload (the comp may not ship the plugin until it actually uses one).
|
||||
ensureMotionPathPluginLoaded(iframeRef.current);
|
||||
onIframeRef?.(iframeRef.current);
|
||||
}, [baseOnIframeLoad, iframeRef, onIframeRef]);
|
||||
|
||||
@@ -294,6 +302,9 @@ export const NLELayout = memo(function NLELayout({
|
||||
if (id && src) map.set(id, src);
|
||||
}
|
||||
setCompIdToSrc(map);
|
||||
// Let DOM source-resolution recover a subcomposition element's source file
|
||||
// (the runtime drops the linkage when inlining — see getSourceFileForElement).
|
||||
setCompositionSourceMap(map);
|
||||
onCompIdToSrcChange?.(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -427,7 +438,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
{/* Preview + player controls */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div
|
||||
className="flex-1 min-h-0 relative"
|
||||
className="flex-1 min-h-0 relative overflow-hidden"
|
||||
data-preview-pan-surface="true"
|
||||
onPointerDown={(e) => {
|
||||
const el = iframeRef.current?.parentElement ?? iframeRef.current;
|
||||
|
||||
@@ -119,7 +119,7 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string
|
||||
mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
|
||||
mov: {
|
||||
label: "MOV (ProRes 4444)",
|
||||
desc: "Transparent video. Works in CapCut, Final Cut Pro, Premiere, DaVinci Resolve, After Effects. Large files.",
|
||||
desc: "Transparent video. Works in Final Cut Pro, DaVinci Resolve, and most video editors. Large files.",
|
||||
},
|
||||
webm: {
|
||||
label: "WebM (VP9)",
|
||||
|
||||
Reference in New Issue
Block a user