import { useCallback, useRef } from "react"; import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation"; import { FONT_EXT } from "../utils/mediaTypes"; import { trackStudioEvent } from "../utils/studioTelemetry"; import { primaryFontFamilyValue } from "../utils/studioFontHelpers"; import { createStudioSaveHttpError, StudioSaveHttpError, trackStudioSaveFailure, } from "../utils/studioSaveDiagnostics"; import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing"; import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; import type { CommitDomEditPatchBatches, DomEditPatchBatch, PersistDomEditOperations, } from "./domEditCommitTypes"; import type { PatchOperation } from "../utils/sourcePatcher"; import { DomEditPersistUnsafeValueError, DomEditPersistUnresolvableError, warnDomEditPersistNoOp, } from "./domEditPersistFailure"; import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit"; import { useDomEditTextCommits } from "./useDomEditTextCommits"; import { useDomGeometryCommits } from "./useDomGeometryCommits"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; import { formatFieldsSuffix } from "./gsapScriptCommitHelpers"; // ── Helpers ── function formatUnsafeFieldList(fields: Array<{ path: string }>): string { return fields.map((field) => field.path).join(", "); } function getErrorDetail(error: unknown): string { return error instanceof Error ? error.message : String(error); } async function readErrorResponseBody( response: Response, ): Promise<{ error?: string; fields?: string[] } | null> { const contentType = response.headers.get("content-type") ?? ""; if (!contentType.includes("application/json")) return null; return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null; } function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string { if (!body?.error) return "Couldn't save edit"; return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`; } interface RecordEditInput { label: string; kind: EditHistoryKind; coalesceKey?: string; coalesceMs?: number; files: Record; } /** Human-readable identifier for a batch patch target (for the unmatched warning). */ function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string { return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)"; } /** * Surface server-reported unmatched patches. The server atomically refuses the * whole multi-file gesture; the caller uses `durable: false` to roll back and * reload, so report the refusal without turning it into a second failure. */ function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void { const unmatchedIds = batch.patches .filter((_, index) => matched[index] === false) .map(describeBatchPatchTarget); if (unmatchedIds.length === 0) return; console.warn( `[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` + `${batch.sourceFile} (the whole z-order gesture will revert on reload):`, unmatchedIds.join(", "), ); trackStudioSaveFailure({ source: "dom_edit", error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`), filePath: batch.sourceFile, mutationType: "z-reorder-unmatched", }); } interface AtomicElementPatchFile { sourceFile: string; changed: boolean; matched?: boolean[]; before: string; after: string; } class AtomicElementPatchConvergenceError extends Error { constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "AtomicElementPatchConvergenceError"; } } // Keep the atomic response contract in one guard so callers do not compose validity. // fallow-ignore-next-line complexity function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFile { return ( typeof value === "object" && value !== null && "sourceFile" in value && typeof value.sourceFile === "string" && "changed" in value && typeof value.changed === "boolean" && (!("matched" in value) || (Array.isArray(value.matched) && value.matched.every((matched) => typeof matched === "boolean"))) && "before" in value && typeof value.before === "string" && "after" in value && typeof value.after === "string" && value.changed === (value.before !== value.after) ); } // This is the single client owner for dispatching and validating the aggregate // atomic endpoint. Splitting validation from the request would weaken that wire contract. // fallow-ignore-next-line complexity async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) { const body = JSON.stringify({ batches }); try { const response = await fetch( `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`, { method: "POST", headers: { "Content-Type": "application/json" }, body, }, ); if (!response.ok) { const rejection = await readErrorResponseBody(response); throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status); } const result: unknown = await response.json().catch(() => null); if ( typeof result !== "object" || result === null || !("durable" in result) || typeof result.durable !== "boolean" || !("files" in result) || !Array.isArray(result.files) || result.files.length !== batches.length || !result.files.every(isAtomicElementPatchFile) || (!result.durable && result.files.some((file) => file.changed)) ) { throw new StudioSaveHttpError("Invalid atomic element patch response", 502); } const files = result.files.map((file, index) => { const batch = batches[index]; const matched = file.matched ?? []; if ( !batch || file.sourceFile !== batch.sourceFile || (matched.length !== 0 && matched.length !== batch.patches.length) ) { throw new StudioSaveHttpError("Invalid atomic element patch response", 502); } reportUnmatchedBatchPatches(batch, matched); return { ...file, matched, allMatched: matched.length === batch.patches.length && matched.every(Boolean), }; }); return { durable: result.durable, files }; } catch (error) { throw new AtomicElementPatchConvergenceError(getErrorDetail(error), { cause: error }); } } /** * A batch is reload-skippable only when it is style-only: every operation is an * `inline-style` write. The z-reorder commit applies those exact styles to the * live iframe DOM synchronously, so persisting them adds nothing the preview * doesn't already show. Any other op type (attribute / text-content / …) can * have server-side semantics the live DOM hasn't mirrored — reload for those. */ function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean { return batches.every((batch) => batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")), ); } export interface UseDomEditCommitsParams { activeCompPath: string | null; previewIframeRef: React.MutableRefObject; showToast: (message: string, tone?: "error" | "info") => void; queueDomEditSave: (save: () => Promise) => Promise; writeProjectFile: (path: string, content: string) => Promise; domEditSaveTimestampRef: React.MutableRefObject; editHistory: { recordEdit: (entry: RecordEditInput) => Promise }; fileTree: string[]; importedFontAssetsRef: React.MutableRefObject; projectId: string | null; projectIdRef: React.MutableRefObject; 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 }, ) => Promise; /** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK * path, whose session is already current) so a later SDK edit doesn't * serialize the pre-write doc and revert the server's change. */ forceReloadSdkSession?: () => void; /** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */ onTrySdkPersist?: ( selection: DomEditSelection, operations: PatchOperation[], originalContent: string, targetPath: string, options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean }, ) => Promise; /** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */ onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise; /** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */ onReorderShadow?: (targets: string[]) => void; } export function useDomEditCommits({ activeCompPath, previewIframeRef, showToast, queueDomEditSave, writeProjectFile, domEditSaveTimestampRef, editHistory, fileTree, importedFontAssetsRef, projectId, projectIdRef, reloadPreview, domEditSelection, applyDomSelection, clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, forceReloadSdkSession, onTrySdkPersist, onTrySdkDelete, onReorderShadow, }: 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 reportedUnresolvableRef = useRef(new Set()); // fallow-ignore-next-line complexity const persistDomEditOperations: PersistDomEditOperations = useCallback( // fallow-ignore-next-line complexity 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 await createStudioSaveHttpError(readResponse, `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; // Validate layout values BEFORE any persist path runs. The SDK cutover // path (onTrySdkPersist) returns early on success, so leaving this check // after it let invalid numeric values bypass the guard whenever the // cutover flag was on. const patchTarget = buildDomEditPatchTarget(selection); const patchBody = { target: patchTarget, operations }; const unsafeFields = findUnsafeDomPatchValues(patchBody); if (unsafeFields.length > 0) { const fields = formatUnsafeFieldList(unsafeFields); showToast("Couldn't save edit because it contains invalid layout values", "error"); throw new DomEditPersistUnsafeValueError(`DOM patch contains unsafe values: ${fields}`, { alreadyToasted: true, }); } // Skip the SDK path when prepareContent is set (e.g. @font-face injection // for a custom font): sdkCutoverPersist serializes only the patched DOM // and would drop the injected content. Let the server path run prepareContent. if ( onTrySdkPersist && !options?.prepareContent && (await onTrySdkPersist(selection, operations, originalContent, targetPath, { label: options?.label, coalesceKey: options?.coalesceKey, skipRefresh: options?.skipRefresh, })) ) { // SDK handled it — its in-memory doc is already current, so do NOT // forceReload (that would echo-reload the session we just wrote). return; } // Mark the save timestamp before the file write so the SSE file-change // handler suppresses the reload even if the event arrives before the // response (the server writes the file and emits SSE during the fetch). domEditSaveTimestampRef.current = Date.now(); const patchResponse = await fetch( `/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patchBody), }, ); if (!patchResponse.ok) { showToast(formatPatchRejectionMessage(await readErrorResponseBody(patchResponse)), "error"); throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`, { alreadyToasted: true, }); } const patchData = (await patchResponse.json()) as { ok?: boolean; changed?: boolean; matched?: boolean; content?: string; }; if (!patchData.changed) { if (patchData.matched === false) { const targetKey = selection.selector ?? selection.id ?? "selection"; if (!reportedUnresolvableRef.current.has(targetKey)) { reportedUnresolvableRef.current.add(targetKey); trackStudioEvent("save_skipped_unresolvable", { target_id: selection.id ?? undefined, target_selector: selection.selector ?? undefined, target_source_file: selection.sourceFile ?? undefined, composition: activeCompPath ?? undefined, }); } throw new DomEditPersistUnresolvableError(targetPath); } warnDomEditPersistNoOp(selection, operations); return; } const patchedContent = typeof patchData.content === "string" ? patchData.content : originalContent; let finalContent = patchedContent; if (options?.prepareContent) { const preparedContent = options.prepareContent(patchedContent, targetPath); if (preparedContent !== patchedContent) { try { await writeProjectFile(targetPath, preparedContent); finalContent = preparedContent; } catch (error) { // The patch above already landed on disk — only the prepareContent // embellishment (e.g. an injected @font-face) failed to write. Keep // the already-persisted patchedContent instead of throwing, which // would otherwise revert a change the server already committed. showToast( `Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`, "error", ); } } } await editHistory.recordEdit({ label: options?.label ?? "Edit layer", kind: "manual", coalesceKey: options?.coalesceKey, coalesceMs: options?.coalesceMs, files: { [targetPath]: { before: originalContent, after: finalContent } }, }); forceReloadSdkSession?.(); if (!options?.skipRefresh) { reloadPreview(); } }, [ activeCompPath, editHistory, writeProjectFile, projectIdRef, domEditSaveTimestampRef, reloadPreview, showToast, forceReloadSdkSession, onTrySdkPersist, ], ); const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback( (batches, options) => queueDomEditSave( // One queued transaction owns validation, persistence, history, reload, // and its durable result; splitting those phases risks partial commits. // fallow-ignore-next-line complexity async () => { const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); const unsafeFields = batches.flatMap((batch) => batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)), ); if (unsafeFields.length > 0) { showToast("Couldn't save edit because it contains invalid layout values", "error"); throw new DomEditPersistUnsafeValueError( `DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`, { alreadyToasted: true }, ); } domEditSaveTimestampRef.current = Date.now(); const atomicResult = await patchElementBatches(pid, batches); const allMatched = atomicResult.durable && atomicResult.files.every((result) => result.allMatched); const files = Object.fromEntries( atomicResult.files .filter((result) => result.changed) .map((result) => [result.sourceFile, { before: result.before, after: result.after }]), ); const changed = Object.keys(files).length > 0; if (changed) { await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, coalesceMs: options.coalesceMs, files, }); forceReloadSdkSession?.(); } const durable = allMatched; // A z-only reorder already applied its inline styles to the live iframe // DOM (and the store) synchronously, so remounting the iframe here only // produces a visible blink. Skip the reload when the caller asked for it // AND the persist is provably in sync: style-only ops, every target // matched. Any unmatched patch means the live DOM now shows state disk // doesn't hold — reload so the preview reconverges. (The SSE/file-watcher // reload is independently suppressed by domEditSaveTimestampRef above.) const skipSafe = options.skipReload === true && batchesAreInlineStyleOnly(batches) && durable; if (!durable || (changed && !skipSafe)) reloadPreview(); return { durable, allMatched, changed }; }, ).catch((error) => { if (error instanceof AtomicElementPatchConvergenceError) reloadPreview(); const alreadyToasted = (error instanceof StudioSaveHttpError || error instanceof DomEditPersistUnsafeValueError) && error.alreadyToasted; if (!alreadyToasted) { showToast(error instanceof Error ? error.message : "Failed to reorder layers", "error"); } trackStudioSaveFailure({ source: "dom_edit", error, filePath: batches.map((batch) => batch.sourceFile).join(","), mutationType: "z-reorder", label: options.label, }); throw error; }), [ domEditSaveTimestampRef, editHistory, forceReloadSdkSession, projectIdRef, queueDomEditSave, reloadPreview, showToast, ], ); // ── Text & style commits (delegated to useDomEditTextCommits) ── const { handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomTextCommit, commitDomTextFields, handleDomTextFieldStyleCommit, handleDomAddTextField, handleDomRemoveTextField, } = useDomEditTextCommits({ activeCompPath, previewIframeRef, domEditSelection, applyDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, persistDomEditOperations, resolveImportedFontAsset, showToast, }); // ── Position patch helper (shared by geometry + lifecycle hooks) ── const commitPositionPatchToHtml = useDomEditPositionPatchCommit({ activeCompPath, persistDomEditOperations, queueDomEditSave, showToast, }); // ── Geometry commits (path offset, box size, rotation) ── const { handleDomPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, handleDomManualEditsReset, } = useDomGeometryCommits({ previewIframeRef, showToast, commitPositionPatchToHtml, }); // ── Element lifecycle (delete, z-index reorder) ── const { handleDomEditElementDelete, handleDomZIndexReorderCommit } = useElementLifecycleOps({ activeCompPath, showToast, writeProjectFile, domEditSaveTimestampRef, editHistory, projectIdRef, reloadPreview, clearDomSelection, onTrySdkDelete, onReorderShadow, forceReloadSdkSession, commitDomEditPatchBatches, }); return { resolveImportedFontAsset, handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomTextCommit, commitDomTextFields, handleDomTextFieldStyleCommit, handleDomAddTextField, handleDomRemoveTextField, handleDomPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, handleDomManualEditsReset, handleDomEditElementDelete, handleDomZIndexReorderCommit, }; }