Files
hyperframes/packages/studio/src/hooks/usePreviewPersistence.ts
T
Miguel Ángel fb2e21090f feat(studio): GSAP tween editing in Design panel (#1102)
* feat(studio): GSAP tween editing in Design panel

Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.

Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.

recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:

- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
  conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
  reachable only via the @hyperframes/core/gsap-parser subpath, loaded
  server-side by the studio-api mutation routes and the linter via dynamic
  import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
  bundles never trace recast.

Adds AST parser unit + stress coverage and e2e helpers for the panel.

* fix(lint): await async lintHyperframeHtml in all callers

lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.

Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
2026-05-28 19:16:34 -04:00

200 lines
6.9 KiB
TypeScript

import { useCallback, useRef } from "react";
import { useMountEffect } from "./useMountEffect";
import {
installStudioManualEditSeekReapply,
reapplyPositionEditsAfterSeek,
readStudioFileChangePath,
} from "../components/editor/manualEdits";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import type { EditHistoryKind } from "../utils/editHistory";
// ── Types ──
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
interface UsePreviewPersistenceParams {
projectId: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
readOptionalProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (entry: RecordEditInput) => Promise<void>;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
activeCompPathRef: React.MutableRefObject<string | null>;
/** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits).
* Used to suppress file-change echoes so we don't reload after our own saves. */
domEditSaveTimestampRef: React.MutableRefObject<number>;
/** Tracks in-flight timeline edits that patch the iframe DOM directly. File-change
* events for these paths are always suppressed since the preview is already up-to-date. */
pendingTimelineEditPathRef?: React.MutableRefObject<Set<string>>;
/** Called to reload the preview after undo/redo or external file changes. */
reloadPreview: () => void;
}
// ── Hook ──
export function usePreviewPersistence({
projectId,
showToast: _showToast,
readOptionalProjectFile: _readOptionalProjectFile,
writeProjectFile: _writeProjectFile,
recordEdit: _recordEdit,
previewIframeRef,
activeCompPathRef: _activeCompPathRef,
domEditSaveTimestampRef,
reloadPreview,
pendingTimelineEditPathRef,
}: UsePreviewPersistenceParams) {
void _showToast;
void _recordEdit;
void _activeCompPathRef;
const domTextCommitVersionRef = useRef(0);
const domEditSaveQueueRef = useRef(Promise.resolve());
const applyStudioManualEditsToPreviewRef = useRef<
(iframe?: HTMLIFrameElement | null) => Promise<void>
>(async () => {});
// 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.
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
// ── Queue / drain helpers ──
const queueDomEditSave = useCallback((save: () => Promise<void>) => {
const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save);
domEditSaveQueueRef.current = queuedSave.then(
() => undefined,
() => undefined,
);
return queuedSave;
}, []);
const waitForPendingDomEditSaves = useCallback(async () => {
await domEditSaveQueueRef.current.catch(() => undefined);
}, []);
// ── Apply manual edits (HTML-baked — install seek hooks) ──
// reapplyPositionEditsAfterSeek now also handles motion reapply from DOM attributes.
const applyCurrentStudioManualEditsToPreview = useCallback(
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
if (!iframe) return;
let doc: Document | null = null;
try {
doc = iframe.contentDocument;
} catch {
return;
}
if (!doc) return;
const reapply = () => {
let d: Document | null = null;
try {
d = iframe.contentDocument;
} catch {
return;
}
if (d) reapplyPositionEditsAfterSeek(d);
};
const install = () => {
reapply();
if (iframe.contentWindow) installStudioManualEditSeekReapply(iframe.contentWindow, reapply);
};
const win = iframe.contentWindow;
install();
win?.requestAnimationFrame?.(install);
win?.setTimeout?.(install, 80);
win?.setTimeout?.(install, 250);
win?.setTimeout?.(install, 500);
win?.setTimeout?.(install, 1000);
win?.setTimeout?.(install, 2000);
},
[previewIframeRef],
);
const applyStudioManualEditsToPreview = useCallback(
async (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
applyCurrentStudioManualEditsToPreview(iframe);
},
[applyCurrentStudioManualEditsToPreview, previewIframeRef],
);
applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
// ── Sync preview after undo/redo ──
const syncHistoryPreviewAfterApply = useCallback(
async (_paths: string[] | undefined) => {
// Motion data is now stored in HTML attributes — any undo/redo that touches HTML
// files triggers a full reload which picks up the changes automatically.
reloadPreview();
},
[reloadPreview],
);
// ── Migrate legacy studio-motion.json ──
// Projects that used the old JSON-file approach may still have a populated
// `.hyperframes/studio-motion.json`. The studio no longer reads from it, but
// the legacy render-script injection in `preview.ts` / `vite.studioMotion.ts`
// could still fire alongside the new seek-reapply runtime. Empty the file so
// the legacy codepath no-ops.
useMountEffect(() => {
_readOptionalProjectFile(STUDIO_MOTION_PATH)
.then((content) => {
if (!content) return;
try {
const parsed = JSON.parse(content) as { motions?: unknown[] };
if (!Array.isArray(parsed.motions) || parsed.motions.length === 0) return;
} catch {
return;
}
return _writeProjectFile(STUDIO_MOTION_PATH, JSON.stringify({ version: 1, motions: [] }));
})
.catch(() => {
/* best-effort migration — ignore failures */
});
});
// ── Listen for external file changes (HMR / SSE) ──
useMountEffect(() => {
const handler = (payload?: unknown) => {
const changedPath = readStudioFileChangePath(payload);
if (!changedPath) return;
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 4000;
if (pendingTimelineEditPathRef?.current.has(changedPath)) {
pendingTimelineEditPathRef.current.delete(changedPath);
return;
}
if (!recentDomEditSave) {
reloadPreview();
}
};
if (import.meta.hot) {
import.meta.hot.on("hf:file-change", handler);
return () => import.meta.hot?.off?.("hf:file-change", handler);
}
// SSE fallback for embedded studio server
const es = new EventSource("/api/events");
es.addEventListener("file-change", handler);
return () => es.close();
});
return {
domTextCommitVersionRef,
domEditSaveQueueRef,
applyStudioManualEditsToPreviewRef,
queueDomEditSave,
waitForPendingDomEditSaves,
applyCurrentStudioManualEditsToPreview,
applyStudioManualEditsToPreview,
syncHistoryPreviewAfterApply,
};
}