mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -30,11 +30,14 @@ function studioPositionSeekReapplyRuntime(): void {
|
|||||||
const ROTATION_ATTR = "data-hf-studio-rotation";
|
const ROTATION_ATTR = "data-hf-studio-rotation";
|
||||||
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
|
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
|
||||||
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
|
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";
|
const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') &&
|
!document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') &&
|
||||||
!document.querySelector("[" + ROTATION_ATTR + '="true"]')
|
!document.querySelector("[" + ROTATION_ATTR + '="true"]') &&
|
||||||
|
!document.querySelector("[" + MOTION_ATTR + "]")
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -77,6 +80,117 @@ function studioPositionSeekReapplyRuntime(): void {
|
|||||||
return "calc(" + original + " + " + rotationValue + ")";
|
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<Element>): 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<string, unknown>) => Record<string, unknown>;
|
||||||
|
set?: (el: HTMLElement, vars: Record<string, unknown>) => void;
|
||||||
|
registerPlugin?: (plugin: unknown) => void;
|
||||||
|
};
|
||||||
|
CustomEase?: { create?: (id: string, data: string) => void };
|
||||||
|
__timelines?: Record<string, Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
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<string, unknown>,
|
||||||
|
to: Record<string, unknown>,
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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 reapplyAll = (): void => {
|
||||||
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
|
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
|
||||||
for (let i = 0; i < offsetEls.length; i++) {
|
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)"));
|
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
reapplyMotionTimeline();
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtimeWindow = window as Window & {
|
const runtimeWindow = window as Window & {
|
||||||
@@ -139,6 +254,7 @@ function studioPositionSeekReapplyRuntime(): void {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const wrapped = function (this: unknown, time: number): unknown {
|
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);
|
const result = seek.call(this, time);
|
||||||
reapplyAll();
|
reapplyAll();
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1000,7 +1000,11 @@ export async function compileForRender(
|
|||||||
// GSAP overwrites the `translate` CSS property on every frame seek; this script
|
// 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
|
// re-asserts the CSS custom property var() form after each seek so dragged
|
||||||
// positions survive frame-by-frame rendering without a JSON sidecar.
|
// 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 hasPositionEdits = HF_POSITION_ATTRS.some((attr) => htmlWithAssets.includes(attr));
|
||||||
const html = hasPositionEdits
|
const html = hasPositionEdits
|
||||||
? htmlWithAssets.replace(
|
? htmlWithAssets.replace(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
STUDIO_INSPECTOR_PANELS_ENABLED,
|
STUDIO_INSPECTOR_PANELS_ENABLED,
|
||||||
STUDIO_MOTION_PANEL_ENABLED,
|
STUDIO_MOTION_PANEL_ENABLED,
|
||||||
} from "./components/editor/manualEditingAvailability";
|
} from "./components/editor/manualEditingAvailability";
|
||||||
import { getStudioMotionForSelection } from "./components/editor/studioMotion";
|
import { readStudioMotionFromElement } from "./components/editor/studioMotion";
|
||||||
import type { DomEditSelection } from "./components/editor/domEditing";
|
import type { DomEditSelection } from "./components/editor/domEditing";
|
||||||
import { AskAgentModal } from "./components/AskAgentModal";
|
import { AskAgentModal } from "./components/AskAgentModal";
|
||||||
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
|
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
|
||||||
@@ -200,9 +200,6 @@ export function StudioApp() {
|
|||||||
showToast,
|
showToast,
|
||||||
refreshPreviewDocumentVersion,
|
refreshPreviewDocumentVersion,
|
||||||
queueDomEditSave: manifestPersistence.queueDomEditSave,
|
queueDomEditSave: manifestPersistence.queueDomEditSave,
|
||||||
commitStudioMotionManifestOptimistically:
|
|
||||||
manifestPersistence.commitStudioMotionManifestOptimistically,
|
|
||||||
applyCurrentStudioMotionToPreview: manifestPersistence.applyCurrentStudioMotionToPreview,
|
|
||||||
readProjectFile: fileManager.readProjectFile,
|
readProjectFile: fileManager.readProjectFile,
|
||||||
writeProjectFile: fileManager.writeProjectFile,
|
writeProjectFile: fileManager.writeProjectFile,
|
||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
@@ -215,7 +212,6 @@ export function StudioApp() {
|
|||||||
refreshKey,
|
refreshKey,
|
||||||
rightPanelTab: panelLayout.rightPanelTab,
|
rightPanelTab: panelLayout.rightPanelTab,
|
||||||
applyStudioManualEditsToPreviewRef: manifestPersistence.applyStudioManualEditsToPreviewRef,
|
applyStudioManualEditsToPreviewRef: manifestPersistence.applyStudioManualEditsToPreviewRef,
|
||||||
applyStudioMotionToPreviewRef: manifestPersistence.applyStudioMotionToPreviewRef,
|
|
||||||
syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
|
syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
|
||||||
reloadPreview,
|
reloadPreview,
|
||||||
setRefreshKey,
|
setRefreshKey,
|
||||||
@@ -292,10 +288,7 @@ export function StudioApp() {
|
|||||||
|
|
||||||
const selectedStudioMotion =
|
const selectedStudioMotion =
|
||||||
STUDIO_INSPECTOR_PANELS_ENABLED && domEditSession.domEditSelection
|
STUDIO_INSPECTOR_PANELS_ENABLED && domEditSession.domEditSelection
|
||||||
? getStudioMotionForSelection(
|
? readStudioMotionFromElement(domEditSession.domEditSelection.element)
|
||||||
manifestPersistence.studioMotionManifestRef.current,
|
|
||||||
domEditSession.domEditSelection,
|
|
||||||
)
|
|
||||||
: null;
|
: null;
|
||||||
const layersPanelActive =
|
const layersPanelActive =
|
||||||
STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "layers";
|
STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "layers";
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
STUDIO_INSPECTOR_PANELS_ENABLED,
|
STUDIO_INSPECTOR_PANELS_ENABLED,
|
||||||
STUDIO_MOTION_PANEL_ENABLED,
|
STUDIO_MOTION_PANEL_ENABLED,
|
||||||
} from "./editor/manualEditingAvailability";
|
} from "./editor/manualEditingAvailability";
|
||||||
|
|
||||||
|
/** Motion data without targeting metadata. */
|
||||||
|
type StudioMotionData = Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">;
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { resolveDomEditSelection, type DomEditLayerItem } from "./editor/domEditing";
|
import { resolveDomEditSelection, type DomEditLayerItem } from "./editor/domEditing";
|
||||||
import { useStudioContext } from "../contexts/StudioContext";
|
import { useStudioContext } from "../contexts/StudioContext";
|
||||||
@@ -17,7 +20,7 @@ import { useFileManagerContext } from "../contexts/FileManagerContext";
|
|||||||
import { useDomEditContext } from "../contexts/DomEditContext";
|
import { useDomEditContext } from "../contexts/DomEditContext";
|
||||||
|
|
||||||
export interface StudioRightPanelProps {
|
export interface StudioRightPanelProps {
|
||||||
selectedStudioMotion: StudioGsapMotion | null;
|
selectedStudioMotion: StudioMotionData | null;
|
||||||
designPanelActive: boolean;
|
designPanelActive: boolean;
|
||||||
motionPanelActive: boolean;
|
motionPanelActive: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ import {
|
|||||||
} from "./MotionPanelFields";
|
} from "./MotionPanelFields";
|
||||||
import { EaseCurveEditor } from "./EaseCurveEditor";
|
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 {
|
interface MotionPanelProps {
|
||||||
element: DomEditSelection | null;
|
element: DomEditSelection | null;
|
||||||
motion: StudioGsapMotion | null;
|
motion: StudioMotionData | null;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
onSetMotion: (
|
onSetMotion: (element: DomEditSelection, motion: StudioMotionData) => void;
|
||||||
element: DomEditSelection,
|
|
||||||
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
|
|
||||||
) => void;
|
|
||||||
onClearMotion: (element: DomEditSelection) => 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"];
|
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;
|
if (!motion) return 32;
|
||||||
return Math.max(Math.abs(motion.from.x ?? 0), Math.abs(motion.from.y ?? 0), 1);
|
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) return "fade-up";
|
||||||
if (motion.from.scale != null || motion.to.scale != null) return "pop";
|
if (motion.from.scale != null || motion.to.scale != null) return "pop";
|
||||||
if (motion.from.x != null || motion.to.x != null) return "slide";
|
if (motion.from.x != null || motion.to.x != null) return "slide";
|
||||||
return "fade-up";
|
return "fade-up";
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferMotionDirection(motion: StudioGsapMotion | null): StudioGsapMotionDirection {
|
function inferMotionDirection(motion: StudioMotionData | null): StudioGsapMotionDirection {
|
||||||
if (!motion) return "up";
|
if (!motion) return "up";
|
||||||
const x = motion.from.x ?? 0;
|
const x = motion.from.x ?? 0;
|
||||||
const y = motion.from.y ?? 0;
|
const y = motion.from.y ?? 0;
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export {
|
|||||||
clearStudioRotation,
|
clearStudioRotation,
|
||||||
clearStudioBoxSize,
|
clearStudioBoxSize,
|
||||||
reapplyPositionEditsAfterSeek,
|
reapplyPositionEditsAfterSeek,
|
||||||
|
buildMotionPatches,
|
||||||
|
buildClearMotionPatches,
|
||||||
} from "./manualEditsDom";
|
} from "./manualEditsDom";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ import {
|
|||||||
STUDIO_ROTATION_TRANSFORM_ORIGIN,
|
STUDIO_ROTATION_TRANSFORM_ORIGIN,
|
||||||
} from "./manualEditsTypes";
|
} from "./manualEditsTypes";
|
||||||
import { roundRotationAngle } from "./manualEditsParsing";
|
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 ─────────────────────────────────────────────── */
|
/* ── Gesture tracking ─────────────────────────────────────────────── */
|
||||||
let studioManualEditGestureId = 0;
|
let studioManualEditGestureId = 0;
|
||||||
@@ -755,6 +762,52 @@ export function buildClearRotationPatches(element: HTMLElement): PatchOperation[
|
|||||||
return ops;
|
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 {
|
export function reapplyPositionEditsAfterSeek(doc: Document): void {
|
||||||
const htmlElement = doc.defaultView?.HTMLElement;
|
const htmlElement = doc.defaultView?.HTMLElement;
|
||||||
if (!htmlElement) return;
|
if (!htmlElement) return;
|
||||||
@@ -793,4 +846,7 @@ export function reapplyPositionEditsAfterSeek(doc: Document): void {
|
|||||||
applyStudioRotation(el, { angle });
|
applyStudioRotation(el, { angle });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reapply DOM-backed motion timeline after seek
|
||||||
|
applyStudioMotionFromDom(doc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ export {
|
|||||||
upsertStudioGsapMotion,
|
upsertStudioGsapMotion,
|
||||||
removeStudioMotionForSelection,
|
removeStudioMotionForSelection,
|
||||||
getStudioMotionForSelection,
|
getStudioMotionForSelection,
|
||||||
|
readStudioMotionFromElement,
|
||||||
|
writeStudioMotionToElement,
|
||||||
|
clearStudioMotionFromElement,
|
||||||
} from "./studioMotionOps";
|
} from "./studioMotionOps";
|
||||||
|
|
||||||
|
import { readStudioMotionFromElement as readMotionAttr } from "./studioMotionOps";
|
||||||
import {
|
import {
|
||||||
STUDIO_MOTION_ATTR,
|
STUDIO_MOTION_ATTR,
|
||||||
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
|
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
|
||||||
@@ -39,6 +43,7 @@ import {
|
|||||||
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
|
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
|
||||||
STUDIO_MOTION_TIMELINE_ID,
|
STUDIO_MOTION_TIMELINE_ID,
|
||||||
type StudioGsapMotion,
|
type StudioGsapMotion,
|
||||||
|
type StudioGsapMotionValues,
|
||||||
type StudioMotionManifest,
|
type StudioMotionManifest,
|
||||||
type StudioMotionTarget,
|
type StudioMotionTarget,
|
||||||
type StudioMotionWindow,
|
type StudioMotionWindow,
|
||||||
@@ -220,6 +225,97 @@ export function applyStudioMotionManifest(
|
|||||||
return applied;
|
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 {
|
export function installStudioMotionSeekReapply(win: Window, apply: () => void): boolean {
|
||||||
const studioWin = win as StudioMotionWindow;
|
const studioWin = win as StudioMotionWindow;
|
||||||
studioWin.__hfStudioMotionApply = () => {
|
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,
|
DEFAULT_CUSTOM_EASE_POINTS,
|
||||||
GSAP_EASE_CONTROL_POINTS,
|
GSAP_EASE_CONTROL_POINTS,
|
||||||
CUSTOM_EASE_DATA_PATTERN,
|
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 StudioCustomEaseControlPoints,
|
||||||
type StudioGsapCustomEase,
|
type StudioGsapCustomEase,
|
||||||
type StudioGsapMotion,
|
type StudioGsapMotion,
|
||||||
type StudioGsapMotionPreset,
|
type StudioGsapMotionPreset,
|
||||||
type StudioGsapPresetMotionOptions,
|
type StudioGsapPresetMotionOptions,
|
||||||
|
type StudioGsapMotionValues,
|
||||||
type StudioMotionManifest,
|
type StudioMotionManifest,
|
||||||
type StudioMotionTarget,
|
type StudioMotionTarget,
|
||||||
} from "./studioMotionTypes";
|
} from "./studioMotionTypes";
|
||||||
@@ -124,12 +129,10 @@ export function buildStudioGsapPresetMotion(
|
|||||||
|
|
||||||
// ── Manifest parse/serialize ──
|
// ── Manifest parse/serialize ──
|
||||||
|
|
||||||
function parseMotionValues(
|
export function parseMotionValues(value: unknown): StudioGsapMotionValues | null {
|
||||||
value: unknown,
|
|
||||||
): import("./studioMotionTypes").StudioGsapMotionValues | null {
|
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const record = value as Record<string, unknown>;
|
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) {
|
for (const key of ["x", "y", "scale", "rotation", "opacity", "autoAlpha"] as const) {
|
||||||
const next = finiteNumber(record[key]);
|
const next = finiteNumber(record[key]);
|
||||||
if (next != null) parsed[key] = next;
|
if (next != null) parsed[key] = next;
|
||||||
@@ -297,3 +300,74 @@ export function getStudioMotionForSelection(
|
|||||||
): StudioGsapMotion | null {
|
): StudioGsapMotion | null {
|
||||||
return manifest.motions.find((motion) => sameSelectionTarget(motion, selection)) ?? 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<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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import {
|
|||||||
buildClearPathOffsetPatches,
|
buildClearPathOffsetPatches,
|
||||||
buildClearBoxSizePatches,
|
buildClearBoxSizePatches,
|
||||||
buildClearRotationPatches,
|
buildClearRotationPatches,
|
||||||
|
buildMotionPatches,
|
||||||
|
buildClearMotionPatches,
|
||||||
} from "../components/editor/manualEditsDom";
|
} from "../components/editor/manualEditsDom";
|
||||||
import {
|
import {
|
||||||
removeStudioMotionForSelection,
|
writeStudioMotionToElement,
|
||||||
|
clearStudioMotionFromElement,
|
||||||
|
applyStudioMotionFromDom,
|
||||||
type StudioGsapMotion,
|
type StudioGsapMotion,
|
||||||
type StudioMotionManifest,
|
|
||||||
upsertStudioGsapMotion,
|
|
||||||
} from "../components/editor/studioMotion";
|
} from "../components/editor/studioMotion";
|
||||||
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
||||||
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
||||||
@@ -58,11 +60,6 @@ export interface UseDomEditCommitsParams {
|
|||||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||||
showToast: (message: string, tone?: "error" | "info") => void;
|
showToast: (message: string, tone?: "error" | "info") => void;
|
||||||
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
||||||
commitStudioMotionManifestOptimistically: (
|
|
||||||
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
|
|
||||||
options: { label: string; coalesceKey: string },
|
|
||||||
) => void;
|
|
||||||
applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
|
|
||||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||||
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
||||||
@@ -93,8 +90,6 @@ export function useDomEditCommits({
|
|||||||
previewIframeRef,
|
previewIframeRef,
|
||||||
showToast,
|
showToast,
|
||||||
queueDomEditSave,
|
queueDomEditSave,
|
||||||
commitStudioMotionManifestOptimistically,
|
|
||||||
applyCurrentStudioMotionToPreview,
|
|
||||||
writeProjectFile,
|
writeProjectFile,
|
||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
editHistory,
|
editHistory,
|
||||||
@@ -306,43 +301,60 @@ export function useDomEditCommits({
|
|||||||
[commitPositionPatchToHtml],
|
[commitPositionPatchToHtml],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Motion commits ──
|
// ── Motion commits (HTML-attribute–backed) ──
|
||||||
|
|
||||||
const handleDomMotionCommit = useCallback(
|
const handleDomMotionCommit = useCallback(
|
||||||
(
|
(
|
||||||
selection: DomEditSelection,
|
selection: DomEditSelection,
|
||||||
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
|
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
|
||||||
) => {
|
) => {
|
||||||
commitStudioMotionManifestOptimistically(
|
// 1. Write motion data as JSON attribute on the element
|
||||||
(manifest) => upsertStudioGsapMotion(manifest, selection, motion),
|
writeStudioMotionToElement(selection.element, motion);
|
||||||
{
|
// 2. Apply the GSAP timeline from DOM attributes
|
||||||
label: "Set GSAP motion",
|
let doc: Document | null = null;
|
||||||
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
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);
|
refreshDomEditSelectionFromPreview(selection);
|
||||||
},
|
},
|
||||||
[commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
|
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDomMotionClear = useCallback(
|
const handleDomMotionClear = useCallback(
|
||||||
(selection: DomEditSelection) => {
|
(selection: DomEditSelection) => {
|
||||||
commitStudioMotionManifestOptimistically(
|
const clearPatches = buildClearMotionPatches(selection.element);
|
||||||
(manifest) => removeStudioMotionForSelection(manifest, selection),
|
// Get gsap from the preview window for proper cleanup
|
||||||
{
|
let gsap: { set?: (target: HTMLElement, vars: Record<string, unknown>) => void } | undefined;
|
||||||
label: "Clear GSAP motion",
|
try {
|
||||||
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
gsap = (previewIframeRef.current?.contentWindow as { gsap?: typeof gsap })?.gsap;
|
||||||
},
|
} catch {
|
||||||
);
|
// cross-origin guard
|
||||||
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
}
|
||||||
|
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);
|
refreshDomEditSelectionFromPreview(selection);
|
||||||
},
|
},
|
||||||
[
|
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
|
||||||
applyCurrentStudioMotionToPreview,
|
|
||||||
commitStudioMotionManifestOptimistically,
|
|
||||||
refreshDomEditSelectionFromPreview,
|
|
||||||
previewIframeRef,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDomEditElementDelete = useCallback(
|
const handleDomEditElementDelete = useCallback(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect } from "react";
|
|||||||
import type { TimelineElement } from "../player";
|
import type { TimelineElement } from "../player";
|
||||||
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||||
import { findElementForSelection } from "../components/editor/domEditing";
|
import { findElementForSelection } from "../components/editor/domEditing";
|
||||||
import type { StudioMotionManifest } from "../components/editor/studioMotion";
|
|
||||||
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
||||||
import type { EditHistoryKind } from "../utils/editHistory";
|
import type { EditHistoryKind } from "../utils/editHistory";
|
||||||
import type { RightPanelTab } from "../utils/studioHelpers";
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
||||||
@@ -36,11 +35,6 @@ export interface UseDomEditSessionParams {
|
|||||||
showToast: (message: string, tone?: "error" | "info") => void;
|
showToast: (message: string, tone?: "error" | "info") => void;
|
||||||
refreshPreviewDocumentVersion: () => void;
|
refreshPreviewDocumentVersion: () => void;
|
||||||
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
||||||
commitStudioMotionManifestOptimistically: (
|
|
||||||
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
|
|
||||||
options: { label: string; coalesceKey: string },
|
|
||||||
) => void;
|
|
||||||
applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
|
|
||||||
readProjectFile: (path: string) => Promise<string>;
|
readProjectFile: (path: string) => Promise<string>;
|
||||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||||
@@ -55,9 +49,6 @@ export interface UseDomEditSessionParams {
|
|||||||
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
|
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
|
||||||
(iframe: HTMLIFrameElement) => Promise<void>
|
(iframe: HTMLIFrameElement) => Promise<void>
|
||||||
>;
|
>;
|
||||||
applyStudioMotionToPreviewRef: React.MutableRefObject<
|
|
||||||
(iframe: HTMLIFrameElement) => Promise<void>
|
|
||||||
>;
|
|
||||||
syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
|
syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
|
||||||
reloadPreview: () => void;
|
reloadPreview: () => void;
|
||||||
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
||||||
@@ -81,8 +72,6 @@ export function useDomEditSession({
|
|||||||
showToast,
|
showToast,
|
||||||
refreshPreviewDocumentVersion,
|
refreshPreviewDocumentVersion,
|
||||||
queueDomEditSave,
|
queueDomEditSave,
|
||||||
commitStudioMotionManifestOptimistically,
|
|
||||||
applyCurrentStudioMotionToPreview,
|
|
||||||
readProjectFile: _readProjectFile,
|
readProjectFile: _readProjectFile,
|
||||||
writeProjectFile,
|
writeProjectFile,
|
||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
@@ -95,7 +84,6 @@ export function useDomEditSession({
|
|||||||
refreshKey,
|
refreshKey,
|
||||||
rightPanelTab,
|
rightPanelTab,
|
||||||
applyStudioManualEditsToPreviewRef,
|
applyStudioManualEditsToPreviewRef,
|
||||||
applyStudioMotionToPreviewRef,
|
|
||||||
syncPreviewHistoryHotkey,
|
syncPreviewHistoryHotkey,
|
||||||
reloadPreview,
|
reloadPreview,
|
||||||
setRefreshKey: _setRefreshKey,
|
setRefreshKey: _setRefreshKey,
|
||||||
@@ -200,8 +188,6 @@ export function useDomEditSession({
|
|||||||
previewIframeRef,
|
previewIframeRef,
|
||||||
showToast,
|
showToast,
|
||||||
queueDomEditSave,
|
queueDomEditSave,
|
||||||
commitStudioMotionManifestOptimistically,
|
|
||||||
applyCurrentStudioMotionToPreview,
|
|
||||||
writeProjectFile,
|
writeProjectFile,
|
||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
editHistory,
|
editHistory,
|
||||||
@@ -249,19 +235,13 @@ export function useDomEditSession({
|
|||||||
};
|
};
|
||||||
|
|
||||||
syncPreviewHistoryHotkey(previewIframe);
|
syncPreviewHistoryHotkey(previewIframe);
|
||||||
void (async () => {
|
void applyStudioManualEditsToPreviewRef.current(previewIframe);
|
||||||
await applyStudioManualEditsToPreviewRef.current(previewIframe);
|
|
||||||
await applyStudioMotionToPreviewRef.current(previewIframe);
|
|
||||||
})();
|
|
||||||
syncSelectionFromDocument();
|
syncSelectionFromDocument();
|
||||||
refreshPreviewDocumentVersion();
|
refreshPreviewDocumentVersion();
|
||||||
|
|
||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
syncPreviewHistoryHotkey(previewIframe);
|
syncPreviewHistoryHotkey(previewIframe);
|
||||||
void (async () => {
|
void applyStudioManualEditsToPreviewRef.current(previewIframe);
|
||||||
await applyStudioManualEditsToPreviewRef.current(previewIframe);
|
|
||||||
await applyStudioMotionToPreviewRef.current(previewIframe);
|
|
||||||
})();
|
|
||||||
syncSelectionFromDocument();
|
syncSelectionFromDocument();
|
||||||
refreshPreviewDocumentVersion();
|
refreshPreviewDocumentVersion();
|
||||||
};
|
};
|
||||||
@@ -280,7 +260,6 @@ export function useDomEditSession({
|
|||||||
refreshPreviewDocumentVersion,
|
refreshPreviewDocumentVersion,
|
||||||
syncPreviewHistoryHotkey,
|
syncPreviewHistoryHotkey,
|
||||||
applyStudioManualEditsToPreviewRef,
|
applyStudioManualEditsToPreviewRef,
|
||||||
applyStudioMotionToPreviewRef,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { useMountEffect } from "./useMountEffect";
|
import { useMountEffect } from "./useMountEffect";
|
||||||
import {
|
import {
|
||||||
installStudioManualEditSeekReapply,
|
installStudioManualEditSeekReapply,
|
||||||
reapplyPositionEditsAfterSeek,
|
reapplyPositionEditsAfterSeek,
|
||||||
readStudioFileChangePath,
|
readStudioFileChangePath,
|
||||||
} from "../components/editor/manualEdits";
|
} from "../components/editor/manualEdits";
|
||||||
import {
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
||||||
STUDIO_MOTION_PATH,
|
|
||||||
applyStudioMotionManifest,
|
|
||||||
emptyStudioMotionManifest,
|
|
||||||
installStudioMotionSeekReapply,
|
|
||||||
isStudioMotionManifestPath,
|
|
||||||
parseStudioMotionManifest,
|
|
||||||
serializeStudioMotionManifest,
|
|
||||||
type StudioMotionManifest,
|
|
||||||
} from "../components/editor/studioMotion";
|
|
||||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
||||||
import type { EditHistoryKind } from "../utils/editHistory";
|
import type { EditHistoryKind } from "../utils/editHistory";
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
@@ -46,35 +36,24 @@ interface UseManifestPersistenceParams {
|
|||||||
|
|
||||||
export function useManifestPersistence({
|
export function useManifestPersistence({
|
||||||
projectId,
|
projectId,
|
||||||
showToast,
|
showToast: _showToast,
|
||||||
readOptionalProjectFile: _readOptionalProjectFile,
|
readOptionalProjectFile: _readOptionalProjectFile,
|
||||||
writeProjectFile,
|
writeProjectFile: _writeProjectFile,
|
||||||
recordEdit,
|
recordEdit: _recordEdit,
|
||||||
previewIframeRef,
|
previewIframeRef,
|
||||||
activeCompPathRef,
|
activeCompPathRef: _activeCompPathRef,
|
||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
reloadPreview,
|
reloadPreview,
|
||||||
}: UseManifestPersistenceParams) {
|
}: UseManifestPersistenceParams) {
|
||||||
void _readOptionalProjectFile;
|
void _showToast;
|
||||||
|
void _recordEdit;
|
||||||
|
void _activeCompPathRef;
|
||||||
|
|
||||||
const [, setStudioMotionRevision] = useState(0);
|
|
||||||
const domTextCommitVersionRef = useRef(0);
|
const domTextCommitVersionRef = useRef(0);
|
||||||
const domEditSaveQueueRef = useRef(Promise.resolve());
|
const domEditSaveQueueRef = useRef(Promise.resolve());
|
||||||
const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
|
|
||||||
const studioMotionRevisionRef = useRef(0);
|
|
||||||
const applyStudioManualEditsToPreviewRef = useRef<
|
const applyStudioManualEditsToPreviewRef = useRef<
|
||||||
(
|
(iframe?: HTMLIFrameElement | null) => Promise<void>
|
||||||
iframe?: HTMLIFrameElement | null,
|
|
||||||
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
||||||
) => Promise<void>
|
|
||||||
>(async () => {});
|
>(async () => {});
|
||||||
const applyStudioMotionToPreviewRef = useRef<
|
|
||||||
(
|
|
||||||
iframe?: HTMLIFrameElement | null,
|
|
||||||
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
||||||
) => Promise<void>
|
|
||||||
>(async () => {});
|
|
||||||
const motionBootstrappedRef = useRef(false);
|
|
||||||
|
|
||||||
// Keep a ref to the latest projectId so async save callbacks always read the
|
// 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.
|
// current value, even when the callback was captured in a stale closure.
|
||||||
@@ -96,7 +75,8 @@ export function useManifestPersistence({
|
|||||||
await domEditSaveQueueRef.current.catch(() => undefined);
|
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(
|
const applyCurrentStudioManualEditsToPreview = useCallback(
|
||||||
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
||||||
@@ -143,199 +123,47 @@ export function useManifestPersistence({
|
|||||||
);
|
);
|
||||||
applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
|
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 ──
|
// ── Sync preview after undo/redo ──
|
||||||
|
|
||||||
const syncHistoryPreviewAfterApply = useCallback(
|
const syncHistoryPreviewAfterApply = useCallback(
|
||||||
async (paths: string[] | undefined) => {
|
async (_paths: string[] | undefined) => {
|
||||||
const changedPaths = paths ?? [];
|
// Motion data is now stored in HTML attributes — any undo/redo that touches HTML
|
||||||
const motionManifestOnly =
|
// files triggers a full reload which picks up the changes automatically.
|
||||||
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.
|
|
||||||
reloadPreview();
|
reloadPreview();
|
||||||
},
|
},
|
||||||
[applyStudioMotionToPreview, previewIframeRef, reloadPreview],
|
[reloadPreview],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Reset manifests when project changes ──
|
// ── Migrate legacy studio-motion.json ──
|
||||||
|
// Projects that used the old JSON-file approach may still have a populated
|
||||||
const projectTrackerRef = useRef<string | null>(projectId);
|
// `.hyperframes/studio-motion.json`. The studio no longer reads from it, but
|
||||||
|
// the legacy render-script injection in `preview.ts` / `vite.studioMotion.ts`
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// could still fire alongside the new seek-reapply runtime. Empty the file so
|
||||||
useEffect(() => {
|
// the legacy codepath no-ops.
|
||||||
const previousProjectId = projectTrackerRef.current;
|
useMountEffect(() => {
|
||||||
projectTrackerRef.current = projectId;
|
_readOptionalProjectFile(STUDIO_MOTION_PATH)
|
||||||
if (!previousProjectId || previousProjectId === projectId) return;
|
.then((content) => {
|
||||||
studioMotionManifestRef.current = emptyStudioMotionManifest();
|
if (!content) return;
|
||||||
studioMotionRevisionRef.current += 1;
|
try {
|
||||||
setStudioMotionRevision((revision) => revision + 1);
|
const parsed = JSON.parse(content) as { motions?: unknown[] };
|
||||||
motionBootstrappedRef.current = false;
|
if (!Array.isArray(parsed.motions) || parsed.motions.length === 0) return;
|
||||||
}, [projectId]);
|
} 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) ──
|
// ── Listen for external file changes (HMR / SSE) ──
|
||||||
useMountEffect(() => {
|
useMountEffect(() => {
|
||||||
const handler = (payload?: unknown) => {
|
const handler = (payload?: unknown) => {
|
||||||
const changedPath = readStudioFileChangePath(payload);
|
const changedPath = readStudioFileChangePath(payload);
|
||||||
|
if (!changedPath) return;
|
||||||
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
|
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
|
||||||
if (isStudioMotionManifestPath(changedPath)) {
|
// External file change — reload unless it's an echo of our own save.
|
||||||
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.
|
|
||||||
if (!recentDomEditSave) {
|
if (!recentDomEditSave) {
|
||||||
reloadPreview();
|
reloadPreview();
|
||||||
}
|
}
|
||||||
@@ -353,17 +181,11 @@ export function useManifestPersistence({
|
|||||||
return {
|
return {
|
||||||
domTextCommitVersionRef,
|
domTextCommitVersionRef,
|
||||||
domEditSaveQueueRef,
|
domEditSaveQueueRef,
|
||||||
studioMotionManifestRef,
|
|
||||||
studioMotionRevisionRef,
|
|
||||||
applyStudioManualEditsToPreviewRef,
|
applyStudioManualEditsToPreviewRef,
|
||||||
applyStudioMotionToPreviewRef,
|
|
||||||
queueDomEditSave,
|
queueDomEditSave,
|
||||||
waitForPendingDomEditSaves,
|
waitForPendingDomEditSaves,
|
||||||
applyCurrentStudioManualEditsToPreview,
|
applyCurrentStudioManualEditsToPreview,
|
||||||
applyStudioManualEditsToPreview,
|
applyStudioManualEditsToPreview,
|
||||||
applyCurrentStudioMotionToPreview,
|
|
||||||
applyStudioMotionToPreview,
|
|
||||||
commitStudioMotionManifestOptimistically,
|
|
||||||
syncHistoryPreviewAfterApply,
|
syncHistoryPreviewAfterApply,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,4 +208,289 @@ describe("applyPatchByTarget", () => {
|
|||||||
expect(patched).toContain(`<div class="headline clip" data-start="0"></div>`);
|
expect(patched).toContain(`<div class="headline clip" data-start="0"></div>`);
|
||||||
expect(patched).toContain(`<div class="headline clip" data-start="2.5"></div>`);
|
expect(patched).toContain(`<div class="headline clip" data-start="2.5"></div>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("escapes JSON attribute values containing double-quotes and round-trips them", () => {
|
||||||
|
const html = `<div id="card" data-start="0"></div>`;
|
||||||
|
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 = `<div id="hero" data-start="0"></div>`;
|
||||||
|
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 = `<div id="el" data-start="0"></div>`;
|
||||||
|
const value = `a&b<c>d"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 = `<div id="card" data-start="0"></div>`;
|
||||||
|
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 = `<div id="hero" style="position: absolute">Hero</div>`;
|
||||||
|
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 = `<div id="card" class="clip" data-start="0" data-duration="10">Card</div>`;
|
||||||
|
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 = `<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible">Hero</div>`;
|
||||||
|
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 = `<div id="hero" style="position: absolute">Hero</div>`;
|
||||||
|
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 = `<div class="headline clip" style="position: absolute">Title</div>`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,24 @@ function escapeStyleAttributeValue(value: string, quote: string): string {
|
|||||||
return quote === '"' ? value.replace(/"/g, """) : value.replace(/'/g, "'");
|
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, "<")
|
||||||
|
.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[] {
|
function splitInlineStyleDeclarations(style: string): string[] {
|
||||||
const declarations: string[] = [];
|
const declarations: string[] = [];
|
||||||
let current = "";
|
let current = "";
|
||||||
@@ -281,7 +299,7 @@ export function readAttributeByTarget(
|
|||||||
|
|
||||||
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
||||||
const valueMatch = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`).exec(match.tag);
|
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 {
|
export function readTagSnippetByTarget(html: string, target: PatchTarget): string | undefined {
|
||||||
@@ -310,12 +328,13 @@ function patchAttributeByTarget(
|
|||||||
return replaceTagAtMatch(html, match, newTag);
|
return replaceTagAtMatch(html, match, newTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const escaped = escapeHtmlAttribute(value);
|
||||||
if (attrPattern.test(tag)) {
|
if (attrPattern.test(tag)) {
|
||||||
const newTag = tag.replace(attrPattern, `${fullAttr}="${value}"`);
|
const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
|
||||||
return replaceTagAtMatch(html, match, newTag);
|
return replaceTagAtMatch(html, match, newTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTag = tag + ` ${fullAttr}="${value}"`;
|
const newTag = tag + ` ${fullAttr}="${escaped}"`;
|
||||||
return replaceTagAtMatch(html, match, newTag);
|
return replaceTagAtMatch(html, match, newTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,13 +362,14 @@ function patchAttribute(
|
|||||||
return html.replace(tag, newTag);
|
return html.replace(tag, newTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const escaped = escapeHtmlAttribute(value);
|
||||||
if (attrPattern.test(tag)) {
|
if (attrPattern.test(tag)) {
|
||||||
// Update existing attribute
|
// Update existing attribute
|
||||||
const newTag = tag.replace(attrPattern, `${fullAttr}="${value}"`);
|
const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
|
||||||
return html.replace(tag, newTag);
|
return html.replace(tag, newTag);
|
||||||
} else {
|
} else {
|
||||||
// Add new attribute
|
// Add new attribute
|
||||||
const newTag = tag + ` ${fullAttr}="${value}"`;
|
const newTag = tag + ` ${fullAttr}="${escaped}"`;
|
||||||
return html.replace(tag, newTag);
|
return html.replace(tag, newTag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user