mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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:
@@ -0,0 +1,16 @@
|
||||
// Gated strategic logging for the GSAP keyframe / manual-drag / gesture / razor
|
||||
// edit flows. Silent in production; on in dev builds, or anywhere once you set
|
||||
// `window.__hfDebug = true` in the console. Single `[hf-edit:<scope>]` prefix so
|
||||
// the whole edit pipeline is greppable. Fires only at commit boundaries (user
|
||||
// actions), never in render/raf loops, so it doesn't spam.
|
||||
export function editLog(scope: string, ...args: unknown[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const w = window as unknown as { __hfDebug?: boolean };
|
||||
if (!import.meta.env.DEV && !w.__hfDebug) return;
|
||||
// Stringify object args so the console prints their contents inline (`{x:1}`)
|
||||
// instead of a collapsed `Object` — keeps the edit trail greppable/copyable.
|
||||
const parts = args.map((a) =>
|
||||
typeof a === "object" && a !== null ? JSON.stringify(a) : String(a),
|
||||
);
|
||||
console.debug(`[hf-edit:${scope}]`, ...parts);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { applySoftReload } from "./gsapSoftReload";
|
||||
import { applySoftReload, ensureMotionPathPluginLoaded } from "./gsapSoftReload";
|
||||
|
||||
const SCRIPT_TEXT = `
|
||||
window.__timelines = window.__timelines || {};
|
||||
@@ -10,6 +10,13 @@ tl.to("#box", { opacity: 0.8 });
|
||||
window.__timelines["root"] = tl;
|
||||
`;
|
||||
|
||||
const MOTION_PATH_SCRIPT_TEXT = `
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 50 }] } });
|
||||
window.__timelines["root"] = tl;
|
||||
`;
|
||||
|
||||
function buildMockIframe(overrides: Record<string, unknown> = {}) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.textContent =
|
||||
@@ -58,29 +65,35 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe("applySoftReload", () => {
|
||||
it("returns false when iframe is null", () => {
|
||||
expect(applySoftReload(null, SCRIPT_TEXT)).toBe(false);
|
||||
it('returns "cannot-soft-reload" when iframe is null', () => {
|
||||
expect(applySoftReload(null, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
||||
});
|
||||
|
||||
it("returns false when scriptText is empty", () => {
|
||||
it('returns "cannot-soft-reload" when scriptText is empty', () => {
|
||||
const { iframe } = buildMockIframe();
|
||||
expect(applySoftReload(iframe, "")).toBe(false);
|
||||
expect(applySoftReload(iframe, "")).toBe("cannot-soft-reload");
|
||||
});
|
||||
|
||||
it("returns false when gsap is not on iframe window", () => {
|
||||
it('returns "cannot-soft-reload" when gsap is not on iframe window', () => {
|
||||
const { iframe } = buildMockIframe({ gsap: undefined });
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
||||
});
|
||||
|
||||
it("returns false when __hfForceTimelineRebind is missing", () => {
|
||||
it('returns "cannot-soft-reload" when __hfForceTimelineRebind is missing', () => {
|
||||
const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined });
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
||||
});
|
||||
|
||||
it('returns "cannot-soft-reload" when the script registers no scopable key', () => {
|
||||
// No __timelines["key"] pattern → targetKeys is empty → can't scope safely.
|
||||
const { iframe } = buildMockIframe();
|
||||
expect(applySoftReload(iframe, 'gsap.to("#box", { x: 1 });')).toBe("cannot-soft-reload");
|
||||
});
|
||||
|
||||
it("kills existing timelines, rebinds, and re-seeks on success", () => {
|
||||
const { iframe, contentWindow, mockTimeline } = buildMockIframe();
|
||||
const result = applySoftReload(iframe, SCRIPT_TEXT);
|
||||
expect(result).toBe(true);
|
||||
expect(result).toBe("applied");
|
||||
expect(mockTimeline.kill).toHaveBeenCalled();
|
||||
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalled();
|
||||
expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
|
||||
@@ -96,11 +109,119 @@ describe("applySoftReload", () => {
|
||||
},
|
||||
});
|
||||
const result = applySoftReload(iframe, SCRIPT_TEXT);
|
||||
expect(result).toBe(true);
|
||||
expect(result).toBe("applied");
|
||||
expect(suppressionCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when multiple GSAP scripts exist (ambiguous)", () => {
|
||||
it('returns "applied" when the re-run re-registers the script\'s expected key', () => {
|
||||
// SCRIPT_TEXT registers __timelines["root"]; buildMockIframe's appendChild
|
||||
// shim repopulates `root` on execution. The hardened verify checks the
|
||||
// expected target key is present (not merely "some key"), so a correct re-run
|
||||
// reliably reports "applied" — it doesn't spuriously hit the transient window.
|
||||
const { iframe, contentWindow } = buildMockIframe();
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
|
||||
expect(contentWindow.__timelines.root).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns "verify-failed" (transient) when the re-run leaves the key empty', () => {
|
||||
// No appendChild shim repopulation: the body container has no shim, so the
|
||||
// re-run kills __timelines["root"] and the new script doesn't re-register it.
|
||||
// That is the TRANSIENT post-run window — surfaced as "verify-failed" so
|
||||
// callers know NOT to escalate (the live gsap.set already shows the value).
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.textContent = 'window.__timelines["root"] = gsap.timeline();';
|
||||
const container = document.createElement("div"); // no appendChild shim
|
||||
container.appendChild(scriptEl);
|
||||
const { iframe } = buildMockIframe();
|
||||
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
|
||||
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
body: container,
|
||||
head: document.createElement("div"),
|
||||
};
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("verify-failed");
|
||||
});
|
||||
|
||||
it("editing composition A leaves composition B's timeline intact (scoped kill)", () => {
|
||||
// Two comps live side by side; the soft reload only re-runs comp "root".
|
||||
// Comp "subscene" must survive untouched — the regression the full remount
|
||||
// (re-inline) used to cause.
|
||||
const subsceneTimeline = { kill: vi.fn(), pause: vi.fn() };
|
||||
const { iframe, contentWindow, mockTimeline } = buildMockIframe({
|
||||
__timelines: {
|
||||
root: { kill: vi.fn(), pause: vi.fn() },
|
||||
subscene: subsceneTimeline,
|
||||
} as Record<string, { kill: ReturnType<typeof vi.fn>; pause: ReturnType<typeof vi.fn> }>,
|
||||
});
|
||||
void mockTimeline;
|
||||
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
|
||||
// Comp B was never killed and is still registered.
|
||||
expect(subsceneTimeline.kill).not.toHaveBeenCalled();
|
||||
expect(contentWindow.__timelines.subscene).toBe(subsceneTimeline);
|
||||
});
|
||||
|
||||
it("runs synchronously (no async plugin load) when MotionPathPlugin is already present", () => {
|
||||
// The preview bootstrap pre-loads MotionPathPlugin, so win.MotionPathPlugin
|
||||
// is set before any motion-path edit. The soft reload must then execute the
|
||||
// script inline — no CDN <script> appended to <head>, the timeline is
|
||||
// repopulated synchronously, and verifyTimelinesPopulated reports the real
|
||||
// result (not the optimistic-true async path).
|
||||
const headAppends: Node[] = [];
|
||||
const head = document.createElement("div");
|
||||
const realHeadAppend = head.appendChild.bind(head);
|
||||
head.appendChild = <T extends Node>(node: T): T => {
|
||||
headAppends.push(node);
|
||||
return realHeadAppend(node);
|
||||
};
|
||||
const { iframe, contentWindow } = buildMockIframe({ MotionPathPlugin: {} });
|
||||
(iframe.contentDocument as unknown as { head: unknown }).head = head;
|
||||
|
||||
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT);
|
||||
|
||||
expect(result).toBe("applied");
|
||||
// No CDN plugin <script> was appended to <head> — ran inline.
|
||||
expect(headAppends.filter((n) => n instanceof HTMLScriptElement)).toHaveLength(0);
|
||||
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalled();
|
||||
expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
|
||||
expect(contentWindow.__timelines.root).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to the async plugin load when MotionPathPlugin is genuinely absent", () => {
|
||||
const head = document.createElement("div");
|
||||
const appendedScripts: HTMLScriptElement[] = [];
|
||||
const realHeadAppend = head.appendChild.bind(head);
|
||||
head.appendChild = <T extends Node>(node: T): T => {
|
||||
if (node instanceof HTMLScriptElement) appendedScripts.push(node);
|
||||
return realHeadAppend(node);
|
||||
};
|
||||
// gsap present but MotionPathPlugin unset → async load path.
|
||||
const { iframe, contentWindow } = buildMockIframe({
|
||||
MotionPathPlugin: undefined,
|
||||
gsap: { timeline: vi.fn(), registerPlugin: vi.fn() },
|
||||
});
|
||||
(iframe.contentDocument as unknown as { head: unknown }).head = head;
|
||||
|
||||
const onAsyncFailure = vi.fn();
|
||||
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, onAsyncFailure);
|
||||
|
||||
// Optimistically "applied" (script will run once the plugin loads) — and the
|
||||
// script has NOT executed yet, so the timeline isn't rebound synchronously.
|
||||
expect(result).toBe("applied");
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
|
||||
expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
|
||||
|
||||
// onerror must NOT run the script (that would reference a missing plugin) —
|
||||
// it escalates via onAsyncFailure so the caller can full-reload to recover,
|
||||
// and clears the in-flight loading flag.
|
||||
appendedScripts[0]!.onerror?.(new Event("error"));
|
||||
expect(onAsyncFailure).toHaveBeenCalledTimes(1);
|
||||
expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
|
||||
expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "cannot-soft-reload" when multiple GSAP scripts exist (ambiguous)', () => {
|
||||
const script1 = document.createElement("script");
|
||||
script1.textContent = "const tl = gsap.timeline({ paused: true });";
|
||||
const script2 = document.createElement("script");
|
||||
@@ -115,6 +236,89 @@ describe("applySoftReload", () => {
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
body: container,
|
||||
};
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
|
||||
// Multiple scripts, none registering "root" → can't identify what to replace
|
||||
// → structural failure that genuinely needs a full reload.
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
||||
});
|
||||
});
|
||||
|
||||
function buildBootstrapIframe(overrides: Record<string, unknown> = {}) {
|
||||
const head = document.createElement("div");
|
||||
const appendedScripts: HTMLScriptElement[] = [];
|
||||
const realHeadAppend = head.appendChild.bind(head);
|
||||
head.appendChild = <T extends Node>(node: T): T => {
|
||||
if (node instanceof HTMLScriptElement) appendedScripts.push(node);
|
||||
return realHeadAppend(node);
|
||||
};
|
||||
|
||||
const registerPlugin = vi.fn();
|
||||
const contentWindow = {
|
||||
gsap: { registerPlugin } as Record<string, unknown> | undefined,
|
||||
MotionPathPlugin: undefined as unknown,
|
||||
__hfMotionPathPluginLoading: undefined as boolean | undefined,
|
||||
...overrides,
|
||||
};
|
||||
const contentDocument = {
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
head,
|
||||
};
|
||||
return {
|
||||
iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
|
||||
contentWindow,
|
||||
appendedScripts,
|
||||
registerPlugin,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ensureMotionPathPluginLoaded", () => {
|
||||
it("no-ops when the iframe is null", () => {
|
||||
expect(() => ensureMotionPathPluginLoaded(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it("no-ops when gsap is unavailable", () => {
|
||||
const { iframe, appendedScripts } = buildBootstrapIframe({ gsap: undefined });
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
expect(appendedScripts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("appends the plugin script once and registers it on load", () => {
|
||||
const { iframe, contentWindow, appendedScripts, registerPlugin } = buildBootstrapIframe();
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
|
||||
expect(contentWindow.__hfMotionPathPluginLoading).toBe(true);
|
||||
|
||||
// Simulate the CDN load completing; the plugin is now present.
|
||||
contentWindow.MotionPathPlugin = {};
|
||||
appendedScripts[0]!.onload?.(new Event("load"));
|
||||
expect(registerPlugin).toHaveBeenCalledWith(contentWindow.MotionPathPlugin);
|
||||
expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent: a second call while loading does not append a second script", () => {
|
||||
const { iframe, appendedScripts } = buildBootstrapIframe();
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("registers an already-present plugin without appending a script", () => {
|
||||
const plugin = {};
|
||||
const { iframe, appendedScripts, registerPlugin } = buildBootstrapIframe({
|
||||
MotionPathPlugin: plugin,
|
||||
});
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
expect(appendedScripts).toHaveLength(0);
|
||||
expect(registerPlugin).toHaveBeenCalledWith(plugin);
|
||||
});
|
||||
|
||||
it("clears the loading flag and still resolves when the CDN load errors", () => {
|
||||
const { iframe, contentWindow, appendedScripts } = buildBootstrapIframe();
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
appendedScripts[0]!.onerror?.(new Event("error"));
|
||||
expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
|
||||
// A subsequent call can retry (plugin still absent, flag cleared).
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
expect(appendedScripts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,10 @@ type IframeWindow = Window & {
|
||||
__hfForceTimelineRebind?: () => void;
|
||||
__hfSuppressSceneMutations?: <T>(fn: () => T) => T;
|
||||
__hfStudioManualEditsApply?: () => void;
|
||||
// Set while a MotionPathPlugin <script> is being fetched, so overlapping soft
|
||||
// reloads (each needing the plugin) don't queue duplicate plugin scripts that
|
||||
// re-flash the iframe. Cleared once the plugin loads or errors.
|
||||
__hfMotionPathPluginLoading?: boolean;
|
||||
gsap?: {
|
||||
timeline?: (...args: unknown[]) => unknown;
|
||||
registerPlugin?: (...plugins: unknown[]) => unknown;
|
||||
@@ -13,6 +17,67 @@ type IframeWindow = Window & {
|
||||
MotionPathPlugin?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* CDN URL for the GSAP MotionPathPlugin. Shared between the one-time preview
|
||||
* bootstrap (ensureMotionPathPluginLoaded) and the soft-reload fallback so the
|
||||
* version is pinned in a single place.
|
||||
*/
|
||||
const MOTION_PATH_PLUGIN_CDN =
|
||||
"https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
|
||||
|
||||
/**
|
||||
* Pre-load + register MotionPathPlugin ONCE in the preview iframe so
|
||||
* `win.MotionPathPlugin` is reliably set before any studio edit. Called from the
|
||||
* preview bootstrap (NLELayout's onIframeLoad) on every iframe load.
|
||||
*
|
||||
* Why: when a user ADDS a motion path to a composition that never used one, the
|
||||
* plugin isn't loaded, so the first soft reload takes the async `<script src>`
|
||||
* load path — the timeline is killed/cleared while the CDN load is pending,
|
||||
* producing a visible flash. Loading it eagerly here means the soft reload runs
|
||||
* synchronously and `needsMotionPath && !win.MotionPathPlugin` never fires for
|
||||
* studio edits.
|
||||
*
|
||||
* Idempotent (no-ops once the plugin is present or already loading) and
|
||||
* defensive: no-ops without gsap/registerPlugin and tolerates a CDN failure
|
||||
* (the soft-reload async fallback in applySoftReload still covers that case).
|
||||
*/
|
||||
export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null): void {
|
||||
if (!iframe?.contentWindow || !iframe.contentDocument) return;
|
||||
const win = iframe.contentWindow as IframeWindow;
|
||||
const doc = iframe.contentDocument;
|
||||
|
||||
// Already registered (composition shipped its own plugin, or a prior bootstrap
|
||||
// ran) — register it on gsap to be safe, then bail.
|
||||
if (win.MotionPathPlugin) {
|
||||
try {
|
||||
if (win.gsap?.registerPlugin) win.gsap.registerPlugin(win.MotionPathPlugin);
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
if (!win.gsap?.registerPlugin) return;
|
||||
// A load is already in flight for this iframe — don't queue a second script.
|
||||
if (win.__hfMotionPathPluginLoading) return;
|
||||
|
||||
try {
|
||||
win.__hfMotionPathPluginLoading = true;
|
||||
const pluginScript = doc.createElement("script");
|
||||
pluginScript.src = MOTION_PATH_PLUGIN_CDN;
|
||||
const finalize = () => {
|
||||
win.__hfMotionPathPluginLoading = false;
|
||||
try {
|
||||
if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
|
||||
win.gsap.registerPlugin(win.MotionPathPlugin);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
pluginScript.onload = finalize;
|
||||
pluginScript.onerror = finalize;
|
||||
doc.head.appendChild(pluginScript);
|
||||
} catch {
|
||||
win.__hfMotionPathPluginLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isGsapScript(text: string): boolean {
|
||||
return (
|
||||
text.includes("gsap.timeline") ||
|
||||
@@ -44,14 +109,44 @@ export function extractGsapScriptText(html: string): string | null {
|
||||
return scripts[0].textContent || null;
|
||||
}
|
||||
|
||||
/** Check that the new script repopulated __timelines with at least one entry. */
|
||||
function verifyTimelinesPopulated(win: IframeWindow): boolean {
|
||||
const tlKeys = win.__timelines
|
||||
? Object.keys(win.__timelines).filter((k) => k !== "__proxied")
|
||||
: [];
|
||||
return tlKeys.length > 0;
|
||||
/**
|
||||
* Confirm the re-run repopulated the timeline(s) this script owns. We check the
|
||||
* EXPECTED keys (the ones the script re-registers), not merely "any key": a
|
||||
* scoped soft reload only re-runs ONE composition, so the right success signal is
|
||||
* "my target keys are back", not "the global map is non-empty". Checking the
|
||||
* exact keys avoids the transient false where the global map momentarily looks
|
||||
* empty right after the re-run — the spurious trigger of the full-remount fallback.
|
||||
*/
|
||||
function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): boolean {
|
||||
const timelines = win.__timelines;
|
||||
if (!timelines) return false;
|
||||
if (targetKeys.length > 0) {
|
||||
return targetKeys.every((key) => timelines[key] != null);
|
||||
}
|
||||
return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a soft-reload attempt. Callers must distinguish PERMANENT failures
|
||||
* (the preview genuinely can't be soft-updated — escalate to a full reload) from
|
||||
* the TRANSIENT post-run empty-timeline window (the live `gsap.set` already shows
|
||||
* the correct value — do NOT escalate; a remount would re-flash the WebGL context
|
||||
* and revert subcomposition keyframes):
|
||||
*
|
||||
* - `"applied"` — the script ran (or is deferred to the async plugin
|
||||
* load and WILL run). The preview is/will be correct.
|
||||
* - `"verify-failed"` — TRANSIENT: the re-run happened but `__timelines`
|
||||
* momentarily read empty. Live state is correct → do
|
||||
* NOT escalate. (Was a bare `false` before.)
|
||||
* - `"cannot-soft-reload"` — PERMANENT/STRUCTURAL: no gsap runtime, no rebind
|
||||
* hook, no scopable target key, or no script element
|
||||
* to replace. The preview is stale/broken → escalate.
|
||||
*
|
||||
* The async MotionPath-plugin load failure is still surfaced via
|
||||
* `onAsyncFailure` (it fires after this returned `"applied"` optimistically).
|
||||
*/
|
||||
export type SoftReloadResult = "applied" | "verify-failed" | "cannot-soft-reload";
|
||||
|
||||
/**
|
||||
* Replace the GSAP script in the live iframe without reloading. This preserves
|
||||
* the WebGL context and shader transition cache.
|
||||
@@ -60,22 +155,60 @@ function verifyTimelinesPopulated(win: IframeWindow): boolean {
|
||||
* elements (sub-compositions) are not visible to `querySelectorAll` and will
|
||||
* fall back to a full iframe reload.
|
||||
*
|
||||
* Returns false (triggering a full reload fallback) when:
|
||||
* Returns `"cannot-soft-reload"` (caller should full-reload) when:
|
||||
* - The iframe or GSAP runtime isn't available
|
||||
* - Multiple GSAP scripts are found (ambiguous which to replace)
|
||||
* - No matching GSAP script element exists in the live DOM
|
||||
* - The rebind hook isn't installed
|
||||
* - The script registers no scopable `__timelines` key
|
||||
* - No GSAP script element exists in the live DOM
|
||||
* - The synchronous re-run threw
|
||||
*
|
||||
* Returns `"verify-failed"` when the re-run executed but the target timeline
|
||||
* keys read empty in the transient post-run window (live state is still correct).
|
||||
*
|
||||
* `onAsyncFailure` is invoked when the soft reload was deferred to load the
|
||||
* MotionPath plugin (so this returned `"applied"` optimistically) but the plugin
|
||||
* `<script>` then failed to load — the iframe is left without the plugin and the
|
||||
* caller should perform a full reload to recover. It never fires on the
|
||||
* synchronous paths.
|
||||
*/
|
||||
export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: string): boolean {
|
||||
if (!iframe || !scriptText) return false;
|
||||
export function applySoftReload(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
scriptText: string,
|
||||
onAsyncFailure?: () => void,
|
||||
): SoftReloadResult {
|
||||
if (!iframe || !scriptText) return "cannot-soft-reload";
|
||||
|
||||
const win = iframe.contentWindow as IframeWindow | null;
|
||||
const doc = iframe.contentDocument;
|
||||
if (!win || !doc) return false;
|
||||
if (!win.gsap || !win.__hfForceTimelineRebind) return false;
|
||||
if (!win || !doc) return "cannot-soft-reload";
|
||||
if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload";
|
||||
|
||||
// Which composition(s) does this script rebuild? A soft reload re-runs ONE
|
||||
// composition's GSAP script, which re-registers its own window.__timelines[key].
|
||||
// In a multi-composition preview (top-level + inlined subcompositions) each
|
||||
// composition owns a separate timeline keyed by its id, and they're all children
|
||||
// of the global timeline — so tearing down ALL of them (or the global timeline's
|
||||
// children) and re-running a single script wipes every OTHER composition,
|
||||
// reverting its edits. Scope the teardown to the keys THIS script re-registers.
|
||||
const targetKeys = [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
|
||||
.map((m) => m[1]!)
|
||||
.filter((key) => key !== "__proxied");
|
||||
if (targetKeys.length === 0) return "cannot-soft-reload"; // can't scope safely → full reload
|
||||
const gsapScripts = findGsapScriptElements(doc);
|
||||
if (gsapScripts.length !== 1) return false;
|
||||
const oldScriptEl = gsapScripts[0]!;
|
||||
if (gsapScripts.length === 0) return "cannot-soft-reload";
|
||||
// Remove only the stale script element(s) that registered a target key; one we
|
||||
// can't match in the doc is left alone (re-running appends a fresh element).
|
||||
const staleScripts = gsapScripts.filter((script) =>
|
||||
targetKeys.some((key) => {
|
||||
const text = script.textContent || "";
|
||||
return text.includes(`__timelines["${key}"]`) || text.includes(`__timelines['${key}']`);
|
||||
}),
|
||||
);
|
||||
// Multiple GSAP scripts exist but none registers a key this script owns — we
|
||||
// can't identify which element to replace (ambiguous, matching
|
||||
// extractGsapScriptText's single-script requirement). Escalate to a full reload
|
||||
// rather than killing the target timeline and appending an orphan script.
|
||||
if (gsapScripts.length > 1 && staleScripts.length === 0) return "cannot-soft-reload";
|
||||
|
||||
const currentTime = win.__player?.getTime?.() ?? 0;
|
||||
|
||||
@@ -91,47 +224,55 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
||||
const timelines = win.__timelines;
|
||||
const allTargets: Element[] = [];
|
||||
|
||||
// Kill ONLY the target composition's timeline(s) — leaving every other
|
||||
// composition's timeline (and its children on the global timeline) intact.
|
||||
if (timelines) {
|
||||
for (const key of Object.keys(timelines)) {
|
||||
if (key === "__proxied") continue;
|
||||
try {
|
||||
const tl = timelines[key] as {
|
||||
kill?: () => void;
|
||||
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
|
||||
};
|
||||
if (tl?.getChildren) {
|
||||
try {
|
||||
for (const child of tl.getChildren(true)) {
|
||||
if (typeof child.targets === "function") {
|
||||
for (const t of child.targets()) allTargets.push(t);
|
||||
}
|
||||
for (const key of targetKeys) {
|
||||
const tl = timelines[key] as
|
||||
| {
|
||||
kill?: () => void;
|
||||
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
|
||||
}
|
||||
| undefined;
|
||||
if (!tl) continue;
|
||||
if (tl.getChildren) {
|
||||
try {
|
||||
for (const child of tl.getChildren(true)) {
|
||||
if (typeof child.targets === "function") {
|
||||
for (const t of child.targets()) allTargets.push(t);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
tl?.kill?.();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
tl.kill?.();
|
||||
} catch {}
|
||||
delete timelines[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Kill bare gsap.to/from tweens not registered on __timelines
|
||||
if (win.gsap?.globalTimeline?.getChildren) {
|
||||
try {
|
||||
for (const child of win.gsap.globalTimeline.getChildren(false)) {
|
||||
child.kill?.();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Clear residual inline transforms left by killed tweens so from() tweens
|
||||
// don't read stale end values from the DOM on re-execution
|
||||
// Reset GSAP's internal transform cache so from() tweens don't read stale
|
||||
// end values. `clearProps: "all"` is needed to flush the cache, but it also
|
||||
// nukes the element's CSS base (position, width, height, etc.) from the
|
||||
// HTML `style=""` attribute. Save → clear → restore → strip `transform`.
|
||||
if (allTargets.length > 0 && win.gsap?.set) {
|
||||
const saved: Array<[Element, string]> = [];
|
||||
for (const el of allTargets) {
|
||||
const s = (el as HTMLElement).style;
|
||||
if (s?.cssText != null) saved.push([el, s.cssText]);
|
||||
}
|
||||
try {
|
||||
win.gsap.set(allTargets, { clearProps: "all" });
|
||||
} catch {}
|
||||
for (const [el, css] of saved) {
|
||||
const s = (el as HTMLElement).style;
|
||||
if (!s) continue;
|
||||
s.cssText = css;
|
||||
s.removeProperty("transform");
|
||||
}
|
||||
}
|
||||
|
||||
oldScriptEl.remove();
|
||||
for (const script of staleScripts) script.remove();
|
||||
|
||||
const executeScript = () => {
|
||||
if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
|
||||
@@ -148,10 +289,41 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
||||
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
|
||||
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
|
||||
deferredToAsync = true;
|
||||
// A prior soft reload is already fetching the plugin — don't queue a second
|
||||
// <script> (it re-flashes the iframe). Defer THIS script's execution until
|
||||
// the in-flight load settles via a one-shot poll. The bootstrap guard is
|
||||
// the single source of truth for "plugin fetch in progress".
|
||||
if (win.__hfMotionPathPluginLoading) {
|
||||
const started = Date.now();
|
||||
const poll = win.setInterval(() => {
|
||||
if (win.MotionPathPlugin) {
|
||||
win.clearInterval(poll);
|
||||
executeScript();
|
||||
} else if (!win.__hfMotionPathPluginLoading || Date.now() - started > 10000) {
|
||||
// The in-flight load finished without registering the plugin (errored)
|
||||
// or we timed out — recover with a full reload instead of running a
|
||||
// script that references a missing plugin.
|
||||
win.clearInterval(poll);
|
||||
onAsyncFailure?.();
|
||||
}
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
win.__hfMotionPathPluginLoading = true;
|
||||
const pluginScript = doc.createElement("script");
|
||||
pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
|
||||
pluginScript.onload = () => executeScript();
|
||||
pluginScript.onerror = () => executeScript();
|
||||
pluginScript.src = MOTION_PATH_PLUGIN_CDN;
|
||||
pluginScript.onload = () => {
|
||||
win.__hfMotionPathPluginLoading = false;
|
||||
executeScript();
|
||||
};
|
||||
pluginScript.onerror = () => {
|
||||
// The plugin failed to load. Running executeScript() now would leave the
|
||||
// iframe with a motionPath tween referencing a missing plugin while the
|
||||
// caller already thinks the soft reload succeeded. Signal failure so the
|
||||
// caller can full-reload (which fetches the plugin fresh) instead.
|
||||
win.__hfMotionPathPluginLoading = false;
|
||||
onAsyncFailure?.();
|
||||
};
|
||||
doc.head.appendChild(pluginScript);
|
||||
return;
|
||||
}
|
||||
@@ -166,10 +338,16 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
||||
doReload();
|
||||
}
|
||||
// When MotionPath needs async loading, the script hasn't executed yet —
|
||||
// skip the __timelines check and return true optimistically.
|
||||
if (deferredToAsync) return true;
|
||||
return verifyTimelinesPopulated(win);
|
||||
// skip the __timelines check and report success optimistically (the script
|
||||
// WILL run on plugin load; onAsyncFailure covers the CDN-error case).
|
||||
if (deferredToAsync) return "applied";
|
||||
// The re-run executed. If the target keys read back, we're done; otherwise
|
||||
// it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
|
||||
// as "verify-failed" so callers know NOT to escalate.
|
||||
return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
|
||||
} catch {
|
||||
return false;
|
||||
// The synchronous re-run threw — the preview is now genuinely broken (target
|
||||
// timeline killed, script not re-registered). Escalate to a full reload.
|
||||
return "cannot-soft-reload";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findMatchingTimelineElementId, resolveTimelineSelectionSeekTime } from "./studioHelpers";
|
||||
import {
|
||||
findMatchingTimelineElementId,
|
||||
findTimelineIdByAncestor,
|
||||
resolveTimelineSelectionSeekTime,
|
||||
} from "./studioHelpers";
|
||||
|
||||
describe("resolveTimelineSelectionSeekTime", () => {
|
||||
it("keeps the current time when it is already inside the clip range", () => {
|
||||
@@ -42,3 +48,27 @@ describe("findMatchingTimelineElementId", () => {
|
||||
expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findTimelineIdByAncestor", () => {
|
||||
const el = (over: Record<string, unknown>) =>
|
||||
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
|
||||
|
||||
it("resolves a static descendant (.num) to its nearest clip ancestor", () => {
|
||||
// #stat1 (a clip) > .num (selected, not a clip)
|
||||
const stat1 = document.createElement("div");
|
||||
stat1.id = "stat1";
|
||||
const num = document.createElement("div");
|
||||
num.className = "num";
|
||||
stat1.appendChild(num);
|
||||
|
||||
const els = [el({ id: "stat1", domId: "stat1", key: "index.html#stat1" })];
|
||||
expect(findTimelineIdByAncestor(num, els, "index.html")).toBe("index.html#stat1");
|
||||
});
|
||||
|
||||
it("returns null when no ancestor is a clip", () => {
|
||||
const wrap = document.createElement("div");
|
||||
const child = document.createElement("span");
|
||||
wrap.appendChild(child);
|
||||
expect(findTimelineIdByAncestor(child, [], "index.html")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,6 +185,30 @@ export function findMatchingTimelineElementId(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A selected DOM node may be a static descendant of a clip (e.g. the `.num` text
|
||||
* inside a `#stat1` card) — not a timeline element itself. Walk up to the nearest
|
||||
* ancestor that IS a clip so the timeline still selects + inline-expands around it.
|
||||
*/
|
||||
export function findTimelineIdByAncestor(
|
||||
element: Element | null | undefined,
|
||||
elements: TimelineElement[],
|
||||
sourceFile: string,
|
||||
): string | null {
|
||||
let ancestor = element?.parentElement ?? null;
|
||||
while (ancestor) {
|
||||
const id = ancestor.id;
|
||||
if (id) {
|
||||
const match = elements.find(
|
||||
(el) => el.domId === id && (el.sourceFile ?? "index.html") === sourceFile,
|
||||
);
|
||||
if (match) return match.key ?? match.id;
|
||||
}
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveTimelineSelectionSeekTime(
|
||||
currentTime: number,
|
||||
element: Pick<TimelineElement, "start" | "duration"> | null | undefined,
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { pauseStudioPreviewPlayback } from "./studioPreviewHelpers";
|
||||
import { coversComposition, pauseStudioPreviewPlayback } from "./studioPreviewHelpers";
|
||||
|
||||
describe("coversComposition (full-bleed canvas-pick exclusion)", () => {
|
||||
const viewport = { width: 1920, height: 1080 };
|
||||
|
||||
it("treats a full-bleed scene wrapper as covering the composition", () => {
|
||||
expect(coversComposition({ width: 1920, height: 1080 }, viewport)).toBe(true);
|
||||
expect(coversComposition({ width: 1900, height: 1040 }, viewport)).toBe(true); // ~99%/96%
|
||||
});
|
||||
|
||||
it("does NOT exclude inner content (a stat card, a heading)", () => {
|
||||
expect(coversComposition({ width: 320, height: 180 }, viewport)).toBe(false);
|
||||
expect(coversComposition({ width: 1900, height: 200 }, viewport)).toBe(false); // wide but short
|
||||
expect(coversComposition({ width: 200, height: 1040 }, viewport)).toBe(false); // tall but narrow
|
||||
});
|
||||
|
||||
it("needs BOTH axes near full-bleed (>=95%)", () => {
|
||||
expect(coversComposition({ width: 1800, height: 1080 }, viewport)).toBe(false); // 93.75% wide
|
||||
expect(coversComposition({ width: 1920, height: 1000 }, viewport)).toBe(false); // 92.6% tall
|
||||
});
|
||||
|
||||
it("guards against a degenerate viewport", () => {
|
||||
expect(coversComposition({ width: 100, height: 100 }, { width: 0, height: 0 })).toBe(false);
|
||||
expect(coversComposition({ width: 100, height: 100 }, { width: 1, height: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pauseStudioPreviewPlayback", () => {
|
||||
it("pauses through __player without pausing sibling timelines directly", () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { DomEditViewport } from "../components/editor/domEditing";
|
||||
import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing";
|
||||
import {
|
||||
getDomLayerPatchTarget,
|
||||
isElementComputedVisible,
|
||||
@@ -13,6 +12,29 @@ interface PreviewLocalPointer {
|
||||
viewport: DomEditViewport;
|
||||
}
|
||||
|
||||
// An element is "full-bleed" when its box spans nearly the whole composition on
|
||||
// BOTH axes. Such elements (scene wrappers, backdrops) are excluded from canvas
|
||||
// click-picking so a click lands on inner content — or deselects on empty area —
|
||||
// instead of grabbing the giant container. The Layers panel still selects them.
|
||||
// ponytail: pure size heuristic; tighten the ratio if decorative full-bleed art
|
||||
// should remain canvas-selectable.
|
||||
const FULL_BLEED_RATIO = 0.95;
|
||||
|
||||
export function coversComposition(
|
||||
elRect: { width: number; height: number },
|
||||
viewport: DomEditViewport,
|
||||
): boolean {
|
||||
if (viewport.width <= 1 || viewport.height <= 1) return false;
|
||||
return (
|
||||
elRect.width / viewport.width >= FULL_BLEED_RATIO &&
|
||||
elRect.height / viewport.height >= FULL_BLEED_RATIO
|
||||
);
|
||||
}
|
||||
|
||||
function isFullBleedTarget(el: HTMLElement, viewport: DomEditViewport): boolean {
|
||||
return coversComposition(el.getBoundingClientRect(), viewport);
|
||||
}
|
||||
|
||||
function resolvePreviewLocalPointer(
|
||||
iframe: HTMLIFrameElement,
|
||||
doc: Document,
|
||||
@@ -82,18 +104,19 @@ export function getPreviewTargetFromPointer(
|
||||
const overrideStyle = forcePointerEventsAuto(doc);
|
||||
try {
|
||||
if (typeof doc.elementsFromPoint === "function") {
|
||||
const visualTarget = resolveVisualDomEditSelectionTarget(
|
||||
const candidates = resolveAllVisualDomEditTargets(
|
||||
doc.elementsFromPoint(localPointer.x, localPointer.y),
|
||||
{
|
||||
activeCompositionPath,
|
||||
},
|
||||
{ activeCompositionPath },
|
||||
);
|
||||
const visualTarget =
|
||||
candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null;
|
||||
if (visualTarget) return visualTarget;
|
||||
}
|
||||
|
||||
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
||||
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
|
||||
if (!isElementComputedVisible(fallback)) return null;
|
||||
if (isFullBleedTarget(fallback, localPointer.viewport)) return null;
|
||||
return fallback;
|
||||
} finally {
|
||||
removePointerEventsOverride(overrideStyle);
|
||||
@@ -125,11 +148,12 @@ export function getAllPreviewTargetsFromPointer(
|
||||
if (typeof doc.elementsFromPoint === "function") {
|
||||
return resolveAllVisualDomEditTargets(doc.elementsFromPoint(localPointer.x, localPointer.y), {
|
||||
activeCompositionPath,
|
||||
});
|
||||
}).filter((el) => !isFullBleedTarget(el, localPointer.viewport));
|
||||
}
|
||||
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
||||
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return [];
|
||||
if (!isElementComputedVisible(fallback)) return [];
|
||||
if (isFullBleedTarget(fallback, localPointer.viewport)) return [];
|
||||
return [fallback];
|
||||
} finally {
|
||||
removePointerEventsOverride(overrideStyle);
|
||||
|
||||
Reference in New Issue
Block a user