Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670// src/plugin/views/markdown-leaf-binding.tsximport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'import type { AutomergeUrl } from '../../storage/document-runtime-contract'import { CM6Editor, type EditorInstancePayload } from '../../editor/CM6Editor'import type { PersistedListFolds } from '../../editor/extensions/list-fold-config'import type { ListFoldNode } from '../../editor/extensions/list-fold-config'import type { EditorBindingOwnership } from '../../editor/editor-binding-ownership'import type { CM6EditorAdapter } from '../cm6-editor'import { useDocumentSync } from '../../sync/use-document-sync'import type { DocumentSyncSnapshot, SaveStatus } from '../../sync/sync-contract'import { useMainAreaContext } from '../../layout/MainAreaContext'import type { WorkspaceLeaf, Workspace } from '../workspace'import { MarkdownView, attachMarkdownViewEditor, detachMarkdownViewEditor, setMarkdownViewMode } from './markdown-view'import type { EditorMode } from '../../layout/layout-state'import { TypeCatalog } from '../../types-records/type-catalog'import { RootFrontmatterRecords } from '../../types-records/root-frontmatter-records'import { deriveRecordNodeTitle, projectRecords } from '../../types-records/record-projection'import type { SetFieldIntent, RecordWriteOutcome, TextContribution } from '../../types-records/record-write-command'import { subscribeRecordProjectionRefresh } from '../../types-records/record-projection-refresh'import { isTypesRecordsEnabled, useTypesRecordsEnabled } from '../../types-records/feature-setting'import type { BlockRecordPlacement, BlockRecordPreviewConfig } from '../../editor/extensions/block-record-widget'import { EMPTY_ROOT_FRONTMATTER_CATALOG, type RootFrontmatterPreviewConfig } from '../../editor/extensions/root-frontmatter-widget'import type { TypeCatalogSnapshot } from '../../types-records/type-catalog'import { registerDocumentSyncInvariantProvider } from '../../testing/invariants/reams-invariant-diagnostics'import { headsEqual, headsKey } from '../../sync/heads'import { Notice } from 'obsidian'import { recordReferenceTarget, type RecordSuggestionProviders } from '../../types-records/record-suggestions'import type { WikilinkPlugin } from '../../plugins/wikilink/wikilink-plugin'import { RecordDraftScope } from '../../types-records/use-debounced-record-input'import { useTemporalFormats } from '../../types-records/temporal-settings'import type { SharedProjectionArtifact } from '../../services/background-indexer'import type { ProjectedRecord } from '../../types-records/record-projection'import { collectMarkdownStructures } from '../../automerge-markdown/node-index-projection'
type PendingListFold = { collapsed: boolean; token: number }type ListFoldBinding = { folds: PersistedListFolds; documentId: string | null; sessionId: number | null }const EMPTY_LIST_FOLD_NODES: readonly ListFoldNode[] = []
function overlayPendingListFolds( folds: PersistedListFolds, pending: ReadonlyMap<string, PendingListFold>,): PersistedListFolds { return { ...folds, ...Object.fromEntries([...pending].map(([nodeId, intent]) => [nodeId, { collapsed: intent.collapsed }])), }}
function mapEditorModeToObsidian(_mode: EditorMode): 'source' { return 'source'}
export function notifyRecordWriteOutcome(outcome: RecordWriteOutcome, notifyModified: (contribution?: TextContribution) => void): void { if (outcome.status === 'accepted' || outcome.status === 'unchanged' && outcome.textContribution !== undefined) notifyModified(outcome.textContribution)}
/** * Resolve the workspace's active MarkdownView via `active-editor-change`. * Local copy of the App.tsx hook to avoid a circular import * (`markdown-view → markdown-leaf-binding → App` would re-import `workspace`). */function useActiveMarkdownView(workspace: Workspace): MarkdownView | null { const subscribe = useCallback((onStoreChange: () => void) => { const a = workspace.on('active-leaf-change', onStoreChange) const b = workspace.on('active-editor-change', onStoreChange) const c = workspace.on('layout-change', onStoreChange) return () => { workspace.offref(a) workspace.offref(b) workspace.offref(c) } }, [workspace]) const getSnapshot = useCallback(() => { const editor = workspace.activeEditor return editor instanceof MarkdownView ? editor : null }, [workspace]) return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)}
/** * React binding for a `MarkdownView` leaf. Hoisted out of the old App-level * `SyncedEditor` component so that MarkdownView can implement `ReactItemView` * and the main area dispatcher (`MainAreaViewContainer`) can render every * view type uniformly. * * Reads dependencies from `MainAreaContext` (wikilink handlers, editor mode, * formatting actions, vault, app) and the workspace itself (the active * MarkdownView instance). The DocumentManager is read from `app.reamsDocumentManager`. * * @internal Host-internal binding between MarkdownView and CM6Editor. */export function MarkdownLeafBinding({ leaf }: { leaf: WorkspaceLeaf }) { const { wikilinkHandlers, editorMode, formattingActionManager, vault, pluginApp, onSaveStatusChange, onConcurrentTabChange, onInitialViewReady, } = useMainAreaContext()
const documentManager = pluginApp.reamsDocumentManager const typesRecordsEnabled = useTypesRecordsEnabled() const filePath = leaf.file?.path ?? null const fileName = leaf.file?.name ?? '' const documentName = leaf.file?.basename ?? '' const docUrl: AutomergeUrl | null = leaf.file ? (vault.getUrlForFile?.(leaf.file) ?? vault.getUrlForPath(filePath!) ?? null) : null const roots = useMemo(() => new RootFrontmatterRecords({ documents: documentManager }), [documentManager]) const catalog = useMemo(() => new TypeCatalog({ documents: documentManager, vaultRoot: vault.rootUrl, projectionVersion: 'records-v1' }), [documentManager, vault.rootUrl]) const [resolvedCatalog, setResolvedCatalog] = useState<{ docUrl: AutomergeUrl filePath: string catalog: TypeCatalogSnapshot } | null>(null) // StrictMode replays mount effects while preserving hook refs. Share the // same in-flight authority read rather than opening every Type JSON twice. const pendingCatalogRef = useRef<{ key: string; promise: Promise<TypeCatalogSnapshot> } | null>(null) const catalogReady = !typesRecordsEnabled || (resolvedCatalog?.docUrl === docUrl && resolvedCatalog.filePath === (filePath ?? '')) const catalogEpochRef = useRef(0) const recordDraftScope = useMemo(() => new RecordDraftScope(), [docUrl]) const templateFormats = useTemporalFormats(pluginApp)
// Cache the TFile ref once on mount (O(n) lookup once, not per save). const fileRef = useRef(docUrl ? vault.getFileByUrl(docUrl) : null) const recordDisclosureRef = useRef({ collapsedNodeIds: new Set<string>(), initialListFoldsApplied: false }) const initializedEditorDocumentRef = useRef<string | null>(null) const [listFoldBinding, setListFoldBinding] = useState<ListFoldBinding>({ folds: {}, documentId: null, sessionId: null }) const pendingListFoldsRef = useRef(new Map<string, PendingListFold>()) const latestListFoldTokenRef = useRef(new Map<string, number>()) const listFoldTokenRef = useRef(0) const foldWriteCountRef = useRef(0) const foldWriteErrorRef = useRef<string | null>(null) const foldSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const [foldSaveStatus, setFoldSaveStatus] = useState<SaveStatus>({ state: 'idle' }) const listFoldBindingGenerationRef = useRef(0) const handleListFoldChanged = useCallback((nodeId: string, collapsed: boolean) => { if (!docUrl) return foldWriteCountRef.current += 1 foldWriteErrorRef.current = null if (foldSavedTimerRef.current) clearTimeout(foldSavedTimerRef.current) foldSavedTimerRef.current = null setFoldSaveStatus({ state: 'saving' }) const generation = listFoldBindingGenerationRef.current const token = ++listFoldTokenRef.current latestListFoldTokenRef.current.set(nodeId, token) pendingListFoldsRef.current.set(nodeId, { collapsed, token }) setListFoldBinding(current => ({ ...current, documentId: docUrl, folds: { ...current.folds, [nodeId]: { collapsed } } })) void documentManager.updateListFold(docUrl, nodeId, collapsed).then(async () => { const folds = await documentManager.readListFolds(docUrl) if (listFoldBindingGenerationRef.current !== generation) return const pending = pendingListFoldsRef.current const intent = pending.get(nodeId) if (intent?.token === token && folds[nodeId]?.collapsed === collapsed) pending.delete(nodeId) setListFoldBinding(current => ({ ...current, folds: overlayPendingListFolds(folds, pending) })) }).catch(error => { if (listFoldBindingGenerationRef.current !== generation) return const intent = pendingListFoldsRef.current.get(nodeId) if (latestListFoldTokenRef.current.get(nodeId) !== token) return if (intent?.token === token) pendingListFoldsRef.current.delete(nodeId) console.warn('[Reams] list fold update failed:', error) foldWriteErrorRef.current = error instanceof Error ? error.message : 'Fold state could not be saved' void documentManager.readListFolds(docUrl).then(folds => { if (listFoldBindingGenerationRef.current === generation) { setListFoldBinding(current => ({ ...current, folds: overlayPendingListFolds(folds, pendingListFoldsRef.current) })) } }).catch(() => {}) }).finally(() => { if (listFoldBindingGenerationRef.current !== generation) return if (latestListFoldTokenRef.current.get(nodeId) === token) latestListFoldTokenRef.current.delete(nodeId) foldWriteCountRef.current = Math.max(0, foldWriteCountRef.current - 1) if (foldWriteCountRef.current > 0) return if (foldWriteErrorRef.current) { setFoldSaveStatus({ state: 'error', message: foldWriteErrorRef.current }) return } setFoldSaveStatus({ state: 'saved' }) foldSavedTimerRef.current = setTimeout(() => { foldSavedTimerRef.current = null setFoldSaveStatus({ state: 'idle' }) }, 3000) }) }, [documentManager, docUrl]) useEffect(() => { fileRef.current = docUrl ? vault.getFileByUrl(docUrl) : null }, [vault, docUrl]) const recordSuggestions = useMemo<RecordSuggestionProviders>(() => ({ references: query => { const needle = query.replace(/^\[\[/, '').replace(/\]\]$/, '').trim() const wikilinkPlugin = pluginApp.plugins?.getPlugin('reams-wikilink') as WikilinkPlugin | null | undefined return wikilinkPlugin?.getSuggestions(needle, docUrl).map(suggestion => { const basename = suggestion.name.replace(/\.md$/i, '') return suggestion.displayTitle && suggestion.displayTitle !== basename ? `[[${suggestion.target}|${suggestion.displayTitle}]]` : `[[${suggestion.target}]]` }) ?? [] }, referenceResolved: reference => { const wikilinkPlugin = pluginApp.plugins?.getPlugin('reams-wikilink') as WikilinkPlugin | null | undefined return wikilinkPlugin?.resolveLink(recordReferenceTarget(reference)) ?? false }, tags: query => { const needle = query.replace(/^#/, '').trim().toLocaleLowerCase() const counts = new Map(Object.entries(pluginApp.metadataCache.getAllTags()).map(([tag, count]) => [tag.replace(/^#/, ''), count])) if (isTypesRecordsEnabled() && pluginApp.hasTypedNodeCatalog?.()) { for (const typeName of pluginApp.reamsTypedNodes.getEffectiveTypeNamesForDocument({ path: filePath ?? '' })) { if (!counts.has(typeName)) counts.set(typeName, 0) } } return [...counts] .map(([tag, count]) => ({ tag, count })) .filter(({ tag }) => !needle || tag.toLocaleLowerCase().includes(needle)) .sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag)) .map(({ tag }) => tag) }, }), [docUrl, filePath, pluginApp, pluginApp.metadataCache])
// The active markdown view — typically `leaf.view` but resolved through the // workspace's active-editor channel so plugins relying on `app.activeEditor` // observe the same instance. const pluginView = useActiveMarkdownView(pluginApp.workspace) const currentAdapterRef = useRef<CM6EditorAdapter | null>(null) const currentOwnershipRef = useRef<EditorBindingOwnership | null>(null) const boundViewRef = useRef<MarkdownView | null>(null) const boundEditorViewRef = useRef<ReturnType<CM6EditorAdapter['getView']> | null>(null)
useEffect(() => { if (pluginView) { setMarkdownViewMode(pluginView, mapEditorModeToObsidian(editorMode)) } }, [pluginView, editorMode])
useEffect(() => { const previousView = boundViewRef.current if (previousView && previousView !== pluginView) { detachMarkdownViewEditor(previousView) } if (pluginView && currentAdapterRef.current) { attachMarkdownViewEditor(pluginView, currentAdapterRef.current) } boundViewRef.current = pluginView }, [pluginView])
useEffect(() => { return () => { if (boundViewRef.current) { detachMarkdownViewEditor(boundViewRef.current) } } }, [])
// Both callbacks fan out to the same `vault.notifyModified` invocation // today — they are kept distinct at the API boundary so future // MetadataCache-side observers (e.g. a "remote change" UI banner) can // distinguish a local save from a remote apply without an out-of-band // heuristic. const notifyModified = useCallback((contribution?: TextContribution) => { const file = fileRef.current ?? (docUrl ? vault.getFileByUrl(docUrl) : null) if (file) { fileRef.current = file pluginApp.notifyDocumentContentChanged(file, contribution) vault.notifyModified(file) } }, [docUrl, pluginApp, vault])
const { content, isLoading, loadError, saveStatus, attachmentDiagnostic, isConcurrentTabOpen, bindView, syncSnapshot, flushPendingPlain, prepareExternalWrite, applyExternalContribution, reloadTextProjection, onDocumentUpdate, getAttachmentClipboardPayload, prepareClipboardTransaction, } = useDocumentSync({ documentManager, docUrl: (docUrl ?? ('' as AutomergeUrl)), onRemoteApplied: notifyModified, typesRecordsEnabled, catalogReady, recognizedTypeNames: catalogReady && typesRecordsEnabled && resolvedCatalog?.docUrl === docUrl ? Object.keys(resolvedCatalog.catalog.types) : [], }) useEffect(() => { const generation = ++listFoldBindingGenerationRef.current let unsubscribe: (() => void) | undefined pendingListFoldsRef.current.clear() latestListFoldTokenRef.current.clear() foldWriteCountRef.current = 0 foldWriteErrorRef.current = null setFoldSaveStatus({ state: 'idle' }) setListFoldBinding({ folds: {}, documentId: null, sessionId: null }) if (docUrl) { void documentManager.subscribeListFolds(docUrl, folds => { if (listFoldBindingGenerationRef.current !== generation) return const pending = pendingListFoldsRef.current for (const [nodeId, intent] of pending) { if (folds[nodeId]?.collapsed === intent.collapsed) pending.delete(nodeId) } setListFoldBinding({ folds: overlayPendingListFolds(folds, pending), documentId: docUrl, sessionId: syncSnapshot.sessionId }) }).then(cleanup => { if (listFoldBindingGenerationRef.current === generation) unsubscribe = cleanup else cleanup() }).catch(error => { if (listFoldBindingGenerationRef.current === generation) console.warn('[Reams] list fold subscription failed:', error) }) } return () => { if (listFoldBindingGenerationRef.current === generation) listFoldBindingGenerationRef.current += 1 if (foldSavedTimerRef.current) clearTimeout(foldSavedTimerRef.current) foldSavedTimerRef.current = null unsubscribe?.() } }, [documentManager, docUrl, syncSnapshot.sessionId]) const checkpoint = syncSnapshot.checkpoint const checkpointKey = checkpoint ? headsKey(checkpoint.heads) : '' const initializingEditor = initializedEditorDocumentRef.current !== docUrl const foldNodeIndex = syncSnapshot.markdownNodeIndex const foldNodeHeadsKey = checkpointKey const effectiveListFolds = listFoldBinding.documentId === docUrl && listFoldBinding.sessionId === syncSnapshot.sessionId ? listFoldBinding.folds : {} const listFoldNodes = useMemo<readonly ListFoldNode[]>(() => foldNodeIndex?.status === 'available' && (initializingEditor || checkpoint?.content === syncSnapshot.settledEditorText) && foldNodeIndex.index.headsKey === foldNodeHeadsKey ? foldNodeIndex.index.nodes.filter(node => node.kind === 'list-item').map(node => ({ nodeId: node.id, from: node.from })) : EMPTY_LIST_FOLD_NODES, [checkpoint?.content, foldNodeHeadsKey, foldNodeIndex, initializingEditor, syncSnapshot.settledEditorText]) const checkpointRef = useRef(checkpoint) checkpointRef.current = checkpoint const flushDocumentDrafts = useCallback(async () => { await recordDraftScope.flush() return flushPendingPlain() }, [flushPendingPlain, recordDraftScope]) useEffect(() => { if (!docUrl) return const cleanupParticipant = pluginApp.registerDocumentSyncCallbacks?.(docUrl, { flush: flushDocumentDrafts, prepareExternalWrite, project: applyExternalContribution, reload: reloadTextProjection, }) return () => { cleanupParticipant?.() } }, [docUrl, pluginApp, applyExternalContribution, flushDocumentDrafts, prepareExternalWrite, reloadTextProjection]) const submitRecordIntent = useCallback((intent: SetFieldIntent): Promise<RecordWriteOutcome> => { return (async (): Promise<RecordWriteOutcome> => { if (!docUrl || !filePath || !isTypesRecordsEnabled()) return { status: 'invalid' } const outcome = await pluginApp.submitRecordEdit({ documents: documentManager, roots, catalog, documentId: docUrl, documentPath: filePath, enabled: true, intent }) notifyRecordWriteOutcome(outcome, notifyModified) if (import.meta.env.DEV) { const diagnostics = globalThis as typeof globalThis & { __reamsBidirectionalIntentDiagnostics__?: { total: number; accepted: number; unchanged: number; failed: number } } const current = diagnostics.__reamsBidirectionalIntentDiagnostics__ ?? { total: 0, accepted: 0, unchanged: 0, failed: 0 } diagnostics.__reamsBidirectionalIntentDiagnostics__ = outcome.status === 'accepted' ? { ...current, total: current.total + 1, accepted: current.accepted + 1 } : outcome.status === 'unchanged' ? { ...current, total: current.total + 1, unchanged: current.unchanged + 1 } : { ...current, failed: current.failed + 1 } } return outcome })() }, [catalog, documentManager, docUrl, filePath, pluginApp, roots]) const [recordRefreshEpoch, setRecordRefreshEpoch] = useState(0) const [recordProjection, setRecordProjection] = useState<{ docUrl: AutomergeUrl checkpointKey: string checkpointContent: string placements: readonly BlockRecordPlacement[] catalog: TypeCatalogSnapshot } | null>(null) // Catalog files can change independently of the active document. They may // request another projection, but never provide document freshness: that // always comes from the sync checkpoint below. useEffect(() => { if (!typesRecordsEnabled || !filePath) return return subscribeRecordProjectionRefresh(vault, filePath, () => { setRecordRefreshEpoch(epoch => epoch + 1) }) }, [filePath, typesRecordsEnabled, vault])
useEffect(() => { let cancelled = false if (!typesRecordsEnabled || !docUrl || !filePath) { setResolvedCatalog(null) setRecordProjection(null) catalogEpochRef.current += 1 return () => { cancelled = true } } setRecordProjection(null) catalogEpochRef.current += 1 const key = `${docUrl}\0${filePath}\0${recordRefreshEpoch}` if (pendingCatalogRef.current?.key !== key) { const promise = catalog.snapshot(filePath) pendingCatalogRef.current = { key, promise } const clear = () => { if (pendingCatalogRef.current?.promise === promise) pendingCatalogRef.current = null } void promise.then(clear, clear) } void pendingCatalogRef.current.promise.then(snapshot => { if (!cancelled) setResolvedCatalog({ docUrl, filePath, catalog: snapshot }) }).catch(() => { if (!cancelled) setResolvedCatalog(null) }) return () => { cancelled = true } }, [catalog, docUrl, filePath, recordRefreshEpoch, typesRecordsEnabled])
useEffect(() => { const indexer = pluginApp.hasBackgroundIndexer() ? pluginApp.reamsBackgroundIndexer : null if (!indexer || !typesRecordsEnabled || !docUrl || !filePath || !resolvedCatalog || resolvedCatalog.docUrl !== docUrl || resolvedCatalog.filePath !== filePath) { setRecordProjection(null) return } const apply = (artifact: SharedProjectionArtifact) => { const currentCheckpoint = checkpointRef.current if (!currentCheckpoint || artifact.path !== filePath || artifact.name !== fileName || !headsEqual(artifact.heads, currentCheckpoint.heads) || artifact.projection.status !== 'available' || !artifact.nodeIndex) return const indexedCatalogRevision = pluginApp.hasTypedNodeCatalog() ? pluginApp.reamsTypedNodes.getDocumentCatalogRevision(docUrl) : undefined if (artifact.catalogRevision !== resolvedCatalog.catalog.revision.key && artifact.catalogRevision !== indexedCatalogRevision) { setRecordRefreshEpoch(epoch => epoch + 1) return } const placements = new Map<string, { kind: BlockRecordPlacement['kind']; title: string; from: number; to: number; hasChildren: boolean; typeMarkers: string[]; records: ProjectedRecord[] }>() const nodes = new Map(artifact.nodeIndex.nodes.map(node => [node.id, node])) // Sparse native indexes omit ordinary descendants. Use the parsed // structure to locate their first row; native children alone cannot // determine where a typed parent's panel belongs. const structures = collectMarkdownStructures(artifact.content) const firstStructureIndex = new Map<string, number>() const firstChildFrom = new Map<number, number>() structures.forEach((structure, index) => { const key = `${structure.kind}:${structure.from}` if (!firstStructureIndex.has(key)) firstStructureIndex.set(key, index) if (structure.parent !== undefined) { const previous = firstChildFrom.get(structure.parent) firstChildFrom.set(structure.parent, previous === undefined ? structure.from : Math.min(previous, structure.from)) } }) for (const record of artifact.projection.records) { if (record.subject.kind !== 'block') continue const node = nodes.get(record.subject.nodeId) if (!node) continue const parentIndex = firstStructureIndex.get(`${node.kind}:${node.from}`) const childFrom = parentIndex === undefined ? undefined : firstChildFrom.get(parentIndex) const childLineFrom = childFrom === undefined ? undefined : artifact.content.lastIndexOf('\n', childFrom - 1) + 1 const typeMarker = `#${record.typeName}` const placement = placements.get(node.id) ?? { kind: node.kind, title: '', from: node.from, to: childLineFrom ?? node.to, hasChildren: childLineFrom !== undefined, typeMarkers: [], records: [] } if (!placement.typeMarkers.includes(typeMarker)) placement.typeMarkers.push(typeMarker) placement.records.push(record) placements.set(node.id, placement) } setRecordProjection({ docUrl, checkpointKey: headsKey(artifact.heads), checkpointContent: artifact.content, placements: [...placements.entries()].map(([nodeId, placement]) => ({ ...placement, nodeId, title: deriveRecordNodeTitle(artifact.content, placement, placement.typeMarkers) ?? '' })), catalog: resolvedCatalog.catalog }) } const unsubscribe = indexer.subscribeProjection(docUrl, apply) const unsubscribeInvalidation = indexer.subscribeProjectionInvalidation?.(docUrl, () => setRecordProjection(null)) ?? (() => {}) const current = indexer.getProjection(docUrl) if (current) apply(current) return () => { unsubscribe(); unsubscribeInvalidation() } }, [checkpointKey, docUrl, fileName, filePath, pluginApp, resolvedCatalog, typesRecordsEnabled]) useEffect(() => { const indexer = pluginApp.hasBackgroundIndexer() ? pluginApp.reamsBackgroundIndexer : null if (!indexer || !typesRecordsEnabled || !docUrl || !filePath || !checkpoint || !resolvedCatalog || resolvedCatalog.docUrl !== docUrl || resolvedCatalog.filePath !== filePath) return indexer.requestProjection({ documentId: docUrl, path: filePath, name: fileName, heads: checkpoint.heads, catalog: resolvedCatalog.catalog }) }, [checkpoint, docUrl, fileName, filePath, pluginApp, resolvedCatalog, typesRecordsEnabled])
const recordPreview = useMemo<BlockRecordPreviewConfig>(() => { // Keep the last complete projection mounted while a newer checkpoint's // derived index is coalesced. CodeMirror maps its decorations through // intervening edits; replacing them on every save needlessly rebuilds all // Record widgets on the input-critical path. const currentProjection = recordProjection?.docUrl === docUrl && recordProjection.catalog.revision.key === resolvedCatalog?.catalog.revision.key ? recordProjection : null return { checkpointContent: currentProjection?.checkpointContent, placements: currentProjection?.placements ?? [], disclosure: recordDisclosureRef.current, suggestions: recordSuggestions, draftScope: recordDraftScope, onChange: async (record, field, value) => { const outcome = await submitRecordIntent({ subject: record.subject, typeName: record.typeName, fieldId: field.fieldId, value }) if (outcome.status !== 'accepted' && outcome.status !== 'unchanged') throw new Error(`Record write failed: ${outcome.status}`) }, dateFormat: templateFormats.dateFormat, timeFormat: templateFormats.timeFormat, onSetField: submitRecordIntent, onError: error => { console.error('[Reams] record write failed:', error) new Notice(error instanceof Error ? error.message : 'Record write failed') }, } }, [docUrl, recordDraftScope, recordProjection, recordSuggestions, resolvedCatalog, submitRecordIntent, templateFormats.dateFormat, templateFormats.timeFormat]) const rootFrontmatterPreview = useMemo<RootFrontmatterPreviewConfig | undefined>(() => { // Type schema freshness is independent of the editor's unsaved Markdown // text. Keep the last resolved catalog mounted through the save/projection // gap so ordinary root edits never make Record sections blink away. if (!docUrl) return undefined const currentCatalog = resolvedCatalog?.docUrl === docUrl && resolvedCatalog.filePath === (filePath ?? '') ? resolvedCatalog.catalog : undefined return { catalog: currentCatalog ?? EMPTY_ROOT_FRONTMATTER_CATALOG, typesEnabled: typesRecordsEnabled && currentCatalog !== undefined, documentId: docUrl, documentName, dateFormat: templateFormats.dateFormat, timeFormat: templateFormats.timeFormat, suggestions: recordSuggestions, draftScope: recordDraftScope, onSetField: submitRecordIntent, onError: error => { console.error('[Reams] root frontmatter edit failed:', error) new Notice(error instanceof Error ? error.message : 'Root frontmatter edit failed') }, } }, [docUrl, documentName, filePath, recordDraftScope, recordSuggestions, resolvedCatalog, submitRecordIntent, templateFormats.dateFormat, templateFormats.timeFormat, typesRecordsEnabled]) useEffect(() => registerDocumentSyncInvariantProvider({ getSnapshot: () => syncSnapshot as DocumentSyncSnapshot, getPersistedTextState: async () => { if (!docUrl) return null const state = await documentManager.readText(docUrl) return state.status === 'available' ? { content: state.content, heads: [...state.heads] } : null }, getPersistedListFolds: async () => docUrl ? documentManager.readListFolds(docUrl) : null, setPersistedListFold: async (nodeId, collapsed) => { if (!docUrl) return false await documentManager.updateListFold(docUrl, nodeId, collapsed) return true }, getPersistedRecordState: async () => { if (!docUrl) return null const state = await documentManager.readText(docUrl) if (state.status !== 'available') return null const file = await documentManager.getFile(docUrl) const facts = await documentManager.readBlockFactsAt(docUrl, state.heads) return { content: state.content, heads: [...state.heads], facts: Object.fromEntries(Object.entries(facts ?? {})), path: filePath ?? null, name: file?.name ?? null, url: docUrl } }, getPersistedRecordProjectionState: async () => { if (!docUrl || !filePath) return null const state = await documentManager.readMarkdown(docUrl) if (state.status !== 'available' || state.nodeIndex.status !== 'available') return null const snapshot = await catalog.snapshot(filePath) const projection = await projectRecords({ documents: documentManager, roots, documentId: docUrl, documentName, heads: state.heads, markdown: state.content, catalog: snapshot, nodeIndex: state.nodeIndex.index, blockFacts: state.blockFacts, }) if (projection.status !== 'available') return null return { content: state.content, heads: [...state.heads], records: projection.records.flatMap(record => record.subject.kind === 'block' ? [{ nodeId: record.subject.nodeId, typeName: record.typeName, fields: record.fields.map(field => ({ key: field.key, value: field.value ?? null, state: field.state })) }] : []), } }, getPersistedMarkdownState: async () => { if (!docUrl) return null const state = await documentManager.readMarkdown(docUrl) if (state.status !== 'available') return { content: '', heads: [], index: { status: 'unavailable' as const }, facts: {} } const index = state.nodeIndex.status === 'available' ? { status: 'available' as const, nodes: state.nodeIndex.index.nodes.map(node => ({ id: node.id, parentId: node.parentId ?? null, kind: node.kind, from: node.from, to: node.to })) } : { status: 'invalid' as const, reason: state.nodeIndex.reason } return { content: state.content, heads: [...state.heads], index, facts: Object.fromEntries(Object.entries(state.blockFacts)) } }, }), [catalog, documentManager, docUrl, documentName, filePath, roots, syncSnapshot]) const handleEditorInstance = useCallback((instance: EditorInstancePayload | null) => { const previousEditorView = boundEditorViewRef.current const adapter = instance?.adapter ?? null const ownership = instance?.ownership ?? currentOwnershipRef.current const nextEditorView = adapter?.getView() ?? null if (nextEditorView) initializedEditorDocumentRef.current = docUrl if (nextEditorView && new URLSearchParams(location.search).has('benchBreakdown')) performance.mark('reams:boot:active-editor-mounted') boundEditorViewRef.current = nextEditorView currentAdapterRef.current = adapter currentOwnershipRef.current = instance?.ownership ?? null bindView(nextEditorView, ownership, previousEditorView) if (nextEditorView) onInitialViewReady?.(leaf) const boundView = boundViewRef.current if (!boundView) return if (adapter) { attachMarkdownViewEditor(boundView, adapter) } else { detachMarkdownViewEditor(boundView) } }, [bindView, docUrl, leaf, onInitialViewReady])
useEffect(() => { if (loadError) onInitialViewReady?.(leaf) }, [loadError, leaf, onInitialViewReady])
const displayedSaveStatus = useMemo<SaveStatus>(() => { if (saveStatus.state === 'error') return saveStatus if (foldSaveStatus.state === 'error') return foldSaveStatus if (saveStatus.state === 'saving' || foldSaveStatus.state === 'saving') return { state: 'saving' } if (saveStatus.state === 'saved' || foldSaveStatus.state === 'saved') return { state: 'saved' } return { state: 'idle' } }, [foldSaveStatus, saveStatus]) useEffect(() => { onSaveStatusChange?.(displayedSaveStatus) }, [displayedSaveStatus, onSaveStatusChange])
useEffect(() => { onConcurrentTabChange?.(isConcurrentTabOpen) }, [isConcurrentTabOpen, onConcurrentTabChange])
if (!docUrl) { // Transient state during open: leaf has no file yet. Render a placeholder // so React doesn't crash when an active leaf hasn't committed a file. return ( <div style={{ color: 'var(--text-secondary)', padding: '2rem', textAlign: 'center' }}> Loading document... </div> ) }
if (loadError) { return ( <div style={{ color: 'var(--error)', padding: '2rem', textAlign: 'center' }}> Error: {loadError} </div> ) }
if (isLoading || content === undefined) { return ( <div style={{ color: 'var(--text-secondary)', padding: '2rem', textAlign: 'center' }}> Loading document... </div> ) }
const editor = ( <CM6Editor key={docUrl} initialContent={content} onDocumentUpdate={onDocumentUpdate} attachmentClipboard={getAttachmentClipboardPayload ? { documentId: docUrl, getPayload: getAttachmentClipboardPayload, isSourceAbsent: nodeId => documentManager.readTextFirstAttachmentPayload(docUrl, nodeId) === undefined, prepareClipboardTransaction } : undefined} wikilinkHandlers={wikilinkHandlers} mode={editorMode} formattingActionManager={formattingActionManager} onEditorInstance={handleEditorInstance} listFolds={effectiveListFolds} onListFoldChanged={handleListFoldChanged} listFoldNodes={listFoldNodes} vault={vault} imageResolver={pluginApp.reamsImageResolver} app={pluginApp} records={typesRecordsEnabled ? recordPreview : undefined} attachmentDiagnostic={attachmentDiagnostic} rootFrontmatter={rootFrontmatterPreview} file={fileRef.current ?? null} /> )
return editor}