Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991import { Text, Transaction, type TransactionSpec } from '@codemirror/state'import type { EditorView, ViewUpdate } from '@codemirror/view'import type { DocumentId, DocumentManager, TextFirstDocumentState, TextChange, TextFirstTransactionResult, FixedWidthTextEditHint } from '../services/document-manager'import type { EditorBindingOwnership } from '../editor/editor-binding-ownership'import { createEditorBindingOwnership } from '../editor/editor-binding-ownership'import { REMOTE_CHANGE_ANNOTATION, projectionChange, type DocumentSyncSnapshot, type SaveStatus } from './sync-contract'import { headsEqual } from './heads'import { lineAttachmentAstExtents, lineAttachmentBindingToken, lineAttachmentOwners, markerDeltaEffect, setLineAttachmentDeltaPreparer, setLineAttachmentProjection, unsupportedAttachmentEffect } from '../editor/line-attachment-extension'import { markerEquality, type LineOwner, type MarkerDelta } from '../automerge-markdown/line-attachment'import type { TextContribution } from '../types-records/record-write-command'import { history, isolateHistory } from '@codemirror/commands'import { snapshotBlockFact } from '../types-records/snapshot-values'import { decodeBlockFactKey, readBlockFactCandidates, sameBlockFact, type BlockScalar } from '../types-records/block-record-facts'import { textFirstHistoryCompartment } from '../editor/CM6Editor'import { isCheckboxAction } from '../editor/checkbox-action'import { TabAwareness } from '../services/tab-awareness'import { CommittedObserverError } from '../spikes/text-first-writer'
export type { TextFirstDocumentState }export type TextFirstChange = TextChangeexport type TextFirstSyncSnapshot = DocumentSyncSnapshottype Options = { documentManager: DocumentManager; docUrl: DocumentId; recognizedTypeNames?: readonly string[]; catalogReady?: boolean; typesRecordsEnabled?: boolean; onRemoteApplied?: () => void }const message = (error: unknown) => error instanceof Error ? error.message : String(error)const normalized = (raw: string) => Text.of(raw.split(/\r\n|\r|\n/))const physicalStarts = (raw: string): number[] => { const starts = [0] for (let index = 0; index < raw.length; index += 1) { if (raw[index] === '\r') { if (raw[index + 1] === '\n') index += 1 starts.push(index + 1) } else if (raw[index] === '\n') starts.push(index + 1) } return starts}const firstLineAtOrAfter = (starts: readonly number[], position: number): number => { let low = 0 let high = starts.length while (low < high) { const middle = low + Math.floor((high - low) / 2) if (starts[middle]! < position) low = middle + 1 else high = middle } return low}const firstLineAfter = (starts: readonly number[], position: number): number => { let low = 0 let high = starts.length while (low < high) { const middle = low + Math.floor((high - low) / 2) if (starts[middle]! <= position) low = middle + 1 else high = middle } return low}const updatePhysicalStarts = (starts: readonly number[], change: TextChange): readonly number[] => { const delta = change.insert.length - (change.to - change.from) const next = starts.filter(start => start < change.from || start > change.to).map(start => start > change.to ? start + delta : start) if (starts.includes(change.from)) next.push(change.from) for (let index = 0; index < change.insert.length; index += 1) { if (change.insert[index] === '\r') { if (change.insert[index + 1] === '\n') index += 1 next.push(change.from + index + 1) } else if (change.insert[index] === '\n') next.push(change.from + index + 1) } return [...new Set([0, ...next])].sort((left, right) => left - right)}function cloneContributionValue(value: BlockScalar | undefined): BlockScalar | undefined { if (value === undefined) return undefined return Object.freeze(snapshotBlockFact(value)) as BlockScalar}
export class TextFirstSyncController { private options: Options private state: DocumentSyncSnapshot private readonly listeners = new Set<() => void>() private readonly origin = {} private bindingToken = {} private prepared: Pick<TextFirstDocumentState, 'documentId' | 'heads' | 'lineOwners' | 'nodeIndex'> | null = null private raw = Text.empty private rawLineStarts: readonly number[] = [0] private projected = Text.empty private newline = '\n' private view: EditorView | null = null private ownership: EditorBindingOwnership = createEditorBindingOwnership() private unsubscribe: (() => void) | null = null private release: (() => void) | null = null private startupLease: (() => void) | null = null private flushTimer: ReturnType<typeof setTimeout> | undefined private projectionTicket = 0 private queuedProjection: number | undefined private generation = 0 private bindingGeneration = 0 private externalReservation: { generation: number; bindingGeneration: number; heads: readonly string[] } | null = null private compositionEndCleanup: (() => void) | null = null private deferredCompositionProjection = false private started = false private disposed = false private blocked: string | undefined private attachmentDiagnostic: string | undefined private policySuspended = false private policyDiagnostic: string | undefined private tabAwareness: TabAwareness | null = null private updateQueue: Promise<void> = Promise.resolve() private queuedOptions: Options | null = null private saveAttemptCount = 0
constructor(options: Options) { this.options = options; this.state = this.emptySnapshot() }
start(): void { if (this.started && !this.disposed || !this.options.docUrl) return if (this.options.typesRecordsEnabled !== false && this.options.catalogReady === false) return this.started = true this.disposed = false const generation = ++this.generation this.state = { ...this.emptySnapshot(), sessionId: generation } this.emit() const { documentManager: manager, docUrl: url } = this.options const releaseLease = manager.acquireTextFirstDocumentLease(url) this.startupLease = releaseLease const releaseStaleStartup = async () => { releaseLease() if (this.startupLease === releaseLease) this.startupLease = null try { await manager.releaseTextFirstDocument(url) } catch (error) { console.error('Text-first stale startup cleanup failed; writer retained for retry:', error) } } const trace = typeof location !== 'undefined' && new URLSearchParams(location.search).has('benchBreakdown') if (trace) performance.mark('reams:boot:active-sync-start') void manager.prepareTextFirstDocument(url).then(async () => { if (!this.current(generation, manager, url)) { await releaseStaleStartup(); return } if (trace) performance.mark('reams:boot:active-prepared') if (this.options.typesRecordsEnabled === false && manager.hasTextFirstRecordData(url)) { this.policySuspended = true this.policyDiagnostic = 'Record maintenance is paused while Types are disabled; existing identities and facts are protected.' this.blocked = this.policyDiagnostic } const initialize = this.options.typesRecordsEnabled === false ? Promise.resolve<'not-requested'>('not-requested') : manager.initializeTextFirstLineOwners(url, this.options.recognizedTypeNames ?? []) void initialize.then(async status => { if (!this.current(generation, manager, url)) { await releaseStaleStartup(); return } if (trace) performance.mark('reams:boot:active-owners-ready') this.install(manager.getTextFirstDocumentState(url)) if (trace) performance.mark('reams:boot:active-installed') if (status === 'unsupported') this.attachmentDiagnostic = 'Line attachments could not be initialized; visible Markdown remains editable.' else this.attachmentDiagnostic = undefined this.tabAwareness = new TabAwareness(url, isConcurrent => { if (!this.current(generation, manager, url)) return this.state = { ...this.state, isConcurrentTabOpen: isConcurrent } this.emit() }) this.subscribeDocument() this.publish({ state: 'idle' }) if (trace) performance.mark('reams:boot:active-published') }).catch(async error => { if (!this.current(generation, manager, url)) { await releaseStaleStartup(); return } releaseLease() if (this.startupLease === releaseLease) this.startupLease = null try { await manager.releaseTextFirstDocument(url) } catch (cleanupError) { console.error('Text-first startup cleanup failed; writer retained for retry:', cleanupError) } try { this.install(manager.getTextFirstDocumentState(url)) } catch { /* preserve the original initialization error */ } this.state = { ...this.state, loadPhase: 'error', loadError: message(error) } this.emit() console.warn('Text-first line attachment initialization failed:', error) }) }, async error => { if (!this.current(generation, manager, url)) { await releaseStaleStartup(); return } releaseLease() if (this.startupLease === releaseLease) this.startupLease = null try { await manager.releaseTextFirstDocument(url) } catch (cleanupError) { console.error('Text-first startup cleanup failed; writer retained for retry:', cleanupError) } this.state = { ...this.state, loadPhase: 'error', loadError: message(error) } this.emit() }) }
updateOptions(options: Options): Promise<void> { this.queuedOptions = options this.updateQueue = this.updateQueue.then(async () => { const next = this.queuedOptions this.queuedOptions = null if (!next) return if (this.matches(next.documentManager, next.docUrl)) { const previousNames = this.options.recognizedTypeNames ?? [] const nextNames = next.recognizedTypeNames ?? [] const namesChanged = previousNames.length !== nextNames.length || previousNames.some((name, index) => name !== nextNames[index]) const previousPolicyDiagnostic = this.policyDiagnostic const wasDisabled = this.options.typesRecordsEnabled === false const wasReady = this.options.typesRecordsEnabled === false || this.options.catalogReady !== false const wasDeferred = !wasReady && this.options.typesRecordsEnabled !== false const isReady = next.typesRecordsEnabled === false || next.catalogReady !== false const isDisabled = next.typesRecordsEnabled === false this.options = next if (!this.started && isReady && wasDeferred) { this.start() return } if (this.started && !isReady) { this.policySuspended = next.typesRecordsEnabled !== false || (this.prepared !== null && next.documentManager.hasTextFirstRecordData(next.docUrl)) if (this.policySuspended) { this.policyDiagnostic = 'Record maintenance is paused until the Type catalog is authoritative; existing identities and facts are protected.' this.blocked = this.policyDiagnostic this.detachPolicyPlanner() this.state = { ...this.state, saveStatus: { state: 'error', message: this.policyDiagnostic }, savePhase: 'error', lastSaveOutcome: 'rejected' } this.emit() } return } if (this.started && isReady) { const disabledRecordPolicy = next.typesRecordsEnabled === false && this.prepared !== null && next.documentManager.hasTextFirstRecordData(next.docUrl) this.policySuspended = disabledRecordPolicy this.policyDiagnostic = disabledRecordPolicy ? 'Record maintenance is paused while Types are disabled; existing identities and facts are protected.' : undefined if (this.blocked === previousPolicyDiagnostic) this.blocked = this.policyDiagnostic if (!this.policySuspended && previousPolicyDiagnostic && this.state.saveStatus.state === 'error' && this.state.saveStatus.message === previousPolicyDiagnostic) { this.state = { ...this.state, saveStatus: { state: 'idle' }, savePhase: 'idle', lastSaveOutcome: null, lastSaveOutcomeGeneration: null } } if (this.policySuspended) this.detachPolicyPlanner() } if (this.prepared && (namesChanged || wasReady !== isReady || wasDisabled !== isDisabled)) { try { if (next.typesRecordsEnabled === false) return const status = await next.documentManager.initializeTextFirstLineOwners(next.docUrl, nextNames) if (this.current(this.generation, next.documentManager, next.docUrl)) { this.attachmentDiagnostic = status === 'unsupported' ? 'Line attachments could not be initialized; visible Markdown remains editable.' : undefined this.projectLatest(false) } } catch (error) { this.blocked = message(error) this.state = { ...this.state, saveStatus: { state: 'error', message: this.blocked }, savePhase: 'error', lastSaveOutcome: 'rejected' } this.emit() } } return } const previousManager = this.options.documentManager const previousUrl = this.options.docUrl if (this.prepared) await this.flushPendingPlain() if (this.view) this.resetHistory(this.view) this.clearTimers() this.detach() this.tabAwareness?.destroy() this.tabAwareness = null this.startupLease?.() this.startupLease = null await previousManager.releaseTextFirstDocument(previousUrl) this.generation += 1 this.options = next this.prepared = null this.raw = this.projected = Text.empty this.blocked = undefined this.attachmentDiagnostic = undefined this.started = false this.disposed = false this.state = this.emptySnapshot() this.emit() }) return this.updateQueue }
matches(manager: DocumentManager, url: DocumentId): boolean { return this.options.documentManager === manager && this.options.docUrl === url } canAcceptLocalInput(manager: DocumentManager, url: DocumentId): boolean { return this.matches(manager, url) && !this.disposed && !this.blocked && this.state.loadPhase === 'ready' } subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener) } getSnapshot(): DocumentSyncSnapshot { return this.state } getAttachmentDiagnostic(): string | undefined { return this.attachmentDiagnostic }
/** Compatibility facade for external whole-text callers; CM writes use onDocumentUpdate. */ onChange(markdown: string, expectedBeforeOrHint?: string | FixedWidthTextEditHint): string | null { if (!this.prepared || this.disposed || this.blocked) return null const before = typeof expectedBeforeOrHint === 'string' ? expectedBeforeOrHint : this.raw.toString() if (before !== this.raw.toString()) { this.failMutation(new Error('Type editor source is based on stale document text')) return null } const changes = projectionChange(before, markdown) if (changes.empty) return this.raw.toString() const textChanges: TextChange[] = [] changes.iterChanges((from, to, _fromB, _toB, inserted) => textChanges.push({ from, to, insert: inserted.toString() })) try { const result = this.options.documentManager.applyTextFirstChanges(this.options.docUrl, this.prepared.heads, textChanges, this.origin) const state = this.options.documentManager.getTextFirstDocumentState(this.options.docUrl) if (result.contributionHeads.length) { this.prepared = { ...this.prepared, heads: [...result.liveHeads], lineOwners: state.lineOwners, nodeIndex: state.nodeIndex } } this.install(state) this.publish({ state: 'saving' }) this.scheduleFlush() return state.content } catch (error) { this.failMutation(error) return null } }
async onSemanticChange(markdown: string, _semantic: unknown): Promise<void> { if (this.disposed || this.blocked || this.policySuspended || !this.prepared) return await this.options.documentManager.updateContent(this.options.docUrl, markdown) this.projectLatest(false) }
getAttachmentClipboardPayload(nodeId: string, text: string) { const payload = this.options.documentManager.readTextFirstAttachmentPayload(this.options.docUrl, nodeId) return payload && ({ sourceNodeId: nodeId, text, marker: payload.marker, facts: payload.facts, }) }
bindView(view: EditorView | null, ownership?: EditorBindingOwnership | null): void { if (!view) { this.detach(); return } if (!this.prepared || this.disposed) return if (this.view === view && (ownership === undefined || ownership === this.ownership) && lineAttachmentBindingToken(view.state) === this.bindingToken && this.release && this.unsubscribe) return this.detach() this.bindingToken = {} this.view = view this.ownership = ownership ?? createEditorBindingOwnership() const onCompositionEnd = () => { if (!this.deferredCompositionProjection) return this.deferredCompositionProjection = false this.projectLatest(false) } view.dom.addEventListener('compositionend', onCompositionEnd) this.compositionEndCleanup = () => view.dom.removeEventListener('compositionend', onCompositionEnd) try { this.release = this.options.documentManager.claimTextFirstEditor(this.options.docUrl, this.origin) view.dispatch({ effects: [ setLineAttachmentProjection.of({ owners: this.cmOwners(this.prepared.lineOwners), astExtents: this.cmAstExtents(), recognizedTypeNames: this.options.recognizedTypeNames ?? [], bindingToken: this.bindingToken }), setLineAttachmentDeltaPreparer.of(this.policySuspended ? undefined : (delta, previousExtents) => this.prepareAttachmentDelta(delta, previousExtents)), ] }) this.subscribeDocument() this.projectLatest(false) this.publish(this.state.saveStatus) } catch (error) { this.failMutation(error) } } unbindView(view?: EditorView | null): void { if (!view || view === this.view) this.detach() }
onDocumentUpdate(update: ViewUpdate): void { const debugWindow = globalThis as typeof globalThis & { __reamsStatefulMarkerTrace__?: { before?: unknown; after?: unknown; transaction?: unknown } } if (debugWindow.__reamsStatefulMarkerTrace__ && update.docChanged) { const transaction = update.transactions[0] debugWindow.__reamsStatefulMarkerTrace__.before = { text: update.startState.doc.toString(), owners: lineAttachmentOwners(update.startState), extents: lineAttachmentAstExtents(update.startState), } const changes: Array<{ from: number; to: number; newFrom: number; newTo: number; insert: string }> = [] update.changes.iterChanges((from, to, newFrom, newTo, inserted) => changes.push({ from, to, newFrom, newTo, insert: inserted.toString() })) debugWindow.__reamsStatefulMarkerTrace__.transaction = { changes, effects: transaction?.effects.map(effect => effect.value) ?? [], } } const hasMarkerDelta = update.transactions.some(transaction => transaction.effects.some(effect => effect.is(markerDeltaEffect))) const unsupported = update.transactions.flatMap(transaction => transaction.effects.filter(effect => effect.is(unsupportedAttachmentEffect)).map(effect => effect.value))[0] if (update.view === this.view && unsupported) { this.attachmentDiagnostic = unsupported this.publish(this.state.saveStatus) } if (update.view !== this.view || (!update.docChanged && !hasMarkerDelta) || this.ownership.isIgnoringTransactions() || !this.prepared || this.disposed) return if (this.policySuspended) { this.recoverCanonical(update.view, new Error(this.policyDiagnostic ?? 'Record policy is unavailable')) this.blocked = this.policyDiagnostic return } if (this.blocked) return if (unsupported) { this.recoverCanonical(update.view, new Error(unsupported)); return } if (update.transactions.some(transaction => transaction.effects.some(effect => effect.is(markerDeltaEffect) && effect.value.bindingToken !== this.bindingToken))) return if (update.view.state.readOnly && !update.transactions.every(isCheckboxAction)) return if (!update.startState.doc.eq(this.projected)) { this.recoverCanonical(update.view, new Error('Editor text does not match its materialized checkpoint')); return } this.projectionTicket++ this.queuedProjection = undefined try { const rawPosition = (position: number, doc: Text = update.startState.doc, starts: readonly number[] = this.rawLineStarts) => { const line = doc.lineAt(position) return starts[line.number - 1]! + position - line.from } const rawNewline = (_position: number) => this.newline const changes: TextChange[] = [] update.changes.iterChanges((from, to, _fromB, _toB, inserted) => { const rawFrom = rawPosition(from) changes.push({ from: rawFrom, to: rawPosition(to), insert: inserted.toString().replace(/\n/g, rawNewline(rawFrom)) }) }) const markerDelta = this.combinedMarkerDelta(update) const rawAfter = [...changes].sort((left, right) => right.from - left.from) .reduce((text, change) => text.replace(change.from, change.to, Text.of([change.insert])), this.raw) const toRawOwner = (owner: { readonly nodeId: string; readonly lineStart: number; readonly markerPlainIndex?: number }, doc: Text, starts: readonly number[]) => ({ ...owner, lineStart: starts[doc.lineAt(owner.lineStart).number - 1]!, ...(owner.markerPlainIndex === undefined ? {} : { markerPlainIndex: rawPosition(owner.markerPlainIndex, doc, starts) }), }) const rawAfterStarts = markerDelta ? physicalStarts(rawAfter.toString()) : [] const rawMarkerDelta = markerDelta ? { before: markerDelta.before.map(owner => toRawOwner(owner, update.startState.doc, this.rawLineStarts)), after: markerDelta.after.map(owner => toRawOwner(owner, update.state.doc, rawAfterStarts)), ...(markerDelta.documentId ? { documentId: markerDelta.documentId } : {}), ...(markerDelta.bindingToken ? { bindingToken: markerDelta.bindingToken } : {}), ...(markerDelta.atomic ? { atomic: markerDelta.atomic } : {}), ...(markerDelta.removedNodeIds ? { removedNodeIds: markerDelta.removedNodeIds } : {}), ...(markerDelta.markers ? { markers: markerDelta.markers } : {}), ...(markerDelta.markerUpdates ? { markerUpdates: markerDelta.markerUpdates } : {}), ...(markerDelta.facts ? { facts: markerDelta.facts } : {}), } : undefined const result = this.options.documentManager.applyTextFirstTransaction(this.options.docUrl, this.prepared.heads, { changes, ...(rawMarkerDelta ? { markerDelta: rawMarkerDelta } : {}) }, this.origin) for (const change of [...changes].sort((a, b) => b.from - a.from)) { this.raw = this.raw.replace(change.from, change.to, Text.of(change.insert.split('\n'))) this.rawLineStarts = updatePhysicalStarts(this.rawLineStarts, change) } this.projected = update.state.doc if (debugWindow.__reamsStatefulMarkerTrace__) { debugWindow.__reamsStatefulMarkerTrace__.after = { text: update.state.doc.toString(), owners: lineAttachmentOwners(update.state), extents: lineAttachmentAstExtents(update.state), } } if (result.factUndoSkipped) { this.attachmentDiagnostic = 'Record undo skipped because another actor edited this field; remote values and conflicts were preserved.' } else if (unsupported || result.attachment === 'unsupported') { this.attachmentDiagnostic = unsupported ?? 'Line attachment maintenance was unsupported for this edit; Markdown was saved and owners were resynchronized.' // This listener runs during dispatch; reconcile after it returns. this.scheduleProjection() } else { this.attachmentDiagnostic = undefined } if (result.contributionHeads.length) { const materialized = result.localGeometry ?? this.options.documentManager.getTextFirstDocumentState(this.options.docUrl) // CM still shows the local contribution until remote text is projected. this.prepared = { ...this.prepared, heads: [...result.contributionHeads], lineOwners: materialized.lineOwners, nodeIndex: materialized.nodeIndex } const wasSaving = this.state.saveStatus.state === 'saving' if (this.externalReservation) { this.externalReservation = null this.attachmentDiagnostic = 'An external record edit overlapped typing; its editor undo entry was not recorded.' } this.publish({ state: 'saving' }, !wasSaving) this.scheduleFlush() } // Text-only writes with returned geometry already advanced this view. // Metadata writes still refresh marker payloads from the canonical state. if (markerDelta || !result.localGeometry || !headsEqual(result.contributionHeads, result.liveHeads)) this.scheduleProjection() } catch (error) { if (error instanceof CommittedObserverError) this.recoverCanonical(update.view, error, true) else this.recoverCanonical(update.view, error) } }
/** * Clipboard mutations must cross Automerge before CM. `state.update` is * intentionally used here instead of reconstructing a transaction so the * normal CM history/effect mapping remains the single source of truth. */ prepareClipboardTransaction(view: EditorView, spec: TransactionSpec): (() => void) | undefined { if (view !== this.view || !this.prepared || this.disposed || this.blocked || view.state.readOnly) return undefined if (!view.state.doc.eq(this.projected)) throw new Error('Editor text does not match its materialized checkpoint') const transaction = view.state.update(spec) if (!transaction.docChanged) return undefined const startState = transaction.startState const generation = this.generation const bindingGeneration = this.bindingGeneration const bindingToken = this.bindingToken const manager = this.options.documentManager const url = this.options.docUrl const rawPosition = (position: number, doc: Text, starts: readonly number[]) => { const line = doc.lineAt(position) return starts[line.number - 1]! + position - line.from } const changes: TextChange[] = [] transaction.changes.iterChanges((from, to, _fromB, _toB, inserted) => { const rawFrom = rawPosition(from, transaction.startState.doc, this.rawLineStarts) changes.push({ from: rawFrom, to: rawPosition(to, transaction.startState.doc, this.rawLineStarts), insert: inserted.toString().replace(/\n/g, this.newline) }) }) const rawAfter = [...changes].sort((left, right) => right.from - left.from) .reduce((text, change) => text.replace(change.from, change.to, Text.of([change.insert])), this.raw) const marker = transaction.effects.find(effect => effect.is(markerDeltaEffect))?.value const toRawOwner = (owner: LineOwner, doc: Text, starts: readonly number[]) => ({ ...owner, lineStart: rawPosition(owner.lineStart, doc, starts), ...(owner.markerPlainIndex === undefined ? {} : { markerPlainIndex: rawPosition(owner.markerPlainIndex, doc, starts) }), }) const rawMarker = marker ? { ...marker, before: marker.before.map(owner => toRawOwner(owner, transaction.startState.doc, this.rawLineStarts)), after: marker.after.map(owner => toRawOwner(owner, transaction.newDoc, physicalStarts(rawAfter.toString()))), } : undefined let result: TextFirstTransactionResult let committedObserverFailure = false try { result = manager.applyTextFirstTransaction(url, this.prepared.heads, { changes, ...(rawMarker ? { markerDelta: rawMarker } : {}), }, this.origin) } catch (error) { // A writer can report an observer failure after committing. Never // replay such a mutation; make the canonical state visible instead. if (error instanceof CommittedObserverError) { this.install(manager.readTextFirstDocumentState(url)) committedObserverFailure = true return () => this.recoverCanonical(view, error, true, manager, url) } throw error } let committed = false return () => { if (committed) return committed = true try { const stillBound = view === this.view && generation === this.generation && bindingGeneration === this.bindingGeneration && bindingToken === this.bindingToken && view.state === startState && this.prepared !== null && lineAttachmentBindingToken(view.state) === bindingToken if (!committedObserverFailure && stillBound) { this.ownership.runIgnoringTransactions(() => view.dispatch(transaction)) } else if (!committedObserverFailure) { this.recoverCanonical(view, new Error('Clipboard transaction became stale after its canonical commit'), true, manager, url) return } } finally { // Automerge is authoritative once preparation succeeded. Re-read it // even if a CM observer throws, rather than attempting a second write. if (generation !== this.generation || manager !== this.options.documentManager || url !== this.options.docUrl || this.disposed) return this.install(manager.isTextFirstWriterStopped(url) ? manager.readTextFirstDocumentState(url) : manager.getTextFirstDocumentState(url)) if (view === this.view && !view.state.doc.eq(this.projected)) this.scheduleProjection() if (result.contributionHeads.length) { const wasSaving = this.state.saveStatus.state === 'saving' this.publish({ state: 'saving' }, !wasSaving) this.scheduleFlush() } else this.publish(this.state.saveStatus) } } }
async flushPendingPlain(): Promise<boolean> { if (this.flushTimer !== undefined) { clearTimeout(this.flushTimer); this.flushTimer = undefined } if (!this.prepared) return false const { documentManager: manager, docUrl: url } = this.options const generation = this.generation this.saveAttemptCount += 1 // Every request reaches the writer, including same-revision remote heads. // The writer owns single-flight draining and exact durability coverage. try { await manager.flushTextFirstDocument(url) if (this.current(generation, manager, url)) { this.projectLatest(false) const progress = manager.getTextFirstProgress(url) if (this.blocked) this.publish({ state: 'error', message: this.blocked }) else if (!headsEqual(progress.durableHeads, progress.materializedHeads)) this.publish({ state: 'saving' }) else this.publish({ state: 'saved' }) } return true } catch (error) { if (this.current(generation, manager, url)) this.publish({ state: 'error', message: message(error) }) return false } }
async dispose(): Promise<void> { const manager = this.options.documentManager const url = this.options.docUrl const flush = this.prepared ? this.flushPendingPlain() : Promise.resolve(true) this.disposed = true this.started = false this.generation += 1 this.clearTimers() this.detach() this.tabAwareness?.destroy() this.tabAwareness = null this.prepared = null this.startupLease?.() this.startupLease = null this.state = { ...this.state, loadPhase: 'disposed', subscriptionPhase: 'detached' } this.emit() await flush await manager.releaseTextFirstDocument(url) }
async applyExternalContribution(contribution?: TextContribution): Promise<void> { const captured = { generation: this.generation, bindingGeneration: this.bindingGeneration, view: this.view, documentId: this.options.docUrl } const reservation = this.externalReservation const view = captured.view await Promise.resolve() const current = (requireHeads?: readonly string[]) => { if (captured.generation !== this.generation || captured.bindingGeneration !== this.bindingGeneration || captured.view !== this.view || captured.documentId !== this.options.docUrl || this.disposed || !this.prepared || this.externalReservation !== reservation) return false if (captured.view?.state.readOnly) return false if (requireHeads && !headsEqual(this.options.documentManager.getTextFirstDocumentState(captured.documentId).heads, requireHeads)) return false return true } if (contribution?.metadataChanges?.length) { if (!reservation || reservation.generation !== captured.generation || reservation.bindingGeneration !== captured.bindingGeneration || !headsEqual(reservation.heads, contribution.beforeHeads) || contribution.documentId !== captured.documentId || !current(contribution.heads)) return const currentState = this.options.documentManager.getTextFirstDocumentState(this.options.docUrl) if (!headsEqual(currentState.heads, contribution.heads)) return const facts = await this.options.documentManager.readBlockFacts(this.options.docUrl) if (!current(contribution.heads)) return const prepared = this.prepared if (!prepared) return for (const change of contribution.metadataChanges) { const decoded = decodeBlockFactKey(change.key) if (!facts || !decoded || prepared.nodeIndex.status !== 'available' || !prepared.nodeIndex.index.getNode(decoded[0]) || !(this.options.recognizedTypeNames ?? []).includes(decoded[1])) return const candidates = readBlockFactCandidates(facts, change.key) if (change.after === undefined ? Object.prototype.hasOwnProperty.call(facts, change.key) || candidates.length > 0 : !candidates.some(candidate => sameBlockFact(candidate, change.after))) return } if (!current(contribution.heads)) return } if (contribution) { if (!reservation || contribution.documentId !== captured.documentId || !headsEqual(reservation.heads, contribution.beforeHeads) || !current(contribution.heads) || !view || view.state.doc.toString() !== normalized(contribution.beforeContent).toString()) return const canonical = this.options.documentManager.getTextFirstDocumentState(captured.documentId) if (canonical.content !== contribution.afterContent && normalized(canonical.content).toString() !== normalized(contribution.afterContent).toString()) return } const generation = captured.generation const bindingGeneration = captured.bindingGeneration const frontier = this.prepared && [...this.prepared.heads] if (contribution && view && frontier && contribution.documentId === this.options.docUrl && reservation && reservation.generation === generation && reservation.bindingGeneration === bindingGeneration && headsEqual(reservation.heads, contribution.beforeHeads) && !this.disposed && generation === this.generation && bindingGeneration === this.bindingGeneration && this.externalReservation === reservation && !view.state.readOnly) { const metadataChanges = (contribution.metadataChanges ?? []).map(change => ({ key: change.key, before: cloneContributionValue(change.before), after: cloneContributionValue(change.after), origin: change.origin, })) try { const after = normalized(contribution.afterContent) const changes = projectionChange(view.state.doc.toString(), after.toString()) this.ownership.runIgnoringTransactions(() => view.dispatch({ ...(changes.empty ? {} : { changes }), effects: markerDeltaEffect.of({ documentId: contribution.documentId, bindingToken: this.bindingToken, before: [], after: [], facts: metadataChanges }), userEvent: 'input', annotations: [REMOTE_CHANGE_ANNOTATION.of(true), Transaction.addToHistory.of(true), isolateHistory.of('full')], })) } finally { // A CM observer may throw after the Automerge write. Do not strand the reservation. if (this.externalReservation === reservation) this.externalReservation = null } } if (generation !== this.generation || bindingGeneration !== this.bindingGeneration || view !== this.view) return this.projectLatest(false) } async prepareExternalWrite(): Promise<(() => void) | null> { if (this.disposed || this.blocked || this.policySuspended || !this.prepared) return null const generation = this.generation const bindingGeneration = this.bindingGeneration const documentId = this.options.docUrl if (!await this.flushPendingPlain() || !this.prepared) return null if (this.disposed || this.blocked || this.policySuspended || generation !== this.generation || bindingGeneration !== this.bindingGeneration || documentId !== this.options.docUrl) return null const currentHeads = this.options.documentManager.getTextFirstDocumentState(documentId).heads if (!headsEqual(currentHeads, this.prepared.heads)) return null const reservation = { generation: this.generation, bindingGeneration: this.bindingGeneration, heads: [...currentHeads] } this.externalReservation = reservation return () => { if (this.externalReservation !== reservation) return this.externalReservation = null // A contribution may have advanced the document after its projection // callback was rejected as stale. Reconcile before the next writer base // is captured, but never cross a binding/document lifecycle boundary. if (this.disposed || reservation.generation !== this.generation || reservation.bindingGeneration !== this.bindingGeneration || !this.prepared) return try { this.projectLatest(false) } catch (error) { this.failMutation(error) } } } async reloadTextProjection(): Promise<void> { this.projectLatest(true) }
private install(state: TextFirstDocumentState): void { // Manager snapshots already use canonical raw-text coordinates. Convert // them only when projecting into CM's normalized-newline document. this.prepared = { documentId: state.documentId, heads: [...state.heads], lineOwners: state.lineOwners, nodeIndex: state.nodeIndex } this.raw = Text.of([state.content]) this.rawLineStarts = physicalStarts(state.content) this.projected = normalized(state.content) this.newline = state.content.match(/\r\n|\r|\n/)?.[0] ?? '\n' this.state = { ...this.state, markdownNodeIndex: state.nodeIndex } }
private cmOwners(owners: readonly { readonly nodeId: string; readonly lineStart: number; readonly markerPlainIndex?: number }[]) { return owners.map(owner => { const line = Math.max(0, firstLineAfter(this.rawLineStarts, owner.lineStart) - 1) const projectedLineStart = this.projected.line(line + 1).from const markerPlainIndex = 'markerPlainIndex' in owner && owner.markerPlainIndex !== undefined ? projectedLineStart + owner.markerPlainIndex - this.rawLineStarts[line]! : undefined return { ...owner, lineStart: projectedLineStart, ...(markerPlainIndex === undefined ? {} : { markerPlainIndex }) } }) }
private cmAstExtents() { if (this.prepared?.nodeIndex.status !== 'available') return [] const markers = this.options.documentManager.readTextFirstAttachmentMarkers(this.options.docUrl, this.prepared.heads) const facts = this.options.documentManager.readTextFirstAttachmentFacts(this.options.docUrl, this.prepared.heads) const factsByOwner = new Map<string, Array<{ readonly key: string; readonly value: BlockScalar }>>() for (const [key, value] of Object.entries(facts)) { const nodeId = decodeBlockFactKey(key)?.[0] if (!nodeId) continue const entries = factsByOwner.get(nodeId) if (entries) entries.push(Object.freeze({ key, value })) else factsByOwner.set(nodeId, [Object.freeze({ key, value })]) } return this.prepared.nodeIndex.index.nodes.map(node => { const semanticFrom = node.markerPlainIndex ?? node.from const firstLine = Math.max(0, Math.min(this.rawLineStarts.length, firstLineAfter(this.rawLineStarts, semanticFrom)) - 1) const endLine = firstLineAtOrAfter(this.rawLineStarts, node.to) // A native end at the next line's start includes the preceding newline. const endsAtLineStart = endLine < this.rawLineStarts.length && this.rawLineStarts[endLine] === node.to const lastLine = endLine < 0 ? this.projected.lines : Math.max(1, endLine) const from = node.markerPlainIndex === undefined ? this.projected.line(firstLine + 1).from : this.projected.line(firstLine + 1).from + semanticFrom - this.rawLineStarts[firstLine]! return Object.freeze({ nodeId: node.id, kind: node.kind, from, to: endsAtLineStart ? this.projected.line(lastLine + 1).from : this.projected.line(lastLine).to, marker: markers[node.id], facts: Object.freeze(factsByOwner.get(node.id) ?? []), parentId: node.parentId, children: node.children, }) }) }
private prepareAttachmentDelta(delta: import('../types-records/record-structure-policy').RecordAttachmentPlan, previousExtents?: readonly import('../editor/line-attachment-extension').AttachmentAstExtent[]): MarkerDelta { if (!this.prepared) throw new Error('Line attachment projection is unavailable') return this.options.documentManager.prepareTextFirstMarkerDelta( this.options.docUrl, delta, this.prepared.heads, previousExtents, ) }
private combinedMarkerDelta(update: ViewUpdate): MarkerDelta | undefined { const entries: Array<{ index: number; delta: MarkerDelta }> = [] update.transactions.forEach((transaction, index) => { for (const effect of transaction.effects) if (effect.is(markerDeltaEffect)) entries.push({ index, delta: effect.value }) }) if (entries.length === 0) return undefined const mapBeforeUpdate = (position: number, transactionIndex: number): number => { let mapped = position for (let index = transactionIndex - 1; index >= 0; index -= 1) { const transaction = update.transactions[index]! mapped = transaction.changes.invert(transaction.startState.doc).mapPos(mapped, 1) } return mapped } const mapAfterUpdate = (position: number, transactionIndex: number): number => { let mapped = position for (let index = transactionIndex + 1; index < update.transactions.length; index += 1) { mapped = update.transactions[index]!.changes.mapPos(mapped, 1) } return mapped } const initial = new Map<string, LineOwner>() const current = new Map<string, LineOwner>() const markers = new Map<string, NonNullable<MarkerDelta['markers']>[number]>() const markerUpdates = new Map<string, NonNullable<MarkerDelta['markerUpdates']>[number]>() const facts = new Map<string, { before?: NonNullable<MarkerDelta['facts']>[number]['before']; after?: NonNullable<MarkerDelta['facts']>[number]['after']; origin?: NonNullable<MarkerDelta['facts']>[number]['origin'] }>() let nativeAppend: NonNullable<MarkerDelta['nativeAppend']> | undefined const sessionOwners = lineAttachmentOwners(update.startState) sessionOwners.forEach(owner => initial.set(owner.nodeId, { ...owner })) sessionOwners.forEach(owner => current.set(owner.nodeId, { ...owner })) const touched = new Set<string>() // Keep every explicit participant in the combined inverse geometry. This // replaces the old relationship transport without inferring joins. const explicitParticipants = new Set<string>() let atomic = false entries.forEach(entry => { atomic ||= entry.delta.atomic === true entry.delta.before.forEach(owner => { const mapped = { ...owner, lineStart: mapBeforeUpdate(owner.lineStart, entry.index), ...(owner.markerPlainIndex === undefined ? {} : { markerPlainIndex: mapBeforeUpdate(owner.markerPlainIndex, entry.index) }) } touched.add(owner.nodeId) explicitParticipants.add(owner.nodeId) // Only batch-start owners exist before the canonical mutation. // Intermediate owners remain in each CM history event, not this input. if (initial.has(owner.nodeId)) initial.set(owner.nodeId, mapped) current.delete(owner.nodeId) }) entry.delta.after.forEach(owner => { touched.add(owner.nodeId) explicitParticipants.add(owner.nodeId) current.set(owner.nodeId, { ...owner, lineStart: mapAfterUpdate(owner.lineStart, entry.index), ...(owner.markerPlainIndex === undefined ? {} : { markerPlainIndex: mapAfterUpdate(owner.markerPlainIndex, entry.index) }) }) }) entry.delta.markers?.forEach(marker => { markers.set(marker.nodeId, marker) }) entry.delta.markerUpdates?.forEach(update => { if (!initial.has(update.nodeId)) { markers.set(update.nodeId, { nodeId: update.nodeId, marker: update.after }) return } const existing = markerUpdates.get(update.nodeId) if (existing && !markerEquality(existing.after, update.before)) throw new Error(`Discontinuous marker history: ${update.nodeId}`) markerUpdates.set(update.nodeId, existing ? { nodeId: update.nodeId, before: existing.before, after: update.after } : update) }) entry.delta.facts?.forEach(fact => { const existing = facts.get(fact.key) if (existing && !sameBlockFact(existing.after, fact.before)) throw new Error(`Discontinuous metadata history: ${fact.key}`) facts.set(fact.key, existing ? { before: existing.before, after: fact.after, origin: existing.origin ?? fact.origin } : { before: fact.before, after: fact.after, origin: fact.origin }) }) if (entries.length === 1 && update.transactions.length === 1 && entry.delta.nativeAppend) nativeAppend = entry.delta.nativeAppend }) const anchor = (owner: LineOwner): number => owner.markerPlainIndex ?? owner.lineStart const before = [...initial.values()].filter(owner => touched.has(owner.nodeId) && (explicitParticipants.has(owner.nodeId) || anchor(current.get(owner.nodeId) ?? owner) !== anchor(owner) || !current.has(owner.nodeId))) const after = [...current.values()].filter(owner => touched.has(owner.nodeId) && (explicitParticipants.has(owner.nodeId) || anchor(initial.get(owner.nodeId) ?? owner) !== anchor(owner) || !initial.has(owner.nodeId))) if (before.length === 0 && after.length === 0 && facts.size === 0 && markerUpdates.size === 0) return undefined const finalMarkers = [...markers.values()].filter(marker => current.has(marker.nodeId) || initial.has(marker.nodeId)) const finalMarkerUpdates = [...markerUpdates.values()].filter(update => current.has(update.nodeId)) const finalFacts = [...facts.entries()] .filter(([, value]) => value.before !== undefined || value.after !== undefined) .map(([key, value]) => ({ key, before: value.before, after: value.after, origin: value.origin })) const removedNodeIds = before.filter(owner => !after.some(candidate => candidate.nodeId === owner.nodeId)).map(owner => owner.nodeId) return { before, after, ...(atomic ? { atomic: true } : {}), ...(removedNodeIds.length ? { removedNodeIds } : {}), ...(finalMarkers.length ? { markers: finalMarkers } : {}), ...(finalMarkerUpdates.length ? { markerUpdates: finalMarkerUpdates } : {}), ...(finalFacts.length ? { facts: finalFacts } : {}), ...(nativeAppend ? { nativeAppend } : {}), } } private projectLatest(clearError: boolean): void { if (!this.prepared || this.disposed || this.blocked && !clearError) return if (this.externalReservation) return if (this.view?.composing) { this.deferredCompositionProjection = true; return } const state = this.options.documentManager.isTextFirstWriterStopped(this.options.docUrl) ? this.options.documentManager.readTextFirstDocumentState(this.options.docUrl) : this.options.documentManager.getTextFirstDocumentState(this.options.docUrl) const target = normalized(state.content) const changed = this.view ? !this.view.state.doc.eq(target) : !this.projected.eq(target) if (changed && this.view) { const changes = projectionChange(this.view.state.doc.toString(), target.toString()) this.ownership.runIgnoringTransactions(() => this.view!.dispatch({ changes, annotations: [REMOTE_CHANGE_ANNOTATION.of(true), Transaction.addToHistory.of(false)] })) } this.install(state) if (this.view) this.view.dispatch({ effects: [ setLineAttachmentProjection.of({ owners: this.cmOwners(state.lineOwners), astExtents: this.cmAstExtents(), recognizedTypeNames: this.options.recognizedTypeNames ?? [], bindingToken: this.bindingToken }), setLineAttachmentDeltaPreparer.of((delta, previousExtents) => this.prepareAttachmentDelta(delta, previousExtents)), ] }) if (clearError) this.blocked = undefined const progress = this.options.documentManager.getTextFirstProgress(this.options.docUrl) let saveStatus = this.state.saveStatus if (!headsEqual(progress.durableHeads, progress.materializedHeads) && !this.blocked) { saveStatus = { state: 'saving' } this.scheduleFlush() } else if (clearError) saveStatus = { state: 'idle' } this.publish(saveStatus) if (changed) { try { this.options.onRemoteApplied?.() } catch (error) { console.error('Text-first projection observer failed:', error) } } } private subscribeDocument(): void { this.unsubscribe?.() this.unsubscribe = this.options.documentManager.subscribeTextFirstDocument(this.options.docUrl, event => { if (event.origin !== this.origin) this.scheduleProjection() }) } private scheduleProjection(): void { if (this.queuedProjection !== undefined) return const generation = this.generation const binding = this.bindingGeneration const ticket = ++this.projectionTicket this.queuedProjection = ticket setTimeout(() => { if (this.queuedProjection !== ticket) return this.queuedProjection = undefined if (this.disposed || generation !== this.generation || binding !== this.bindingGeneration) return try { this.projectLatest(false) } catch (error) { this.failMutation(error) } }) } private scheduleFlush(): void { if (this.flushTimer === undefined) this.flushTimer = setTimeout(() => { this.flushTimer = undefined; void this.flushPendingPlain() }, 100) }
private failMutation(error: unknown): void { this.externalReservation = null this.blocked = message(error) this.state = { ...this.state, saveStatus: { state: 'error', message: this.blocked }, savePhase: 'error', lastSaveOutcome: 'rejected' } this.emit() } private detachPolicyPlanner(): void { if (!this.view) return this.view.dispatch({ effects: [ setLineAttachmentProjection.of({ owners: [], astExtents: [], recognizedTypeNames: [], bindingToken: this.bindingToken }), setLineAttachmentDeltaPreparer.of(undefined), ] }) } private recoverCanonical(view: EditorView, error: unknown, committed = false, manager = this.options.documentManager, url = this.options.docUrl): void { let stopped = manager.isTextFirstWriterStopped(url) if (committed && stopped) { try { manager.recoverTextFirstWriter(url); stopped = false } catch (recoveryError) { error = new Error(`${message(error)}; recovery failed: ${message(recoveryError)}`) } } if (manager !== this.options.documentManager || url !== this.options.docUrl || this.disposed) return this.blocked = stopped ? message(error) : undefined this.externalReservation = null this.attachmentDiagnostic = committed ? `Edit committed, but editor notification failed: ${message(error)}` : `Edit was rolled back: ${message(error)}` const state = manager.readTextFirstDocumentState(url) if (view === this.view && !view.state.doc.eq(normalized(state.content))) { this.ownership.runIgnoringTransactions(() => view.dispatch({ changes: projectionChange(view.state.doc.toString(), normalized(state.content).toString()), annotations: [REMOTE_CHANGE_ANNOTATION.of(true), Transaction.addToHistory.of(false)], })) } this.install(state) // The rejected CM transaction may already have changed its attachment // state. Restore that state as well as text before allowing another edit. if (view === this.view) { if (this.policySuspended) this.detachPolicyPlanner() else view.dispatch({ effects: [ setLineAttachmentProjection.of({ owners: this.cmOwners(state.lineOwners), astExtents: this.cmAstExtents(), recognizedTypeNames: this.options.recognizedTypeNames ?? [], bindingToken: this.bindingToken }), setLineAttachmentDeltaPreparer.of((delta, previousExtents) => this.prepareAttachmentDelta(delta, previousExtents)), ] }) } if (!committed) this.resetHistory(view) this.publish({ state: 'error', message: this.attachmentDiagnostic }) if (committed) this.scheduleFlush() } private publish(saveStatus: SaveStatus, notify = true): void { if (!this.prepared) return const progress = this.options.documentManager.getTextFirstProgress(this.options.docUrl) const content = this.raw.toString() const durable = progress.flushError === null && progress.durableHeads.length > 0 && headsEqual(progress.durableHeads, progress.materializedHeads) const lastSaveOutcome = saveStatus.state === 'error' ? 'rejected' : durable ? 'accepted' : this.state.lastSaveOutcome const lastSaveOutcomeGeneration = saveStatus.state === 'error' ? progress.materializedRevision : durable ? progress.durableRevision : this.state.lastSaveOutcomeGeneration this.state = { ...this.state, loadPhase: 'ready', loadError: undefined, content, saveStatus, savePhase: saveStatus.state === 'saving' ? 'saving-plain' : saveStatus.state, localGeneration: progress.materializedRevision, persistedGeneration: progress.durableRevision, subscriptionPhase: this.unsubscribe ? 'attached' : 'detached', checkpoint: { documentId: this.prepared.documentId, heads: [...this.prepared.heads], content }, durableHeads: [...progress.durableHeads], materializedHeads: [...progress.materializedHeads], persistedMarkdown: saveStatus.state !== 'error' && durable ? content : this.state.persistedMarkdown, lastSaveOutcome, lastSaveOutcomeGeneration, markdownNodeIndex: this.prepared?.nodeIndex ?? null, settledEditorText: this.view?.state.doc.toString(), } if (notify) this.emit() } private resetHistory(view: EditorView): void { view.dispatch({ effects: textFirstHistoryCompartment.reconfigure([]) }) view.dispatch({ effects: textFirstHistoryCompartment.reconfigure(history()) }) } private detach(): void { this.bindingGeneration += 1 this.externalReservation = null this.compositionEndCleanup?.(); this.compositionEndCleanup = null this.unsubscribe?.(); this.unsubscribe = null this.release?.(); this.release = null const detachedView = this.view this.view = null if (detachedView) { queueMicrotask(() => { if (this.view === null && !(detachedView as unknown as { destroyed?: boolean }).destroyed && lineAttachmentBindingToken(detachedView.state) === this.bindingToken) { detachedView.dispatch({ effects: setLineAttachmentProjection.of({ owners: [], recognizedTypeNames: [], active: false, bindingToken: this.bindingToken }) }) } }) } this.queuedProjection = undefined this.state = { ...this.state, subscriptionPhase: 'detached', boundViewGeneration: this.bindingGeneration } } private clearTimers(): void { if (this.flushTimer !== undefined) clearTimeout(this.flushTimer) this.flushTimer = undefined this.queuedProjection = undefined } private current(generation: number, manager: DocumentManager, url: DocumentId): boolean { return !this.disposed && generation === this.generation && this.matches(manager, url) } private emptySnapshot(): DocumentSyncSnapshot { return { sessionId: this.generation, docUrl: this.options.docUrl, loadPhase: 'loading', loadError: undefined, content: undefined, saveStatus: { state: 'idle' }, savePhase: 'idle', isConcurrentTabOpen: false, localGeneration: 0, persistedGeneration: 0, boundViewGeneration: this.bindingGeneration, subscriptionPhase: 'detached', saveAttemptCount: this.saveAttemptCount, lastSaveOutcome: null, lastSaveOutcomeGeneration: null, markdownNodeIndex: null, settledEditorText: undefined, persistedMarkdown: undefined, checkpoint: null, durableHeads: [], materializedHeads: [] } } private emit(): void { this.state = { ...this.state, settledEditorText: this.view?.state.doc.toString() } for (const listener of this.listeners) listener() }}