576 lines
18 KiB
TypeScript
576 lines
18 KiB
TypeScript
import { EditorView } from "@codemirror/view";
|
|
import { isolateHistory } from "@codemirror/commands";
|
|
import { throttle } from "@silverbulletmd/silverbullet/lib/async";
|
|
import {
|
|
getNameFromPath,
|
|
getOffsetFromHeader,
|
|
getOffsetFromLineColumn,
|
|
isMarkdownPath,
|
|
} 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>;
|
|
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");
|
|
}
|
|
}
|
|
|
|
async loadDocumentEditor(locationState: LocationState) {
|
|
const path = locationState.path;
|
|
if (isMarkdownPath(path)) throw Error("This is a markdown path");
|
|
|
|
const previousPath = this.client.ui.viewState.current?.path;
|
|
const loadingDifferentPath = previousPath
|
|
? previousPath !== path
|
|
: // Always load as different editor if editor is loaded from scratch
|
|
true;
|
|
|
|
if (previousPath) {
|
|
this.client.space.unwatchFile(previousPath);
|
|
await this.save(true);
|
|
await this.client.objectIndex.awaitIndexQueueDrain();
|
|
}
|
|
|
|
// This can throw, but that will be catched and handled upstream.
|
|
const doc = await this.client.space.readDocument(path);
|
|
|
|
// Create the document editor if it doesn't already exist
|
|
if (
|
|
!this.isDocumentEditor() ||
|
|
this.documentEditor.extension !== doc.meta.extension
|
|
) {
|
|
try {
|
|
await this.switchToDocumentEditor(doc.meta.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}`,
|
|
!previousPath,
|
|
);
|
|
}
|
|
|
|
throw e;
|
|
}
|
|
|
|
if (!this.isDocumentEditor()) {
|
|
throw new Error("Problem setting up document editor");
|
|
}
|
|
}
|
|
|
|
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 = this.client.ui.viewState.current?.path;
|
|
const loadingDifferentPath = previousPath
|
|
? previousPath !== path
|
|
: // Always load as different page if page is loaded from scratch
|
|
true;
|
|
const pageName = getNameFromPath(path);
|
|
|
|
if (previousPath) {
|
|
this.client.space.unwatchFile(previousPath);
|
|
await this.save(true);
|
|
await this.client.objectIndex.awaitIndexQueueDrain();
|
|
}
|
|
|
|
// 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) {
|
|
setTimeout(() => {
|
|
this.client.editorView.scrollDOM.scrollTop = 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) {
|
|
// 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,
|
|
});
|
|
}
|
|
}
|
|
}
|