feat(studio): GSAP tween editing in Design panel (#1102)

* feat(studio): GSAP tween editing in Design panel

Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.

Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.

recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:

- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
  conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
  reachable only via the @hyperframes/core/gsap-parser subpath, loaded
  server-side by the studio-api mutation routes and the linter via dynamic
  import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
  bundles never trace recast.

Adds AST parser unit + stress coverage and e2e helpers for the panel.

* fix(lint): await async lintHyperframeHtml in all callers

lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.

Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
This commit is contained in:
Miguel Ángel
2026-05-28 19:16:34 -04:00
committed by GitHub
parent e16f916448
commit fb2e21090f
61 changed files with 4354 additions and 1128 deletions
@@ -78,6 +78,15 @@ export function StudioRightPanel({
handleAskAgent,
handleDomMotionCommit,
handleDomMotionClear,
selectedGsapAnimations,
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
} = useDomEditContext();
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
@@ -198,6 +207,15 @@ export function StudioRightPanel({
onImportAssets={handleImportFiles}
fontAssets={fontAssets}
onImportFonts={handleImportFonts}
gsapAnimations={selectedGsapAnimations}
gsapMultipleTimelines={gsapMultipleTimelines}
gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
onUpdateGsapProperty={handleGsapUpdateProperty}
onUpdateGsapMeta={handleGsapUpdateMeta}
onDeleteGsapAnimation={handleGsapDeleteAnimation}
onAddGsapProperty={handleGsapAddProperty}
onRemoveGsapProperty={handleGsapRemoveProperty}
onAddGsapAnimation={handleGsapAddAnimation}
/>
) : motionPanelActive ? (
<MotionPanel
@@ -0,0 +1,325 @@
import { memo, useCallback, useMemo, useState } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
import { MetricField, SelectField } from "./propertyPanelPrimitives";
import { controlPointsForGsapEase } from "./studioMotion";
import {
EASE_LABELS,
METHOD_LABELS,
METHOD_TOOLTIPS,
PROP_LABELS,
PROP_TOOLTIPS,
PROP_UNITS,
} from "./gsapAnimationConstants";
import { EaseCurveSection } from "./EaseCurveSection";
const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
function isPercentProp(prop: string): boolean {
return PERCENT_PROPS.has(prop);
}
function buildTweenSummary(animation: GsapAnimation): string {
const easeName = animation.ease ?? "none";
const ease = EASE_LABELS[easeName] ?? easeName;
const props = Object.entries(animation.properties);
const target = animation.targetSelector;
const dur = animation.duration ?? 0;
const pos = animation.position;
const propDescs = props.map(([p, v]) => {
const label = (PROP_LABELS[p] ?? p).toLowerCase();
const unit = PROP_UNITS[p] ?? "";
return `${label} to ${v}${unit}`;
});
const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet";
if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`;
if (animation.method === "from")
return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`;
return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`;
}
function parseNumericOrString(raw: string): number | string {
const num = Number(raw);
return Number.isFinite(num) ? num : raw;
}
interface AnimationCardProps {
animation: GsapAnimation;
defaultExpanded: boolean;
onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
onUpdateMeta: (
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => void;
onDeleteAnimation: (animationId: string) => void;
onAddProperty: (animationId: string, property: string) => void;
onRemoveProperty: (animationId: string, property: string) => void;
onLivePreview?: (property: string, value: number | string) => void;
onLivePreviewEnd?: () => void;
}
// fallow-ignore-next-line complexity
export const AnimationCard = memo(function AnimationCard({
animation,
defaultExpanded,
onUpdateProperty,
onUpdateMeta,
onDeleteAnimation,
onAddProperty,
onRemoveProperty,
onLivePreview,
onLivePreviewEnd,
}: AnimationCardProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [addingProp, setAddingProp] = useState(false);
const usedProps = useMemo(
() => new Set(Object.keys(animation.properties)),
[animation.properties],
);
const availableProps = useMemo(
() => SUPPORTED_PROPS.filter((p) => !usedProps.has(p)),
[usedProps],
);
const commitProperty = useCallback(
(prop: string, raw: string) => {
const value = parseNumericOrString(raw);
onUpdateProperty(animation.id, prop, value);
onLivePreviewEnd?.();
},
[animation.id, onUpdateProperty, onLivePreviewEnd],
);
const scrubProperty = useCallback(
(prop: string, raw: string) => {
onLivePreview?.(prop, parseNumericOrString(raw));
},
[onLivePreview],
);
const commitDuration = useCallback(
(raw: string) => {
const num = Number(raw);
if (Number.isFinite(num) && num >= 0)
onUpdateMeta(animation.id, { duration: Math.max(0, num) });
},
[animation.id, onUpdateMeta],
);
const commitPosition = useCallback(
(raw: string) => {
const num = Number(raw);
if (Number.isFinite(num) && num >= 0)
onUpdateMeta(animation.id, { position: Math.max(0, num) });
},
[animation.id, onUpdateMeta],
);
const [copied, setCopied] = useState(false);
const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
const easeName = animation.ease ?? "none";
const easeLabel = easeName.startsWith("custom(")
? "Custom curve"
: (EASE_LABELS[easeName] ?? easeName);
const endTime =
typeof animation.position === "number"
? animation.position + (animation.duration ?? 0)
: animation.position;
const summary = useMemo(() => buildTweenSummary(animation), [animation]);
return (
<div className="border-b border-neutral-800 pb-3">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
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"
title={METHOD_TOOLTIPS[animation.method]}
>
{methodLabel}
</span>
<span className="text-[11px] font-medium text-neutral-400" title="When this effect plays">
{typeof animation.position === "number" ? `${animation.position}s` : animation.position} {" "}
{typeof endTime === "number" ? `${endTime.toFixed(1)}s` : endTime}
</span>
<span className="ml-auto text-[10px] text-neutral-500" title={easeName}>
{easeLabel}
</span>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className={`flex-shrink-0 text-neutral-500 transition-transform ${expanded ? "" : "-rotate-90"}`}
>
<path d="M2 3l3 4 3-4z" />
</svg>
</button>
{expanded && (
<div className="pt-2">
<div className="space-y-3">
<div className="flex items-start gap-2">
<p className="flex-1 text-[10px] leading-relaxed text-neutral-400 italic">
{summary}
</p>
<button
type="button"
onClick={() => {
void navigator.clipboard.writeText(summary);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
className="flex-shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
title="Copy description to clipboard — paste into agent prompts"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
<div className={RESPONSIVE_GRID}>
{animation.method !== "set" && (
<MetricField
label="Length"
value={String(Math.max(0, animation.duration ?? 0))}
suffix="s"
tooltip="How long this effect lasts"
onCommit={commitDuration}
/>
)}
<MetricField
label="Starts at"
value={
typeof animation.position === "string"
? animation.position
: String(Math.max(0, animation.position))
}
suffix={typeof animation.position === "number" ? "s" : undefined}
tooltip="When this effect begins on the timeline"
onCommit={commitPosition}
/>
</div>
{animation.method !== "set" && (
<>
<SelectField
label="Speed"
value={
animation.ease?.startsWith("custom(") ? "custom" : (animation.ease ?? "none")
}
options={[...SUPPORTED_EASES, "custom"]}
onChange={(next) => {
if (next === "custom") {
const points = controlPointsForGsapEase(animation.ease ?? "power2.out");
const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
onUpdateMeta(animation.id, { ease: `custom(${path})` });
} else {
onUpdateMeta(animation.id, { ease: next });
}
}}
/>
<EaseCurveSection
ease={animation.ease ?? "none"}
duration={animation.duration}
onCustomEaseCommit={(customEase) =>
onUpdateMeta(animation.id, { ease: customEase })
}
/>
</>
)}
{Object.keys(animation.properties).length > 0 && (
<div className="space-y-1.5">
{Object.entries(animation.properties).map(([prop, val]) => (
<div key={prop} className="flex items-center gap-1">
<div className="min-w-0 flex-1">
<MetricField
label={PROP_LABELS[prop] ?? prop}
value={
isPercentProp(prop) ? String(Math.round(Number(val) * 100)) : String(val)
}
suffix={PROP_UNITS[prop]}
tooltip={PROP_TOOLTIPS[prop]}
scrub
liveCommit
onCommit={(raw) => {
const adjusted = isPercentProp(prop) ? String(Number(raw) / 100) : raw;
scrubProperty(prop, adjusted);
commitProperty(prop, adjusted);
}}
/>
</div>
<button
type="button"
onClick={() => onRemoveProperty(animation.id, prop)}
className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
title={`Remove ${PROP_LABELS[prop] ?? prop}`}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-2 pt-1">
{addingProp && availableProps.length > 0 ? (
<select
autoFocus
className="min-w-0 rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-[11px] text-neutral-100 outline-none"
defaultValue=""
onChange={(e) => {
if (e.target.value) onAddProperty(animation.id, e.target.value);
setAddingProp(false);
}}
onBlur={() => setAddingProp(false)}
>
<option value="" disabled>
Choose effect
</option>
{availableProps.map((p) => (
<option key={p} value={p}>
{PROP_LABELS[p] ?? p}
</option>
))}
</select>
) : (
availableProps.length > 0 && (
<button
type="button"
onClick={() => setAddingProp(true)}
className="text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200"
title="Add another animated property to this effect"
>
+ Effect
</button>
)
)}
<button
type="button"
onClick={() => onDeleteAnimation(animation.id)}
className="ml-auto text-[11px] font-medium text-red-400 transition-colors hover:text-red-300"
title="Remove this animation"
>
Remove
</button>
</div>
</div>
</div>
)}
</div>
);
});
@@ -0,0 +1,213 @@
import { useCallback, useRef, useState } from "react";
import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
export function EaseCurveSection({
ease,
duration,
onCustomEaseCommit,
}: {
ease: string;
duration?: number;
onCustomEaseCommit: (ease: string) => void;
}) {
const isCustom = ease.startsWith("custom(");
const curveFromPreset = EASE_CURVES[ease];
const customPoints = isCustom ? parseCustomEaseFromString(ease) : null;
const curve: [number, number, number, number] | null =
isCustom && customPoints
? [customPoints.x1, customPoints.y1, customPoints.x2, customPoints.y2]
: (curveFromPreset ?? null);
const [draft, setDraft] = useState<[number, number, number, number] | null>(null);
const [progress, setProgress] = useState<number | null>(null);
const draggingRef = useRef<"p1" | "p2" | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const rafRef = useRef<number>(0);
const play = useCallback(() => {
const start = performance.now();
const dur = 1000;
const tick = (now: number) => {
const t = Math.min((now - start) / dur, 1);
setProgress(t);
if (t < 1) rafRef.current = requestAnimationFrame(tick);
else setTimeout(() => setProgress(null), 400);
};
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(tick);
}, []);
const active = draft ?? curve;
if (!active) return null;
const [x1, y1, x2, y2] = active;
const w = 200;
const h = 100;
const pad = 14;
const gw = w - pad * 2;
const gh = h - pad * 2;
const toSvg = (px: number, py: number) => ({
x: pad + gw * px,
y: h - pad - gh * py,
});
const curvePath = `M${pad},${h - pad} C${toSvg(x1, y1).x},${toSvg(x1, y1).y} ${toSvg(x2, y2).x},${toSvg(x2, y2).y} ${w - pad},${pad}`;
let dotX = pad;
let dotY = h - pad;
if (progress !== null) {
const t = progress;
const mt = 1 - t;
dotX = pad + gw * (mt * mt * mt * 0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t);
dotY =
h - pad - gh * (mt * mt * mt * 0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t);
}
const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
draggingRef.current = handle;
(e.target as SVGElement).setPointerCapture(e.pointerId);
if (!draft) setDraft([x1, y1, x2, y2]);
};
const handlePointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
if (!draggingRef.current || !svgRef.current) return;
e.preventDefault();
const rect = svgRef.current.getBoundingClientRect();
const sx = ((e.clientX - rect.left) / rect.width) * w;
const sy = ((e.clientY - rect.top) / rect.height) * h;
const px = Math.max(0, Math.min(1, (sx - pad) / gw));
const py = Math.max(-1, Math.min(2, (h - pad - sy) / gh));
const prev = draft ?? [x1, y1, x2, y2];
const next: [number, number, number, number] =
draggingRef.current === "p1"
? [round2(px), round2(py), prev[2], prev[3]]
: [prev[0], prev[1], round2(px), round2(py)];
setDraft(next);
};
const handlePointerUp = () => {
if (!draggingRef.current || !draft) return;
draggingRef.current = null;
const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`;
onCustomEaseCommit(`custom(${path})`);
setDraft(null);
};
const p1 = toSvg(x1, y1);
const p2 = toSvg(x2, y2);
const start = toSvg(0, 0);
const end = toSvg(1, 1);
const label = isCustom ? "Custom curve" : (EASE_LABELS[ease] ?? ease);
return (
<div className="rounded-lg bg-neutral-900/50 p-2">
<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"
>
{progress !== null ? "Playing…" : "Preview"}
</button>
</div>
<div className="overflow-hidden rounded pt-[72px] -mt-[72px]">
<svg
ref={svgRef}
width="100%"
height={h}
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
style={{ overflow: "visible" }}
className="touch-none select-none"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<line
x1={pad}
y1={h - pad}
x2={w - pad}
y2={h - pad}
stroke="white"
strokeOpacity="0.06"
strokeWidth="0.5"
/>
<line
x1={pad}
y1={pad}
x2={pad}
y2={h - pad}
stroke="white"
strokeOpacity="0.06"
strokeWidth="0.5"
/>
<line
x1={start.x}
y1={start.y}
x2={p1.x}
y2={p1.y}
stroke="rgba(52,211,153,0.25)"
strokeWidth="1"
/>
<line
x1={end.x}
y1={end.y}
x2={p2.x}
y2={p2.y}
stroke="rgba(52,211,153,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" />}
<circle
cx={p1.x}
cy={p1.y}
r="5"
fill="#0a0a1a"
stroke="#34d399"
strokeWidth="2"
className="cursor-grab active:cursor-grabbing"
onPointerDown={(e) => handlePointerDown("p1", e)}
/>
<circle
cx={p2.x}
cy={p2.y}
r="5"
fill="#0a0a1a"
stroke="#34d399"
strokeWidth="2"
className="cursor-grab active:cursor-grabbing"
onPointerDown={(e) => handlePointerDown("p2", e)}
/>
{duration != null && duration > 0 && (
<>
<text x={pad} y={h - 1} textAnchor="start" className="fill-neutral-600 text-[8px]">
0s
</text>
<text
x={pad + gw / 2}
y={h - 1}
textAnchor="middle"
className="fill-neutral-600 text-[8px]"
>
{(duration / 2).toFixed(1)}s
</text>
<text x={w - pad} y={h - 1} textAnchor="end" className="fill-neutral-600 text-[8px]">
{duration}s
</text>
</>
)}
</svg>
</div>
<p className="mt-1 text-center text-[10px] text-neutral-500">{label}</p>
</div>
);
}
@@ -0,0 +1,112 @@
import { memo, useState } from "react";
import type { 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";
import { AnimationCard } from "./AnimationCard";
interface GsapAnimationSectionProps {
animations: GsapAnimation[];
multipleTimelines?: boolean;
unsupportedTimelinePattern?: boolean;
onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
onUpdateMeta: (
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => void;
onDeleteAnimation: (animationId: string) => void;
onAddProperty: (animationId: string, property: string) => void;
onRemoveProperty: (animationId: string, property: string) => void;
onAddAnimation: (method: "to" | "from" | "set") => void;
onLivePreview?: (property: string, value: number | string) => void;
onLivePreviewEnd?: () => void;
}
export const GsapAnimationSection = memo(function GsapAnimationSection({
animations,
multipleTimelines,
unsupportedTimelinePattern,
onUpdateProperty,
onUpdateMeta,
onDeleteAnimation,
onAddProperty,
onRemoveProperty,
onAddAnimation,
onLivePreview,
onLivePreviewEnd,
}: GsapAnimationSectionProps) {
const [addMenuOpen, setAddMenuOpen] = useState(false);
return (
<Section title="Animation" icon={<Film size={15} />}>
{multipleTimelines && (
<p className="mb-2 rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
This file has multiple GSAP timelines. Animation editing is disabled to prevent data loss
consolidate into a single timeline to enable editing.
</p>
)}
{unsupportedTimelinePattern && (
<p className="mb-2 rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
This composition uses a timeline assignment pattern (window.__timelines[...]) that the
editor doesn&apos;t support. Use a variable declaration (const tl = gsap.timeline()) to
enable editing.
</p>
)}
{multipleTimelines || unsupportedTimelinePattern ? null : (
<div className="space-y-2">
{animations.map((anim, index) => (
<AnimationCard
key={anim.id}
animation={anim}
defaultExpanded={index === 0}
onUpdateProperty={onUpdateProperty}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={onDeleteAnimation}
onAddProperty={onAddProperty}
onRemoveProperty={onRemoveProperty}
onLivePreview={onLivePreview}
onLivePreviewEnd={onLivePreviewEnd}
/>
))}
<div className="relative pt-1">
{addMenuOpen ? (
<div className="flex gap-1.5">
{ADD_METHODS.map((method) => (
<button
key={method}
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
onAddAnimation(method);
setAddMenuOpen(false);
}}
className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
>
{ADD_METHOD_LABELS[method] ?? method}
</button>
))}
<button
type="button"
onClick={() => setAddMenuOpen(false)}
className="px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setAddMenuOpen(true)}
className="text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200"
title="Add a new animation effect to this element"
>
+ Add effect
</button>
)}
</div>
</div>
)}
</Section>
);
});
@@ -1,12 +1,7 @@
import { memo } from "react";
import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
import { type DomEditSelection } from "./domEditing";
import {
readStudioBoxSize,
readStudioPathOffset,
readStudioRotation,
readGsapTranslateFromTransform,
} from "./manualEdits";
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
import type { ImportedFontAsset } from "./fontAssets";
import {
EMPTY_STYLES,
@@ -18,12 +13,13 @@ import {
import { MetricField, Section } from "./propertyPanelPrimitives";
import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
import { TextSection, StyleSections } from "./propertyPanelSections";
import { GsapAnimationSection } from "./GsapAnimationSection";
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
// Re-export helpers that external consumers import from this module
export {
buildStrokeStyleUpdates,
buildStrokeWidthStyleUpdates,
clampPanelNumber,
getCssFilterFunctionPx,
getClipPathInsetPx,
inferBoxShadowPreset,
@@ -54,6 +50,18 @@ interface PropertyPanelProps {
onImportAssets?: (files: FileList) => Promise<string[]>;
fontAssets?: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[];
gsapMultipleTimelines?: boolean;
gsapUnsupportedTimelinePattern?: boolean;
onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void;
onUpdateGsapMeta?: (
animId: string,
updates: { duration?: number; ease?: string; position?: number },
) => void;
onDeleteGsapAnimation?: (animId: string) => void;
onAddGsapProperty?: (animId: string, prop: string) => void;
onRemoveGsapProperty?: (animId: string, prop: string) => void;
onAddGsapAnimation?: (method: "to" | "from" | "set") => void;
}
/* ------------------------------------------------------------------ */
@@ -146,6 +154,15 @@ export const PropertyPanel = memo(function PropertyPanel({
onImportAssets,
fontAssets = [],
onImportFonts,
gsapAnimations = [],
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
onUpdateGsapProperty,
onUpdateGsapMeta,
onDeleteGsapAnimation,
onAddGsapProperty,
onRemoveGsapProperty,
onAddGsapAnimation,
}: PropertyPanelProps) {
const styles = element?.computedStyles ?? EMPTY_STYLES;
@@ -186,11 +203,6 @@ export const PropertyPanel = memo(function PropertyPanel({
const sourceLabel = element.id ? `#${element.id}` : element.selector;
const showEditableSections = element.capabilities.canEditStyles;
const manualOffset = readStudioPathOffset(element.element);
const gsapTranslate = readGsapTranslateFromTransform(element.element);
const visualOffset = {
x: manualOffset.x + gsapTranslate.x,
y: manualOffset.y + gsapTranslate.y,
};
const manualSize = readStudioBoxSize(element.element);
const resolvedWidth =
manualSize.width > 0
@@ -204,11 +216,10 @@ export const PropertyPanel = memo(function PropertyPanel({
const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null) return;
const currentRaw = readStudioPathOffset(element.element);
const currentGsap = readGsapTranslateFromTransform(element.element);
const current = readStudioPathOffset(element.element);
onSetManualOffset(element, {
x: axis === "x" ? parsed - currentGsap.x : currentRaw.x,
y: axis === "y" ? parsed - currentGsap.y : currentRaw.y,
x: axis === "x" ? parsed : current.x,
y: axis === "y" ? parsed : current.y,
});
};
@@ -300,14 +311,14 @@ export const PropertyPanel = memo(function PropertyPanel({
<div className={RESPONSIVE_GRID}>
<MetricField
label="X"
value={formatPxMetricValue(visualOffset.x)}
value={formatPxMetricValue(manualOffset.x)}
disabled={manualOffsetEditingDisabled}
scrub
onCommit={(next) => commitManualOffset("x", next)}
/>
<MetricField
label="Y"
value={formatPxMetricValue(visualOffset.y)}
value={formatPxMetricValue(manualOffset.y)}
disabled={manualOffsetEditingDisabled}
scrub
onCommit={(next) => commitManualOffset("y", next)}
@@ -342,6 +353,25 @@ export const PropertyPanel = memo(function PropertyPanel({
</div>
</Section>
{STUDIO_GSAP_PANEL_ENABLED &&
onUpdateGsapProperty &&
onUpdateGsapMeta &&
onDeleteGsapAnimation &&
onAddGsapProperty &&
onAddGsapAnimation && (
<GsapAnimationSection
animations={gsapAnimations}
multipleTimelines={gsapMultipleTimelines}
unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
onUpdateProperty={onUpdateGsapProperty}
onUpdateMeta={onUpdateGsapMeta}
onDeleteAnimation={onDeleteGsapAnimation}
onAddProperty={onAddGsapProperty}
onRemoveProperty={onRemoveGsapProperty ?? (() => {})}
onAddAnimation={onAddGsapAnimation}
/>
)}
{showEditableSections && (
<StyleSections
projectId={projectId}
@@ -1,4 +1,5 @@
import type { PatchTarget } from "../../utils/sourcePatcher";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
export const CURATED_STYLE_PROPERTIES = [
"position",
@@ -86,6 +87,7 @@ export interface DomEditSelection extends PatchTarget {
computedStyles: Record<string, string>;
textFields: DomEditTextField[];
capabilities: DomEditCapabilities;
gsapAnimations?: GsapAnimation[];
}
export interface DomEditLayerItem {
@@ -0,0 +1,130 @@
import { controlPointsForGsapEase } from "./studioMotion";
export const METHOD_LABELS: Record<string, string> = {
set: "Set",
to: "Animate",
from: "Animate In",
fromTo: "Animate",
};
export const METHOD_TOOLTIPS: Record<string, string> = {
set: "Instantly snap to these values — no transition",
to: "Smoothly animate the element to these target values",
from: "Element starts at these values and transitions to its normal state",
fromTo: "Animate from one state to another",
};
export const PROP_LABELS: Record<string, string> = {
x: "Move X",
y: "Move Y",
width: "Width",
height: "Height",
rotation: "Rotate",
opacity: "Opacity",
scale: "Scale",
scaleX: "Scale X",
scaleY: "Scale Y",
autoAlpha: "Visibility",
visibility: "Visible",
scaleX_alias: "Stretch X",
};
export const PROP_UNITS: Record<string, string> = {
x: "px",
y: "px",
width: "px",
height: "px",
rotation: "°",
opacity: "%",
scale: "×",
scaleX: "×",
scaleY: "×",
autoAlpha: "%",
visibility: "",
};
export const PROP_TOOLTIPS: Record<string, string> = {
x: "Move left/right (negative = left, positive = right)",
y: "Move up/down (negative = up, positive = down)",
opacity: "How visible (0 = invisible, 1 = fully visible)",
scale: "Size multiplier (1 = normal, 2 = double, 0.5 = half)",
scaleX: "Horizontal stretch (1 = normal)",
scaleY: "Vertical stretch (1 = normal)",
rotation: "Spin angle (360 = full rotation)",
width: "Element width",
height: "Element height",
autoAlpha: "Like opacity but hides element completely at 0",
visibility: "Show or hide the element",
};
export const EASE_LABELS: Record<string, string> = {
none: "Constant speed",
"power1.out": "Gentle slowdown",
"power2.out": "Smooth slowdown",
"power3.out": "Snappy slowdown",
"power4.out": "Sharp slowdown",
"power1.in": "Gentle speedup",
"power2.in": "Smooth speedup",
"power3.in": "Strong speedup",
"power4.in": "Sharp speedup",
"power1.inOut": "Gentle ease",
"power2.inOut": "Smooth ease",
"power3.inOut": "Strong ease",
"power4.inOut": "Sharp ease",
"back.out": "Overshoot & settle",
"back.in": "Pull back & go",
"back.inOut": "Pull & overshoot",
"elastic.out": "Springy bounce",
"elastic.in": "Wind up spring",
"elastic.inOut": "Full spring",
"bounce.out": "Drop & bounce",
"bounce.in": "Reverse bounce",
"bounce.inOut": "Double bounce",
"expo.out": "Very snappy stop",
"expo.in": "Very slow start",
"expo.inOut": "Dramatic ease",
};
export const EASE_CURVES: Record<string, [number, number, number, number]> = {
none: [0, 0, 1, 1],
"power1.out": [0, 0, 0.58, 1],
"power2.out": [0.16, 1, 0.3, 1],
"power3.out": [0.08, 0.82, 0.17, 1],
"power4.out": [0.06, 0.73, 0.09, 1],
"power1.in": [0.42, 0, 1, 1],
"power2.in": [0.55, 0.06, 0.68, 0.19],
"power3.in": [0.6, 0.04, 0.98, 0.34],
"power4.in": [0.7, 0, 0.84, 0],
"power1.inOut": [0.42, 0, 0.58, 1],
"power2.inOut": [0.45, 0.05, 0.55, 0.95],
"power3.inOut": [0.65, 0.05, 0.35, 1],
"power4.inOut": [0.76, 0, 0.24, 1],
"back.out": [0.34, 1.56, 0.64, 1],
"back.in": [0.36, 0, 0.66, -0.56],
"back.inOut": [0.68, -0.55, 0.27, 1.55],
"expo.out": [0.16, 1, 0.3, 1],
"expo.in": [0.7, 0, 0.84, 0],
"expo.inOut": [0.87, 0, 0.13, 1],
};
export function parseCustomEaseFromString(ease: string): {
x1: number;
y1: number;
x2: number;
y2: number;
} {
const match = ease.match(/^custom\((.+)\)$/);
if (!match) return controlPointsForGsapEase("power2.out");
const data = match[1];
const nums = data.match(/[\d.]+/g)?.map(Number);
if (!nums || nums.length < 6) return controlPointsForGsapEase("power2.out");
return { x1: nums[2], y1: nums[3], x2: nums[4], y2: nums[5] };
}
export const ADD_METHODS = ["to", "from", "set"] as const;
export const ADD_METHOD_LABELS: Record<string, string> = {
to: "Animate",
from: "Animate In",
set: "Set Instantly",
};
@@ -65,6 +65,12 @@ export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
true,
);
export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_GSAP_PANEL", "VITE_STUDIO_GSAP_PANEL_ENABLED"],
false,
);
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
@@ -516,3 +516,104 @@ describe("studio manual edits", () => {
expect(frames).toHaveLength(0);
});
});
describe("applyStudioPathOffset sets correct attribute name", () => {
it("sets data-hf-studio-path-offset without double data- prefix", () => {
const window = new Window();
const el = window.document.createElement("div");
window.document.body.append(el);
applyStudioPathOffset(el, { x: 100, y: 50 });
expect(el.getAttribute("data-hf-studio-path-offset")).toBe("true");
expect(el.getAttribute("data-data-hf-studio-path-offset")).toBeNull();
});
it("stores offset in CSS vars alongside the attribute marker", () => {
const window = new Window();
const el = window.document.createElement("div");
window.document.body.append(el);
applyStudioPathOffset(el, { x: 50, y: 25 });
expect(el.getAttribute("data-hf-studio-path-offset")).toBe("true");
expect(el.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("50px");
expect(el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("25px");
expect(el.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
});
it("corrects offset applied on top of legacy double-prefix element", () => {
const window = new Window();
const el = window.document.createElement("div");
el.setAttribute("data-data-hf-studio-path-offset", "true");
el.style.setProperty(STUDIO_OFFSET_X_PROP, "200px");
el.style.setProperty(STUDIO_OFFSET_Y_PROP, "-30px");
window.document.body.append(el);
applyStudioPathOffset(el, { x: 200, y: -30 });
expect(el.getAttribute("data-hf-studio-path-offset")).toBe("true");
expect(readStudioPathOffset(el)).toEqual({ x: 200, y: -30 });
expect(el.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
});
});
describe("applyStudioPathOffset strips GSAP double-counted translate", () => {
it("strips GSAP transform translate when applying offset", () => {
const window = new Window();
const element = window.document.createElement("div");
window.document.body.append(element);
// Simulate GSAP having baked translate into the transform matrix
element.style.setProperty("transform", "matrix(1, 0, 0, 1, 200, 0)");
applyStudioPathOffset(element, { x: 200, y: 0 });
// The transform translate should be stripped (GSAP's 200px removed)
const transform = element.style.getPropertyValue("transform");
if (transform && transform !== "none") {
const m = new window.DOMMatrix(transform);
expect(m.m41).toBe(0);
expect(m.m42).toBe(0);
}
// The offset should be stored in CSS vars
expect(readStudioPathOffset(element).x).toBe(200);
});
it("subtracts only the studio offset from GSAP transform, preserving animation values", () => {
const window = new Window();
const element = window.document.createElement("div");
window.document.body.append(element);
// GSAP has scale + baked translate (offset 50) + animation contribution (-70)
// Total m42 = 50 + (-70) = -20
element.style.setProperty("transform", "matrix(0.5, 0, 0, 0.5, 0, -20)");
applyStudioPathOffset(element, { x: 0, y: 50 });
const transform = element.style.getPropertyValue("transform");
if (transform && transform !== "none") {
const m = new window.DOMMatrix(transform);
expect(m.a).toBeCloseTo(0.5);
expect(m.d).toBeCloseTo(0.5);
// Only the studio offset (50) is subtracted, animation contribution (-70) preserved
expect(m.m41).toBe(0);
expect(m.m42).toBe(-70);
}
expect(readStudioPathOffset(element).y).toBe(50);
});
it("offset survives repeated applyStudioPathOffset calls without drift", () => {
const window = new Window();
const element = window.document.createElement("div");
window.document.body.append(element);
// Apply offset 3 times with same value (simulates reapply hook firing multiple times)
applyStudioPathOffset(element, { x: 100, y: -20 });
applyStudioPathOffset(element, { x: 100, y: -20 });
applyStudioPathOffset(element, { x: 100, y: -20 });
expect(readStudioPathOffset(element).x).toBe(100);
expect(readStudioPathOffset(element).y).toBe(-20);
});
});
@@ -3,9 +3,7 @@ export {
STUDIO_OFFSET_X_PROP,
STUDIO_OFFSET_Y_PROP,
STUDIO_WIDTH_PROP,
STUDIO_HEIGHT_PROP,
STUDIO_ROTATION_PROP,
type StudioManualEditSeekWindow,
type StudioBoxSizeSnapshot,
type StudioRotationSnapshot,
type StudioPathOffsetSnapshot,
@@ -20,7 +18,6 @@ export {
readStudioPathOffset,
readStudioBoxSize,
readStudioRotation,
readGsapTranslateFromTransform,
applyStudioPathOffset,
applyStudioPathOffsetDraft,
applyStudioBoxSize,
@@ -28,8 +25,6 @@ export {
applyStudioRotation,
applyStudioRotationDraft,
reapplyPositionEditsAfterSeek,
buildMotionPatches,
buildClearMotionPatches,
} from "./manualEditsDom";
export {
@@ -51,7 +46,6 @@ import {
STUDIO_MANUAL_EDITS_PLAYBACK_FRAME_PROP,
} from "./manualEditsTypes";
import { finiteNumber } from "./manualEditsParsing";
import { isStudioManualEditGestureActive } from "./manualEditsDom";
/* ── Seek/play reapply wrappers ───────────────────────────────────── */
function markWrapped(fn: (...args: unknown[]) => unknown): void {
@@ -262,6 +256,28 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
wrapApplyAfterFunction(studioWin, timeline, "pause") || wrappedNamedTimelinePause;
}
// Auto-wrap timelines registered AFTER this install runs. GSAP compositions
// register via `window.__timelines[id] = tl` which may happen after the
// Studio hook runs. The Proxy intercepts new registrations and wraps
// seek/play/pause immediately, closing the gap that causes translate doubling.
if (studioWin.__timelines && !(studioWin.__timelines as Record<string, unknown>).__proxied) {
const original = studioWin.__timelines;
studioWin.__timelines = new Proxy(original, {
set(target, prop, value) {
target[prop as string] = value;
if (typeof value === "object" && value !== null) {
const tl = value as Record<string, unknown>;
wrapSeekReapplyFunction(studioWin, tl, "seek");
wrapPlayReapplyFunction(studioWin, tl, "play");
wrapApplyAfterFunction(studioWin, tl, "pause");
studioWin.__hfStudioManualEditsApply?.();
}
return true;
},
});
(studioWin.__timelines as Record<string, unknown>).__proxied = true;
}
if (isStudioManualEditPlaybackActive(studioWin)) {
startStudioManualEditPlaybackReapply(studioWin);
}
@@ -280,6 +296,3 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
wrappedNamedTimelinePause
);
}
// Re-export for internal use (seek hooks need this)
export { isStudioManualEditGestureActive };
@@ -48,7 +48,7 @@ export function endStudioManualEditGesture(element: HTMLElement, token?: string)
element.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
}
export function isStudioManualEditGestureActive(element: HTMLElement): boolean {
function isStudioManualEditGestureActive(element: HTMLElement): boolean {
return element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
}
@@ -213,26 +213,15 @@ function writeStudioPathOffsetVars(
// GSAP 3.x reads the resolved CSS `translate` individual property at initialization and bakes it
// into element.style.transform (as a matrix) on every seek. When the studio's reapply hook also
// writes `translate`, both properties compose additively, doubling the visual offset. This helper
// zeroes out only the translate component (m41/m42) so the `translate` prop isn't double-counted.
// writes `translate`, both properties compose additively, doubling the visual offset.
//
// This helper subtracts only the baked studio offset from m41/m42, preserving any GSAP animation
// contribution (e.g. a tween animating y: -20). The studio offset is read from the CSS custom
// properties which tell us exactly how much was baked from the CSS translate.
function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1;
}
export function readGsapTranslateFromTransform(element: HTMLElement): { x: number; y: number } {
const transform = element.style.getPropertyValue("transform");
if (!transform || transform === "none") return { x: 0, y: 0 };
const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null)
?.DOMMatrix;
if (!DOMMatrixCtor) return { x: 0, y: 0 };
try {
const m = new DOMMatrixCtor(transform);
return { x: m.m41, y: m.m42 };
} catch {
return { x: 0, y: 0 };
}
}
function stripGsapTranslateFromTransform(element: HTMLElement): void {
const transform = element.style.getPropertyValue("transform");
if (!transform || transform === "none") return;
@@ -242,9 +231,11 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
try {
const m = new DOMMatrixCtor(transform);
if (m.m41 === 0 && m.m42 === 0) return;
m.m41 = 0;
m.m42 = 0;
if (isIdentityAfterTranslateStrip(m)) {
const offsetX = readPxCustomProperty(element, STUDIO_OFFSET_X_PROP);
const offsetY = readPxCustomProperty(element, STUDIO_OFFSET_Y_PROP);
m.m41 -= offsetX;
m.m42 -= offsetY;
if (Math.abs(m.m41) < 0.01 && Math.abs(m.m42) < 0.01 && isIdentityAfterTranslateStrip(m)) {
element.style.removeProperty("transform");
} else {
element.style.setProperty("transform", m.toString());
@@ -493,9 +484,19 @@ export {
function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
const ctor = doc.defaultView?.HTMLElement;
if (!ctor) return [];
return Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
(el): el is HTMLElement => el instanceof ctor,
);
// Handle legacy HTML files where attributes were persisted with a double data- prefix
const legacyAttr = `data-${attr}`;
for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) {
if (el instanceof ctor && !el.hasAttribute(attr)) {
el.setAttribute(attr, "true");
el.removeAttribute(legacyAttr);
elements.push(el);
}
}
return elements;
}
function reapplyPathOffsets(doc: Document): void {
@@ -1,8 +1,10 @@
import { Window } from "happy-dom";
import { describe, expect, it } from "vitest";
import {
applyManualOffsetDragCommit,
applyManualOffsetDragMatrix,
createManualOffsetDragMember,
endManualOffsetDragMembers,
invertManualOffsetDragMatrix,
measureManualOffsetDragScreenToOffsetMatrix,
resolveManualOffsetForPointerDelta,
@@ -140,8 +142,8 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
});
});
describe("createManualOffsetDragMember GSAP translate compensation", () => {
it("folds GSAP translate from element.style.transform into initialOffset", () => {
describe("createManualOffsetDragMember uses raw CSS var offset", () => {
it("ignores GSAP transform initialOffset comes from CSS vars only", () => {
const window = new Window();
const element = window.document.createElement("div");
window.document.body.append(element);
@@ -161,37 +163,13 @@ describe("createManualOffsetDragMember GSAP translate compensation", () => {
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.member.initialOffset.x).toBe(0);
expect(result.member.initialOffset.y).toBe(-20);
});
it("leaves initialOffset unchanged when no GSAP transform is present", () => {
const window = new Window();
const element = window.document.createElement("div");
window.document.body.append(element);
element.getBoundingClientRect = () => {
const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
return new window.DOMRect(10 + offsetX, 20 + offsetY, 100, 50);
};
const result = createManualOffsetDragMember({
key: "test",
selection: { element } as never,
element,
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.member.initialOffset.x).toBe(0);
expect(result.member.initialOffset.y).toBe(0);
});
it("combines existing manual offset with GSAP translate", () => {
it("reads only the CSS var offset, not GSAP transform", () => {
const window = new Window();
const element = window.document.createElement("div");
window.document.body.append(element);
@@ -215,7 +193,42 @@ describe("createManualOffsetDragMember GSAP translate compensation", () => {
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.member.initialOffset.x).toBe(80);
expect(result.member.initialOffset.y).toBe(-5);
expect(result.member.initialOffset.x).toBe(30);
expect(result.member.initialOffset.y).toBe(10);
});
it("does not accumulate drift across multiple drag cycles", () => {
const window = new Window();
const element = window.document.createElement("div");
window.document.body.append(element);
element.getBoundingClientRect = () => {
const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
return new window.DOMRect(10 + offsetX, 20 + offsetY, 100, 50);
};
// Simulate GSAP baking a translate into transform each cycle
for (let cycle = 0; cycle < 3; cycle++) {
element.style.setProperty("transform", `translate(${50 * (cycle + 1)}px, 0px)`);
const result = createManualOffsetDragMember({
key: "test",
selection: { element } as never,
element,
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
// initialOffset should always be the CSS var value, never inflated by GSAP transform
const currentRawX =
Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
expect(result.member.initialOffset.x).toBe(currentRawX);
// Simulate drag commit: apply a small offset
applyManualOffsetDragCommit(result.member, 10, 0);
endManualOffsetDragMembers([result.member]);
}
});
});
@@ -5,7 +5,6 @@ import {
beginStudioManualEditGesture,
captureStudioPathOffset,
endStudioManualEditGesture,
readGsapTranslateFromTransform,
readStudioPathOffset,
restoreStudioPathOffset,
type StudioPathOffsetSnapshot,
@@ -232,12 +231,7 @@ export function createManualOffsetDragMember(input: {
element: HTMLElement;
rect: ManualOffsetDragRect;
}): ManualOffsetDragMemberResult {
const rawOffset = readStudioPathOffset(input.element);
const gsapTranslate = readGsapTranslateFromTransform(input.element);
const initialOffset = {
x: rawOffset.x + gsapTranslate.x,
y: rawOffset.y + gsapTranslate.y,
};
const initialOffset = readStudioPathOffset(input.element);
const initialPathOffset = captureStudioPathOffset(input.element);
const gestureToken = beginStudioManualEditGesture(input.element);
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
@@ -103,6 +103,8 @@ export function MetricField({
disabled,
liveCommit,
scrub,
suffix,
tooltip,
onCommit,
}: {
label: string;
@@ -110,6 +112,8 @@ export function MetricField({
disabled?: boolean;
liveCommit?: boolean;
scrub?: boolean;
suffix?: string;
tooltip?: string;
onCommit: (nextValue: string) => void;
}) {
const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
@@ -151,7 +155,7 @@ export function MetricField({
: ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const);
return (
<div className={FIELD}>
<div className={FIELD} title={tooltip}>
<div className="flex min-w-0 items-center gap-3">
<span {...scrubProps}>{label}</span>
<CommitField
@@ -160,6 +164,7 @@ export function MetricField({
liveCommit={liveCommit}
onCommit={onCommit}
/>
{suffix && <span className="flex-shrink-0 text-[10px] text-neutral-600">{suffix}</span>}
</div>
</div>
);