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
@@ -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,
};
}