/**
* 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 {};