feat(studio): html-backed motion panel (#873)

## Summary

Re-architects the studio motion panel to persist GSAP motion data directly in HTML element attributes instead of a `.hyperframes/studio-motion.json` JSON sidecar file. Same pattern as position/resize/rotation edits.

### Before
```
MotionPanel → commitStudioMotionManifestOptimistically()
  → writes .hyperframes/studio-motion.json
  → applyStudioMotionManifest(doc, manifest)
```

### After
```html
<div id="hero" data-hf-studio-motion='{"start":0.5,"duration":1,"ease":"power3.out","from":{"opacity":0,"y":40},"to":{"opacity":1,"y":0}}'>
```
```
MotionPanel → writeStudioMotionToElement(element, motion)
  → buildMotionPatches(element)
  → commitPositionPatchToHtml(selection, patches)
```

## What changed

- **studioMotionOps.ts** — Added `readStudioMotionFromElement()`, `writeStudioMotionToElement()`, `clearStudioMotionFromElement()` for attribute-based CRUD
- **studioMotion.ts** — Added `applyStudioMotionFromDom()` that reads motion from DOM attributes and builds GSAP timeline (kept `applyStudioMotionManifest` for render script compat)
- **manualEditsDom.ts** — Added `buildMotionPatches()` / `buildClearMotionPatches()`, integrated motion into `reapplyPositionEditsAfterSeek()`
- **useDomEditCommits.ts** — Rewrote `handleDomMotionCommit` / `handleDomMotionClear` to use HTML patching instead of manifest persistence
- **useManifestPersistence.ts** — Removed all motion manifest state (~200 lines): `studioMotionManifestRef`, `commitStudioMotionManifestOptimistically`, `applyStudioMotionToPreview`, motion SSE handler
- **App.tsx** — Reads motion from element attribute (`readStudioMotionFromElement`) instead of manifest ref
- **manualEditsRenderScript.ts** — Extended `studioPositionSeekReapplyRuntime` to rebuild GSAP motion timeline from `data-hf-studio-motion` attributes after each seek, including CustomEase support
- **htmlCompiler.ts** — Trigger seek-reapply script injection on `data-hf-studio-motion=` attributes

## Benefits

- No sidecar file — motion survives git, copy-paste, and manual HTML editing
- Undo/redo works via HTML source history (same as position edits)
- Renders correctly via CLI — seek-reapply script handles motion timeline rebuild
- Simpler architecture — one persistence path for all studio edits

## Test plan

- [x] `bun run build` passes
- [x] Pre-commit hooks pass (lint, format, typecheck)
- [ ] Set motion on element in Studio → `data-hf-studio-motion` attribute appears in HTML source
- [ ] Reload page → motion persists and plays correctly
- [ ] Clear motion → attribute removed, element returns to original state
- [ ] Undo/redo motion changes
- [ ] Render via CLI → motion visible in rendered video
- [ ] Seek animation → motion timeline re-syncs correctly
This commit is contained in:
Miguel Ángel
2026-05-15 21:45:57 +02:00
committed by GitHub
parent adeb92ecb7
commit 9b23ccf665
15 changed files with 1210 additions and 303 deletions
@@ -24,14 +24,14 @@ import {
} from "./MotionPanelFields";
import { EaseCurveEditor } from "./EaseCurveEditor";
/** Motion data without targeting metadata (kind/target/updatedAt are derived from context). */
type StudioMotionData = Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">;
interface MotionPanelProps {
element: DomEditSelection | null;
motion: StudioGsapMotion | null;
motion: StudioMotionData | null;
onClearSelection: () => void;
onSetMotion: (
element: DomEditSelection,
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
) => void;
onSetMotion: (element: DomEditSelection, motion: StudioMotionData) => void;
onClearMotion: (element: DomEditSelection) => void;
}
@@ -43,19 +43,19 @@ const MOTION_PRESET_OPTIONS: Array<{ label: string; value: StudioGsapMotionPrese
const MOTION_DIRECTION_OPTIONS: StudioGsapMotionDirection[] = ["up", "down", "left", "right"];
function motionValueDistance(motion: StudioGsapMotion | null): number {
function motionValueDistance(motion: StudioMotionData | null): number {
if (!motion) return 32;
return Math.max(Math.abs(motion.from.x ?? 0), Math.abs(motion.from.y ?? 0), 1);
}
function inferMotionPreset(motion: StudioGsapMotion | null): StudioGsapMotionPreset {
function inferMotionPreset(motion: StudioMotionData | null): StudioGsapMotionPreset {
if (!motion) return "fade-up";
if (motion.from.scale != null || motion.to.scale != null) return "pop";
if (motion.from.x != null || motion.to.x != null) return "slide";
return "fade-up";
}
function inferMotionDirection(motion: StudioGsapMotion | null): StudioGsapMotionDirection {
function inferMotionDirection(motion: StudioMotionData | null): StudioGsapMotionDirection {
if (!motion) return "up";
const x = motion.from.x ?? 0;
const y = motion.from.y ?? 0;
@@ -30,6 +30,8 @@ export {
clearStudioRotation,
clearStudioBoxSize,
reapplyPositionEditsAfterSeek,
buildMotionPatches,
buildClearMotionPatches,
} from "./manualEditsDom";
export {
@@ -31,6 +31,13 @@ import {
STUDIO_ROTATION_TRANSFORM_ORIGIN,
} from "./manualEditsTypes";
import { roundRotationAngle } from "./manualEditsParsing";
import {
STUDIO_MOTION_ATTR,
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
} from "./studioMotionTypes";
import { applyStudioMotionFromDom } from "./studioMotion";
/* ── Gesture tracking ─────────────────────────────────────────────── */
let studioManualEditGestureId = 0;
@@ -755,6 +762,52 @@ export function buildClearRotationPatches(element: HTMLElement): PatchOperation[
return ops;
}
/* ── Motion HTML patch builders ──────────────────────────────────── */
export function buildMotionPatches(element: HTMLElement): PatchOperation[] {
const motionJson = element.getAttribute(STUDIO_MOTION_ATTR);
if (!motionJson) return [];
const ops: PatchOperation[] = [
{ type: "attribute", property: STUDIO_MOTION_ATTR, value: motionJson },
];
const origTransform = element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR);
if (origTransform !== null) {
ops.push({
type: "attribute",
property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
value: origTransform,
});
}
const origOpacity = element.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR);
if (origOpacity !== null) {
ops.push({
type: "attribute",
property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
value: origOpacity,
});
}
const origVisibility = element.getAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR);
if (origVisibility !== null) {
ops.push({
type: "attribute",
property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
value: origVisibility,
});
}
return ops;
}
export function buildClearMotionPatches(_element: HTMLElement): PatchOperation[] {
return [
{ type: "attribute", property: STUDIO_MOTION_ATTR, value: null },
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, value: null },
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: null },
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: null },
];
}
/* ── Seek reapply (position + motion) ────────────────────────────── */
export function reapplyPositionEditsAfterSeek(doc: Document): void {
const htmlElement = doc.defaultView?.HTMLElement;
if (!htmlElement) return;
@@ -793,4 +846,7 @@ export function reapplyPositionEditsAfterSeek(doc: Document): void {
applyStudioRotation(el, { angle });
}
}
// Reapply DOM-backed motion timeline after seek
applyStudioMotionFromDom(doc);
}
@@ -30,8 +30,12 @@ export {
upsertStudioGsapMotion,
removeStudioMotionForSelection,
getStudioMotionForSelection,
readStudioMotionFromElement,
writeStudioMotionToElement,
clearStudioMotionFromElement,
} from "./studioMotionOps";
import { readStudioMotionFromElement as readMotionAttr } from "./studioMotionOps";
import {
STUDIO_MOTION_ATTR,
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
@@ -39,6 +43,7 @@ import {
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
STUDIO_MOTION_TIMELINE_ID,
type StudioGsapMotion,
type StudioGsapMotionValues,
type StudioMotionManifest,
type StudioMotionTarget,
type StudioMotionWindow,
@@ -220,6 +225,97 @@ export function applyStudioMotionManifest(
return applied;
}
/**
* Reads motion data from `data-hf-studio-motion` JSON attributes in the DOM,
* builds a GSAP timeline, and seeks to the current time.
* This replaces the manifest-based `applyStudioMotionManifest` for the studio preview.
*/
export function applyStudioMotionFromDom(document: Document, currentTime?: number): number {
const win = document.defaultView as StudioMotionWindow | null;
if (!win) return 0;
const gsap = win.gsap;
win.__timelines = win.__timelines ?? {};
win.__timelines[STUDIO_MOTION_TIMELINE_ID]?.kill?.();
delete win.__timelines[STUDIO_MOTION_TIMELINE_ID];
// Restore elements that had GSAP motion applied previously but whose attribute
// is now just the legacy marker "true" (i.e. they were restored/cleared).
const HTMLElementCtor = document.defaultView?.HTMLElement;
if (!HTMLElementCtor) return 0;
// Collect elements that have JSON motion data in their attribute
const motionElements: Array<{
element: HTMLElement;
motion: {
start: number;
duration: number;
ease: string;
customEase?: { id: string; data: string };
from: StudioGsapMotionValues;
to: StudioGsapMotionValues;
};
}> = [];
for (const el of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) {
if (!(el instanceof HTMLElementCtor)) continue;
const motionData = readMotionAttr(el);
if (motionData) {
motionElements.push({ element: el, motion: motionData });
}
}
if (!gsap?.timeline || motionElements.length === 0) return 0;
const timeline = gsap.timeline({
paused: true,
defaults: { overwrite: "auto" },
});
let applied = 0;
for (const { element, motion } of motionElements) {
if (!timeline.fromTo) continue;
// Original styles are already captured when writeStudioMotionToElement was called
const fromVars: Record<string, unknown> = { ...motion.from };
const ease = resolveGsapEaseFromPayload(win, motion);
const toVars: Record<string, unknown> = {
...motion.to,
duration: motion.duration,
ease,
overwrite: "auto",
immediateRender: false,
};
timeline.fromTo(element, fromVars, toVars, motion.start);
applied += 1;
}
if (applied === 0) {
timeline.kill?.();
return 0;
}
win.__timelines[STUDIO_MOTION_TIMELINE_ID] = timeline;
timeline.pause?.();
const safeTime = readCurrentTime(win, currentTime);
if (timeline.totalTime) timeline.totalTime(safeTime, false);
else timeline.time?.(safeTime);
return applied;
}
function resolveGsapEaseFromPayload(
win: StudioMotionWindow,
motion: { ease: string; customEase?: { id: string; data: string } },
): string {
const customEase = motion.customEase;
if (!customEase) return motion.ease;
const customEasePlugin = win.CustomEase;
if (typeof customEasePlugin?.create !== "function") return motion.ease;
try {
win.gsap?.registerPlugin?.(customEasePlugin);
customEasePlugin.create(customEase.id, customEase.data);
return customEase.id;
} catch {
return motion.ease;
}
}
export function installStudioMotionSeekReapply(win: Window, apply: () => void): boolean {
const studioWin = win as StudioMotionWindow;
studioWin.__hfStudioMotionApply = () => {
@@ -0,0 +1,445 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import {
readStudioMotionFromElement,
writeStudioMotionToElement,
clearStudioMotionFromElement,
} from "./studioMotionOps";
import {
STUDIO_MOTION_ATTR,
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
} from "./studioMotionTypes";
import { buildMotionPatches, buildClearMotionPatches } from "./manualEditsDom";
import { applyPatchByTarget, readAttributeByTarget } from "../../utils/sourcePatcher";
function createElement(markup: string): HTMLElement {
const window = new Window();
window.document.body.innerHTML = markup;
return window.document.body.firstElementChild as HTMLElement;
}
// ── readStudioMotionFromElement semantics ──
describe("readStudioMotionFromElement", () => {
it("returns null for element with no attribute", () => {
const el = createElement(`<div id="test"></div>`);
expect(readStudioMotionFromElement(el)).toBeNull();
});
it("returns null for legacy marker value 'true'", () => {
const el = createElement(`<div id="test"></div>`);
el.setAttribute(STUDIO_MOTION_ATTR, "true");
expect(readStudioMotionFromElement(el)).toBeNull();
});
it("returns null for malformed JSON", () => {
const el = createElement(`<div id="test"></div>`);
el.setAttribute(STUDIO_MOTION_ATTR, "{not valid json");
expect(readStudioMotionFromElement(el)).toBeNull();
});
it("returns null for non-object JSON", () => {
const el = createElement(`<div id="test"></div>`);
el.setAttribute(STUDIO_MOTION_ATTR, '"just a string"');
expect(readStudioMotionFromElement(el)).toBeNull();
});
it("returns null when start < 0", () => {
const el = createElement(`<div id="test"></div>`);
el.setAttribute(
STUDIO_MOTION_ATTR,
JSON.stringify({
start: -0.5,
duration: 1,
ease: "none",
from: { opacity: 0 },
to: { opacity: 1 },
}),
);
expect(readStudioMotionFromElement(el)).toBeNull();
});
it("returns null when duration <= 0", () => {
const el = createElement(`<div id="test"></div>`);
el.setAttribute(
STUDIO_MOTION_ATTR,
JSON.stringify({
start: 0,
duration: 0,
ease: "none",
from: { opacity: 0 },
to: { opacity: 1 },
}),
);
expect(readStudioMotionFromElement(el)).toBeNull();
});
it("returns null when duration is negative", () => {
const el = createElement(`<div id="test"></div>`);
el.setAttribute(
STUDIO_MOTION_ATTR,
JSON.stringify({
start: 0,
duration: -1,
ease: "none",
from: { opacity: 0 },
to: { opacity: 1 },
}),
);
expect(readStudioMotionFromElement(el)).toBeNull();
});
it("returns null when from is missing", () => {
const el = createElement(`<div id="test"></div>`);
el.setAttribute(
STUDIO_MOTION_ATTR,
JSON.stringify({
start: 0,
duration: 1,
ease: "none",
to: { opacity: 1 },
}),
);
expect(readStudioMotionFromElement(el)).toBeNull();
});
it("returns null when to is missing", () => {
const el = createElement(`<div id="test"></div>`);
el.setAttribute(
STUDIO_MOTION_ATTR,
JSON.stringify({
start: 0,
duration: 1,
ease: "none",
from: { opacity: 0 },
}),
);
expect(readStudioMotionFromElement(el)).toBeNull();
});
it("returns null when from/to have no recognized motion properties", () => {
const el = createElement(`<div id="test"></div>`);
el.setAttribute(
STUDIO_MOTION_ATTR,
JSON.stringify({
start: 0,
duration: 1,
ease: "none",
from: { color: "red" },
to: { color: "blue" },
}),
);
expect(readStudioMotionFromElement(el)).toBeNull();
});
it("returns parsed motion for valid JSON", () => {
const el = createElement(`<div id="test"></div>`);
const motion = {
start: 0.5,
duration: 1,
ease: "power3.out",
from: { opacity: 0, y: 40 },
to: { opacity: 1, y: 0 },
};
el.setAttribute(STUDIO_MOTION_ATTR, JSON.stringify(motion));
const result = readStudioMotionFromElement(el);
expect(result).not.toBeNull();
expect(result).toEqual({
start: 0.5,
duration: 1,
ease: "power3.out",
customEase: undefined,
from: { opacity: 0, y: 40 },
to: { opacity: 1, y: 0 },
});
});
it("returns parsed motion with customEase", () => {
const el = createElement(`<div id="test"></div>`);
const motion = {
start: 0,
duration: 0.6,
ease: "studio-custom",
customEase: { id: "studio-custom", data: "M0,0 C0.2,0.9 0.28,1 1,1" },
from: { scale: 0.88, autoAlpha: 0 },
to: { scale: 1, autoAlpha: 1 },
};
el.setAttribute(STUDIO_MOTION_ATTR, JSON.stringify(motion));
const result = readStudioMotionFromElement(el);
expect(result).not.toBeNull();
expect(result!.customEase).toEqual({ id: "studio-custom", data: "M0,0 C0.2,0.9 0.28,1 1,1" });
});
it("defaults ease to 'none' when ease is empty string", () => {
const el = createElement(`<div id="test"></div>`);
el.setAttribute(
STUDIO_MOTION_ATTR,
JSON.stringify({
start: 0,
duration: 1,
ease: "",
from: { y: 40 },
to: { y: 0 },
}),
);
const result = readStudioMotionFromElement(el);
expect(result).not.toBeNull();
expect(result!.ease).toBe("none");
});
it("accepts start = 0 as valid", () => {
const el = createElement(`<div id="test"></div>`);
el.setAttribute(
STUDIO_MOTION_ATTR,
JSON.stringify({
start: 0,
duration: 0.5,
ease: "none",
from: { opacity: 0 },
to: { opacity: 1 },
}),
);
const result = readStudioMotionFromElement(el);
expect(result).not.toBeNull();
expect(result!.start).toBe(0);
});
});
// ── writeStudioMotionToElement / readStudioMotionFromElement round-trip ──
describe("write → read round-trip via DOM", () => {
it("round-trips motion through write and read", () => {
const el = createElement(`<div id="hero" style="transform: rotate(5deg); opacity: 0.8"></div>`);
const motion = {
start: 0.5,
duration: 1,
ease: "power3.out",
from: { opacity: 0, y: 40 },
to: { opacity: 1, y: 0 },
};
writeStudioMotionToElement(el, motion);
const result = readStudioMotionFromElement(el);
expect(result).not.toBeNull();
expect(result!.start).toBe(0.5);
expect(result!.duration).toBe(1);
expect(result!.ease).toBe("power3.out");
expect(result!.from).toEqual({ opacity: 0, y: 40 });
expect(result!.to).toEqual({ opacity: 1, y: 0 });
});
it("captures original styles on first write", () => {
const el = createElement(
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: hidden"></div>`,
);
const motion = {
start: 0,
duration: 0.6,
ease: "none",
from: { autoAlpha: 0 },
to: { autoAlpha: 1 },
};
writeStudioMotionToElement(el, motion);
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBe("rotate(5deg)");
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBe("0.8");
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR)).toBe("hidden");
});
it("does not overwrite original styles on subsequent writes", () => {
const el = createElement(
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`,
);
const first = { start: 0, duration: 0.6, ease: "none", from: { y: 40 }, to: { y: 0 } };
const second = { start: 0.2, duration: 1, ease: "power2.out", from: { y: 60 }, to: { y: 0 } };
writeStudioMotionToElement(el, first);
// Simulate GSAP modifying styles
el.style.transform = "matrix(1, 0, 0, 1, 0, 20)";
el.style.opacity = "0.3";
writeStudioMotionToElement(el, second);
// Original capture should be preserved from the first write
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBe("rotate(5deg)");
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBe("0.8");
});
});
// ── clearStudioMotionFromElement ──
describe("clearStudioMotionFromElement", () => {
it("removes all four motion-related attributes", () => {
const el = createElement(
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`,
);
const motion = {
start: 0,
duration: 0.6,
ease: "none",
from: { autoAlpha: 0 },
to: { autoAlpha: 1 },
};
writeStudioMotionToElement(el, motion);
expect(el.hasAttribute(STUDIO_MOTION_ATTR)).toBe(true);
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBe(true);
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBe(true);
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR)).toBe(true);
clearStudioMotionFromElement(el);
expect(el.hasAttribute(STUDIO_MOTION_ATTR)).toBe(false);
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBe(false);
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBe(false);
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR)).toBe(false);
});
it("restores original inline styles after clearing", () => {
const el = createElement(
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: hidden"></div>`,
);
writeStudioMotionToElement(el, {
start: 0,
duration: 0.6,
ease: "none",
from: { autoAlpha: 0, y: 32 },
to: { autoAlpha: 1, y: 0 },
});
// Simulate GSAP overwriting styles
el.style.transform = "matrix(1, 0, 0, 1, 0, 16)";
el.style.opacity = "0.5";
el.style.visibility = "visible";
clearStudioMotionFromElement(el);
expect(el.style.transform).toBe("rotate(5deg)");
expect(el.style.opacity).toBe("0.8");
expect(el.style.visibility).toBe("hidden");
});
it("is a no-op when element has no motion attribute", () => {
const el = createElement(`<div id="hero" style="opacity: 1"></div>`);
clearStudioMotionFromElement(el);
expect(el.style.opacity).toBe("1");
expect(el.hasAttribute(STUDIO_MOTION_ATTR)).toBe(false);
});
});
// ── buildMotionPatches / buildClearMotionPatches ──
describe("buildMotionPatches", () => {
it("produces patches for all motion-related attributes present on the element", () => {
const el = createElement(
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`,
);
const motion = {
start: 0.5,
duration: 1,
ease: "power3.out",
from: { opacity: 0, y: 40 },
to: { opacity: 1, y: 0 },
};
writeStudioMotionToElement(el, motion);
const patches = buildMotionPatches(el);
// Should have at least the motion attribute patch
const motionPatch = patches.find((p) => p.property === STUDIO_MOTION_ATTR);
expect(motionPatch).toBeDefined();
expect(motionPatch!.type).toBe("attribute");
expect(JSON.parse(motionPatch!.value!)).toMatchObject(motion);
// Should include original style capture patches
expect(patches.find((p) => p.property === STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBeDefined();
expect(patches.find((p) => p.property === STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBeDefined();
expect(
patches.find((p) => p.property === STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR),
).toBeDefined();
});
it("returns empty when element has no motion attribute", () => {
const el = createElement(`<div id="hero"></div>`);
expect(buildMotionPatches(el)).toEqual([]);
});
});
describe("buildClearMotionPatches round-trip", () => {
it("applying clear patches removes all four motion attributes from HTML", () => {
const el = createElement(
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`,
);
writeStudioMotionToElement(el, {
start: 0,
duration: 0.6,
ease: "power2.out",
from: { autoAlpha: 0, y: 32 },
to: { autoAlpha: 1, y: 0 },
});
// First, apply the motion patches to an HTML string
const motionPatches = buildMotionPatches(el);
let html = `<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`;
for (const patch of motionPatches) {
html = applyPatchByTarget(html, { id: "hero" }, patch);
}
// Verify all four attributes are present
expect(readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ATTR)).toBeDefined();
expect(
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR),
).toBeDefined();
expect(
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_OPACITY_ATTR),
).toBeDefined();
expect(
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR),
).toBeDefined();
// Now apply clear patches
const clearPatches = buildClearMotionPatches(el);
for (const patch of clearPatches) {
html = applyPatchByTarget(html, { id: "hero" }, patch);
}
// All four should be gone
expect(readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ATTR)).toBeUndefined();
expect(
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR),
).toBeUndefined();
expect(
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_OPACITY_ATTR),
).toBeUndefined();
expect(
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR),
).toBeUndefined();
});
it("clear patches produce exactly four null-value attribute operations", () => {
const el = createElement(`<div id="hero"></div>`);
const clearPatches = buildClearMotionPatches(el);
expect(clearPatches).toHaveLength(4);
for (const patch of clearPatches) {
expect(patch.type).toBe("attribute");
expect(patch.value).toBeNull();
}
const properties = clearPatches.map((p) => p.property);
expect(properties).toContain(STUDIO_MOTION_ATTR);
expect(properties).toContain(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR);
expect(properties).toContain(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR);
expect(properties).toContain(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR);
});
});
@@ -5,11 +5,16 @@ import {
DEFAULT_CUSTOM_EASE_POINTS,
GSAP_EASE_CONTROL_POINTS,
CUSTOM_EASE_DATA_PATTERN,
STUDIO_MOTION_ATTR,
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
type StudioCustomEaseControlPoints,
type StudioGsapCustomEase,
type StudioGsapMotion,
type StudioGsapMotionPreset,
type StudioGsapPresetMotionOptions,
type StudioGsapMotionValues,
type StudioMotionManifest,
type StudioMotionTarget,
} from "./studioMotionTypes";
@@ -124,12 +129,10 @@ export function buildStudioGsapPresetMotion(
// ── Manifest parse/serialize ──
function parseMotionValues(
value: unknown,
): import("./studioMotionTypes").StudioGsapMotionValues | null {
export function parseMotionValues(value: unknown): StudioGsapMotionValues | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const parsed: import("./studioMotionTypes").StudioGsapMotionValues = {};
const parsed: StudioGsapMotionValues = {};
for (const key of ["x", "y", "scale", "rotation", "opacity", "autoAlpha"] as const) {
const next = finiteNumber(record[key]);
if (next != null) parsed[key] = next;
@@ -297,3 +300,74 @@ export function getStudioMotionForSelection(
): StudioGsapMotion | null {
return manifest.motions.find((motion) => sameSelectionTarget(motion, selection)) ?? null;
}
// ── HTML-attributebacked motion storage ──
/** The JSON stored in the attribute omits kind/target/updatedAt — those are derived from context. */
interface StudioMotionAttrPayload {
start: number;
duration: number;
ease: string;
customEase?: StudioGsapCustomEase;
from: StudioGsapMotionValues;
to: StudioGsapMotionValues;
}
export function readStudioMotionFromElement(
element: HTMLElement,
): Omit<StudioGsapMotion, "kind" | "target" | "updatedAt"> | null {
const json = element.getAttribute(STUDIO_MOTION_ATTR);
if (!json || json === "true") return null;
try {
const parsed = JSON.parse(json) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const record = parsed as Record<string, unknown>;
const start = finiteNumber(record.start);
const duration = finiteNumber(record.duration);
if (start == null || duration == null || start < 0 || duration <= 0) return null;
const ease =
typeof record.ease === "string" && record.ease.trim() ? record.ease.trim() : "none";
const from = parseMotionValues(record.from);
const to = parseMotionValues(record.to);
if (!from || !to) return null;
return { start, duration, ease, customEase: parseCustomEase(record.customEase), from, to };
} catch {
return null;
}
}
export function writeStudioMotionToElement(
element: HTMLElement,
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
): void {
// Capture original styles before first write (only if not already captured)
if (!element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)) {
element.setAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, element.style.transform);
element.setAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, element.style.opacity);
element.setAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, element.style.visibility);
}
const payload: StudioMotionAttrPayload = {
start: motion.start,
duration: motion.duration,
ease: motion.ease,
from: motion.from,
to: motion.to,
};
if (motion.customEase) payload.customEase = motion.customEase;
element.setAttribute(STUDIO_MOTION_ATTR, JSON.stringify(payload));
}
export function clearStudioMotionFromElement(
element: HTMLElement,
gsap?: { set?: (target: HTMLElement, vars: Record<string, unknown>) => void },
): void {
if (!element.hasAttribute(STUDIO_MOTION_ATTR)) return;
gsap?.set?.(element, { clearProps: "transform,opacity,visibility" });
element.style.transform = element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR) ?? "";
element.style.opacity = element.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR) ?? "";
element.style.visibility = element.getAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR) ?? "";
element.removeAttribute(STUDIO_MOTION_ATTR);
element.removeAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR);
element.removeAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR);
element.removeAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR);
}