/** * Per-instance Zustand store for the Editors component. * * ARCHITECTURE NOTE — the store owns the live editing buffer: * `createEditorStore(init)` seeds the editing buffer (`divisions`, `title`, * `docinfo`, `activeDivisionId`, …) from the host's initial props *once*. * After that, the store is authoritative for what's being edited: * • Internal edit actions (`setDivisionContent`, `patchDivision`, `setTitle`, * …) update the store optimistically and the host callbacks are fired * purely as notifications (so the host can persist/autosave). A host is no * longer required to echo every edit back as new props for it to display. * • Genuine external updates (a save that reconciles server-assigned ids, or * swapping to a different project) still win: Editors.tsx detects when a * controlled prop actually changes since the last render and calls * `applyExternalUpdate()` to overwrite the buffer. A stale prop that the * host simply never updated is NOT re-applied, so it can't clobber a local * edit. * * Derived/config fields that are never edited locally (`source`, `sourceFormat`, * `projectAssets`, `projectType`, `rootDivisionId`, …) are still mirrored from * props every render via `syncState()`. * * Callback stability: createEditorStore returns a `bindCallbacks` function that * EditorsInner calls from useLayoutEffect after every render. Store actions * close over an internal mutable bag (`bag.cbs`) rather than React refs, so * they are stable while always calling the latest mode-routed callback. */ import { type StoreApi } from "zustand/vanilla"; import type { Asset, AssetKind, FeedbackSubmission, SourceFormat } from "../types/editor"; import type { Division, DivisionType } from "../types/sections"; import type { EditDraft } from "../components/toc/types"; export type DivisionChanges = { id?: string; title?: string; type?: DivisionType; xmlId?: string | null; sourceFormat?: SourceFormat; label?: string | null; }; /** * A batch of editing-buffer fields the host has genuinely changed (an external * reset). Only the provided fields are overwritten in the store; omitted * fields keep their current — possibly locally edited — value. */ export interface ExternalUpdate { divisions?: Division[]; projectAssets?: Asset[]; rootDivisionId?: string; activeDivisionId?: string | null; title?: string; docinfo?: string; commonDocinfo?: string; useCommonDocinfo?: boolean; } type ModalKey = "isLatexDialogOpen" | "isConvertDialogOpen" | "isDocinfoEditorOpen" | "isAssetPickerOpen" | "isFullSourceOpen"; /** * All callbacks wired by Editors.tsx that deep components need to call. * Updated on every render via `callbacksRef.current = { ... }`. * Actions in the store call through this ref, so they stay stable even as * the callbacks close over changing state. */ export interface EditorCallbacks { selectDivision: (id: string) => void; /** Add a new division as the last child of `parentXmlId` (or unplaced if `null`). */ addDivision: (parentXmlId: string | null) => void; removeDivision: (id: string) => void; updateDivision: (id: string, changes: DivisionChanges) => void; /** Emit a content change for a specific division (edit or structural reorder). */ divisionContentChange: (xmlId: string, content: string) => void; handleDivisionContentChange: (content: string | undefined) => void; assetInsert: (asset: Asset) => void; /** Remove a project asset (optimistic pool drop + host persistence). */ assetRemove?: (asset: Asset) => void; /** Remove every `` placeholder for an unresolved ref from source. */ assetRefRemove?: (kind: AssetKind, ref: string) => void; /** Duplicate a project asset under a fresh ref (host persists + pool add). */ assetDuplicate?: (asset: Asset) => void | Promise; updateTitle: (title: string) => void; feedbackSubmit?: (feedback: FeedbackSubmission) => void | Promise; insertContentAtCursor?: (content: string) => void; } export interface EditorStoreState { source: string; sourceFormat: SourceFormat; /** * Authoritative project-asset pool — owned by the store as a live editing * buffer, exactly like {@link EditorStoreState.divisions}. Seeded once from * the host's `projectAssets` prop, then mutated optimistically by * `addAssetToPool`/`updateAssetInPool`/`removeAssetFromPool` (host callbacks * fire purely as persistence notifications). A genuine external change to the * prop wins via `applyExternalUpdate`, but a stale prop the host never updated * can't clobber a just-created asset — so an asset is editable the instant * it's added, without waiting for the host to echo it back. */ projectAssets: Asset[] | undefined; /** * The user's cross-project asset library. Unlike `projectAssets` this stays a * host-owned, fetch-on-demand cache (loaded via `onLoadLibraryAssets`), so it * is still mirrored from props every render via `syncState`. */ libraryAssets: Asset[] | undefined; title: string; docinfo: string; commonDocinfo: string; useCommonDocinfo: boolean; projectType: "article" | "book" | undefined; projectUrl: string | undefined; divisions: Division[] | undefined; rootDivisionId: string | undefined; activeDivisionId: string | null; canConvertToPretext: boolean; /** The source string currently open in the code editor. */ activeEditorSource: string; /** True when the host passed `onFeedbackSubmit`. Controls whether feedback UI is shown. */ hasFeedback: boolean; /** True when the host passed `onAssetDuplicate`. Controls whether Duplicate is offered. */ hasAssetDuplicate: boolean; isTocCollapsed: boolean; showFullPreview: boolean; isNarrowScreen: boolean; activeTab: "editor" | "preview"; isLatexDialogOpen: boolean; isConvertDialogOpen: boolean; isDocinfoEditorOpen: boolean; isAssetPickerOpen: boolean; isFullSourceOpen: boolean; editingId: string | null; editDraft: EditDraft | null; /** True while `editDraft` belongs to a just-created, not-yet-saved division. */ editingIsNew: boolean; /** The asset currently open in the asset edit modal, identified by kind+ref. */ editingAssetRef: { kind: AssetKind; ref: string; } | null; /** * An unresolved placeholder the user is resolving — opens the asset manager * in "resolve this ref" mode, where picking/uploading binds the result to * this `kind`+`ref` instead of copying an embed code. */ assetResolveTarget: { kind: AssetKind; ref: string; } | null; /** Sync a batch of derived/controlled data from Editors into the store. */ syncState: (partial: Partial) => void; /** Apply a genuine external update from the host (host wins). */ applyExternalUpdate: (partial: ExternalUpdate) => void; /** Optimistically set a division's content in the local pool. */ setDivisionContent: (xmlId: string, content: string) => void; /** Optimistically patch a division's metadata (title/type/xml:id/format). */ patchDivision: (xmlId: string, changes: DivisionChanges) => void; /** Optimistically add a division to the local pool (no-op if it exists). */ addDivisionToPool: (division: Division) => void; /** Optimistically remove a division from the local pool. */ removeDivisionFromPool: (xmlId: string) => void; /** Set the active (open-for-editing) division id. */ setActiveDivisionId: (id: string | null) => void; /** Optimistically set the document title. */ setTitle: (title: string) => void; /** Optimistically set the docinfo-related fields together. */ setDocinfo: (info: { docinfo: string; commonDocinfo: string; useCommonDocinfo: boolean; }) => void; setShowFullPreview: (show: boolean) => void; setActiveTab: (tab: "editor" | "preview") => void; setIsNarrowScreen: (narrow: boolean) => void; setIsTocCollapsed: (value: boolean | ((prev: boolean) => boolean)) => void; openModal: (modal: ModalKey) => void; closeModal: (modal: ModalKey) => void; selectSection: (id: string) => void; addSection: (parentXmlId: string | null) => void; removeSection: (id: string) => void; updateSection: (id: string, changes: DivisionChanges) => void; /** Update a parent division's content after a structural DnD change. */ divisionContentChange: (xmlId: string, content: string) => void; startSectionEdit: (section: Division, options?: { isNew?: boolean; }) => void; setEditDraft: (draft: EditDraft) => void; commitSectionEdit: () => void; cancelSectionEdit: () => void; insertAsset: (asset: Asset) => void; insertAtCursor: (content: string) => void; /** Open the asset edit modal for the asset identified by `kind`+`ref`. */ openAssetEditor: (kind: AssetKind, ref: string) => void; closeAssetEditor: () => void; /** Open the asset manager in resolve mode for an unresolved `kind`+`ref`. */ openAssetResolver: (kind: AssetKind, ref: string) => void; closeAssetResolver: () => void; /** Remove a project asset (pool + host persistence). */ removeAsset: (asset: Asset) => void; /** Remove every placeholder for an unresolved `kind`+`ref` from the document. */ removeAssetRefFromDocument: (kind: AssetKind, ref: string) => void; /** Duplicate a project asset under a fresh ref. Resolves when the host settles. */ duplicateAsset: (asset: Asset) => Promise; /** * Replace the whole project-asset pool — e.g. after `onLoadAssets` resolves * with the server's fresh list. The server is authoritative at that point, * so this overwrites the pool wholesale (matching how the divisions pool is * reset on an external update). */ setProjectAssets: (assets: Asset[]) => void; /** * Optimistically add an asset to the pool (no-op if one with the same * kind+ref already exists). Used when an asset is uploaded, created, added * from the library, or inserted, so it's editable immediately. */ addAssetToPool: (asset: Asset) => void; /** * Optimistically replace the pool entry matching `asset` by kind+ref (adding * it if absent). Used when an asset's content/source is edited. */ updateAssetInPool: (asset: Asset) => void; /** * Optimistically rename an asset's `ref`: drop the pool entry matching * `kind`+`oldRef` and insert `newAsset` (which carries the new ref). Used when * an asset's `ref` is edited — a plain `updateAssetInPool` can't match it * because the kind+ref key has changed. */ renameAssetInPool: (kind: AssetKind, oldRef: string, newAsset: Asset) => void; /** Optimistically remove the asset matching `asset` by kind+ref from the pool. */ removeAssetFromPool: (asset: Asset) => void; updateTitle: (title: string) => void; feedbackSubmit: (feedback: FeedbackSubmission) => void; } /** The subset of EditorStoreState that Editors.tsx syncs on each render. */ export type EditorSyncableState = Pick; export interface EditorStoreInit { source: string; sourceFormat: SourceFormat; title: string; docinfo: string; commonDocinfo: string; useCommonDocinfo: boolean; projectType: "article" | "book" | undefined; divisions: Division[]; activeDivisionId: string | null; projectAssets: Asset[] | undefined; } /** The Zustand vanilla store instance type. */ export type EditorStoreInstance = StoreApi; /** Return value of createEditorStore. */ export interface EditorStoreHandle { /** The Zustand vanilla store — pass to EditorStoreProvider. */ store: EditorStoreInstance; /** * Update the mutable callbacks bag. Call from useLayoutEffect after every * render so store actions always invoke the latest mode-routed callbacks. */ bindCallbacks: (cbs: EditorCallbacks) => void; } export declare function createEditorStore(init: EditorStoreInit): EditorStoreHandle; export {};