Currently when an inintial index takes a long time, navigation will basically be blocked until it's finished which isn't great. This special-cases that scenario and doesn't block (at the cost of possibly not showing up to date widgets on the target page).
659 lines
21 KiB
TypeScript
659 lines
21 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 } from "./codemirror/editor_state.ts";
|
|
import { diffAndPrepareChanges } from "./codemirror/cm_util.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;
|
|
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(),
|
|
);
|
|
this.client.space
|
|
.writePage(
|
|
this.client.currentName(),
|
|
this.client.editorView.state.sliceDoc(0),
|
|
)
|
|
.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,
|
|
);
|
|
});
|
|
}
|
|
|
|
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.
|
|
if (await this.client.objectIndex.hasFullIndexCompleted()) {
|
|
await this.client.objectIndex.awaitIndexQueueDrain();
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
await this.client.pageMetaAugmenter.setAugmentation(pageName, {
|
|
lastOpened: Date.now(),
|
|
});
|
|
|
|
this.client.ui.viewDispatch({
|
|
type: "page-loaded",
|
|
meta: doc.meta,
|
|
path: path,
|
|
});
|
|
|
|
// Fetch the meta which includes the possibly indexed stuff, like page
|
|
// decorations
|
|
if (await this.client.objectIndex.hasFullIndexCompleted()) {
|
|
try {
|
|
const enrichedMeta =
|
|
(await this.client.objectIndex.getObjectByRef(
|
|
pageName,
|
|
"page",
|
|
pageName,
|
|
)) ?? doc.meta;
|
|
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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") {
|
|
const editorState = createEditorState(
|
|
this.client,
|
|
pageName,
|
|
doc.text,
|
|
doc.meta.perm === "ro",
|
|
);
|
|
this.client.editorView.setState(editorState);
|
|
} else {
|
|
// Just apply minimal patches so that the cursor is preserved
|
|
this.setEditorText(doc.text, true);
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
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) {
|
|
scrollDOM.scrollTop = scrollTop;
|
|
}
|
|
};
|
|
|
|
// Apply immediately on the next tick (as before)
|
|
setTimeout(applyScroll);
|
|
|
|
// 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;
|
|
observer.disconnect();
|
|
clearTimeout(timeout);
|
|
clearTimeout(scrollListenerTimer);
|
|
scrollDOM.removeEventListener("scroll", onUserScroll);
|
|
if (this.scrollRestoreCleanup === cleanup) {
|
|
this.scrollRestoreCleanup = undefined;
|
|
}
|
|
};
|
|
|
|
this.scrollRestoreCleanup = cleanup;
|
|
}
|
|
}
|