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