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:
Miguel Ángel
2026-06-09 18:30:23 -04:00
committed by GitHub
parent 96b8d617d8
commit a468550f82
72 changed files with 4421 additions and 621 deletions
@@ -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,
},
);
}
}
+147 -85
View File
@@ -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,
+47 -4
View File
@@ -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,
};
}
+169 -34
View File
@@ -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,
+169 -11
View File
@@ -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,
],
);
+6 -1
View File
@@ -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 };
}