Files
hyperframes/packages/studio/src/hooks/useDomEditCommits.ts
T
Miguel Ángel 45999226a3 fix(studio): server-side DOM patching, render CSS scoping, and resilience
Root-cause fix for edits being wiped after refresh: the studio's
inspector edits were patched client-side via regex matching in
sourcePatcher.ts, which silently failed for many compositions ("Unable
to patch" toast). Replaced with a server-side patch-element API endpoint
using linkedom for proper DOM parsing via querySelector.

Also fixes the WYSIWYG render bug where sub-composition CSS was not
applied. The CSS scoping generated descendant selectors when both
attributes coexist on the same host element. Fixed to use compound
selectors for the authored root.

Edit persistence:
- New POST /file-mutations/patch-element endpoint using linkedom
- persistDomEditOperations calls server instead of client regex
- 15 tests covering all patch operation types

Render CSS scoping:
- Compound selector for authored root on host element
- Regression test: wysiwyg-subcomp-css (baseline pending Docker)
- 3 unit tests + 1 integration test

GSAP CDN fallback:
- Preview: error-handler catches gsap 404 and loads from CDN
- Producer: rewrites missing local gsap paths to CDN before compile

Studio resilience:
- Error boundary with recoverable UI
- Lazy mediabunny import prevents crash cascade
- Hash routing listens for hashchange events
- Sub-composition duration reads data-hf-authored-duration fallback
- Save debounce 600ms to requestAnimationFrame

Observability:
- PostHog telemetry for crashes, save failures, tab switches, playback,
  toolbar actions, navigation, and render starts
2026-05-20 17:07:31 -04:00

487 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback } from "react";
import { usePlayerStore } from "../player";
import { FONT_EXT } from "../utils/mediaTypes";
import type { PatchOperation } from "../utils/sourcePatcher";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
import {
applyStudioPathOffset,
applyStudioBoxSize,
applyStudioRotation,
clearStudioPathOffset,
clearStudioBoxSize,
clearStudioRotation,
} from "../components/editor/manualEdits";
import {
buildPathOffsetPatches,
buildBoxSizePatches,
buildRotationPatches,
buildClearPathOffsetPatches,
buildClearBoxSizePatches,
buildClearRotationPatches,
buildMotionPatches,
buildClearMotionPatches,
} from "../components/editor/manualEditsDom";
import {
writeStudioMotionToElement,
clearStudioMotionFromElement,
applyStudioMotionFromDom,
type StudioGsapMotion,
} from "../components/editor/studioMotion";
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
import type { EditHistoryKind } from "../utils/editHistory";
import { useDomEditTextCommits } from "./useDomEditTextCommits";
// ── Types ──
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
export type PersistDomEditOperations = (
selection: DomEditSelection,
operations: PatchOperation[],
options?: {
label?: string;
coalesceKey?: string;
skipRefresh?: boolean;
prepareContent?: (html: string, sourceFile: string) => string;
shouldSave?: () => boolean;
},
) => Promise<void>;
export interface UseDomEditCommitsParams {
activeCompPath: string | null;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
showToast: (message: string, tone?: "error" | "info") => void;
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
writeProjectFile: (path: string, content: string) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
fileTree: string[];
importedFontAssetsRef: React.MutableRefObject<ImportedFontAsset[]>;
projectId: string | null;
projectIdRef: React.MutableRefObject<string | null>;
reloadPreview: () => void;
// From useDomSelection
domEditSelection: DomEditSelection | null;
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
) => void;
clearDomSelection: () => void;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
buildDomSelectionFromTarget: (
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null;
}
// ── Hook ──
export function useDomEditCommits({
activeCompPath,
previewIframeRef,
showToast,
queueDomEditSave,
writeProjectFile,
domEditSaveTimestampRef,
editHistory,
fileTree,
importedFontAssetsRef,
projectId,
projectIdRef,
reloadPreview,
domEditSelection,
applyDomSelection,
clearDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
}: UseDomEditCommitsParams) {
const resolveImportedFontAsset = useCallback(
(fontFamilyValue: string): ImportedFontAsset | null => {
const family = primaryFontFamilyValue(fontFamilyValue);
if (!family) return null;
const imported = importedFontAssetsRef.current.find(
(font) => font.family.toLowerCase() === family.toLowerCase(),
);
if (imported) return imported;
const asset = fileTree.find(
(path) =>
FONT_EXT.test(path) &&
fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
);
if (!asset) return null;
return {
family: fontFamilyFromAssetPath(asset),
path: asset,
url: `/api/projects/${projectId}/preview/${asset}`,
};
},
[fileTree, projectId, importedFontAssetsRef],
);
const persistDomEditOperations: PersistDomEditOperations = useCallback(
async (selection, operations, options) => {
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
if (options?.shouldSave && !options.shouldSave()) return;
const targetPath = selection.sourceFile || activeCompPath || "index.html";
const readResponse = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
);
if (!readResponse.ok) throw new Error(`Failed to read ${targetPath}`);
const readData = (await readResponse.json()) as { content?: string };
const originalContent = readData.content;
if (typeof originalContent !== "string") {
throw new Error(`Missing file contents for ${targetPath}`);
}
if (options?.shouldSave && !options.shouldSave()) return;
const patchTarget: { id?: string | null; selector?: string; selectorIndex?: number } = {
id: selection.id,
selector: selection.selector,
selectorIndex: selection.selectorIndex,
};
const patchResponse = await fetch(
`/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: patchTarget, operations }),
},
);
if (!patchResponse.ok) throw new Error(`Failed to patch ${targetPath}`);
const patchData = (await patchResponse.json()) as {
ok?: boolean;
changed?: boolean;
content?: string;
};
if (!patchData.changed) {
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
}
const patchedContent =
typeof patchData.content === "string" ? patchData.content : originalContent;
let finalContent = patchedContent;
if (options?.prepareContent) {
finalContent = options.prepareContent(patchedContent, targetPath);
if (finalContent !== patchedContent) {
await writeProjectFile(targetPath, finalContent);
}
}
await editHistory.recordEdit({
label: options?.label ?? "Edit layer",
kind: "manual",
coalesceKey: options?.coalesceKey,
files: { [targetPath]: { before: originalContent, after: finalContent } },
});
if (options?.skipRefresh) {
domEditSaveTimestampRef.current = Date.now();
} else {
reloadPreview();
}
},
[
activeCompPath,
editHistory,
writeProjectFile,
projectIdRef,
domEditSaveTimestampRef,
reloadPreview,
],
);
// ── Text & style commits (delegated to useDomEditTextCommits) ──
const {
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
} = useDomEditTextCommits({
activeCompPath,
previewIframeRef,
domEditSelection,
applyDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
persistDomEditOperations,
resolveImportedFontAsset,
});
// ── Position patch helper ──
const commitPositionPatchToHtml = useCallback(
(
selection: DomEditSelection,
patches: PatchOperation[],
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
) => {
void queueDomEditSave(async () => {
await persistDomEditOperations(selection, patches, {
label: options.label,
coalesceKey: options.coalesceKey,
skipRefresh: options.skipRefresh ?? true,
});
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to save position";
showToast(message);
trackStudioEvent("save_failure", {
source: "dom_edit",
label: options.label,
error_message: message,
});
});
},
[persistDomEditOperations, queueDomEditSave, showToast],
);
// ── Position commits ──
const handleDomPathOffsetCommit = useCallback(
(selection: DomEditSelection, next: { x: number; y: number }) => {
applyStudioPathOffset(selection.element, next);
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: "Move layer",
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
});
},
[commitPositionPatchToHtml],
);
const handleDomGroupPathOffsetCommit = useCallback(
(updates: DomEditGroupPathOffsetCommit[]) => {
if (updates.length === 0) return;
const coalesceKey = updates
.map((u) => getDomEditTargetKey(u.selection))
.sort()
.join(":");
for (const { selection, next } of updates) {
applyStudioPathOffset(selection.element, next);
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: `Move ${updates.length} layers`,
coalesceKey: `group-path-offset:${coalesceKey}`,
});
}
},
[commitPositionPatchToHtml],
);
const handleDomBoxSizeCommit = useCallback(
(selection: DomEditSelection, next: { width: number; height: number }) => {
applyStudioBoxSize(selection.element, next);
commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
label: "Resize layer box",
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
});
},
[commitPositionPatchToHtml],
);
const handleDomRotationCommit = useCallback(
(selection: DomEditSelection, next: { angle: number }) => {
applyStudioRotation(selection.element, next);
commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
label: "Rotate layer",
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
});
},
[commitPositionPatchToHtml],
);
const handleDomManualEditsReset = useCallback(
(selection: DomEditSelection) => {
const element = selection.element;
const clearPatches = [
...buildClearPathOffsetPatches(element),
...buildClearBoxSizePatches(element),
...buildClearRotationPatches(element),
];
clearStudioPathOffset(element);
clearStudioBoxSize(element);
clearStudioRotation(element);
// skipRefresh:false triggers reloadPreview() which re-syncs selection on load
commitPositionPatchToHtml(selection, clearPatches, {
label: "Reset layer edits",
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
skipRefresh: false,
});
},
[commitPositionPatchToHtml],
);
// ── Motion commits (HTML-attribute–backed) ──
const handleDomMotionCommit = useCallback(
(
selection: DomEditSelection,
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
) => {
// 1. Write motion data as JSON attribute on the element
writeStudioMotionToElement(selection.element, motion);
// 2. Apply the GSAP timeline from DOM attributes
let doc: Document | null = null;
try {
doc = previewIframeRef.current?.contentDocument ?? null;
} catch {
// cross-origin guard
}
if (doc) applyStudioMotionFromDom(doc);
// 3. Build patches and persist to HTML
const patches = buildMotionPatches(selection.element);
commitPositionPatchToHtml(selection, patches, {
label: "Set GSAP motion",
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
});
refreshDomEditSelectionFromPreview(selection);
},
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
);
const handleDomMotionClear = useCallback(
(selection: DomEditSelection) => {
const clearPatches = buildClearMotionPatches(selection.element);
// Get gsap from the preview window for proper cleanup
let gsap: { set?: (target: HTMLElement, vars: Record<string, unknown>) => void } | undefined;
try {
gsap = (previewIframeRef.current?.contentWindow as { gsap?: typeof gsap })?.gsap;
} catch {
// cross-origin guard
}
clearStudioMotionFromElement(selection.element, gsap);
let doc: Document | null = null;
try {
doc = previewIframeRef.current?.contentDocument ?? null;
} catch {
// cross-origin guard
}
if (doc) applyStudioMotionFromDom(doc);
commitPositionPatchToHtml(selection, clearPatches, {
label: "Clear GSAP motion",
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
skipRefresh: false,
});
refreshDomEditSelectionFromPreview(selection);
},
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
);
const handleDomEditElementDelete = useCallback(
async (selection: DomEditSelection) => {
const pid = projectIdRef.current;
if (!pid) return;
const label = selection.label || selection.id || selection.selector || selection.tagName;
const targetPath = selection.sourceFile || activeCompPath || "index.html";
try {
const response = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
const data = (await response.json()) as { content?: string };
const originalContent = data.content;
if (typeof originalContent !== "string")
throw new Error(`Missing file contents for ${targetPath}`);
const patchTarget: { id?: string; selector?: string; selectorIndex?: number } = selection.id
? {
id: selection.id,
selector: selection.selector,
selectorIndex: selection.selectorIndex,
}
: selection.selector
? { selector: selection.selector, selectorIndex: selection.selectorIndex }
: ({} as never);
if (!patchTarget.id && !patchTarget.selector) {
throw new Error("Selected element has no patchable target");
}
const removeResponse = await fetch(
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: patchTarget }),
},
);
if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
const patchedContent =
typeof removeData.content === "string" ? removeData.content : originalContent;
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Delete element",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit: editHistory.recordEdit,
});
clearDomSelection();
usePlayerStore.getState().setSelectedElementId(null);
reloadPreview();
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete element";
showToast(message);
}
},
[
activeCompPath,
clearDomSelection,
domEditSaveTimestampRef,
editHistory.recordEdit,
projectIdRef,
reloadPreview,
showToast,
writeProjectFile,
],
);
return {
resolveImportedFontAsset,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
handleDomManualEditsReset,
handleDomMotionCommit,
handleDomMotionClear,
handleDomEditElementDelete,
};
}