mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user