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
+1 -2
View File
@@ -74,7 +74,6 @@
{
"group": "Guides",
"pages": [
"guides/mcp",
"guides/pipeline",
"guides/html-in-canvas",
"guides/website-to-video",
@@ -85,6 +84,7 @@
"guides/prompting",
"guides/hyperframes-vs-remotion",
"guides/gsap-animation",
"guides/keyframes",
"guides/rendering",
"guides/remove-background",
"guides/hdr",
@@ -227,7 +227,6 @@
"pages": [
"catalog/components/grain-overlay",
"catalog/components/grid-pixelate-wipe",
"catalog/components/motion-blur",
"catalog/components/parallax-unzoom",
"catalog/components/parallax-zoom",
"catalog/components/shimmer-sweep",
+141
View File
@@ -0,0 +1,141 @@
---
title: Keyframes & Arc Motion
description: "Edit GSAP keyframes visually in Studio — timeline diamonds, arc motion paths, and gesture recording."
---
Studio gives you visual tools to create and edit GSAP keyframes without writing code. You can adjust animation properties in the Design Panel, convert straight-line motion into curved arcs, and record gesture-based motion by dragging elements in the preview.
## Timeline Keyframe Diamonds
When you open a composition in Studio, the timeline shows **diamond markers** on clips that have GSAP animations. Each diamond represents a keyframe — a point in time where a property value is set.
- **Start diamond** — where the tween begins (e.g., `x: 0`)
- **End diamond** — where the tween ends (e.g., `x: 1000`)
- Elements with multiple tweens show multiple diamond pairs
<Note>
Keyframe diamonds are synthesized from your GSAP tweens automatically. Every `.to()`, `.from()`, and `.fromTo()` call produces start and end markers on the timeline.
</Note>
## Editing Animation Properties
Select any animated element in the preview or timeline to open the Design Panel. The **Animation** section shows:
- **Method badge** — `Animate`, `Animate In`, or `Animate Out` (maps to `.to()`, `.from()`, `.fromTo()`)
- **Timing** — Length (duration) and Starts at (position on timeline)
- **Speed** — The GSAP ease (e.g., `power2.inOut`, `back.out(3)`)
- **Speed curve** — Visual preview of the easing function
- **Properties** — Each animated property (Move X, Move Y, Scale, Opacity, etc.) with its target value
<Steps>
<Step title="Select an element">
Click an animated element in the preview or its clip in the timeline. The Design Panel opens on the right.
</Step>
<Step title="Edit property values">
Change any property value directly — for example, set Move X to `500` to make the element travel 500px. Changes apply immediately via soft reload.
</Step>
<Step title="Change the ease">
Click the ease dropdown (e.g., "Smooth ease") to pick a different easing function. The speed curve preview updates live.
</Step>
<Step title="Verify in Code tab">
Switch to the Code tab to see the generated GSAP code. Every Design Panel edit writes valid GSAP that renders identically in preview and headless export.
</Step>
</Steps>
## Arc Motion
Arc Motion converts a straight-line x/y animation into a curved path using GSAP's MotionPathPlugin. Instead of moving in a straight diagonal, the element follows a smooth arc — like tossing an object into a basket.
### When to Use It
Use Arc Motion when an element has both `x` and `y` properties in a single tween. Common examples:
- Add-to-cart animations (item arcs from product to cart icon)
- Throw/toss effects
- Any motion that should feel physical rather than robotic
### Step-by-Step
<Steps>
<Step title="Select an element with x/y motion">
The element must have a `.to()` tween with both Move X and Move Y properties. Select it in the preview or timeline.
</Step>
<Step title="Toggle Arc Motion ON">
In the Animation section of the Design Panel, find the **Arc Motion** toggle below the property list. Switch it ON.
</Step>
<Step title="Adjust Curviness">
The **Curviness** slider controls how exaggerated the arc is:
- `0` — straight line (no curve)
- `1` — gentle natural arc
- `1.52.0` — smooth throw feel (recommended)
- `3.0` — extreme loop
Scrub the timeline to preview the arc in real time.
</Step>
<Step title="Toggle Auto-Rotate (optional)">
Enable **Auto-Rotate** to make the element rotate to face the direction of travel along the arc. This adds a "thrown" feel vs. a "floating" feel.
</Step>
<Step title="Verify the generated code">
Switch to the Code tab. You'll see:
```javascript
tl.to("#element", {
scale: 0.4,
opacity: 0,
duration: 1.0,
ease: "power2.inOut",
motionPath: {
path: [{x: 0, y: 0}, {x: 1400, y: -280}],
curviness: 1.5,
autoRotate: true
}
}, 1.0);
```
The MotionPathPlugin CDN script is added automatically.
</Step>
<Step title="Disable to restore straight motion">
Toggle Arc Motion OFF to restore the original `x` and `y` properties as flat tween values.
</Step>
</Steps>
<Note>
Arc Motion works for flat `.to()` tweens with x/y properties. It synthesizes waypoints from `{x: 0, y: 0}` (start) to `{x: targetX, y: targetY}` (end). For more complex paths with intermediate waypoints, edit the `motionPath.path` array directly in the Code tab.
</Note>
## Gesture Recording
Record motion by physically dragging an element in the preview while the timeline plays. The pointer path is simplified and converted into GSAP keyframes automatically.
<Steps>
<Step title="Select an element">
Click the element you want to animate in the preview.
</Step>
<Step title="Click Record or press R">
In the Animation section of the Design Panel, click **Record gesture (R)** or press the R key. The timeline starts playing.
</Step>
<Step title="Drag the element">
Move the element in the preview by dragging it. Your pointer motion is sampled at ~60fps. A trail overlay shows the path you're drawing.
</Step>
<Step title="Stop recording">
Press R again or wait for the timeline to reach the end. Recording stops, the motion is simplified (reducing ~180 raw samples to 515 clean keyframes), and the keyframes are written to the GSAP script immediately.
</Step>
<Step title="Review or undo">
The timeline seeks back to the recording start so you can scrub through the result. If you don't like it, press **Cmd+Z** to undo and try again.
</Step>
</Steps>
## Clipboard Context
The **clipboard icon** next to the element name in the Design Panel copies structured element context to your clipboard:
```
Element: Title (#title)
File: index.html:15
Position: x=100, y=40
Size: 264×43
Tag: <div>
Animation: from() 0.5s at 0s, ease: power2.out
Properties: x: -40, opacity: 0
```
Paste this into any AI agent prompt to give it spatial context about the element — its position, size, animation, and source location.
+68 -45
View File
@@ -9,7 +9,7 @@ import { createThreeAdapter } from "./adapters/three";
import { createTypegpuAdapter } from "./adapters/typegpu";
import { patchVideoTextureCompat } from "./adapters/video-texture-compat";
import { createWaapiAdapter } from "./adapters/waapi";
import { readElementPlaybackRate, refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
import { createPickerModule } from "./picker";
import { createRuntimePlayer } from "./player";
@@ -954,33 +954,13 @@ export function initSandboxRuntimeModular(): void {
state.capturedTimeline.totalTime(seekTime, false);
}
// Strip stale CSS offset artifacts from GSAP-targeted elements.
// These leak into the HTML when the CSS offset path fires for a
// GSAP-animated element (stale cache race). On reload, both the
// offset and GSAP transform stack, doubling the visual position.
const staleEls = document.querySelectorAll("[data-hf-studio-path-offset]");
if (staleEls.length > 0 && state.capturedTimeline.getChildren) {
const tweenTargets = new Set<Element>();
try {
for (const child of state.capturedTimeline.getChildren(true)) {
if (typeof child.targets === "function") {
for (const t of child.targets()) tweenTargets.add(t);
}
}
} catch {
/* timeline access guard */
}
for (const el of staleEls) {
if (!tweenTargets.has(el)) continue;
const htmlEl = el as HTMLElement;
htmlEl.removeAttribute("data-hf-studio-path-offset");
htmlEl.removeAttribute("data-hf-studio-original-translate");
htmlEl.removeAttribute("data-hf-studio-original-inline-translate");
htmlEl.style.removeProperty("--hf-studio-offset-x");
htmlEl.style.removeProperty("--hf-studio-offset-y");
htmlEl.style.removeProperty("translate");
}
}
// GSAP bakes the CSS `translate` into style.transform on seek.
// The Studio seek wrapper (installStudioManualEditSeekReapply) calls
// reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook
// directly here as well, since the wrapper may not be installed yet
// during initial rebind (timing race on first load / soft reload).
const applyFn = (window as Record<string, unknown>).__hfStudioManualEditsApply;
if (typeof applyFn === "function") applyFn();
}
if (resolution.diagnostics) {
postRuntimeMessage({
@@ -1000,6 +980,51 @@ export function initSandboxRuntimeModular(): void {
mediaDurationFloorSeconds: resolution.mediaDurationFloorSeconds ?? null,
},
});
// Stamp data-start / data-duration on GSAP-targeted elements that lack
// them so the Studio timeline can discover individual animated elements.
{
const rootComp = resolveRootCompositionElement();
const rootDuration = boundDuration > 0 ? boundDuration : 0;
const dur = String(rootDuration > 0 ? rootDuration : 1);
const seen = new Set<Element>();
// Stamp GSAP-targeted elements
if (state.capturedTimeline.getChildren) {
try {
for (const child of state.capturedTimeline.getChildren(true)) {
if (typeof child.targets !== "function") continue;
for (const target of child.targets()) {
if (!(target instanceof HTMLElement)) continue;
if (target === rootComp) continue;
if (target.hasAttribute("data-start")) continue;
if (seen.has(target)) continue;
seen.add(target);
target.setAttribute("data-start", "0");
target.setAttribute("data-duration", dur);
}
}
} catch {
/* timeline access guard */
}
}
// Stamp all ID'd children of the composition root so they appear
// in the timeline even without animations. Enables selecting and
// adding animations from the design panel on a blank canvas.
if (rootComp instanceof HTMLElement) {
for (const el of rootComp.querySelectorAll("[id]")) {
if (!(el instanceof HTMLElement)) continue;
if (el === rootComp) continue;
if (el.hasAttribute("data-start")) continue;
if (seen.has(el)) continue;
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
seen.add(el);
el.setAttribute("data-start", "0");
el.setAttribute("data-duration", dur);
}
}
}
// (Re-)probe all already-bound media elements against the new timeline.
// Clear the cache first so elements probed against a prior timeline get fresh keyframes.
for (const el of metadataBoundMedia) {
@@ -1356,7 +1381,6 @@ export function initSandboxRuntimeModular(): void {
const mediaStart =
Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") ||
0;
const playbackRate = readElementPlaybackRate(element);
const hostRemaining =
context.inheritedStart != null &&
context.inheritedDuration != null &&
@@ -1365,7 +1389,7 @@ export function initSandboxRuntimeModular(): void {
: null;
const sourceDuration =
Number.isFinite(element.duration) && element.duration > mediaStart
? Math.max(0, (element.duration - mediaStart) / playbackRate)
? Math.max(0, element.duration - mediaStart)
: null;
if (sourceDuration != null && hostRemaining != null) {
return Math.min(sourceDuration, hostRemaining);
@@ -1758,28 +1782,27 @@ export function initSandboxRuntimeModular(): void {
postState(true);
};
let buildListenerPending = false;
maybePublishRenderReady = () => {
if (!externalCompositionsReady) {
if (!externalCompositionsReady || window.__hfTimelinesBuilding) {
window.__renderReady = false;
return;
}
if (window.__hfTimelinesBuilding) {
window.__renderReady = false;
if (!buildListenerPending) {
buildListenerPending = true;
const onBuilt = () => {
buildListenerPending = false;
maybePublishRenderReady();
};
window.addEventListener("hf-timelines-built", onBuilt, { once: true });
}
return;
}
publishRenderReadyAfterTimelineBinding();
};
// When the GSAP tween-batching interceptor (HF_EARLY_STUB, fileServer.ts) is
// active, composition scripts queue tl.to() calls instead of executing them
// synchronously. Wait for the "hf-timelines-built" event before the first
// binding attempt so the transport clock receives the finished timeline
// duration instead of permanently publishing duration=0.
if (window.__hfTimelinesBuilding) {
window.__renderReady = false;
const onTimelinesBuilt = () => {
window.removeEventListener("hf-timelines-built", onTimelinesBuilt);
maybePublishRenderReady();
};
window.addEventListener("hf-timelines-built", onTimelinesBuilt);
}
maybePublishRenderReady();
// When the bundler inlines compositions, data-composition-src is removed so
@@ -230,7 +230,11 @@ export function patchElementInHtml(
}
break;
case "text-content":
if (op.value != null) htmlEl.textContent = op.value;
if (op.value != null) {
const inner = htmlEl.children.length === 1 ? htmlEl.firstElementChild : null;
const textTarget = inner ? (inner as unknown as HTMLElement) : htmlEl;
textTarget.textContent = op.value;
}
break;
}
}
@@ -254,6 +258,35 @@ export interface SplitElementResult {
newId: string | null;
}
function resolveElementTiming(el: Element): {
start: number;
duration: number;
usesDataEnd: boolean;
} {
const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
const usesDataEnd = el.hasAttribute("data-end");
const duration = usesDataEnd
? parseFloat(el.getAttribute("data-end") ?? "") - start || 0
: parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
return { start, duration, usesDataEnd };
}
function setElementDuration(
el: Element,
start: number,
duration: number,
usesDataEnd: boolean,
): void {
if (usesDataEnd) {
const endTime = String(Math.round((start + duration) * 1000) / 1000);
el.setAttribute("data-end", endTime);
el.removeAttribute("data-duration");
} else {
el.setAttribute("data-duration", String(Math.round(duration * 1000) / 1000));
el.removeAttribute("data-end");
}
}
export function splitElementInHtml(
source: string,
target: SourceMutationTarget,
@@ -264,8 +297,7 @@ export function splitElementInHtml(
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };
const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
const duration = parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
const { start, duration, usesDataEnd } = resolveElementTiming(el);
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
return { html: source, matched: false, newId: null };
}
@@ -277,7 +309,7 @@ export function splitElementInHtml(
clone.setAttribute("id", newId);
clone.removeAttribute("data-hf-id");
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
clone.setAttribute("data-duration", String(Math.round(secondDuration * 1000) / 1000));
setElementDuration(clone, splitTime, secondDuration, usesDataEnd);
// Adjust media trim offset for the second half
const playbackStartAttr = el.hasAttribute("data-playback-start")
@@ -287,7 +319,8 @@ export function splitElementInHtml(
: null;
if (playbackStartAttr) {
const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0;
const rate = parseFloat(el.getAttribute("data-playback-rate") ?? "1") || 1;
const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
const rate = Number.isFinite(rateRaw) ? rateRaw : 1;
clone.setAttribute(
playbackStartAttr,
String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000),
@@ -295,7 +328,7 @@ export function splitElementInHtml(
}
// Trim the original element's duration
el.setAttribute("data-duration", String(Math.round(firstDuration * 1000) / 1000));
setElementDuration(el, start, firstDuration, usesDataEnd);
// Insert clone after original
if (el.nextSibling) {
+159 -6
View File
@@ -35,6 +35,10 @@ import type { DomEditSelection } from "./components/editor/domEditing";
import { AskAgentModal } from "./components/AskAgentModal";
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
import { StudioHeader } from "./components/StudioHeader";
import { useGestureRecording } from "./hooks/useGestureRecording";
import { simplifyGestureSamples } from "./utils/rdpSimplify";
import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
import { StudioPreviewArea } from "./components/StudioPreviewArea";
import { StudioRightPanel } from "./components/StudioRightPanel";
@@ -94,7 +98,6 @@ export function StudioApp() {
const captionEditMode = useCaptionStore((s) => s.isEditMode);
const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
const captionSync = useCaptionSync(projectId);
const currentTime = usePlayerStore((s) => s.currentTime);
const timelineElements = usePlayerStore((s) => s.elements);
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
const timelineDuration = usePlayerStore((s) => s.duration);
@@ -128,7 +131,7 @@ export function StudioApp() {
return !v;
});
}, []);
const { appToast, showToast } = useToast();
const { appToast, showToast, dismissToast } = useToast();
const panelLayout = usePanelLayout({
rightCollapsed: initialUrlStateRef.current.rightCollapsed,
rightPanelTab: initialUrlStateRef.current.rightPanelTab,
@@ -136,6 +139,7 @@ export function StudioApp() {
const editHistory = usePersistentEditHistory({ projectId });
const domEditSaveTimestampRef = useRef(0);
const pendingTimelineEditPathRef = useRef(new Set<string>());
const isGestureRecordingRef = useRef(false);
const reloadPreview = useCallback(() => {
setRefreshKey((k) => k + 1);
}, []);
@@ -185,6 +189,7 @@ export function StudioApp() {
previewIframeRef,
pendingTimelineEditPathRef,
uploadProjectFiles: fileManager.uploadProjectFiles,
isRecordingRef: isGestureRecordingRef,
});
const blockCtx = useMemo(
@@ -306,6 +311,7 @@ export function StudioApp() {
onResetKeyframes: () => resetKeyframesRef.current(),
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
onToggleRecording: () => handleToggleRecordingRef.current(),
});
const selectSidebarTabStable = useCallback(
(tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
@@ -325,7 +331,6 @@ export function StudioApp() {
compositionLoading,
previewIframeRef,
timelineElements,
currentTime,
setSelectedTimelineElementId,
setRightCollapsed: panelLayout.setRightCollapsed,
setRightPanelTab: panelLayout.setRightPanelTab,
@@ -334,6 +339,7 @@ export function StudioApp() {
queueDomEditSave: previewPersistence.queueDomEditSave,
readProjectFile: fileManager.readProjectFile,
writeProjectFile: fileManager.writeProjectFile,
updateEditingFileContent: fileManager.updateEditingFileContent,
domEditSaveTimestampRef,
editHistory: { recordEdit: editHistory.recordEdit },
fileTree: fileManager.fileTree,
@@ -399,6 +405,128 @@ export function StudioApp() {
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
// Gesture recording
const gestureRecording = useGestureRecording();
const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
// Synchronous mirror of gestureState — immune to React batching.
// Prevents double-R-press within a single render cycle from swallowing the stop.
const gestureStateRef = useRef<"idle" | "recording">("idle");
const recordingAutoStopRef = useRef<ReturnType<typeof setInterval>>(undefined);
const recordingStartTimeRef = useRef(0);
const commitInFlightRef = useRef(false);
const handleToggleRecordingRef = useRef<() => void>(() => {});
const domEditSessionRef = useRef(domEditSession);
domEditSessionRef.current = domEditSession;
// Unmount: clear auto-stop interval
useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
// fallow-ignore-next-line complexity
const stopAndCommitRecording = useCallback(async () => {
clearInterval(recordingAutoStopRef.current);
if (commitInFlightRef.current) return;
commitInFlightRef.current = true;
gestureStateRef.current = "idle";
isGestureRecordingRef.current = false;
const frozenSamples = gestureRecording.stopRecording();
const store = usePlayerStore.getState();
store.setIsPlaying(false);
try {
const liveSession = domEditSessionRef.current;
const sel = liveSession.domEditSelection;
if (!sel) {
if (frozenSamples.length > 2) {
showToast("Selection lost during recording", "error");
}
return;
}
const duration = frozenSamples.length > 0 ? frozenSamples[frozenSamples.length - 1]!.time : 0;
if (frozenSamples.length <= 2) {
showToast("No gesture detected — move the pointer while recording", "error");
return;
}
if (duration <= 0) {
showToast("Recording too short — try again", "error");
return;
}
const simplified = simplifyGestureSamples(frozenSamples, duration, 5);
const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b);
// Always create a new tween scoped to the recording range.
// Injecting into an existing tween creates keyframes before the recording
// start (from the convert-to-keyframes step), causing wrong positions.
const selector = sel.id ? `#${sel.id}` : sel.selector;
if (!selector) {
showToast("Cannot save — element has no selector", "error");
return;
}
if (liveSession.commitMutation) {
const recStart = recordingStartTimeRef.current;
const keyframes = sortedPcts.map((pct) => ({
percentage: pct,
properties: simplified.get(pct) as Record<string, number | string>,
}));
await liveSession.commitMutation(
{
type: "add-with-keyframes",
targetSelector: selector,
position: Math.round(recStart * 1000) / 1000,
duration: Math.round(duration * 1000) / 1000,
keyframes,
},
{ label: "Gesture recording", softReload: true },
);
}
showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
} finally {
store.requestSeek(recordingStartTimeRef.current);
gestureRecording.clearSamples();
setGestureState("idle");
commitInFlightRef.current = false;
}
}, [gestureRecording, showToast]);
const handleToggleRecording = useCallback(() => {
if (gestureStateRef.current === "recording") {
void stopAndCommitRecording();
return;
}
const sel = domEditSessionRef.current.domEditSelection;
if (!sel) {
showToast("Select an element first", "error");
return;
}
const iframe = previewIframeRef.current;
if (!iframe) {
showToast("Preview not ready — try again", "error");
return;
}
const store = usePlayerStore.getState();
recordingStartTimeRef.current = store.currentTime;
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0;
const elementEnd = elDur > 0 ? elStart + elDur : undefined;
gestureRecording.startRecording(sel.element, iframe, elementEnd);
gestureStateRef.current = "recording";
isGestureRecordingRef.current = true;
setGestureState("recording");
clearInterval(recordingAutoStopRef.current);
const autoStopAt = elementEnd ?? Infinity;
recordingAutoStopRef.current = setInterval(() => {
const { currentTime: t, duration: d } = usePlayerStore.getState();
const limit = Math.min(autoStopAt, d);
if (limit > 0 && t >= limit - 0.05) {
void stopAndCommitRecording();
}
}, 100);
}, [gestureRecording, showToast, stopAndCommitRecording]);
handleToggleRecordingRef.current = handleToggleRecording;
const handlePreviewIframeRef = useCallback(
(iframe: HTMLIFrameElement | null) => {
previewIframeRef.current = iframe;
@@ -434,12 +562,12 @@ export function StudioApp() {
panelLayout.rightCollapsed,
isPlaying,
domEditSession.domEditSelection,
gestureState === "recording",
);
useStudioUrlState({
projectId,
activeCompPath,
currentTime,
duration: effectiveTimelineDuration,
isPlaying,
compositionLoading,
@@ -465,7 +593,6 @@ export function StudioApp() {
compositionLoading,
refreshKey,
setRefreshKey,
currentTime,
timelineElements,
isPlaying,
editHistory,
@@ -513,6 +640,7 @@ export function StudioApp() {
refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
inspectorButtonActive={inspectorButtonActive}
inspectorPanelActive={inspectorPanelActive}
onExport={() => void renderQueue.startRender()}
/>
<div className="flex flex-1 min-h-0">
@@ -539,7 +667,23 @@ export function StudioApp() {
setCompIdToSrc={setCompIdToSrc}
setCompositionLoading={setCompositionLoading}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
isGestureRecording={gestureState === "recording"}
blockPreview={blockPreview}
gestureOverlay={
gestureState === "recording" && previewIframe ? (
<GestureTrailOverlay
samples={gestureRecording.samplesRef.current}
sampleCount={gestureRecording.samplesRef.current.length}
trail={gestureRecording.trailRef.current}
canvasRect={(() => {
const r = previewIframe.getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
})()}
compositionSize={compositionDimensions ?? undefined}
mode="recording"
/>
) : undefined
}
/>
{!panelLayout.rightCollapsed && (
@@ -552,6 +696,9 @@ export function StudioApp() {
setActiveBlockParams(null);
panelLayout.setRightPanelTab("design");
}}
recordingState={gestureState}
recordingDuration={gestureRecording.recordingDuration}
onToggleRecording={handleToggleRecording}
/>
)}
</div>
@@ -584,7 +731,13 @@ export function StudioApp() {
)}
{dragOverlay.active && <StudioGlobalDragOverlay />}
{appToast && <StudioToast message={appToast.message} tone={appToast.tone} />}
{appToast && (
<StudioToast
message={appToast.message}
tone={appToast.tone}
onDismiss={dismissToast}
/>
)}
</div>
</DomEditProvider>
</FileManagerProvider>
@@ -17,6 +17,7 @@ export interface StudioHeaderProps {
refreshCaptureFrameTime: () => void;
inspectorButtonActive: boolean;
inspectorPanelActive: boolean;
onExport?: () => void;
}
function HyperframesLogo() {
@@ -147,6 +148,7 @@ export function StudioHeader({
refreshCaptureFrameTime,
inspectorButtonActive,
inspectorPanelActive,
onExport,
}: StudioHeaderProps) {
const { projectId, editHistory, handleUndo, handleRedo } = useStudioContext();
const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
@@ -171,10 +173,10 @@ export function StudioHeader({
void handleUndo();
}}
disabled={!editHistory.canUndo}
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors ${
editHistory.canUndo
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
: "border-neutral-900 text-neutral-700"
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
: "text-neutral-700 cursor-default"
}`}
title={
editHistory.undoLabel
@@ -192,10 +194,10 @@ export function StudioHeader({
void handleRedo();
}}
disabled={!editHistory.canRedo}
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors ${
editHistory.canRedo
? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
: "border-neutral-900 text-neutral-700"
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
: "text-neutral-700 cursor-default"
}`}
title={
editHistory.redoLabel
@@ -215,7 +217,7 @@ export function StudioHeader({
}}
onFocus={refreshCaptureFrameTime}
onPointerDown={refreshCaptureFrameTime}
className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200 hover:bg-neutral-800"
title="Capture current frame"
aria-label="Capture current frame"
>
@@ -264,6 +266,17 @@ export function StudioHeader({
</svg>
Inspector
</button>
<button
type="button"
onClick={() => {
setRightPanelTab("renders");
setRightCollapsed(false);
onExport?.();
}}
className="h-7 flex items-center gap-1.5 px-3 rounded-md text-[11px] font-semibold bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors"
>
Export
</button>
</div>
</div>
);
@@ -56,6 +56,8 @@ export interface StudioPreviewAreaProps {
setCompositionLoading: (loading: boolean) => void;
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
isGestureRecording?: boolean;
gestureOverlay?: ReactNode;
}
// fallow-ignore-next-line complexity
@@ -74,7 +76,9 @@ export function StudioPreviewArea({
setCompIdToSrc,
setCompositionLoading,
shouldShowSelectedDomBounds,
isGestureRecording,
blockPreview,
gestureOverlay,
}: StudioPreviewAreaProps) {
const {
projectId,
@@ -241,7 +245,7 @@ export function StudioPreviewArea({
}
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !isGestureRecording}
onCanvasMouseDown={handlePreviewCanvasMouseDown}
onCanvasPointerMove={handlePreviewCanvasPointerMove}
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
@@ -256,6 +260,7 @@ export function StudioPreviewArea({
gridSpacing={snapPrefs.gridSpacing}
/>
<SnapToolbar onSnapChange={setSnapPrefs} />
{gestureOverlay}
</>
) : null
}
@@ -32,6 +32,9 @@ export interface StudioRightPanelProps {
compositionPath: string;
} | null;
onCloseBlockParams?: () => void;
recordingState?: "idle" | "recording" | "preview";
recordingDuration?: number;
onToggleRecording?: () => void;
}
// fallow-ignore-next-line complexity
@@ -41,6 +44,9 @@ export function StudioRightPanel({
motionPanelActive,
activeBlockParams,
onCloseBlockParams,
recordingState,
recordingDuration,
onToggleRecording,
}: StudioRightPanelProps) {
const {
rightWidth,
@@ -92,6 +98,8 @@ export function StudioRightPanel({
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
} = useDomEditContext();
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
@@ -226,6 +234,11 @@ export function StudioRightPanel({
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
onAddGsapAnimation={handleGsapAddAnimation}
onCommitAnimatedProperty={commitAnimatedProperty}
onSetArcPath={handleSetArcPath}
onUpdateArcSegment={handleUpdateArcSegment}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
/>
) : motionPanelActive ? (
<MotionPanel
+47 -7
View File
@@ -1,18 +1,58 @@
interface StudioToastProps {
message: string;
tone?: "error" | "info";
onDismiss?: () => void;
}
export function StudioToast({ message, tone }: StudioToastProps) {
export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
const isError = tone === "error";
return (
<div
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
tone === "error"
? "bg-red-900/90 border-red-700/50 text-red-200"
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
}`}
className="absolute bottom-6 right-6 z-[91] animate-in fade-in slide-in-from-bottom-2"
onClick={onDismiss}
role={onDismiss ? "button" : undefined}
style={onDismiss ? { cursor: "pointer" } : undefined}
>
{message}
<div
className="relative flex items-center gap-3 overflow-hidden rounded-2xl pl-4 pr-2 py-3 text-[12px]"
style={{
background: isError
? "linear-gradient(135deg, rgba(127,29,29,0.55), rgba(80,10,10,0.45))"
: "linear-gradient(135deg, rgba(38,38,38,0.55), rgba(23,23,23,0.45))",
backdropFilter: "blur(16px) saturate(1.6)",
WebkitBackdropFilter: "blur(16px) saturate(1.6)",
border: `1px solid ${isError ? "rgba(239,68,68,0.18)" : "rgba(255,255,255,0.08)"}`,
boxShadow: [
"0 8px 32px rgba(0,0,0,0.35)",
`inset 0 1px 0 ${isError ? "rgba(239,68,68,0.12)" : "rgba(255,255,255,0.06)"}`,
`inset 0 -1px 0 rgba(0,0,0,0.15)`,
].join(", "),
}}
>
<span className={isError ? "text-red-200" : "text-neutral-200"}>{message}</span>
{onDismiss && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-300"
aria-label="Dismiss"
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M2 2l6 6M8 2l-6 6" />
</svg>
</button>
)}
</div>
</div>
);
}
@@ -1,3 +1,5 @@
import { useRef } from "react";
import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes";
import {
getNextTimelineZoomPercent,
getTimelineZoomPercent,
@@ -7,88 +9,12 @@ import { usePlayerStore, type TimelineElement } from "../player";
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
import { Tooltip } from "./ui";
import { Scissors } from "../icons/SystemIcons";
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
function interpolateKeyframeProperties(
keyframes: GsapPercentageKeyframe[],
pct: number,
): Record<string, number> {
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
const allProps = new Set<string>();
for (const kf of sorted) {
for (const p of Object.keys(kf.properties)) {
if (typeof kf.properties[p] === "number") allProps.add(p);
}
}
const result: Record<string, number> = {};
for (const prop of allProps) {
let prev: { pct: number; val: number } | null = null;
let next: { pct: number; val: number } | null = null;
for (const kf of sorted) {
const v = kf.properties[prop];
if (typeof v !== "number") continue;
if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v };
if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v };
}
if (prev && next && prev.pct !== next.pct) {
const t = (pct - prev.pct) / (next.pct - prev.pct);
result[prop] = Math.round(prev.val + t * (next.val - prev.val));
} else if (prev) {
result[prop] = Math.round(prev.val);
} else if (next) {
result[prop] = Math.round(next.val);
}
}
return result;
}
function readRuntimeKeyframeValues(
iframe: HTMLIFrameElement | null,
sel: DomEditSelection,
keyframes: GsapPercentageKeyframe[],
): Record<string, number> {
if (!iframe?.contentWindow) return {};
let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
try {
gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
} catch {
return {};
}
if (!gsap?.getProperty) return {};
const selector = sel.id ? `#${sel.id}` : sel.selector;
if (!selector) return {};
let doc: Document | null = null;
try {
doc = iframe.contentDocument;
} catch {
return {};
}
const element = doc?.querySelector(selector);
if (!element) return {};
const allProps = new Set<string>();
for (const kf of keyframes) {
for (const p of Object.keys(kf.properties)) {
if (typeof kf.properties[p] === "number") allProps.add(p);
}
}
const result: Record<string, number> = {};
for (const prop of allProps) {
const val = Number(gsap.getProperty(element, prop));
if (Number.isFinite(val)) result[prop] = Math.round(val);
}
return result;
}
interface DomEditSessionSlice {
interface DomEditSessionSlice extends EnableKeyframesSession {
domEditSelection: DomEditSelection | null;
selectedGsapAnimations: GsapAnimation[];
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
handleGsapConvertToKeyframes: (animId: string) => void;
handleGsapMaterializeKeyframes?: (animId: string) => Promise<void>;
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
}
interface TimelineToolbarProps {
@@ -97,15 +23,20 @@ interface TimelineToolbarProps {
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
}
// fallow-ignore-next-line complexity
function useKeyframeToggle(session?: DomEditSessionSlice) {
const currentTime = usePlayerStore((s) => s.currentTime);
const sessionRef = useRef(session);
sessionRef.current = session;
const onToggle = useEnableKeyframes(
sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
);
if (!session) return { state: "none" as const, onToggle: undefined };
const sel = session.domEditSelection;
const anims = session.selectedGsapAnimations;
const kfAnim = anims.find((a) => a.keyframes);
const flatAnim = anims.find((a) => !a.keyframes);
let state: "active" | "inactive" | "none" = "none";
if (kfAnim?.keyframes && sel) {
@@ -120,48 +51,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
: "inactive";
}
// fallow-ignore-next-line complexity
const onToggle = sel
? async () => {
const t = usePlayerStore.getState().currentTime;
if (kfAnim?.keyframes) {
if (kfAnim.hasUnresolvedKeyframes) {
await session.handleGsapMaterializeKeyframes?.(kfAnim.id);
}
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
const pct =
elDuration > 0
? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
: 0;
const existing = kfAnim.keyframes.keyframes.find(
(k) => Math.abs(k.percentage - pct) <= 1,
);
if (existing) {
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
} else {
const runtimeValues = readRuntimeKeyframeValues(
session.previewIframeRef?.current ?? null,
sel,
kfAnim.keyframes.keyframes,
);
const values =
Object.keys(runtimeValues).length > 0
? runtimeValues
: interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct);
for (const [prop, val] of Object.entries(values)) {
session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val);
}
}
} else if (flatAnim) {
session.handleGsapConvertToKeyframes(flatAnim.id);
} else {
session.handleGsapAddAnimation("to");
}
}
: undefined;
return { state, onToggle };
return { state, onToggle: sel ? onToggle : undefined };
}
export function TimelineToolbar({
@@ -17,6 +17,9 @@ import {
} from "./gsapAnimationConstants";
import { buildTweenSummary } from "./gsapAnimationHelpers";
import { EaseCurveSection } from "./EaseCurveSection";
import { ArcPathControls } from "./ArcPathControls";
import type { ArcPathSegment } from "@hyperframes/core/gsap-parser";
import { P } from "./panelTokens";
const BOOLEAN_PROPS = new Set(["visibility"]);
const STRING_PROPS = new Set(["filter", "clipPath"]);
@@ -97,11 +100,18 @@ function PropertyRow({
<button
type="button"
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
className={`flex-shrink-0 w-7 h-4 rounded-full transition-colors relative ${isVisible ? "bg-emerald-500/30" : "bg-neutral-700"}`}
className={`flex-shrink-0 rounded-full transition-all duration-150 relative`}
style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full transition-transform ${isVisible ? "bg-emerald-400 translate-x-3.5" : "bg-neutral-500 translate-x-0.5"}`}
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
style={{
width: 12,
height: 12,
background: isVisible ? P.white : P.textMuted,
transform: isVisible ? "translateX(14px)" : "translateX(2px)",
}}
/>
</button>
</div>
@@ -241,6 +251,15 @@ interface AnimationCardProps {
onRemoveFromProperty?: (animationId: string, property: string) => void;
onLivePreview?: (property: string, value: number | string) => void;
onLivePreviewEnd?: () => void;
onSetArcPath?: (
animationId: string,
config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
) => void;
onUpdateArcSegment?: (
animationId: string,
segmentIndex: number,
update: Partial<ArcPathSegment>,
) => void;
}
// fallow-ignore-next-line complexity
@@ -257,6 +276,8 @@ export const AnimationCard = memo(function AnimationCard({
onRemoveFromProperty,
onLivePreview,
onLivePreviewEnd,
onSetArcPath,
onUpdateArcSegment,
}: AnimationCardProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [addingProp, setAddingProp] = useState(false);
@@ -329,7 +350,7 @@ export const AnimationCard = memo(function AnimationCard({
const [copied, setCopied] = useState(false);
const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
const easeName = animation.ease ?? "none";
const easeName = animation.ease ?? animation.keyframes?.easeEach ?? "none";
const easeLabel = easeName.startsWith("custom(")
? "Custom curve"
: (EASE_LABELS[easeName] ?? easeName);
@@ -348,7 +369,7 @@ export const AnimationCard = memo(function AnimationCard({
className="flex w-full items-center gap-2 py-1.5"
>
<span
className="rounded bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-400"
className="rounded bg-panel-accent/10 px-1.5 py-0.5 text-[10px] font-semibold text-panel-accent"
title={METHOD_TOOLTIPS[animation.method]}
>
{methodLabel}
@@ -420,13 +441,13 @@ export const AnimationCard = memo(function AnimationCard({
<>
<SelectField
label="Speed"
value={
animation.ease?.startsWith("custom(") ? "custom" : (animation.ease ?? "none")
}
value={easeName.startsWith("custom(") ? "custom" : easeName}
options={[...SUPPORTED_EASES, "custom"]}
onChange={(next) => {
if (next === "custom") {
const points = controlPointsForGsapEase(animation.ease ?? "power2.out");
const points = controlPointsForGsapEase(
easeName !== "none" ? easeName : "power2.out",
);
const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
onUpdateMeta(animation.id, { ease: `custom(${path})` });
} else {
@@ -435,7 +456,7 @@ export const AnimationCard = memo(function AnimationCard({
}}
/>
<EaseCurveSection
ease={animation.ease ?? "none"}
ease={easeName}
duration={animation.duration}
onCustomEaseCommit={(customEase) =>
onUpdateMeta(animation.id, { ease: customEase })
@@ -477,7 +498,7 @@ export const AnimationCard = memo(function AnimationCard({
)}
{animation.method === "fromTo" && Object.keys(animation.properties).length > 0 && (
<p className="text-[9px] font-semibold uppercase tracking-wider text-emerald-400/70">
<p className="text-[9px] font-semibold uppercase tracking-wider text-panel-accent/70">
To
</p>
)}
@@ -500,6 +521,39 @@ export const AnimationCard = memo(function AnimationCard({
</div>
)}
{onSetArcPath &&
(animation.properties.x != null ||
animation.properties.y != null ||
animation.keyframes) && (
<div className="border-t border-neutral-800 pt-3">
<ArcPathControls
arcPath={
animation.arcPath ?? { enabled: false, autoRotate: false, segments: [] }
}
segmentCount={Math.max(
animation.properties.x != null || animation.properties.y != null ? 1 : 0,
(animation.keyframes?.keyframes?.length ?? 0) - 1,
)}
onToggle={(enabled) =>
onSetArcPath(animation.id, {
enabled,
segments: animation.arcPath?.segments,
})
}
onUpdateSegment={(index, update) =>
onUpdateArcSegment?.(animation.id, index, update)
}
onToggleAutoRotate={(autoRotate) =>
onSetArcPath(animation.id, {
enabled: true,
autoRotate,
segments: animation.arcPath?.segments,
})
}
/>
</div>
)}
<div className="flex items-center gap-2 pt-1">
<AddPropertyTrigger
adding={addingProp}
@@ -0,0 +1,131 @@
import { memo, useCallback } from "react";
import type { ArcPathConfig, ArcPathSegment } from "@hyperframes/core/gsap-parser";
import { SliderControl } from "./propertyPanelPrimitives";
import { LABEL } from "./propertyPanelHelpers";
import { P } from "./panelTokens";
interface ArcPathControlsProps {
arcPath: ArcPathConfig;
segmentCount: number;
onToggle: (enabled: boolean) => void;
onUpdateSegment: (index: number, update: Partial<ArcPathSegment>) => void;
onToggleAutoRotate: (autoRotate: boolean) => void;
disabled?: boolean;
}
export const ArcPathControls = memo(function ArcPathControls({
arcPath,
segmentCount,
onToggle,
onUpdateSegment,
onToggleAutoRotate,
disabled,
}: ArcPathControlsProps) {
const handleToggle = useCallback(() => {
onToggle(!arcPath.enabled);
}, [arcPath.enabled, onToggle]);
const handleAutoRotate = useCallback(() => {
onToggleAutoRotate(!arcPath.autoRotate);
}, [arcPath.autoRotate, onToggleAutoRotate]);
if (segmentCount < 1) {
return (
<div className="rounded-md border border-neutral-800 bg-neutral-900/50 px-3 py-2">
<p className="text-[11px] text-neutral-500">
Add at least 2 position keyframes to enable arc motion.
</p>
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className={LABEL}>Arc Motion</span>
<button
type="button"
onClick={handleToggle}
disabled={disabled}
className="relative rounded-full transition-all duration-150"
style={{ width: 28, height: 16, background: arcPath.enabled ? P.accent : P.borderInput }}
title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
>
<span
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
style={{
width: 12,
height: 12,
background: arcPath.enabled ? P.white : P.textMuted,
transform: arcPath.enabled ? "translateX(14px)" : "translateX(2px)",
}}
/>
</button>
</div>
{arcPath.enabled && (
<>
<div className="flex items-center justify-between">
<span className={LABEL}>Auto-Rotate</span>
<button
type="button"
onClick={handleAutoRotate}
disabled={disabled}
className="relative rounded-full transition-all duration-150"
style={{
width: 28,
height: 16,
background: arcPath.autoRotate ? P.accent : "#27272A",
}}
title={
arcPath.autoRotate
? "Disable auto-rotate along path"
: "Rotate element to follow path tangent"
}
>
<span
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
style={{
width: 12,
height: 12,
background: arcPath.autoRotate ? P.white : P.textMuted,
transform: arcPath.autoRotate ? "translateX(14px)" : "translateX(2px)",
}}
/>
</button>
</div>
{arcPath.segments.map((seg, i) => (
<div key={i} className="grid min-w-0 gap-1.5">
<div className="flex items-center justify-between">
<span className={LABEL}>
{segmentCount === 1 ? "Curviness" : `Segment ${i + 1}`}
</span>
{seg.cp1 && seg.cp2 && (
<button
type="button"
onClick={() => onUpdateSegment(i, { cp1: undefined, cp2: undefined })}
className="text-[9px] font-medium text-neutral-500 transition-colors hover:text-neutral-300"
title="Reset to auto-generated control points"
>
Reset
</button>
)}
</div>
<SliderControl
value={seg.curviness}
min={0}
max={3}
step={0.1}
disabled={disabled}
displayValue={seg.curviness.toFixed(1)}
formatDisplayValue={(v) => v.toFixed(1)}
onCommit={(v) => onUpdateSegment(i, { curviness: v })}
/>
</div>
))}
</>
)}
</div>
);
});
@@ -0,0 +1,209 @@
import { useCallback, useState } from "react";
import { MetricField } from "./propertyPanelPrimitives";
import { formatNumericValue, parseNumericValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
type Corner = "tl" | "tr" | "br" | "bl";
interface BorderRadiusEditorProps {
tl: number;
tr: number;
br: number;
bl: number;
disabled?: boolean;
onCommit: (corner: Corner | "all", value: number) => void;
}
const PREVIEW_W = 72;
const PREVIEW_H = 52;
const MAX_RADIUS = 26;
function clampRadius(v: number): number {
return Math.max(0, Math.min(MAX_RADIUS, v));
}
function scaleRadius(v: number, maxPx: number): number {
if (maxPx <= 0) return 0;
return clampRadius(Math.round((v / Math.max(maxPx, 1)) * MAX_RADIUS));
}
export function BorderRadiusEditor({
tl,
tr,
br,
bl,
disabled,
onCommit,
}: BorderRadiusEditorProps) {
const uniform = tl === tr && tr === br && br === bl;
const [linked, setLinked] = useState(uniform);
const maxVal = Math.max(tl, tr, br, bl, 1);
const sTL = scaleRadius(tl, maxVal);
const sTR = scaleRadius(tr, maxVal);
const sBR = scaleRadius(br, maxVal);
const sBL = scaleRadius(bl, maxVal);
const handleCornerCommit = useCallback(
(corner: Corner, raw: string) => {
const v = parseNumericValue(raw) ?? 0;
if (linked) {
onCommit("all", v);
} else {
onCommit(corner, v);
}
},
[linked, onCommit],
);
const handleToggleLinked = useCallback(() => {
if (!linked && !uniform) {
onCommit("all", tl);
}
setLinked((l) => !l);
}, [linked, uniform, tl, onCommit]);
const path = buildRoundedRectPath(PREVIEW_W, PREVIEW_H, sTL, sTR, sBR, sBL);
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<svg
width={PREVIEW_W}
height={PREVIEW_H}
viewBox={`0 0 ${PREVIEW_W} ${PREVIEW_H}`}
className="flex-shrink-0"
>
<path
d={path}
fill="rgba(255,255,255,0.06)"
stroke="rgba(255,255,255,0.24)"
strokeWidth={1.5}
/>
<circle
cx={sTL}
cy={sTL}
r={3}
fill={linked ? "#3b82f6" : "#a78bfa"}
className="cursor-pointer"
/>
<circle
cx={PREVIEW_W - sTR}
cy={sTR}
r={3}
fill={linked ? "#3b82f6" : "#a78bfa"}
className="cursor-pointer"
/>
<circle
cx={PREVIEW_W - sBR}
cy={PREVIEW_H - sBR}
r={3}
fill={linked ? "#3b82f6" : "#a78bfa"}
className="cursor-pointer"
/>
<circle
cx={sBL}
cy={PREVIEW_H - sBL}
r={3}
fill={linked ? "#3b82f6" : "#a78bfa"}
className="cursor-pointer"
/>
</svg>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
onClick={handleToggleLinked}
disabled={disabled}
title={linked ? "Unlink corners" : "Link all corners"}
>
{linked ? (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
>
<path d="M6 12H4a4 4 0 010-8h2M10 4h2a4 4 0 010 8h-2M5 8h6" />
</svg>
) : (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
>
<path d="M6 12H4a4 4 0 010-8h2M10 4h2a4 4 0 010 8h-2" />
</svg>
)}
</button>
</div>
{linked ? (
<MetricField
label="All"
value={formatNumericValue(tl)}
disabled={disabled}
liveCommit
onCommit={(next) => handleCornerCommit("tl", next)}
/>
) : (
<div className={RESPONSIVE_GRID}>
<MetricField
label="TL"
value={formatNumericValue(tl)}
disabled={disabled}
liveCommit
onCommit={(next) => handleCornerCommit("tl", next)}
/>
<MetricField
label="TR"
value={formatNumericValue(tr)}
disabled={disabled}
liveCommit
onCommit={(next) => handleCornerCommit("tr", next)}
/>
<MetricField
label="BL"
value={formatNumericValue(bl)}
disabled={disabled}
liveCommit
onCommit={(next) => handleCornerCommit("bl", next)}
/>
<MetricField
label="BR"
value={formatNumericValue(br)}
disabled={disabled}
liveCommit
onCommit={(next) => handleCornerCommit("br", next)}
/>
</div>
)}
</div>
);
}
function buildRoundedRectPath(
w: number,
h: number,
tl: number,
tr: number,
br: number,
bl: number,
): string {
return [
`M ${tl} 0`,
`L ${w - tr} 0`,
`Q ${w} 0 ${w} ${tr}`,
`L ${w} ${h - br}`,
`Q ${w} ${h} ${w - br} ${h}`,
`L ${bl} ${h}`,
`Q 0 ${h} 0 ${h - bl}`,
`L 0 ${tl}`,
`Q 0 0 ${tl} 0`,
"Z",
].join(" ");
}
@@ -90,6 +90,29 @@ export const DomEditOverlay = memo(function DomEditOverlay({
}: DomEditOverlayProps) {
const overlayRef = useRef<HTMLDivElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
const selectionShapeStyles = (() => {
const fallback = {
borderRadius: 4 as string | number,
clipPath: undefined as string | undefined,
};
if (!selection?.element) return fallback;
try {
const tag = selection.element.tagName.toLowerCase();
if (tag === "svg" || tag === "img" || tag === "video" || tag === "canvas") return fallback;
const win = selection.element.ownerDocument.defaultView;
if (!win) return fallback;
const cs = win.getComputedStyle(selection.element);
const br = cs.borderRadius;
const cp = cs.clipPath;
return {
borderRadius: br && br !== "0px" ? br : 4,
clipPath: cp && cp !== "none" ? cp : undefined,
};
} catch {
return fallback;
}
})();
const gestureRef = useRef<GestureState | null>(null);
const groupGestureRef = useRef<GroupGestureState | null>(null);
const blockedMoveRef = useRef<BlockedMoveState | null>(null);
@@ -134,6 +157,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
groupOverlayItems,
groupOverlayItemsRef,
setGroupOverlayItems,
childRects,
} = useDomEditOverlayRects({
iframeRef,
overlayRef,
@@ -228,6 +252,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
groupOverlayItems.every((item) => item.selection.capabilities.canApplyManualOffset);
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (!allowCanvasMovement) return;
if (suppressNextOverlayMouseDownRef.current) {
suppressNextOverlayMouseDownRef.current = false;
suppressNextBoxMouseDownRef.current = false;
@@ -288,6 +313,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
};
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (!allowCanvasMovement) return;
if (gestureRef.current || groupGestureRef.current) return;
if (suppressNextBoxClickRef.current) {
suppressNextBoxClickRef.current = false;
@@ -320,20 +346,37 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onPointerUp={gestures.onPointerUp}
onPointerCancel={() => gestures.clearPointerState(selectionRef)}
>
{hoverSelection && hoverRect && (
{hoverSelection && hoverRect && compRect.width > 0 && (
<div
aria-hidden="true"
data-dom-edit-hover-box="true"
className="pointer-events-none absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
style={{
left: hoverRect.left,
top: hoverRect.top,
width: hoverRect.width,
height: hoverRect.height,
}}
className="pointer-events-none absolute border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
style={(() => {
let br: string | number = 4;
let cp: string | undefined;
try {
const el = hoverSelection.element;
const tag = el.tagName.toLowerCase();
if (tag !== "svg" && tag !== "img" && tag !== "video" && tag !== "canvas") {
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
if (cs?.borderRadius && cs.borderRadius !== "0px") br = cs.borderRadius;
if (cs?.clipPath && cs.clipPath !== "none") cp = cs.clipPath;
}
} catch {
/* cross-origin guard */
}
return {
left: hoverRect.left,
top: hoverRect.top,
width: hoverRect.width,
height: hoverRect.height,
borderRadius: br,
clipPath: cp,
};
})()}
/>
)}
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && (
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
<>
{groupOverlayItems.map((item) => (
<div
@@ -367,7 +410,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
/>
</>
)}
{!hasGroupSelection && selection && overlayRect && (
{!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
<>
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
<div
@@ -398,12 +441,14 @@ export const DomEditOverlay = memo(function DomEditOverlay({
key={selectionKey}
ref={boxRef}
data-dom-edit-selection-box="true"
className="pointer-events-auto absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
className={`pointer-events-auto absolute ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
style={{
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width,
height: overlayRect.height,
borderRadius: selectionShapeStyles.borderRadius,
clipPath: selectionShapeStyles.clipPath,
cursor:
allowCanvasMovement && selection.capabilities.canApplyManualOffset
? "move"
@@ -441,6 +486,20 @@ export const DomEditOverlay = memo(function DomEditOverlay({
</div>
</>
)}
{childRects.length > 0 &&
compRect.width > 0 &&
childRects.map((cr, i) => (
<div
key={i}
className="pointer-events-none absolute border border-dashed border-white/20 rounded-sm"
style={{
left: cr.left,
top: cr.top,
width: cr.width,
height: cr.height,
}}
/>
))}
<GridOverlay
visible={gridVisible}
spacing={gridSpacing}
@@ -0,0 +1,141 @@
import { memo, useCallback, useRef } from "react";
interface DopesheetKeyframe {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}
interface DopesheetStripProps {
keyframes: DopesheetKeyframe[];
selectedPercentage: number | null;
currentPercentage: number;
accentColor?: string;
onSelectKeyframe: (percentage: number) => void;
onDragKeyframe?: (fromPct: number, toPct: number) => void;
}
const DIAMOND_SIZE = 8;
const HALF = DIAMOND_SIZE / 2;
const STRIP_HEIGHT = 20;
const PADDING_X = 8;
export const DopesheetStrip = memo(function DopesheetStrip({
keyframes,
selectedPercentage,
currentPercentage,
accentColor = "#3CE6AC",
onSelectKeyframe,
onDragKeyframe,
}: DopesheetStripProps) {
const containerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{ startX: number; startPct: number } | null>(null);
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
const handlePointerDown = useCallback(
(e: React.PointerEvent, pct: number) => {
if (e.button !== 0) return;
e.stopPropagation();
const startX = e.clientX;
const handleMove = (me: PointerEvent) => {
if (Math.abs(me.clientX - startX) > 4) {
dragRef.current = { startX, startPct: pct };
}
};
const handleUp = (ue: PointerEvent) => {
document.removeEventListener("pointermove", handleMove);
document.removeEventListener("pointerup", handleUp);
if (dragRef.current && containerRef.current && onDragKeyframe) {
const rect = containerRef.current.getBoundingClientRect();
const usableWidth = rect.width - PADDING_X * 2;
const dx = ue.clientX - dragRef.current.startX;
const dpct = (dx / usableWidth) * 100;
const newPct = Math.max(0, Math.min(100, Math.round((pct + dpct) * 10) / 10));
if (newPct !== pct) onDragKeyframe(pct, newPct);
} else {
onSelectKeyframe(pct);
}
dragRef.current = null;
};
document.addEventListener("pointermove", handleMove);
document.addEventListener("pointerup", handleUp);
},
[onSelectKeyframe, onDragKeyframe],
);
return (
<div
ref={containerRef}
className="relative w-full rounded-md bg-neutral-900/60 border border-neutral-800/50"
style={{ height: STRIP_HEIGHT }}
>
{/* Playhead indicator */}
<div
className="absolute top-0 bottom-0 w-px bg-white/30"
style={{
left: `${PADDING_X + (currentPercentage / 100) * (100 - PADDING_X * 2)}%`,
marginLeft: -0.5,
}}
/>
{/* Diamond markers */}
<svg
className="absolute inset-0 w-full"
style={{ height: STRIP_HEIGHT }}
viewBox={`0 0 100 ${STRIP_HEIGHT}`}
preserveAspectRatio="none"
>
{sorted.map((kf) => {
const x = PADDING_X + (kf.percentage / 100) * (100 - PADDING_X * 2);
const y = STRIP_HEIGHT / 2;
const isSelected =
selectedPercentage !== null && Math.abs(kf.percentage - selectedPercentage) < 0.5;
const isHold = kf.ease === "steps(1)";
const fillColor = isSelected ? accentColor : "#737373";
return (
<g
key={kf.percentage}
onPointerDown={(e) => handlePointerDown(e, kf.percentage)}
style={{ cursor: "pointer" }}
>
{isHold ? (
<rect
x={x - HALF}
y={y - HALF}
width={DIAMOND_SIZE}
height={DIAMOND_SIZE}
fill={fillColor}
/>
) : (
<rect
x={x - HALF}
y={y - HALF}
width={DIAMOND_SIZE}
height={DIAMOND_SIZE}
fill={fillColor}
transform={`rotate(45, ${x}, ${y})`}
/>
)}
</g>
);
})}
</svg>
{/* Time labels */}
{sorted.length > 0 && (
<div
className="absolute bottom-0 left-0 right-0 flex justify-between px-2 text-[8px] text-neutral-600 pointer-events-none"
style={{ lineHeight: "10px" }}
>
<span>{sorted[0].percentage}%</span>
{sorted.length > 1 && <span>{sorted[sorted.length - 1].percentage}%</span>}
</div>
)}
</div>
);
});
@@ -1,6 +1,80 @@
import { useCallback, useRef, useState } from "react";
import { memo, useCallback, useRef, useState } from "react";
import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
const PRESET_GRID_EASES = [
"none",
"power2.out",
"power2.in",
"power2.inOut",
"power3.out",
"back.out",
"expo.out",
"elastic.out",
] as const;
function MiniCurveSvg({
curve,
active,
}: {
curve: [number, number, number, number];
active: boolean;
}) {
const [x1, y1, x2, y2] = curve;
const s = 24;
const p = 3;
const g = s - p * 2;
const sx = (px: number) => p + g * px;
const sy = (py: number) => s - p - g * py;
const d = `M${p},${s - p} C${sx(x1)},${sy(y1)} ${sx(x2)},${sy(y2)} ${s - p},${p}`;
return (
<svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
<path
d={d}
fill="none"
stroke={active ? "#3CE6AC" : "#737373"}
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
const EasePresetGrid = memo(function EasePresetGrid({
currentEase,
onSelect,
}: {
currentEase: string;
onSelect: (ease: string) => void;
}) {
return (
<div className="grid grid-cols-4 gap-1 mb-2">
{PRESET_GRID_EASES.map((name) => {
const curve = EASE_CURVES[name];
if (!curve) return null;
const isActive = currentEase === name;
return (
<button
key={name}
type="button"
onClick={() => onSelect(name)}
className={`flex flex-col items-center gap-0.5 rounded-md p-1 transition-colors ${
isActive ? "bg-panel-accent/10 ring-1 ring-panel-accent/30" : "hover:bg-neutral-800"
}`}
title={EASE_LABELS[name] ?? name}
>
<MiniCurveSvg curve={curve} active={isActive} />
<span
className={`text-[8px] leading-none ${isActive ? "text-panel-accent" : "text-neutral-500"}`}
>
{(EASE_LABELS[name] ?? name).split(" ").slice(0, 2).join(" ")}
</span>
</button>
);
})}
</div>
);
});
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
@@ -108,12 +182,13 @@ export function EaseCurveSection({
return (
<div className="rounded-lg bg-neutral-900/50 p-2">
<EasePresetGrid currentEase={ease} onSelect={(name) => onCustomEaseCommit(name)} />
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[10px] font-medium text-neutral-500">Speed curve</span>
<button
type="button"
onClick={play}
className="rounded px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 transition-colors hover:bg-emerald-500/10"
className="rounded px-1.5 py-0.5 text-[10px] font-medium text-panel-accent transition-colors hover:bg-panel-accent/10"
>
{progress !== null ? "Playing…" : "Preview"}
</button>
@@ -165,17 +240,17 @@ export function EaseCurveSection({
y1={end.y}
x2={p2.x}
y2={p2.y}
stroke="rgba(52,211,153,0.25)"
stroke="rgba(45,212,191,0.25)"
strokeWidth="1"
/>
<path d={curvePath} fill="none" stroke="#34d399" strokeWidth="2" strokeLinecap="round" />
{progress !== null && <circle cx={dotX} cy={dotY} r="4" fill="#34d399" />}
<path d={curvePath} fill="none" stroke="#3CE6AC" strokeWidth="2" strokeLinecap="round" />
{progress !== null && <circle cx={dotX} cy={dotY} r="4" fill="#3CE6AC" />}
<circle
cx={p1.x}
cy={p1.y}
r="5"
fill="#0a0a1a"
stroke="#34d399"
stroke="#3CE6AC"
strokeWidth="2"
className="cursor-grab active:cursor-grabbing"
onPointerDown={(e) => handlePointerDown("p1", e)}
@@ -185,7 +260,7 @@ export function EaseCurveSection({
cy={p2.y}
r="5"
fill="#0a0a1a"
stroke="#34d399"
stroke="#3CE6AC"
strokeWidth="2"
className="cursor-grab active:cursor-grabbing"
onPointerDown={(e) => handlePointerDown("p2", e)}
@@ -0,0 +1,132 @@
import { memo, useMemo } from "react";
import type { GestureSample } from "../../hooks/useGestureRecording";
interface GestureTrailOverlayProps {
samples: GestureSample[];
sampleCount?: number;
trail?: Array<{ x: number; y: number }>;
simplifiedPoints?: Map<number, Record<string, number>>;
canvasRect: { left: number; top: number; width: number; height: number };
compositionSize?: { width: number; height: number };
mode: "recording" | "preview";
accentColor?: string;
}
export const GestureTrailOverlay = memo(function GestureTrailOverlay({
samples,
sampleCount,
trail,
simplifiedPoints,
canvasRect,
compositionSize,
mode,
accentColor = "#3CE6AC",
}: GestureTrailOverlayProps) {
const trailPoints = useMemo(() => {
if (trail && trail.length > 1) {
return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" ");
}
if (samples.length === 0) return "";
return samples
.filter((s) => s.properties.x != null && s.properties.y != null)
.map((s) => `${s.properties.x},${s.properties.y}`)
.join(" ");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [samples, trail, sampleCount, canvasRect.left, canvasRect.top]);
const simplifiedPath = useMemo(() => {
if (!simplifiedPoints || simplifiedPoints.size === 0) return "";
const pts: Array<{ x: number; y: number; pct: number }> = [];
for (const [pct, props] of simplifiedPoints) {
if (props.x != null && props.y != null) {
pts.push({ x: props.x, y: props.y, pct });
}
}
pts.sort((a, b) => a.pct - b.pct);
if (pts.length === 0) return "";
return pts.map((p) => `${p.x},${p.y}`).join(" ");
}, [simplifiedPoints]);
const diamondPositions = useMemo(() => {
if (!simplifiedPoints || simplifiedPoints.size === 0) return [];
const pts: Array<{ x: number; y: number; pct: number }> = [];
for (const [pct, props] of simplifiedPoints) {
if (props.x != null && props.y != null) {
pts.push({ x: props.x, y: props.y, pct });
}
}
return pts.sort((a, b) => a.pct - b.pct);
}, [simplifiedPoints]);
if (samples.length < 2 && !simplifiedPoints) return null;
return (
<svg
className="pointer-events-none fixed z-50"
style={{
left: canvasRect.left,
top: canvasRect.top,
width: canvasRect.width,
height: canvasRect.height,
}}
viewBox={
trail && trail.length > 1
? `0 0 ${canvasRect.width} ${canvasRect.height}`
: `0 0 ${compositionSize?.width ?? canvasRect.width} ${compositionSize?.height ?? canvasRect.height}`
}
>
{mode === "recording" && trailPoints && (
<polyline
points={trailPoints}
fill="none"
stroke={accentColor}
strokeWidth="2"
strokeOpacity="0.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{mode === "preview" && (
<>
{trailPoints && (
<polyline
points={trailPoints}
fill="none"
stroke={accentColor}
strokeWidth="1"
strokeOpacity="0.2"
strokeDasharray="4 3"
strokeLinecap="round"
/>
)}
{simplifiedPath && (
<polyline
points={simplifiedPath}
fill="none"
stroke={accentColor}
strokeWidth="2"
strokeOpacity="0.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{diamondPositions.map((pt) => (
<g key={pt.pct} transform={`translate(${pt.x}, ${pt.y})`}>
<rect
x="-4"
y="-4"
width="8"
height="8"
rx="1"
transform="rotate(45)"
fill={accentColor}
fillOpacity="0.9"
/>
</g>
))}
</>
)}
</svg>
);
});
@@ -1,5 +1,5 @@
import { memo, useState } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { ArcPathSegment, GsapAnimation } from "@hyperframes/core/gsap-parser";
import { Film } from "../../icons/SystemIcons";
import { Section } from "./propertyPanelPrimitives";
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
@@ -23,6 +23,15 @@ interface GsapAnimationSectionProps {
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
onLivePreview?: (property: string, value: number | string) => void;
onLivePreviewEnd?: () => void;
onSetArcPath?: (
animationId: string,
config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
) => void;
onUpdateArcSegment?: (
animationId: string,
segmentIndex: number,
update: Partial<ArcPathSegment>,
) => void;
}
export const GsapAnimationSection = memo(function GsapAnimationSection({
@@ -40,6 +49,8 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
onAddAnimation,
onLivePreview,
onLivePreviewEnd,
onSetArcPath,
onUpdateArcSegment,
}: GsapAnimationSectionProps) {
const [addMenuOpen, setAddMenuOpen] = useState(false);
@@ -75,6 +86,8 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
onRemoveFromProperty={onRemoveFromProperty}
onLivePreview={onLivePreview}
onLivePreviewEnd={onLivePreviewEnd}
onSetArcPath={onSetArcPath}
onUpdateArcSegment={onUpdateArcSegment}
/>
))}
@@ -7,6 +7,7 @@ interface KeyframeDiamondProps {
onClick: () => void;
title?: string;
size?: number;
isHold?: boolean;
}
// fallow-ignore-next-line complexity
@@ -15,10 +16,11 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
onClick,
title,
size = 10,
isHold = false,
}: KeyframeDiamondProps) {
const isFilled = state === "active";
const opacity = state === "ghost" ? 0.25 : state === "inactive" ? 0.6 : 1;
const color = state === "active" ? "#3b82f6" : "#a3a3a3";
const color = state === "active" ? "#3CE6AC" : "#a3a3a3";
return (
<button
@@ -32,17 +34,30 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
title={title}
>
<svg width={size} height={size} viewBox="0 0 10 10">
<rect
x="5"
y="0.7"
width="6"
height="6"
rx="1"
transform="rotate(45 5 0.7)"
fill={isFilled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.2"
/>
{isHold ? (
<rect
x="2"
y="2"
width="6"
height="6"
rx="0.5"
fill={isFilled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.2"
/>
) : (
<rect
x="5"
y="0.7"
width="6"
height="6"
rx="1"
transform="rotate(45 5 0.7)"
fill={isFilled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.2"
/>
)}
</svg>
</button>
);
@@ -60,9 +60,9 @@ export const LayersPanel = memo(function LayersPanel() {
refreshKey,
compositionLoading,
timelineElements,
currentTime,
showToast,
} = useStudioContext();
const currentTime = usePlayerStore((s) => s.currentTime);
const {
domEditSelection,
applyDomSelection,
@@ -239,9 +239,9 @@ export const LayersPanel = memo(function LayersPanel() {
if (layers.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
<Layers size={18} className="mb-3 text-neutral-600" />
<p className="text-sm font-medium text-neutral-200">No layers</p>
<div className="flex h-full flex-col items-center justify-center bg-panel-bg px-6 text-center">
<Layers size={18} className="mb-3 text-panel-text-5" />
<p className="text-sm font-medium text-panel-text-1">No layers</p>
<p className="mt-1 text-xs text-neutral-500">Load a composition to see its element tree</p>
</div>
);
@@ -249,10 +249,10 @@ export const LayersPanel = memo(function LayersPanel() {
return (
<div
className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900"
className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg"
onPointerLeave={() => handleLayerHover(null)}
>
<div className="border-b border-white/10 px-3 py-2 text-[11px] text-neutral-500">
<div className="border-b border-panel-border px-3 py-2 text-[11px] text-panel-text-3">
{layers.length} layer{layers.length === 1 ? "" : "s"}
</div>
<div
@@ -289,8 +289,8 @@ export const LayersPanel = memo(function LayersPanel() {
isDragged
? "opacity-40"
: selected
? "bg-studio-accent/14 text-studio-accent"
: "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
? "bg-panel-accent/14 text-panel-accent"
: "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
} ${dragKey ? "cursor-grabbing" : draggable ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
style={{ paddingLeft: 8 + layer.depth * 16 }}
>
@@ -316,17 +316,19 @@ export const LayersPanel = memo(function LayersPanel() {
<span
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[8px] font-bold uppercase ${
selected
? "bg-studio-accent/18 text-studio-accent"
? "bg-panel-accent/18 text-panel-accent"
: isCompHost
? "bg-blue-900/40 text-blue-400"
: "bg-neutral-800 text-neutral-500"
? "bg-panel-accent/40 text-panel-accent"
: "bg-panel-hover text-panel-text-4"
}`}
>
{getTagBadge(layer.tagName)}
</span>
<span className="min-w-0 flex-1 truncate text-[11px]">{layer.label}</span>
{hasChildren && (
<span className="text-[9px] tabular-nums text-neutral-600">{layer.childCount}</span>
<span className="text-[9px] tabular-nums text-panel-text-5">
{layer.childCount}
</span>
)}
</div>
);
@@ -0,0 +1,146 @@
import { memo, useMemo, type RefObject } from "react";
import type { ArcPathConfig } from "@hyperframes/core/gsap-parser";
interface MotionPathOverlayProps {
iframeRef: RefObject<HTMLIFrameElement | null>;
arcPath: ArcPathConfig | null;
waypoints: Array<{ x: number; y: number }> | null;
elementBaseRect: { left: number; top: number; scaleX: number; scaleY: number } | null;
}
function buildSvgPath(
waypoints: Array<{ x: number; y: number }>,
segments: ArcPathConfig["segments"],
base: { left: number; top: number; scaleX: number; scaleY: number },
): string {
if (waypoints.length < 2) return "";
const toPixel = (wp: { x: number; y: number }) => ({
x: base.left + wp.x * base.scaleX,
y: base.top + wp.y * base.scaleY,
});
const first = toPixel(waypoints[0]!);
const parts = [`M ${first.x} ${first.y}`];
for (let i = 0; i < segments.length && i < waypoints.length - 1; i++) {
const seg = segments[i]!;
const end = toPixel(waypoints[i + 1]!);
if (seg.cp1 && seg.cp2) {
const c1 = toPixel(seg.cp1);
const c2 = toPixel(seg.cp2);
parts.push(`C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${end.x} ${end.y}`);
} else {
const start = toPixel(waypoints[i]!);
const dx = end.x - start.x;
const dy = end.y - start.y;
const c = seg.curviness ?? 1;
const offset = c * Math.abs(dx) * 0.25;
const c1x = start.x + dx * 0.33;
const c1y = start.y + dy * 0.33 - offset;
const c2x = start.x + dx * 0.66;
const c2y = start.y + dy * 0.66 - offset;
parts.push(`C ${c1x} ${c1y} ${c2x} ${c2y} ${end.x} ${end.y}`);
}
}
return parts.join(" ");
}
export const MotionPathOverlay = memo(function MotionPathOverlay({
arcPath,
waypoints,
elementBaseRect,
}: MotionPathOverlayProps) {
const pathD = useMemo(() => {
if (!arcPath?.enabled || !waypoints || waypoints.length < 2 || !elementBaseRect) return "";
return buildSvgPath(waypoints, arcPath.segments, elementBaseRect);
}, [arcPath, waypoints, elementBaseRect]);
const anchorPoints = useMemo(() => {
if (!waypoints || !elementBaseRect) return [];
return waypoints.map((wp) => ({
x: elementBaseRect.left + wp.x * elementBaseRect.scaleX,
y: elementBaseRect.top + wp.y * elementBaseRect.scaleY,
}));
}, [waypoints, elementBaseRect]);
const controlPoints = useMemo(() => {
if (!arcPath?.enabled || !elementBaseRect) return [];
const points: Array<{
segIndex: number;
type: "cp1" | "cp2";
x: number;
y: number;
anchorX: number;
anchorY: number;
}> = [];
for (let i = 0; i < arcPath.segments.length; i++) {
const seg = arcPath.segments[i]!;
if (seg.cp1 && seg.cp2 && waypoints) {
const anchor1 = waypoints[i]!;
const anchor2 = waypoints[i + 1]!;
points.push({
segIndex: i,
type: "cp1",
x: elementBaseRect.left + seg.cp1.x * elementBaseRect.scaleX,
y: elementBaseRect.top + seg.cp1.y * elementBaseRect.scaleY,
anchorX: elementBaseRect.left + anchor1.x * elementBaseRect.scaleX,
anchorY: elementBaseRect.top + anchor1.y * elementBaseRect.scaleY,
});
points.push({
segIndex: i,
type: "cp2",
x: elementBaseRect.left + seg.cp2.x * elementBaseRect.scaleX,
y: elementBaseRect.top + seg.cp2.y * elementBaseRect.scaleY,
anchorX: elementBaseRect.left + anchor2.x * elementBaseRect.scaleX,
anchorY: elementBaseRect.top + anchor2.y * elementBaseRect.scaleY,
});
}
}
return points;
}, [arcPath, waypoints, elementBaseRect]);
if (!pathD) return null;
return (
<svg className="absolute inset-0 pointer-events-none z-20 overflow-visible">
<path d={pathD} fill="none" stroke="rgba(45, 212, 191, 0.4)" strokeWidth={2} />
{controlPoints.map((cp) => (
<g key={`${cp.segIndex}-${cp.type}`}>
<line
x1={cp.anchorX}
y1={cp.anchorY}
x2={cp.x}
y2={cp.y}
stroke="rgba(167, 139, 250, 0.3)"
strokeWidth={1}
strokeDasharray="3 2"
/>
<circle
cx={cp.x}
cy={cp.y}
r={4}
fill="#a78bfa"
className="pointer-events-auto cursor-grab"
/>
</g>
))}
{anchorPoints.map((pt, i) => (
<circle
key={i}
cx={pt.x}
cy={pt.y}
r={5}
fill="#3CE6AC"
stroke="rgba(255,255,255,0.5)"
strokeWidth={1}
className="pointer-events-auto cursor-pointer"
/>
))}
</svg>
);
});
@@ -1,10 +1,10 @@
import { memo } from "react";
import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
import { memo, useRef, useState } from "react";
import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
import { useStudioContext } from "../../contexts/StudioContext";
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
import {
EMPTY_STYLES,
formatPxMetricValue,
LABEL,
parsePxMetricValue,
RESPONSIVE_GRID,
} from "./propertyPanelHelpers";
@@ -16,7 +16,7 @@ import { KeyframeNavigation } from "./KeyframeNavigation";
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
import { usePlayerStore } from "../../player";
import { TimingSection } from "./propertyPanelTimingSection";
import { computeFitToChildrenSize, type PropertyPanelProps } from "./propertyPanelHelpers";
import { type PropertyPanelProps } from "./propertyPanelHelpers";
// Re-export helpers that external consumers import from this module
export {
@@ -41,7 +41,7 @@ export const PropertyPanel = memo(function PropertyPanel({
assets,
element,
multiSelectCount = 0,
copiedAgentPrompt,
copiedAgentPrompt: _copiedAgentPrompt,
onClearSelection,
onSetStyle,
onSetAttribute,
@@ -53,7 +53,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onSetTextFieldStyle,
onAddTextField,
onRemoveTextField,
onAskAgent,
onAskAgent: _onAskAgent,
onImportAssets,
fontAssets = [],
onImportFonts,
@@ -70,13 +70,22 @@ export const PropertyPanel = memo(function PropertyPanel({
onAddGsapFromProperty,
onRemoveGsapFromProperty,
onAddGsapAnimation,
onSetArcPath,
onUpdateArcSegment,
onAddKeyframe,
onRemoveKeyframe,
onConvertToKeyframes,
onCommitAnimatedProperty,
onSeekToTime,
recordingState,
recordingDuration,
onToggleRecording,
}: PropertyPanelProps) {
const styles = element?.computedStyles ?? EMPTY_STYLES;
const { showToast } = useStudioContext();
const [clipboardCopied, setClipboardCopied] = useState(false);
const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const currentTime = usePlayerStore((s) => s.currentTime);
if (!element) {
return (
@@ -170,10 +179,8 @@ export const PropertyPanel = memo(function PropertyPanel({
onSetManualRotation(element, { angle: parsed });
};
// Keyframe navigation state
const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
const currentTime = usePlayerStore((s) => s.currentTime);
const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null;
@@ -217,6 +224,34 @@ export const PropertyPanel = memo(function PropertyPanel({
}
})();
const gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = (() => {
if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) {
const hasBRProp = gsapAnimations.some(
(a) =>
"borderRadius" in a.properties ||
a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties),
);
if (!hasBRProp) return null;
}
const iframe = previewIframeRef?.current;
const selector = element.id ? `#${element.id}` : element.selector;
if (!iframe?.contentDocument || !selector) return null;
try {
const el = iframe.contentDocument.querySelector(selector);
if (!el) return null;
const cs = iframe.contentWindow!.getComputedStyle(el);
const parse = (v: string) => Number.parseFloat(v) || 0;
return {
tl: parse(cs.borderTopLeftRadius),
tr: parse(cs.borderTopRightRadius),
br: parse(cs.borderBottomRightRadius),
bl: parse(cs.borderBottomLeftRadius),
};
} catch {
return null;
}
})();
const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
const displayW = gsapRuntimeValues?.width ?? resolvedWidth;
@@ -224,34 +259,100 @@ export const PropertyPanel = memo(function PropertyPanel({
const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900 text-neutral-100">
<div className="border-b border-neutral-800 px-4 py-5">
<div className="flex items-start justify-between gap-4">
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<div className="px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className={LABEL}>Document</div>
<div className="mt-3 truncate text-[12px] font-semibold text-neutral-100">
<div className="truncate text-[13px] font-semibold text-neutral-100">
{element.label}
</div>
<div className="mt-1 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
const file = element.sourceFile ?? "index.html";
let lineNum: number | null = null;
try {
const src =
previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
if (src && element.id) {
const idx = src.indexOf(`id="${element.id}"`);
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
}
if (!lineNum && element.selector) {
const tag = element.tagName.toLowerCase();
const cls = element.selector.startsWith(".")
? element.selector.slice(1).split(".")[0]
: null;
const search = cls ? `class="${cls}` : `<${tag}`;
const idx = src.indexOf(search);
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
}
} catch {}
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
const lines = [
`Element: ${element.label} (${sourceLabel})`,
`File: ${fileLoc}`,
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
`Tag: <${element.tagName}>`,
];
if (
element.computedStyles["z-index"] &&
element.computedStyles["z-index"] !== "auto"
) {
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
}
if (gsapAnimations.length > 0) {
const anim = gsapAnimations[0];
lines.push(
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
);
const props = Object.entries(anim.properties)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
if (props) lines.push(`Properties: ${props}`);
}
const text = lines.join("\n");
void navigator.clipboard.writeText(text);
showToast(
`Copied element info for ${element.label} — paste into any AI agent`,
"info",
);
setClipboardCopied(true);
clearTimeout(clipboardTimerRef.current);
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
}}
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
clipboardCopied
? "text-studio-accent"
: "text-neutral-500 hover:bg-neutral-800 hover:text-neutral-300"
}`}
title={clipboardCopied ? "Copied!" : "Copy element info to clipboard"}
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<rect x="5" y="5" width="9" height="9" rx="1.5" />
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
</svg>
</button>
<button
type="button"
aria-label="Clear selection"
onClick={onClearSelection}
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
>
<X size={13} />
</button>
</div>
<button
type="button"
aria-label="Clear selection"
onClick={onClearSelection}
className="flex h-9 w-9 items-center justify-center rounded-full border border-neutral-700 bg-neutral-950 text-neutral-500 shadow-[0_1px_2px_rgba(0,0,0,0.2)] transition-colors hover:border-neutral-600 hover:text-neutral-200"
>
<X size={13} />
</button>
</div>
<div className="mt-4 flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
onClick={onAskAgent}
className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-studio-accent/40 hover:text-studio-accent"
>
<MessageSquare size={15} />
<span>{copiedAgentPrompt ? "Prompt copied" : "Copy prompt to AI agent"}</span>
</button>
</div>
</div>
@@ -384,29 +485,6 @@ export const PropertyPanel = memo(function PropertyPanel({
/>
)}
</div>
{element.capabilities.canApplyManualSize && (
<button
type="button"
className="flex-shrink-0 rounded p-1 text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
title="Fit to children"
onClick={() => {
const size = computeFitToChildrenSize(element);
if (size) onSetManualSize(element, size);
}}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
>
<rect x="2" y="2" width="10" height="10" strokeDasharray="2 1.5" rx="1" />
<path d="M2 4.5h1m-1 5h1m8-5h1m-1 5h1M4.5 2v1m5-1v1M4.5 11v1m5-1v1" />
</svg>
</button>
)}
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
@@ -467,17 +545,40 @@ export const PropertyPanel = memo(function PropertyPanel({
/>
)}
</div>
<MetricField
label="Scale"
value={String(gsapRuntimeValues.scale ?? 1)}
scrub
onCommit={(next) => {
const v = Number.parseFloat(next);
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "scale", v);
}
}}
/>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="Scale"
value={String(gsapRuntimeValues.scale ?? 1)}
scrub
onCommit={(next) => {
const v = Number.parseFloat(next);
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "scale", v);
}
}}
/>
</div>
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
<KeyframeNavigation
property="scale"
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={() => {
if (onCommitAnimatedProperty) {
void onCommitAnimatedProperty(
element,
"scale",
gsapRuntimeValues?.scale ?? 1,
);
}
}}
onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
/>
)}
</div>
<MetricField
label="RotX"
value={`${gsapRuntimeValues.rotationX ?? 0}°`}
@@ -533,9 +634,37 @@ export const PropertyPanel = memo(function PropertyPanel({
onAddFromProperty={onAddGsapFromProperty}
onRemoveFromProperty={onRemoveGsapFromProperty}
onAddAnimation={onAddGsapAnimation}
onSetArcPath={onSetArcPath}
onUpdateArcSegment={onUpdateArcSegment}
/>
)}
{onToggleRecording && (
<div className="px-4 pb-3">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={onToggleRecording}
className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
recordingState === "recording"
? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
: "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
}`}
>
<svg width="10" height="10" viewBox="0 0 10 10">
{recordingState === "recording" ? (
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
) : (
<circle cx="5" cy="5" r="4.5" fill="currentColor" />
)}
</svg>
{recordingState === "recording"
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s — press R`
: "Record gesture (R) — move pointer to capture motion"}
</button>
</div>
)}
{showEditableSections && (
<StyleSections
projectId={projectId}
@@ -544,6 +673,7 @@ export const PropertyPanel = memo(function PropertyPanel({
assets={assets}
onSetStyle={onSetStyle}
onImportAssets={onImportAssets}
gsapBorderRadius={gsapBorderRadius}
/>
)}
</div>
@@ -143,7 +143,6 @@ export const SourceEditor = memo(function SourceEditor({
selection: { anchor: pos },
effects: EditorView.scrollIntoView(pos, { y: "center" }),
});
view.focus();
}, [revealOffset]);
return <div ref={mountEditor} className="h-full w-full overflow-hidden" />;
@@ -0,0 +1,61 @@
import { memo, useState } from "react";
import { MetricField } from "./propertyPanelPrimitives";
export type StaggerOrder = "dom" | "reverse" | "center" | "edges" | "random";
interface StaggerControlsProps {
elementCount: number;
onApplyStagger: (offsetMs: number, order: StaggerOrder) => void;
}
const ORDER_OPTIONS: StaggerOrder[] = ["dom", "reverse", "center", "edges", "random"];
const ORDER_LABELS: Record<StaggerOrder, string> = {
dom: "DOM order",
reverse: "Reverse",
center: "Center out",
edges: "Edges in",
random: "Random",
};
export const StaggerControls = memo(function StaggerControls({
elementCount,
onApplyStagger,
}: StaggerControlsProps) {
const [offsetMs, setOffsetMs] = useState(80);
const [order, setOrder] = useState<StaggerOrder>("dom");
if (elementCount < 2) return null;
return (
<div className="flex items-center gap-2 rounded-lg border border-neutral-800 bg-neutral-900/50 px-2 py-1.5">
<span className="text-[10px] font-medium text-neutral-500">Stagger</span>
<MetricField
label="Offset"
value={String(offsetMs)}
suffix="ms"
onCommit={(raw) => {
const v = Number.parseInt(raw, 10);
if (Number.isFinite(v) && v >= 0) setOffsetMs(v);
}}
/>
<select
value={order}
onChange={(e) => setOrder(e.target.value as StaggerOrder)}
className="rounded-md border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-[10px] text-neutral-200 outline-none"
>
{ORDER_OPTIONS.map((o) => (
<option key={o} value={o}>
{ORDER_LABELS[o]}
</option>
))}
</select>
<button
type="button"
onClick={() => onApplyStagger(offsetMs, order)}
className="rounded-md bg-panel-accent/10 px-2 py-1 text-[10px] font-semibold text-panel-accent transition-colors hover:bg-panel-accent/20"
>
Apply ({elementCount})
</button>
</div>
);
});
@@ -37,9 +37,26 @@ export function isElementComputedVisible(el: HTMLElement): boolean {
const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
function hasVisualPresence(el: HTMLElement): boolean {
const win = el.ownerDocument.defaultView;
if (!win) return false;
const cs = win.getComputedStyle(el);
if (cs.backgroundImage !== "none") return true;
if (
cs.backgroundColor &&
cs.backgroundColor !== "transparent" &&
cs.backgroundColor !== "rgba(0, 0, 0, 0)"
)
return true;
if (cs.borderWidth && parseFloat(cs.borderWidth) > 0 && cs.borderStyle !== "none") return true;
if (cs.boxShadow && cs.boxShadow !== "none") return true;
return false;
}
function isEmptyVisualContainer(el: HTMLElement): boolean {
const tag = el.tagName.toLowerCase();
if (VISUAL_LEAF_TAGS.has(tag)) return false;
if (hasVisualPresence(el)) return false;
const { children } = el;
if (children.length === 0) {
@@ -74,7 +74,7 @@ export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"],
false,
true,
);
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
@@ -240,6 +240,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
"renderSeek",
);
const wrappedTimelineSeek = wrapSeekReapplyFunction(studioWin, studioWin.__timeline, "seek");
wrapSeekReapplyFunction(studioWin, studioWin.__timeline, "totalTime");
const wrappedPlayerPlay = wrapPlayReapplyFunction(studioWin, studioWin.__player, "play");
const wrappedTimelinePlay = wrapPlayReapplyFunction(studioWin, studioWin.__timeline, "play");
const wrappedPlayerPause = wrapApplyAfterFunction(studioWin, studioWin.__player, "pause");
@@ -250,6 +251,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
for (const timeline of Object.values(studioWin.__timelines ?? {})) {
wrappedNamedTimelineSeek =
wrapSeekReapplyFunction(studioWin, timeline, "seek") || wrappedNamedTimelineSeek;
wrapSeekReapplyFunction(studioWin, timeline, "totalTime");
wrappedNamedTimelinePlay =
wrapPlayReapplyFunction(studioWin, timeline, "play") || wrappedNamedTimelinePlay;
wrappedNamedTimelinePause =
@@ -268,6 +270,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
if (typeof value === "object" && value !== null) {
const tl = value as Record<string, unknown>;
wrapSeekReapplyFunction(studioWin, tl, "seek");
wrapSeekReapplyFunction(studioWin, tl, "totalTime");
wrapPlayReapplyFunction(studioWin, tl, "play");
wrapApplyAfterFunction(studioWin, tl, "pause");
studioWin.__hfStudioManualEditsApply?.();
@@ -273,11 +273,25 @@ export function applyStudioPathOffsetDraft(
): void {
promoteInlineForTransform(element);
writeStudioPathOffsetVars(element, offset, { updateBase: false });
element.style.setProperty(
"translate",
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
);
stripGsapTranslateFromTransform(element);
const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
if (isGsapAnimated) {
// For GSAP-animated elements: use gsap.set for positioning (the timeline
// is paused during drag). Set translate:none explicitly to prevent
// double-counting with the transform.
element.style.setProperty("translate", "none");
const win = element.ownerDocument.defaultView as
| (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
| null;
win?.gsap?.set(element, { x: offset.x, y: offset.y });
} else {
// Non-GSAP elements: use CSS translate as before.
element.style.setProperty(
"translate",
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
);
stripGsapTranslateFromTransform(element);
}
}
/* ── Box size apply ───────────────────────────────────────────────── */
@@ -505,6 +519,10 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
function reapplyPathOffsets(doc: Document): void {
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
// Skip elements where GSAP actively animates position — GSAP bakes the
// CSS translate into its transform and sets translate: none every tick.
// Stripping/restoring would oscillate against GSAP's rendering.
if (gsapAnimatesProperty(el, "x", "y")) continue;
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
if (x || y) {
@@ -232,6 +232,41 @@ export function createManualOffsetDragMember(input: {
rect: ManualOffsetDragRect;
}): ManualOffsetDragMemberResult {
const initialOffset = readStudioPathOffset(input.element);
input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
// Capture GSAP's x/y BEFORE any draft applies gsap.set — the commit path
// needs the original (uncorrupted) GSAP position to compute the new keyframe value.
const win = input.element.ownerDocument.defaultView as
| (Window & {
gsap?: { getProperty?: (el: Element, prop: string) => number };
__timelines?: Record<string, { pause?: () => void; paused?: () => boolean }>;
})
| null;
const gsapX = win?.gsap?.getProperty?.(input.element, "x") || 0;
const gsapY = win?.gsap?.getProperty?.(input.element, "y") || 0;
input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX));
input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY));
// Pause GSAP timelines during drag to prevent the tween from overwriting
// the draft's gsap.set on every tick. Track which we paused to resume later.
if (win?.__timelines) {
const paused: string[] = [];
for (const [id, tl] of Object.entries(win.__timelines)) {
try {
if (tl?.pause && !tl.paused?.()) {
tl.pause();
paused.push(id);
}
} catch {
/* cross-origin guard */
}
}
if (paused.length > 0) {
input.element.setAttribute("data-hf-drag-paused-timelines", paused.join(","));
}
}
const initialPathOffset = captureStudioPathOffset(input.element);
const gestureToken = beginStudioManualEditGesture(input.element);
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
@@ -313,11 +348,35 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
for (const member of members) {
restoreManualOffsetDragMember(member);
resumeGsapTimelines(member.element);
}
}
export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
for (const member of members) {
endStudioManualEditGesture(member.element, member.gestureToken);
member.element.removeAttribute("data-hf-drag-initial-offset-x");
member.element.removeAttribute("data-hf-drag-initial-offset-y");
member.element.removeAttribute("data-hf-drag-gsap-base-x");
member.element.removeAttribute("data-hf-drag-gsap-base-y");
resumeGsapTimelines(member.element);
}
}
function resumeGsapTimelines(element: HTMLElement): void {
const ids = element.getAttribute("data-hf-drag-paused-timelines");
element.removeAttribute("data-hf-drag-paused-timelines");
if (!ids) return;
const win = element.ownerDocument.defaultView as
| (Window & {
__timelines?: Record<string, { pause?: () => void }>;
__player?: { seek?: (t: number) => void; getTime?: () => number };
})
| null;
if (!win) return;
// Re-seek to the current time to restore the paused timeline's render state.
// play() would start playback; pause() already stops. Seek re-renders at the
// current position without starting playback.
const t = win.__player?.getTime?.() ?? 0;
win.__player?.seek?.(t);
}
@@ -0,0 +1,10 @@
// ── Design Panel Tokens (for inline style={{}} usage) ──────────────────
// Tailwind classes use `panel-*` from tailwind.config.js theme.extend.colors.
// This file provides the same values for inline styles where Tailwind can't reach.
export const P = {
accent: "#3CE6AC",
borderInput: "#27272A",
textMuted: "#52525B",
white: "#FAFAFA",
} as const;
@@ -71,7 +71,7 @@ function ColorSlider({
aria-valuemax={max}
aria-valuenow={value}
aria-disabled={disabled}
className={`relative h-4 rounded-full border border-neutral-700 shadow-[inset_0_1px_2px_rgba(0,0,0,0.55)] outline-none focus:border-[#f5a400] focus:ring-2 focus:ring-[#f5a400]/40 ${
className={`relative h-4 rounded-full border border-neutral-700 shadow-[inset_0_1px_2px_rgba(0,0,0,0.55)] outline-none focus:border-panel-accent focus:ring-2 focus:ring-panel-accent/40 ${
disabled ? "cursor-not-allowed opacity-50" : "cursor-ew-resize"
}`}
style={{ background }}
@@ -294,7 +294,7 @@ export function ColorField({
<div className="truncate text-[11px] font-medium text-neutral-100">
{currentColor}
</div>
<div className="mt-0.5 text-[9px] uppercase tracking-[0.12em] text-neutral-600">
<div className="mt-0.5 text-[9px] text-neutral-600">
S {saturationPercent}% · B {brightnessPercent}% · A {alphaPercent}%
</div>
</div>
@@ -278,7 +278,7 @@ export function GradientField({
checked={parsed.repeating}
disabled={disabled}
onChange={(e) => patch({ repeating: e.target.checked })}
className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-[#3ce6ac] focus:ring-[#3ce6ac]"
className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-panel-accent focus:ring-panel-accent"
/>
Repeat
</label>
@@ -41,6 +41,19 @@ export interface PropertyPanelProps {
onAddGsapFromProperty?: (animId: string, prop: string) => void;
onRemoveGsapFromProperty?: (animId: string, prop: string) => void;
onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void;
onSetArcPath?: (
animId: string,
config: {
enabled: boolean;
autoRotate?: boolean | number;
segments?: import("@hyperframes/core/gsap-parser").ArcPathSegment[];
},
) => void;
onUpdateArcSegment?: (
animId: string,
segmentIndex: number,
update: Partial<import("@hyperframes/core/gsap-parser").ArcPathSegment>,
) => void;
onAddKeyframe?: (
animationId: string,
percentage: number,
@@ -55,6 +68,9 @@ export interface PropertyPanelProps {
value: number | string,
) => Promise<void>;
onSeekToTime?: (time: number) => void;
recordingState?: "idle" | "recording" | "preview";
recordingDuration?: number;
onToggleRecording?: () => void;
}
/* ------------------------------------------------------------------ */
@@ -184,8 +200,8 @@ function fontSourceRank(source: FontSource): number {
/* ------------------------------------------------------------------ */
export const FIELD =
"min-w-0 rounded-xl border border-neutral-800 bg-neutral-900/95 px-3 py-2 text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] transition-colors focus-within:border-neutral-600";
export const LABEL = "text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500";
"min-w-0 rounded-md bg-panel-input px-3 py-[7px] text-panel-text-1 transition-colors focus-within:ring-1 focus-within:ring-panel-accent/30";
export const LABEL = "text-[11px] font-medium text-panel-text-3";
export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
export const EMPTY_STYLES: Record<string, string> = {};
@@ -76,7 +76,7 @@ export function MediaSection({
{srcAttr && (
<div className="min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">Source</div>
<div className="text-[11px] font-medium text-neutral-500">Source</div>
<button
type="button"
onClick={() => {
@@ -257,9 +257,9 @@ export function SliderControl({
onMouseUp={() => commitDraft(draft)}
onTouchEnd={() => commitDraft(draft)}
onBlur={() => commitDraft(draft)}
className="h-2 min-w-0 w-full cursor-pointer appearance-none rounded-full bg-neutral-800 accent-[#3ce6ac] disabled:cursor-not-allowed"
className="h-4 min-w-0 w-full cursor-pointer appearance-none bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-panel-border [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0C0C0E,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
/>
<div className="min-w-[52px] rounded-xl border border-neutral-800 bg-neutral-900 px-2 py-2 text-right text-[11px] font-medium text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
<div className="min-w-[44px] rounded-md bg-panel-input px-2 py-1.5 text-right text-[11px] font-medium text-panel-text-1 tabular-nums">
{formatDisplayValue?.(draft) ?? displayValue}
</div>
</div>
@@ -279,7 +279,7 @@ export function SegmentedControl({
}) {
return (
<div
className="grid min-w-0 gap-1 rounded-xl bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
className="grid min-w-0 gap-[2px] rounded-md bg-panel-input p-[2px]"
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
>
{options.map((option) => (
@@ -288,10 +288,10 @@ export function SegmentedControl({
type="button"
disabled={disabled}
onClick={() => onChange(option.value)}
className={`min-w-0 truncate rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
className={`min-w-0 truncate rounded px-2 py-[5px] text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
option.value === value
? "bg-neutral-800 text-white shadow-[0_1px_3px_rgba(0,0,0,0.28)]"
: "text-neutral-500 hover:text-neutral-200"
? "bg-panel-hover text-white"
: "text-panel-text-4 hover:text-panel-text-2"
}`}
>
{option.label}
@@ -336,7 +336,7 @@ export function SelectField({
export function Section({
title,
icon,
icon: _icon,
children,
accessory,
defaultCollapsed = false,
@@ -350,32 +350,45 @@ export function Section({
const [collapsed, setCollapsed] = useState(defaultCollapsed);
return (
<section className="min-w-0 border-t border-neutral-800/80">
<section className="min-w-0 border-t border-panel-border">
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="flex w-full items-center justify-between gap-2 px-4 py-3"
className="flex w-full items-center justify-between gap-2 px-4 py-2.5"
>
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex-shrink-0 text-neutral-500">{icon}</span>
<h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-300">
{title}
</h3>
</div>
<h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
<div className="flex items-center gap-2">
{accessory}
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className={`flex-shrink-0 text-neutral-500 transition-transform ${collapsed ? "-rotate-90" : ""}`}
>
<path d="M2 3l3 4 3-4z" />
</svg>
{collapsed && (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className="flex-shrink-0 text-panel-text-5"
>
<path
d="M6 2.5v7M2.5 6h7"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
)}
{!collapsed && (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className="flex-shrink-0 text-panel-text-5"
>
<path d="M2 3l3 4 3-4z" />
</svg>
)}
</div>
</button>
{!collapsed && <div className="px-4 pb-4">{children}</div>}
{!collapsed && <div className="px-4 pb-3">{children}</div>}
</section>
);
}
@@ -262,15 +262,13 @@ function TextFieldEditor({
onRemoveTextField: (fieldKey: string) => void;
}) {
return (
<div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
<div className="space-y-3">
<div className={showRemove ? "flex min-w-0 items-center justify-between gap-2" : "min-w-0"}>
<div className="min-w-0">
<div className="truncate text-[11px] font-medium text-neutral-100">
{formatTextFieldPreview(field.value) || "Text"}
</div>
<div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">
{field.tagName}
</div>
<div className="text-[10px] text-neutral-500">{field.tagName}</div>
</div>
{showRemove && (
<button
@@ -368,7 +366,7 @@ export function TextSection({
if (textFields.length === 1) {
return (
<Section title="Text" icon={<Type size={15} />}>
<Section title="Text" icon={<Type size={15} />} defaultCollapsed>
<TextFieldEditor
field={activeField}
styles={styles}
@@ -426,7 +424,7 @@ export function TextSection({
{formatTextFieldPreview(field.value) || `Text ${index + 1}`}
</span>
</div>
<span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-neutral-500">
<span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] text-neutral-500">
{field.tagName}
</span>
</div>
@@ -33,6 +33,7 @@ import {
} from "./propertyPanelPrimitives";
import { ColorField } from "./propertyPanelColor";
import { GradientField, ImageFillField } from "./propertyPanelFill";
import { BorderRadiusEditor } from "./BorderRadiusEditor";
export function StyleSections({
projectId,
@@ -41,6 +42,7 @@ export function StyleSections({
assets,
onSetStyle,
onImportAssets,
gsapBorderRadius,
}: {
projectId: string;
element: DomEditSelection;
@@ -48,10 +50,19 @@ export function StyleSections({
assets: string[];
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onImportAssets?: (files: FileList) => Promise<string[]>;
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
}) {
const styleEditingDisabled = !element.capabilities.canEditStyles;
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
const radiusTL =
gsapBorderRadius?.tl ?? parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue;
const radiusTR =
gsapBorderRadius?.tr ?? parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue;
const radiusBR =
gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue;
const radiusBL =
gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue;
const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
const borderWidthValue =
parsePxMetricValue(styles["border-width"] ?? "") ??
@@ -155,15 +166,26 @@ export function StyleSections({
{hasVisualBackground && (
<Section title="Radius" icon={<Settings size={15} />} defaultCollapsed>
<SliderControl
value={radiusValue}
min={0}
max={Math.max(240, Math.ceil(radiusValue))}
step={1}
<BorderRadiusEditor
tl={radiusTL}
tr={radiusTR}
br={radiusBR}
bl={radiusBL}
disabled={styleEditingDisabled}
displayValue={`${formatNumericValue(radiusValue)}px`}
formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
onCommit={(corner, value) => {
const px = `${formatNumericValue(value)}px`;
if (corner === "all") {
onSetStyle("border-radius", px);
} else {
const prop = {
tl: "border-top-left-radius",
tr: "border-top-right-radius",
br: "border-bottom-right-radius",
bl: "border-bottom-left-radius",
}[corner];
onSetStyle(prop, px);
}
}}
/>
</Section>
)}
@@ -17,6 +17,14 @@ import {
toOverlayRect,
} from "./domEditOverlayGeometry";
function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!rectsEqual(a[i]!, b[i]!)) return false;
}
return true;
}
interface UseDomEditOverlayRectsOptions {
iframeRef: RefObject<HTMLIFrameElement | null>;
overlayRef: RefObject<HTMLDivElement | null>;
@@ -37,6 +45,7 @@ interface UseDomEditOverlayRectsResult {
groupOverlayItems: GroupOverlayItem[];
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
childRects: OverlayRect[];
}
export function useDomEditOverlayRects({
@@ -51,6 +60,7 @@ export function useDomEditOverlayRects({
const [overlayRect, setOverlayRectState] = useState<OverlayRect | null>(null);
const [hoverRect, setHoverRectState] = useState<OverlayRect | null>(null);
const [groupOverlayItems, setGroupOverlayItemsState] = useState<GroupOverlayItem[]>([]);
const [childRects, setChildRectsState] = useState<OverlayRect[]>([]);
const overlayRectRef = useRef<OverlayRect | null>(null);
const hoverRectRef = useRef<OverlayRect | null>(null);
@@ -58,6 +68,7 @@ export function useDomEditOverlayRects({
const resolvedElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
const resolvedHoverElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
const resolvedGroupElementRef = useRef<Map<string, HTMLElement>>(new Map());
const childRectsRef = useRef<OverlayRect[]>([]);
const setOverlayRect = (next: OverlayRect | null) => {
if (rectsEqual(overlayRectRef.current, next)) return;
@@ -102,7 +113,13 @@ export function useDomEditOverlayRects({
const update = () => {
frame = requestAnimationFrame(update);
if (rafPausedRef.current) return;
if (rafPausedRef.current) {
if (childRectsRef.current.length > 0) {
childRectsRef.current = [];
setChildRectsState([]);
}
return;
}
const sel = selectionRef.current;
const iframe = iframeRef.current;
@@ -132,13 +149,39 @@ export function useDomEditOverlayRects({
resolvedElementRef as ResolvedElementRef,
);
if (el && isElementVisibleForOverlay(el)) {
setOverlayRect(toOverlayRect(overlayEl, iframe, el));
const nextRect = toOverlayRect(overlayEl, iframe, el);
setOverlayRect(nextRect);
const descendants = el.querySelectorAll("*");
if (descendants.length > 0 && descendants.length <= 60) {
const nextChildRects: OverlayRect[] = [];
for (let i = 0; i < descendants.length; i++) {
const child = descendants[i] as HTMLElement;
if (!child.getBoundingClientRect) continue;
const r = toOverlayRect(overlayEl, iframe, child);
if (r && r.width > 2 && r.height > 2) nextChildRects.push(r);
}
if (!childRectsEqual(childRectsRef.current, nextChildRects)) {
childRectsRef.current = nextChildRects;
setChildRectsState(nextChildRects);
}
} else if (childRectsRef.current.length > 0) {
childRectsRef.current = [];
setChildRectsState([]);
}
} else {
setOverlayRect(null);
if (childRectsRef.current.length > 0) {
childRectsRef.current = [];
setChildRectsState([]);
}
}
} else {
resolvedElementRef.current = null;
setOverlayRect(null);
if (childRectsRef.current.length > 0) {
childRectsRef.current = [];
setChildRectsState([]);
}
}
const group = groupSelectionsRef.current;
@@ -203,5 +246,6 @@ export function useDomEditOverlayRects({
groupOverlayItems,
groupOverlayItemsRef,
setGroupOverlayItems,
childRects,
};
}
@@ -57,15 +57,15 @@ export const RenderQueueItem = memo(function RenderQueueItem({
onPointerLeave={() => setHovered(false)}
onClick={isComplete ? handleOpen : undefined}
className={[
"px-3 py-2.5 border-b border-neutral-800/30 last:border-0 transition-colors duration-150",
isComplete ? "cursor-pointer hover:bg-neutral-800/30" : "",
"px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150",
isComplete ? "cursor-pointer hover:bg-panel-hover/30" : "",
]
.filter(Boolean)
.join(" ")}
>
<div className="flex items-center gap-2.5">
{/* Thumbnail — static frame; swaps to live video on hover */}
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
<div className="w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative">
{isComplete && (
<>
{/* Live video — visible on hover */}
@@ -90,7 +90,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
)}
{job.status === "rendering" && (
<div className="w-full h-full flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-studio-accent animate-pulse" />
<div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse" />
</div>
)}
{job.status === "failed" && (
@@ -108,11 +108,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-neutral-300 truncate">
<span className="text-[11px] font-medium text-panel-text-2 truncate">
{job.filename}
</span>
{job.durationMs && (
<span className="text-[9px] text-neutral-600 flex-shrink-0">
<span className="text-[9px] text-panel-text-5 flex-shrink-0">
{formatDuration(job.durationMs)}
</span>
)}
@@ -121,12 +121,12 @@ export const RenderQueueItem = memo(function RenderQueueItem({
{job.status === "rendering" && (
<div className="mt-1">
<div className="flex items-center justify-between mb-0.5">
<span className="text-[9px] text-neutral-500">{job.stage || "Rendering"}</span>
<span className="text-[9px] font-mono text-studio-accent">{job.progress}%</span>
<span className="text-[9px] text-panel-text-4">{job.stage || "Rendering"}</span>
<span className="text-[9px] font-mono text-panel-accent">{job.progress}%</span>
</div>
<div className="w-full h-1 bg-neutral-800 rounded-full overflow-hidden">
<div className="w-full h-1 bg-panel-border rounded-full overflow-hidden">
<div
className="h-full bg-studio-accent rounded-full transition-all duration-300"
className="h-full bg-panel-accent rounded-full transition-all duration-300"
style={{ width: `${job.progress}%` }}
/>
</div>
@@ -138,57 +138,58 @@ export const RenderQueueItem = memo(function RenderQueueItem({
)}
{job.status !== "rendering" && (
<span className="text-[9px] text-neutral-600">{formatTimeAgo(job.createdAt)}</span>
<span className="text-[9px] text-panel-text-5">{formatTimeAgo(job.createdAt)}</span>
)}
</div>
{/* Actions */}
{hovered && (
<div className="flex items-center gap-1 flex-shrink-0">
{isComplete && (
<button
onClick={handleDownload}
className="p-1 rounded text-neutral-500 hover:text-green-400 transition-colors"
title="Download"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 rounded text-neutral-500 hover:text-red-400 transition-colors"
title="Remove"
{/* Actions — always visible to prevent layout shifts */}
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={isComplete ? handleDownload : undefined}
className={`p-1 rounded transition-colors ${
isComplete
? "text-panel-text-5 hover:text-panel-accent"
: "text-panel-text-5/30 pointer-events-none"
}`}
title={isComplete ? "Download" : "Rendering..."}
disabled={!isComplete}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
)}
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
title="Remove"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
@@ -67,13 +67,17 @@ export function DomEditProvider({
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
invalidateGsapCache,
previewIframeRef,
commitMutation,
},
children,
}: {
@@ -136,13 +140,17 @@ export function DomEditProvider({
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
invalidateGsapCache,
previewIframeRef,
commitMutation,
}),
[
domEditSelection,
@@ -199,13 +207,17 @@ export function DomEditProvider({
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
invalidateGsapCache,
previewIframeRef,
commitMutation,
],
);
return <DomEditContext value={stable}>{children}</DomEditContext>;
@@ -26,6 +26,7 @@ export function FileManagerProvider({
readProjectFile,
writeProjectFile,
readOptionalProjectFile,
updateEditingFileContent,
revealSourceOffset,
openSourceForSelection,
handleFileSelect,
@@ -64,6 +65,7 @@ export function FileManagerProvider({
readProjectFile,
writeProjectFile,
readOptionalProjectFile,
updateEditingFileContent,
revealSourceOffset,
openSourceForSelection,
handleFileSelect,
@@ -96,6 +98,7 @@ export function FileManagerProvider({
readProjectFile,
writeProjectFile,
readOptionalProjectFile,
updateEditingFileContent,
revealSourceOffset,
openSourceForSelection,
handleFileSelect,
@@ -12,7 +12,6 @@ export interface StudioContextValue {
compositionLoading: boolean;
refreshKey: number;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
currentTime: number;
timelineElements: TimelineElement[];
isPlaying: boolean;
editHistory: {
@@ -63,7 +62,6 @@ export function StudioProvider({
compositionLoading,
refreshKey,
setRefreshKey,
currentTime,
timelineElements,
isPlaying,
editHistory,
@@ -89,7 +87,6 @@ export function StudioProvider({
compositionLoading,
refreshKey,
setRefreshKey,
currentTime,
timelineElements,
isPlaying,
editHistory,
@@ -112,7 +109,6 @@ export function StudioProvider({
captionEditMode,
compositionLoading,
refreshKey,
currentTime,
isPlaying,
compositionDimensions,
timelineVisible,
@@ -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 };
}
@@ -17,6 +17,46 @@ const SHORTCUT_SECTIONS = [
{ key: "F", label: "Toggle fullscreen" },
],
},
{
title: "Keyframes",
hints: [
{ key: "K", label: "Add keyframe at playhead" },
{ key: "Del", label: "Delete selected keyframe" },
{ key: "H", label: "Toggle hold / bezier" },
{ key: "U", label: "Expand / collapse properties" },
{ key: "R", label: "Record gesture" },
],
},
{
title: "Editing",
hints: [
{ key: "⌘Z", label: "Undo" },
{ key: "⌘⇧Z", label: "Redo" },
{ key: "⌘C", label: "Copy element" },
{ key: "⌘V", label: "Paste element" },
{ key: "⌘X", label: "Cut element" },
{ key: "S", label: "Split clip at playhead" },
{ key: "Del", label: "Delete selected element" },
],
},
{
title: "Gesture recording modifiers",
hints: [
{ key: "Drag", label: "Record x / y position" },
{ key: "Scroll", label: "Record z depth" },
{ key: "⇧ Drag", label: "Record rotationX / rotationY" },
{ key: "⌥ Drag", label: "Record rotation" },
{ key: "⌘ Drag↕", label: "Record opacity" },
{ key: "⌘ Scroll", label: "Record scale" },
],
},
{
title: "Panels",
hints: [
{ key: "⌘1", label: "Compositions tab" },
{ key: "⌘2", label: "Assets tab" },
],
},
{
title: "Work area",
hints: [
@@ -102,7 +102,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
const x2 = (kf.percentage / 100) * clipWidthPx;
return (
<div
key={`line-${prev.percentage}-${kf.percentage}`}
key={`line-${i}-${prev.percentage}-${kf.percentage}`}
className="absolute"
style={{
left: x1,
@@ -118,7 +118,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
);
})}
{sorted.map((kf) => {
{sorted.map((kf, i) => {
const leftPx = (kf.percentage / 100) * clipWidthPx - half;
const kfKey = `${elementId}:${kf.percentage}`;
const isKfSelected = selectedKeyframes.has(kfKey);
@@ -126,7 +126,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3";
return (
<button
key={kf.percentage}
key={`${i}-${kf.percentage}`}
type="button"
className="absolute"
style={{
@@ -0,0 +1,120 @@
import { memo } from "react";
import type { KeyframeCacheEntry } from "../store/playerStore";
const SUB_TRACK_H = 24;
const DIAMOND_SIZE = 6;
const HALF = DIAMOND_SIZE / 2;
interface TimelinePropertyRowsProps {
keyframesData: KeyframeCacheEntry;
clipWidthPx: number;
clipLeftPx: number;
accentColor: string;
isSelected: boolean;
currentPercentage: number;
elementId: string;
selectedKeyframes: Set<string>;
onClickKeyframe?: (percentage: number) => void;
}
function extractProperties(data: KeyframeCacheEntry): string[] {
const props = new Set<string>();
for (const kf of data.keyframes) {
for (const key of Object.keys(kf.properties)) {
props.add(key);
}
}
return Array.from(props).sort();
}
export const TimelinePropertyRows = memo(function TimelinePropertyRows({
keyframesData,
clipWidthPx,
clipLeftPx,
accentColor,
isSelected,
currentPercentage,
elementId,
selectedKeyframes,
onClickKeyframe,
}: TimelinePropertyRowsProps) {
const properties = extractProperties(keyframesData);
if (properties.length === 0 || clipWidthPx < 20) return null;
return (
<div className="flex flex-col">
{properties.map((prop) => {
const propKeyframes = keyframesData.keyframes.filter((kf) => prop in kf.properties);
if (propKeyframes.length === 0) return null;
return (
<div key={prop} className="relative flex items-center" style={{ height: SUB_TRACK_H }}>
<span className="absolute left-1 text-[8px] font-medium text-neutral-600 z-10 select-none">
{prop}
</span>
<svg
className="absolute"
style={{ left: clipLeftPx, width: clipWidthPx, height: SUB_TRACK_H }}
viewBox={`0 0 ${clipWidthPx} ${SUB_TRACK_H}`}
>
<line
x1={0}
y1={SUB_TRACK_H / 2}
x2={clipWidthPx}
y2={SUB_TRACK_H / 2}
stroke={isSelected ? accentColor : "#525252"}
strokeOpacity={0.15}
strokeWidth={1}
/>
{propKeyframes.map((kf) => {
const x = (kf.percentage / 100) * clipWidthPx;
const y = SUB_TRACK_H / 2;
const key = `${elementId}:${kf.percentage}`;
const isKfSelected = selectedKeyframes.has(key);
const isHold = kf.ease === "steps(1)";
const fillColor =
isKfSelected || (isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5)
? accentColor
: isSelected
? `${accentColor}80`
: "#737373";
return (
<g
key={kf.percentage}
onClick={(e) => {
e.stopPropagation();
onClickKeyframe?.(kf.percentage);
}}
style={{ cursor: "pointer" }}
>
{isHold ? (
<rect
x={x - HALF}
y={y - HALF}
width={DIAMOND_SIZE}
height={DIAMOND_SIZE}
fill={fillColor}
/>
) : (
<rect
x={x - HALF}
y={y - HALF}
width={DIAMOND_SIZE}
height={DIAMOND_SIZE}
fill={fillColor}
transform={`rotate(45, ${x}, ${y})`}
/>
)}
</g>
);
})}
</svg>
</div>
);
})}
</div>
);
});
export { SUB_TRACK_H };
@@ -70,6 +70,23 @@ interface PlayerState {
toggleSelectedKeyframe: (key: string) => void;
clearSelectedKeyframes: () => void;
/** Multi-select: additional selected elements beyond selectedElementId. */
selectedElementIds: Set<string>;
toggleSelectedElementId: (id: string) => void;
clearSelectedElementIds: () => void;
/** Clipboard for keyframe copy/paste — stores keyframes with relative times. */
keyframeClipboard: Array<{
relativeTime: number;
properties: Record<string, number | string>;
ease?: string;
}> | null;
setKeyframeClipboard: (data: PlayerState["keyframeClipboard"]) => void;
/** Elements with expanded property rows in the timeline. */
expandedTimelineElements: Set<string>;
toggleExpandedElement: (id: string) => void;
/** Keyframe data per element id, populated from parsed GSAP animations. */
keyframeCache: Map<string, KeyframeCacheEntry>;
setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
@@ -140,6 +157,28 @@ export const usePlayerStore = create<PlayerState>((set) => ({
}),
clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }),
keyframeClipboard: null,
setKeyframeClipboard: (data) => set({ keyframeClipboard: data }),
selectedElementIds: new Set<string>(),
toggleSelectedElementId: (id: string) =>
set((s) => {
const next = new Set(s.selectedElementIds);
if (next.has(id)) next.delete(id);
else next.add(id);
return { selectedElementIds: next };
}),
clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
expandedTimelineElements: new Set<string>(),
toggleExpandedElement: (id: string) =>
set((s) => {
const next = new Set(s.expandedTimelineElements);
if (next.has(id)) next.delete(id);
else next.add(id);
return { expandedTimelineElements: next };
}),
keyframeCache: new Map(),
setKeyframeCache: (elementId, data) =>
set((s) => {
@@ -212,6 +251,8 @@ export const usePlayerStore = create<PlayerState>((set) => ({
inPoint: null,
outPoint: null,
selectedKeyframes: new Set(),
selectedElementIds: new Set(),
expandedTimelineElements: new Set(),
keyframeCache: new Map(),
}),
}));
@@ -0,0 +1,58 @@
const WINDOW_SIZE = 1024;
const HOP_SIZE = 512;
// fallow-ignore-next-line complexity
export async function detectBeats(audioBuffer: AudioBuffer): Promise<number[]> {
const channelData = audioBuffer.getChannelData(0);
const sampleRate = audioBuffer.sampleRate;
const energies: number[] = [];
for (let i = 0; i < channelData.length - WINDOW_SIZE; i += HOP_SIZE) {
let sum = 0;
for (let j = 0; j < WINDOW_SIZE; j++) {
const sample = channelData[i + j]!;
sum += sample * sample;
}
energies.push(sum / WINDOW_SIZE);
}
const beats: number[] = [];
const localWindowSize = 20;
for (let i = localWindowSize; i < energies.length - localWindowSize; i++) {
let localMean = 0;
for (let j = i - localWindowSize; j < i + localWindowSize; j++) {
localMean += energies[j]!;
}
localMean /= localWindowSize * 2;
const threshold = localMean * 1.5;
const current = energies[i]!;
if (
current > threshold &&
current > (energies[i - 1] ?? 0) &&
current > (energies[i + 1] ?? 0)
) {
const timeInSeconds = (i * HOP_SIZE) / sampleRate;
if (beats.length === 0 || timeInSeconds - beats[beats.length - 1]! > 0.1) {
beats.push(Math.round(timeInSeconds * 1000) / 1000);
}
}
}
return beats;
}
// fallow-ignore-next-line complexity
export async function detectBeatsFromUrl(url: string): Promise<number[]> {
const audioContext = new AudioContext();
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return detectBeats(audioBuffer);
} finally {
await audioContext.close();
}
}
@@ -0,0 +1,169 @@
import { describe, expect, test } from "vitest";
import {
absoluteToPercentage,
absoluteToPercentageForAnimation,
findTweenAtTime,
isTimeWithinTween,
percentageToAbsolute,
percentageToAbsoluteForAnimation,
resolveTweenDuration,
resolveTweenStart,
} from "./globalTimeCompiler";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
function makeAnim(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
return {
id: "#el-to-0",
targetSelector: "#el",
method: "to",
position: 0,
properties: { x: 100 },
...overrides,
};
}
describe("absoluteToPercentage", () => {
test("mid-point of a tween", () => {
expect(absoluteToPercentage(0.5, 0, 2)).toBe(25);
});
test("tween with offset start", () => {
expect(absoluteToPercentage(1.0, 0.5, 1)).toBe(50);
});
test("clamps below tween start to 0%", () => {
expect(absoluteToPercentage(-1, 0, 2)).toBe(0);
});
test("clamps past tween end to 100%", () => {
expect(absoluteToPercentage(5, 0, 2)).toBe(100);
});
test("zero duration returns 0", () => {
expect(absoluteToPercentage(1, 0, 0)).toBe(0);
});
});
describe("percentageToAbsolute", () => {
test("converts percentage back to absolute time", () => {
expect(percentageToAbsolute(50, 0.5, 1)).toBe(1.0);
});
test("0% returns tween start", () => {
expect(percentageToAbsolute(0, 2, 3)).toBe(2);
});
test("100% returns tween end", () => {
expect(percentageToAbsolute(100, 2, 3)).toBe(5);
});
});
describe("isTimeWithinTween", () => {
test("time inside returns true", () => {
expect(isTimeWithinTween(0.5, 0, 2)).toBe(true);
});
test("time at start returns true", () => {
expect(isTimeWithinTween(0, 0, 2)).toBe(true);
});
test("time at end returns true", () => {
expect(isTimeWithinTween(2, 0, 2)).toBe(true);
});
test("time before returns false", () => {
expect(isTimeWithinTween(-0.1, 0, 2)).toBe(false);
});
test("time after returns false", () => {
expect(isTimeWithinTween(2.1, 0, 2)).toBe(false);
});
});
describe("resolveTweenStart", () => {
test("numeric position", () => {
expect(resolveTweenStart(makeAnim({ position: 1.5 }))).toBe(1.5);
});
test("parseable string position", () => {
expect(resolveTweenStart(makeAnim({ position: "2.5" }))).toBe(2.5);
});
test("unparseable string position returns null", () => {
expect(resolveTweenStart(makeAnim({ position: "myLabel" }))).toBeNull();
});
test("relative position +=0.5 returns null", () => {
expect(resolveTweenStart(makeAnim({ position: "+=0.5" }))).toBeNull();
});
});
describe("resolveTweenDuration", () => {
test("explicit duration", () => {
expect(resolveTweenDuration(makeAnim({ duration: 2 }))).toBe(2);
});
test("missing duration defaults to 1", () => {
expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(1);
});
});
describe("findTweenAtTime", () => {
const anims = [
makeAnim({ id: "#el-to-0", position: 0, duration: 0.5 }),
makeAnim({ id: "#el-to-1", position: 1, duration: 1 }),
makeAnim({
id: "#other-to-0",
targetSelector: "#other",
position: 0,
duration: 2,
}),
];
test("finds tween at time within range", () => {
expect(findTweenAtTime(0.3, anims, "#el")?.id).toBe("#el-to-0");
});
test("finds second tween", () => {
expect(findTweenAtTime(1.5, anims, "#el")?.id).toBe("#el-to-1");
});
test("returns null for gap between tweens", () => {
expect(findTweenAtTime(0.7, anims, "#el")).toBeNull();
});
test("filters by selector", () => {
expect(findTweenAtTime(0.3, anims, "#other")?.id).toBe("#other-to-0");
});
test("returns null for unmatched selector", () => {
expect(findTweenAtTime(0.3, anims, "#missing")).toBeNull();
});
test("skips tweens with unresolvable string positions", () => {
const withLabel = [makeAnim({ id: "#el-to-0", position: "myLabel", duration: 1 })];
expect(findTweenAtTime(0.5, withLabel, "#el")).toBeNull();
});
});
describe("animation-level helpers", () => {
const anim = makeAnim({ position: 0.5, duration: 2 });
test("absoluteToPercentageForAnimation", () => {
expect(absoluteToPercentageForAnimation(1.5, anim)).toBe(50);
});
test("absoluteToPercentageForAnimation returns null for string position", () => {
const labelAnim = makeAnim({ position: "label" });
expect(absoluteToPercentageForAnimation(0.5, labelAnim)).toBeNull();
});
test("percentageToAbsoluteForAnimation", () => {
expect(percentageToAbsoluteForAnimation(50, anim)).toBe(1.5);
});
test("percentageToAbsoluteForAnimation returns null for string position", () => {
const labelAnim = makeAnim({ position: "+=1" });
expect(percentageToAbsoluteForAnimation(50, labelAnim)).toBeNull();
});
});
@@ -0,0 +1,77 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
export function absoluteToPercentage(
time: number,
tweenStart: number,
tweenDuration: number,
): number {
if (tweenDuration <= 0) return 0;
const raw = ((time - tweenStart) / tweenDuration) * 100;
return Math.max(0, Math.min(100, Math.round(raw * 10) / 10));
}
export function percentageToAbsolute(
pct: number,
tweenStart: number,
tweenDuration: number,
): number {
return tweenStart + (pct / 100) * tweenDuration;
}
export function isTimeWithinTween(
time: number,
tweenStart: number,
tweenDuration: number,
): boolean {
return time >= tweenStart && time <= tweenStart + tweenDuration;
}
export function resolveTweenStart(animation: GsapAnimation): number | null {
if (typeof animation.position === "number") return animation.position;
const parsed = Number.parseFloat(animation.position as string);
if (!Number.isNaN(parsed)) return parsed;
return null;
}
export function resolveTweenDuration(animation: GsapAnimation): number {
return animation.duration ?? 1;
}
export function findTweenAtTime(
time: number,
animations: GsapAnimation[],
selector: string,
): GsapAnimation | null {
for (const anim of animations) {
if (!matchesSelector(anim.targetSelector, selector)) continue;
const start = resolveTweenStart(anim);
if (start === null) continue;
const duration = resolveTweenDuration(anim);
if (isTimeWithinTween(time, start, duration)) return anim;
}
return null;
}
export function absoluteToPercentageForAnimation(
time: number,
animation: GsapAnimation,
): number | null {
const start = resolveTweenStart(animation);
if (start === null) return null;
const duration = resolveTweenDuration(animation);
return absoluteToPercentage(time, start, duration);
}
export function percentageToAbsoluteForAnimation(
pct: number,
animation: GsapAnimation,
): number | null {
const start = resolveTweenStart(animation);
if (start === null) return null;
const duration = resolveTweenDuration(animation);
return percentageToAbsolute(pct, start, duration);
}
function matchesSelector(tweenSelector: string, querySelector: string): boolean {
return tweenSelector.split(",").some((part) => part.trim() === querySelector);
}
+30 -10
View File
@@ -4,7 +4,11 @@ type IframeWindow = Window & {
__hfForceTimelineRebind?: () => void;
__hfSuppressSceneMutations?: <T>(fn: () => T) => T;
__hfStudioManualEditsApply?: () => void;
gsap?: { timeline?: (...args: unknown[]) => unknown };
gsap?: {
timeline?: (...args: unknown[]) => unknown;
registerPlugin?: (...plugins: unknown[]) => unknown;
};
MotionPathPlugin?: unknown;
};
function isGsapScript(text: string): boolean {
@@ -64,16 +68,32 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
}
oldScriptEl.remove();
const newScript = doc.createElement("script");
// IIFE prevents const/let redeclaration errors across consecutive edits.
// Top-level declarations are scoped to the IIFE; window.* assignments
// (e.g. window.__timelines["root"] = tl) still reach the global scope.
newScript.textContent = `(function(){${scriptText}\n})();`;
doc.body.appendChild(newScript);
win.__hfForceTimelineRebind?.();
win.__player?.seek?.(currentTime);
win.__hfStudioManualEditsApply?.();
const executeScript = () => {
if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
win.gsap.registerPlugin(win.MotionPathPlugin);
}
const s = doc.createElement("script");
s.textContent = `(function(){${scriptText}\n})();`;
doc.body.appendChild(s);
win.__hfForceTimelineRebind?.();
win.__player?.seek?.(currentTime);
win.__hfStudioManualEditsApply?.();
};
// Load MotionPathPlugin on demand if the script uses motionPath.
// Uses the same CDN as composition templates (GSAP_CDN in constants.ts).
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
const pluginScript = doc.createElement("script");
pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
pluginScript.onload = () => executeScript();
pluginScript.onerror = () => executeScript();
doc.head.appendChild(pluginScript);
return;
}
executeScript();
};
try {
@@ -0,0 +1,74 @@
import { describe, expect, test } from "vitest";
import { computeSnapThreshold, snapKeyframe } from "./keyframeSnapping";
describe("snapKeyframe", () => {
test("snaps to frame boundary", () => {
const result = snapKeyframe(0.34, { fps: 30, keyframeTimes: [], threshold: 0.05 });
expect(result.snapType).toBe("frame");
expect(Math.abs(result.snappedTime - 1 / 3)).toBeLessThan(0.01);
});
test("snaps to cross-element keyframe when closest", () => {
const result = snapKeyframe(1.005, { fps: 30, keyframeTimes: [1.0], threshold: 0.05 });
expect(result.snapType).toBe("keyframe");
expect(result.snappedTime).toBe(1.0);
});
test("keyframe snap wins tie with frame at same position", () => {
const result = snapKeyframe(1.0, { fps: 30, keyframeTimes: [1.0], threshold: 0.05 });
expect(result.snapType).toBe("keyframe");
expect(result.snappedTime).toBe(1.0);
});
test("snaps to beat marker when closer than frame", () => {
const result = snapKeyframe(2.49, {
fps: 30,
keyframeTimes: [],
beatTimes: [2.5],
threshold: 0.05,
});
expect(result.snapType).toBe("beat");
expect(result.snappedTime).toBe(2.5);
});
test("disabled returns raw time", () => {
const result = snapKeyframe(1.5, {
fps: 30,
keyframeTimes: [1.5],
threshold: 0.05,
disabled: true,
});
expect(result.snapType).toBeNull();
expect(result.snappedTime).toBe(1.5);
});
test("no snap when outside threshold", () => {
const result = snapKeyframe(1.5, {
fps: 30,
keyframeTimes: [0.5],
threshold: 0.05,
});
expect(result.snapType).toBe("frame");
});
test("empty beat times is graceful", () => {
const result = snapKeyframe(0.5, {
fps: 30,
keyframeTimes: [],
beatTimes: [],
threshold: 0.05,
});
expect(result.snapType).toBe("frame");
});
});
describe("computeSnapThreshold", () => {
test("returns threshold based on pixels per second", () => {
const threshold = computeSnapThreshold(100, 5);
expect(threshold).toBe(0.05);
});
test("fallback for zero pixels per second", () => {
expect(computeSnapThreshold(0)).toBe(0.1);
});
});
@@ -0,0 +1,63 @@
export type SnapType = "frame" | "keyframe" | "beat" | null;
export interface SnapResult {
snappedTime: number;
snapType: SnapType;
}
export function snapKeyframe(
time: number,
options: {
fps: number;
keyframeTimes: number[];
beatTimes?: number[];
threshold: number;
disabled?: boolean;
},
): SnapResult {
if (options.disabled) return { snappedTime: time, snapType: null };
const { fps, keyframeTimes, beatTimes = [], threshold } = options;
let bestDist = threshold;
let bestTime = time;
let bestType: SnapType = null;
// Priority: cross-element keyframes > beat markers > frame boundaries
// Higher priority snaps use strict < so they win on equal distance
if (fps > 0) {
const frameDuration = 1 / fps;
const nearestFrame = Math.round(time / frameDuration) * frameDuration;
const dist = Math.abs(time - nearestFrame);
if (dist < bestDist) {
bestDist = dist;
bestTime = nearestFrame;
bestType = "frame";
}
}
for (const bt of beatTimes) {
const dist = Math.abs(time - bt);
if (dist <= bestDist) {
bestDist = dist;
bestTime = bt;
bestType = "beat";
}
}
for (const kt of keyframeTimes) {
const dist = Math.abs(time - kt);
if (dist <= bestDist) {
bestDist = dist;
bestTime = kt;
bestType = "keyframe";
}
}
return { snappedTime: bestTime, snapType: bestType };
}
export function computeSnapThreshold(pixelsPerSecond: number, baseThresholdPx: number = 5): number {
if (pixelsPerSecond <= 0) return 0.1;
return baseThresholdPx / pixelsPerSecond;
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Ramer-Douglas-Peucker simplification for time-series data.
*
* Used to reduce gesture recording samples into a minimal set of keyframes
* that approximate the original curve within a configurable tolerance.
*/
// ---------------------------------------------------------------------------
// 1D time-series simplification
// ---------------------------------------------------------------------------
/**
* Perpendicular distance from point (t, v) to the line segment between
* (t1, v1) and (t2, v2). For 1D time-series this reduces to the vertical
* distance from the point to the interpolated value on the line.
*/
function perpendicularDistance(
t: number,
v: number,
t1: number,
v1: number,
t2: number,
v2: number,
): number {
// Degenerate case: start and end share the same time
if (t2 === t1) return Math.abs(v - v1);
const interpolated = v1 + ((v2 - v1) * (t - t1)) / (t2 - t1);
return Math.abs(v - interpolated);
}
/**
* Standard Ramer-Douglas-Peucker on 1D time-series data.
*
* Each point is treated as (time, value) in 2D space. Returns the minimal
* subset of input points that approximates the curve within `epsilon`.
*
* - `epsilon = 0` returns all points (no simplification).
* - A large `epsilon` returns just the first and last points.
* - Empty or single-point input is returned unchanged.
*/
function simplifyTimeSeries(
points: Array<{ time: number; value: number }>,
epsilon: number,
): Array<{ time: number; value: number }> {
if (points.length <= 2) return points;
if (epsilon <= 0) return points;
const first = points[0];
const last = points[points.length - 1];
let maxDist = 0;
let maxIndex = 0;
for (let i = 1; i < points.length - 1; i++) {
const d = perpendicularDistance(
points[i].time,
points[i].value,
first.time,
first.value,
last.time,
last.value,
);
if (d > maxDist) {
maxDist = d;
maxIndex = i;
}
}
if (maxDist > epsilon) {
const left = simplifyTimeSeries(points.slice(0, maxIndex + 1), epsilon);
const right = simplifyTimeSeries(points.slice(maxIndex), epsilon);
// left includes maxIndex, right starts with maxIndex — drop the duplicate
return left.slice(0, -1).concat(right);
}
return [first, last];
}
// ---------------------------------------------------------------------------
// Multi-property gesture simplification
// ---------------------------------------------------------------------------
/**
* Simplify gesture recording samples into percentage-keyed keyframes.
*
* Runs `simplifyTimeSeries` independently per property across all samples,
* then merges the retained time points into a single Map keyed by percentage
* of `totalDuration` (0100, rounded to 1 decimal).
*
* Independent per-property simplification means that complex motion on one
* property (e.g. `x`) does not force extra keyframes on a simpler property
* (e.g. `opacity`).
*
* At each retained percentage the output contains all properties interpolated
* at that time not just the property that caused the time point to survive.
*/
export function simplifyGestureSamples(
samples: Array<{ time: number; properties: Record<string, number> }>,
totalDuration: number,
epsilon: number,
): Map<number, Record<string, number>> {
if (samples.length === 0) return new Map();
if (totalDuration <= 0) return new Map();
// Collect all property keys present across samples
const propertyKeys = new Set<string>();
for (const s of samples) {
for (const key of Object.keys(s.properties)) {
propertyKeys.add(key);
}
}
// Run RDP independently per property and collect surviving times
const survivingTimes = new Set<number>();
for (const key of propertyKeys) {
const series: Array<{ time: number; value: number }> = [];
for (const s of samples) {
if (key in s.properties) {
series.push({ time: s.time, value: s.properties[key] });
}
}
const simplified = simplifyTimeSeries(series, epsilon);
for (const pt of simplified) {
survivingTimes.add(pt.time);
}
}
// Sort surviving times so we can iterate in order
const sortedTimes = Array.from(survivingTimes).sort((a, b) => a - b);
// For each surviving time, interpolate all properties and store by percentage
const result = new Map<number, Record<string, number>>();
for (const t of sortedTimes) {
const pct = Math.round((t / totalDuration) * 1000) / 10; // 1 decimal
const props: Record<string, number> = {};
for (const key of propertyKeys) {
props[key] = interpolatePropertyAtTime(samples, key, t);
}
result.set(pct, props);
}
return result;
}
/**
* Linearly interpolate a single property value at the given time from the
* samples array. Assumes samples are sorted by time.
*/
function interpolatePropertyAtTime(
samples: Array<{ time: number; properties: Record<string, number> }>,
key: string,
t: number,
): number {
// Find bracketing samples that contain this property
let before: { time: number; value: number } | undefined;
let after: { time: number; value: number } | undefined;
for (const s of samples) {
if (!(key in s.properties)) continue;
const v = s.properties[key];
if (s.time <= t) {
before = { time: s.time, value: v };
}
if (s.time >= t && after === undefined) {
after = { time: s.time, value: v };
}
}
// Exact match or only one side available
if (before && before.time === t) return before.value;
if (after && after.time === t) return after.value;
if (!before) return after!.value;
if (!after) return before.value;
// Linear interpolation
const ratio = (t - before.time) / (after.time - before.time);
return before.value + (after.value - before.value) * ratio;
}
+11
View File
@@ -17,6 +17,17 @@ export default {
muted: "#737373",
accent: "#3CE6AC",
},
panel: {
bg: "#0a0a0a",
border: "#262626",
hover: "#1a1a1a",
accent: "#3CE6AC",
"text-1": "#FAFAFA",
"text-2": "#A1A1AA",
"text-3": "#71717A",
"text-4": "#52525B",
"text-5": "#3F3F46",
},
},
},
},