import { EditorView } from "@codemirror/view"; import { isolateHistory } from "@codemirror/commands"; import { throttle } from "@silverbulletmd/silverbullet/lib/async"; import { getNameFromPath, getOffsetFromHeader, getOffsetFromLineColumn, getPathExtension, isMarkdownPath, type Path, } from "@silverbulletmd/silverbullet/lib/ref"; import type { PageMeta } from "@silverbulletmd/silverbullet/type/index"; import type { PageCreatingContent, PageCreatingEvent, } from "@silverbulletmd/silverbullet/type/event"; import { notFoundError, offlineError, } from "@silverbulletmd/silverbullet/constants"; import { createEditorState, externalUpdate, } from "./codemirror/editor_state.ts"; import { externalSource } from "./codemirror/external_presence.ts"; import { diffAndPrepareChanges } from "./codemirror/cm_util.ts"; import { computeExternalChanges } from "./external_merge.ts"; import { parsePageMetaLastModified } from "./lib/page_meta.ts"; import { DocumentEditor } from "./document_editor.ts"; import { fsEndpoint } from "./spaces/constants.ts"; import { parseMarkdown } from "./markdown_parser/parser.ts"; import type { Client } from "./client.ts"; import type { LocationState } from "./navigator.ts"; const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/; const autoSaveInterval = 1000; /** * Manages content lifecycle: loading, saving, reloading, and editor mode switching * for both markdown pages and non-markdown documents. */ export class ContentManager { documentEditor: DocumentEditor | null = null; saveTimeout?: ReturnType; private scrollRestoreCleanup?: () => void; // Last content known to be on disk (base for 3-way external merges) private lastKnownDiskText = ""; // lastModified backing lastKnownDiskText, used to reject an apply whose // fetched content is older than what's already applied (out-of-order // resolution of overlapping reloads for the same page) private lastKnownDiskModified?: number; // Resolves once the in-flight write has updated the base above private pendingWrite?: Promise; debouncedUpdateEvent = throttle(() => { this.client.eventHook .dispatchEvent("editor:updated") .catch((e) => console.error("Error dispatching editor:updated event", e)); }, 1000); constructor(private client: Client) {} // Save the current page or document save(immediate = false): Promise { return new Promise((resolve, reject) => { if (this.saveTimeout) { clearTimeout(this.saveTimeout); } this.saveTimeout = setTimeout( () => { if ( !this.client.ui.viewState.unsavedChanges || this.client.isReadOnlyMode() ) { // No unsaved changes, or read-only mode, not gonna save return resolve(); } if (this.isDocumentEditor()) { console.log( "Requesting save for document", this.client.currentPath(), ); void this.client.dispatchAppEvent( "editor:documentSaving", this.client.currentPath(), ); // Only thing we can really do is request a save this.documentEditor.requestSave(); return resolve(); } else { // Do not save while IME composition is active if (this.client.editorView.composing) { // Re-schedule save after composition likely ends this.saveTimeout = setTimeout( this.save.bind(this), autoSaveInterval, ); return resolve(); } console.log("Saving page", this.client.currentPath()); void this.client.dispatchAppEvent( "editor:pageSaving", this.client.currentName(), ); const text = this.client.editorView.state.sliceDoc(0); const writePromise = this.client.space.writePage( this.client.currentName(), text, ); // Separate from the promise save() returns: waiters need the base // updated, not the events and meta fetch that follow it. const baseUpdated = writePromise.then( (meta) => this.adoptOwnWriteAsBase( text, parsePageMetaLastModified(meta.lastModified), ), () => {}, // errors are reported by the .catch() below ); this.pendingWrite = baseUpdated; void baseUpdated.then(() => { if (this.pendingWrite === baseUpdated) { this.pendingWrite = undefined; } }); writePromise .then(async (meta) => { this.client.ui.viewDispatch({ type: "page-saved" }); await this.client.dispatchAppEvent( "editor:pageSaved", this.client.currentName(), meta, ); // At this all the essential stuff is done, let's proceed resolve(); // In the background we'll fetch any enriched meta data, if any const enrichedMeta = await this.client.objectIndex.getObjectByRef( this.client.currentName(), "page", this.client.currentName(), ); if (enrichedMeta) { this.client.ui.viewDispatch({ type: "update-current-page-meta", meta: enrichedMeta, }); // Skip during IME composition if (!this.client.editorView.composing) { // Trigger editor re-render to update Lua widgets // with the new metadata this.client.editorView.dispatch({}); } } }) .catch((e) => { this.client.ui.flashNotification( "Could not save page, retrying again in 10 seconds", "error", ); this.saveTimeout = setTimeout(this.save.bind(this), 10000); reject(e); }); } }, immediate ? 0 : autoSaveInterval, ); }); } /** * Records the text we just wrote as the new base, unless a newer state was * adopted while this write was in flight: dragging the base backwards would * make the next merge apply that newer change a second time. */ private adoptOwnWriteAsBase(text: string, modified: number | undefined) { if ( modified !== undefined && this.lastKnownDiskModified !== undefined && modified < this.lastKnownDiskModified ) { return; } this.lastKnownDiskText = text; this.lastKnownDiskModified = modified; } async reloadEditor() { if (!this.client.systemReady) return; console.log("Reloading editor"); clearTimeout(this.saveTimeout); try { if (isMarkdownPath(this.client.currentPath())) { await this.loadPage({ path: this.client.currentPath() }, false); } else { await this.loadDocumentEditor({ path: this.client.currentPath() }); } } catch { console.log(this.client.currentPath()); console.error("There was an error during reload"); } } /** * Cleanly leave the current page/document before navigating to a new one: * unwatches the file, saves pending changes, and waits for index processing. * Returns { previousPath, loadingDifferentPath }. */ private async leaveCurrentPage(newPath: string) { const previousPath = this.client.ui.viewState.current?.path; const loadingDifferentPath = previousPath ? previousPath !== newPath : true; if (previousPath) { this.client.space.unwatchFile(previousPath); await this.save(true); // Wait for index to process the saved page so the next page renders // with up-to-date widget data. Skip during initial indexing though: // the queue may contain hundreds of files and blocking navigation on // a full drain would make the app feel unresponsive. Cap the wait so // a backed-up queue (e.g. just after sync) doesn't stall navigation // for many seconds: fresh widget data is a nice-to-have. if (await this.client.objectIndex.hasFullIndexCompleted()) { await Promise.race([ this.client.objectIndex.awaitIndexQueueDrain(), new Promise((resolve) => setTimeout(resolve, 2000)), ]); } } if (loadingDifferentPath) { // Drop session prewarmed widget results: iframe widgets key prewarms // by bodyText alone, so without this the next page would reuse the // previous page's query results. this.client.widgetCache.clearPrewarm(); } return { previousPath, loadingDifferentPath }; } async loadDocumentEditor(locationState: LocationState) { const path = locationState.path; if (isMarkdownPath(path)) throw Error("This is a markdown path"); const { previousPath, loadingDifferentPath } = await this.leaveCurrentPage(path); const extension = getPathExtension(path as Path); // Create the document editor if it doesn't already exist if ( !this.isDocumentEditor() || this.documentEditor.extension !== extension ) { try { await this.switchToDocumentEditor(extension); } catch (e: any) { // If there is no document editor we will open the file raw if (e.message.includes("Couldn't find")) { this.client.openUrl( `${document.baseURI.replace(/\/*$/, "") + fsEndpoint}/${path}`, ); throw new Error("Opened externally"); } throw e; } if (!this.isDocumentEditor()) { throw new Error("Problem setting up document editor"); } } // This can throw, but that will be catched and handled upstream. const doc = await this.client.space.readDocument(path); this.documentEditor!.openFile(doc.data, doc.meta, locationState.details); this.client.space.watchFile(path); this.client.ui.viewDispatch({ type: "document-editor-loaded", meta: doc.meta, path: path, }); this.client.eventHook .dispatchEvent( loadingDifferentPath ? "editor:documentLoaded" : "editor:documentReloaded", path, previousPath, ) .catch(console.error); } async loadPage( locationState: LocationState, navigateWithinPage: boolean = true, ) { const path = locationState.path; if (!isMarkdownPath(path)) throw Error("This is not a markdown path"); const { previousPath, loadingDifferentPath } = await this.leaveCurrentPage(path); const pageName = getNameFromPath(path); // Fetch next page to open let doc; let markerIndex = -1; try { doc = await this.client.space.readPage(pageName); } catch (e: any) { if ( e.message !== notFoundError.message && e.message !== offlineError.message ) { // If the error is not a "not found" or "offline" error, rethrow it throw e; } if (e.message === offlineError.message) { console.info( "Currently offline, will assume page doesn't exist:", pageName, ); } // Scenarios: // 1. We got a not found error -> Create an empty page // 2. We got a offline error (which meant that the service worker didn't locally retrieve the page either so likely it doesn't exist) -> Create a new page // Either way... we create an empty page! console.log(`Page doesn't exist, creating new page: ${pageName}`); // Mock up the page. We won't yet safe it, because the user may not even // want to create that page doc = { text: "", meta: { ref: pageName, tags: ["page"], name: pageName, lastModified: "", created: "", perm: "rw", } as PageMeta, }; // Let's dispatch a editor:pageCreating event to see if anybody wants to do something before the page is created const results = (await this.client.dispatchAppEvent( "editor:pageCreating", { name: pageName, } as PageCreatingEvent, )) as PageCreatingContent[]; if (results.length === 1) { doc.text = results[0].text; doc.meta.perm = results[0].perm; // check for |^| and remove it; record position to place cursor later const cursorMarker = "|^|"; const idx = doc.text.indexOf(cursorMarker); if (idx !== -1) { markerIndex = idx; doc.text = doc.text.slice(0, idx) + doc.text.slice(idx + cursorMarker.length); } } else if (results.length > 1) { console.error( "Multiple responses for editor:pageCreating event, this is not supported", ); } } // This could create an invalid editor state, but that doesn't matter, we'll update it later this.switchToPageEditor(); // Record last-opened time best-effort and non-blocking: this is a write // to the shared index store, and awaiting it would stall navigation // behind an in-progress reindex for no user-visible benefit. void this.client.pageMetaAugmenter .setAugmentation(pageName, { lastOpened: Date.now() }) .catch((e) => console.warn("Could not record lastOpened for", pageName, e), ); this.client.ui.viewDispatch({ type: "page-loaded", meta: doc.meta, path: path, }); await this.refreshCurrentPageMeta(pageName, doc.meta); // When loading a different page OR if the page is read-only (in which case we don't want to apply local patches, because there's no point) if (loadingDifferentPath || doc.meta.perm === "ro") { // Fresh state, nothing to diff against yet: doc.text *is* the new base. this.lastKnownDiskText = doc.text; this.lastKnownDiskModified = parsePageMetaLastModified( doc.meta.lastModified, ); const editorState = createEditorState( this.client, pageName, doc.text, doc.meta.perm === "ro", ); this.client.editorView.setState(editorState); } else { // Same-page reload: applyExternalPatches diffs against the base from // the *previous* load (this.lastKnownDiskText, untouched above) and // updates it to doc.text itself. this.applyExternalPatches( doc.text, parsePageMetaLastModified(doc.meta.lastModified), ); } this.client.space.watchFile(path); if (navigateWithinPage) { // Setup scroll position, cursor position, etc try { this.navigateWithinPage(locationState); } catch { // We don't really care if this fails. } } // Note: these events are dispatched asynchronously deliberately (not waiting for results) this.client.eventHook .dispatchEvent( loadingDifferentPath ? "editor:pageLoaded" : "editor:pageReloaded", pageName, previousPath ? getNameFromPath(previousPath) : undefined, ) .catch(console.error); // If a cursor marker was found for a newly-created page, place the // cursor there now (after navigateWithinPage so it doesn't get // overwritten by default positioning). if (markerIndex !== -1) { try { const pos = Math.max( 0, Math.min(markerIndex, this.client.editorView.state.doc.length), ); this.client.editorView.dispatch({ selection: { anchor: pos }, effects: [EditorView.scrollIntoView(pos, { y: "center" })], }); this.client.editorView.focus(); } catch (e) { console.error("Failed to set cursor at cursor marker:", e); } } } isDocumentEditor(): this is { documentEditor: DocumentEditor } & this { return this.documentEditor !== null; } switchToPageEditor() { if (!this.isDocumentEditor()) return; // Deliberately not awaiting this function as destroying & last-save can be handled in the background this.documentEditor.destroy(); // @ts-expect-error: This is there the hacked type-guard from isDocumentEditor fails this.documentEditor = null; this.client.rebuildEditorState(); document.getElementById("sb-editor")!.classList.remove("hide-cm"); } async switchToDocumentEditor(extension: string) { if (this.documentEditor) { // Deliberately not awaiting this function as destroying & last-save can be handled in the background this.documentEditor.destroy(); } // This is probably not the best way to hide the codemirror editor, but it works document.getElementById("sb-editor")!.classList.add("hide-cm"); this.documentEditor = new DocumentEditor( document.getElementById("sb-editor")!, this.client, (path, content) => { this.client.space .writeDocument(path, content) .then(async (meta) => { this.client.ui.viewDispatch({ type: "document-editor-saved" }); await this.client.dispatchAppEvent( "editor:documentSaved", path, meta, ); }) .catch(() => { this.client.ui.flashNotification( "Could not save document, retrying again in 10 seconds", "error", ); this.saveTimeout = setTimeout(this.save.bind(this), 10000); }); }, ); await this.documentEditor.init(extension); // We have to rebuild the editor state here to update the keymap correctly // This is a little hacky but any other solution would pose a larger rewrite this.client.rebuildEditorState(); this.client.editorView.contentDOM.blur(); } setEditorText(newText: string, shouldIsolateHistory = false) { const currentText = this.client.editorView.state.sliceDoc(); const allChanges = diffAndPrepareChanges(currentText, newText); this.client.editorView.dispatch({ changes: allChanges, annotations: shouldIsolateHistory ? isolateHistory.of("full") : undefined, }); } // Applies an external (storage-side) content change as a minimal, // cursor-preserving transaction: a 3-way merge against the last known // on-disk text so unsaved local edits survive. The transaction is marked // externalUpdate (skips the save-on-change handler) and isolated in undo // history, so a single undo reverts exactly this external change. // Returns whether a change was actually dispatched, so callers can skip // follow-up work (meta refresh, notifications) for a dropped/no-op apply. private applyExternalPatches( newText: string, modified: number | undefined, source = "external", ): boolean { if ( modified !== undefined && this.lastKnownDiskModified !== undefined && modified < this.lastKnownDiskModified ) { // Two reloads for this page were in flight and this older one resolved // last (e.g. a direct reloadEditor racing an SSE-triggered // reloadPageContent). Applying it would revert already-applied, // newer content. console.log( "Dropping stale external patch, older than already-applied content", { source, modified, lastKnownDiskModified: this.lastKnownDiskModified }, ); return false; } const currentText = this.client.editorView.state.sliceDoc(); const changes = computeExternalChanges( this.lastKnownDiskText, newText, currentText, ); this.lastKnownDiskText = newText; this.lastKnownDiskModified = modified; if (changes.empty) { return false; } this.client.editorView.dispatch({ changes, annotations: [ isolateHistory.of("full"), externalUpdate.of(true), externalSource.of(source), ], }); return true; } // Fetch the current page's content from storage and apply it as an // external patch -- the live-edit path (no full loadPage, no editor state // rebuild). Still refreshes enriched page meta and dispatches // editor:pageReloaded when a real change was applied, so plugs and // frontmatter-derived UI (page decorations) stay in sync with the file // that changed on disk, per the documented contract of that event. async reloadPageContent(source = "external"): Promise { const path = this.client.currentPath(); // Defensive, not currently reachable from the file:changed listener // (which already gates on isMarkdownPath before calling in): kept so // this method stays safe to call directly with an unchecked path, which // future callers (e.g. an SSE push handler) may do. if (!isMarkdownPath(path)) { return this.reloadEditor(); } const doc = await this.client.space.readPage(getNameFromPath(path)); // A write that finished during the read left the base behind what the read // returned, so merging now would re-insert our own text. After the read, // not before: it's writes overlapping the *fetch* that strand the base. // Not covered by ownWrite -- a write overlapping another space operation // dispatches no event of its own, and surfaces later via a probe instead. if (this.pendingWrite) { await this.pendingWrite; } // The user may have navigated to a different page while this fetch was // in flight. Applying now would diff/merge page-A content against // whatever page is currently open -- bail out rather than corrupt it. if (this.client.currentPath() !== path) { return; } const pageName = getNameFromPath(path); const applied = this.applyExternalPatches( doc.text, parsePageMetaLastModified(doc.meta.lastModified), source, ); if (!applied) { return; } await this.refreshCurrentPageMeta(pageName, doc.meta); // Note: dispatched asynchronously deliberately (not waiting for // results), matching the full-reload path in loadPage. this.client.eventHook .dispatchEvent("editor:pageReloaded", pageName, pageName) .catch(console.error); } // Re-fetches enriched (indexed) page meta -- e.g. frontmatter-derived page // decorations -- and pushes it into viewState.current.meta and the body's // decoration classes. Shared by loadPage and the live reloadPageContent // path so an external edit's frontmatter changes show up without // requiring navigation away and back. private async refreshCurrentPageMeta( pageName: string, fallbackMeta: PageMeta, ): Promise { if (!(await this.client.objectIndex.hasFullIndexCompleted())) { return; } try { const enrichedMeta = (await this.client.objectIndex.getObjectByRef( pageName, "page", pageName, )) ?? fallbackMeta; const body = document.body; body.removeAttribute("class"); if (enrichedMeta.pageDecoration?.cssClasses) { body.className = enrichedMeta.pageDecoration.cssClasses .join(" ") .replaceAll(/[^a-zA-Z0-9-_ ]/g, ""); } this.client.ui.viewDispatch({ type: "update-current-page-meta", meta: enrichedMeta, }); // Trigger editor re-render to update Lua widgets with the new metadata this.client.editorView.dispatch({}); } catch (e: any) { console.log( `There was an error trying to fetch enriched metadata: ${e.message}`, ); } } private navigateWithinPage(pageState: LocationState) { if (!isMarkdownPath(pageState.path)) return; // We can't use getOffsetFromRef here, because it is asyncronous. let pos: number | undefined; // Don't use getOffsetFromRef, so we can show error messages if (pageState.details?.type === "header") { const pageText = this.client.editorView.state.sliceDoc(); pos = getOffsetFromHeader( parseMarkdown(pageText), pageState.details.header, ); if (pos === -1) { this.client.ui.flashNotification( `Could not find header "${pageState.details.header}"`, "error", ); pos = undefined; } } else if (pageState.details?.type === "position") { pos = Math.max( 0, Math.min( pageState.details.pos, this.client.editorView.state.doc.length, ), ); } else if (pageState.details?.type === "linecolumn") { const pageText = this.client.editorView.state.sliceDoc(); pos = getOffsetFromLineColumn( pageText, pageState.details.line, pageState.details.column, ); } if (pos !== undefined) { this.client.editorView.dispatch({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: "start", yMargin: 5, }), }); // If a position was specified, we bail out and ignore any cached state return; } let adjustedPosition = false; // Was a particular scroll position persisted? if (pageState.scrollTop && pageState.scrollTop > 0) { this.restoreScrollPosition(pageState.scrollTop); adjustedPosition = true; } // Was a particular cursor/selection set? if (pageState.selection?.anchor) { this.client.editorView.dispatch({ selection: pageState.selection, }); adjustedPosition = true; } // If not: just put the cursor at the top of the page, right after the frontmatter if (!adjustedPosition && this.scrollRestoreCleanup) { // No scroll position to restore, cancel any pending restoration this.scrollRestoreCleanup(); this.scrollRestoreCleanup = undefined; } if (!adjustedPosition) { // Somewhat ad-hoc way to determine if the document contains frontmatter and if so, putting the cursor _after it_. const pageText = this.client.editorView.state.sliceDoc(); // Default the cursor to be at position 0 let initialCursorPos = 0; const match = frontMatterRegex.exec(pageText); if (match) { // Frontmatter found, put cursor after it initialCursorPos = match[0].length; } // By default scroll to the top this.client.editorView.scrollDOM.scrollTop = 0; this.client.editorView.dispatch({ selection: { anchor: initialCursorPos }, // And then scroll down if required scrollIntoView: true, }); } } /** * Restores scroll position after page navigation, accounting for async widget * rendering that may change the page layout. Uses a MutationObserver to * re-apply the scroll position whenever the DOM changes (e.g. widgets finish * rendering), with a timeout to stop after the layout has stabilized. */ private restoreScrollPosition(scrollTop: number) { // Cancel any previous scroll restoration if (this.scrollRestoreCleanup) { this.scrollRestoreCleanup(); } const scrollDOM = this.client.editorView.scrollDOM; let settled = false; const applyScroll = () => { if (settled) return; // Skip no-op writes so the observer below doesn't fight CodeMirror's // own scroll-anchoring on widget-unrelated mutations. if (scrollDOM.scrollTop !== scrollTop) { scrollDOM.scrollTop = scrollTop; } }; // Hide the editor until CodeMirror has done its first measure pass: a // sync set after setState gets clamped to 0 because scrollHeight isn't // established yet, and without hiding the browser paints once at 0. scrollDOM.style.visibility = "hidden"; applyScroll(); requestAnimationFrame(() => { applyScroll(); scrollDOM.style.visibility = ""; }); // Watch for DOM mutations (widget rendering) and re-apply scroll position const observer = new MutationObserver(() => { applyScroll(); }); observer.observe(scrollDOM, { childList: true, subtree: true, attributes: true, // Watch for style changes (widget height changes) attributeFilter: ["style", "class"], }); // Also handle user scroll: if the user manually scrolls, stop restoring const onUserScroll = () => { cleanup(); }; // Delay attaching the scroll listener so our own scroll assignments don't // trigger it const scrollListenerTimer = setTimeout(() => { scrollDOM.addEventListener("scroll", onUserScroll, { once: true }); }, 100); // Stop restoring after a reasonable timeout (widgets should be done by then) const timeout = setTimeout(() => { cleanup(); }, 2000); const cleanup = () => { if (settled) return; settled = true; scrollDOM.style.visibility = ""; observer.disconnect(); clearTimeout(timeout); clearTimeout(scrollListenerTimer); scrollDOM.removeEventListener("scroll", onUserScroll); if (this.scrollRestoreCleanup === cleanup) { this.scrollRestoreCleanup = undefined; } }; this.scrollRestoreCleanup = cleanup; } }