Files
plainleaf/client/content_manager.ts
T

852 lines
29 KiB
TypeScript

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<typeof setTimeout>;
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<void>;
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<void> {
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<void>((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<void> {
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<void> {
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;
}
}