Skip to content

Commit 93db037

Browse files
committed
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).
1 parent 4a682ec commit 93db037

10 files changed

Lines changed: 561 additions & 34 deletions

File tree

packages/studio/src/components/TimelineToolbar.tsx

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,47 @@ function readRuntimeKeyframeValues(
8080
return result;
8181
}
8282

83+
// fallow-ignore-next-line complexity
84+
function readRuntimeValuesForAnim(
85+
iframe: HTMLIFrameElement | null,
86+
sel: DomEditSelection,
87+
anim: GsapAnimation,
88+
): Record<string, number> {
89+
if (!iframe?.contentWindow) return {};
90+
let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
91+
try {
92+
gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
93+
} catch {
94+
return {};
95+
}
96+
if (!gsap?.getProperty) return {};
97+
const selector = sel.id ? `#${sel.id}` : sel.selector;
98+
if (!selector) return {};
99+
let doc: Document | null = null;
100+
try {
101+
doc = iframe.contentDocument;
102+
} catch {
103+
return {};
104+
}
105+
const element = doc?.querySelector(selector);
106+
if (!element) return {};
107+
const result: Record<string, number> = {};
108+
for (const prop of Object.keys(anim.properties)) {
109+
const val = Number(gsap.getProperty(element, prop));
110+
if (Number.isFinite(val)) result[prop] = Math.round(val);
111+
}
112+
return result;
113+
}
114+
83115
interface DomEditSessionSlice {
84116
domEditSelection: DomEditSelection | null;
85117
selectedGsapAnimations: GsapAnimation[];
86118
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
87119
handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
88-
handleGsapConvertToKeyframes: (animId: string) => void;
120+
handleGsapConvertToKeyframes: (
121+
animId: string,
122+
resolvedFromValues?: Record<string, number | string>,
123+
) => void;
89124
handleGsapMaterializeKeyframes?: (animId: string) => Promise<void>;
90125
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
91126
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
@@ -154,7 +189,15 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
154189
}
155190
}
156191
} else if (flatAnim) {
157-
session.handleGsapConvertToKeyframes(flatAnim.id);
192+
const runtimeProps = readRuntimeValuesForAnim(
193+
session.previewIframeRef?.current ?? null,
194+
sel,
195+
flatAnim,
196+
);
197+
session.handleGsapConvertToKeyframes(
198+
flatAnim.id,
199+
Object.keys(runtimeProps).length > 0 ? runtimeProps : undefined,
200+
);
158201
} else {
159202
session.handleGsapAddAnimation("to");
160203
}

packages/studio/src/components/editor/DomEditOverlay.tsx

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,29 @@ export const DomEditOverlay = memo(function DomEditOverlay({
9090
}: DomEditOverlayProps) {
9191
const overlayRef = useRef<HTMLDivElement | null>(null);
9292
const boxRef = useRef<HTMLDivElement | null>(null);
93+
94+
const selectionShapeStyles = (() => {
95+
const fallback = {
96+
borderRadius: 4 as string | number,
97+
clipPath: undefined as string | undefined,
98+
};
99+
if (!selection?.element) return fallback;
100+
try {
101+
const tag = selection.element.tagName.toLowerCase();
102+
if (tag === "svg" || tag === "img" || tag === "video" || tag === "canvas") return fallback;
103+
const win = selection.element.ownerDocument.defaultView;
104+
if (!win) return fallback;
105+
const cs = win.getComputedStyle(selection.element);
106+
const br = cs.borderRadius;
107+
const cp = cs.clipPath;
108+
return {
109+
borderRadius: br && br !== "0px" ? br : 4,
110+
clipPath: cp && cp !== "none" ? cp : undefined,
111+
};
112+
} catch {
113+
return fallback;
114+
}
115+
})();
93116
const gestureRef = useRef<GestureState | null>(null);
94117
const groupGestureRef = useRef<GroupGestureState | null>(null);
95118
const blockedMoveRef = useRef<BlockedMoveState | null>(null);
@@ -134,6 +157,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
134157
groupOverlayItems,
135158
groupOverlayItemsRef,
136159
setGroupOverlayItems,
160+
childRects,
137161
} = useDomEditOverlayRects({
138162
iframeRef,
139163
overlayRef,
@@ -324,13 +348,30 @@ export const DomEditOverlay = memo(function DomEditOverlay({
324348
<div
325349
aria-hidden="true"
326350
data-dom-edit-hover-box="true"
327-
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)]"
328-
style={{
329-
left: hoverRect.left,
330-
top: hoverRect.top,
331-
width: hoverRect.width,
332-
height: hoverRect.height,
333-
}}
351+
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)]"
352+
style={(() => {
353+
let br: string | number = 4;
354+
let cp: string | undefined;
355+
try {
356+
const el = hoverSelection.element;
357+
const tag = el.tagName.toLowerCase();
358+
if (tag !== "svg" && tag !== "img" && tag !== "video" && tag !== "canvas") {
359+
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
360+
if (cs?.borderRadius && cs.borderRadius !== "0px") br = cs.borderRadius;
361+
if (cs?.clipPath && cs.clipPath !== "none") cp = cs.clipPath;
362+
}
363+
} catch {
364+
/* cross-origin guard */
365+
}
366+
return {
367+
left: hoverRect.left,
368+
top: hoverRect.top,
369+
width: hoverRect.width,
370+
height: hoverRect.height,
371+
borderRadius: br,
372+
clipPath: cp,
373+
};
374+
})()}
334375
/>
335376
)}
336377
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && (
@@ -398,12 +439,14 @@ export const DomEditOverlay = memo(function DomEditOverlay({
398439
key={selectionKey}
399440
ref={boxRef}
400441
data-dom-edit-selection-box="true"
401-
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)]"
442+
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`}
402443
style={{
403444
left: overlayRect.left,
404445
top: overlayRect.top,
405446
width: overlayRect.width,
406447
height: overlayRect.height,
448+
borderRadius: selectionShapeStyles.borderRadius,
449+
clipPath: selectionShapeStyles.clipPath,
407450
cursor:
408451
allowCanvasMovement && selection.capabilities.canApplyManualOffset
409452
? "move"
@@ -441,6 +484,19 @@ export const DomEditOverlay = memo(function DomEditOverlay({
441484
</div>
442485
</>
443486
)}
487+
{childRects.length > 0 &&
488+
childRects.map((cr, i) => (
489+
<div
490+
key={i}
491+
className="pointer-events-none absolute border border-dashed border-white/20 rounded-sm"
492+
style={{
493+
left: cr.left,
494+
top: cr.top,
495+
width: cr.width,
496+
height: cr.height,
497+
}}
498+
/>
499+
))}
444500
<GridOverlay
445501
visible={gridVisible}
446502
spacing={gridSpacing}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { memo, useCallback, useRef } from "react";
2+
3+
interface DopesheetKeyframe {
4+
percentage: number;
5+
properties: Record<string, number | string>;
6+
ease?: string;
7+
}
8+
9+
interface DopesheetStripProps {
10+
keyframes: DopesheetKeyframe[];
11+
selectedPercentage: number | null;
12+
currentPercentage: number;
13+
accentColor?: string;
14+
onSelectKeyframe: (percentage: number) => void;
15+
onDragKeyframe?: (fromPct: number, toPct: number) => void;
16+
}
17+
18+
const DIAMOND_SIZE = 8;
19+
const HALF = DIAMOND_SIZE / 2;
20+
const STRIP_HEIGHT = 20;
21+
const PADDING_X = 8;
22+
23+
export const DopesheetStrip = memo(function DopesheetStrip({
24+
keyframes,
25+
selectedPercentage,
26+
currentPercentage,
27+
accentColor = "#3CE6AC",
28+
onSelectKeyframe,
29+
onDragKeyframe,
30+
}: DopesheetStripProps) {
31+
const containerRef = useRef<HTMLDivElement>(null);
32+
const dragRef = useRef<{ startX: number; startPct: number } | null>(null);
33+
34+
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
35+
36+
const handlePointerDown = useCallback(
37+
(e: React.PointerEvent, pct: number) => {
38+
if (e.button !== 0) return;
39+
e.stopPropagation();
40+
const startX = e.clientX;
41+
42+
const handleMove = (me: PointerEvent) => {
43+
if (Math.abs(me.clientX - startX) > 4) {
44+
dragRef.current = { startX, startPct: pct };
45+
}
46+
};
47+
48+
const handleUp = (ue: PointerEvent) => {
49+
document.removeEventListener("pointermove", handleMove);
50+
document.removeEventListener("pointerup", handleUp);
51+
if (dragRef.current && containerRef.current && onDragKeyframe) {
52+
const rect = containerRef.current.getBoundingClientRect();
53+
const usableWidth = rect.width - PADDING_X * 2;
54+
const dx = ue.clientX - dragRef.current.startX;
55+
const dpct = (dx / usableWidth) * 100;
56+
const newPct = Math.max(0, Math.min(100, Math.round((pct + dpct) * 10) / 10));
57+
if (newPct !== pct) onDragKeyframe(pct, newPct);
58+
} else {
59+
onSelectKeyframe(pct);
60+
}
61+
dragRef.current = null;
62+
};
63+
64+
document.addEventListener("pointermove", handleMove);
65+
document.addEventListener("pointerup", handleUp);
66+
},
67+
[onSelectKeyframe, onDragKeyframe],
68+
);
69+
70+
return (
71+
<div
72+
ref={containerRef}
73+
className="relative w-full rounded-md bg-neutral-900/60 border border-neutral-800/50"
74+
style={{ height: STRIP_HEIGHT }}
75+
>
76+
{/* Playhead indicator */}
77+
<div
78+
className="absolute top-0 bottom-0 w-px bg-white/30"
79+
style={{
80+
left: `${PADDING_X + (currentPercentage / 100) * (100 - PADDING_X * 2)}%`,
81+
marginLeft: -0.5,
82+
}}
83+
/>
84+
85+
{/* Diamond markers */}
86+
<svg
87+
className="absolute inset-0 w-full"
88+
style={{ height: STRIP_HEIGHT }}
89+
viewBox={`0 0 100 ${STRIP_HEIGHT}`}
90+
preserveAspectRatio="none"
91+
>
92+
{sorted.map((kf) => {
93+
const x = PADDING_X + (kf.percentage / 100) * (100 - PADDING_X * 2);
94+
const y = STRIP_HEIGHT / 2;
95+
const isSelected =
96+
selectedPercentage !== null && Math.abs(kf.percentage - selectedPercentage) < 0.5;
97+
const isHold = kf.ease === "steps(1)";
98+
const fillColor = isSelected ? accentColor : "#737373";
99+
100+
return (
101+
<g
102+
key={kf.percentage}
103+
onPointerDown={(e) => handlePointerDown(e, kf.percentage)}
104+
style={{ cursor: "pointer" }}
105+
>
106+
{isHold ? (
107+
<rect
108+
x={x - HALF}
109+
y={y - HALF}
110+
width={DIAMOND_SIZE}
111+
height={DIAMOND_SIZE}
112+
fill={fillColor}
113+
/>
114+
) : (
115+
<rect
116+
x={x - HALF}
117+
y={y - HALF}
118+
width={DIAMOND_SIZE}
119+
height={DIAMOND_SIZE}
120+
fill={fillColor}
121+
transform={`rotate(45, ${x}, ${y})`}
122+
/>
123+
)}
124+
</g>
125+
);
126+
})}
127+
</svg>
128+
129+
{/* Time labels */}
130+
{sorted.length > 0 && (
131+
<div
132+
className="absolute bottom-0 left-0 right-0 flex justify-between px-2 text-[8px] text-neutral-600 pointer-events-none"
133+
style={{ lineHeight: "10px" }}
134+
>
135+
<span>{sorted[0].percentage}%</span>
136+
{sorted.length > 1 && <span>{sorted[sorted.length - 1].percentage}%</span>}
137+
</div>
138+
)}
139+
</div>
140+
);
141+
});

packages/studio/src/components/editor/KeyframeDiamond.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ interface KeyframeDiamondProps {
77
onClick: () => void;
88
title?: string;
99
size?: number;
10+
isHold?: boolean;
1011
}
1112

1213
// fallow-ignore-next-line complexity
@@ -15,10 +16,11 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
1516
onClick,
1617
title,
1718
size = 10,
19+
isHold = false,
1820
}: KeyframeDiamondProps) {
1921
const isFilled = state === "active";
2022
const opacity = state === "ghost" ? 0.25 : state === "inactive" ? 0.6 : 1;
21-
const color = state === "active" ? "#3b82f6" : "#a3a3a3";
23+
const color = state === "active" ? "#3CE6AC" : "#a3a3a3";
2224

2325
return (
2426
<button
@@ -32,17 +34,30 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
3234
title={title}
3335
>
3436
<svg width={size} height={size} viewBox="0 0 10 10">
35-
<rect
36-
x="5"
37-
y="0.7"
38-
width="6"
39-
height="6"
40-
rx="1"
41-
transform="rotate(45 5 0.7)"
42-
fill={isFilled ? "currentColor" : "none"}
43-
stroke="currentColor"
44-
strokeWidth="1.2"
45-
/>
37+
{isHold ? (
38+
<rect
39+
x="2"
40+
y="2"
41+
width="6"
42+
height="6"
43+
rx="0.5"
44+
fill={isFilled ? "currentColor" : "none"}
45+
stroke="currentColor"
46+
strokeWidth="1.2"
47+
/>
48+
) : (
49+
<rect
50+
x="5"
51+
y="0.7"
52+
width="6"
53+
height="6"
54+
rx="1"
55+
transform="rotate(45 5 0.7)"
56+
fill={isFilled ? "currentColor" : "none"}
57+
stroke="currentColor"
58+
strokeWidth="1.2"
59+
/>
60+
)}
4661
</svg>
4762
</button>
4863
);

0 commit comments

Comments
 (0)