mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
* 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)
603 lines
24 KiB
TypeScript
603 lines
24 KiB
TypeScript
import {
|
|
STUDIO_OFFSET_X_PROP,
|
|
STUDIO_OFFSET_Y_PROP,
|
|
STUDIO_WIDTH_PROP,
|
|
STUDIO_HEIGHT_PROP,
|
|
STUDIO_ROTATION_PROP,
|
|
STUDIO_PATH_OFFSET_ATTR,
|
|
STUDIO_MANUAL_EDIT_GESTURE_ATTR,
|
|
STUDIO_BOX_SIZE_ATTR,
|
|
STUDIO_ROTATION_ATTR,
|
|
STUDIO_ORIGINAL_TRANSLATE_ATTR,
|
|
STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
|
|
STUDIO_ORIGINAL_WIDTH_ATTR,
|
|
STUDIO_ORIGINAL_HEIGHT_ATTR,
|
|
STUDIO_ORIGINAL_MIN_WIDTH_ATTR,
|
|
STUDIO_ORIGINAL_MIN_HEIGHT_ATTR,
|
|
STUDIO_ORIGINAL_MAX_WIDTH_ATTR,
|
|
STUDIO_ORIGINAL_MAX_HEIGHT_ATTR,
|
|
STUDIO_ORIGINAL_FLEX_BASIS_ATTR,
|
|
STUDIO_ORIGINAL_FLEX_GROW_ATTR,
|
|
STUDIO_ORIGINAL_FLEX_SHRINK_ATTR,
|
|
STUDIO_ORIGINAL_BOX_SIZING_ATTR,
|
|
STUDIO_ORIGINAL_SCALE_ATTR,
|
|
STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR,
|
|
STUDIO_ORIGINAL_DISPLAY_ATTR,
|
|
STUDIO_ORIGINAL_ROTATE_ATTR,
|
|
STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
|
|
STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
|
|
STUDIO_ROTATION_DRAFT_ATTR,
|
|
STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
|
STUDIO_ROTATION_TRANSFORM_ORIGIN,
|
|
} from "./manualEditsTypes";
|
|
import { roundRotationAngle } from "./manualEditsParsing";
|
|
import { applyStudioMotionFromDom } from "./studioMotion";
|
|
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
|
|
|
|
/* ── Gesture tracking ─────────────────────────────────────────────── */
|
|
let studioManualEditGestureId = 0;
|
|
|
|
export function beginStudioManualEditGesture(element: HTMLElement): string {
|
|
studioManualEditGestureId += 1;
|
|
const token = `gesture-${studioManualEditGestureId}`;
|
|
element.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, token);
|
|
return token;
|
|
}
|
|
|
|
export function endStudioManualEditGesture(element: HTMLElement, token?: string): void {
|
|
if (token && element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR) !== token) return;
|
|
element.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
|
|
}
|
|
|
|
function isStudioManualEditGestureActive(element: HTMLElement): boolean {
|
|
return element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
|
|
}
|
|
|
|
export function isStudioManualEditGestureCurrent(element: HTMLElement, token: string): boolean {
|
|
return element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR) === token;
|
|
}
|
|
|
|
/* ── CSS custom-property readers ──────────────────────────────────── */
|
|
function readPxCustomProperty(element: HTMLElement, property: string): number {
|
|
const value = Number.parseFloat(element.style.getPropertyValue(property));
|
|
return Number.isFinite(value) ? value : 0;
|
|
}
|
|
|
|
export function readStudioPathOffset(element: HTMLElement): { x: number; y: number } {
|
|
return {
|
|
x: readPxCustomProperty(element, STUDIO_OFFSET_X_PROP),
|
|
y: readPxCustomProperty(element, STUDIO_OFFSET_Y_PROP),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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),
|
|
height: readPxCustomProperty(element, STUDIO_HEIGHT_PROP),
|
|
};
|
|
}
|
|
|
|
export function readStudioRotation(element: HTMLElement): { angle: number } {
|
|
const value = Number.parseFloat(element.style.getPropertyValue(STUDIO_ROTATION_PROP));
|
|
return { angle: Number.isFinite(value) ? value : 0 };
|
|
}
|
|
|
|
/* ── Internal style helpers ───────────────────────────────────────── */
|
|
function safeComputedStyleProperty(element: HTMLElement, property: string): string {
|
|
try {
|
|
return (
|
|
element.ownerDocument.defaultView?.getComputedStyle(element).getPropertyValue(property) ?? ""
|
|
);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function readStyleOrComputed(element: HTMLElement, property: string): string {
|
|
return element.style.getPropertyValue(property) || safeComputedStyleProperty(element, property);
|
|
}
|
|
|
|
function readTransformLonghandBase(element: HTMLElement, property: "translate" | "rotate"): string {
|
|
const value = readStyleOrComputed(element, property).trim();
|
|
return value === "none" ? "" : value;
|
|
}
|
|
|
|
export function styleUsesStudioOffset(value: string): boolean {
|
|
return value.includes(STUDIO_OFFSET_X_PROP) || value.includes(STUDIO_OFFSET_Y_PROP);
|
|
}
|
|
|
|
export function styleUsesStudioSize(value: string): boolean {
|
|
return value.includes(STUDIO_WIDTH_PROP) || value.includes(STUDIO_HEIGHT_PROP);
|
|
}
|
|
|
|
export function styleUsesStudioRotation(value: string): boolean {
|
|
return value.includes(STUDIO_ROTATION_PROP);
|
|
}
|
|
|
|
function compactStyleValue(value: string): string {
|
|
return value.replace(/\s+/g, "").toLowerCase();
|
|
}
|
|
|
|
function styleMatchesStudioRotationDraft(element: HTMLElement, value: string): boolean {
|
|
if (!element.hasAttribute(STUDIO_ROTATION_DRAFT_ATTR)) return false;
|
|
const rotation = element.style.getPropertyValue(STUDIO_ROTATION_PROP).trim();
|
|
if (!rotation || !value.trim()) return false;
|
|
return (
|
|
compactStyleValue(value) === compactStyleValue(composeStudioRotationValue(element, rotation))
|
|
);
|
|
}
|
|
|
|
/* ── Inline promotion ─────────────────────────────────────────────── */
|
|
function promoteInlineForTransform(element: HTMLElement): void {
|
|
const computedDisplay = safeComputedStyleProperty(element, "display");
|
|
if (computedDisplay !== "inline") return;
|
|
if (!element.hasAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR)) {
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
|
element.style.getPropertyValue("display"),
|
|
);
|
|
}
|
|
element.style.setProperty("display", "inline-block");
|
|
}
|
|
|
|
export function restoreInlineDisplay(element: HTMLElement): void {
|
|
const original = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
|
|
if (original == null) return;
|
|
if (original === "") element.style.removeProperty("display");
|
|
else element.style.setProperty("display", original);
|
|
element.removeAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
|
|
}
|
|
|
|
/* ── Translate helpers ────────────────────────────────────────────── */
|
|
function splitTopLevelWhitespace(value: string): string[] {
|
|
const parts: string[] = [];
|
|
let depth = 0;
|
|
let current = "";
|
|
for (const char of value.trim()) {
|
|
if (char === "(") depth += 1;
|
|
if (char === ")") depth = Math.max(0, depth - 1);
|
|
if (/\s/.test(char) && depth === 0) {
|
|
if (current) parts.push(current);
|
|
current = "";
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
if (current) parts.push(current);
|
|
return parts;
|
|
}
|
|
|
|
function composeTranslateValue(element: HTMLElement, x: string, y: string): string {
|
|
const original = element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR)?.trim();
|
|
if (!original || original === "none") return `${x} ${y}`;
|
|
|
|
const parts = splitTopLevelWhitespace(original);
|
|
if (parts.length === 1) return `calc(${parts[0]} + ${x}) ${y}`;
|
|
if (parts.length === 2) return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y})`;
|
|
if (parts.length === 3) {
|
|
return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y}) ${parts[2]}`;
|
|
}
|
|
return `${x} ${y}`;
|
|
}
|
|
|
|
function prepareStudioPathOffsetBase(element: HTMLElement, updateBase: boolean): void {
|
|
const inlineTranslate = element.style.getPropertyValue("translate");
|
|
const currentTranslate = readTransformLonghandBase(element, "translate");
|
|
const hasMarker = element.hasAttribute(STUDIO_PATH_OFFSET_ATTR);
|
|
const wasResetByAnimation = !styleUsesStudioOffset(currentTranslate);
|
|
if (!hasMarker) {
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
|
|
styleUsesStudioOffset(inlineTranslate) ? "" : inlineTranslate,
|
|
);
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_TRANSLATE_ATTR,
|
|
wasResetByAnimation ? currentTranslate : "",
|
|
);
|
|
} else if (updateBase && wasResetByAnimation && !isStudioManualEditGestureActive(element)) {
|
|
element.setAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR, currentTranslate);
|
|
}
|
|
}
|
|
|
|
function writeStudioPathOffsetVars(
|
|
element: HTMLElement,
|
|
offset: { x: number; y: number },
|
|
options: { updateBase?: boolean } = {},
|
|
): void {
|
|
prepareStudioPathOffsetBase(element, options.updateBase ?? true);
|
|
element.setAttribute(STUDIO_PATH_OFFSET_ATTR, "true");
|
|
element.style.setProperty(STUDIO_OFFSET_X_PROP, `${Math.round(offset.x)}px`);
|
|
element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${Math.round(offset.y)}px`);
|
|
}
|
|
|
|
/* ── Path offset apply ────────────────────────────────────────────── */
|
|
|
|
// GSAP 3.x reads the resolved CSS `translate` individual property at initialization and bakes it
|
|
// into element.style.transform (as a matrix) on every seek. When the studio's reapply hook also
|
|
// writes `translate`, both properties compose additively, doubling the visual offset.
|
|
//
|
|
// This helper subtracts only the baked studio offset from m41/m42, preserving any GSAP animation
|
|
// contribution (e.g. a tween animating y: -20). The studio offset is read from the CSS custom
|
|
// properties which tell us exactly how much was baked from the CSS translate.
|
|
function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
|
|
return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1;
|
|
}
|
|
|
|
function stripGsapTranslateFromTransform(element: HTMLElement): void {
|
|
if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
|
|
const transform = element.style.getPropertyValue("transform");
|
|
if (!transform || transform === "none") return;
|
|
const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null)
|
|
?.DOMMatrix;
|
|
if (!DOMMatrixCtor) return;
|
|
try {
|
|
const m = new DOMMatrixCtor(transform);
|
|
if (m.m41 === 0 && m.m42 === 0) return;
|
|
const offsetX = readPxCustomProperty(element, STUDIO_OFFSET_X_PROP);
|
|
const offsetY = readPxCustomProperty(element, STUDIO_OFFSET_Y_PROP);
|
|
const angle = Math.atan2(m.b, m.a);
|
|
const cos = Math.cos(angle);
|
|
const sin = Math.sin(angle);
|
|
m.m41 -= offsetX * cos - offsetY * sin;
|
|
m.m42 -= offsetX * sin + offsetY * cos;
|
|
if (Math.abs(m.m41) < 0.01 && Math.abs(m.m42) < 0.01 && isIdentityAfterTranslateStrip(m)) {
|
|
element.style.removeProperty("transform");
|
|
} else {
|
|
element.style.setProperty("transform", m.toString());
|
|
}
|
|
} catch {
|
|
/* non-parseable transform — leave as-is */
|
|
}
|
|
}
|
|
|
|
// 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 },
|
|
options: { updateBase?: boolean } = {},
|
|
): 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(
|
|
element,
|
|
`var(${STUDIO_OFFSET_X_PROP}, 0px)`,
|
|
`var(${STUDIO_OFFSET_Y_PROP}, 0px)`,
|
|
),
|
|
);
|
|
stripGsapTranslateFromTransform(element);
|
|
}
|
|
|
|
export function applyStudioPathOffsetDraft(
|
|
element: HTMLElement,
|
|
offset: { x: number; y: number },
|
|
): void {
|
|
promoteInlineForTransform(element);
|
|
writeStudioPathOffsetVars(element, offset, { updateBase: false });
|
|
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 ───────────────────────────────────────────────── */
|
|
function readParentFlexBasisPixels(
|
|
element: HTMLElement,
|
|
size: { width: number; height: number },
|
|
): number | null {
|
|
const parent = element.parentElement;
|
|
if (!parent) return null;
|
|
|
|
const display = readStyleOrComputed(parent, "display").trim();
|
|
if (display !== "flex" && display !== "inline-flex") return null;
|
|
|
|
const direction = readStyleOrComputed(parent, "flex-direction").trim();
|
|
return Math.round(Math.max(1, direction.startsWith("column") ? size.height : size.width));
|
|
}
|
|
|
|
function restoreStaleStudioScaleResize(element: HTMLElement): void {
|
|
if (!element.hasAttribute(STUDIO_ORIGINAL_SCALE_ATTR)) return;
|
|
const origScale = element.getAttribute(STUDIO_ORIGINAL_SCALE_ATTR);
|
|
if (origScale == null || origScale === "") element.style.removeProperty("scale");
|
|
else element.style.setProperty("scale", origScale);
|
|
element.removeAttribute(STUDIO_ORIGINAL_SCALE_ATTR);
|
|
const origOrigin = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR);
|
|
if (origOrigin == null || origOrigin === "") element.style.removeProperty("transform-origin");
|
|
else element.style.setProperty("transform-origin", origOrigin);
|
|
element.removeAttribute(STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR);
|
|
}
|
|
|
|
function writeStudioBoxSizeVars(
|
|
element: HTMLElement,
|
|
size: { width: number; height: number },
|
|
): void {
|
|
if (!element.hasAttribute(STUDIO_BOX_SIZE_ATTR)) {
|
|
element.setAttribute(STUDIO_ORIGINAL_WIDTH_ATTR, element.style.getPropertyValue("width"));
|
|
element.setAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR, element.style.getPropertyValue("height"));
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_MIN_WIDTH_ATTR,
|
|
element.style.getPropertyValue("min-width"),
|
|
);
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_MIN_HEIGHT_ATTR,
|
|
element.style.getPropertyValue("min-height"),
|
|
);
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_MAX_WIDTH_ATTR,
|
|
element.style.getPropertyValue("max-width"),
|
|
);
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_MAX_HEIGHT_ATTR,
|
|
element.style.getPropertyValue("max-height"),
|
|
);
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_FLEX_BASIS_ATTR,
|
|
element.style.getPropertyValue("flex-basis"),
|
|
);
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_FLEX_GROW_ATTR,
|
|
element.style.getPropertyValue("flex-grow"),
|
|
);
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_FLEX_SHRINK_ATTR,
|
|
element.style.getPropertyValue("flex-shrink"),
|
|
);
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_BOX_SIZING_ATTR,
|
|
element.style.getPropertyValue("box-sizing"),
|
|
);
|
|
element.setAttribute(STUDIO_ORIGINAL_SCALE_ATTR, element.style.getPropertyValue("scale"));
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR,
|
|
element.style.getPropertyValue("transform-origin"),
|
|
);
|
|
element.setAttribute(STUDIO_ORIGINAL_DISPLAY_ATTR, element.style.getPropertyValue("display"));
|
|
}
|
|
|
|
element.setAttribute(STUDIO_BOX_SIZE_ATTR, "true");
|
|
element.style.setProperty(STUDIO_WIDTH_PROP, `${Math.round(Math.max(1, size.width))}px`);
|
|
element.style.setProperty(STUDIO_HEIGHT_PROP, `${Math.round(Math.max(1, size.height))}px`);
|
|
}
|
|
|
|
function applyStudioBoxSizeDimensions(
|
|
element: HTMLElement,
|
|
size: { width: number; height: number },
|
|
): void {
|
|
writeStudioBoxSizeVars(element, size);
|
|
restoreStaleStudioScaleResize(element);
|
|
|
|
const width = Math.round(Math.max(1, size.width));
|
|
const height = Math.round(Math.max(1, size.height));
|
|
element.style.setProperty("box-sizing", "border-box");
|
|
element.style.setProperty("width", `${width}px`);
|
|
element.style.setProperty("height", `${height}px`);
|
|
element.style.setProperty("min-width", "0px");
|
|
element.style.setProperty("min-height", "0px");
|
|
element.style.setProperty("max-width", "none");
|
|
element.style.setProperty("max-height", "none");
|
|
const flexBasis = readParentFlexBasisPixels(element, size);
|
|
if (flexBasis != null) {
|
|
element.style.setProperty("flex-basis", `${flexBasis}px`);
|
|
element.style.setProperty("flex-grow", "0");
|
|
element.style.setProperty("flex-shrink", "0");
|
|
}
|
|
const computedDisplay = safeComputedStyleProperty(element, "display");
|
|
if (computedDisplay === "inline") {
|
|
element.style.setProperty("display", "inline-block");
|
|
}
|
|
}
|
|
|
|
export function applyStudioBoxSize(
|
|
element: HTMLElement,
|
|
size: { width: number; height: number },
|
|
): void {
|
|
promoteInlineForTransform(element);
|
|
applyStudioBoxSizeDimensions(element, size);
|
|
}
|
|
|
|
export function applyStudioBoxSizeDraft(
|
|
element: HTMLElement,
|
|
size: { width: number; height: number },
|
|
): void {
|
|
promoteInlineForTransform(element);
|
|
applyStudioBoxSizeDimensions(element, size);
|
|
}
|
|
|
|
/* ── Rotation apply ───────────────────────────────────────────────── */
|
|
function isSimpleRotateAngle(value: string): boolean {
|
|
return /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim());
|
|
}
|
|
|
|
function composeStudioRotationValue(element: HTMLElement, rotationValue: string): string {
|
|
const original = element.getAttribute(STUDIO_ORIGINAL_ROTATE_ATTR)?.trim();
|
|
if (!original || original === "none" || !isSimpleRotateAngle(original)) {
|
|
return rotationValue;
|
|
}
|
|
return `calc(${original} + ${rotationValue})`;
|
|
}
|
|
|
|
function prepareStudioRotationBase(element: HTMLElement, updateBase: boolean): void {
|
|
const inlineRotate = element.style.getPropertyValue("rotate");
|
|
const currentRotate = readTransformLonghandBase(element, "rotate");
|
|
const hasMarker = element.hasAttribute(STUDIO_ROTATION_ATTR);
|
|
const wasResetByAnimation =
|
|
!styleUsesStudioRotation(currentRotate) &&
|
|
!styleMatchesStudioRotationDraft(element, currentRotate);
|
|
if (!hasMarker) {
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
|
|
styleUsesStudioRotation(inlineRotate) ? "" : inlineRotate,
|
|
);
|
|
element.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, wasResetByAnimation ? currentRotate : "");
|
|
} else if (updateBase && wasResetByAnimation && !isStudioManualEditGestureActive(element)) {
|
|
element.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, currentRotate);
|
|
}
|
|
if (!element.hasAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR)) {
|
|
element.setAttribute(
|
|
STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
|
|
element.style.getPropertyValue("transform-origin"),
|
|
);
|
|
}
|
|
}
|
|
|
|
function writeStudioRotationVars(
|
|
element: HTMLElement,
|
|
rotation: { angle: number },
|
|
options: { updateBase?: boolean } = {},
|
|
): void {
|
|
prepareStudioRotationBase(element, options.updateBase ?? true);
|
|
element.setAttribute(STUDIO_ROTATION_ATTR, "true");
|
|
element.style.setProperty(STUDIO_ROTATION_PROP, `${roundRotationAngle(rotation.angle)}deg`);
|
|
element.style.setProperty("transform-origin", STUDIO_ROTATION_TRANSFORM_ORIGIN);
|
|
}
|
|
|
|
export function applyStudioRotation(element: HTMLElement, rotation: { angle: number }): void {
|
|
promoteInlineForTransform(element);
|
|
writeStudioRotationVars(element, rotation);
|
|
element.removeAttribute(STUDIO_ROTATION_DRAFT_ATTR);
|
|
element.style.setProperty(
|
|
"rotate",
|
|
composeStudioRotationValue(element, `var(${STUDIO_ROTATION_PROP}, 0deg)`),
|
|
);
|
|
}
|
|
|
|
export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle: number }): void {
|
|
promoteInlineForTransform(element);
|
|
writeStudioRotationVars(element, rotation, { updateBase: false });
|
|
element.setAttribute(STUDIO_ROTATION_DRAFT_ATTR, "true");
|
|
element.style.setProperty(
|
|
"rotate",
|
|
composeStudioRotationValue(element, `${roundRotationAngle(rotation.angle)}deg`),
|
|
);
|
|
}
|
|
|
|
/* ── Seek reapply (position + motion) ────────────────────────────── */
|
|
|
|
function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
|
|
const ctor = doc.defaultView?.HTMLElement;
|
|
if (!ctor) return [];
|
|
const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
|
|
(el): el is HTMLElement => el instanceof ctor,
|
|
);
|
|
// Handle legacy HTML files where attributes were persisted with a double data- prefix
|
|
const legacyAttr = `data-${attr}`;
|
|
for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) {
|
|
if (el instanceof ctor && !el.hasAttribute(attr)) {
|
|
el.setAttribute(attr, "true");
|
|
el.removeAttribute(legacyAttr);
|
|
elements.push(el);
|
|
}
|
|
}
|
|
return elements;
|
|
}
|
|
|
|
function reapplyPathOffsets(doc: Document): void {
|
|
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
|
|
const gsapSkip = gsapAnimatesProperty(el, "x", "y");
|
|
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
|
|
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
|
|
if (gsapSkip) continue;
|
|
if (x || y) {
|
|
applyStudioPathOffset(
|
|
el,
|
|
{
|
|
x: Number.parseFloat(x) || 0,
|
|
y: Number.parseFloat(y) || 0,
|
|
},
|
|
{ updateBase: false },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function reapplyBoxSizes(doc: Document): void {
|
|
for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
|
|
if (gsapAnimatesProperty(el, "width", "height")) continue;
|
|
const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
|
|
const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
|
|
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
|
applyStudioBoxSize(el, { width: w, height: h });
|
|
}
|
|
}
|
|
}
|
|
|
|
function reapplyRotations(doc: Document): void {
|
|
for (const el of queryStudioElements(doc, STUDIO_ROTATION_ATTR)) {
|
|
const angle = Number.parseFloat(el.style.getPropertyValue(STUDIO_ROTATION_PROP));
|
|
if (Number.isFinite(angle)) {
|
|
applyStudioRotation(el, { angle });
|
|
}
|
|
}
|
|
}
|
|
|
|
export function reapplyPositionEditsAfterSeek(doc: Document): void {
|
|
reapplyPathOffsets(doc);
|
|
reapplyBoxSizes(doc);
|
|
reapplyRotations(doc);
|
|
applyStudioMotionFromDom(doc);
|
|
}
|