diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts index 0ca7b4b72..976f49df0 100644 --- a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts +++ b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts @@ -30,11 +30,14 @@ function studioPositionSeekReapplyRuntime(): void { const ROTATION_ATTR = "data-hf-studio-rotation"; const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate"; const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate"; + const MOTION_ATTR = "data-hf-studio-motion"; + const MOTION_TL_KEY = "studio-motion"; const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped"; if ( !document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') && - !document.querySelector("[" + ROTATION_ATTR + '="true"]') + !document.querySelector("[" + ROTATION_ATTR + '="true"]') && + !document.querySelector("[" + MOTION_ATTR + "]") ) return; @@ -77,6 +80,117 @@ function studioPositionSeekReapplyRuntime(): void { return "calc(" + original + " + " + rotationValue + ")"; }; + let lastSeekTime = 0; + let cachedMotionKey = ""; + + const finiteNum = (v: unknown): number | null => + typeof v === "number" && Number.isFinite(v) ? v : null; + + const computeMotionKey = (motionEls: NodeListOf): string => { + let key = ""; + for (let i = 0; i < motionEls.length; i++) { + const json = (motionEls[i] as HTMLElement).getAttribute?.(MOTION_ATTR); + if (json) key += (key ? "\n" : "") + json; + } + return key; + }; + + const reapplyMotionTimeline = (): void => { + const motionEls = document.querySelectorAll("[" + MOTION_ATTR + "]"); + if (motionEls.length === 0) { + cachedMotionKey = ""; + return; + } + const win = window as Window & { + gsap?: { + timeline?: (opts: Record) => Record; + set?: (el: HTMLElement, vars: Record) => void; + registerPlugin?: (plugin: unknown) => void; + }; + CustomEase?: { create?: (id: string, data: string) => void }; + __timelines?: Record>; + }; + const gsap = win.gsap; + if (!gsap || typeof gsap.timeline !== "function") return; + win.__timelines = win.__timelines || {}; + + // Cache the timeline keyed by the concatenated motion JSON strings. + // On each seek, if the key hasn't changed, just seek the existing timeline + // instead of rebuilding it (avoids kill+recreate on every frame). + const motionKey = computeMotionKey(motionEls); + const existing = win.__timelines[MOTION_TL_KEY]; + if ( + motionKey && + motionKey === cachedMotionKey && + existing && + typeof existing.totalTime === "function" + ) { + (existing.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false); + return; + } + + if (existing && typeof existing.kill === "function") (existing.kill as () => void)(); + const tl = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } }); + const fromTo = tl.fromTo as ( + el: HTMLElement, + from: Record, + to: Record, + pos: number, + ) => void; + if (typeof fromTo !== "function") return; + let applied = 0; + for (let i = 0; i < motionEls.length; i++) { + const el = motionEls[i] as HTMLElement; + if (!(el instanceof HTMLElement)) continue; + const json = el.getAttribute(MOTION_ATTR); + if (!json) continue; + try { + const m = JSON.parse(json) as Record; + const start = finiteNum(m.start); + const duration = finiteNum(m.duration); + if (start == null || duration == null || duration <= 0) continue; + const ease = typeof m.ease === "string" ? m.ease : "none"; + const from = (m.from && typeof m.from === "object" ? m.from : {}) as Record< + string, + unknown + >; + const to = (m.to && typeof m.to === "object" ? m.to : {}) as Record; + const customEase = m.customEase as { id?: string; data?: string } | null | undefined; + let resolvedEase = ease; + if (customEase?.id && customEase?.data && win.CustomEase?.create) { + try { + gsap.registerPlugin?.(win.CustomEase); + win.CustomEase.create(customEase.id, customEase.data); + resolvedEase = customEase.id; + } catch { + /* use default ease */ + } + } + fromTo.call( + tl, + el, + { ...from }, + { ...to, duration, ease: resolvedEase, overwrite: "auto", immediateRender: false }, + start, + ); + applied += 1; + } catch { + /* malformed JSON — skip */ + } + } + if (applied === 0) { + cachedMotionKey = ""; + if (typeof (tl as { kill?: () => void }).kill === "function") + (tl as { kill: () => void }).kill(); + return; + } + cachedMotionKey = motionKey; + win.__timelines[MOTION_TL_KEY] = tl; + if (typeof tl.pause === "function") (tl.pause as () => void)(); + if (typeof tl.totalTime === "function") + (tl.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false); + }; + const reapplyAll = (): void => { const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]'); for (let i = 0; i < offsetEls.length; i++) { @@ -104,6 +218,7 @@ function studioPositionSeekReapplyRuntime(): void { el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)")); } } + reapplyMotionTimeline(); }; const runtimeWindow = window as Window & { @@ -139,6 +254,7 @@ function studioPositionSeekReapplyRuntime(): void { return true; } const wrapped = function (this: unknown, time: number): unknown { + lastSeekTime = typeof time === "number" && Number.isFinite(time) ? Math.max(0, time) : 0; const result = seek.call(this, time); reapplyAll(); return result; diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 4c44d534e..fd20f5a35 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -1000,7 +1000,11 @@ export async function compileForRender( // GSAP overwrites the `translate` CSS property on every frame seek; this script // re-asserts the CSS custom property var() form after each seek so dragged // positions survive frame-by-frame rendering without a JSON sidecar. - const HF_POSITION_ATTRS = ['data-hf-studio-path-offset="true"', 'data-hf-studio-rotation="true"']; + const HF_POSITION_ATTRS = [ + 'data-hf-studio-path-offset="true"', + 'data-hf-studio-rotation="true"', + 'data-hf-studio-motion="', + ]; const hasPositionEdits = HF_POSITION_ATTRS.some((attr) => htmlWithAssets.includes(attr)); const html = hasPositionEdits ? htmlWithAssets.replace( diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 6454ce02a..2d7d6c661 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -25,7 +25,7 @@ import { STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_MOTION_PANEL_ENABLED, } from "./components/editor/manualEditingAvailability"; -import { getStudioMotionForSelection } from "./components/editor/studioMotion"; +import { readStudioMotionFromElement } from "./components/editor/studioMotion"; import type { DomEditSelection } from "./components/editor/domEditing"; import { AskAgentModal } from "./components/AskAgentModal"; import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay"; @@ -200,9 +200,6 @@ export function StudioApp() { showToast, refreshPreviewDocumentVersion, queueDomEditSave: manifestPersistence.queueDomEditSave, - commitStudioMotionManifestOptimistically: - manifestPersistence.commitStudioMotionManifestOptimistically, - applyCurrentStudioMotionToPreview: manifestPersistence.applyCurrentStudioMotionToPreview, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, domEditSaveTimestampRef, @@ -215,7 +212,6 @@ export function StudioApp() { refreshKey, rightPanelTab: panelLayout.rightPanelTab, applyStudioManualEditsToPreviewRef: manifestPersistence.applyStudioManualEditsToPreviewRef, - applyStudioMotionToPreviewRef: manifestPersistence.applyStudioMotionToPreviewRef, syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey, reloadPreview, setRefreshKey, @@ -292,10 +288,7 @@ export function StudioApp() { const selectedStudioMotion = STUDIO_INSPECTOR_PANELS_ENABLED && domEditSession.domEditSelection - ? getStudioMotionForSelection( - manifestPersistence.studioMotionManifestRef.current, - domEditSession.domEditSelection, - ) + ? readStudioMotionFromElement(domEditSession.domEditSelection.element) : null; const layersPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "layers"; diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 64a169ac8..7d27a81ca 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -9,6 +9,9 @@ import { STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_MOTION_PANEL_ENABLED, } from "./editor/manualEditingAvailability"; + +/** Motion data without targeting metadata. */ +type StudioMotionData = Omit; import { useCallback } from "react"; import { resolveDomEditSelection, type DomEditLayerItem } from "./editor/domEditing"; import { useStudioContext } from "../contexts/StudioContext"; @@ -17,7 +20,7 @@ import { useFileManagerContext } from "../contexts/FileManagerContext"; import { useDomEditContext } from "../contexts/DomEditContext"; export interface StudioRightPanelProps { - selectedStudioMotion: StudioGsapMotion | null; + selectedStudioMotion: StudioMotionData | null; designPanelActive: boolean; motionPanelActive: boolean; } diff --git a/packages/studio/src/components/editor/MotionPanel.tsx b/packages/studio/src/components/editor/MotionPanel.tsx index 03a5a6b4c..2887c0a76 100644 --- a/packages/studio/src/components/editor/MotionPanel.tsx +++ b/packages/studio/src/components/editor/MotionPanel.tsx @@ -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; + interface MotionPanelProps { element: DomEditSelection | null; - motion: StudioGsapMotion | null; + motion: StudioMotionData | null; onClearSelection: () => void; - onSetMotion: ( - element: DomEditSelection, - motion: Omit, - ) => 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; diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index 9c5929b1c..bc45de701 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -30,6 +30,8 @@ export { clearStudioRotation, clearStudioBoxSize, reapplyPositionEditsAfterSeek, + buildMotionPatches, + buildClearMotionPatches, } from "./manualEditsDom"; export { diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index efacb6a4d..e9442d7fe 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -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); } diff --git a/packages/studio/src/components/editor/studioMotion.ts b/packages/studio/src/components/editor/studioMotion.ts index 4aa06477b..19e494910 100644 --- a/packages/studio/src/components/editor/studioMotion.ts +++ b/packages/studio/src/components/editor/studioMotion.ts @@ -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 = { ...motion.from }; + const ease = resolveGsapEaseFromPayload(win, motion); + const toVars: Record = { + ...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 = () => { diff --git a/packages/studio/src/components/editor/studioMotionOps.test.ts b/packages/studio/src/components/editor/studioMotionOps.test.ts new file mode 100644 index 000000000..1aed2cad2 --- /dev/null +++ b/packages/studio/src/components/editor/studioMotionOps.test.ts @@ -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(`
`); + expect(readStudioMotionFromElement(el)).toBeNull(); + }); + + it("returns null for legacy marker value 'true'", () => { + const el = createElement(`
`); + el.setAttribute(STUDIO_MOTION_ATTR, "true"); + expect(readStudioMotionFromElement(el)).toBeNull(); + }); + + it("returns null for malformed JSON", () => { + const el = createElement(`
`); + el.setAttribute(STUDIO_MOTION_ATTR, "{not valid json"); + expect(readStudioMotionFromElement(el)).toBeNull(); + }); + + it("returns null for non-object JSON", () => { + const el = createElement(`
`); + el.setAttribute(STUDIO_MOTION_ATTR, '"just a string"'); + expect(readStudioMotionFromElement(el)).toBeNull(); + }); + + it("returns null when start < 0", () => { + const el = createElement(`
`); + 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(`
`); + 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(`
`); + 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(`
`); + 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(`
`); + 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(`
`); + 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(`
`); + 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(`
`); + 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(`
`); + 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(`
`); + 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(`
`); + 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( + ``, + ); + 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( + `
`, + ); + 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( + `
`, + ); + 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( + ``, + ); + 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(`
`); + + 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( + `
`, + ); + 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(`
`); + expect(buildMotionPatches(el)).toEqual([]); + }); +}); + +describe("buildClearMotionPatches round-trip", () => { + it("applying clear patches removes all four motion attributes from HTML", () => { + const el = createElement( + `
`, + ); + 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 = `
`; + 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(`
`); + 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); + }); +}); diff --git a/packages/studio/src/components/editor/studioMotionOps.ts b/packages/studio/src/components/editor/studioMotionOps.ts index 71cf58c8b..1d09e4ddd 100644 --- a/packages/studio/src/components/editor/studioMotionOps.ts +++ b/packages/studio/src/components/editor/studioMotionOps.ts @@ -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; - 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-attribute–backed 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 | 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; + 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, +): 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) => 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); +} diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index babd64d86..8c838b66c 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -20,12 +20,14 @@ import { buildClearPathOffsetPatches, buildClearBoxSizePatches, buildClearRotationPatches, + buildMotionPatches, + buildClearMotionPatches, } from "../components/editor/manualEditsDom"; import { - removeStudioMotionForSelection, + writeStudioMotionToElement, + clearStudioMotionFromElement, + applyStudioMotionFromDom, type StudioGsapMotion, - type StudioMotionManifest, - upsertStudioGsapMotion, } from "../components/editor/studioMotion"; import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets"; import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay"; @@ -58,11 +60,6 @@ export interface UseDomEditCommitsParams { previewIframeRef: React.MutableRefObject; showToast: (message: string, tone?: "error" | "info") => void; queueDomEditSave: (save: () => Promise) => Promise; - commitStudioMotionManifestOptimistically: ( - updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest, - options: { label: string; coalesceKey: string }, - ) => void; - applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void; writeProjectFile: (path: string, content: string) => Promise; domEditSaveTimestampRef: React.MutableRefObject; editHistory: { recordEdit: (entry: RecordEditInput) => Promise }; @@ -93,8 +90,6 @@ export function useDomEditCommits({ previewIframeRef, showToast, queueDomEditSave, - commitStudioMotionManifestOptimistically, - applyCurrentStudioMotionToPreview, writeProjectFile, domEditSaveTimestampRef, editHistory, @@ -306,43 +301,60 @@ export function useDomEditCommits({ [commitPositionPatchToHtml], ); - // ── Motion commits ── + // ── Motion commits (HTML-attribute–backed) ── const handleDomMotionCommit = useCallback( ( selection: DomEditSelection, motion: Omit, ) => { - commitStudioMotionManifestOptimistically( - (manifest) => upsertStudioGsapMotion(manifest, selection, motion), - { - label: "Set GSAP motion", - coalesceKey: `motion:${getDomEditTargetKey(selection)}`, - }, - ); + // 1. Write motion data as JSON attribute on the element + writeStudioMotionToElement(selection.element, motion); + // 2. Apply the GSAP timeline from DOM attributes + let doc: Document | null = null; + try { + doc = previewIframeRef.current?.contentDocument ?? null; + } catch { + // cross-origin guard + } + if (doc) applyStudioMotionFromDom(doc); + // 3. Build patches and persist to HTML + const patches = buildMotionPatches(selection.element); + commitPositionPatchToHtml(selection, patches, { + label: "Set GSAP motion", + coalesceKey: `motion:${getDomEditTargetKey(selection)}`, + }); refreshDomEditSelectionFromPreview(selection); }, - [commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview], + [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview], ); const handleDomMotionClear = useCallback( (selection: DomEditSelection) => { - commitStudioMotionManifestOptimistically( - (manifest) => removeStudioMotionForSelection(manifest, selection), - { - label: "Clear GSAP motion", - coalesceKey: `motion:${getDomEditTargetKey(selection)}`, - }, - ); - applyCurrentStudioMotionToPreview(previewIframeRef.current); + const clearPatches = buildClearMotionPatches(selection.element); + // Get gsap from the preview window for proper cleanup + let gsap: { set?: (target: HTMLElement, vars: Record) => void } | undefined; + try { + gsap = (previewIframeRef.current?.contentWindow as { gsap?: typeof gsap })?.gsap; + } catch { + // cross-origin guard + } + clearStudioMotionFromElement(selection.element, gsap); + let doc: Document | null = null; + try { + doc = previewIframeRef.current?.contentDocument ?? null; + } catch { + // cross-origin guard + } + if (doc) applyStudioMotionFromDom(doc); + commitPositionPatchToHtml(selection, clearPatches, { + label: "Clear GSAP motion", + coalesceKey: `motion:${getDomEditTargetKey(selection)}`, + skipRefresh: false, + }); refreshDomEditSelectionFromPreview(selection); }, - [ - applyCurrentStudioMotionToPreview, - commitStudioMotionManifestOptimistically, - refreshDomEditSelectionFromPreview, - previewIframeRef, - ], + [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview], ); const handleDomEditElementDelete = useCallback( diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index ad216f390..a4f395685 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -2,7 +2,6 @@ import { useEffect } from "react"; import type { TimelineElement } from "../player"; import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability"; import { findElementForSelection } from "../components/editor/domEditing"; -import type { StudioMotionManifest } from "../components/editor/studioMotion"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; import type { RightPanelTab } from "../utils/studioHelpers"; @@ -36,11 +35,6 @@ export interface UseDomEditSessionParams { showToast: (message: string, tone?: "error" | "info") => void; refreshPreviewDocumentVersion: () => void; queueDomEditSave: (save: () => Promise) => Promise; - commitStudioMotionManifestOptimistically: ( - updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest, - options: { label: string; coalesceKey: string }, - ) => void; - applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void; readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string) => Promise; domEditSaveTimestampRef: React.MutableRefObject; @@ -55,9 +49,6 @@ export interface UseDomEditSessionParams { applyStudioManualEditsToPreviewRef: React.MutableRefObject< (iframe: HTMLIFrameElement) => Promise >; - applyStudioMotionToPreviewRef: React.MutableRefObject< - (iframe: HTMLIFrameElement) => Promise - >; syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void; reloadPreview: () => void; setRefreshKey: React.Dispatch>; @@ -81,8 +72,6 @@ export function useDomEditSession({ showToast, refreshPreviewDocumentVersion, queueDomEditSave, - commitStudioMotionManifestOptimistically, - applyCurrentStudioMotionToPreview, readProjectFile: _readProjectFile, writeProjectFile, domEditSaveTimestampRef, @@ -95,7 +84,6 @@ export function useDomEditSession({ refreshKey, rightPanelTab, applyStudioManualEditsToPreviewRef, - applyStudioMotionToPreviewRef, syncPreviewHistoryHotkey, reloadPreview, setRefreshKey: _setRefreshKey, @@ -200,8 +188,6 @@ export function useDomEditSession({ previewIframeRef, showToast, queueDomEditSave, - commitStudioMotionManifestOptimistically, - applyCurrentStudioMotionToPreview, writeProjectFile, domEditSaveTimestampRef, editHistory, @@ -249,19 +235,13 @@ export function useDomEditSession({ }; syncPreviewHistoryHotkey(previewIframe); - void (async () => { - await applyStudioManualEditsToPreviewRef.current(previewIframe); - await applyStudioMotionToPreviewRef.current(previewIframe); - })(); + void applyStudioManualEditsToPreviewRef.current(previewIframe); syncSelectionFromDocument(); refreshPreviewDocumentVersion(); const handleLoad = () => { syncPreviewHistoryHotkey(previewIframe); - void (async () => { - await applyStudioManualEditsToPreviewRef.current(previewIframe); - await applyStudioMotionToPreviewRef.current(previewIframe); - })(); + void applyStudioManualEditsToPreviewRef.current(previewIframe); syncSelectionFromDocument(); refreshPreviewDocumentVersion(); }; @@ -280,7 +260,6 @@ export function useDomEditSession({ refreshPreviewDocumentVersion, syncPreviewHistoryHotkey, applyStudioManualEditsToPreviewRef, - applyStudioMotionToPreviewRef, ]); return { diff --git a/packages/studio/src/hooks/useManifestPersistence.ts b/packages/studio/src/hooks/useManifestPersistence.ts index 55d925982..d5c859f1f 100644 --- a/packages/studio/src/hooks/useManifestPersistence.ts +++ b/packages/studio/src/hooks/useManifestPersistence.ts @@ -1,21 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef } from "react"; import { useMountEffect } from "./useMountEffect"; import { installStudioManualEditSeekReapply, reapplyPositionEditsAfterSeek, readStudioFileChangePath, } from "../components/editor/manualEdits"; -import { - STUDIO_MOTION_PATH, - applyStudioMotionManifest, - emptyStudioMotionManifest, - installStudioMotionSeekReapply, - isStudioMotionManifestPath, - parseStudioMotionManifest, - serializeStudioMotionManifest, - type StudioMotionManifest, -} from "../components/editor/studioMotion"; -import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion"; import type { EditHistoryKind } from "../utils/editHistory"; // ── Types ── @@ -46,35 +36,24 @@ interface UseManifestPersistenceParams { export function useManifestPersistence({ projectId, - showToast, + showToast: _showToast, readOptionalProjectFile: _readOptionalProjectFile, - writeProjectFile, - recordEdit, + writeProjectFile: _writeProjectFile, + recordEdit: _recordEdit, previewIframeRef, - activeCompPathRef, + activeCompPathRef: _activeCompPathRef, domEditSaveTimestampRef, reloadPreview, }: UseManifestPersistenceParams) { - void _readOptionalProjectFile; + void _showToast; + void _recordEdit; + void _activeCompPathRef; - const [, setStudioMotionRevision] = useState(0); const domTextCommitVersionRef = useRef(0); const domEditSaveQueueRef = useRef(Promise.resolve()); - const studioMotionManifestRef = useRef(emptyStudioMotionManifest()); - const studioMotionRevisionRef = useRef(0); const applyStudioManualEditsToPreviewRef = useRef< - ( - iframe?: HTMLIFrameElement | null, - options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean }, - ) => Promise - >(async () => {}); - const applyStudioMotionToPreviewRef = useRef< - ( - iframe?: HTMLIFrameElement | null, - options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean }, - ) => Promise + (iframe?: HTMLIFrameElement | null) => Promise >(async () => {}); - const motionBootstrappedRef = useRef(false); // Keep a ref to the latest projectId so async save callbacks always read the // current value, even when the callback was captured in a stale closure. @@ -96,7 +75,8 @@ export function useManifestPersistence({ await domEditSaveQueueRef.current.catch(() => undefined); }, []); - // ── Apply manual edits (HTML-baked — just install seek hooks) ── + // ── Apply manual edits (HTML-baked — install seek hooks) ── + // reapplyPositionEditsAfterSeek now also handles motion reapply from DOM attributes. const applyCurrentStudioManualEditsToPreview = useCallback( (iframe: HTMLIFrameElement | null = previewIframeRef.current) => { @@ -143,199 +123,47 @@ export function useManifestPersistence({ ); applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview; - // ── Apply motion ── - - const applyCurrentStudioMotionToPreview = useCallback( - (iframe: HTMLIFrameElement | null = previewIframeRef.current) => { - if (!iframe) return; - let doc: Document | null = null; - try { - doc = iframe.contentDocument; - } catch { - return; - } - if (!doc) return; - const previewDoc = doc; - - const applyManifest = () => { - applyStudioMotionManifest( - previewDoc, - studioMotionManifestRef.current, - activeCompPathRef.current, - ); - }; - const applyAndInstallSeekHooks = () => { - applyManifest(); - if (iframe.contentWindow) { - installStudioMotionSeekReapply(iframe.contentWindow, applyManifest); - } - }; - - const win = iframe.contentWindow; - win?.requestAnimationFrame?.(applyAndInstallSeekHooks); - win?.setTimeout?.(applyAndInstallSeekHooks, 120); - }, - [activeCompPathRef, previewIframeRef], - ); - - const applyStudioMotionToPreview = useCallback( - async ( - iframe: HTMLIFrameElement | null = previewIframeRef.current, - options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean }, - ) => { - const needsBootstrap = !motionBootstrappedRef.current; - if (needsBootstrap) motionBootstrappedRef.current = true; - const readFromDiskFirst = Boolean( - options?.forceFromDisk || options?.readFromDiskFirst || needsBootstrap, - ); - if (!readFromDiskFirst) { - applyCurrentStudioMotionToPreview(iframe); - return; - } - const readRevision = studioMotionRevisionRef.current; - let content: string; - try { - content = await _readOptionalProjectFile(STUDIO_MOTION_PATH); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to read motion manifest"; - showToast(message); - applyCurrentStudioMotionToPreview(iframe); - return; - } - if (options?.forceFromDisk || readRevision === studioMotionRevisionRef.current) { - studioMotionManifestRef.current = parseStudioMotionManifest(content); - if (options?.forceFromDisk) studioMotionRevisionRef.current += 1; - setStudioMotionRevision((revision) => revision + 1); - } - applyCurrentStudioMotionToPreview(iframe); - }, - [applyCurrentStudioMotionToPreview, previewIframeRef, _readOptionalProjectFile, showToast], - ); - applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview; - - // ── Optimistic motion commit ── - - const commitStudioMotionManifestOptimistically = useCallback( - ( - updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest, - options: { label: string; coalesceKey: string }, - ) => { - const previousManifest = studioMotionManifestRef.current; - const nextManifest = updateManifest(previousManifest); - const previousContent = serializeStudioMotionManifest(previousManifest); - const nextContent = serializeStudioMotionManifest(nextManifest); - if (nextContent === previousContent) { - return; - } - - const revision = studioMotionRevisionRef.current + 1; - studioMotionRevisionRef.current = revision; - studioMotionManifestRef.current = nextManifest; - setStudioMotionRevision((current) => current + 1); - applyCurrentStudioMotionToPreview(previewIframeRef.current); - - const save = async () => { - const originalContent = await _readOptionalProjectFile(STUDIO_MOTION_PATH); - const diskManifest = parseStudioMotionManifest(originalContent); - const nextDiskManifest = updateManifest(diskManifest); - const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest); - if (nextDiskContent === originalContent) { - return; - } - - const pid = projectIdRef.current; - if (!pid) throw new Error("No active project"); - domEditSaveTimestampRef.current = Date.now(); - await saveProjectFilesWithHistory({ - projectId: pid, - label: options.label, - kind: "motion", - coalesceKey: options.coalesceKey, - files: { [STUDIO_MOTION_PATH]: nextDiskContent }, - readFile: async () => originalContent, - writeFile: writeProjectFile, - recordEdit, - }); - domEditSaveTimestampRef.current = Date.now(); - - if (studioMotionRevisionRef.current === revision) { - studioMotionManifestRef.current = nextDiskManifest; - setStudioMotionRevision((current) => current + 1); - applyCurrentStudioMotionToPreview(previewIframeRef.current); - } - }; - - void queueDomEditSave(save).catch((error) => { - if (studioMotionRevisionRef.current === revision) { - studioMotionRevisionRef.current += 1; - studioMotionManifestRef.current = previousManifest; - setStudioMotionRevision((current) => current + 1); - applyCurrentStudioMotionToPreview(previewIframeRef.current); - } - const message = error instanceof Error ? error.message : "Failed to save motion edit"; - showToast(message); - }); - }, - [ - applyCurrentStudioMotionToPreview, - recordEdit, - queueDomEditSave, - _readOptionalProjectFile, - showToast, - writeProjectFile, - previewIframeRef, - domEditSaveTimestampRef, - ], - ); - // ── Sync preview after undo/redo ── const syncHistoryPreviewAfterApply = useCallback( - async (paths: string[] | undefined) => { - const changedPaths = paths ?? []; - const motionManifestOnly = - changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH); - - if (motionManifestOnly) { - await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true }); - return; - } - - // Reload via refreshKey so NLELayout saves seek position before the iframe reloads. + async (_paths: string[] | undefined) => { + // Motion data is now stored in HTML attributes — any undo/redo that touches HTML + // files triggers a full reload which picks up the changes automatically. reloadPreview(); }, - [applyStudioMotionToPreview, previewIframeRef, reloadPreview], + [reloadPreview], ); - // ── Reset manifests when project changes ── - - const projectTrackerRef = useRef(projectId); - - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - const previousProjectId = projectTrackerRef.current; - projectTrackerRef.current = projectId; - if (!previousProjectId || previousProjectId === projectId) return; - studioMotionManifestRef.current = emptyStudioMotionManifest(); - studioMotionRevisionRef.current += 1; - setStudioMotionRevision((revision) => revision + 1); - motionBootstrappedRef.current = false; - }, [projectId]); + // ── Migrate legacy studio-motion.json ── + // Projects that used the old JSON-file approach may still have a populated + // `.hyperframes/studio-motion.json`. The studio no longer reads from it, but + // the legacy render-script injection in `preview.ts` / `vite.studioMotion.ts` + // could still fire alongside the new seek-reapply runtime. Empty the file so + // the legacy codepath no-ops. + useMountEffect(() => { + _readOptionalProjectFile(STUDIO_MOTION_PATH) + .then((content) => { + if (!content) return; + try { + const parsed = JSON.parse(content) as { motions?: unknown[] }; + if (!Array.isArray(parsed.motions) || parsed.motions.length === 0) return; + } catch { + return; + } + return _writeProjectFile(STUDIO_MOTION_PATH, JSON.stringify({ version: 1, motions: [] })); + }) + .catch(() => { + /* best-effort migration — ignore failures */ + }); + }); // ── Listen for external file changes (HMR / SSE) ── useMountEffect(() => { const handler = (payload?: unknown) => { const changedPath = readStudioFileChangePath(payload); + if (!changedPath) return; const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200; - if (isStudioMotionManifestPath(changedPath)) { - if (!recentDomEditSave) { - void applyStudioMotionToPreviewRef.current(previewIframeRef.current, { - forceFromDisk: true, - }); - } - return; - } - // Non-motion external file change — reload unless it's an echo of our own save. + // External file change — reload unless it's an echo of our own save. if (!recentDomEditSave) { reloadPreview(); } @@ -353,17 +181,11 @@ export function useManifestPersistence({ return { domTextCommitVersionRef, domEditSaveQueueRef, - studioMotionManifestRef, - studioMotionRevisionRef, applyStudioManualEditsToPreviewRef, - applyStudioMotionToPreviewRef, queueDomEditSave, waitForPendingDomEditSaves, applyCurrentStudioManualEditsToPreview, applyStudioManualEditsToPreview, - applyCurrentStudioMotionToPreview, - applyStudioMotionToPreview, - commitStudioMotionManifestOptimistically, syncHistoryPreviewAfterApply, }; } diff --git a/packages/studio/src/utils/sourcePatcher.test.ts b/packages/studio/src/utils/sourcePatcher.test.ts index 0ae73e097..7e8787eba 100644 --- a/packages/studio/src/utils/sourcePatcher.test.ts +++ b/packages/studio/src/utils/sourcePatcher.test.ts @@ -208,4 +208,289 @@ describe("applyPatchByTarget", () => { expect(patched).toContain(`
`); expect(patched).toContain(`
`); }); + + it("escapes JSON attribute values containing double-quotes and round-trips them", () => { + const html = `
`; + const motionJson = JSON.stringify({ preset: "fadeIn", start: 0, duration: 1.5 }); + + const patched = applyPatch(html, "card", { + type: "attribute", + property: "data-hf-studio-motion", + value: motionJson, + }); + + // The raw HTML must NOT contain unescaped quotes inside the attribute + expect(patched).not.toMatch(/data-hf-studio-motion="[^"]*"[^"]*"/); + // Entities should be present + expect(patched).toContain("""); + + // Reading the attribute back should return the original JSON + const readBack = readAttributeByTarget(patched, { id: "card" }, "data-hf-studio-motion"); + expect(readBack).toBe(motionJson); + }); + + it("escapes and round-trips data-hf-studio-motion-original-transform with quotes", () => { + const html = `
`; + const transform = `rotate(15deg) translate("50px", "100px")`; + + const patched = applyPatchByTarget( + html, + { id: "hero" }, + { + type: "attribute", + property: "data-hf-studio-motion-original-transform", + value: transform, + }, + ); + + // No broken attribute boundary + expect(patched).not.toMatch(/data-hf-studio-motion-original-transform="[^"]*"[^"]*"/); + + const readBack = readAttributeByTarget( + patched, + { id: "hero" }, + "data-hf-studio-motion-original-transform", + ); + expect(readBack).toBe(transform); + }); + + it("escapes ampersands and angle brackets in attribute values", () => { + const html = `
`; + const value = `a&bd"e`; + + const patched = applyPatch(html, "el", { + type: "attribute", + property: "data-custom", + value, + }); + + expect(patched).toContain("a&b<c>d"e"); + + const readBack = readAttributeByTarget(patched, { id: "el" }, "data-custom"); + expect(readBack).toBe(value); + }); + + it("updates an already-escaped attribute value to a new escaped value", () => { + const html = `
`; + const first = JSON.stringify({ preset: "fadeIn" }); + const second = JSON.stringify({ preset: "slideUp", easing: "ease-out" }); + + const patched1 = applyPatch(html, "card", { + type: "attribute", + property: "data-hf-studio-motion", + value: first, + }); + const patched2 = applyPatch(patched1, "card", { + type: "attribute", + property: "data-hf-studio-motion", + value: second, + }); + + const readBack = readAttributeByTarget(patched2, { id: "card" }, "data-hf-studio-motion"); + expect(readBack).toBe(second); + }); +}); + +describe("motion attribute round-trip via sourcePatcher", () => { + it("round-trips data-hf-studio-motion JSON through patch and read", () => { + const html = `
Hero
`; + const motion = { + start: 0.5, + duration: 1, + ease: "power3.out", + from: { opacity: 0, y: 40 }, + to: { opacity: 1, y: 0 }, + }; + const motionJson = JSON.stringify(motion); + + const patched = applyPatchByTarget( + html, + { id: "hero" }, + { type: "attribute", property: "data-hf-studio-motion", value: motionJson }, + ); + + const readBack = readAttributeByTarget(patched, { id: "hero" }, "data-hf-studio-motion"); + expect(readBack).toBeDefined(); + expect(JSON.parse(readBack!)).toEqual(motion); + }); + + it("round-trips motion with customEase containing SVG path data", () => { + const html = `
Card
`; + const motion = { + start: 0.25, + duration: 0.8, + ease: "studio-card-bounce", + customEase: { id: "studio-card-bounce", data: "M0,0 C0.18,0.9 0.32,1 1,1" }, + from: { y: 44, autoAlpha: 0 }, + to: { y: 0, autoAlpha: 1 }, + }; + const motionJson = JSON.stringify(motion); + + const patched = applyPatchByTarget( + html, + { id: "card" }, + { type: "attribute", property: "data-hf-studio-motion", value: motionJson }, + ); + + const readBack = readAttributeByTarget(patched, { id: "card" }, "data-hf-studio-motion"); + expect(readBack).toBeDefined(); + expect(JSON.parse(readBack!)).toEqual(motion); + }); + + it("round-trips all four motion attributes (motion + three originals)", () => { + const html = `
Hero
`; + const motion = { + start: 0, + duration: 0.6, + ease: "power2.out", + from: { autoAlpha: 0, y: 32 }, + to: { autoAlpha: 1, y: 0 }, + }; + + let result = html; + result = applyPatchByTarget( + result, + { id: "hero" }, + { type: "attribute", property: "data-hf-studio-motion", value: JSON.stringify(motion) }, + ); + result = applyPatchByTarget( + result, + { id: "hero" }, + { + type: "attribute", + property: "data-hf-studio-motion-original-transform", + value: "rotate(5deg)", + }, + ); + result = applyPatchByTarget( + result, + { id: "hero" }, + { type: "attribute", property: "data-hf-studio-motion-original-opacity", value: "0.8" }, + ); + result = applyPatchByTarget( + result, + { id: "hero" }, + { + type: "attribute", + property: "data-hf-studio-motion-original-visibility", + value: "visible", + }, + ); + + expect( + JSON.parse(readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion")!), + ).toEqual(motion); + expect( + readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-transform"), + ).toBe("rotate(5deg)"); + expect( + readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-opacity"), + ).toBe("0.8"); + expect( + readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-visibility"), + ).toBe("visible"); + }); + + it("removes all four motion attributes when clearing", () => { + const html = `
Hero
`; + const motion = { + start: 0, + duration: 1, + ease: "none", + from: { opacity: 0 }, + to: { opacity: 1 }, + }; + + let result = html; + result = applyPatchByTarget( + result, + { id: "hero" }, + { type: "attribute", property: "data-hf-studio-motion", value: JSON.stringify(motion) }, + ); + result = applyPatchByTarget( + result, + { id: "hero" }, + { type: "attribute", property: "data-hf-studio-motion-original-transform", value: "" }, + ); + result = applyPatchByTarget( + result, + { id: "hero" }, + { type: "attribute", property: "data-hf-studio-motion-original-opacity", value: "1" }, + ); + result = applyPatchByTarget( + result, + { id: "hero" }, + { type: "attribute", property: "data-hf-studio-motion-original-visibility", value: "" }, + ); + + // Verify all four attributes exist + expect(readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion")).toBeDefined(); + expect( + readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-transform"), + ).toBeDefined(); + expect( + readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-opacity"), + ).toBeDefined(); + expect( + readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-visibility"), + ).toBeDefined(); + + // Remove all four + result = applyPatchByTarget( + result, + { id: "hero" }, + { type: "attribute", property: "data-hf-studio-motion", value: null }, + ); + result = applyPatchByTarget( + result, + { id: "hero" }, + { type: "attribute", property: "data-hf-studio-motion-original-transform", value: null }, + ); + result = applyPatchByTarget( + result, + { id: "hero" }, + { type: "attribute", property: "data-hf-studio-motion-original-opacity", value: null }, + ); + result = applyPatchByTarget( + result, + { id: "hero" }, + { type: "attribute", property: "data-hf-studio-motion-original-visibility", value: null }, + ); + + expect(readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion")).toBeUndefined(); + expect( + readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-transform"), + ).toBeUndefined(); + expect( + readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-opacity"), + ).toBeUndefined(); + expect( + readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-visibility"), + ).toBeUndefined(); + }); + + it("round-trips motion via selector when element has no id", () => { + const html = `
Title
`; + const motion = { + start: 0.3, + duration: 0.5, + ease: "sine.out", + from: { scale: 0.88, autoAlpha: 0 }, + to: { scale: 1, autoAlpha: 1 }, + }; + + const patched = applyPatchByTarget( + html, + { selector: ".headline" }, + { type: "attribute", property: "data-hf-studio-motion", value: JSON.stringify(motion) }, + ); + + const readBack = readAttributeByTarget( + patched, + { selector: ".headline" }, + "data-hf-studio-motion", + ); + expect(readBack).toBeDefined(); + expect(JSON.parse(readBack!)).toEqual(motion); + }); }); diff --git a/packages/studio/src/utils/sourcePatcher.ts b/packages/studio/src/utils/sourcePatcher.ts index 50969b4b7..69d60b18b 100644 --- a/packages/studio/src/utils/sourcePatcher.ts +++ b/packages/studio/src/utils/sourcePatcher.ts @@ -11,6 +11,24 @@ function escapeStyleAttributeValue(value: string, quote: string): string { return quote === '"' ? value.replace(/"/g, """) : value.replace(/'/g, "'"); } +/** Escape a string for safe use inside a double-quoted HTML attribute. */ +function escapeHtmlAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +/** Reverse escapeHtmlAttribute so callers get the original value. */ +function unescapeHtmlAttribute(value: string): string { + return value + .replace(/"/g, '"') + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&"); +} + function splitInlineStyleDeclarations(style: string): string[] { const declarations: string[] = []; let current = ""; @@ -281,7 +299,7 @@ export function readAttributeByTarget( const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`; const valueMatch = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`).exec(match.tag); - return valueMatch?.[2]; + return valueMatch?.[2] != null ? unescapeHtmlAttribute(valueMatch[2]) : undefined; } export function readTagSnippetByTarget(html: string, target: PatchTarget): string | undefined { @@ -310,12 +328,13 @@ function patchAttributeByTarget( return replaceTagAtMatch(html, match, newTag); } + const escaped = escapeHtmlAttribute(value); if (attrPattern.test(tag)) { - const newTag = tag.replace(attrPattern, `${fullAttr}="${value}"`); + const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`); return replaceTagAtMatch(html, match, newTag); } - const newTag = tag + ` ${fullAttr}="${value}"`; + const newTag = tag + ` ${fullAttr}="${escaped}"`; return replaceTagAtMatch(html, match, newTag); } @@ -343,13 +362,14 @@ function patchAttribute( return html.replace(tag, newTag); } + const escaped = escapeHtmlAttribute(value); if (attrPattern.test(tag)) { // Update existing attribute - const newTag = tag.replace(attrPattern, `${fullAttr}="${value}"`); + const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`); return html.replace(tag, newTag); } else { // Add new attribute - const newTag = tag + ` ${fullAttr}="${value}"`; + const newTag = tag + ` ${fullAttr}="${escaped}"`; return html.replace(tag, newTag); } }