mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
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:
@@ -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'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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user