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 } 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 { PersistDomEditOperations } from "./domEditCommitTypes"; import type { PatchOperation } from "../utils/sourcePatcher"; import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit"; import { useDomEditTextCommits } from "./useDomEditTextCommits"; import { useDomGeometryCommits } from "./useDomGeometryCommits"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; // ── Helpers ── function formatUnsafeFieldList(fields: Array<{ path: string }>): string { return fields.map((field) => field.path).join(", "); } 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"; // Pre-existing clone of the GSAP save-error formatter (gsapScriptCommitHelpers); // surfaced here by this PR's adjacent edits, not introduced by it. // fallow-ignore-next-line code-duplication const fields = Array.isArray(body.fields) ? body.fields.filter((field): field is string => typeof field === "string") : []; const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : ""; return `Couldn't save edit: ${body.error}${suffix}`; } interface RecordEditInput { label: string; kind: EditHistoryKind; coalesceKey?: string; files: Record; } 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; /** Stage 7 Step 3b: called after a successful server-side element patch. */ onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void; /** Stage 7 Step 3b: called after a successful server-side element delete. */ onElementDeleted?: (selection: DomEditSelection) => void; } export function useDomEditCommits({ activeCompPath, previewIframeRef, showToast, queueDomEditSave, writeProjectFile, domEditSaveTimestampRef, editHistory, fileTree, importedFontAssetsRef, projectId, projectIdRef, reloadPreview, domEditSelection, applyDomSelection, clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, onDomEditPersisted, onElementDeleted, }: 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; 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 Error(`DOM patch contains unsafe values: ${fields}`); } // 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}`); } 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, }); console.warn( `[studio] Element not found in source: ${targetKey}. ` + "This element may be generated at runtime and cannot be persisted.", ); } } return; } 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 } }, }); onDomEditPersisted?.(selection, operations); if (!options?.skipRefresh) { reloadPreview(); } }, [ activeCompPath, editHistory, writeProjectFile, projectIdRef, domEditSaveTimestampRef, reloadPreview, showToast, onDomEditPersisted, ], ); // ── 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 (shared by geometry + lifecycle hooks) ── const commitPositionPatchToHtml = useDomEditPositionPatchCommit({ activeCompPath, persistDomEditOperations, queueDomEditSave, showToast, }); // ── Geometry commits (path offset, box size, rotation) ── const { handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, 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, commitPositionPatchToHtml, onElementDeleted, }); return { resolveImportedFontAsset, handleDomStyleCommit, handleDomAttributeCommit, handleDomHtmlAttributeCommit, handleDomTextCommit, commitDomTextFields, handleDomTextFieldStyleCommit, handleDomAddTextField, handleDomRemoveTextField, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, handleDomManualEditsReset, handleDomEditElementDelete, handleDomZIndexReorderCommit, }; }