mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
* fix(studio): guard Zustand no-op setters and fix useConsoleErrorCapture memory leak - Guard setIsPlaying to skip set() when value unchanged (eliminates 60 notifications/sec during reverse playback) - Guard caption store selectGroup to bail before set() when group missing (prevents empty Zustand notifications) - Guard clearSelection to skip when already empty - Fix useConsoleErrorCapture: restore original console.error, remove error event listener, and delete __hfErrorCapture flag on cleanup * fix(studio): delete dead files and unused exports Remove 7 dead files (audioBeatDetection, keyframeSnapping, timelineInspector, DopesheetStrip, StaggerControls, TimelineLayerPanel, TimelineEditorNotice) and their test companions. Delete unused computeFitToChildrenSize export from propertyPanelHelpers. Fix re-export indirection: useDomEditCommits and studioMotionOps.test now import patch builders directly from manualEditsDomPatches instead of the re-export passthrough in manualEditsDom. * fix(studio): eliminate effect-chain state mirroring for lint findings, hover, and GSAP fetch Move lint findingsByElement sync from App.tsx into useLintModal where the value is produced, removing the mirroring useEffect. Consolidate 4 hover-clearing effects in useDomSelection into 2 (one unconditional on context change, one conditional combining caption mode, selection match, and disconnected element checks). Fold the GSAP retry effect into the fetch effect in useGsapTweenCache, scheduling a single retry via setTimeout when the initial fetch returns 0 animations. Eliminates 3 unnecessary render cycles from effect chains. * fix(studio): memoize renderQueue, toolbar, and canvas rect to prevent re-render cascade - Wrap renderQueue object in useMemo so StudioContext consumers don't re-render on every App render - Memoize timelineToolbar JSX so NLELayout memo isn't defeated - Move canvasRect getBoundingClientRect() from render-time IIFE to a useLayoutEffect-backed ref, eliminating layout thrashing - Track and clear setTimeout handles in refreshPreviewDocumentVersion to prevent stale timer accumulation on rapid calls and unmount * refactor(studio): consolidate GSAP shared primitives — defaults, iframe access, keyframe parsing Extract duplicated PROPERTY_DEFAULTS, IframeGsap interface, iframe accessors (getIframeGsap, queryIframeElement), percentage keyframe parsing, and toAbsoluteTime into a single gsapShared.ts module. Removes ~120 lines of copy-pasted logic across 8 hook files, reducing drift risk between the duplicate implementations. * fix(studio): remove dead store fields, dead file, duplicate helper, and unsafe assertions * refactor(studio): deduplicate selector helpers, rounding utils, percentage computation, and iframe access * fix(studio): split StudioContext into Shell + Playback to prevent cascade re-renders * refactor(studio): decompose useGsapScriptCommits into focused mutation hooks * refactor(studio): decompose useFileManager into focused file operation hooks Extract useFileTree (tree loading, refresh, derived assets/compositions) and useEditorSave (debounced save with history tracking) from the 508-LOC useFileManager. The parent hook composes both and retains file I/O, click-to-source, upload/import, and CRUD — preserving the same public interface so no consumers change. * refactor(studio): decompose useDomEditCommits into focused commit hooks Extract geometry (path offset, box size, rotation) and element lifecycle (delete, z-index reorder) into useDomGeometryCommits and useElementLifecycleOps. Parent keeps persistDomEditOperations as core and composes all sub-hooks — public interface unchanged. * refactor(studio): simplify useAppHotkeys with declarative command table * refactor(studio): simplify useAppHotkeys with declarative command table Replace 15 individual useRef callback refs with a single cbRef object. Extract keydown dispatch into pure dispatchModifierKey/dispatchPlainKey functions. Merge duplicate undo/redo logic into shared applyHistory. Extract cross-origin listener boilerplate into safeAddListener/safeRemoveListener. Hook body: 204 LOC (down from 445). Public API unchanged. * fix(studio): remove unused getDomEditTargetKey import * refactor(studio): decompose useDomEditSession into focused editing hooks Extract GSAP-aware geometry intercepts (move/resize/rotation) and animated property commit into useGsapAwareEditing, and selection wiring, GSAP cache management, preview sync, and selection handlers into useDomEditWiring. The parent remains a pure composition shell. * style(studio): fix formatting in 5 files * fix(studio): trim App.tsx to 598 lines (under 600 limit) --------- Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
/**
|
|
* Low-level drag commit helpers for GSAP position mutations.
|
|
* Extracted from gsapRuntimeBridge.ts to keep file sizes under the 600-line limit.
|
|
*/
|
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
import { roundTo3 } from "../utils/rounding";
|
|
import { computeElementPercentage } from "./gsapShared";
|
|
export interface GsapDragCommitCallbacks {
|
|
commitMutation: (
|
|
selection: DomEditSelection,
|
|
mutation: Record<string, unknown>,
|
|
options: {
|
|
label: string;
|
|
coalesceKey?: string;
|
|
softReload?: boolean;
|
|
skipReload?: boolean;
|
|
beforeReload?: () => void;
|
|
},
|
|
) => Promise<void>;
|
|
fetchAnimations?: () => Promise<GsapAnimation[]>;
|
|
}
|
|
|
|
// Re-export for backward compatibility with existing imports.
|
|
export function computeCurrentPercentage(
|
|
selection: DomEditSelection,
|
|
animation?: GsapAnimation,
|
|
): number {
|
|
return computeElementPercentage(usePlayerStore.getState().currentTime, selection, animation);
|
|
}
|
|
|
|
// ── Dynamic keyframe materialization ──────────────────────────────────────
|
|
|
|
export async function materializeIfDynamic(
|
|
anim: GsapAnimation,
|
|
iframe: HTMLIFrameElement | null,
|
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
selection: DomEditSelection,
|
|
): Promise<string | void> {
|
|
if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
|
|
|
|
if (anim.hasUnresolvedSelector) {
|
|
const allScanned = scanAllRuntimeKeyframes(iframe);
|
|
if (allScanned.size === 0) return;
|
|
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
|
|
selector: `#${id}`,
|
|
keyframes: data.keyframes,
|
|
easeEach: data.easeEach,
|
|
}));
|
|
await commitMutation(
|
|
selection,
|
|
{
|
|
type: "materialize-keyframes",
|
|
animationId: anim.id,
|
|
keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
|
|
allElements,
|
|
},
|
|
{ label: "Unroll dynamic animations", skipReload: true },
|
|
);
|
|
return `${anim.targetSelector}-to-0`;
|
|
}
|
|
|
|
const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
|
|
if (!runtime || runtime.keyframes.length === 0) return;
|
|
await commitMutation(
|
|
selection,
|
|
{
|
|
type: "materialize-keyframes",
|
|
animationId: anim.id,
|
|
keyframes: runtime.keyframes,
|
|
easeEach: runtime.easeEach,
|
|
},
|
|
{ label: "Materialize dynamic keyframes", skipReload: true },
|
|
);
|
|
}
|
|
|
|
// ── Extend tween ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extend a tween's time range to cover `targetTime`, remap all existing
|
|
* keyframe percentages to preserve their absolute positions, then add
|
|
* a new keyframe at the target time.
|
|
*/
|
|
async function extendTweenAndAddKeyframe(
|
|
selection: DomEditSelection,
|
|
anim: GsapAnimation,
|
|
properties: Record<string, number>,
|
|
targetTime: number,
|
|
tweenStart: number,
|
|
tweenDuration: number,
|
|
callbacks: GsapDragCommitCallbacks,
|
|
beforeReload?: () => void,
|
|
): Promise<void> {
|
|
const tweenEnd = tweenStart + tweenDuration;
|
|
const newStart = Math.min(targetTime, tweenStart);
|
|
const newEnd = Math.max(targetTime, tweenEnd);
|
|
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
const existingKfs = anim.keyframes?.keyframes ?? [];
|
|
const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
|
[];
|
|
for (const kf of existingKfs) {
|
|
const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
|
|
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
|
|
remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } });
|
|
}
|
|
|
|
const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
|
|
remappedKfs.push({ percentage: targetPct, properties });
|
|
|
|
remappedKfs.sort((a, b) => a.percentage - b.percentage);
|
|
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "replace-with-keyframes",
|
|
animationId: anim.id,
|
|
targetSelector: anim.targetSelector,
|
|
position: roundTo3(newStart),
|
|
duration: roundTo3(newDuration),
|
|
keyframes: remappedKfs,
|
|
},
|
|
{ label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
|
|
);
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
async function commitKeyframedPosition(
|
|
selection: DomEditSelection,
|
|
anim: GsapAnimation,
|
|
properties: Record<string, number>,
|
|
callbacks: GsapDragCommitCallbacks,
|
|
beforeReload?: () => void,
|
|
): Promise<void> {
|
|
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "add-keyframe",
|
|
animationId: anim.id,
|
|
percentage: pct,
|
|
properties,
|
|
},
|
|
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
|
|
);
|
|
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
|
}
|
|
|
|
/**
|
|
* For flat to()/set() tweens, convert to keyframes first so we can place the
|
|
* drag position at the current percentage.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
async function commitFlatViaKeyframes(
|
|
selection: DomEditSelection,
|
|
anim: GsapAnimation,
|
|
properties: Record<string, number>,
|
|
callbacks: GsapDragCommitCallbacks,
|
|
beforeReload?: () => void,
|
|
): Promise<void> {
|
|
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{ type: "convert-to-keyframes", animationId: anim.id },
|
|
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
|
|
);
|
|
|
|
const pct = computeCurrentPercentage(selection, anim);
|
|
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "add-keyframe",
|
|
animationId: anim.id,
|
|
percentage: pct,
|
|
properties,
|
|
},
|
|
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey },
|
|
);
|
|
}
|
|
|
|
// ── Main drag commit ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Compute the new GSAP position values from runtime-read positions + drag
|
|
* offset, then commit the mutation to the GSAP script.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
export async function commitGsapPositionFromDrag(
|
|
selection: DomEditSelection,
|
|
anim: GsapAnimation,
|
|
studioOffset: { x: number; y: number },
|
|
gsapPos: { x: number; y: number },
|
|
iframe: HTMLIFrameElement | null,
|
|
selector: string,
|
|
callbacks: GsapDragCommitCallbacks,
|
|
): Promise<void> {
|
|
const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation");
|
|
const rotDeg = Number.parseFloat(rotStyle) || 0;
|
|
const rad = (-rotDeg * Math.PI) / 180;
|
|
const cos = Math.cos(rad);
|
|
const sin = Math.sin(rad);
|
|
const el = selection.element;
|
|
const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
|
|
const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
|
|
const deltaX = studioOffset.x - origX;
|
|
const deltaY = studioOffset.y - origY;
|
|
const adjX = deltaX * cos - deltaY * sin;
|
|
const adjY = deltaX * sin + deltaY * cos;
|
|
const parsedBaseX = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "");
|
|
const parsedBaseY = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "");
|
|
const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : gsapPos.x;
|
|
const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : gsapPos.y;
|
|
const newX = Math.round(baseGsapX + adjX);
|
|
const newY = Math.round(baseGsapY + adjY);
|
|
const restoreOffset = () => {
|
|
el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
|
|
el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
|
|
el.removeAttribute("data-hf-drag-initial-offset-x");
|
|
el.removeAttribute("data-hf-drag-initial-offset-y");
|
|
};
|
|
|
|
const ct = usePlayerStore.getState().currentTime;
|
|
if (anim.keyframes) {
|
|
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
|
|
const dragProps: Record<string, number> = { x: newX, y: newY };
|
|
|
|
const ts = resolveTweenStart(effectiveAnim);
|
|
const td = resolveTweenDuration(effectiveAnim);
|
|
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
if (outsideRange) {
|
|
await extendTweenAndAddKeyframe(
|
|
selection,
|
|
effectiveAnim,
|
|
dragProps,
|
|
ct,
|
|
ts,
|
|
td,
|
|
callbacks,
|
|
restoreOffset,
|
|
);
|
|
} else {
|
|
await commitKeyframedPosition(selection, effectiveAnim, dragProps, callbacks, restoreOffset);
|
|
}
|
|
} else if (anim.method === "from" || anim.method === "fromTo") {
|
|
const ct = usePlayerStore.getState().currentTime;
|
|
const ts = resolveTweenStart(anim);
|
|
const td = resolveTweenDuration(anim);
|
|
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
const dragProps: Record<string, number> = { x: newX, y: newY };
|
|
|
|
if (outsideRange && ts !== null) {
|
|
// Split the original from() tween into property groups first.
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{ type: "split-into-property-groups", animationId: anim.id },
|
|
{ label: "Split from() for drag", skipReload: true },
|
|
);
|
|
|
|
const allAnims = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
|
|
const existingPosAnim = allAnims.find(
|
|
(a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector,
|
|
);
|
|
|
|
if (existingPosAnim?.keyframes) {
|
|
// Extend the existing position tween
|
|
const posTs = resolveTweenStart(existingPosAnim);
|
|
const posTd = resolveTweenDuration(existingPosAnim);
|
|
if (posTs !== null) {
|
|
await extendTweenAndAddKeyframe(
|
|
selection,
|
|
existingPosAnim,
|
|
{ x: newX, y: newY },
|
|
ct,
|
|
posTs,
|
|
posTd,
|
|
callbacks,
|
|
restoreOffset,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// No existing position tween — create one
|
|
const newStart = Math.min(ct, ts);
|
|
const newEnd = Math.max(ct, ts + td);
|
|
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
const dragBefore = ct < ts;
|
|
const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10;
|
|
const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10;
|
|
|
|
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
|
[];
|
|
if (dragBefore) {
|
|
keyframes.push({ percentage: 0, properties: { x: newX, y: newY } });
|
|
if (origStartPct > 0.5 && origStartPct < 99.5) {
|
|
keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } });
|
|
}
|
|
keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } });
|
|
} else {
|
|
keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } });
|
|
if (origEndPct > 0.5 && origEndPct < 99.5) {
|
|
keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } });
|
|
}
|
|
keyframes.push({ percentage: 100, properties: { x: newX, y: newY } });
|
|
}
|
|
keyframes.sort((a, b) => a.percentage - b.percentage);
|
|
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "add-with-keyframes",
|
|
targetSelector: anim.targetSelector,
|
|
position: roundTo3(newStart),
|
|
duration: roundTo3(newDuration),
|
|
keyframes,
|
|
},
|
|
{ label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset },
|
|
);
|
|
} else {
|
|
// Inside tween range: convert then add keyframe at current time
|
|
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "convert-to-keyframes",
|
|
animationId: anim.id,
|
|
},
|
|
{ label: "Convert from() for drag", skipReload: true, coalesceKey },
|
|
);
|
|
const pct = computeCurrentPercentage(selection, anim);
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "add-keyframe",
|
|
animationId: anim.id,
|
|
percentage: pct,
|
|
properties: dragProps,
|
|
},
|
|
{
|
|
label: `Move layer (keyframe ${pct}%)`,
|
|
softReload: true,
|
|
beforeReload: restoreOffset,
|
|
coalesceKey,
|
|
},
|
|
);
|
|
}
|
|
} else {
|
|
await commitFlatViaKeyframes(selection, anim, { x: newX, y: newY }, callbacks, restoreOffset);
|
|
}
|
|
}
|