feat(studio): keyframe system — parser, runtime, timeline UI, design panel, gesture recording (#1311)

* feat(studio): runtime hooks — global time compiler + keyframe runtime

Add the runtime bridge layer: global time compilation (tween % → clip %),
soft reload after mutations, runtime keyframe preview, and keyframe
commit helper.

* feat(studio): runtime hooks — global time compiler + keyframe runtime

Add the runtime bridge layer: global time compilation (tween % → clip %),
soft reload after mutations, runtime keyframe preview, and keyframe
commit helper.

* feat(studio): keyframe cache + commit hooks

Add hooks for keyframe cache population (tween → clip-relative %),
mutation dispatch, keyframe snapping, and audio beat detection.

* feat(studio): timeline UI — dopesheet diamonds + keyboard nav

Add dopesheet strip with diamond keyframe indicators, timeline property
rows, keyboard navigation (J/Shift+J/Delete/K), and feature gate
(STUDIO_KEYFRAMES_ENABLED defaults to false).

* feat(studio): design panel — arc controls + ease curve + stagger

Add arc path controls (curviness slider, auto-rotate), motion path SVG
overlay, ease curve visualization, stagger controls, and expanded
animation card. Includes border-radius editor dependency from #1217.

* feat(studio): gesture recording core

Add gesture recording engine with RAF sampling, modifier key property
mapping (Shift→rotationXY, Alt→rotation, Cmd→opacity),
Ramer-Douglas-Peucker simplification, and ghost trail SVG overlay.

* fix(studio): keyframe drag + recording bug bash

21 fixes: capture GSAP base at drag start, translate:none before
gsap.set, skip reapplyPathOffsets for GSAP elements, clamp recording
seek, _auto flag for 100% keyframes, overlay flash fix, block edits
during recording.

* feat(studio): keyframe integration wiring + docs

Wire App.tsx recording orchestration, TimelineToolbar K/R buttons,
PropertyPanel per-property diamonds, shortcuts panel, toast
notifications, and keyframes guide documentation. All gated on
STUDIO_KEYFRAMES_ENABLED (default false).
This commit is contained in:
Miguel Ángel
2026-06-09 18:30:23 -04:00
committed by GitHub
parent 96b8d617d8
commit a468550f82
72 changed files with 4421 additions and 621 deletions
@@ -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}