fix(studio): surface fromTo from-state in GSAP design panel (#1122)

* fix(studio): surface fromTo from-state in GSAP design panel

Closes #1121.

The core already parsed, serialized, and mutated fromProperties end to end
(gsapParser.ts, applyUpdatesToCall, buildTweenStatementCode). The panel
never wired it in — AnimationCard only read animation.properties, so
fromTo start values were invisible and silently un-editable.

Changes:
- files.ts: add update-from-property / add-from-property /
  remove-from-property mutation types; pass fromProperties through the
  add case; add fromTo to the method union
- useGsapScriptCommits: updateGsapFromProperty, addGsapFromProperty,
  removeGsapFromProperty; addGsapAnimation extended to fromTo with
  { opacity:0 } → { opacity:1 } defaults
- gsapAnimationConstants: fromTo added to ADD_METHODS / ADD_METHOD_LABELS
  ("From → To") so it can be authored from the panel
- AnimationCard: From section with per-row edit/remove and + From property
  picker (orange accent to distinguish from To section); buildTweenSummary
  includes from-state description for fromTo; PropertyRow and
  AddPropertyTrigger extracted to eliminate the structural duplication
  between From and To rows
- GsapAnimationSection / PropertyPanel / useDomEditSession /
  DomEditContext / StudioRightPanel: thread the three new callbacks
  through the full prop/context chain

* test(studio): add API-level tests for fromProperties mutation routes

Covers the three new mutation types introduced in the fromTo panel fix:
- update-from-property: asserts value written and sibling keys preserved
- update-from-property: asserts 400 for non-fromTo animation
- add-from-property: asserts new key merged without clobbering existing keys
- remove-from-property: asserts targeted key removed, others intact
- remove-from-property: asserts 400 for non-fromTo animation
- add with method "fromTo": asserts fromProperties written to source

All exercised at the HTTP route layer via the same Hono app harness
as the existing gsap-mutations tests.
This commit is contained in:
Miguel Ángel
2026-05-29 13:18:05 -04:00
committed by GitHub
parent 62475b7649
commit 6a0c9a5e22
10 changed files with 590 additions and 77 deletions
@@ -109,6 +109,224 @@ describe("registerFileRoutes", () => {
expect(payload.animations[0].targetSelector).toBe(".kicker");
});
// A composition with a fromTo tween — used by the fromProperties mutation tests.
const FROMTO_COMP = `<!DOCTYPE html><html><body data-duration="3">
<div id="box" data-start="0" data-duration="3" style="opacity:0"></div>
<script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, ease: "power2.out" }, 0);
</script>
</body></html>`;
function writeHtml(projectDir: string, name: string, html: string): void {
writeFileSync(join(projectDir, name), html);
}
async function getFirstAnimation(
app: Hono,
file: string,
): Promise<{ id: string; method: string; fromProperties?: Record<string, number | string> }> {
const res = await app.request(`http://localhost/projects/demo/gsap-animations/${file}`);
const payload = (await res.json()) as {
animations: Array<{
id: string;
method: string;
fromProperties?: Record<string, number | string>;
}>;
};
return payload.animations[0];
}
it("update-from-property updates a fromTo start value in place", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "comp.html");
expect(anim.method).toBe("fromTo");
expect(anim.fromProperties?.opacity).toBe(0);
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "update-from-property",
animationId: anim.id,
property: "opacity",
value: 0.2,
}),
});
const result = (await res.json()) as {
ok: boolean;
after: string;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.after).toContain("opacity: 0.2");
expect(result.parsed.animations[0].fromProperties?.opacity).toBe(0.2);
// x unchanged
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);
});
it("update-from-property returns 400 for a non-fromTo animation", async () => {
const projectDir = createProjectDir();
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.to("#box", { opacity: 1, duration: 1 }, 0);
</script></body></html>`;
writeHtml(projectDir, "to.html", TO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "to.html");
expect(anim.method).toBe("to");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/to.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "update-from-property",
animationId: anim.id,
property: "opacity",
value: 0,
}),
});
expect(res.status).toBe(400);
});
it("add-from-property merges a new key into existing fromProperties", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "comp.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "add-from-property",
animationId: anim.id,
property: "scale",
defaultValue: 0.5,
}),
});
const result = (await res.json()) as {
ok: boolean;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
// Existing keys preserved, new key added
const fp = result.parsed.animations[0].fromProperties ?? {};
expect(fp.opacity).toBe(0);
expect(fp.x).toBe(-50);
expect(fp.scale).toBe(0.5);
});
it("remove-from-property deletes one key, leaving others intact", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "comp.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "remove-from-property",
animationId: anim.id,
property: "x",
}),
});
const result = (await res.json()) as {
ok: boolean;
after: string;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
const fp = result.parsed.animations[0].fromProperties ?? {};
expect(fp.x).toBeUndefined();
expect(fp.opacity).toBe(0); // untouched
});
it("remove-from-property returns 400 for a non-fromTo animation", async () => {
const projectDir = createProjectDir();
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.to("#box", { opacity: 1, duration: 1 }, 0);
</script></body></html>`;
writeHtml(projectDir, "to.html", TO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "to.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/to.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "remove-from-property",
animationId: anim.id,
property: "opacity",
}),
});
expect(res.status).toBe(400);
});
it("add mutation with fromTo method creates a fromTo tween with fromProperties", async () => {
const projectDir = createProjectDir();
const EMPTY_COMP = `<!DOCTYPE html><html><body><div id="el"></div><script data-hyperframes-gsap>
const tl = gsap.timeline();
</script></body></html>`;
writeHtml(projectDir, "empty.html", EMPTY_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/gsap-mutations/empty.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "add",
targetSelector: "#el",
method: "fromTo",
position: 0,
duration: 0.5,
ease: "power2.out",
properties: { opacity: 1 },
fromProperties: { opacity: 0 },
}),
});
const result = (await res.json()) as {
ok: boolean;
parsed: {
animations: Array<{
method: string;
fromProperties?: Record<string, number | string>;
properties: Record<string, number | string>;
}>;
};
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
const anim = result.parsed.animations[0];
expect(anim.method).toBe("fromTo");
expect(anim.fromProperties?.opacity).toBe(0);
expect(anim.properties.opacity).toBe(1);
});
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
+50 -2
View File
@@ -539,6 +539,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
property: string;
value: number | string;
}
| {
type: "update-from-property";
animationId: string;
property: string;
value: number | string;
}
| {
type: "update-meta";
animationId: string;
@@ -547,11 +553,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
| {
type: "add";
targetSelector: string;
method: "to" | "from" | "set";
method: "to" | "from" | "set" | "fromTo";
position: number;
duration?: number;
ease?: string;
properties: Record<string, number | string>;
fromProperties?: Record<string, number | string>;
}
| { type: "delete"; animationId: string }
| {
@@ -560,7 +567,14 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
property: string;
defaultValue: number | string;
}
| { type: "remove-property"; animationId: string; property: string };
| {
type: "add-from-property";
animationId: string;
property: string;
defaultValue: number | string;
}
| { type: "remove-property"; animationId: string; property: string }
| { type: "remove-from-property"; animationId: string; property: string };
api.post("/projects/:id/gsap-mutations/*", async (c) => {
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
@@ -588,6 +602,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
let newScript: string;
// fallow-ignore-next-line complexity
switch (body.type) {
case "update-property": {
const parsed = parseGsapScript(block.scriptText);
@@ -598,6 +613,16 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
break;
}
case "update-from-property": {
const parsed = parseGsapScript(block.scriptText);
const anim = parsed.animations.find((a) => a.id === body.animationId);
if (!anim) return c.json({ error: "animation not found" }, 404);
if (anim.method !== "fromTo") return c.json({ error: "animation is not a fromTo" }, 400);
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: { ...(anim.fromProperties ?? {}), [body.property]: body.value },
});
break;
}
case "update-meta": {
newScript = updateAnimationInScript(block.scriptText, body.animationId, body.updates);
break;
@@ -610,6 +635,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
duration: body.duration,
ease: body.ease,
properties: body.properties,
fromProperties: body.fromProperties,
});
newScript = result.script;
break;
@@ -627,6 +653,16 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
break;
}
case "add-from-property": {
const parsed = parseGsapScript(block.scriptText);
const anim = parsed.animations.find((a) => a.id === body.animationId);
if (!anim) return c.json({ error: "animation not found" }, 404);
if (anim.method !== "fromTo") return c.json({ error: "animation is not a fromTo" }, 400);
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: { ...(anim.fromProperties ?? {}), [body.property]: body.defaultValue },
});
break;
}
case "remove-property": {
const parsed = parseGsapScript(block.scriptText);
const anim = parsed.animations.find((a) => a.id === body.animationId);
@@ -638,6 +674,18 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
break;
}
case "remove-from-property": {
const parsed = parseGsapScript(block.scriptText);
const anim = parsed.animations.find((a) => a.id === body.animationId);
if (!anim) return c.json({ error: "animation not found" }, 404);
if (anim.method !== "fromTo") return c.json({ error: "animation is not a fromTo" }, 400);
const filtered = { ...(anim.fromProperties ?? {}) };
delete filtered[body.property];
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: filtered,
});
break;
}
default:
return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
}
@@ -87,6 +87,9 @@ export function StudioRightPanel({
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
} = useDomEditContext();
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
@@ -215,6 +218,9 @@ export function StudioRightPanel({
onDeleteGsapAnimation={handleGsapDeleteAnimation}
onAddGsapProperty={handleGsapAddProperty}
onRemoveGsapProperty={handleGsapRemoveProperty}
onUpdateGsapFromProperty={handleGsapUpdateFromProperty}
onAddGsapFromProperty={handleGsapAddFromProperty}
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
onAddGsapAnimation={handleGsapAddAnimation}
/>
) : motionPanelActive ? (
@@ -19,6 +19,112 @@ function isPercentProp(prop: string): boolean {
return PERCENT_PROPS.has(prop);
}
function displayValue(prop: string, val: number | string): string {
return isPercentProp(prop) ? String(Math.round(Number(val) * 100)) : String(val);
}
function adjustedValue(prop: string, raw: string): string {
return isPercentProp(prop) ? String(Number(raw) / 100) : raw;
}
function PropertyRow({
prop,
val,
onCommit,
onRemove,
removeTitle,
}: {
prop: string;
val: number | string;
onCommit: (adjusted: string) => void;
onRemove: () => void;
removeTitle: string;
}) {
return (
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1">
<MetricField
label={PROP_LABELS[prop] ?? prop}
value={displayValue(prop, val)}
suffix={PROP_UNITS[prop]}
tooltip={PROP_TOOLTIPS[prop]}
scrub
liveCommit
onCommit={(raw) => onCommit(adjustedValue(prop, raw))}
/>
</div>
<button
type="button"
onClick={onRemove}
className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
title={removeTitle}
>
<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>
);
}
function AddPropertyTrigger({
adding,
available,
addLabel,
addTitle,
onAdd,
onOpen,
onClose,
buttonClassName,
}: {
adding: boolean;
available: string[];
addLabel: string;
addTitle: string;
onAdd: (prop: string) => void;
onOpen: () => void;
onClose: () => void;
buttonClassName: string;
}) {
if (adding && available.length > 0) {
return (
<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) onAdd(e.target.value);
onClose();
}}
onBlur={onClose}
>
<option value="" disabled>
Choose property
</option>
{available.map((p) => (
<option key={p} value={p}>
{PROP_LABELS[p] ?? p}
</option>
))}
</select>
);
}
if (available.length === 0) return null;
return (
<button type="button" onClick={onOpen} className={buttonClassName} title={addTitle}>
{addLabel}
</button>
);
}
// fallow-ignore-next-line complexity
function buildTweenSummary(animation: GsapAnimation): string {
const easeName = animation.ease ?? "none";
const ease = EASE_LABELS[easeName] ?? easeName;
@@ -35,6 +141,16 @@ function buildTweenSummary(animation: GsapAnimation): string {
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.`;
if (animation.method === "fromTo") {
const fromProps = Object.entries(animation.fromProperties ?? {});
const fromDescs = fromProps.map(([p, v]) => {
const label = (PROP_LABELS[p] ?? p).toLowerCase();
const unit = PROP_UNITS[p] ?? "";
return `${label} ${v}${unit}`;
});
const fromText = fromDescs.length > 0 ? fromDescs.join(", ") : "—";
return `Starting at ${pos}s, over ${dur}s, ${target} animates from [${fromText}] to [${propText}] using a ${ease.toLowerCase()} curve.`;
}
return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`;
}
@@ -54,6 +170,9 @@ interface AnimationCardProps {
onDeleteAnimation: (animationId: string) => void;
onAddProperty: (animationId: string, property: string) => void;
onRemoveProperty: (animationId: string, property: string) => void;
onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void;
onAddFromProperty?: (animationId: string, property: string) => void;
onRemoveFromProperty?: (animationId: string, property: string) => void;
onLivePreview?: (property: string, value: number | string) => void;
onLivePreviewEnd?: () => void;
}
@@ -67,11 +186,15 @@ export const AnimationCard = memo(function AnimationCard({
onDeleteAnimation,
onAddProperty,
onRemoveProperty,
onUpdateFromProperty,
onAddFromProperty,
onRemoveFromProperty,
onLivePreview,
onLivePreviewEnd,
}: AnimationCardProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [addingProp, setAddingProp] = useState(false);
const [addingFromProp, setAddingFromProp] = useState(false);
const usedProps = useMemo(
() => new Set(Object.keys(animation.properties)),
@@ -82,6 +205,15 @@ export const AnimationCard = memo(function AnimationCard({
[usedProps],
);
const usedFromProps = useMemo(
() => new Set(Object.keys(animation.fromProperties ?? {})),
[animation.fromProperties],
);
const availableFromProps = useMemo(
() => SUPPORTED_PROPS.filter((p) => !usedFromProps.has(p)),
[usedFromProps],
);
const commitProperty = useCallback(
(prop: string, raw: string) => {
const value = parseNumericOrString(raw);
@@ -98,6 +230,15 @@ export const AnimationCard = memo(function AnimationCard({
[onLivePreview],
);
const commitFromProperty = useCallback(
(prop: string, raw: string) => {
const value = parseNumericOrString(raw);
onUpdateFromProperty?.(animation.id, prop, value);
onLivePreviewEnd?.();
},
[animation.id, onUpdateFromProperty, onLivePreviewEnd],
);
const commitDuration = useCallback(
(raw: string) => {
const num = Number(raw);
@@ -232,82 +373,73 @@ export const AnimationCard = memo(function AnimationCard({
</>
)}
{animation.method === "fromTo" && (
<div className="space-y-1">
<p className="text-[9px] font-semibold uppercase tracking-wider text-orange-400/70">
From
</p>
<div className="space-y-1.5">
{Object.entries(animation.fromProperties ?? {}).map(([prop, val]) => (
<PropertyRow
key={prop}
prop={prop}
val={val}
onCommit={(adjusted) => commitFromProperty(prop, adjusted)}
onRemove={() => onRemoveFromProperty?.(animation.id, prop)}
removeTitle={`Remove from-${PROP_LABELS[prop] ?? prop}`}
/>
))}
</div>
<div className="pt-0.5">
<AddPropertyTrigger
adding={addingFromProp}
available={availableFromProps}
addLabel="+ From property"
addTitle="Add a from-state property"
onAdd={(prop) => onAddFromProperty?.(animation.id, prop)}
onOpen={() => setAddingFromProp(true)}
onClose={() => setAddingFromProp(false)}
buttonClassName="text-[11px] font-medium text-orange-400/70 transition-colors hover:text-orange-300"
/>
</div>
</div>
)}
{animation.method === "fromTo" && Object.keys(animation.properties).length > 0 && (
<p className="text-[9px] font-semibold uppercase tracking-wider text-emerald-400/70">
To
</p>
)}
{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>
<PropertyRow
key={prop}
prop={prop}
val={val}
onCommit={(adjusted) => {
scrubProperty(prop, adjusted);
commitProperty(prop, adjusted);
}}
onRemove={() => onRemoveProperty(animation.id, prop)}
removeTitle={`Remove ${PROP_LABELS[prop] ?? prop}`}
/>
))}
</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>
)
)}
<AddPropertyTrigger
adding={addingProp}
available={availableProps}
addLabel="+ Effect"
addTitle="Add another animated property to this effect"
onAdd={(prop) => onAddProperty(animation.id, prop)}
onOpen={() => setAddingProp(true)}
onClose={() => setAddingProp(false)}
buttonClassName="text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200"
/>
<button
type="button"
onClick={() => onDeleteAnimation(animation.id)}
@@ -17,7 +17,10 @@ interface GsapAnimationSectionProps {
onDeleteAnimation: (animationId: string) => void;
onAddProperty: (animationId: string, property: string) => void;
onRemoveProperty: (animationId: string, property: string) => void;
onAddAnimation: (method: "to" | "from" | "set") => void;
onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void;
onAddFromProperty?: (animationId: string, property: string) => void;
onRemoveFromProperty?: (animationId: string, property: string) => void;
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
onLivePreview?: (property: string, value: number | string) => void;
onLivePreviewEnd?: () => void;
}
@@ -31,6 +34,9 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
onDeleteAnimation,
onAddProperty,
onRemoveProperty,
onUpdateFromProperty,
onAddFromProperty,
onRemoveFromProperty,
onAddAnimation,
onLivePreview,
onLivePreviewEnd,
@@ -64,6 +70,9 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
onDeleteAnimation={onDeleteAnimation}
onAddProperty={onAddProperty}
onRemoveProperty={onRemoveProperty}
onUpdateFromProperty={onUpdateFromProperty}
onAddFromProperty={onAddFromProperty}
onRemoveFromProperty={onRemoveFromProperty}
onLivePreview={onLivePreview}
onLivePreviewEnd={onLivePreviewEnd}
/>
@@ -61,7 +61,10 @@ interface PropertyPanelProps {
onDeleteGsapAnimation?: (animId: string) => void;
onAddGsapProperty?: (animId: string, prop: string) => void;
onRemoveGsapProperty?: (animId: string, prop: string) => void;
onAddGsapAnimation?: (method: "to" | "from" | "set") => void;
onUpdateGsapFromProperty?: (animId: string, prop: string, value: number | string) => void;
onAddGsapFromProperty?: (animId: string, prop: string) => void;
onRemoveGsapFromProperty?: (animId: string, prop: string) => void;
onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void;
}
/* ------------------------------------------------------------------ */
@@ -162,6 +165,9 @@ export const PropertyPanel = memo(function PropertyPanel({
onDeleteGsapAnimation,
onAddGsapProperty,
onRemoveGsapProperty,
onUpdateGsapFromProperty,
onAddGsapFromProperty,
onRemoveGsapFromProperty,
onAddGsapAnimation,
}: PropertyPanelProps) {
const styles = element?.computedStyles ?? EMPTY_STYLES;
@@ -368,6 +374,9 @@ export const PropertyPanel = memo(function PropertyPanel({
onDeleteAnimation={onDeleteGsapAnimation}
onAddProperty={onAddGsapProperty}
onRemoveProperty={onRemoveGsapProperty ?? (() => {})}
onUpdateFromProperty={onUpdateGsapFromProperty}
onAddFromProperty={onAddGsapFromProperty}
onRemoveFromProperty={onRemoveGsapFromProperty}
onAddAnimation={onAddGsapAnimation}
/>
)}
@@ -121,10 +121,11 @@ export function parseCustomEaseFromString(ease: string): {
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_METHODS = ["to", "from", "fromTo", "set"] as const;
export const ADD_METHOD_LABELS: Record<string, string> = {
to: "Animate",
from: "Animate In",
fromTo: "From → To",
set: "Set Instantly",
};
@@ -62,6 +62,9 @@ export function DomEditProvider({
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
},
children,
}: {
@@ -119,6 +122,9 @@ export function DomEditProvider({
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
}),
[
domEditSelection,
@@ -170,6 +176,9 @@ export function DomEditProvider({
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
],
);
return <DomEditContext value={stable}>{children}</DomEditContext>;
+31 -1
View File
@@ -215,6 +215,9 @@ export function useDomEditSession({
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
updateGsapFromProperty,
addGsapFromProperty,
removeGsapFromProperty,
} = useGsapScriptCommits({
projectIdRef,
activeCompPath,
@@ -288,7 +291,7 @@ export function useDomEditSession({
);
const handleGsapAddAnimation = useCallback(
(method: "to" | "from" | "set") => {
(method: "to" | "from" | "set" | "fromTo") => {
if (!domEditSelection) return;
addGsapAnimation(domEditSelection, method, currentTime);
},
@@ -311,6 +314,30 @@ export function useDomEditSession({
[domEditSelection, removeGsapProperty],
);
const handleGsapUpdateFromProperty = useCallback(
(animId: string, prop: string, value: number | string) => {
if (!domEditSelection) return;
updateGsapFromProperty(domEditSelection, animId, prop, value);
},
[domEditSelection, updateGsapFromProperty],
);
const handleGsapAddFromProperty = useCallback(
(animId: string, prop: string) => {
if (!domEditSelection) return;
addGsapFromProperty(domEditSelection, animId, prop);
},
[domEditSelection, addGsapFromProperty],
);
const handleGsapRemoveFromProperty = useCallback(
(animId: string, prop: string) => {
if (!domEditSelection) return;
removeGsapFromProperty(domEditSelection, animId, prop);
},
[domEditSelection, removeGsapFromProperty],
);
// Sync selection from preview document on load / refresh
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
@@ -443,5 +470,8 @@ export function useDomEditSession({
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
};
}
@@ -210,7 +210,11 @@ export function useGsapScriptCommits({
);
const addGsapAnimation = useCallback(
async (selection: DomEditSelection, method: "to" | "from" | "set", currentTime?: number) => {
async (
selection: DomEditSelection,
method: "to" | "from" | "set" | "fromTo",
currentTime?: number,
) => {
const { selector, autoId } = ensureElementAddressable(selection);
if (autoId) {
@@ -238,10 +242,11 @@ export function useGsapScriptCommits({
}
const start = currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0);
const defaults: Record<string, Record<string, number>> = {
const toDefaults: Record<string, Record<string, number>> = {
from: { opacity: 0 },
to: { opacity: 1 },
set: { opacity: 1 },
fromTo: { opacity: 1 },
};
await commitMutation(
@@ -253,7 +258,8 @@ export function useGsapScriptCommits({
position: start,
duration: method === "set" ? undefined : 0.5,
ease: method === "set" ? undefined : "power2.out",
properties: defaults[method] ?? { opacity: 1 },
properties: toDefaults[method] ?? { opacity: 1 },
fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
},
{ label: `Add GSAP ${method} animation` },
);
@@ -292,6 +298,48 @@ export function useGsapScriptCommits({
[commitMutation],
);
const updateGsapFromProperty = useCallback(
(
selection: DomEditSelection,
animationId: string,
property: string,
value: number | string,
) => {
void commitMutation(
selection,
{ type: "update-from-property", animationId, property, value },
{
label: `Edit GSAP from-${property}`,
coalesceKey: `gsap:${animationId}:from:${property}`,
},
);
},
[commitMutation],
);
const addGsapFromProperty = useCallback(
(selection: DomEditSelection, animationId: string, property: string) => {
const defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
void commitMutation(
selection,
{ type: "add-from-property", animationId, property, defaultValue },
{ label: `Add GSAP from-${property}` },
);
},
[commitMutation],
);
const removeGsapFromProperty = useCallback(
(selection: DomEditSelection, animationId: string, property: string) => {
void commitMutation(
selection,
{ type: "remove-from-property", animationId, property },
{ label: `Remove GSAP from-${property}` },
);
},
[commitMutation],
);
return {
updateGsapProperty,
updateGsapMeta,
@@ -299,5 +347,8 @@ export function useGsapScriptCommits({
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
updateGsapFromProperty,
addGsapFromProperty,
removeGsapFromProperty,
};
}