import { type ReactNode, useCallback, useMemo, useRef, useState, } from "react"; import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts"; import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts"; import type { StudioGateway } from "../../application/ports/studio-gateway.ts"; import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts"; import type { WorkingCopy, WorkingCopyInput, } from "../../contracts/studio/contract.ts"; import { StudioContext, type StudioContextValue, type StudioEditorState, type StudioEditorStatus, } from "./use-studio.ts"; import { UnsavedLeaveDialog } from "./components/unsaved-leave-dialog.tsx"; import { useBeforeUnload } from "./components/use-before-unload.ts"; type StudioProviderProps = Readonly<{ children: ReactNode; createGateway: () => StudioGateway; // Optional so test harnesses that only exercise the document gateway keep // working unchanged. `StudioShell` always supplies one in the running app. createAssetGateway?: () => StudioAssetGateway; resolvePublishedLabel?: ResolvePublishedLabel; now?: () => Date; navigate?: (href: string) => void; }>; function inputOf(document: WorkingCopy): WorkingCopyInput { const input: Record = { ...document }; delete input.id; delete input.version; delete input.updatedAt; return input as WorkingCopyInput; } function defaultNavigate(href: string): void { window.history.pushState({}, "", href); window.dispatchEvent(new PopStateEvent("popstate")); } const missingPublishedLabel: ResolvePublishedLabel = () => undefined; export function StudioProvider({ children, createGateway, createAssetGateway, resolvePublishedLabel = missingPublishedLabel, now = () => new Date("2026-08-14T01:00:00.000Z"), navigate = defaultNavigate, }: StudioProviderProps) { const [gateway] = useState(() => createGateway()); // Same lazy, called-once-per-mount pattern as `gateway`. Both are keyed to // the same provider generation in `StudioShell`, so a persisted `pageshow` // remount recreates them together — never one without the other. const [assetGateway] = useState( () => createAssetGateway?.() ?? null, ); const [editor, setEditor] = useState(null); const [pendingHref, setPendingHref] = useState(null); const [requestAnnouncement, setRequestAnnouncement] = useState(""); const triggerRef = useRef(null); const saveSequence = useRef(0); const unsafe = editor?.status === "DIRTY" || editor?.status === "CONFLICT"; useBeforeUnload(unsafe); const navigateInternal = useCallback(( href: string, trigger?: HTMLElement | null, ) => { if (!unsafe) { navigate(href); return; } triggerRef.current = trigger ?? null; setPendingHref(href); setRequestAnnouncement(""); }, [navigate, unsafe]); const beginEditor = useCallback((saved: WorkingCopy, draft: WorkingCopyInput) => { setEditor({ documentId: saved.id, saved, draft, status: "CLEAN" }); }, []); const updateEditorDraft = useCallback((draft: WorkingCopyInput) => { setEditor((current) => current ? { ...current, draft, status: "DIRTY" } : current); }, []); const setEditorStatus = useCallback((status: StudioEditorStatus) => { setEditor((current) => current ? { ...current, status } : current); }, []); const clearEditor = useCallback(() => setEditor(null), []); const stay = useCallback(() => { const trigger = triggerRef.current; triggerRef.current = null; setPendingHref(null); setRequestAnnouncement(""); queueMicrotask(() => trigger?.focus()); }, []); const discard = useCallback(() => { const href = pendingHref; setEditor((current) => current ? { ...current, draft: inputOf(current.saved), status: "CLEAN" } : current); setPendingHref(null); setRequestAnnouncement(""); triggerRef.current = null; if (href) navigate(href); }, [navigate, pendingHref]); const saveThenNavigate = useCallback(async (): Promise => { const current = editor; const href = pendingHref; if (!current || !href || current.status === "SAVING") return false; setEditor({ ...current, status: "SAVING" }); try { const detail = await gateway.saveDocument( current.documentId, { expectedVersion: current.saved.version, document: current.draft, }, { idempotencyKey: `studio-local-save-${++saveSequence.current}` }, ); setEditor({ documentId: detail.document.id, saved: detail.document, draft: inputOf(detail.document), status: "CLEAN", }); setPendingHref(null); setRequestAnnouncement(`버전 ${detail.document.version}으로 저장했습니다.`); triggerRef.current = null; navigate(href); return true; } catch (error) { setEditor({ ...current, status: isStudioGatewayError(error) && error.code === "VERSION_CONFLICT" ? "CONFLICT" : "DIRTY", }); setRequestAnnouncement( isStudioGatewayError(error) ? error.problem.detail : "저장하지 못했습니다. 다시 시도해 주세요.", ); return false; } }, [editor, gateway, navigate, pendingHref]); const value = useMemo( () => ({ gateway, assetGateway, resolvePublishedLabel, now, editor, requestAnnouncement, setRequestAnnouncement, navigateInternal, beginEditor, updateEditorDraft, setEditorStatus, clearEditor, }), [ assetGateway, beginEditor, clearEditor, editor, gateway, navigateInternal, now, requestAnnouncement, resolvePublishedLabel, setEditorStatus, updateEditorDraft, ], ); return (
{children}
); }