mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio): keyframe system — parser, runtime, timeline UI, design panel, gesture recording (#1311)
* feat(studio): runtime hooks — global time compiler + keyframe runtime Add the runtime bridge layer: global time compilation (tween % → clip %), soft reload after mutations, runtime keyframe preview, and keyframe commit helper. * feat(studio): runtime hooks — global time compiler + keyframe runtime Add the runtime bridge layer: global time compilation (tween % → clip %), soft reload after mutations, runtime keyframe preview, and keyframe commit helper. * feat(studio): keyframe cache + commit hooks Add hooks for keyframe cache population (tween → clip-relative %), mutation dispatch, keyframe snapping, and audio beat detection. * feat(studio): timeline UI — dopesheet diamonds + keyboard nav Add dopesheet strip with diamond keyframe indicators, timeline property rows, keyboard navigation (J/Shift+J/Delete/K), and feature gate (STUDIO_KEYFRAMES_ENABLED defaults to false). * feat(studio): design panel — arc controls + ease curve + stagger Add arc path controls (curviness slider, auto-rotate), motion path SVG overlay, ease curve visualization, stagger controls, and expanded animation card. Includes border-radius editor dependency from #1217. * feat(studio): gesture recording core Add gesture recording engine with RAF sampling, modifier key property mapping (Shift→rotationXY, Alt→rotation, Cmd→opacity), Ramer-Douglas-Peucker simplification, and ghost trail SVG overlay. * fix(studio): keyframe drag + recording bug bash 21 fixes: capture GSAP base at drag start, translate:none before gsap.set, skip reapplyPathOffsets for GSAP elements, clamp recording seek, _auto flag for 100% keyframes, overlay flash fix, block edits during recording. * feat(studio): keyframe integration wiring + docs Wire App.tsx recording orchestration, TimelineToolbar K/R buttons, PropertyPanel per-property diamonds, shortcuts panel, toast notifications, and keyframes guide documentation. All gated on STUDIO_KEYFRAMES_ENABLED (default false).
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/globalTimeCompiler";
|
||||
|
||||
const PROPERTY_DEFAULTS: Record<string, number> = {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
};
|
||||
|
||||
type CommitFn = (
|
||||
selection: DomEditSelection,
|
||||
mutation: Record<string, unknown>,
|
||||
options: {
|
||||
label: string;
|
||||
coalesceKey?: string;
|
||||
softReload?: boolean;
|
||||
skipReload?: boolean;
|
||||
},
|
||||
) => Promise<void>;
|
||||
|
||||
export async function commitKeyframeAtTimeImpl(
|
||||
selection: DomEditSelection,
|
||||
absoluteTime: number,
|
||||
animations: GsapAnimation[],
|
||||
properties: Record<string, number | string>,
|
||||
commitMutation: CommitFn,
|
||||
): Promise<void> {
|
||||
const selector = selection.id ? `#${selection.id}` : selection.selector;
|
||||
if (!selector) return;
|
||||
|
||||
const tween = findTweenAtTime(absoluteTime, animations, selector);
|
||||
if (tween) {
|
||||
const pct = absoluteToPercentageForAnimation(absoluteTime, tween);
|
||||
if (pct === null) return;
|
||||
|
||||
const hasExplicitKeyframes = !!tween.keyframes && tween.keyframes.keyframes.length > 0;
|
||||
if (!hasExplicitKeyframes) {
|
||||
await commitMutation(
|
||||
selection,
|
||||
{ type: "convert-to-keyframes", animationId: tween.id },
|
||||
{ label: "Convert to keyframes", skipReload: true },
|
||||
);
|
||||
}
|
||||
|
||||
const backfillDefaults: Record<string, number | string> = {};
|
||||
for (const key of Object.keys(properties)) {
|
||||
backfillDefaults[key] = PROPERTY_DEFAULTS[key] ?? 0;
|
||||
}
|
||||
|
||||
await commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-keyframe",
|
||||
animationId: tween.id,
|
||||
percentage: pct,
|
||||
properties,
|
||||
backfillDefaults,
|
||||
},
|
||||
{
|
||||
label: `Add keyframe at ${Math.round(absoluteTime * 100) / 100}s`,
|
||||
coalesceKey: `keyframe:${tween.id}:${pct}`,
|
||||
softReload: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const defaultDuration = 0.5;
|
||||
await commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-with-keyframes" as const,
|
||||
targetSelector: selector,
|
||||
position: absoluteTime,
|
||||
duration: defaultDuration,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties },
|
||||
{ percentage: 100, properties },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: `New animation at ${Math.round(absoluteTime * 100) / 100}s`,
|
||||
softReload: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,14 @@
|
||||
*/
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { clearStudioPathOffset } from "../components/editor/manualEdits";
|
||||
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
||||
import {
|
||||
absoluteToPercentage,
|
||||
resolveTweenStart,
|
||||
resolveTweenDuration,
|
||||
} from "../utils/globalTimeCompiler";
|
||||
|
||||
// ── Runtime reads ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -91,10 +96,17 @@ function selectorForSelection(selection: DomEditSelection): string | null {
|
||||
|
||||
// ── Percentage computation ─────────────────────────────────────────────────
|
||||
|
||||
function computeCurrentPercentage(selection: DomEditSelection): number {
|
||||
function computeCurrentPercentage(selection: DomEditSelection, animation?: GsapAnimation): number {
|
||||
const currentTime = usePlayerStore.getState().currentTime;
|
||||
if (animation) {
|
||||
const start = resolveTweenStart(animation);
|
||||
const duration = resolveTweenDuration(animation);
|
||||
if (start !== null) {
|
||||
return absoluteToPercentage(currentTime, start, duration);
|
||||
}
|
||||
}
|
||||
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
|
||||
const currentTime = usePlayerStore.getState().currentTime;
|
||||
return elDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
||||
: 0;
|
||||
@@ -190,6 +202,10 @@ export async function tryGsapDragIntercept(
|
||||
const selector = selectorForSelection(selection);
|
||||
if (!selector) return false;
|
||||
|
||||
// Keyframe writes at 0%/100% when outside the tween range. Acceptable
|
||||
// trade-off — CSS path must NEVER touch GSAP-targeted elements because
|
||||
// changing the CSS offset corrupts all existing keyframes (baked mismatch).
|
||||
|
||||
const gsapPos = readGsapPositionFromIframe(iframe, selector);
|
||||
if (!gsapPos) return false;
|
||||
|
||||
@@ -232,50 +248,155 @@ async function commitGsapPositionFromDrag(
|
||||
const rad = (-rotDeg * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const adjX = studioOffset.x * cos - studioOffset.y * sin;
|
||||
const adjY = studioOffset.x * sin + studioOffset.y * cos;
|
||||
const newX = Math.round(gsapPos.x + adjX);
|
||||
const newY = Math.round(gsapPos.y + adjY);
|
||||
const clearOffset = () => clearStudioPathOffset(selection.element);
|
||||
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;
|
||||
// Use the GSAP base captured at drag start — the live gsapPos is corrupted
|
||||
// by the draft's gsap.set() calls during drag.
|
||||
const baseGsapX =
|
||||
Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "") || gsapPos.x;
|
||||
const baseGsapY =
|
||||
Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "") || gsapPos.y;
|
||||
const newX = Math.round(baseGsapX + adjX);
|
||||
const newY = Math.round(baseGsapY + adjY);
|
||||
// Restore the CSS offset to pre-drag value so the baked translate stays
|
||||
// consistent with existing keyframes. The drag is captured in the new keyframe.
|
||||
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");
|
||||
};
|
||||
|
||||
if (anim.keyframes) {
|
||||
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
||||
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
|
||||
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
||||
await commitKeyframedPosition(
|
||||
|
||||
// Check if current time is outside the tween's range — extend the tween
|
||||
// to cover the playhead, remap existing keyframes, then add the new one.
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
const ts = resolveTweenStart(effectiveAnim);
|
||||
const td = resolveTweenDuration(effectiveAnim);
|
||||
if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) {
|
||||
await extendTweenAndAddKeyframe(
|
||||
selection,
|
||||
effectiveAnim,
|
||||
{ ...runtimeProps, x: newX, y: newY },
|
||||
ct,
|
||||
ts,
|
||||
td,
|
||||
callbacks,
|
||||
restoreOffset,
|
||||
);
|
||||
} else {
|
||||
await commitKeyframedPosition(
|
||||
selection,
|
||||
effectiveAnim,
|
||||
{ ...runtimeProps, x: newX, y: newY },
|
||||
callbacks,
|
||||
restoreOffset,
|
||||
);
|
||||
}
|
||||
} else if (anim.method === "from" || anim.method === "fromTo") {
|
||||
// from()/fromTo() — convert to keyframes in a single mutation, placing
|
||||
// the dragged position at the 100% (rest) keyframe. A single mutation
|
||||
// avoids the stable-id flip (from→to) that breaks chained mutations.
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
effectiveAnim,
|
||||
{ ...runtimeProps, x: newX, y: newY },
|
||||
callbacks,
|
||||
clearOffset,
|
||||
{
|
||||
type: "convert-to-keyframes",
|
||||
animationId: anim.id,
|
||||
resolvedFromValues: { x: newX, y: newY },
|
||||
},
|
||||
{ label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset },
|
||||
);
|
||||
} else if (anim.method === "from") {
|
||||
await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset);
|
||||
} else if (anim.method === "fromTo") {
|
||||
await commitFromToPosition(selection, anim, studioOffset, callbacks, clearOffset);
|
||||
} else {
|
||||
// Flat to()/set() — convert to keyframes first so the drag position
|
||||
// is captured at the current seek time, not just the tween endpoint.
|
||||
// Flat to()/set() — convert to keyframes then add at current percentage.
|
||||
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
||||
await commitFlatViaKeyframes(
|
||||
selection,
|
||||
anim,
|
||||
{ ...runtimeProps, x: newX, y: newY },
|
||||
callbacks,
|
||||
clearOffset,
|
||||
restoreOffset,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// Step 1: Remap all existing keyframes to preserve their absolute times
|
||||
// in the new range, then add the new keyframe.
|
||||
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 } });
|
||||
}
|
||||
|
||||
// Add the new keyframe at the target time
|
||||
const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
|
||||
remappedKfs.push({ percentage: targetPct, properties });
|
||||
|
||||
// Sort and dedupe
|
||||
remappedKfs.sort((a, b) => a.percentage - b.percentage);
|
||||
|
||||
// Step 2: Delete the old tween and create a new one with the extended range
|
||||
// and all remapped keyframes. Using delete + add-with-keyframes as an atomic pair.
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{ type: "delete", animationId: anim.id },
|
||||
{ label: "Extend tween range", skipReload: true },
|
||||
);
|
||||
|
||||
const selector = anim.targetSelector;
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-with-keyframes",
|
||||
targetSelector: selector,
|
||||
position: Math.round(newStart * 1000) / 1000,
|
||||
duration: Math.round(newDuration * 1000) / 1000,
|
||||
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,
|
||||
beforeReload?: () => void,
|
||||
): Promise<void> {
|
||||
const pct = computeCurrentPercentage(selection);
|
||||
const pct = computeCurrentPercentage(selection, anim);
|
||||
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
@@ -300,7 +421,7 @@ async function commitFlatViaKeyframes(
|
||||
anim: GsapAnimation,
|
||||
properties: Record<string, number>,
|
||||
callbacks: GsapDragCommitCallbacks,
|
||||
beforeReload: () => void,
|
||||
beforeReload?: () => void,
|
||||
): Promise<void> {
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
@@ -308,7 +429,7 @@ async function commitFlatViaKeyframes(
|
||||
{ label: "Convert to keyframes for drag", skipReload: true },
|
||||
);
|
||||
|
||||
const pct = computeCurrentPercentage(selection);
|
||||
const pct = computeCurrentPercentage(selection, anim);
|
||||
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
@@ -322,65 +443,6 @@ async function commitFlatViaKeyframes(
|
||||
);
|
||||
}
|
||||
|
||||
async function commitFromPosition(
|
||||
selection: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
delta: { x: number; y: number },
|
||||
callbacks: GsapDragCommitCallbacks,
|
||||
beforeReload: () => void,
|
||||
): Promise<void> {
|
||||
const fromX = Math.round(Number(anim.properties.x ?? 0) + delta.x);
|
||||
const fromY = Math.round(Number(anim.properties.y ?? 0) + delta.y);
|
||||
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{ type: "update-property", animationId: anim.id, property: "x", value: fromX },
|
||||
{ label: "Move layer (GSAP from x)", skipReload: true },
|
||||
);
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{ type: "update-property", animationId: anim.id, property: "y", value: fromY },
|
||||
{ label: "Move layer (GSAP from y)", softReload: true, beforeReload },
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function commitFromToPosition(
|
||||
selection: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
delta: { x: number; y: number },
|
||||
callbacks: GsapDragCommitCallbacks,
|
||||
beforeReload: () => void,
|
||||
): Promise<void> {
|
||||
if (anim.fromProperties) {
|
||||
const fromX = Math.round(Number(anim.fromProperties.x ?? 0) + delta.x);
|
||||
const fromY = Math.round(Number(anim.fromProperties.y ?? 0) + delta.y);
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{ type: "update-from-property", animationId: anim.id, property: "x", value: fromX },
|
||||
{ label: "Move (GSAP from x)", skipReload: true },
|
||||
);
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{ type: "update-from-property", animationId: anim.id, property: "y", value: fromY },
|
||||
{ label: "Move (GSAP from y)", skipReload: true },
|
||||
);
|
||||
}
|
||||
|
||||
const toX = Math.round(Number(anim.properties.x ?? 0) + delta.x);
|
||||
const toY = Math.round(Number(anim.properties.y ?? 0) + delta.y);
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{ type: "update-property", animationId: anim.id, property: "x", value: toX },
|
||||
{ label: "Move (GSAP to x)", skipReload: true },
|
||||
);
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{ type: "update-property", animationId: anim.id, property: "y", value: toY },
|
||||
{ label: "Move (GSAP to y)", softReload: true, beforeReload },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Runtime property reader ───────────────────────────────────────────────
|
||||
|
||||
export function readGsapProperty(
|
||||
@@ -461,7 +523,7 @@ export async function tryGsapResizeIntercept(
|
||||
}
|
||||
if (!anim) return false;
|
||||
|
||||
const pct = computeCurrentPercentage(selection);
|
||||
const pct = computeCurrentPercentage(selection, anim);
|
||||
|
||||
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
||||
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
||||
@@ -545,7 +607,7 @@ export async function tryGsapRotationIntercept(
|
||||
}
|
||||
}
|
||||
|
||||
const pct = computeCurrentPercentage(selection);
|
||||
const pct = computeCurrentPercentage(selection, anim);
|
||||
const newRotation = Math.round(gsapRotation + angle);
|
||||
|
||||
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
||||
|
||||
@@ -126,45 +126,96 @@ export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map<
|
||||
|
||||
for (const timeline of Object.values(timelines)) {
|
||||
if (!timeline?.getChildren) continue;
|
||||
const tlDuration = typeof timeline.duration === "function" ? timeline.duration() : 0;
|
||||
|
||||
for (const tween of timeline.getChildren(true)) {
|
||||
if (!tween.targets || !tween.vars) continue;
|
||||
const vars = tween.vars;
|
||||
if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
|
||||
|
||||
const kfObj = vars.keyframes as Record<string, unknown>;
|
||||
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
||||
[];
|
||||
let easeEach: string | undefined;
|
||||
if (vars.keyframes && typeof vars.keyframes === "object") {
|
||||
const kfObj = vars.keyframes as Record<string, unknown>;
|
||||
const keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
}> = [];
|
||||
let easeEach: string | undefined;
|
||||
|
||||
for (const [key, val] of Object.entries(kfObj)) {
|
||||
if (key === "easeEach") {
|
||||
if (typeof val === "string") easeEach = val;
|
||||
for (const [key, val] of Object.entries(kfObj)) {
|
||||
if (key === "easeEach") {
|
||||
if (typeof val === "string") easeEach = val;
|
||||
continue;
|
||||
}
|
||||
const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
|
||||
if (!pctMatch || !val || typeof val !== "object") continue;
|
||||
const percentage = parseFloat(pctMatch[1]);
|
||||
const properties: Record<string, number | string> = {};
|
||||
for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
|
||||
if (pk === "ease") continue;
|
||||
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
||||
else if (typeof pv === "string") properties[pk] = pv;
|
||||
}
|
||||
if (Object.keys(properties).length > 0) {
|
||||
keyframes.push({ percentage, properties });
|
||||
}
|
||||
}
|
||||
|
||||
if (keyframes.length > 0) {
|
||||
keyframes.sort((a, b) => a.percentage - b.percentage);
|
||||
for (const target of tween.targets()) {
|
||||
const id = (target as HTMLElement).id;
|
||||
if (id && !result.has(id)) {
|
||||
result.set(id, { keyframes, easeEach });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
|
||||
if (!pctMatch || !val || typeof val !== "object") continue;
|
||||
const percentage = parseFloat(pctMatch[1]);
|
||||
const properties: Record<string, number | string> = {};
|
||||
for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
|
||||
if (pk === "ease") continue;
|
||||
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
||||
else if (typeof pv === "string") properties[pk] = pv;
|
||||
}
|
||||
if (Object.keys(properties).length > 0) {
|
||||
keyframes.push({ percentage, properties });
|
||||
}
|
||||
}
|
||||
|
||||
if (keyframes.length === 0) continue;
|
||||
keyframes.sort((a, b) => a.percentage - b.percentage);
|
||||
// Flat tweens: synthesize start + end keyframe entries
|
||||
if (!tlDuration || tlDuration <= 0) continue;
|
||||
const tweenStart = typeof tween.startTime === "function" ? tween.startTime() : undefined;
|
||||
if (typeof tweenStart !== "number" || !Number.isFinite(tweenStart)) continue;
|
||||
const tweenDur = typeof tween.duration === "function" ? tween.duration() : 0;
|
||||
|
||||
const startPct = Math.round((tweenStart / tlDuration) * 1000) / 10;
|
||||
const endPct =
|
||||
tweenDur > 0 ? Math.round(((tweenStart + tweenDur) / tlDuration) * 1000) / 10 : startPct;
|
||||
const properties: Record<string, number | string> = {};
|
||||
const skip = new Set([
|
||||
"ease",
|
||||
"duration",
|
||||
"delay",
|
||||
"stagger",
|
||||
"motionPath",
|
||||
"overwrite",
|
||||
"immediateRender",
|
||||
"onComplete",
|
||||
"onUpdate",
|
||||
"onStart",
|
||||
]);
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (skip.has(k)) continue;
|
||||
if (typeof v === "number") properties[k] = Math.round(v * 1000) / 1000;
|
||||
else if (typeof v === "string") properties[k] = v;
|
||||
}
|
||||
if (Object.keys(properties).length === 0) continue;
|
||||
|
||||
for (const target of tween.targets()) {
|
||||
const id = (target as HTMLElement).id;
|
||||
if (id && !result.has(id)) {
|
||||
result.set(id, { keyframes, easeEach });
|
||||
if (!id) continue;
|
||||
const existing = result.get(id);
|
||||
const entries = existing ?? { keyframes: [] };
|
||||
entries.keyframes.push({ percentage: startPct, properties });
|
||||
if (endPct !== startPct) {
|
||||
entries.keyframes.push({ percentage: endPct, properties });
|
||||
}
|
||||
if (!existing) result.set(id, entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of result.values()) {
|
||||
entry.keyframes.sort((a, b) => a.percentage - b.percentage);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export function previewKeyframeChange(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
selector: string,
|
||||
properties: Record<string, number | string>,
|
||||
): boolean {
|
||||
if (!iframe?.contentWindow) return false;
|
||||
try {
|
||||
const gsap = (
|
||||
iframe.contentWindow as unknown as {
|
||||
gsap?: { set: (target: string, vars: Record<string, number | string>) => void };
|
||||
}
|
||||
).gsap;
|
||||
if (!gsap?.set) return false;
|
||||
gsap.set(selector, properties);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ interface UseAppHotkeysParams {
|
||||
onResetKeyframes: () => boolean;
|
||||
onDeleteSelectedKeyframes: () => void;
|
||||
onAfterUndoRedo?: () => void;
|
||||
onToggleRecording?: () => void;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
@@ -106,6 +107,7 @@ export function useAppHotkeys({
|
||||
onResetKeyframes,
|
||||
onDeleteSelectedKeyframes,
|
||||
onAfterUndoRedo,
|
||||
onToggleRecording,
|
||||
}: UseAppHotkeysParams) {
|
||||
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
||||
const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
|
||||
@@ -215,6 +217,8 @@ export function useAppHotkeys({
|
||||
onResetKeyframesRef.current = onResetKeyframes;
|
||||
const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes);
|
||||
onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes;
|
||||
const onToggleRecordingRef = useRef(onToggleRecording);
|
||||
onToggleRecordingRef.current = onToggleRecording;
|
||||
|
||||
// ── Consolidated keydown handler ──
|
||||
|
||||
@@ -377,6 +381,20 @@ export function useAppHotkeys({
|
||||
void handleDomEditDeleteRef.current(domSelection);
|
||||
}
|
||||
}
|
||||
|
||||
// R — toggle gesture recording
|
||||
if (
|
||||
event.key === "r" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
!isEditableTarget(event.target) &&
|
||||
onToggleRecordingRef.current
|
||||
) {
|
||||
event.preventDefault();
|
||||
onToggleRecordingRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Window keydown listener ──
|
||||
|
||||
@@ -3,6 +3,7 @@ import { copyTextToClipboard } from "../utils/clipboard";
|
||||
import { readTagSnippetByTarget } from "../utils/sourcePatcher";
|
||||
import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/studioHelpers";
|
||||
import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing";
|
||||
import { usePlayerStore } from "../player";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -11,7 +12,6 @@ export interface UseAskAgentModalParams {
|
||||
activeCompPath: string | null;
|
||||
projectDir: string | null;
|
||||
projectIdRef: React.MutableRefObject<string | null>;
|
||||
currentTime: number;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
domEditSelection: DomEditSelection | null;
|
||||
@@ -23,7 +23,6 @@ export function useAskAgentModal({
|
||||
activeCompPath,
|
||||
projectDir,
|
||||
projectIdRef,
|
||||
currentTime,
|
||||
showToast,
|
||||
domEditSelectionRef,
|
||||
domEditSelection,
|
||||
@@ -91,7 +90,7 @@ export function useAskAgentModal({
|
||||
const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
|
||||
const prompt = buildElementAgentPrompt({
|
||||
selection: domEditSelection,
|
||||
currentTime,
|
||||
currentTime: usePlayerStore.getState().currentTime,
|
||||
tagSnippet,
|
||||
selectionContext: agentPromptSelectionContext,
|
||||
userInstruction,
|
||||
@@ -115,7 +114,6 @@ export function useAskAgentModal({
|
||||
activeCompPath,
|
||||
agentPromptSelectionContext,
|
||||
agentPromptTagSnippet,
|
||||
currentTime,
|
||||
domEditSelection,
|
||||
projectDir,
|
||||
showToast,
|
||||
|
||||
@@ -50,7 +50,6 @@ export interface UseDomEditSessionParams {
|
||||
compositionLoading: boolean;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
timelineElements: TimelineElement[];
|
||||
currentTime: number;
|
||||
setSelectedTimelineElementId: (id: string | null) => void;
|
||||
setRightCollapsed: (collapsed: boolean) => void;
|
||||
setRightPanelTab: (tab: RightPanelTab) => void;
|
||||
@@ -59,6 +58,7 @@ export interface UseDomEditSessionParams {
|
||||
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
updateEditingFileContent: (path: string, content: string) => void;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
||||
fileTree: string[];
|
||||
@@ -91,7 +91,6 @@ export function useDomEditSession({
|
||||
compositionLoading,
|
||||
previewIframeRef,
|
||||
timelineElements,
|
||||
currentTime,
|
||||
setSelectedTimelineElementId,
|
||||
setRightCollapsed,
|
||||
setRightPanelTab,
|
||||
@@ -100,6 +99,7 @@ export function useDomEditSession({
|
||||
queueDomEditSave,
|
||||
readProjectFile: _readProjectFile,
|
||||
writeProjectFile,
|
||||
updateEditingFileContent,
|
||||
domEditSaveTimestampRef,
|
||||
editHistory,
|
||||
fileTree,
|
||||
@@ -182,7 +182,6 @@ export function useDomEditSession({
|
||||
activeCompPath,
|
||||
projectDir,
|
||||
projectIdRef,
|
||||
currentTime,
|
||||
showToast,
|
||||
domEditSelectionRef,
|
||||
domEditSelection,
|
||||
@@ -224,12 +223,25 @@ export function useDomEditSession({
|
||||
|
||||
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
|
||||
|
||||
// Bump GSAP cache when refreshKey changes (code-tab edits trigger iframe
|
||||
// reload via refreshKey but don't go through commitMutation, so the cache
|
||||
// would otherwise retain stale keyframe entries).
|
||||
const prevRefreshKeyRef = useRef(refreshKey);
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (refreshKey !== prevRefreshKeyRef.current) {
|
||||
prevRefreshKeyRef.current = refreshKey;
|
||||
bumpGsapCache();
|
||||
}
|
||||
}, [refreshKey, bumpGsapCache]);
|
||||
|
||||
const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html";
|
||||
|
||||
usePopulateKeyframeCacheForFile(
|
||||
STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
|
||||
gsapSourceFile,
|
||||
gsapCacheVersion,
|
||||
previewIframeRef,
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -257,9 +269,12 @@ export function useDomEditSession({
|
||||
addGsapFromProperty,
|
||||
removeGsapFromProperty,
|
||||
addKeyframe,
|
||||
addKeyframeBatch,
|
||||
removeKeyframe,
|
||||
convertToKeyframes,
|
||||
removeAllKeyframes,
|
||||
setArcPath,
|
||||
updateArcSegment,
|
||||
} = useGsapScriptCommits({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
@@ -268,6 +283,7 @@ export function useDomEditSession({
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
onCacheInvalidate: bumpGsapCache,
|
||||
onFileContentChanged: updateEditingFileContent,
|
||||
});
|
||||
|
||||
// ── Commit handlers (delegated to useDomEditCommits) ──
|
||||
@@ -416,6 +432,7 @@ export function useDomEditSession({
|
||||
handleGsapAddFromProperty,
|
||||
handleGsapRemoveFromProperty,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapAddKeyframeBatch,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
@@ -432,10 +449,10 @@ export function useDomEditSession({
|
||||
addGsapFromProperty,
|
||||
removeGsapFromProperty,
|
||||
addKeyframe,
|
||||
addKeyframeBatch,
|
||||
removeKeyframe,
|
||||
convertToKeyframes,
|
||||
removeAllKeyframes,
|
||||
currentTime,
|
||||
handleDomManualEditsReset,
|
||||
selectedGsapAnimations,
|
||||
});
|
||||
@@ -449,6 +466,22 @@ export function useDomEditSession({
|
||||
bumpGsapCache,
|
||||
});
|
||||
|
||||
const handleSetArcPath = useCallback(
|
||||
(animId: string, config: Parameters<typeof setArcPath>[2]) => {
|
||||
if (!domEditSelection) return;
|
||||
setArcPath(domEditSelection, animId, config);
|
||||
},
|
||||
[domEditSelection, setArcPath],
|
||||
);
|
||||
|
||||
const handleUpdateArcSegment = useCallback(
|
||||
(animId: string, segmentIndex: number, update: Parameters<typeof updateArcSegment>[3]) => {
|
||||
if (!domEditSelection) return;
|
||||
updateArcSegment(domEditSelection, animId, segmentIndex, update);
|
||||
},
|
||||
[domEditSelection, updateArcSegment],
|
||||
);
|
||||
|
||||
// Sync selection from preview document on load / refresh
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
@@ -589,12 +622,22 @@ export function useDomEditSession({
|
||||
handleGsapAddFromProperty,
|
||||
handleGsapRemoveFromProperty,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapAddKeyframeBatch,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
commitAnimatedProperty,
|
||||
handleSetArcPath,
|
||||
handleUpdateArcSegment,
|
||||
invalidateGsapCache: bumpGsapCache,
|
||||
previewIframeRef,
|
||||
commitMutation: async (
|
||||
mutation: Record<string, unknown>,
|
||||
options: { label: string; softReload?: boolean },
|
||||
) => {
|
||||
if (!domEditSelection) return;
|
||||
await gsapCommitMutation(domEditSelection, mutation, options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Centralized "Enable keyframes" logic that handles ALL scenarios:
|
||||
* - Element has explicit keyframes → add/remove at seeked time
|
||||
* - Element has a flat tween → convert + add at seeked time + propagate to end
|
||||
* - Element has no animation (deleted) → create new tween with correct position + keyframes
|
||||
*
|
||||
* Always fetches fresh animation data to avoid stale session state.
|
||||
* Reads GSAP runtime values only (no CSS offset — it applies separately via translate).
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
|
||||
|
||||
export interface EnableKeyframesSession {
|
||||
domEditSelection: DomEditSelection | null;
|
||||
selectedGsapAnimations: GsapAnimation[];
|
||||
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
||||
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
handleGsapConvertToKeyframes: (
|
||||
animId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
) => void | Promise<void>;
|
||||
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
|
||||
handleGsapAddKeyframeBatch?: (
|
||||
animId: string,
|
||||
pct: number,
|
||||
properties: Record<string, number | string>,
|
||||
) => Promise<void>;
|
||||
commitMutation?: (
|
||||
mutation: Record<string, unknown>,
|
||||
options: { label: string; softReload?: boolean },
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
function readElementPosition(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
sel: DomEditSelection,
|
||||
anim: GsapAnimation | null,
|
||||
): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
if (!iframe?.contentWindow) return result;
|
||||
|
||||
let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
|
||||
try {
|
||||
gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const element = sel.element;
|
||||
if (!element?.isConnected || !gsap?.getProperty) return result;
|
||||
|
||||
const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"];
|
||||
for (const prop of props) {
|
||||
const val = Number(gsap.getProperty(element, prop));
|
||||
if (Number.isFinite(val)) result[prop] = Math.round(val);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAnimation[]> {
|
||||
const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1];
|
||||
if (!projectId) return [];
|
||||
const sourceFile = sel.sourceFile || "index.html";
|
||||
const parsed = await fetchParsedAnimations(projectId, sourceFile);
|
||||
if (!parsed) return [];
|
||||
return getAnimationsForElement(parsed.animations, {
|
||||
id: sel.id,
|
||||
selector: sel.selector,
|
||||
});
|
||||
}
|
||||
|
||||
function computePercentage(t: number, sel: DomEditSelection): number {
|
||||
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
||||
if (elDuration <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10));
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function useEnableKeyframes(
|
||||
sessionRef: React.RefObject<EnableKeyframesSession | undefined>,
|
||||
) {
|
||||
return useCallback(async () => {
|
||||
const session = sessionRef.current;
|
||||
if (!session) return;
|
||||
const sel = session.domEditSelection;
|
||||
if (!sel) return;
|
||||
|
||||
const t = usePlayerStore.getState().currentTime;
|
||||
const iframe = session.previewIframeRef?.current ?? null;
|
||||
|
||||
let anims = session.selectedGsapAnimations;
|
||||
if (anims.length === 0) {
|
||||
anims = await fetchAnimationsForElement(sel);
|
||||
}
|
||||
|
||||
const kfAnim = anims.find((a) => a.keyframes);
|
||||
const flatAnim = anims.find((a) => !a.keyframes);
|
||||
|
||||
if (kfAnim?.keyframes) {
|
||||
const pct = computePercentage(t, sel);
|
||||
const existing = kfAnim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
|
||||
if (existing) {
|
||||
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
||||
} else if (session.handleGsapAddKeyframeBatch) {
|
||||
const position = readElementPosition(iframe, sel, kfAnim);
|
||||
if (Object.keys(position).length > 0) {
|
||||
await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position);
|
||||
}
|
||||
}
|
||||
} else if (flatAnim) {
|
||||
const position = readElementPosition(iframe, sel, flatAnim);
|
||||
const hasPosition = Object.keys(position).length > 0;
|
||||
|
||||
await session.handleGsapConvertToKeyframes(flatAnim.id, hasPosition ? position : undefined);
|
||||
|
||||
const pct = computePercentage(t, sel);
|
||||
if (pct > 1 && pct < 99 && hasPosition && session.handleGsapAddKeyframeBatch) {
|
||||
await session.handleGsapAddKeyframeBatch(flatAnim.id, pct, position);
|
||||
await session.handleGsapAddKeyframeBatch(flatAnim.id, 100, position);
|
||||
}
|
||||
} else {
|
||||
const position = readElementPosition(iframe, sel, null);
|
||||
const pct = computePercentage(t, sel);
|
||||
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
||||
const selector = sel.id ? `#${sel.id}` : sel.selector;
|
||||
|
||||
if (!selector) {
|
||||
session.handleGsapAddAnimation("to");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(position).length === 0) {
|
||||
position.x = 0;
|
||||
position.y = 0;
|
||||
position.opacity = 1;
|
||||
}
|
||||
|
||||
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
||||
[{ percentage: 0, properties: { ...position } }];
|
||||
if (pct > 1 && pct < 99) {
|
||||
keyframes.push({ percentage: pct, properties: { ...position } });
|
||||
}
|
||||
keyframes.push({
|
||||
percentage: 100,
|
||||
properties: { ...position },
|
||||
auto: true,
|
||||
} as (typeof keyframes)[number]);
|
||||
|
||||
if (session.commitMutation) {
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "add-with-keyframes",
|
||||
targetSelector: selector,
|
||||
position: Math.round(elStart * 1000) / 1000,
|
||||
duration: Math.round(elDuration * 1000) / 1000,
|
||||
keyframes,
|
||||
},
|
||||
{ label: "Enable keyframes", softReload: true },
|
||||
);
|
||||
} else {
|
||||
session.handleGsapAddAnimation("to");
|
||||
}
|
||||
}
|
||||
}, [sessionRef]);
|
||||
}
|
||||
@@ -108,6 +108,12 @@ export function useFileManager({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateEditingFileContent = useCallback((path: string, content: string) => {
|
||||
if (editingPathRef.current === path) {
|
||||
setEditingFile({ path, content });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
@@ -460,6 +466,7 @@ export function useFileManager({
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
readOptionalProjectFile,
|
||||
updateEditingFileContent,
|
||||
|
||||
// Click-to-source
|
||||
revealSourceOffset,
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { usePlayerStore, liveTime } from "../player/store/playerStore";
|
||||
|
||||
export interface GestureSample {
|
||||
time: number;
|
||||
properties: Record<string, number>;
|
||||
}
|
||||
|
||||
interface Modifiers {
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}
|
||||
|
||||
interface AccumulatedState {
|
||||
opacity: number;
|
||||
scale: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
function resolveGestureProperties(
|
||||
dx: number,
|
||||
dy: number,
|
||||
scrollDelta: number,
|
||||
modifiers: Modifiers,
|
||||
accumulatedState: AccumulatedState,
|
||||
): {
|
||||
properties: Record<string, number>;
|
||||
nextState: AccumulatedState;
|
||||
} {
|
||||
const properties: Record<string, number> = {};
|
||||
let nextOpacity = accumulatedState.opacity;
|
||||
let nextScale = accumulatedState.scale;
|
||||
let nextZ = accumulatedState.z;
|
||||
|
||||
if (modifiers.meta) {
|
||||
// Opacity derived from total vertical displacement (absolute, not accumulated).
|
||||
// Dragging down reduces opacity; dragging back up restores it.
|
||||
nextOpacity = Math.max(0, Math.min(1, 1 - dy * 0.005));
|
||||
properties.opacity = nextOpacity;
|
||||
if (scrollDelta !== 0) {
|
||||
nextScale = Math.max(0.01, accumulatedState.scale + scrollDelta * 0.01);
|
||||
properties.scale = nextScale;
|
||||
}
|
||||
} else if (modifiers.shift) {
|
||||
properties.rotationX = dy * 0.5;
|
||||
properties.rotationY = dx * 0.5;
|
||||
} else if (modifiers.alt) {
|
||||
properties.rotation = dx * 0.5;
|
||||
} else {
|
||||
properties.x = dx;
|
||||
properties.y = dy;
|
||||
}
|
||||
|
||||
if (!modifiers.meta && scrollDelta !== 0) {
|
||||
nextZ = accumulatedState.z + scrollDelta;
|
||||
properties.z = nextZ;
|
||||
}
|
||||
|
||||
return {
|
||||
properties,
|
||||
nextState: { opacity: nextOpacity, scale: nextScale, z: nextZ },
|
||||
};
|
||||
}
|
||||
|
||||
export function useGestureRecording() {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
|
||||
// Synchronous guard — immune to React's async state batching.
|
||||
// startRecording and stopRecording check this ref, not the useState value.
|
||||
const isRecordingRef = useRef(false);
|
||||
|
||||
const pointerRef = useRef({ x: 0, y: 0 });
|
||||
const startPointerRef = useRef({ x: 0, y: 0 });
|
||||
const scrollDeltaRef = useRef(0);
|
||||
const modifiersRef = useRef<Modifiers>({ shift: false, alt: false, meta: false });
|
||||
const accumulatedRef = useRef<AccumulatedState>({ opacity: 1, scale: 1, z: 0 });
|
||||
const basePositionRef = useRef({ x: 0, y: 0 });
|
||||
const scaleRef = useRef(1);
|
||||
const hasMovedRef = useRef(false);
|
||||
const pointerElementOffsetRef = useRef({ x: 0, y: 0 });
|
||||
const runtimeRef = useRef<{
|
||||
seek: (t: number) => void;
|
||||
set: (target: string, vars: Record<string, number>) => void;
|
||||
selector: string;
|
||||
element: HTMLElement;
|
||||
startTime: number;
|
||||
maxSeekTime: number;
|
||||
} | null>(null);
|
||||
|
||||
const rafIdRef = useRef(0);
|
||||
const samplesRef = useRef<GestureSample[]>([]);
|
||||
const trailRef = useRef<Array<{ x: number; y: number }>>([]);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Unmount safety: cancel RAF + remove listeners if component tears down mid-recording.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupRef.current?.();
|
||||
cleanupRef.current = null;
|
||||
isRecordingRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(
|
||||
(element: HTMLElement, iframeEl: HTMLIFrameElement, elementEndTime?: number) => {
|
||||
if (isRecordingRef.current) return;
|
||||
isRecordingRef.current = true;
|
||||
|
||||
samplesRef.current = [];
|
||||
trailRef.current = [];
|
||||
hasMovedRef.current = false;
|
||||
setRecordingDuration(0);
|
||||
scrollDeltaRef.current = 0;
|
||||
|
||||
let baseOpacity = 1;
|
||||
let baseScaleVal = 1;
|
||||
let baseX = 0;
|
||||
let baseY = 0;
|
||||
try {
|
||||
const gsap = (
|
||||
iframeEl.contentWindow as Window & {
|
||||
gsap?: { getProperty: (el: Element, prop: string) => number };
|
||||
}
|
||||
).gsap;
|
||||
if (gsap?.getProperty) {
|
||||
baseOpacity = Number(gsap.getProperty(element, "opacity")) || 1;
|
||||
baseScaleVal = Number(gsap.getProperty(element, "scaleX")) || 1;
|
||||
baseX = Number(gsap.getProperty(element, "x")) || 0;
|
||||
baseY = Number(gsap.getProperty(element, "y")) || 0;
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin guard */
|
||||
}
|
||||
// When reapplyPathOffsets has run (translate restored to var-based),
|
||||
// GSAP's cache was stripped — gsapX is 0 but the element is visually
|
||||
// at CSSLeft + translate(offset). gsap.set wipes translate, so we need
|
||||
// baseX to include the offset. When translate is "none" (GSAP owns it),
|
||||
// gsapX already includes the baked offset — don't add.
|
||||
const translateVal = element.style.translate ?? "";
|
||||
if (translateVal.includes("var(")) {
|
||||
const offX = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-x")) || 0;
|
||||
const offY = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-y")) || 0;
|
||||
baseX += offX;
|
||||
baseY += offY;
|
||||
}
|
||||
accumulatedRef.current = { opacity: baseOpacity, scale: baseScaleVal, z: 0 };
|
||||
basePositionRef.current = { x: baseX, y: baseY };
|
||||
|
||||
const selector = element.id ? `#${element.id}` : null;
|
||||
try {
|
||||
const win = iframeEl.contentWindow as Window & {
|
||||
gsap?: { set: (t: string, v: Record<string, number>) => void };
|
||||
__timelines?: Record<string, { seek: (t: number) => void; duration: () => number }>;
|
||||
__player?: { getTime: () => number };
|
||||
};
|
||||
const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null;
|
||||
if (win?.gsap?.set && tl?.seek && selector) {
|
||||
const tlDuration = tl.duration();
|
||||
runtimeRef.current = {
|
||||
seek: tl.seek.bind(tl),
|
||||
set: win.gsap.set.bind(win.gsap),
|
||||
selector,
|
||||
element,
|
||||
startTime: win.__player?.getTime() ?? 0,
|
||||
maxSeekTime:
|
||||
elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
runtimeRef.current = null;
|
||||
}
|
||||
|
||||
const iframeRect = iframeEl.getBoundingClientRect();
|
||||
const doc = iframeEl.contentDocument;
|
||||
const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
|
||||
const declaredWidth = Number(root?.getAttribute("data-width")) || 1920;
|
||||
scaleRef.current = declaredWidth > 0 ? iframeRect.width / declaredWidth : 1;
|
||||
|
||||
// Compute the offset between the element's visual center and the pointer
|
||||
// so the element tracks the pointer exactly during recording (no jump).
|
||||
const elRect = element.getBoundingClientRect();
|
||||
const elCenterViewport = {
|
||||
x: elRect.left + elRect.width / 2,
|
||||
y: elRect.top + elRect.height / 2,
|
||||
};
|
||||
pointerElementOffsetRef.current = { x: 0, y: 0 }; // reset; set on first move
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
pointerRef.current = { x: e.clientX, y: e.clientY };
|
||||
modifiersRef.current = {
|
||||
shift: e.shiftKey,
|
||||
alt: e.altKey,
|
||||
meta: e.metaKey || e.ctrlKey,
|
||||
};
|
||||
};
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
scrollDeltaRef.current += e.deltaY;
|
||||
modifiersRef.current = {
|
||||
shift: e.shiftKey,
|
||||
alt: e.altKey,
|
||||
meta: e.metaKey || e.ctrlKey,
|
||||
};
|
||||
};
|
||||
|
||||
const handleKeyChange = (e: KeyboardEvent) => {
|
||||
modifiersRef.current = {
|
||||
shift: e.shiftKey,
|
||||
alt: e.altKey,
|
||||
meta: e.metaKey || e.ctrlKey,
|
||||
};
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", handlePointerMove, { passive: true });
|
||||
document.addEventListener("wheel", handleWheel, { passive: true });
|
||||
document.addEventListener("keydown", handleKeyChange, { passive: true });
|
||||
document.addEventListener("keyup", handleKeyChange, { passive: true });
|
||||
|
||||
startPointerRef.current = { ...pointerRef.current };
|
||||
const startMs = performance.now();
|
||||
|
||||
let startCaptured = false;
|
||||
const captureStart = (e: PointerEvent) => {
|
||||
if (!startCaptured) {
|
||||
startPointerRef.current = { x: e.clientX, y: e.clientY };
|
||||
// Compute the offset between the pointer and the element center
|
||||
// so the element follows the pointer without jumping.
|
||||
pointerElementOffsetRef.current = {
|
||||
x: e.clientX - elCenterViewport.x,
|
||||
y: e.clientY - elCenterViewport.y,
|
||||
};
|
||||
startCaptured = true;
|
||||
hasMovedRef.current = true;
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointermove", captureStart, { passive: true, once: true });
|
||||
|
||||
const tick = () => {
|
||||
if (!isRecordingRef.current) return;
|
||||
const now = performance.now();
|
||||
const time = (now - startMs) / 1000;
|
||||
const scale = scaleRef.current || 1;
|
||||
const dx = (pointerRef.current.x - startPointerRef.current.x) / scale;
|
||||
const dy = (pointerRef.current.y - startPointerRef.current.y) / scale;
|
||||
const scrollDelta = scrollDeltaRef.current;
|
||||
|
||||
// Skip zero-displacement samples before the pointer has moved.
|
||||
if (!hasMovedRef.current && dx === 0 && dy === 0 && scrollDelta === 0) {
|
||||
rafIdRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
hasMovedRef.current = true;
|
||||
|
||||
const { properties, nextState } = resolveGestureProperties(
|
||||
dx,
|
||||
dy,
|
||||
scrollDelta,
|
||||
modifiersRef.current,
|
||||
accumulatedRef.current,
|
||||
);
|
||||
if ("x" in properties) properties.x = Math.round(basePositionRef.current.x + properties.x);
|
||||
if ("y" in properties) properties.y = Math.round(basePositionRef.current.y + properties.y);
|
||||
|
||||
accumulatedRef.current = nextState;
|
||||
scrollDeltaRef.current = 0;
|
||||
|
||||
// Manual seek on the raw GSAP timeline (not the Studio player wrapper,
|
||||
// which triggers React state updates). After seek renders all elements
|
||||
// at the correct time, gsap.set overrides the recorded element so it
|
||||
// follows the pointer. The browser paints the set values on this frame;
|
||||
// next tick's seek will overwrite, but we re-apply immediately.
|
||||
if (runtimeRef.current) {
|
||||
try {
|
||||
const seekTime = Math.min(
|
||||
runtimeRef.current.startTime + time,
|
||||
runtimeRef.current.maxSeekTime,
|
||||
);
|
||||
runtimeRef.current.seek(seekTime);
|
||||
runtimeRef.current.set(runtimeRef.current.selector, { ...properties });
|
||||
runtimeRef.current.element.style.visibility = "visible";
|
||||
liveTime.notify(seekTime);
|
||||
usePlayerStore.getState().setCurrentTime(seekTime);
|
||||
} catch {
|
||||
runtimeRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
samplesRef.current.push({ time, properties });
|
||||
trailRef.current.push({ x: pointerRef.current.x, y: pointerRef.current.y });
|
||||
setRecordingDuration(time);
|
||||
rafIdRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
setIsRecording(true);
|
||||
rafIdRef.current = requestAnimationFrame(tick);
|
||||
|
||||
cleanupRef.current = () => {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
document.removeEventListener("pointermove", handlePointerMove);
|
||||
document.removeEventListener("wheel", handleWheel);
|
||||
document.removeEventListener("keydown", handleKeyChange);
|
||||
document.removeEventListener("keyup", handleKeyChange);
|
||||
document.removeEventListener("pointermove", captureStart);
|
||||
};
|
||||
},
|
||||
[], // No deps — uses refs only for all mutable state
|
||||
);
|
||||
|
||||
const stopRecording = useCallback((): GestureSample[] => {
|
||||
if (!isRecordingRef.current) return [];
|
||||
isRecordingRef.current = false;
|
||||
runtimeRef.current = null;
|
||||
cleanupRef.current?.();
|
||||
cleanupRef.current = null;
|
||||
const frozen = samplesRef.current.slice();
|
||||
setRecordingDuration(frozen.length > 0 ? frozen[frozen.length - 1]!.time : 0);
|
||||
setIsRecording(false);
|
||||
return frozen;
|
||||
}, []); // No deps — uses refs only
|
||||
|
||||
const clearSamples = useCallback(() => {
|
||||
samplesRef.current = [];
|
||||
trailRef.current = [];
|
||||
setRecordingDuration(0);
|
||||
accumulatedRef.current = { opacity: 1, scale: 1, z: 0 };
|
||||
scrollDeltaRef.current = 0;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startRecording,
|
||||
stopRecording,
|
||||
isRecording,
|
||||
samplesRef,
|
||||
trailRef,
|
||||
recordingDuration,
|
||||
clearSamples,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import { applySoftReload } from "../utils/gsapSoftReload";
|
||||
import { executeOptimistic } from "../utils/optimisticUpdate";
|
||||
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
||||
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
||||
|
||||
const PROPERTY_DEFAULTS: Record<string, number> = {
|
||||
opacity: 1,
|
||||
@@ -71,11 +72,69 @@ async function mutateGsapScript(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function updateKeyframeCacheFromParsed(
|
||||
animations: GsapAnimation[],
|
||||
targetPath: string,
|
||||
selectionId: string | undefined,
|
||||
mutation: Record<string, unknown>,
|
||||
): void {
|
||||
const { setKeyframeCache, elements } = usePlayerStore.getState();
|
||||
const idsWithKeyframes = new Set<string>();
|
||||
const merged = new Map<string, KeyframeCacheEntry>();
|
||||
for (const anim of animations) {
|
||||
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
|
||||
if (!id || !anim.keyframes) continue;
|
||||
idsWithKeyframes.add(id);
|
||||
|
||||
// Convert tween-relative percentages to clip-relative so diamonds
|
||||
// render at the correct position within the timeline clip.
|
||||
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
|
||||
const tweenDur = anim.duration ?? 1;
|
||||
const timelineEl = elements.find(
|
||||
(el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
|
||||
);
|
||||
const elStart = timelineEl?.start ?? 0;
|
||||
const elDuration = timelineEl?.duration ?? 4;
|
||||
const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
|
||||
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
|
||||
const clipPct =
|
||||
elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
|
||||
return { ...kf, percentage: clipPct };
|
||||
});
|
||||
|
||||
const existing = merged.get(id);
|
||||
if (existing) {
|
||||
const byPct = new Map<number, (typeof existing.keyframes)[0]>();
|
||||
for (const kf of [...existing.keyframes, ...clipKeyframes]) {
|
||||
const prev = byPct.get(kf.percentage);
|
||||
if (prev) {
|
||||
prev.properties = { ...prev.properties, ...kf.properties };
|
||||
if (kf.ease) prev.ease = kf.ease;
|
||||
} else {
|
||||
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
||||
}
|
||||
}
|
||||
existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
|
||||
} else {
|
||||
merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes });
|
||||
}
|
||||
}
|
||||
for (const [id, entry] of merged) {
|
||||
setKeyframeCache(`${targetPath}#${id}`, entry);
|
||||
setKeyframeCache(id, entry);
|
||||
if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
|
||||
}
|
||||
const targetId =
|
||||
(mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
|
||||
selectionId;
|
||||
if (targetId && !idsWithKeyframes.has(targetId)) {
|
||||
setKeyframeCache(`${targetPath}#${targetId}`, undefined);
|
||||
if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined);
|
||||
}
|
||||
}
|
||||
function buildCacheKey(sourceFile: string, elementId: string): string {
|
||||
return `${sourceFile}#${elementId}`;
|
||||
}
|
||||
|
||||
function readKeyframeSnapshot(
|
||||
sourceFile: string,
|
||||
elementId: string | null | undefined,
|
||||
@@ -83,7 +142,6 @@ function readKeyframeSnapshot(
|
||||
if (!elementId) return undefined;
|
||||
return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId));
|
||||
}
|
||||
|
||||
function writeKeyframeCache(
|
||||
sourceFile: string,
|
||||
elementId: string | null | undefined,
|
||||
@@ -92,7 +150,6 @@ function writeKeyframeCache(
|
||||
if (!elementId) return;
|
||||
usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data);
|
||||
}
|
||||
|
||||
interface GsapScriptCommitsParams {
|
||||
projectIdRef: React.MutableRefObject<string | null>;
|
||||
activeCompPath: string | null;
|
||||
@@ -108,8 +165,8 @@ interface GsapScriptCommitsParams {
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
onCacheInvalidate: () => void;
|
||||
onFileContentChanged?: (path: string, content: string) => void;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
// fallow-ignore-next-line complexity unit-size
|
||||
@@ -121,6 +178,7 @@ export function useGsapScriptCommits({
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
onCacheInvalidate,
|
||||
onFileContentChanged,
|
||||
}: GsapScriptCommitsParams) {
|
||||
const pendingPropertyEditRef = useRef<{
|
||||
selection: DomEditSelection;
|
||||
@@ -129,7 +187,6 @@ export function useGsapScriptCommits({
|
||||
value: number | string;
|
||||
} | null>(null);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
/** Send a mutation and record the edit in undo history. */
|
||||
const commitMutation = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -162,21 +219,23 @@ export function useGsapScriptCommits({
|
||||
});
|
||||
}
|
||||
|
||||
onCacheInvalidate();
|
||||
|
||||
if (result.parsed?.animations) {
|
||||
const { setKeyframeCache } = usePlayerStore.getState();
|
||||
for (const anim of result.parsed.animations) {
|
||||
if (!anim.keyframes) continue;
|
||||
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
|
||||
if (!id) continue;
|
||||
setKeyframeCache(`${targetPath}#${id}`, anim.keyframes);
|
||||
if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes);
|
||||
}
|
||||
if (result.after != null) {
|
||||
onFileContentChanged?.(targetPath, result.after);
|
||||
}
|
||||
|
||||
if (options.skipReload) return;
|
||||
|
||||
// Write the keyframe cache immediately from the parsed response
|
||||
// (synchronous — the timeline diamonds appear on the next render).
|
||||
if (result.parsed?.animations) {
|
||||
updateKeyframeCacheFromParsed(
|
||||
result.parsed.animations,
|
||||
targetPath,
|
||||
selection.id ?? undefined,
|
||||
mutation,
|
||||
);
|
||||
}
|
||||
|
||||
options.beforeReload?.();
|
||||
|
||||
if (options.softReload && result.scriptText) {
|
||||
@@ -186,6 +245,11 @@ export function useGsapScriptCommits({
|
||||
} else {
|
||||
reloadPreview();
|
||||
}
|
||||
|
||||
// Bump the cache version AFTER reload so the async re-fetch in
|
||||
// useGsapAnimationsForElement reads the post-reload script, not
|
||||
// the stale pre-reload version that would overwrite fresh data.
|
||||
onCacheInvalidate();
|
||||
},
|
||||
[
|
||||
projectIdRef,
|
||||
@@ -195,9 +259,9 @@ export function useGsapScriptCommits({
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
onCacheInvalidate,
|
||||
onFileContentChanged,
|
||||
],
|
||||
);
|
||||
|
||||
const flushPendingPropertyEdit = useCallback(() => {
|
||||
const pending = pendingPropertyEditRef.current;
|
||||
if (!pending) return;
|
||||
@@ -227,7 +291,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[flushPendingPropertyEdit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
@@ -252,7 +315,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const deleteGsapAnimation = useCallback(
|
||||
(selection: DomEditSelection, animationId: string) => {
|
||||
void commitMutation(
|
||||
@@ -263,7 +325,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const addGsapAnimation = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (
|
||||
@@ -326,7 +387,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation, projectIdRef, activeCompPath],
|
||||
);
|
||||
|
||||
const addGsapProperty = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
||||
@@ -347,7 +407,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const removeGsapProperty = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
||||
void commitMutation(
|
||||
@@ -358,7 +417,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const updateGsapFromProperty = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
@@ -377,7 +435,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const addGsapFromProperty = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
||||
const defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
|
||||
@@ -389,7 +446,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const removeGsapFromProperty = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
||||
void commitMutation(
|
||||
@@ -400,7 +456,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const addKeyframe = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
@@ -436,7 +491,21 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation, activeCompPath],
|
||||
);
|
||||
|
||||
const addKeyframeBatch = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
) => {
|
||||
return commitMutation(
|
||||
selection,
|
||||
{ type: "add-keyframe", animationId, percentage, properties },
|
||||
{ label: `Add keyframe at ${percentage}%`, softReload: true },
|
||||
);
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
const removeKeyframe = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, percentage: number) => {
|
||||
const sf = selection.sourceFile || activeCompPath || "index.html";
|
||||
@@ -463,18 +532,20 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation, activeCompPath],
|
||||
);
|
||||
|
||||
const convertToKeyframes = useCallback(
|
||||
(selection: DomEditSelection, animationId: string) => {
|
||||
void commitMutation(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
) => {
|
||||
return commitMutation(
|
||||
selection,
|
||||
{ type: "convert-to-keyframes", animationId },
|
||||
{ type: "convert-to-keyframes", animationId, resolvedFromValues },
|
||||
{ label: "Convert to keyframes" },
|
||||
);
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const removeAllKeyframes = useCallback(
|
||||
(selection: DomEditSelection, animationId: string) => {
|
||||
void commitMutation(
|
||||
@@ -485,7 +556,66 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const setArcPath = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
config: {
|
||||
enabled: boolean;
|
||||
autoRotate?: boolean | number;
|
||||
segments?: Array<{
|
||||
curviness: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
}>;
|
||||
},
|
||||
) => {
|
||||
void commitMutation(
|
||||
selection,
|
||||
{ type: "set-arc-path" as const, animationId, ...config },
|
||||
{ label: config.enabled ? "Enable arc path" : "Disable arc path", softReload: true },
|
||||
);
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
const updateArcSegment = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
segmentIndex: number,
|
||||
update: {
|
||||
curviness?: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
},
|
||||
) => {
|
||||
void commitMutation(
|
||||
selection,
|
||||
{ type: "update-arc-segment" as const, animationId, segmentIndex, ...update },
|
||||
{ label: "Update arc segment", softReload: true },
|
||||
);
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
const removeArcPath = useCallback(
|
||||
(selection: DomEditSelection, animationId: string) => {
|
||||
void commitMutation(
|
||||
selection,
|
||||
{ type: "remove-arc-path" as const, animationId },
|
||||
{ label: "Remove arc path", softReload: true },
|
||||
);
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
const commitKeyframeAtTime = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
absoluteTime: number,
|
||||
animations: GsapAnimation[],
|
||||
properties: Record<string, number | string>,
|
||||
) => commitKeyframeAtTimeImpl(selection, absoluteTime, animations, properties, commitMutation),
|
||||
[commitMutation],
|
||||
);
|
||||
return {
|
||||
commitMutation,
|
||||
updateGsapProperty,
|
||||
@@ -498,8 +628,13 @@ export function useGsapScriptCommits({
|
||||
addGsapFromProperty,
|
||||
removeGsapFromProperty,
|
||||
addKeyframe,
|
||||
addKeyframeBatch,
|
||||
removeKeyframe,
|
||||
convertToKeyframes,
|
||||
removeAllKeyframes,
|
||||
setArcPath,
|
||||
updateArcSegment,
|
||||
removeArcPath,
|
||||
commitKeyframeAtTime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { usePlayerStore } from "../player";
|
||||
|
||||
/**
|
||||
* Thin useCallback wrappers that guard on `domEditSelection` before
|
||||
@@ -19,10 +20,10 @@ export function useGsapSelectionHandlers({
|
||||
addGsapFromProperty,
|
||||
removeGsapFromProperty,
|
||||
addKeyframe,
|
||||
addKeyframeBatch,
|
||||
removeKeyframe,
|
||||
convertToKeyframes,
|
||||
removeAllKeyframes,
|
||||
currentTime,
|
||||
handleDomManualEditsReset,
|
||||
selectedGsapAnimations,
|
||||
}: {
|
||||
@@ -61,10 +62,20 @@ export function useGsapSelectionHandlers({
|
||||
property: string,
|
||||
value: number | string,
|
||||
) => void;
|
||||
addKeyframeBatch: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
) => Promise<void>;
|
||||
removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
|
||||
convertToKeyframes: (sel: DomEditSelection, animId: string) => void;
|
||||
convertToKeyframes: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
) => void;
|
||||
removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
|
||||
currentTime: number;
|
||||
|
||||
handleDomManualEditsReset: (sel: DomEditSelection) => void;
|
||||
selectedGsapAnimations: { id: string; keyframes?: unknown }[];
|
||||
}) {
|
||||
@@ -95,12 +106,12 @@ export function useGsapSelectionHandlers({
|
||||
const handleGsapAddAnimation = useCallback(
|
||||
(method: "to" | "from" | "set" | "fromTo") => {
|
||||
if (!domEditSelection) return;
|
||||
addGsapAnimation(domEditSelection, method, currentTime);
|
||||
addGsapAnimation(domEditSelection, method, usePlayerStore.getState().currentTime);
|
||||
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
|
||||
handleDomManualEditsReset(domEditSelection);
|
||||
}
|
||||
},
|
||||
[domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset],
|
||||
[domEditSelection, addGsapAnimation, handleDomManualEditsReset],
|
||||
);
|
||||
|
||||
const handleGsapAddProperty = useCallback(
|
||||
@@ -151,6 +162,13 @@ export function useGsapSelectionHandlers({
|
||||
[domEditSelection, addKeyframe],
|
||||
);
|
||||
|
||||
const handleGsapAddKeyframeBatch = useCallback(
|
||||
(animId: string, percentage: number, properties: Record<string, number | string>) => {
|
||||
if (!domEditSelection) return Promise.resolve();
|
||||
return addKeyframeBatch(domEditSelection, animId, percentage, properties);
|
||||
},
|
||||
[domEditSelection, addKeyframeBatch],
|
||||
);
|
||||
const handleGsapRemoveKeyframe = useCallback(
|
||||
(animId: string, percentage: number) => {
|
||||
if (!domEditSelection) return;
|
||||
@@ -160,9 +178,9 @@ export function useGsapSelectionHandlers({
|
||||
);
|
||||
|
||||
const handleGsapConvertToKeyframes = useCallback(
|
||||
(animId: string) => {
|
||||
if (!domEditSelection) return;
|
||||
convertToKeyframes(domEditSelection, animId);
|
||||
(animId: string, resolvedFromValues?: Record<string, number | string>) => {
|
||||
if (!domEditSelection) return Promise.resolve();
|
||||
return convertToKeyframes(domEditSelection, animId, resolvedFromValues);
|
||||
},
|
||||
[domEditSelection, convertToKeyframes],
|
||||
);
|
||||
@@ -194,6 +212,7 @@ export function useGsapSelectionHandlers({
|
||||
handleGsapAddFromProperty,
|
||||
handleGsapRemoveFromProperty,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapAddKeyframeBatch,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
|
||||
@@ -1,8 +1,72 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
||||
|
||||
function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
|
||||
const byPct = new Map<number, GsapPercentageKeyframe>();
|
||||
for (const kf of keyframes) {
|
||||
const existing = byPct.get(kf.percentage);
|
||||
if (existing) {
|
||||
existing.properties = { ...existing.properties, ...kf.properties };
|
||||
if (kf.ease) existing.ease = kf.ease;
|
||||
} else {
|
||||
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
||||
}
|
||||
}
|
||||
return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
|
||||
}
|
||||
|
||||
const PROPERTY_DEFAULTS: Record<string, number> = {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
};
|
||||
|
||||
function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
|
||||
if (anim.method === "set") {
|
||||
return {
|
||||
format: "percentage",
|
||||
keyframes: [{ percentage: 0, properties: { ...anim.properties } }],
|
||||
};
|
||||
}
|
||||
const toProps = anim.properties;
|
||||
const fromProps = anim.fromProperties;
|
||||
if (!toProps || Object.keys(toProps).length === 0) return null;
|
||||
|
||||
const startProps: Record<string, number | string> = {};
|
||||
const endProps: Record<string, number | string> = {};
|
||||
|
||||
if (anim.method === "from") {
|
||||
for (const [k, v] of Object.entries(toProps)) {
|
||||
startProps[k] = v;
|
||||
endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
||||
}
|
||||
} else if (anim.method === "fromTo" && fromProps) {
|
||||
Object.assign(startProps, fromProps);
|
||||
Object.assign(endProps, toProps);
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(toProps)) {
|
||||
startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
||||
endProps[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: startProps },
|
||||
{ percentage: 100, properties: endProps },
|
||||
],
|
||||
...(anim.ease ? { ease: anim.ease } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function extractIdFromSelector(selector: string): string | null {
|
||||
const match = selector.match(/^#([\w-]+)/);
|
||||
return match ? match[1] : null;
|
||||
@@ -31,7 +95,12 @@ export function getAnimationsForElement(
|
||||
if (target.selector) matchers.add(target.selector);
|
||||
if (matchers.size === 0) return [];
|
||||
return animations.filter((a) =>
|
||||
a.targetSelector.split(",").some((part) => matchers.has(part.trim())),
|
||||
a.targetSelector.split(",").some((part) => {
|
||||
const trimmed = part.trim();
|
||||
if (matchers.has(trimmed)) return true;
|
||||
const lastSimple = trimmed.split(/\s+/).pop();
|
||||
return lastSimple ? matchers.has(lastSimple) : false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,12 +251,60 @@ export function useGsapAnimationsForElement(
|
||||
|
||||
// Populate keyframe cache for the selected element.
|
||||
// Key format must match timeline element keys: "sourceFile#domId".
|
||||
// Merges keyframes from ALL animations targeting this element and synthesizes
|
||||
// flat tweens so the cache is never downgraded vs the bulk populate.
|
||||
const elementId = target?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (!elementId) return;
|
||||
|
||||
// Resolve the element's time range from the player store so we can
|
||||
// convert tween-relative keyframe percentages to clip-relative ones.
|
||||
const { elements } = usePlayerStore.getState();
|
||||
const timelineEl = elements.find(
|
||||
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
||||
);
|
||||
const elStart = timelineEl?.start ?? 0;
|
||||
const elDuration = timelineEl?.duration ?? 4;
|
||||
|
||||
const allKeyframes: GsapKeyframesData["keyframes"] = [];
|
||||
let format: GsapKeyframesData["format"] = "percentage";
|
||||
let ease: string | undefined;
|
||||
let easeEach: string | undefined;
|
||||
for (const anim of animations) {
|
||||
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
||||
if (!kf) continue;
|
||||
// Convert tween-relative percentages to clip-relative so diamonds
|
||||
// render at the correct position within the timeline clip.
|
||||
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
|
||||
const tweenDur = anim.duration ?? elDuration;
|
||||
for (const k of kf.keyframes) {
|
||||
const absTime = tweenPos + (k.percentage / 100) * tweenDur;
|
||||
const clipPct =
|
||||
elDuration > 0
|
||||
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
|
||||
: k.percentage;
|
||||
allKeyframes.push({ ...k, percentage: clipPct });
|
||||
}
|
||||
format = kf.format;
|
||||
if (kf.ease) ease = kf.ease;
|
||||
if (kf.easeEach) easeEach = kf.easeEach;
|
||||
}
|
||||
if (allKeyframes.length === 0) {
|
||||
const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
|
||||
if (keyframeCache.has(`${sourceFile}#${elementId}`)) {
|
||||
setKeyframeCache(`${sourceFile}#${elementId}`, undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
|
||||
const merged: GsapKeyframesData = {
|
||||
format,
|
||||
keyframes: dedupedKeyframes,
|
||||
...(ease ? { ease } : {}),
|
||||
...(easeEach ? { easeEach } : {}),
|
||||
};
|
||||
const { setKeyframeCache } = usePlayerStore.getState();
|
||||
const withKeyframes = animations.find((a) => a.keyframes);
|
||||
setKeyframeCache(`${sourceFile}#${elementId}`, withKeyframes?.keyframes ?? undefined);
|
||||
setKeyframeCache(`${sourceFile}#${elementId}`, merged);
|
||||
}, [elementId, sourceFile, animations]);
|
||||
|
||||
return { animations, multipleTimelines, unsupportedTimelinePattern };
|
||||
@@ -213,25 +330,63 @@ export function usePopulateKeyframeCacheForFile(
|
||||
const lastFetchKeyRef = useRef("");
|
||||
|
||||
const runtimeScanDoneRef = useRef("");
|
||||
const astFetchDoneRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
|
||||
if (fetchKey === lastFetchKeyRef.current) return;
|
||||
lastFetchKeyRef.current = fetchKey;
|
||||
runtimeScanDoneRef.current = "";
|
||||
astFetchDoneRef.current = "";
|
||||
if (!projectId) return;
|
||||
|
||||
const sf = sourceFile;
|
||||
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
||||
if (!parsed) return;
|
||||
const { setKeyframeCache } = usePlayerStore.getState();
|
||||
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
|
||||
const sfPrefix = `${sf}#`;
|
||||
const fallbackPrefix = "index.html#";
|
||||
for (const key of keyframeCache.keys()) {
|
||||
if (key.startsWith(sfPrefix) || (sf !== "index.html" && key.startsWith(fallbackPrefix))) {
|
||||
setKeyframeCache(key, undefined);
|
||||
}
|
||||
}
|
||||
const { elements } = usePlayerStore.getState();
|
||||
const mergedByElement = new Map<string, GsapKeyframesData>();
|
||||
for (const anim of parsed.animations) {
|
||||
const id = extractIdFromSelector(anim.targetSelector);
|
||||
if (!id || !anim.keyframes) continue;
|
||||
setKeyframeCache(`${sf}#${id}`, anim.keyframes);
|
||||
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes);
|
||||
if (!id) continue;
|
||||
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
||||
if (!kfData) continue;
|
||||
// Convert tween-relative percentages to clip-relative.
|
||||
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
|
||||
const tweenDur = anim.duration ?? 1;
|
||||
const timelineEl = elements.find(
|
||||
(el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
|
||||
);
|
||||
const elStart = timelineEl?.start ?? 0;
|
||||
const elDuration = timelineEl?.duration ?? 4;
|
||||
const clipKeyframes = kfData.keyframes.map((kf) => {
|
||||
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
|
||||
const clipPct =
|
||||
elDuration > 0
|
||||
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
|
||||
: kf.percentage;
|
||||
return { ...kf, percentage: clipPct };
|
||||
});
|
||||
const existing = mergedByElement.get(id);
|
||||
if (existing) {
|
||||
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
|
||||
} else {
|
||||
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
|
||||
}
|
||||
}
|
||||
runtimeScanDoneRef.current = fetchKey;
|
||||
for (const [id, kfData] of mergedByElement) {
|
||||
setKeyframeCache(`${sf}#${id}`, kfData);
|
||||
setKeyframeCache(id, kfData);
|
||||
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData);
|
||||
}
|
||||
astFetchDoneRef.current = fetchKey;
|
||||
});
|
||||
}, [projectId, sourceFile, version]);
|
||||
|
||||
@@ -246,7 +401,8 @@ export function usePopulateKeyframeCacheForFile(
|
||||
|
||||
const tryRuntimeScan = () => {
|
||||
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
|
||||
const iframe = iframeRef?.current;
|
||||
const iframe =
|
||||
iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
|
||||
if (!iframe) return false;
|
||||
const scanned = scanAllRuntimeKeyframes(iframe);
|
||||
if (scanned.size === 0) return false;
|
||||
@@ -254,7 +410,8 @@ export function usePopulateKeyframeCacheForFile(
|
||||
for (const [id, data] of scanned) {
|
||||
const cacheKey = `${sf}#${id}`;
|
||||
const fallbackKey = `index.html#${id}`;
|
||||
if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey)) continue;
|
||||
if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id))
|
||||
continue;
|
||||
const entry = {
|
||||
format: "percentage" as const,
|
||||
keyframes: data.keyframes,
|
||||
@@ -262,6 +419,7 @@ export function usePopulateKeyframeCacheForFile(
|
||||
};
|
||||
setKeyframeCache(cacheKey, entry);
|
||||
if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
|
||||
setKeyframeCache(id, entry);
|
||||
}
|
||||
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
|
||||
interface KeyframeKeyboardOptions {
|
||||
enabled: boolean;
|
||||
onAddKeyframe?: () => void;
|
||||
onDeleteKeyframe?: () => void;
|
||||
onPrevKeyframe?: () => void;
|
||||
onNextKeyframe?: () => void;
|
||||
onToggleHold?: () => void;
|
||||
onToggleExpand?: () => void;
|
||||
onNudgeKeyframe?: (direction: -1 | 1, large: boolean) => void;
|
||||
}
|
||||
|
||||
function isTextInput(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
return (el as HTMLElement).isContentEditable === true;
|
||||
}
|
||||
|
||||
export function useKeyframeKeyboard({
|
||||
enabled,
|
||||
onAddKeyframe,
|
||||
onDeleteKeyframe,
|
||||
onPrevKeyframe,
|
||||
onNextKeyframe,
|
||||
onToggleHold,
|
||||
onToggleExpand,
|
||||
onNudgeKeyframe,
|
||||
}: KeyframeKeyboardOptions): void {
|
||||
const handler = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
if (isTextInput(document.activeElement)) return;
|
||||
|
||||
const hasSelectedKeyframes = usePlayerStore.getState().selectedKeyframes.size > 0;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "k":
|
||||
if (!e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
onAddKeyframe?.();
|
||||
}
|
||||
break;
|
||||
case "delete":
|
||||
case "backspace":
|
||||
if (hasSelectedKeyframes) {
|
||||
e.preventDefault();
|
||||
onDeleteKeyframe?.();
|
||||
}
|
||||
break;
|
||||
case "j":
|
||||
if (!e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) onNextKeyframe?.();
|
||||
else onPrevKeyframe?.();
|
||||
}
|
||||
break;
|
||||
case "h":
|
||||
if (!e.metaKey && !e.ctrlKey && hasSelectedKeyframes) {
|
||||
e.preventDefault();
|
||||
onToggleHold?.();
|
||||
}
|
||||
break;
|
||||
case "u":
|
||||
if (!e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
onToggleExpand?.();
|
||||
}
|
||||
break;
|
||||
case "arrowleft":
|
||||
if (hasSelectedKeyframes && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
onNudgeKeyframe?.(-1, e.shiftKey);
|
||||
}
|
||||
break;
|
||||
case "arrowright":
|
||||
if (hasSelectedKeyframes && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
onNudgeKeyframe?.(1, e.shiftKey);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
enabled,
|
||||
onAddKeyframe,
|
||||
onDeleteKeyframe,
|
||||
onPrevKeyframe,
|
||||
onNextKeyframe,
|
||||
onToggleHold,
|
||||
onToggleExpand,
|
||||
onNudgeKeyframe,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [enabled, handler]);
|
||||
}
|
||||
@@ -17,7 +17,6 @@ interface StudioContextInput {
|
||||
compositionLoading: boolean;
|
||||
refreshKey: number;
|
||||
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
||||
currentTime: number;
|
||||
timelineElements: StudioContextValue["timelineElements"];
|
||||
isPlaying: boolean;
|
||||
editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string };
|
||||
@@ -50,7 +49,7 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
|
||||
compositionLoading: input.compositionLoading,
|
||||
refreshKey: input.refreshKey,
|
||||
setRefreshKey: input.setRefreshKey,
|
||||
currentTime: input.currentTime,
|
||||
|
||||
timelineElements: input.timelineElements,
|
||||
isPlaying: input.isPlaying,
|
||||
editHistory: input.editHistory,
|
||||
@@ -81,6 +80,7 @@ export function useInspectorState(
|
||||
rightCollapsed: boolean,
|
||||
isPlaying: boolean,
|
||||
domEditSelection: DomEditSelection | null,
|
||||
isGestureRecording?: boolean,
|
||||
): InspectorState {
|
||||
// fallow-ignore-next-line complexity
|
||||
return useMemo(() => {
|
||||
@@ -101,9 +101,10 @@ export function useInspectorState(
|
||||
inspectorPanelActive,
|
||||
inspectorButtonActive:
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
|
||||
shouldShowSelectedDomBounds: inspectorPanelActive && !rightCollapsed && !isPlaying,
|
||||
shouldShowSelectedDomBounds:
|
||||
inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording,
|
||||
};
|
||||
}, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection]);
|
||||
}, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection, isGestureRecording]);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
interface UseStudioUrlStateParams {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
compositionLoading: boolean;
|
||||
@@ -57,7 +56,6 @@ function replaceHash(nextHash: string) {
|
||||
export function useStudioUrlState({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
compositionLoading,
|
||||
@@ -72,6 +70,7 @@ export function useStudioUrlState({
|
||||
applyDomSelection,
|
||||
initialState,
|
||||
}: UseStudioUrlStateParams) {
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const hydratedSeekRef = useRef(initialState.currentTime == null);
|
||||
const hydratedInitialTimeRef = useRef(initialState.currentTime == null);
|
||||
const hydratedSelectionRef = useRef(initialState.selection == null);
|
||||
|
||||
@@ -41,6 +41,7 @@ interface UseTimelineEditingOptions {
|
||||
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
||||
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
||||
isRecordingRef?: React.RefObject<boolean>;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
@@ -187,6 +188,7 @@ export function useTimelineEditing({
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
uploadProjectFiles,
|
||||
isRecordingRef,
|
||||
}: UseTimelineEditingOptions) {
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
@@ -200,6 +202,10 @@ export function useTimelineEditing({
|
||||
label: string,
|
||||
buildPatches: PersistTimelineEditInput["buildPatches"],
|
||||
): Promise<void> => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return Promise.resolve();
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return Promise.resolve();
|
||||
const queued = editQueueRef.current.then(() =>
|
||||
@@ -226,6 +232,8 @@ export function useTimelineEditing({
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
showToast,
|
||||
isRecordingRef,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -287,6 +295,10 @@ export function useTimelineEditing({
|
||||
|
||||
const handleTimelineElementDelete = useCallback(
|
||||
async (element: TimelineElement) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
const label = getTimelineElementLabel(element);
|
||||
@@ -351,6 +363,7 @@ export function useTimelineEditing({
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -360,6 +373,10 @@ export function useTimelineEditing({
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
durationOverride?: number,
|
||||
) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
|
||||
@@ -428,11 +445,16 @@ export function useTimelineEditing({
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTimelineFileDrop = useCallback(
|
||||
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const uploaded = await uploadProjectFiles(files);
|
||||
@@ -466,7 +488,14 @@ export function useTimelineEditing({
|
||||
);
|
||||
}
|
||||
},
|
||||
[activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
|
||||
[
|
||||
activeCompPath,
|
||||
handleTimelineAssetDrop,
|
||||
timelineElements,
|
||||
uploadProjectFiles,
|
||||
isRecordingRef,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
const handleBlockedTimelineEdit = useCallback(
|
||||
@@ -481,6 +510,10 @@ export function useTimelineEditing({
|
||||
|
||||
const handleTimelineElementSplit = useCallback(
|
||||
async (element: TimelineElement, splitTime: number) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
|
||||
@@ -568,6 +601,7 @@ export function useTimelineEditing({
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -16,5 +16,10 @@ export function useToast() {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
});
|
||||
|
||||
return { appToast, showToast };
|
||||
const dismissToast = useCallback(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setAppToast(null);
|
||||
}, []);
|
||||
|
||||
return { appToast, showToast, dismissToast };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user