From e61a447d98fb8600dfdc3c85cced65e38ecf4845 Mon Sep 17 00:00:00 2001 From: MrMugame <40832361+MrMugame@users.noreply.github.com> Date: Thu, 6 Mar 2025 15:11:17 +0100 Subject: [PATCH] Implementing #913 (#1230) Implemented custom editors --- .gitignore | 2 +- common/common_system.ts | 2 + common/space.ts | 63 +-- common/space_index.ts | 2 +- common/space_lua.ts | 1 + common/spaces/evented_space_primitives.ts | 6 +- lib/command.ts | 13 + lib/manifest.ts | 5 + plug-api/lib/page_ref.test.ts | 67 ++- plug-api/lib/page_ref.ts | 50 ++- plug-api/syscalls/editor.ts | 25 +- plug-api/syscalls/space.ts | 52 +-- plug-api/types.ts | 13 +- plugs/core/core.plug.yaml | 2 +- plugs/editor/complete.ts | 10 +- plugs/editor/document.ts | 16 + plugs/editor/editor.plug.yaml | 46 +++ plugs/editor/editor.ts | 4 + plugs/editor/navigate.ts | 42 +- plugs/editor/page.ts | 4 +- plugs/editor/upload.ts | 60 +-- plugs/index/command.ts | 4 +- plugs/index/{attachment.ts => document.ts} | 10 +- plugs/index/index.plug.yaml | 22 +- plugs/index/page_links.ts | 8 +- plugs/index/refactor.ts | 80 ++-- plugs/markdown/markdown.plug.yaml | 1 + plugs/markdown/markdown_render.ts | 20 +- plugs/plug-manager/plugmanager.ts | 6 +- plugs/query/query.plug.yaml | 3 + plugs/search/search.ts | 2 +- plugs/share/share.plug.yaml | 1 + plugs/tasks/tasks.plug.yaml | 3 + plugs/template/page.ts | 5 +- server/http_server.ts | 54 ++- server/server_system.ts | 4 +- server/syscalls/space.ts | 30 +- type/client.ts | 1 + type/config.ts | 4 +- web/client.ts | 430 +++++++++++++++++--- web/client_system.ts | 5 + web/cm_plugins/editor_paste.ts | 30 +- web/cm_plugins/lua_directive.ts | 5 +- web/cm_plugins/util.ts | 3 +- web/cm_plugins/widget_util.ts | 1 + web/components/filter.tsx | 2 +- web/components/page_navigator.tsx | 216 ++++++---- web/constants.ts | 2 +- web/document_editor.ts | 206 ++++++++++ web/document_editor_skeleton.ts | 96 +++++ web/editor_state.ts | 56 +-- web/editor_ui.tsx | 95 +++-- web/hooks/document_editor.ts | 76 ++++ web/navigator.ts | 105 +++-- web/reducer.ts | 73 +++- web/service_worker.ts | 14 +- web/styles/colors.scss | 7 +- web/styles/main.scss | 11 + web/styles/theme.scss | 4 + web/sync_service.ts | 24 +- web/syscalls/editor.ts | 35 +- web/syscalls/space.ts | 32 +- web/type.ts | 30 +- website/API/space.md | 36 +- website/Attachments.md | 36 -- website/Document Editor.md | 32 ++ website/Documents.md | 37 ++ website/Library/Core/Page/Space Overview.md | 2 +- website/Manual.md | 2 +- website/SETTINGS.md | 2 +- website/Spaces.md | 2 +- website/Transclusions.md | 4 +- 72 files changed, 1838 insertions(+), 616 deletions(-) create mode 100644 plugs/editor/document.ts rename plugs/index/{attachment.ts => document.ts} (50%) create mode 100644 web/document_editor.ts create mode 100644 web/document_editor_skeleton.ts create mode 100644 web/hooks/document_editor.ts delete mode 100644 website/Attachments.md create mode 100644 website/Document Editor.md create mode 100644 website/Documents.md diff --git a/.gitignore b/.gitignore index d181f39a..32e2e9ec 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,4 @@ test_space silverbullet deploy.json *.generated -tmp_playground \ No newline at end of file +tmp_playground diff --git a/common/common_system.ts b/common/common_system.ts index 59f9c340..00f76e69 100644 --- a/common/common_system.ts +++ b/common/common_system.ts @@ -13,6 +13,7 @@ import type { SlashCommandHook } from "../web/hooks/slash_command.ts"; import type { DataStoreMQ } from "$lib/data/mq.datastore.ts"; import type { ParseTree } from "../plug-api/lib/tree.ts"; import { SpaceLuaEnvironment } from "$common/space_lua.ts"; +import type { DocumentEditorHook } from "../web/hooks/document_editor.ts"; const mqTimeout = 10000; // 10s const mqTimeoutRetry = 3; @@ -26,6 +27,7 @@ export abstract class CommonSystem { namespaceHook!: PlugNamespaceHook; codeWidgetHook!: CodeWidgetHook; panelWidgetHook!: PanelWidgetHook; + documentEditorHook!: DocumentEditorHook; readonly allKnownFiles = new Set(); readonly spaceScriptCommands = new Map(); diff --git a/common/space.ts b/common/space.ts index 72710025..3165de4c 100644 --- a/common/space.ts +++ b/common/space.ts @@ -1,7 +1,7 @@ import type { SpacePrimitives } from "$common/spaces/space_primitives.ts"; import { plugPrefix } from "$common/spaces/constants.ts"; -import type { AttachmentMeta, FileMeta, PageMeta } from "../plug-api/types.ts"; +import type { DocumentMeta, FileMeta, PageMeta } from "../plug-api/types.ts"; import type { EventHook } from "./hooks/event.ts"; import { safeRun } from "../lib/async.ts"; import { localDateString } from "$lib/dates.ts"; @@ -11,7 +11,7 @@ const pageWatchInterval = 5000; export class Space { // We do watch files in the background to detect changes // This set of pages should only ever contain 1 page - watchedPages = new Set(); + watchedFiles = new Set(); watchInterval?: number; // private initialPageListLoad = true; @@ -21,10 +21,10 @@ export class Space { readonly spacePrimitives: SpacePrimitives, eventHook: EventHook, ) { - eventHook.addLocalListener("page:deleted", (pageName: string) => { - if (this.watchedPages.has(pageName)) { - // Stop watching deleted pages already - this.watchedPages.delete(pageName); + eventHook.addLocalListener("file:deleted", (fileName: string) => { + if (this.watchedFiles.has(fileName)) { + // Stop watching deleted files already + this.watchedFiles.delete(fileName); } }); setTimeout(() => { @@ -35,7 +35,7 @@ export class Space { public async updatePageList() { // The only reason to do this is to trigger events - await this.fetchPageList(); + await this.spacePrimitives.fetchFileList(); } async deletePage(name: string): Promise { @@ -98,11 +98,11 @@ export class Space { .map(fileMetaToPageMeta); } - async fetchAttachmentList(): Promise { + async fetchDocumentList(): Promise { return (await this.deduplicatedFileList()).flatMap((fileMeta) => !this.isListedPage(fileMeta) && !fileMeta.name.endsWith(".plug.js") - ? [fileMetaToAttachmentMeta(fileMeta)] + ? [fileMetaToDocumentMeta(fileMeta)] : [] ); } @@ -124,34 +124,34 @@ export class Space { } /** - * Reads an attachment - * @param name path of the attachment + * Reads a document + * @param name path of the document * @returns */ - async readAttachment( + async readDocument( name: string, - ): Promise<{ data: Uint8Array; meta: AttachmentMeta }> { + ): Promise<{ data: Uint8Array; meta: DocumentMeta }> { const file = await this.spacePrimitives.readFile(name); - return { data: file.data, meta: fileMetaToAttachmentMeta(file.meta) }; + return { data: file.data, meta: fileMetaToDocumentMeta(file.meta) }; } - async getAttachmentMeta(name: string): Promise { - return fileMetaToAttachmentMeta( + async getDocumentMeta(name: string): Promise { + return fileMetaToDocumentMeta( await this.spacePrimitives.getFileMeta(name), ); } - async writeAttachment( + async writeDocument( name: string, data: Uint8Array, selfUpdate?: boolean, - ): Promise { - return fileMetaToAttachmentMeta( + ): Promise { + return fileMetaToDocumentMeta( await this.spacePrimitives.writeFile(name, data, selfUpdate), ); } - deleteAttachment(name: string): Promise { + deleteDocument(name: string): Promise { return this.spacePrimitives.deleteFile(name); } @@ -166,8 +166,8 @@ export class Space { if (this.saving) { return; } - for (const pageName of this.watchedPages) { - await this.getPageMeta(pageName); + for (const fileName of this.watchedFiles) { + await this.spacePrimitives.getFileMeta(fileName); } }); }, pageWatchInterval); @@ -179,12 +179,12 @@ export class Space { } } - watchPage(pageName: string) { - this.watchedPages.add(pageName); + watchFile(fileName: string) { + this.watchedFiles.add(fileName); } - unwatchPage(pageName: string) { - this.watchedPages.delete(pageName); + unwatchFile(fileName: string) { + this.watchedFiles.delete(fileName); } } @@ -205,19 +205,20 @@ export function fileMetaToPageMeta(fileMeta: FileMeta): PageMeta { } } -export function fileMetaToAttachmentMeta( +export function fileMetaToDocumentMeta( fileMeta: FileMeta, -): AttachmentMeta { +): DocumentMeta { try { return { ...fileMeta, ref: fileMeta.name, - tag: "attachment", + tag: "document", created: localDateString(new Date(fileMeta.created)), lastModified: localDateString(new Date(fileMeta.lastModified)), - } as AttachmentMeta; + extension: fileMeta.name.split(".").pop()?.toLowerCase(), + } as DocumentMeta; } catch (e) { - console.error("Failed to convert fileMeta to attachmentMeta", fileMeta, e); + console.error("Failed to convert fileMeta to documentMeta", fileMeta, e); throw e; } } diff --git a/common/space_index.ts b/common/space_index.ts index f0404ee7..fa63bd2c 100644 --- a/common/space_index.ts +++ b/common/space_index.ts @@ -4,7 +4,7 @@ import type { System } from "$lib/plugos/system.ts"; const indexVersionKey = ["$indexVersion"]; // Bump this one every time a full reindex is needed -const desiredIndexVersion = 6; +const desiredIndexVersion = 7; let indexOngoing = false; diff --git a/common/space_lua.ts b/common/space_lua.ts index 61f07af9..2eca19d4 100644 --- a/common/space_lua.ts +++ b/common/space_lua.ts @@ -74,6 +74,7 @@ export function resolveASTReference(ctx?: ASTCtx): PageRef | null { } const pageRef = parsePageRef(ctx.ref); return { + kind: "page", page: pageRef.page, pos: (pageRef.pos as number) + "```space-lua\n".length + ctx.from!, }; diff --git a/common/spaces/evented_space_primitives.ts b/common/spaces/evented_space_primitives.ts index 7d69a16a..70acca41 100644 --- a/common/spaces/evented_space_primitives.ts +++ b/common/spaces/evented_space_primitives.ts @@ -27,15 +27,15 @@ export class EventedSpacePrimitives implements SpacePrimitives { private eventHook: EventHook, private spaceSnapshot: Record = {}, ) { - // Translate file change events for attachments into attachment:index events + // Translate file change events for documents into document:index events this.eventHook.addLocalListener( "file:changed", async ( name: string, ) => { if (!name.endsWith(".md") && !name.startsWith(plugPrefix)) { - // Not a page nor plug, so an attachment! - await this.dispatchEvent("attachment:index", name); + // Not a page nor plug, so a document! + await this.dispatchEvent("document:index", name); } }, ); diff --git a/lib/command.ts b/lib/command.ts index f483a0f1..beffe100 100644 --- a/lib/command.ts +++ b/lib/command.ts @@ -14,6 +14,7 @@ export type CommandDef = { hide?: boolean; requireMode?: "rw" | "ro"; + requireEditor?: "any" | "page" | "notpage" | string; }; export type AppCommand = { @@ -29,3 +30,15 @@ export type SlashCommand = { export type CommandHookEvents = { commandsUpdated(commandMap: Map): void; }; + +export function isValidEditor( + currentEditor: string | undefined, + requiredEditor: string | undefined, +): boolean { + return (requiredEditor === undefined) || + (currentEditor === undefined && + requiredEditor === "page") || + (requiredEditor === "any") || + (currentEditor === requiredEditor) || + (currentEditor !== undefined && requiredEditor === "notpage"); +} diff --git a/lib/manifest.ts b/lib/manifest.ts index 5d34c3f2..9eec2f61 100644 --- a/lib/manifest.ts +++ b/lib/manifest.ts @@ -66,6 +66,10 @@ export type SyscallHookT = { syscall?: string; }; +export type DocumentEditorT = { + editor?: string | string[]; +}; + /** Silverbullet hooks give plugs access to silverbullet core systems. * * Hooks are associated with typescript functions through a manifest file. @@ -83,6 +87,7 @@ export type SilverBulletHooks = & PanelWidgetT & EndpointHookT & PlugNamespaceHookT + & DocumentEditorT & SyscallHookT; /** A plug manifest configures hooks, declares syntax extensions, and describes plug metadata. diff --git a/plug-api/lib/page_ref.test.ts b/plug-api/lib/page_ref.test.ts index 35abf247..fc56ee43 100644 --- a/plug-api/lib/page_ref.test.ts +++ b/plug-api/lib/page_ref.test.ts @@ -2,6 +2,7 @@ import { decodePageURI, encodePageRef, encodePageURI, + parseLocationRef, parsePageRef, validatePageName, } from "./page_ref.ts"; @@ -9,52 +10,82 @@ import { assertEquals, AssertionError, assertThrows } from "@std/assert"; Deno.test("Page utility functions", () => { // Base cases - assertEquals(parsePageRef("foo"), { page: "foo" }); - assertEquals(parsePageRef("[[foo]]"), { page: "foo" }); - assertEquals(parsePageRef("foo@1"), { page: "foo", pos: 1 }); - assertEquals(parsePageRef("foo@L1"), { + assertEquals(parseLocationRef("foo"), { kind: "page", page: "foo" }); + assertEquals(parseLocationRef("[[foo]]"), { kind: "page", page: "foo" }); + assertEquals(parseLocationRef("foo@1"), { + kind: "page", + page: "foo", + pos: 1, + }); + assertEquals(parseLocationRef("foo@L1"), { + kind: "page", page: "foo", pos: { line: 1, column: 1 }, }); - assertEquals(parsePageRef("foo@L2C3"), { + assertEquals(parseLocationRef("foo@L2C3"), { + kind: "page", page: "foo", pos: { line: 2, column: 3 }, }); - assertEquals(parsePageRef("foo@l2c3"), { + assertEquals(parseLocationRef("foo@l2c3"), { + kind: "page", page: "foo", pos: { line: 2, column: 3 }, }); - assertEquals(parsePageRef("foo$bar"), { page: "foo", anchor: "bar" }); - assertEquals(parsePageRef("foo#My header"), { + assertEquals(parseLocationRef("foo$bar"), { + kind: "page", + page: "foo", + anchor: "bar", + }); + assertEquals(parseLocationRef("foo#My header"), { + kind: "page", page: "foo", header: "My header", }); - assertEquals(parsePageRef("foo$bar@1"), { + assertEquals(parseLocationRef("foo$bar@1"), { + kind: "page", page: "foo", anchor: "bar", pos: 1, }); + assertEquals(parseLocationRef("foo.pdf"), { + kind: "document", + page: "foo.pdf", + }); // Meta page - assertEquals(parsePageRef("^foo"), { page: "foo", meta: true }); + assertEquals(parseLocationRef("^foo"), { + kind: "page", + page: "foo", + meta: true, + }); // Edge cases - assertEquals(parsePageRef(""), { page: "" }); - assertEquals(parsePageRef("user@domain.com"), { page: "user@domain.com" }); + assertEquals(parseLocationRef(""), { kind: "page", page: "" }); + assertEquals(parsePageRef("user@domain.com"), { + kind: "page", + page: "user@domain.com", + }); // Encoding - assertEquals(encodePageRef({ page: "foo" }), "foo"); - assertEquals(encodePageRef({ page: "foo", pos: 10 }), "foo@10"); + assertEquals(encodePageRef({ kind: "page", page: "foo" }), "foo"); + assertEquals(encodePageRef({ kind: "page", page: "foo", pos: 10 }), "foo@10"); assertEquals( - encodePageRef({ page: "foo", pos: { line: 10, column: 1 } }), + encodePageRef({ kind: "page", page: "foo", pos: { line: 10, column: 1 } }), "foo@L10", ); assertEquals( - encodePageRef({ page: "foo", pos: { line: 10, column: 5 } }), + encodePageRef({ kind: "page", page: "foo", pos: { line: 10, column: 5 } }), "foo@L10C5", ); - assertEquals(encodePageRef({ page: "foo", anchor: "bar" }), "foo$bar"); - assertEquals(encodePageRef({ page: "foo", header: "bar" }), "foo#bar"); + assertEquals( + encodePageRef({ kind: "page", page: "foo", anchor: "bar" }), + "foo$bar", + ); + assertEquals( + encodePageRef({ kind: "page", page: "foo", header: "bar" }), + "foo#bar", + ); // Page name validation diff --git a/plug-api/lib/page_ref.ts b/plug-api/lib/page_ref.ts index 2dbd37a1..7475e04c 100644 --- a/plug-api/lib/page_ref.ts +++ b/plug-api/lib/page_ref.ts @@ -2,6 +2,7 @@ * Represents a reference to a page, with optional position, anchor and header. */ export type PageRef = { + kind: "page"; page: string; pos?: number | { line: number; column: number }; anchor?: string; @@ -9,6 +10,17 @@ export type PageRef = { meta?: boolean; }; +/** + * Represents a reference to an document. + */ +export type DocumentRef = { + kind: "document"; + // This isn't really referring to a page, but this saves us from a lot of name changing + page: string; +}; + +export type LocationRef = PageRef | DocumentRef; + /** * Checks if a name looks like a full path (with a file extension), is not a conflicted file and not a search page. */ @@ -22,17 +34,21 @@ export function looksLikePathWithExtension(name: string): boolean { */ export function validatePageName(name: string) { // Page can not be empty and not end with a file extension (e.g. "bla.md") - if (name === "") { - throw new Error("Page name can not be empty"); - } - if (name.startsWith(".")) { - throw new Error("Page name cannot start with a '.'"); - } + validatePath(name); if (looksLikePathWithExtension(name)) { throw new Error("Page name can not end with a file extension"); } } +export function validatePath(path: string) { + if (path === "") { + throw new Error("Path can not be empty"); + } + if (path.startsWith(".")) { + throw new Error("Path cannot start with a '.'"); + } +} + const posRegex = /@(\d+)$/; const linePosRegex = /@[Ll](\d+)(?:[Cc](\d+))?$/; // column is optional, implicit 1 const anchorRegex = /\$([a-zA-Z\.\-\/]+[\w\.\-\/]*)$/; @@ -48,7 +64,7 @@ export function parsePageRef(name: string): PageRef { if (name.startsWith("[[") && name.endsWith("]]")) { name = name.slice(2, -2); } - const pageRef: PageRef = { page: name }; + const pageRef: PageRef = { kind: "page", page: name }; if (pageRef.page.startsWith("^")) { // A caret prefix means we're looking for a meta page, but that doesn't matter for most use cases pageRef.page = pageRef.page.slice(1); @@ -82,6 +98,16 @@ export function parsePageRef(name: string): PageRef { return pageRef; } +export function parseDocumentRef(name: string): DocumentRef { + return { kind: "document", page: name }; +} + +export function parseLocationRef(name: string): LocationRef { + return looksLikePathWithExtension(name) + ? parseDocumentRef(name) + : parsePageRef(name); +} + /** * The inverse of parsePageRef, encodes a PageRef object into a string. * @param pageRef the page reference to encode @@ -108,6 +134,16 @@ export function encodePageRef(pageRef: PageRef): string { return name; } +export function encodeDocumentRef(documentRef: DocumentRef): string { + return documentRef.page; +} + +export function encodeLocationRef(locationRef: LocationRef): string { + return locationRef.kind === "page" + ? encodePageRef(locationRef) + : encodeDocumentRef(locationRef); +} + /** * Translate line and column number (counting from 1) to position in text (counting from 0) */ diff --git a/plug-api/syscalls/editor.ts b/plug-api/syscalls/editor.ts index 1dc82492..46526da0 100644 --- a/plug-api/syscalls/editor.ts +++ b/plug-api/syscalls/editor.ts @@ -1,6 +1,6 @@ import type { PageMeta, UploadFile } from "../types.ts"; import { syscall } from "../syscall.ts"; -import type { PageRef } from "../lib/page_ref.ts"; +import type { LocationRef } from "../lib/page_ref.ts"; import type { FilterOption } from "@silverbulletmd/silverbullet/type/client"; /** @@ -25,6 +25,23 @@ export function getCurrentPageMeta(): Promise { return syscall("editor.getCurrentPageMeta"); } +/** + * Returns the name of the page or document currently open in the editor. + * @param extension If true returns page paths with the their `.md` extension + * @returns the current page path + */ +export function getCurrentPath(extension: boolean = false): Promise { + return syscall("editor.getCurrentPath", extension); +} + +/** + * Returns the name of the currently open editor. + * @returns the name of the current editor + */ +export function getCurrentEditor(): Promise { + return syscall("editor.getCurrentEditor"); +} + /** * Returns the full text of the currently open page */ @@ -80,11 +97,11 @@ export function save(): Promise { * @param newWindow whether to open the page in a new window */ export function navigate( - pageRef: PageRef, + locationRef: LocationRef, replaceState = false, newWindow = false, ): Promise { - return syscall("editor.navigate", pageRef, replaceState, newWindow); + return syscall("editor.navigate", locationRef, replaceState, newWindow); } /** @@ -92,7 +109,7 @@ export function navigate( * @param mode the mode to open the navigator in */ export function openPageNavigator( - mode: "page" | "meta" | "all" = "page", + mode: "page" | "meta" | "document" | "all" = "page", ): Promise { return syscall("editor.openPageNavigator", mode); } diff --git a/plug-api/syscalls/space.ts b/plug-api/syscalls/space.ts index 5a22f130..e2d77df2 100644 --- a/plug-api/syscalls/space.ts +++ b/plug-api/syscalls/space.ts @@ -1,8 +1,8 @@ import { syscall } from "../syscall.ts"; -import type { AttachmentMeta, FileMeta, PageMeta } from "../types.ts"; +import type { DocumentMeta, FileMeta, PageMeta } from "../types.ts"; /** - * Exposes the space with its pages, attachments and plugs. + * Exposes the space with its pages, documents and plugs. * @module */ @@ -62,58 +62,58 @@ export function listPlugs(): Promise { } /** - * Lists all attachments in the space (all files not ending in .md). - * @returns a list of all attachments in the space represented as AttachmentMeta objects + * Lists all documents in the space (all files not ending in .md). + * @returns a list of all documents in the space represented as DocumentMeta objects */ -export function listAttachments(): Promise { - return syscall("space.listAttachments"); +export function listDocuments(): Promise { + return syscall("space.listDocuments"); } /** - * Get metadata for an attachment in the space. - * @param name the path of the attachment to get metadata for - * @returns the metadata for the attachment + * Get metadata for an document in the space. + * @param name the path of the document to get metadata for + * @returns the metadata for the document */ -export function getAttachmentMeta(name: string): Promise { - return syscall("space.getAttachmentMeta", name); +export function getDocumentMeta(name: string): Promise { + return syscall("space.getDocumentMeta", name); } /** - * Read an attachment from the space - * @param name path of the attachment to read - * @returns the attachment data encoded as a data URL + * Read an document from the space + * @param name path of the document to read + * @returns the document data as a UInt8Array */ -export function readAttachment( +export function readDocument( name: string, ): Promise { - return syscall("space.readAttachment", name); + return syscall("space.readDocument", name); } /** - * Writes an attachment to the space - * @param name path of the attachment to write + * Writes a document to the space + * @param name path of the document to write * @param data data itself * @returns */ -export function writeAttachment( +export function writeDocument( name: string, data: Uint8Array, -): Promise { - return syscall("space.writeAttachment", name, data); +): Promise { + return syscall("space.writeDocument", name, data); } /** - * Deletes an attachment from the space - * @param name path of the attachment to delete + * Deletes a document from the space + * @param name path of the document to delete */ -export function deleteAttachment(name: string): Promise { - return syscall("space.deleteAttachment", name); +export function deleteDocument(name: string): Promise { + return syscall("space.deleteDocument", name); } // Lower level-file operations /** - * List all files in the space (pages, attachments and plugs). + * List all files in the space (pages, documents and plugs). * @returns a list of all files in the space represented as FileMeta objects */ export function listFiles(): Promise { diff --git a/plug-api/types.ts b/plug-api/types.ts index 33e15b98..0acafa66 100644 --- a/plug-api/types.ts +++ b/plug-api/types.ts @@ -32,7 +32,7 @@ export type PageDecoration = { renderWidgets?: boolean; // Defaults to true }; -export type AttachmentMeta = ObjectValue< +export type DocumentMeta = ObjectValue< { name: string; contentType: string; @@ -40,6 +40,7 @@ export type AttachmentMeta = ObjectValue< lastModified: string; size: number; perm: "ro" | "rw"; + extension: string; } & Record >; @@ -176,6 +177,14 @@ export type CodeWidgetButton = { invokeFunction: string[]; }; +// Document editors stuff +export type DocumentEditorCallback = () => Promise; + +export type DocumentEditorContent = { + html: string; + script?: string; +}; + export type LintDiagnostic = { from: number; to: number; @@ -201,6 +210,8 @@ export type AppEvent = | "editor:pageReloaded" | "editor:pageSaving" | "editor:pageSaved" + | "editor:documentSaving" + | "editor:documentSaved" | "editor:modeswitch" | "plugs:loaded" | "editor:pageModified"; diff --git a/plugs/core/core.plug.yaml b/plugs/core/core.plug.yaml index b30a4fa2..de49cece 100644 --- a/plugs/core/core.plug.yaml +++ b/plugs/core/core.plug.yaml @@ -126,7 +126,7 @@ config: indexPage: type: string format: page-ref - maximumAttachmentSize: + maximumDocumentSize: type: number nullable: true objectDecorators: diff --git a/plugs/editor/complete.ts b/plugs/editor/complete.ts index e8c4adce..dfb006fb 100644 --- a/plugs/editor/complete.ts +++ b/plugs/editor/complete.ts @@ -1,6 +1,6 @@ import type { - AttachmentMeta, CompleteEvent, + DocumentMeta, FileMeta, PageMeta, QueryExpression, @@ -36,7 +36,7 @@ export async function pageComplete(completeEvent: CompleteEvent) { const prefix = match[1]; - let allPages: (PageMeta | AttachmentMeta)[] = []; + let allPages: (PageMeta | DocumentMeta)[] = []; if (prefix.startsWith("^")) { // A carrot prefix means we're looking for a meta page @@ -73,9 +73,9 @@ export async function pageComplete(completeEvent: CompleteEvent) { queryObjects("page", { filter: ["not", isMetaPageFilter], }, 5), - // All attachments - queryObjects("attachment", { - // All attachment that do not start with a _ (internal attachments) + // All documents + queryObjects("document", { + // All documents that do not start with a _ (internal documents) filter: ["!=~", ["attr", "name"], ["regexp", "^_", ""]], }, 5), // And all links to non-existing pages (to augment the existing ones) diff --git a/plugs/editor/document.ts b/plugs/editor/document.ts new file mode 100644 index 00000000..3846daf9 --- /dev/null +++ b/plugs/editor/document.ts @@ -0,0 +1,16 @@ +import { editor, space } from "@silverbulletmd/silverbullet/syscalls"; + +export async function deleteDocument() { + const documentName = await editor.getCurrentPath(); + if ( + !await editor.confirm( + `Are you sure you would like to delete ${documentName}?`, + ) + ) { + return; + } + console.log("Navigating to index page"); + await editor.navigate({ kind: "page", page: "" }); + console.log("Deleting document from space"); + await space.deleteDocument(documentName); +} diff --git a/plugs/editor/editor.plug.yaml b/plugs/editor/editor.plug.yaml index e5a2d100..760ad5ed 100644 --- a/plugs/editor/editor.plug.yaml +++ b/plugs/editor/editor.plug.yaml @@ -107,6 +107,13 @@ functions: key: "Ctrl-Shift-k" mac: "Cmd-Shift-k" + openDocumentNavigator: + path: editor.ts:openDocumentNavigator + command: + name: "Navigate: Document Picker" + key: "Ctrl-o" + mac: "Cmd-o" + openAllNavigator: path: editor.ts:openAllNavigator command: @@ -118,11 +125,13 @@ functions: command: name: "Page: Delete" requireMode: rw + requireEditor: page copyPage: path: "./page.ts:copyPage" command: name: "Page: Copy" requireMode: rw + requireEditor: page # Completion pageComplete: @@ -147,6 +156,7 @@ functions: name: "Navigate: To This Page" key: Ctrl-Enter mac: Cmd-Enter + requireEditor: page clickNavigate: path: "./navigate.ts:clickNavigate" events: @@ -168,10 +178,12 @@ functions: path: "./editor.ts:moveToPosCommand" command: name: "Navigate: To Position" + requireEditor: page moveToLine: path: "./editor.ts:moveToLineCommand" command: name: "Navigate: To Line" + requireEditor: page navigateToPage: path: "./navigate.ts:navigateToPage" command: @@ -199,6 +211,7 @@ functions: key: "Ctrl-Shift-." mac: "Cmd-Shift-." requireMode: rw + requireEditor: page listifySelection: path: ./text.ts:listifySelection command: @@ -206,16 +219,19 @@ functions: key: "Ctrl-Shift-8" mac: "Cmd-Shift-8" requireMode: rw + requireEditor: page numberListifySelection: path: ./text.ts:numberListifySelection command: name: "Text: Number Listify Selection" requireMode: rw + requireEditor: page linkSelection: path: ./text.ts:linkSelection command: name: "Text: Link Selection" requireMode: rw + requireEditor: page bold: path: ./text.ts:wrapSelection command: @@ -224,6 +240,7 @@ functions: mac: "Cmd-b" wrapper: "**" requireMode: rw + requireEditor: page italic: path: ./text.ts:wrapSelection command: @@ -232,6 +249,7 @@ functions: mac: "Cmd-i" wrapper: "_" requireMode: rw + requireEditor: page strikethrough: path: ./text.ts:wrapSelection command: @@ -239,6 +257,7 @@ functions: key: "Ctrl-Shift-s" wrapper: "~~" requireMode: rw + requireEditor: page marker: path: ./text.ts:wrapSelection command: @@ -246,24 +265,28 @@ functions: key: "Alt-m" wrapper: "==" requireMode: rw + requireEditor: page centerCursor: path: "./editor.ts:centerCursorCommand" command: name: "Navigate: Center Cursor" key: "Ctrl-Alt-l" requireMode: rw + requireEditor: page deleteLine: path: "./editor.ts:deleteLineCommand" command: name: "Delete Line" key: "Ctrl-d" requireMode: rw + requireEditor: page # Debug commands parseCommand: path: ./debug.ts:parsePageCommand command: name: "Debug: Parse Document" + requireEditor: page # Link unfurl infrastructure unfurlLink: @@ -273,6 +296,7 @@ functions: key: "Ctrl-Shift-u" mac: "Cmd-Shift-u" requireMode: rw + requireEditor: page # Title-based link unfurl titleUnfurlOptions: @@ -305,10 +329,12 @@ functions: path: "./vim.ts:toggleVimMode" command: name: "Editor: Toggle Vim Mode" + requireEditor: page loadVimRc: path: "./vim.ts:loadVimRc" command: name: "Editor: Vim: Load VIMRC" + requireEditor: page events: - editor:modeswitch @@ -317,6 +343,7 @@ functions: path: ./stats.ts:statsCommand command: name: "Stats: Show" + requireEditor: page reloadUICommand: path: ./debug.ts:reloadUICommand command: @@ -354,6 +381,7 @@ functions: name: "Outline: Move Up" key: "Alt-ArrowUp" requireMode: rw + requireEditor: page outlineMoveDown: path: ./outline.ts:moveItemDown @@ -361,6 +389,7 @@ functions: name: "Outline: Move Down" key: "Alt-ArrowDown" requireMode: rw + requireEditor: page outlineIndent: path: ./outline.ts:indentItem @@ -368,6 +397,7 @@ functions: name: "Outline: Move Right" key: "Alt->" requireMode: rw + requireEditor: page outlineOutdent: path: ./outline.ts:outdentItem @@ -375,6 +405,7 @@ functions: name: "Outline: Move Left" key: "Alt-<" requireMode: rw + requireEditor: page # Outline folding commands foldCommand: @@ -382,32 +413,38 @@ functions: command: name: "Outline: Fold" key: "Ctrl-Alt-[" + requireEditor: page unfoldCommand: path: ./outline.ts:unfoldCommand command: name: "Outline: Unfold" key: "Ctrl-Alt-]" + requireEditor: page toggleFoldCommand: path: ./outline.ts:toggleFoldCommand command: name: "Outline: Toggle Fold" key: "Alt-Shift-f" + requireEditor: page foldAllCommand: path: ./outline.ts:foldAllCommand command: name: "Outline: Fold All" key: "Ctrl-Alt-Shift-[" + requireEditor: page unfoldAllCommand: path: ./outline.ts:unfoldAllCommand command: name: "Outline: Unfold All" key: "Ctrl-Alt-Shift-]" + requireEditor: page findInPageCommand: path: editor.ts:findInPageCommand command: name: "Editor: Find in Page" key: "Ctrl-f" mac: "Cmd-f" + requireEditor: page # Outline helper functions determineItemBounds: @@ -423,6 +460,7 @@ functions: key: "Ctrl-z" mac: "Cmd-z" requireMode: rw + requireEditor: page redoCommand: path: ./editor.ts:redoCommand command: @@ -430,6 +468,7 @@ functions: key: "Ctrl-y" mac: "Cmd-Shift-z" requireMode: rw + requireEditor: page # Demo customFlashMessage: @@ -439,3 +478,10 @@ functions: hide: true contexts: - internal + + deleteDocument: + path: "./document.ts:deleteDocument" + command: + name: "Document: Delete" + requireMode: rw + requireEditor: notpage diff --git a/plugs/editor/editor.ts b/plugs/editor/editor.ts index ae80b8de..92ff8324 100644 --- a/plugs/editor/editor.ts +++ b/plugs/editor/editor.ts @@ -26,6 +26,10 @@ export async function openMetaNavigator() { await editor.openPageNavigator("meta"); } +export async function openDocumentNavigator() { + await editor.openPageNavigator("document"); +} + export async function openAllNavigator() { await editor.openPageNavigator("all"); } diff --git a/plugs/editor/navigate.ts b/plugs/editor/navigate.ts index 5b986eb1..d90fdc4c 100644 --- a/plugs/editor/navigate.ts +++ b/plugs/editor/navigate.ts @@ -17,7 +17,7 @@ import { resolvePath, } from "@silverbulletmd/silverbullet/lib/resolve"; import { - looksLikePathWithExtension, + parseLocationRef, parsePageRef, } from "@silverbulletmd/silverbullet/lib/page_ref"; import { tagPrefix } from "../index/constants.ts"; @@ -54,29 +54,25 @@ async function actionClickOrActionEnter( switch (mdTree.type) { case "WikiLink": { const link = mdTree.children![1]!.children![0].text!; - // Assume is attachment if it has extension - if (looksLikePathWithExtension(link)) { - const attachmentPath = resolvePath( - currentPage, - "/" + decodeURI(link), - ); - return editor.openUrl(attachmentPath); - } else { - const pageRef = parsePageRef(link); - pageRef.page = resolvePath(currentPage, "/" + pageRef.page); - if (!pageRef.page) { - pageRef.page = currentPage; - } - // This is an explicit navigate, move to the top - if (pageRef.pos === undefined) { - pageRef.pos = 0; - } - return editor.navigate(pageRef, false, inNewWindow); + const currentPath = await editor.getCurrentPath(); + const locationRef = parseLocationRef(link); + locationRef.page = resolvePath(currentPage, "/" + locationRef.page); + if (!locationRef.page) { + locationRef.page = currentPath; } + // This is an explicit navigate, move to the top + if (locationRef.kind === "page" && locationRef.pos === undefined) { + locationRef.pos = 0; + } + return editor.navigate(locationRef, false, inNewWindow); } case "PageRef": { const pageName = parsePageRef(mdTree.children![0].text!).page; - return editor.navigate({ page: pageName, pos: 0 }, false, inNewWindow); + return editor.navigate( + { kind: "page", page: pageName, pos: 0 }, + false, + inNewWindow, + ); } case "NakedURL": case "URL": @@ -126,7 +122,7 @@ async function actionClickOrActionEnter( case "Hashtag": { const hashtag = extractHashtag(mdTree.children![0].text!); await editor.navigate( - { page: `${tagPrefix}${hashtag}`, pos: 0 }, + { kind: "page", page: `${tagPrefix}${hashtag}`, pos: 0 }, false, inNewWindow, ); @@ -154,11 +150,11 @@ export async function clickNavigate(event: ClickEvent) { } export async function navigateCommand(cmdDef: any) { - await editor.navigate({ page: cmdDef.page, pos: 0 }); + await editor.navigate({ kind: "page", page: cmdDef.page, pos: 0 }); } export async function navigateToPage(_cmdDef: any, pageName: string) { - await editor.navigate({ page: pageName, pos: 0 }); + await editor.navigate({ kind: "page", page: pageName, pos: 0 }); } export async function navigateToURL(_cmdDef: any, url: string) { diff --git a/plugs/editor/page.ts b/plugs/editor/page.ts index faf70175..034bc243 100644 --- a/plugs/editor/page.ts +++ b/plugs/editor/page.ts @@ -9,7 +9,7 @@ export async function deletePage() { return; } console.log("Navigating to index page"); - await editor.navigate({ page: "" }); + await editor.navigate({ kind: "page", page: "" }); console.log("Deleting page from space"); await space.deletePage(pageName); } @@ -57,7 +57,7 @@ export async function copyPage( if (currentPage === fromName) { // If we're copying the current page, navigate there console.log("Navigating to new page"); - await editor.navigate({ page: newName }); + await editor.navigate({ kind: "page", page: newName }); } else { // Otherwise just notify of success await editor.flashNotification("Page copied successfully"); diff --git a/plugs/editor/upload.ts b/plugs/editor/upload.ts index 2ee15f68..45c0034c 100644 --- a/plugs/editor/upload.ts +++ b/plugs/editor/upload.ts @@ -1,58 +1,66 @@ import { editor, space, system } from "@silverbulletmd/silverbullet/syscalls"; import type { UploadFile } from "@silverbulletmd/silverbullet/types"; -import { - defaultLinkStyle, - maximumAttachmentSize, -} from "../../web/constants.ts"; +import { defaultLinkStyle, maximumDocumentSize } from "../../web/constants.ts"; import { resolvePath } from "@silverbulletmd/silverbullet/lib/resolve"; import { encodePageURI } from "@silverbulletmd/silverbullet/lib/page_ref"; export async function saveFile(file: UploadFile) { const maxSize = await system.getSpaceConfig( - "maximumAttachmentSize", - maximumAttachmentSize, + "maximumDocumentSize", + maximumDocumentSize, ); if (typeof maxSize !== "number") { await editor.flashNotification( - "The setting 'maximumAttachmentSize' must be a number", + "The setting 'maximumDocumentSize' must be a number", "error", ); } if (file.content.length > maxSize * 1024 * 1024) { editor.flashNotification( - `Attachment is too large, maximum is ${maxSize}MiB`, + `Document is too large, maximum is ${maxSize}MiB`, "error", ); return; } const finalFileName = await editor.prompt( - "File name for pasted attachment", + "File name for pasted document", file.name, ); if (!finalFileName) { return; } - const attachmentPath = resolvePath( - await editor.getCurrentPage(), - finalFileName, - ); - await space.writeAttachment(attachmentPath, file.content); - const linkStyle = await system.getSpaceConfig( - "defaultLinkStyle", - defaultLinkStyle, - ); - let attachmentMarkdown = ""; - if (linkStyle === "wikilink") { - attachmentMarkdown = `[[${attachmentPath}]]`; + if (await editor.getCurrentEditor() === "page") { + const documentPath = resolvePath( + await editor.getCurrentPage(), + finalFileName, + ); + + await space.writeDocument(documentPath, file.content); + + const linkStyle = await system.getSpaceConfig( + "defaultLinkStyle", + defaultLinkStyle, + ); + let documentMarkdown = ""; + if (linkStyle === "wikilink") { + documentMarkdown = `[[${documentPath}]]`; + } else { + documentMarkdown = `[${finalFileName}](${encodePageURI(finalFileName)})`; + } + if (file.contentType.startsWith("image/")) { + documentMarkdown = "!" + documentMarkdown; + } + editor.insertAtCursor(documentMarkdown); } else { - attachmentMarkdown = `[${finalFileName}](${encodePageURI(finalFileName)})`; + const documentFolder = (await editor.getCurrentPath()) + .split("/") + .slice(0, -1) + .join("/"); + + await space.writeDocument(documentFolder + finalFileName, file.content); } - if (file.contentType.startsWith("image/")) { - attachmentMarkdown = "!" + attachmentMarkdown; - } - editor.insertAtCursor(attachmentMarkdown); } export async function uploadFile(_ctx: any, accept?: string, capture?: string) { diff --git a/plugs/index/command.ts b/plugs/index/command.ts index f616659a..61af4c11 100644 --- a/plugs/index/command.ts +++ b/plugs/index/command.ts @@ -9,7 +9,7 @@ import { import type { IndexEvent, MQMessage } from "@silverbulletmd/silverbullet/types"; import { isTemplate } from "$lib/cheap_yaml.ts"; import { sleep } from "$lib/async.ts"; -import { indexAttachment } from "./attachment.ts"; +import { indexDocument } from "./document.ts"; import { clearFileIndex } from "./api.ts"; export async function reindexCommand() { @@ -59,7 +59,7 @@ export async function processIndexQueue(messages: MQMessage[]) { name = name.slice(0, -3); await indexPage(name); } else { - await indexAttachment(name); + await indexDocument(name); } } } diff --git a/plugs/index/attachment.ts b/plugs/index/document.ts similarity index 50% rename from plugs/index/attachment.ts rename to plugs/index/document.ts index 8e50b030..e4d08a98 100644 --- a/plugs/index/attachment.ts +++ b/plugs/index/document.ts @@ -1,13 +1,13 @@ import { space, system } from "@silverbulletmd/silverbullet/syscalls"; -import type { AttachmentMeta } from "@silverbulletmd/silverbullet/types"; +import type { DocumentMeta } from "@silverbulletmd/silverbullet/types"; import { indexObjects } from "./api.ts"; // Note: clearFileIndex is not called but since this is the only attachmet:index listener, this should be fine (famous last words) -export async function indexAttachment(name: string) { +export async function indexDocument(name: string) { if (await system.getMode() === "ro") { return; } - console.log("Indexing attachment", name); - const fileMeta = await space.getAttachmentMeta(name); - await indexObjects(fileMeta.name, [fileMeta]); + console.log("Indexing document", name); + const fileMeta = await space.getDocumentMeta(name); + await indexObjects(fileMeta.name, [fileMeta]); } diff --git a/plugs/index/index.plug.yaml b/plugs/index/index.plug.yaml index befc123f..2bd7b5a0 100644 --- a/plugs/index/index.plug.yaml +++ b/plugs/index/index.plug.yaml @@ -69,10 +69,10 @@ functions: path: "./command.ts:reindexSpace" env: server - indexAttachment: - path: "./attachment.ts:indexAttachment" + indexDocument: + path: "./document.ts:indexDocument" events: - - attachment:index + - document:index indexPage: path: page.ts:indexPage @@ -177,11 +177,13 @@ functions: mac: Cmd-Alt-r page: "" requireMode: rw + requireEditor: page renamePrefixCommand: path: "./refactor.ts:renamePrefixCommand" command: name: "Page: Batch Rename Prefix" requireMode: rw + requireEditor: page renamePageLinkCommand: path: "./refactor.ts:renamePageLinkCommand" command: @@ -189,6 +191,14 @@ functions: mac: Cmd-Ctrl-Alt-r key: Ctrl-Shift-Alt-r requireMode: rw + requireEditor: page + + renameDocumentCommand: + path: "./refactor.ts:renameDocumentCommand" + command: + name: "Document: Rename Document" + requireMode: rw + requireEditor: notpage # Refactoring Commands extractToPageCommand: @@ -196,6 +206,7 @@ functions: command: name: "Page: Extract" requireMode: rw + requireEditor: page # TOC tocWidget: @@ -350,7 +361,7 @@ config: pos: type: number readOnly: true - attachment: + document: type: object additionalProperties: true properties: @@ -393,6 +404,9 @@ config: enum: - ro - rw + extension: + readOnly: true + type: string item: type: object additionalProperties: true diff --git a/plugs/index/page_links.ts b/plugs/index/page_links.ts index d2290401..a9b5cbe2 100644 --- a/plugs/index/page_links.ts +++ b/plugs/index/page_links.ts @@ -39,7 +39,7 @@ export type LinkObject = ObjectValue< asTemplate: boolean; toFile?: never; } | { - // Attachment Link + // Document Link // The file the link points to toFile: string; // The page the link occurs in @@ -86,7 +86,7 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) { page: name, asTemplate: false, }; - // Assume link is to an attachment if it has + // Assume link is to a document if it has // an extension, to a page otherwise if (looksLikePathWithExtension(url)) { link.toFile = url; @@ -129,7 +129,7 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) { page: name, asTemplate: false, }; - // Assume link is to an attachment if it has + // Assume link is to a document if it has // an extension, to a page otherwise if (looksLikePathWithExtension(url)) { link.toFile = url; @@ -169,7 +169,7 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) { pos: pos, asTemplate: true, }; - // Assume link is to an attachment if it has + // Assume link is to a document if it has // an extension, to a page otherwise if (looksLikePathWithExtension(url)) { link.toFile = resolvePath(name, "/" + url); diff --git a/plugs/index/refactor.ts b/plugs/index/refactor.ts index 2ec760af..8134db39 100644 --- a/plugs/index/refactor.ts +++ b/plugs/index/refactor.ts @@ -68,6 +68,26 @@ export async function renamePageLinkCommand() { await batchRenameFiles(pageList); } +/** + * Renames a single document. + * @param cmdDef Optional command arguments + * @param cmdDef.oldDocument The current name of the document to rename. + * @param cmdDef.document The name to rename the document to. If not provided the + * user will be prompted to enter a new name. + * @returns True if the rename succeeded; otherwise, false. + */ +export async function renameDocumentCommand(cmdDef: any) { + const oldName: string = cmdDef.oldDocument || await editor.getCurrentPath(); + const newName: string = cmdDef.document || + await editor.prompt(`Rename ${oldName} to:`, oldName); + if (!newName) { + return false; + } + const pageList: [string, string][] = [[oldName, newName]]; + await batchRenameFiles(pageList); + return true; +} + /** * Renames any amount of files. * If renaming pages, names should be passed with a .md extension @@ -116,7 +136,7 @@ export async function batchRenameFiles(fileList: [string, string][]) { if (newName.endsWith(".md")) { await renamePage(oldName.slice(0, -3), newName.slice(0, -3)); } else { - await renameAttachment(oldName, newName); + await renameDocument(oldName, newName); } } catch (e: any) { if (e.message === "Not found") { @@ -134,14 +154,14 @@ export async function batchRenameFiles(fileList: [string, string][]) { } } -// Rename a page, update any backlinks and linked attachments +// Rename a page, update any backlinks and linked documents async function renamePage(oldName: string, newName: string) { let text = await space.readPage(oldName); - // Update relative links and attachments on this page + // Update relative links and documents on this page const oldFolder = folderName(oldName); const newFolder = folderName(newName); - const attachmentsToMove = new Set(); + const documentsToMove = new Set(); // Links only need to be updated if the folder changes if (oldFolder !== newFolder) { const linksInPage = await queryObjects("link", { @@ -151,11 +171,11 @@ async function renamePage(oldName: string, newName: string) { const linksToUpdate: ObjectValue[] = []; for (const link of linksInPage) { if (link.toFile && folderName(link.toFile) === oldFolder) { - const attBackLinks = await getBackLinks(link.toFile); - if (attBackLinks.filter((a) => a.page !== oldName).length === 0) { - // Attachments is in the same folder as the page + const documentBackLinks = await getBackLinks(link.toFile); + if (documentBackLinks.filter((a) => a.page !== oldName).length === 0) { + // Documents is in the same folder as the page // and is only linked to on this page, move it along with the page - attachmentsToMove.add(link.toFile); + documentsToMove.add(link.toFile); continue; } } @@ -188,21 +208,21 @@ async function renamePage(oldName: string, newName: string) { // Write the new page const newPageMeta = await space.writePage(newName, text); - // Move attachements along with page - const batchRenameAttachments: [string, string][] = []; - for (const att of attachmentsToMove) { + // Move documents along with page + const batchRenameDocuments: [string, string][] = []; + for (const document of documentsToMove) { const newAttName = oldFolder.length === 0 - ? newFolder + "/" + att - : att.replace(oldFolder, newFolder).replace(/^\//, ""); - batchRenameAttachments.push([att, newAttName]); + ? newFolder + "/" + document + : document.replace(oldFolder, newFolder).replace(/^\//, ""); + batchRenameDocuments.push([document, newAttName]); } - if (batchRenameAttachments.length > 0) { - await batchRenameFiles(batchRenameAttachments); + if (batchRenameDocuments.length > 0) { + await batchRenameFiles(batchRenameDocuments); } // Navigate to new page if currently viewing old page if (await editor.getCurrentPage() === oldName) { - await editor.navigate({ page: newName, pos: 0 }, true); + await editor.navigate({ kind: "page", page: newName, pos: 0 }, true); } // Handling the edge case of a changing page name just in casing on a case insensitive FS const oldPageMeta = await space.getPageMeta(oldName); @@ -218,26 +238,30 @@ async function renamePage(oldName: string, newName: string) { if (updatedRefences > 0) { message = `${message}, updated ${updatedRefences} backlinks`; } - if (attachmentsToMove.size > 0) { - message = `${message}, moved ${attachmentsToMove.size} attachments`; + if (documentsToMove.size > 0) { + message = `${message}, moved ${documentsToMove.size} documents`; } await editor.flashNotification(message, "info"); } -// Rename an attachment and update any backlinks -async function renameAttachment( +// Rename a document and update any backlinks +async function renameDocument( oldName: string, newName: string, ) { // Move the file - const oldFile = await space.readAttachment(oldName); - const newFileMeta = await space.writeAttachment(newName, oldFile); + const oldFile = await space.readDocument(oldName); + const newFileMeta = await space.writeDocument(newName, oldFile); + + if (await editor.getCurrentPath() === oldName) { + await editor.navigate({ kind: "document", page: newName }, true); + } // Handling the edge case of a changing file name just in casing on a case insensitive FS - const oldFileMeta = await space.getAttachmentMeta(oldName); + const oldFileMeta = await space.getDocumentMeta(oldName); if (oldFileMeta.lastModified !== newFileMeta.lastModified) { // If they're the same, let's assume it's the same file (case insensitive FS) and not delete, otherwise... - await space.deleteAttachment(oldName); + await space.deleteDocument(oldName); } // Update any backlinks @@ -274,9 +298,9 @@ export async function renamePrefixCommand(cmdDef: any) { return false; } - const allAttachments = await space.listAttachments(); + const allDocuments = await space.listDocuments(); const allPages = await space.listPages(); - let allAffectedFiles = allAttachments.map((file) => file.name).filter(( + let allAffectedFiles = allDocuments.map((file) => file.name).filter(( file, ) => file.startsWith(oldPrefix)); allAffectedFiles = allAffectedFiles.concat( @@ -335,7 +359,7 @@ export async function extractToPageCommand() { console.log("Writing new page to space"); await space.writePage(newName, text); console.log("Navigating to new page"); - await editor.navigate({ page: newName }); + await editor.navigate({ kind: "page", page: newName }); } /** diff --git a/plugs/markdown/markdown.plug.yaml b/plugs/markdown/markdown.plug.yaml index 625ecd4e..a00bf5d6 100644 --- a/plugs/markdown/markdown.plug.yaml +++ b/plugs/markdown/markdown.plug.yaml @@ -18,6 +18,7 @@ functions: name: "Markdown Preview: Toggle" key: Ctrl-p mac: Cmd-p + requireEditor: page preview: path: "./preview.ts:updateMarkdownPreview" diff --git a/plugs/markdown/markdown_render.ts b/plugs/markdown/markdown_render.ts index a938dbd2..4928b9e5 100644 --- a/plugs/markdown/markdown_render.ts +++ b/plugs/markdown/markdown_render.ts @@ -21,7 +21,7 @@ export type MarkdownRenderOptions = { failOnUnknown?: true; smartHardBreak?: true; annotationPositions?: true; - attachmentUrlPrefix?: string; + documentUrlPrefix?: string; preserveAttributes?: true; // When defined, use to inline images as data: urls translateUrls?: (url: string, type: "link" | "image") => string; @@ -223,10 +223,10 @@ function render( let url = urlNode.children![0].text!; if (isLocalPath(url)) { if ( - options.attachmentUrlPrefix && - !url.startsWith(options.attachmentUrlPrefix) + options.documentUrlPrefix && + !url.startsWith(options.documentUrlPrefix) ) { - url = `${options.attachmentUrlPrefix}${url}`; + url = `${options.documentUrlPrefix}${url}`; } } return { @@ -246,10 +246,10 @@ function render( let url = urlNode.children![0].text!; if (isLocalPath(url)) { if ( - options.attachmentUrlPrefix && - !url.startsWith(options.attachmentUrlPrefix) + options.documentUrlPrefix && + !url.startsWith(options.documentUrlPrefix) ) { - url = `${options.attachmentUrlPrefix}${url}`; + url = `${options.documentUrlPrefix}${url}`; } } return { @@ -293,10 +293,10 @@ function render( if ( isLocalPath(url) && - options.attachmentUrlPrefix && - !url.startsWith(options.attachmentUrlPrefix) + options.documentUrlPrefix && + !url.startsWith(options.documentUrlPrefix) ) { - url = `${options.attachmentUrlPrefix}${url}`; + url = `${options.documentUrlPrefix}${url}`; } return { diff --git a/plugs/plug-manager/plugmanager.ts b/plugs/plug-manager/plugmanager.ts index 59e69465..7217dd3c 100644 --- a/plugs/plug-manager/plugmanager.ts +++ b/plugs/plug-manager/plugmanager.ts @@ -126,7 +126,7 @@ export async function updatePlugsCommand() { const workerCode = manifests[0] as string; allCustomPlugNames.push(plugName); // console.log("Writing", `_plug/${plugName}.plug.js`, workerCode); - await space.writeAttachment( + await space.writeDocument( `_plug/${plugName}.plug.js`, new TextEncoder().encode(workerCode), ); @@ -140,7 +140,7 @@ export async function updatePlugsCommand() { existingPlug.length - ".plug.js".length, ); if (!allPlugNames.includes(plugName)) { - await space.deleteAttachment(existingPlug); + await space.deleteDocument(existingPlug); } } await editor.flashNotification("And... done!"); @@ -165,7 +165,7 @@ export async function addPlugCommand(_cmdDef: any, uriSuggestion: string = "") { } else { space.writePage(plugsPage, plugPageContent); } - await editor.navigate({ page: plugsPage }); + await editor.navigate({ kind: "page", page: plugsPage }); // Here we are on the PLUGS page, if it didn't exist before it's filled with prelude const changeList = insertIntoPlugPage(uri, plugPageContent); for (const { from, to, text } of changeList) { diff --git a/plugs/query/query.plug.yaml b/plugs/query/query.plug.yaml index 7749ad5f..d8bc3dd5 100644 --- a/plugs/query/query.plug.yaml +++ b/plugs/query/query.plug.yaml @@ -46,8 +46,11 @@ functions: command: name: "Live Queries and Templates: Refresh All" key: "Alt-q" + requireEditor: page bakeAllWidgets: path: widget.ts:bakeAllWidgets command: name: "Page: Bake live blocks" + requireMode: rw + requireEditor: page diff --git a/plugs/search/search.ts b/plugs/search/search.ts index c7b95073..8fac4e4c 100644 --- a/plugs/search/search.ts +++ b/plugs/search/search.ts @@ -51,7 +51,7 @@ export async function queryProvider({ export async function searchCommand() { const phrase = await editor.prompt("Search for: "); if (phrase) { - await editor.navigate({ page: `${searchPrefix}${phrase}` }); + await editor.navigate({ kind: "page", page: `${searchPrefix}${phrase}` }); } } diff --git a/plugs/share/share.plug.yaml b/plugs/share/share.plug.yaml index f783b8ae..523f1d9f 100644 --- a/plugs/share/share.plug.yaml +++ b/plugs/share/share.plug.yaml @@ -6,6 +6,7 @@ functions: name: "Share: Page Or Selection" key: "Ctrl-s" mac: "Cmd-s" + requireEditor: page clipboardShareOptions: path: share.ts:clipboardShareOptions diff --git a/plugs/tasks/tasks.plug.yaml b/plugs/tasks/tasks.plug.yaml index ebd7b3ca..0ae0bfc8 100644 --- a/plugs/tasks/tasks.plug.yaml +++ b/plugs/tasks/tasks.plug.yaml @@ -18,12 +18,14 @@ functions: name: "Task: Cycle State" key: Alt-t requireMode: rw + requireEditor: page taskPostponeCommand: path: ./task.ts:postponeCommand command: name: "Task: Postpone" key: Alt-+ requireMode: rw + requireEditor: page contexts: - DeadlineDate previewTaskToggle: @@ -41,6 +43,7 @@ functions: command: name: "Task: Remove Completed" requireMode: rw + requireEditor: page config: schema.tag: diff --git a/plugs/template/page.ts b/plugs/template/page.ts index 077fe856..3c7a125b 100644 --- a/plugs/template/page.ts +++ b/plugs/template/page.ts @@ -158,7 +158,7 @@ export async function instantiatePageTemplate( // So, page exists if (newPageConfig.openIfExists) { console.log("Page already exists, navigating there"); - await editor.navigate({ page: pageName, pos: 0 }); + await editor.navigate({ kind: "page", page: pageName, pos: 0 }); return pageName; } @@ -169,7 +169,7 @@ export async function instantiatePageTemplate( ) ) { // Just navigate there without instantiating - return editor.navigate({ page: pageName, pos: 0 }); + return editor.navigate({ kind: "page", page: pageName, pos: 0 }); } } catch { // The preferred scenario, let's keep going @@ -197,6 +197,7 @@ export async function instantiatePageTemplate( fullPageText, ); await editor.navigate({ + kind: "page", page: pageName, pos: carretPos !== -1 ? carretPos : undefined, }); diff --git a/server/http_server.ts b/server/http_server.ts index b178c1f3..6062b01f 100644 --- a/server/http_server.ts +++ b/server/http_server.ts @@ -77,25 +77,37 @@ export class HttpServer { let lastModified = utcDateString(Date.now()); if (!spaceServer.auth) { // Only attempt server-side rendering when this site is not protected by auth - try { - const { data, meta } = await spaceServer.spacePrimitives.readFile( - `${pageName}.md`, - ); - lastModified = utcDateString(meta.lastModified); + if (!looksLikePathWithExtension(pageName)) { + try { + const { data, meta } = await spaceServer.spacePrimitives.readFile( + `${pageName}.md`, + ); + lastModified = utcDateString(meta.lastModified); - if (c.req.header("If-Modified-Since") === lastModified) { - // Not modified, empty body status 304 - return c.body(null, 304); + if (c.req.header("If-Modified-Since") === lastModified) { + // Not modified, empty body status 304 + return c.body(null, 304); + } + const text = new TextDecoder().decode(data); + const tree = parse(extendedMarkdownLanguage, text); + html = renderMarkdownToHtml(tree); + } catch (e: any) { + if (e.message !== "Not found") { + console.error("Error server-side rendering page", e); + } } - const text = new TextDecoder().decode(data); - const tree = parse(extendedMarkdownLanguage, text); - html = renderMarkdownToHtml(tree); - } catch (e: any) { - if (e.message !== "Not found") { - console.error("Error server-side rendering page", e); + } else { + // If it it's a file with an extension and it doesn't exist we can't really create a new one/recover + try { + await spaceServer.spacePrimitives.getFileMeta(`${pageName}`); + } catch (e: any) { + if (e.message !== "Not found") { + return c.notFound(); + } } } } + // TODO: Replace this with a proper template engine html = this.clientAssetBundle.readTextFileSync(".client/index.html") .replace( @@ -566,7 +578,7 @@ export class HttpServer { const filePathRegex = "/:path{[^!].*\\.[a-zA-Z0-9]+}"; const mdExt = ".md"; - this.app.get(filePathRegex, async (c) => { + this.app.get(filePathRegex, async (c, next) => { const req = c.req; const name = req.param("path")!; console.log("Requested file", name); @@ -586,11 +598,19 @@ export class HttpServer { ); return c.redirect(`/${name.slice(0, -mdExt.length)}`); } + // This is a good guess that the request comes directly from a user + if ( + req.header("Accept")?.includes("text/html") && + req.query("raw") !== "true" + ) { + return next(); + } + if (name.startsWith(".")) { // Don't expose hidden files return c.notFound(); } - // Handle federated links through a simple redirect, only used for attachments loads with service workers disabled + // Handle federated links through a simple redirect, only used for documents loads with service workers disabled if (name.startsWith("!")) { let url = name.slice(1); console.log("Handling this as a federated link", url); @@ -682,7 +702,7 @@ export class HttpServer { await this.spaceServer.spacePrimitives.deleteFile(name); return c.text("OK"); } catch (e: any) { - console.error("Error deleting attachment", e); + console.error("Error deleting document", e); return c.text(e.message, 500); } }).options(); diff --git a/server/server_system.ts b/server/server_system.ts index de99102c..ee5039fb 100644 --- a/server/server_system.ts +++ b/server/server_system.ts @@ -207,7 +207,7 @@ export class ServerSystem extends CommonSystem { this.eventHook.addLocalListener( "file:listed", (allFiles: FileMeta[]) => { - // Update list of known pages and attachments + // Update list of known pages and documents this.allKnownFiles.clear(); allFiles.forEach((f) => { if (!f.name.startsWith(plugPrefix)) { @@ -219,7 +219,7 @@ export class ServerSystem extends CommonSystem { this.eventHook.addLocalListener( "file:deleted", (path: string) => { - // Update list of known pages and attachments + // Update list of known pages and documents this.allKnownFiles.delete(path); }, ); diff --git a/server/syscalls/space.ts b/server/syscalls/space.ts index 17e77941..f5df0a74 100644 --- a/server/syscalls/space.ts +++ b/server/syscalls/space.ts @@ -1,8 +1,4 @@ -import type { - AttachmentMeta, - FileMeta, - PageMeta, -} from "../../plug-api/types.ts"; +import type { DocumentMeta, FileMeta, PageMeta } from "../../plug-api/types.ts"; import type { SysCallMapping } from "../../lib/plugos/system.ts"; import type { Space } from "../../common/space.ts"; @@ -26,17 +22,17 @@ export function spaceReadSyscalls( "space.listPlugs": (): Promise => { return space.listPlugs(); }, - "space.listAttachments": async (): Promise => { - return await space.fetchAttachmentList(); + "space.listDocuments": async (): Promise => { + return await space.fetchDocumentList(); }, - "space.readAttachment": async (_ctx, name: string): Promise => { - return (await space.readAttachment(name)).data; + "space.readDocument": async (_ctx, name: string): Promise => { + return (await space.readDocument(name)).data; }, - "space.getAttachmentMeta": async ( + "space.getDocumentMeta": async ( _ctx, name: string, - ): Promise => { - return await space.getAttachmentMeta(name); + ): Promise => { + return await space.getDocumentMeta(name); }, // FS @@ -67,15 +63,15 @@ export function spaceWriteSyscalls(space: Space): SysCallMapping { "space.deletePage": async (_ctx, name: string) => { await space.deletePage(name); }, - "space.writeAttachment": ( + "space.writeDocument": ( _ctx, name: string, data: Uint8Array, - ): Promise => { - return space.writeAttachment(name, data); + ): Promise => { + return space.writeDocument(name, data); }, - "space.deleteAttachment": async (_ctx, name: string) => { - await space.deleteAttachment(name); + "space.deleteDocument": async (_ctx, name: string) => { + await space.deleteDocument(name); }, "space.writeFile": ( _ctx, diff --git a/type/client.ts b/type/client.ts index 5fd4723f..64278987 100644 --- a/type/client.ts +++ b/type/client.ts @@ -4,6 +4,7 @@ export type FilterOption = { description?: string; orderId?: number; hint?: string; + hintInactive?: boolean; classes?: string; } & Record; diff --git a/type/config.ts b/type/config.ts index 77a3ce49..cf3f3bfb 100644 --- a/type/config.ts +++ b/type/config.ts @@ -37,7 +37,7 @@ export type Config = { shortcuts?: Shortcut[]; // DEPRECATED: Use smartQuotes instead useSmartQuotes?: boolean; - maximumAttachmentSize?: number; + maximumDocumentSize?: number; libraries?: LibraryDef[]; // Open the last page that was open when the app was closed pwaOpenLastPage?: boolean; @@ -70,7 +70,7 @@ type SchemaConfig = { export const defaultConfig: Config = { indexPage: "index", hideSyncButton: false, - maximumAttachmentSize: 10, // MiB + maximumDocumentSize: 10, // MiB defaultLinkStyle: "wikilink", // wikilink [[]] or markdown []() actionButtons: [], // Actually defaults to defaultActionButtons autoCloseBrackets: "([{`", diff --git a/web/client.ts b/web/client.ts index 7591db4f..956e6c9f 100644 --- a/web/client.ts +++ b/web/client.ts @@ -11,10 +11,10 @@ import type { SyntaxNode } from "@lezer/common"; import { Space } from "../common/space.ts"; import type { FilterOption } from "@silverbulletmd/silverbullet/type/client"; import { EventHook } from "../common/hooks/event.ts"; -import type { AppCommand } from "$lib/command.ts"; +import { type AppCommand, isValidEditor } from "$lib/command.ts"; import { - type PageState, - parsePageRefFromURI, + type LocationState, + parseLocationRefFromURI, PathPageNavigator, } from "./navigator.ts"; @@ -23,6 +23,7 @@ import type { AppViewState } from "./type.ts"; import type { AppEvent, CompleteEvent, + DocumentMeta, SlashCompletions, } from "../plug-api/types.ts"; import type { StyleObject } from "../plugs/index/style.ts"; @@ -41,9 +42,10 @@ import { HttpSpacePrimitives } from "$common/spaces/http_space_primitives.ts"; import { FallbackSpacePrimitives } from "$common/spaces/fallback_space_primitives.ts"; import { FilteredSpacePrimitives } from "$common/spaces/filtered_space_primitives.ts"; import { - encodePageRef, + encodeLocationRef, encodePageURI, - validatePageName, + parseLocationRef, + validatePath, } from "@silverbulletmd/silverbullet/lib/page_ref"; import { ClientSystem } from "./client_system.ts"; import { createEditorState } from "./editor_state.ts"; @@ -62,7 +64,7 @@ import { DataStoreSpacePrimitives } from "$common/spaces/datastore_space_primiti import { ensureSpaceIndex } from "$common/space_index.ts"; import { renderTheTemplate } from "$common/syscalls/template.ts"; -import type { PageRef } from "../plug-api/lib/page_ref.ts"; +import type { LocationRef } from "../plug-api/lib/page_ref.ts"; import { ReadOnlySpacePrimitives } from "$common/spaces/ro_space_primitives.ts"; import type { KvPrimitives } from "$lib/data/kv_primitives.ts"; import { @@ -76,6 +78,7 @@ import { findNodeMatching } from "@silverbulletmd/silverbullet/lib/tree"; import type { AspiringPageObject } from "../plugs/index/page_links.ts"; import type { Config, ConfigContainer } from "../type/config.ts"; import { diffAndPrepareChanges } from "./cm_util.ts"; +import { DocumentEditor } from "./document_editor.ts"; const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/; @@ -120,6 +123,9 @@ export class Client implements ConfigContainer { indentUnitCompartment?: Compartment; undoHistoryCompartment?: Compartment; + // Document editor + documentEditor: DocumentEditor | null = null; + private pageNavigator!: PathPageNavigator; private dbPrefix: string; @@ -136,7 +142,7 @@ export class Client implements ConfigContainer { fullSyncCompleted = false; syncService!: ISyncService; - private onLoadPageRef: PageRef; + private onLoadLocationRef: LocationRef; constructor( private parent: Element, @@ -149,7 +155,7 @@ export class Client implements ConfigContainer { // Generate a semi-unique prefix for the database so not to reuse databases for different space paths this.dbPrefix = "" + simpleHash(globalThis.silverBulletConfig.spaceFolderPath); - this.onLoadPageRef = parsePageRefFromURI(); + this.onLoadLocationRef = parseLocationRefFromURI(); } /** @@ -261,7 +267,7 @@ export class Client implements ConfigContainer { // Regularly sync the currently open file setInterval(() => { try { - this.syncService.syncFile(`${this.currentPage}.md`).catch((e: any) => { + this.syncService.syncFile(this.currentPath(true)).catch((e: any) => { console.error("Interval sync error", e); }); } catch (e: any) { @@ -271,6 +277,7 @@ export class Client implements ConfigContainer { // Let's update the local page list cache asynchronously this.updatePageListCache().catch(console.error); + this.updateDocumentListCache().catch(console.error); } async loadConfig() { @@ -352,15 +359,17 @@ export class Client implements ConfigContainer { this.eventHook.addLocalListener( "file:synced", (meta: FileMeta, direction: string) => { - if (meta.name.endsWith(".md") && direction === "secondary->primary") { - // We likely polled the currently open page which trigggered a local update, let's update the editor accordingly - this.space.getPageMeta(meta.name.slice(0, -3)); + if (direction === "secondary->primary") { + // We likely polled the currently open page or document which triggered a local update, let's update the editor accordingly + this.space.spacePrimitives.getFileMeta(meta.name); } }, ); } - private navigateWithinPage(pageState: PageState) { + private navigateWithinPage(pageState: LocationState) { + if (pageState.kind === "document") return; + // Did we end up doing anything in terms of internal navigation? let adjustedPosition = false; @@ -483,30 +492,34 @@ export class Client implements ConfigContainer { await this.pageNavigator.init(); - this.pageNavigator.subscribe(async (pageState) => { - console.log("Now navigating to", pageState); + this.pageNavigator.subscribe(async (locationState) => { + console.log("Now navigating to", locationState.page); - await this.loadPage(pageState.page); + if (locationState.kind === "page") { + await this.loadPage(locationState.page); + } else { + await this.loadDocumentEditor(locationState.page); + } // Setup scroll position, cursor position, etc - this.navigateWithinPage(pageState); + this.navigateWithinPage(locationState); // Persist this page as the last opened page, we'll use this for cold start PWA loads await this.stateDataStore.set( - ["client", "lastOpenedPage"], - pageState.page, + ["client", "lastOpenedPath"], + locationState.page, ); }); if (location.hash === "#boot" && this.config.pwaOpenLastPage !== false) { // Cold start PWA load - const lastPage = await this.stateDataStore.get([ + const lastPath = await this.stateDataStore.get([ "client", - "lastOpenedPage", + "lastOpenedPath", ]); - if (lastPage) { - console.log("Navigating to last opened page", lastPage); - await this.navigate({ page: lastPage }); + if (lastPath) { + console.log("Navigating to last opened page", lastPath.path); + await this.navigate(parseLocationRef(lastPath)); } } setTimeout(() => { @@ -591,9 +604,19 @@ export class Client implements ConfigContainer { let lastSaveTimestamp: number | undefined; - this.eventHook.addLocalListener("editor:pageSaving", () => { + const updateLastSaveTimestamp = () => { lastSaveTimestamp = Date.now(); - }); + }; + + this.eventHook.addLocalListener( + "editor:pageSaving", + updateLastSaveTimestamp, + ); + + this.eventHook.addLocalListener( + "editor:documentSaving", + updateLastSaveTimestamp, + ); this.eventHook.addLocalListener( "file:changed", @@ -603,9 +626,9 @@ export class Client implements ConfigContainer { oldHash: number, newHash: number, ) => { - // Only reload when watching the current page (to avoid reloading when switching pages) + // Only reload when watching the current page or document (to avoid reloading when switching pages) if ( - this.space.watchInterval && `${this.currentPage}.md` === path && + this.space.watchInterval && this.currentPath(true) === path && // Avoid reloading if the page was just saved (5s window) (!lastSaveTimestamp || (lastSaveTimestamp < Date.now() - 5000)) ) { @@ -621,8 +644,10 @@ export class Client implements ConfigContainer { "now", Date.now(), ); - this.flashNotification("Page changed elsewhere, reloading"); - this.reloadPage(); + this.flashNotification( + "Page or document changed elsewhere, reloading", + ); + this.reloadEditor(); } }, ); @@ -654,10 +679,21 @@ export class Client implements ConfigContainer { return localSpacePrimitives; } + // Note: This is a legacy method, which only makes sense when the current editor is a page editor get currentPage(): string { - return this.ui.viewState.currentPage !== undefined - ? this.ui.viewState.currentPage - : this.onLoadPageRef.page; // best effort + return this.ui.viewState.current !== undefined + ? this.ui.viewState.current.path + : this.onLoadLocationRef.page; // best effort + } + + currentPath(extension: boolean = false): string { + if (this.ui.viewState.current !== undefined) { + return this.ui.viewState.current.path + + ((this.ui.viewState.current.kind === "page" && extension) ? ".md" : ""); + } else { + return this.onLoadLocationRef.page + + ((this.onLoadLocationRef.kind === "page" && extension) ? ".md" : ""); + } } dispatchAppEvent(name: AppEvent, ...args: any[]): Promise { @@ -672,14 +708,31 @@ export class Client implements ConfigContainer { } this.saveTimeout = setTimeout( () => { - if (this.currentPage) { - if ( - !this.ui.viewState.unsavedChanges || - this.ui.viewState.uiOptions.forcedROMode || this.readOnlyMode - ) { - // No unsaved changes, or read-only mode, not gonna save - return resolve(); - } + // Note: Is this case really necessary? The fallback path will always exist, right? + if (!this.currentPath()) { + resolve(); + } + + if ( + !this.ui.viewState.unsavedChanges || + this.ui.viewState.uiOptions.forcedROMode || this.readOnlyMode + ) { + // No unsaved changes, or read-only mode, not gonna save + return resolve(); + } + + if (this.isDocumentEditor()) { + console.log("Requesting save for document", this.currentPath()); + this.dispatchAppEvent( + "editor:documentSaving", + this.currentPath(), + ); + + // Only thing we can really do is request a save + this.documentEditor.requestSave(); + + return resolve(); + } else { console.log("Saving page", this.currentPage); this.dispatchAppEvent( "editor:pageSaving", @@ -725,8 +778,6 @@ export class Client implements ConfigContainer { this.saveTimeout = setTimeout(this.save.bind(this), 10000); reject(e); }); - } else { - resolve(); } }, immediate ? 0 : autoSaveInterval, @@ -756,11 +807,13 @@ export class Client implements ConfigContainer { ); } - startPageNavigate(mode: "page" | "meta" | "all") { + startPageNavigate(mode: "page" | "meta" | "document" | "all") { // Then show the page navigator this.ui.viewDispatch({ type: "start-navigate", mode }); // And update the page list cache asynchronously this.updatePageListCache().catch(console.error); + + this.updateDocumentListCache().catch(console.error); } async updatePageListCache() { @@ -790,6 +843,20 @@ export class Client implements ConfigContainer { }); } + async updateDocumentListCache() { + console.log("Updating document list cache"); + + const allDocuments = await this.clientSystem.queryObjects( + "document", + {}, + ); + + this.ui.viewDispatch({ + type: "update-document-list", + allDocuments: allDocuments, + }); + } + // Progress circle handling private progressTimeout?: number; @@ -883,7 +950,7 @@ export class Client implements ConfigContainer { this, this.currentPage, editorView.state.sliceDoc(), - this.ui.viewState.currentPageMeta?.perm === "ro", + this.ui.viewState.current?.meta?.perm === "ro", ), ); if (editorView.contentDOM) { @@ -987,12 +1054,23 @@ export class Client implements ConfigContainer { >; } + async reloadEditor() { + if (this.isDocumentEditor()) await this.reloadDocumentEditor(); + else await this.reloadPage(); + } + async reloadPage() { console.log("Reloading page"); clearTimeout(this.saveTimeout); await this.loadPage(this.currentPage); } + async reloadDocumentEditor() { + console.log("Reloading dediacted editor"); + clearTimeout(this.saveTimeout); + await this.loadDocumentEditor(this.currentPath()); + } + // Focus the editor focus() { const viewState = this.ui.viewState; @@ -1009,16 +1087,22 @@ export class Client implements ConfigContainer { // Some other modal UI element is visible, don't focus editor now return; } - this.editorView.focus(); + + if (this.isDocumentEditor()) { + this.documentEditor.focus(); + } else { + this.editorView.focus(); + } } async navigate( - pageRef: PageRef, + locationRef: LocationRef, replaceState = false, newWindow = false, ) { - if (!pageRef.page) { - pageRef.page = cleanPageRef( + if (!locationRef.page) { + locationRef.kind = "page"; + locationRef.page = cleanPageRef( await renderTheTemplate( this.config.indexPage, {}, @@ -1029,7 +1113,7 @@ export class Client implements ConfigContainer { } try { - validatePageName(pageRef.page); + validatePath(locationRef.page); } catch (e: any) { return this.flashNotification(e.message, "error"); } @@ -1037,10 +1121,10 @@ export class Client implements ConfigContainer { if (newWindow) { console.log( "Navigating to new page in new window", - `${location.origin}/${encodePageURI(encodePageRef(pageRef))}`, + `${location.origin}/${encodePageURI(encodeLocationRef(locationRef))}`, ); const win = globalThis.open( - `${location.origin}/${encodePageURI(encodePageRef(pageRef))}`, + `${location.origin}/${encodePageURI(encodeLocationRef(locationRef))}`, "_blank", ); if (win) { @@ -1050,22 +1134,153 @@ export class Client implements ConfigContainer { } await this.pageNavigator!.navigate( - pageRef, + locationRef, replaceState, ); this.focus(); } + async loadDocumentEditor(path: string) { + const previousPath = this.currentPath(); + const previousRef = this.ui.viewState.current; + const initalLoad = !previousRef; + const loadingDifferentPath = !initalLoad + ? (previousPath !== path) + // Always load as different page if page is loaded from scratch + : true; + + const revertPath = () => { + if (previousPath && previousRef) { + this.ui.viewDispatch( + previousRef.kind === "page" + ? { type: "page-loaded", meta: previousRef.meta } + : { type: "document-editor-loaded", meta: previousRef.meta }, + ); + } + }; + + if (previousPath) { + this.space.unwatchFile(previousPath); + + if (loadingDifferentPath) { + this.save(true); + } + } + + let doc; + + this.ui.viewDispatch({ + type: "document-editor-loading", + name: path, + }); + + try { + doc = await this.space.readDocument(path); + } catch (e: any) { + revertPath(); + + if (e.message.includes("Not found")) { + console.log("This path doesn't exist, redirecting to the index page"); + + if (initalLoad) this.navigate({ kind: "page", page: "" }); + } else { + this.flashNotification( + `Could not load document editor ${path}: ${e.message}`, + "error", + ); + } + + return; + } + + if ( + loadingDifferentPath && + !(this.isDocumentEditor() && + this.documentEditor.extension === doc.meta.extension) + ) { + try { + await this.switchToDocumentEditor(doc.meta.extension); + + if (!this.documentEditor) { + throw new Error("Problem setting up document editor"); + } + } catch (e: any) { + console.log(e.message); + + if (e.message.includes("Couldn't find")) { + this.openUrl(path + "?raw=true", initalLoad); + + // This is a hacky way to clean up the history here + globalThis.history.replaceState( + previousRef, + "", + `/${encodePageURI(previousPath)}`, + ); + } + + if (!initalLoad) { + revertPath(); + + // Unsure about this case. It is probably not handled correctly, but currently this case cannot fully happen + if (previousRef.kind === "page") { + this.loadPage(previousRef.path); + } else { + this.loadDocumentEditor(previousRef.path); + } + } else { + // Navigate to index page if there was no previous page + this.navigate({ kind: "page", page: "" }); + } + + return; + } + } + + this.ui.viewDispatch({ + type: "document-editor-loaded", + meta: doc.meta, + }); + + if (!loadingDifferentPath && this.isDocumentEditor()) { + // We are loading the same page again so just send a file changed event + await this.documentEditor.changeContent(doc.data, doc.meta); + } else { + this.documentEditor!.setContent(doc.data, doc.meta); + this.space.watchFile(path); + } + + if (loadingDifferentPath) { + this.eventHook.dispatchEvent( + "editor:documentLoaded", + path, + previousPath, + ) + .catch( + console.error, + ); + } else { + this.eventHook.dispatchEvent( + "editor:documentReloaded", + path, + previousPath, + ) + .catch( + console.error, + ); + } + } + async loadPage(pageName: string) { const loadingDifferentPage = pageName !== this.currentPage; const editorView = this.editorView; - const previousPage = this.currentPage; + const previousPath = this.currentPath(); + const previousRef = this.ui.viewState.current; // Persist current page state and nicely close page - if (previousPage) { + if (previousPath) { // this.openPages.saveState(previousPage); - this.space.unwatchPage(previousPage); - if (previousPage !== pageName) { + this.space.unwatchFile(previousPath); + if (previousPath !== `${pageName}.md`) { await this.save(true); } } @@ -1109,17 +1324,22 @@ export class Client implements ConfigContainer { `Could not load page ${pageName}: ${e.message}`, "error", ); - if (previousPage) { - this.ui.viewDispatch({ - type: "page-loading", - name: previousPage, - }); + if (previousPath && previousRef) { + this.ui.viewDispatch( + previousRef.kind === "page" + ? { type: "page-loaded", meta: previousRef.meta } + : { type: "document-editor-loaded", meta: previousRef.meta }, + ); } - return false; + return; } } + if (this.isDocumentEditor()) { + this.switchToPageEditor(); + } + this.ui.viewDispatch({ type: "page-loaded", meta: doc.meta, @@ -1164,7 +1384,7 @@ export class Client implements ConfigContainer { if (editorView.contentDOM) { this.tweakEditorDOM(editorView.contentDOM); } - this.space.watchPage(pageName); + this.space.watchFile(`${pageName}.md`); } else { // Just apply minimal patches so that the cursor is preserved this.setEditorText(doc.text, true); @@ -1172,7 +1392,11 @@ export class Client implements ConfigContainer { // Note: these events are dispatched asynchronously deliberately (not waiting for results) if (loadingDifferentPage) { - this.eventHook.dispatchEvent("editor:pageLoaded", pageName, previousPage) + this.eventHook.dispatchEvent( + "editor:pageLoaded", + pageName, + previousPath.slice(0, -3), + ) .catch( console.error, ); @@ -1194,6 +1418,65 @@ export class Client implements ConfigContainer { ); } + 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-ignore: This is there the hacked type-guard from isDocumentEditor fails + this.documentEditor = null; + + this.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, + (path, content) => { + this.space + .writeDocument(path, content, true) + .then(async (meta) => { + this.ui.viewDispatch({ type: "document-editor-saved" }); + + await this.dispatchAppEvent( + "editor:documentSaved", + path, + meta, + ); + }) + .catch(() => { + this.flashNotification( + "Could not save document, retrying again in 10 seconds", + "error", + ); + this.saveTimeout = setTimeout(this.save.bind(this), 10000); + }); + }, + ); + + await this.documentEditor.init(this, 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.rebuildEditorState(); + this.editorView.contentDOM.blur(); + } + tweakEditorDOM(contentDOM: HTMLElement) { contentDOM.spellcheck = true; contentDOM.setAttribute("autocorrect", "on"); @@ -1209,6 +1492,17 @@ export class Client implements ConfigContainer { }); } + openUrl(url: string, existingWindow = false) { + if (!existingWindow) { + const win = globalThis.open(url, "_blank"); + if (win) { + win.focus(); + } + } else { + location.href = url; + } + } + async loadCustomStyles() { const spaceStyles = await this.clientSystem.queryObjects( "space-style", @@ -1254,6 +1548,7 @@ export class Client implements ConfigContainer { getCommandsByContext( state: AppViewState, ): Map { + const currentEditor = client.documentEditor?.name; const commands = new Map(state.commands); for (const [k, v] of state.commands.entries()) { if ( @@ -1263,6 +1558,11 @@ export class Client implements ConfigContainer { ) { commands.delete(k); } + + const requiredEditor = v.command.requireEditor; + if (!isValidEditor(currentEditor, requiredEditor)) { + commands.delete(k); + } } return commands; diff --git a/web/client_system.ts b/web/client_system.ts index 53c0f1a0..1cc961c2 100644 --- a/web/client_system.ts +++ b/web/client_system.ts @@ -48,6 +48,7 @@ import { luaSyscalls } from "$common/syscalls/lua.ts"; import { indexSyscalls } from "$common/syscalls/index.ts"; import { commandSyscalls } from "$common/syscalls/command.ts"; import { eventListenerSyscalls } from "$common/syscalls/event.ts"; +import { DocumentEditorHook } from "./hooks/document_editor.ts"; const plugNameExtractRegex = /\/(.+)\.plug\.js$/; @@ -98,6 +99,10 @@ export class ClientSystem extends CommonSystem { this.panelWidgetHook = new PanelWidgetHook(); this.system.addHook(this.panelWidgetHook); + // Document editor hook + this.documentEditorHook = new DocumentEditorHook(); + this.system.addHook(this.documentEditorHook); + // MQ hook if (client.syncMode) { // Process MQ messages locally diff --git a/web/cm_plugins/editor_paste.ts b/web/cm_plugins/editor_paste.ts index 54b39189..597cf87b 100644 --- a/web/cm_plugins/editor_paste.ts +++ b/web/cm_plugins/editor_paste.ts @@ -14,7 +14,7 @@ import { findParentMatching, nodeAtPos, } from "@silverbulletmd/silverbullet/lib/tree"; -import { defaultLinkStyle, maximumAttachmentSize } from "../constants.ts"; +import { defaultLinkStyle, maximumDocumentSize } from "../constants.ts"; import { safeRun } from "$lib/async.ts"; import { resolvePath } from "@silverbulletmd/silverbullet/lib/resolve"; import { localDateString } from "$lib/dates.ts"; @@ -80,7 +80,7 @@ export const pasteLinkExtension = ViewPlugin.fromClass( }, ); -export function attachmentExtension(editor: Client) { +export function documentExtension(editor: Client) { let shiftDown = false; return EditorView.domEventHandlers({ dragover: (event) => { @@ -100,7 +100,7 @@ export function attachmentExtension(editor: Client) { }, drop: (event: DragEvent) => { // TODO: This doesn't take into account the target cursor position, - // it just drops the attachment wherever the cursor was last. + // it just drops the document wherever the cursor was last. if (event.dataTransfer) { const payload = [...event.dataTransfer.files]; if (!payload.length) { @@ -172,7 +172,7 @@ export function attachmentExtension(editor: Client) { async function processFileTransfer(payload: File[]) { const data = await payload[0].arrayBuffer(); - // data.byteLength > maximumAttachmentSize; + // data.byteLength > maximumDocumentSize; const fileData: UploadFile = { name: payload[0].name, contentType: payload[0].type, @@ -205,40 +205,40 @@ export function attachmentExtension(editor: Client) { } async function saveFile(file: UploadFile) { - const maxSize = editor.config.maximumAttachmentSize || - maximumAttachmentSize; + const maxSize = editor.config.maximumDocumentSize || + maximumDocumentSize; if (file.content.length > maxSize * 1024 * 1024) { editor.flashNotification( - `Attachment is too large, maximum is ${maxSize}MiB`, + `Document is too large, maximum is ${maxSize}MiB`, "error", ); return; } const finalFileName = await editor.prompt( - "File name for pasted attachment", + "File name for pasted document", file.name, ); if (!finalFileName) { return; } - const attachmentPath = resolvePath(editor.currentPage, finalFileName); - await editor.space.writeAttachment(attachmentPath, file.content); + const documentPath = resolvePath(editor.currentPage, finalFileName); + await editor.space.writeDocument(documentPath, file.content); const linkStyle = editor.config.defaultLinkStyle || defaultLinkStyle.toLowerCase(); - let attachmentMarkdown = ""; + let documentMarkdown = ""; if (linkStyle === "wikilink") { - attachmentMarkdown = `[[${attachmentPath}]]`; + documentMarkdown = `[[${documentPath}]]`; } else { - attachmentMarkdown = `[${finalFileName}](${encodeURI(finalFileName)})`; + documentMarkdown = `[${finalFileName}](${encodeURI(finalFileName)})`; } if (file.contentType.startsWith("image/")) { - attachmentMarkdown = "!" + attachmentMarkdown; + documentMarkdown = "!" + documentMarkdown; } editor.editorView.dispatch({ changes: [ { - insert: attachmentMarkdown, + insert: documentMarkdown, from: editor.editorView.state.selection.main.from, }, ], diff --git a/web/cm_plugins/lua_directive.ts b/web/cm_plugins/lua_directive.ts index 9285ae4d..6ce076ec 100644 --- a/web/cm_plugins/lua_directive.ts +++ b/web/cm_plugins/lua_directive.ts @@ -23,6 +23,7 @@ import { LuaRuntimeError } from "$common/space_lua/runtime.ts"; import { encodePageRef } from "@silverbulletmd/silverbullet/lib/page_ref"; import { resolveASTReference } from "$common/space_lua.ts"; import { LuaWidget } from "./lua_widget.ts"; +import type { PageMeta } from "@silverbulletmd/silverbullet/types"; export function luaDirectivePlugin(client: Client) { return decoratorStateField((state: EditorState) => { @@ -43,7 +44,7 @@ export function luaDirectivePlugin(client: Client) { } const text = state.sliceDoc(node.from + 2, node.to - 1); - const currentPageMeta = client.ui.viewState.currentPageMeta; + const currentPageMeta = client.ui.viewState.current?.meta as PageMeta; widgets.push( Decoration.widget({ widget: new LuaWidget( @@ -62,7 +63,7 @@ export function luaDirectivePlugin(client: Client) { tl.setLocal( "currentPage", currentPageMeta || - { name: client.ui.viewState.currentPage }, + { name: client.ui.viewState.current?.path }, ); const sf = LuaStackFrame.createWithGlobalEnv( client.clientSystem.spaceLuaEnv.env, diff --git a/web/cm_plugins/util.ts b/web/cm_plugins/util.ts index 3cb31ccd..c0b9eb7c 100644 --- a/web/cm_plugins/util.ts +++ b/web/cm_plugins/util.ts @@ -9,6 +9,7 @@ import { import type { DecorationSet } from "@codemirror/view"; import { Decoration, EditorView, WidgetType } from "@codemirror/view"; import type { Client } from "../client.ts"; +import type { PageMeta } from "@silverbulletmd/silverbullet/types"; type LinkOptions = { text: string; @@ -177,7 +178,7 @@ export function isCursorInRange(state: EditorState, range: [number, number]) { export const invisibleDecoration = Decoration.replace({}); export function shouldRenderWidgets(client: Client) { - const currentPageMeta = client.ui.viewState.currentPageMeta; + const currentPageMeta = client.ui.viewState.current?.meta as PageMeta; return !currentPageMeta?.tags?.includes("template") && currentPageMeta?.pageDecoration?.renderWidgets !== false && !currentPageMeta?.name.startsWith("!"); diff --git a/web/cm_plugins/widget_util.ts b/web/cm_plugins/widget_util.ts index 39a9ddf5..a2357c7b 100644 --- a/web/cm_plugins/widget_util.ts +++ b/web/cm_plugins/widget_util.ts @@ -46,6 +46,7 @@ export function attachWidgetEventHandlers( return; } client.navigate({ + kind: "page", page: `${tagPrefix}${extractHashtag(el.innerText)}`, pos: 0, }); diff --git a/web/components/filter.tsx b/web/components/filter.tsx index 8b84ab90..d56b718f 100644 --- a/web/components/filter.tsx +++ b/web/components/filter.tsx @@ -231,7 +231,7 @@ export function FilterList({ {option.name} - {option.hint && {option.hint}} + {option.hint && {option.hint}}
{option.description}
)) diff --git a/web/components/page_navigator.tsx b/web/components/page_navigator.tsx index 453c4223..ee6ae10b 100644 --- a/web/components/page_navigator.tsx +++ b/web/components/page_navigator.tsx @@ -4,7 +4,7 @@ import type { CompletionContext, CompletionResult, } from "@codemirror/autocomplete"; -import type { PageMeta } from "@silverbulletmd/silverbullet/types"; +import type { DocumentMeta, PageMeta } from "@silverbulletmd/silverbullet/types"; import { tagRegex as mdTagRegex } from "$common/markdown_parser/constants.ts"; import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags"; @@ -12,116 +12,163 @@ const tagRegex = new RegExp(mdTagRegex.source, "g"); export function PageNavigator({ allPages, + allDocuments, + extensions, onNavigate, onModeSwitch, completer, vimMode, mode, darkMode, - currentPage, + currentPath, }: { + allDocuments: DocumentMeta[]; allPages: PageMeta[]; + extensions: Set; vimMode: boolean; darkMode: boolean; - mode: "page" | "meta" | "all"; - onNavigate: (page: string | undefined) => void; - onModeSwitch: (mode: "page" | "meta" | "all") => void; + mode: "page" | "meta" | "document" | "all"; + onNavigate: (page: string | undefined, type: "document" | "page") => void; + onModeSwitch: (mode: "page" | "meta" | "document" | "all") => void; completer: (context: CompletionContext) => Promise; - currentPage?: string; + currentPath?: string; }) { const options: FilterOption[] = []; - for (const pageMeta of allPages) { - // Sanitize the page name - if (!pageMeta.name) { - pageMeta.name = pageMeta.ref; - } - // Order by last modified date in descending order - let orderId = -new Date(pageMeta.lastModified).getTime(); - // Unless it was opened in this session - if (pageMeta.lastOpened) { - orderId = -pageMeta.lastOpened; - } - // Or it's the currently open page - if (currentPage && currentPage === pageMeta.name || pageMeta._isAspiring) { - // ... then we put it all the way to the end - orderId = Infinity; - } - const cssClass = (pageMeta.pageDecoration?.cssClasses || []).join(" ") - .replaceAll(/[^a-zA-Z0-9-_ ]/g, ""); - if (mode === "page") { - // Special behavior for regular pages + if (mode === "document" || mode === "all") { + for (const documentMeta of allDocuments) { + const isViewable = extensions.has(documentMeta.extension); + + let orderId = isViewable ? -new Date(documentMeta.lastModified).getTime() : (Number.MAX_VALUE - new Date(documentMeta.lastModified).getTime()); + + if (currentPath && currentPath === documentMeta.name) { + orderId = 0; + } + + // Can't really add tags to document as of right now, but maybe in the future let description: string | undefined; - let aliases: string[] = []; - if (pageMeta.displayName) { - aliases.push(pageMeta.displayName); - } - if (Array.isArray(pageMeta.aliases)) { - aliases = aliases.concat(pageMeta.aliases); - } - if (aliases.length > 0) { - description = "(a.k.a. " + aliases.join(", ") + ") "; - } - if (pageMeta.tags) { + if (documentMeta.tags) { description = (description || "") + - pageMeta.tags.map((tag) => `#${tag}`).join(" "); + documentMeta.tags.map((tag) => `#${tag}`).join(" "); } + + if (!isViewable && client.clientSystem.readOnlyMode) continue; + options.push({ - ...pageMeta, - name: (pageMeta.pageDecoration?.prefix ?? "") + pageMeta.name, + type: "document", + ...documentMeta, + name: documentMeta.name, description, orderId: orderId, - hint: pageMeta._isAspiring ? "Create page" : undefined, - cssClass, - }); - } else if (mode === "meta") { - // Special behavior for #template and #meta pages - if (pageMeta._isAspiring) { - // Skip over broken links - continue; - } - options.push({ - ...pageMeta, - // Use the displayName or last bit of the path as the name - name: pageMeta.displayName || pageMeta.name.split("/").pop()!, - // And use the full path as the description - description: pageMeta.name, - hint: pageMeta.tags![0], - orderId: orderId, - cssClass, - }); - } else { // all - // In mode "all" just show the full path and all tags - let description: string | undefined; - if (pageMeta.tags) { - description = (description || "") + - pageMeta.tags.map((tag) => `#${tag}`).join(" "); - } - options.push({ - ...pageMeta, - name: pageMeta.name, - description, - orderId: orderId, - cssClass, + hint: documentMeta.name.split(".").pop()?.toUpperCase(), + hintInactive: !isViewable, }); } } - let completePrefix = currentPage + "/"; - if (currentPage && currentPage.includes("/")) { - const pieces = currentPage.split("/"); + + if (mode !== "document") { + for (const pageMeta of allPages) { + // Sanitize the page name + if (!pageMeta.name) { + pageMeta.name = pageMeta.ref; + } + // Order by last modified date in descending order + let orderId = -new Date(pageMeta.lastModified).getTime(); + // Unless it was opened in this session + if (pageMeta.lastOpened) { + orderId = -pageMeta.lastOpened; + } + // Or it's the currently open page + if (currentPath && currentPath === pageMeta.name || pageMeta._isAspiring) { + // ... then we put it all the way to the end + orderId = Infinity; + } + const cssClass = (pageMeta.pageDecoration?.cssClasses || []).join(" ") + .replaceAll(/[^a-zA-Z0-9-_ ]/g, ""); + + if (mode === "page") { + // Special behavior for regular pages + let description: string | undefined; + let aliases: string[] = []; + if (pageMeta.displayName) { + aliases.push(pageMeta.displayName); + } + if (Array.isArray(pageMeta.aliases)) { + aliases = aliases.concat(pageMeta.aliases); + } + if (aliases.length > 0) { + description = "(a.k.a. " + aliases.join(", ") + ") "; + } + if (pageMeta.tags) { + description = (description || "") + + pageMeta.tags.map((tag) => `#${tag}`).join(" "); + } + options.push({ + type: "page", + ...pageMeta, + name: (pageMeta.pageDecoration?.prefix ?? "") + pageMeta.name, + description, + orderId: orderId, + hint: pageMeta._isAspiring ? "Create page" : undefined, + cssClass, + }); + } else if (mode === "meta") { + // Special behavior for #template and #meta pages + if (pageMeta._isAspiring) { + // Skip over broken links + continue; + } + options.push({ + type: "page", + ...pageMeta, + // Use the displayName or last bit of the path as the name + name: pageMeta.displayName || pageMeta.name.split("/").pop()!, + // And use the full path as the description + description: pageMeta.name, + hint: pageMeta.tags![0], + orderId: orderId, + cssClass, + }); + } else { // all + // In mode "all" just show the full path and all tags + let description: string | undefined; + if (pageMeta.tags) { + description = (description || "") + + pageMeta.tags.map((tag) => `#${tag}`).join(" "); + } + options.push({ + type: "page", + ...pageMeta, + name: pageMeta.name, + description, + orderId: orderId, + cssClass, + }); + } + } + } + + let completePrefix = currentPath + "/"; + if (currentPath && currentPath.includes("/")) { + const pieces = currentPath.split("/"); completePrefix = pieces.slice(0, pieces.length - 1).join("/") + "/"; - } else if (currentPage && currentPage.includes(" ")) { - completePrefix = currentPage.split(" ")[0] + " "; + } else if (currentPath && currentPath.includes(" ")) { + completePrefix = currentPath.split(" ")[0] + " "; } - const pageNoun = mode === "meta" ? mode : "page"; + const allowNew = mode !== "document"; + const creatablePageNoun = mode !== "all" ? mode : "page"; + const openablePageNoun = mode !== "all" ? mode : "page or document"; + return ( Enter to open the selected ${pageNoun}, or Shift-Enter to create a new ${pageNoun} with this exact name.`} - newHint={`Create ${pageNoun}`} + allowNew={allowNew} + helpText={`Press Enter to open the selected ${openablePageNoun}` + (allowNew ? `, or Shift-Enter to create a new ${creatablePageNoun} with this exact name.` : "")} + newHint={`Create ${creatablePageNoun}`} completePrefix={completePrefix} onSelect={(opt) => { - onNavigate(opt?.ref || opt?.name); + onNavigate(opt?.ref || opt?.name, opt?.type); }} /> ); diff --git a/web/constants.ts b/web/constants.ts index 9f3db89e..adf3bed5 100644 --- a/web/constants.ts +++ b/web/constants.ts @@ -1,2 +1,2 @@ -export const maximumAttachmentSize = 10; // MiB +export const maximumDocumentSize = 10; // MiB export const defaultLinkStyle = "wikilink"; diff --git a/web/document_editor.ts b/web/document_editor.ts new file mode 100644 index 00000000..e6cac56f --- /dev/null +++ b/web/document_editor.ts @@ -0,0 +1,206 @@ +import type { DocumentMeta } from "@silverbulletmd/silverbullet/types"; +import type { Client } from "./client.ts"; +import { html as skeleton } from "./document_editor_skeleton.ts"; +import { timeout } from "$lib/async.ts"; + +export class DocumentEditor { + iframe!: HTMLIFrameElement; + name!: string; + extension!: string; + currentPath: string | null = null; + savePromise: PromiseWithResolvers | null = null; + + constructor( + readonly parent: HTMLElement, + readonly client: Client, + readonly saveMethod: (path: string, content: Uint8Array) => void, + ) {} + + async init(client: Client, extension: string) { + this.extension = extension; + + const entry = Array.from( + client.clientSystem.documentEditorHook.documentEditors + .entries(), + ).find(([_, { extensions }]) => extensions.includes(this.extension)); + + if (!entry) { + throw new Error("Couldn't find plug for specified extension"); + } + + const [name, { callback }] = entry; + this.name = name; + + const content = await callback(); + + const [iframe, finished] = this.createIframe(); + this.iframe = iframe; + + this.parent.appendChild(this.iframe); + + await finished; + + this.sendMessage({ + type: "internal-init", + html: content.html, + script: content.script, + theme: document.getElementsByTagName("html")[0].dataset.theme, + }); + } + + async destroy() { + // If name isn't initalized the editor is probably dead + if (!this.name) return; + + await this.waitForSave(); + + globalThis.removeEventListener("message", this.messageHandler); + this.iframe.remove(); + } + + private async waitForSave() { + if (this.savePromise) { + try { + await Promise.race([ + this.savePromise.promise, + timeout(2500), + ]); + } catch { + this.savePromise.resolve(); + this.savePromise = null; + + console.log( + "Unable to save content of document editor in 2.5s. Aborting save", + ); + } + } + } + + private sendMessage(message: { type: string } & any) { + if (!this.iframe?.contentWindow) return; + this.iframe.contentWindow.postMessage(message); + } + + setContent(data: Uint8Array, meta: DocumentMeta) { + this.sendMessage({ + type: "file-open", + data, + meta, + }); + + this.currentPath = meta.name; + } + + async changeContent(data: Uint8Array, meta: DocumentMeta) { + await this.waitForSave(); + + this.sendMessage({ + type: "file-update", + data, + meta, + }); + } + + requestSave() { + if (this.savePromise) { + console.log( + "Save was already requested from editor, trying again anyways", + ); + } else { + this.savePromise = Promise.withResolvers(); + } + + this.sendMessage({ + type: "request-save", + }); + } + + focus() { + this.sendMessage({ + type: "focus", + }); + } + + private async messageHandler(event: any) { + if (event.source !== this.iframe.contentWindow) return; + const data = event.data; + if (!data) return; + + switch (data.type) { + case "file-changed": + { + this.client.ui.viewDispatch({ + type: "document-editor-changed", + }); + this.client.save().catch((e) => console.error("Couldn't save: ", e)); + } + break; + case "file-saved": + { + this.savePromise?.resolve(); + this.savePromise = null; + + if (!this.currentPath) return; + this.saveMethod(this.currentPath, data.data); + } + break; + case "internal-syscall": + { + let result: any; + + try { + const response = await this.client.clientSystem.localSyscall( + data.name, + data.args, + ); + + result = { result: response }; + } catch (e: any) { + result = { error: e.message }; + } + + this.sendMessage({ + type: "internal-syscall-response", + id: data.id, + ...result, + }); + } + break; + default: + console.warn("Unknown event sent from plug: ", data.type); + } + } + + private createIframe(): [HTMLIFrameElement, Promise] { + // Note: In the future we could maybe cache this iframe, for now it is not nearly necessary + const iframe = document.createElement("iframe"); + + iframe.src = "about:blank"; + iframe.style.visibility = "hidden"; + + const ready = new Promise((resolve) => { + iframe.onload = () => { + iframe.contentDocument!.write(skeleton); + iframe.style.visibility = "visible"; + resolve(); + }; + }); + + const finished = new Promise((resolve) => { + ready.then(() => { + globalThis.addEventListener("message", this.messageHandler.bind(this)); + + iframe.onload = null; + + if (!iframe.contentWindow) { + console.warn("Iframe went away or content was not loaded"); + return; + } + + resolve(); + }); + }); + + return [iframe, finished]; + } +} diff --git a/web/document_editor_skeleton.ts b/web/document_editor_skeleton.ts new file mode 100644 index 00000000..e8899de9 --- /dev/null +++ b/web/document_editor_skeleton.ts @@ -0,0 +1,96 @@ +export const html = ` + + + + + + + + + + + + +`; diff --git a/web/editor_state.ts b/web/editor_state.ts index c9c284d4..eb3745b4 100644 --- a/web/editor_state.ts +++ b/web/editor_state.ts @@ -41,7 +41,7 @@ import { lineWrapper } from "./cm_plugins/line_wrapper.ts"; import { createSmartQuoteKeyBindings } from "./cm_plugins/smart_quotes.ts"; import type { ClickEvent } from "../plug-api/types.ts"; import { - attachmentExtension, + documentExtension, pasteLinkExtension, } from "./cm_plugins/editor_paste.ts"; import type { TextChange } from "./change.ts"; @@ -54,6 +54,7 @@ import { parseCommand } from "$common/command.ts"; import { safeRun } from "$lib/async.ts"; import { codeCopyPlugin } from "./cm_plugins/code_copy.ts"; import { disableSpellcheck } from "./cm_plugins/spell_checking.ts"; +import { isValidEditor } from "$lib/command.ts"; export function createEditorState( client: Client, @@ -291,7 +292,7 @@ export function createEditorState( }, ), pasteLinkExtension, - attachmentExtension(client), + documentExtension(client), closeBrackets(), ], }); @@ -337,7 +338,10 @@ export function createCommandKeyBindings(client: Client): KeyBinding[] { // Then add bindings for plug commands for (const def of client.clientSystem.commandHook.editorCommands.values()) { - if (def.command.key) { + const currentEditor = client.documentEditor?.name; + const requiredEditor = def.command.requireEditor; + + if (def.command.key && isValidEditor(currentEditor, requiredEditor)) { // If we've already overridden this command, skip it if (overriddenCommands.has(def.command.name)) { continue; @@ -377,24 +381,30 @@ export function createCommandKeyBindings(client: Client): KeyBinding[] { } export function createKeyBindings(client: Client): Extension { - return keymap.of([ - ...createCommandKeyBindings(client), - ...createSmartQuoteKeyBindings(client), - ...closeBracketsKeymap, - ...client.ui.viewState.uiOptions.vimMode - ? [ - // Workaround for https://github.com/replit/codemirror-vim/issues/182; - // without this, Enter does nothing for ordinary paragraphs in insert - // mode. - { - key: "Enter", - run: insertNewlineAndIndent, - shift: insertNewlineAndIndent, - }, - ] - : standardKeymap, - ...completionKeymap, - { key: "Tab", run: acceptCompletion }, - indentWithTab, - ]); + if (client.isDocumentEditor()) { + return keymap.of([ + ...createCommandKeyBindings(client), + ]); + } else { + return keymap.of([ + ...createCommandKeyBindings(client), + ...createSmartQuoteKeyBindings(client), + ...closeBracketsKeymap, + ...client.ui.viewState.uiOptions.vimMode + ? [ + // Workaround for https://github.com/replit/codemirror-vim/issues/182; + // without this, Enter does nothing for ordinary paragraphs in insert + // mode. + { + key: "Enter", + run: insertNewlineAndIndent, + shift: insertNewlineAndIndent, + }, + ] + : standardKeymap, + ...completionKeymap, + { key: "Tab", run: acceptCompletion }, + indentWithTab, + ]); + } } diff --git a/web/editor_ui.tsx b/web/editor_ui.tsx index 2efa0ee3..d0f5de59 100644 --- a/web/editor_ui.tsx +++ b/web/editor_ui.tsx @@ -16,6 +16,7 @@ import { Panel } from "./components/panel.tsx"; import { safeRun, sleep } from "../lib/async.ts"; import { parseCommand } from "$common/command.ts"; import { defaultActionButtons } from "@silverbulletmd/silverbullet/type/config"; +import type { FilterOption } from "@silverbulletmd/silverbullet/type/client"; export class MainUI { viewState: AppViewState = initialViewState; @@ -38,7 +39,6 @@ export class MainUI { // In some cm element, let's back out return; } - console.log("Delegated keydown", ev, "to editor"); if (runScopeHandlers(client.editorView, ev, "editor")) { ev.preventDefault(); } @@ -78,12 +78,12 @@ export class MainUI { const client = this.client; useEffect(() => { - if (viewState.currentPage) { + if (viewState.current) { document.title = - (viewState.currentPageMeta?.pageDecoration?.prefix ?? "") + - viewState.currentPage; + (viewState.current.meta?.pageDecoration?.prefix ?? "") + + viewState.current.path; } - }, [viewState.currentPage, viewState.currentPageMeta]); + }, [viewState.current]); useEffect(() => { client.tweakEditorDOM( @@ -111,8 +111,10 @@ export class MainUI { <> {viewState.showPageNavigator && ( extensions))} + currentPath={client.currentPath()} mode={viewState.pageNavigatorMode} completer={client.miniEditorComplete.bind(client)} vimMode={viewState.uiOptions.vimMode} @@ -123,16 +125,39 @@ export class MainUI { dispatch({ type: "start-navigate", mode }); }); }} - onNavigate={(page) => { + onNavigate={(name, type) => { dispatch({ type: "stop-navigate" }); setTimeout(() => { client.focus(); }); - if (page) { - safeRun(async () => { - await client.navigate({ page }); - }); - } + if (!name) return; + + safeRun(async () => { + const documentMeta = viewState.allDocuments.find((document) => document.name === name); + + if (type === "document" && !Array.from(client.clientSystem.documentEditorHook.documentEditors.values()).some(({extensions}) => extensions.includes(documentMeta!.extension))) { + const options: string[] = ["Delete", "Rename"] + + const option = await client.filterBox("Modify", options.map(x => ({name: x} as FilterOption)), "There is no editor for this file type. Modify the selected document"); + if (!option) return; + + switch (option.name) { + case "Delete": { + client.space.deleteDocument(name) + return; + } + case "Rename": { + await client.clientSystem.system.invokeFunction( + "index.renameDocumentCommand", + [{ oldDocument: name }], + ); + return; + } + } + } else { + await client.navigate({ kind: type, page: name }); + } + }); }} /> )} @@ -201,7 +226,7 @@ export class MainUI { /> )} { - client.editorView.scrollDOM.scrollTop = 0; + if (!client.isDocumentEditor()) { + client.editorView.scrollDOM.scrollTop = 0; + } }} onRename={async (newName) => { - if (!newName) { - // Always move cursor to the start of the page - client.editorView.dispatch({ - selection: { anchor: 0 }, - }); + if (client.isDocumentEditor()) { + if (!newName) return; + + console.log("Now renaming document to...", newName); + await client.clientSystem.system.invokeFunction( + "index.renameDocumentCommand", + [{ document: newName }], + ); + } else { + if (!newName) { + // Always move cursor to the start of the page + client.editorView.dispatch({ + selection: { anchor: 0 }, + }); + client.focus(); + return; + } + console.log("Now renaming page to...", newName); + await client.clientSystem.system.invokeFunction( + "index.renamePageCommand", + [{ page: newName }], + ); client.focus(); - return; } - console.log("Now renaming page to...", newName); - await client.clientSystem.system.invokeFunction( - "index.renamePageCommand", - [{ page: newName }], - ); - client.focus(); }} actionButtons={[ // Sync button @@ -327,11 +364,11 @@ export class MainUI { style={{ flex: viewState.panels.lhs.mode }} /> )} - pageNamePrefix={viewState.currentPageMeta?.pageDecoration + pageNamePrefix={viewState.current?.meta?.pageDecoration ?.prefix ?? ""} - cssClass={viewState.currentPageMeta?.pageDecoration?.cssClasses - ? viewState.currentPageMeta?.pageDecoration?.cssClasses + cssClass={viewState.current?.meta?.pageDecoration?.cssClasses + ? viewState.current?.meta?.pageDecoration?.cssClasses .join(" ").replaceAll(/[^a-zA-Z0-9-_ ]/g, "") : ""} /> diff --git a/web/hooks/document_editor.ts b/web/hooks/document_editor.ts new file mode 100644 index 00000000..c723982e --- /dev/null +++ b/web/hooks/document_editor.ts @@ -0,0 +1,76 @@ +import type { Hook, Manifest } from "$lib/plugos/types.ts"; +import type { DocumentEditorT } from "$lib/manifest.ts"; +import type { DocumentEditorCallback } from "@silverbulletmd/silverbullet/types"; +import type { System } from "$lib/plugos/system.ts"; + +export class DocumentEditorHook implements Hook { + documentEditors = new Map< + string, + { extensions: string[]; callback: DocumentEditorCallback } + >(); + + constructor() {} + + collectAllDocumentEditors(system: System) { + this.documentEditors.clear(); + for (const plug of system.loadedPlugs.values()) { + for ( + const [name, functionDef] of Object.entries( + plug.manifest!.functions, + ) + ) { + if (!functionDef.editor) { + continue; + } + + const keys = Array.isArray(functionDef.editor) + ? functionDef.editor + : [functionDef.editor]; + + const conflict = Array.from(this.documentEditors.entries()).find(( + [_, { extensions }], + ) => keys.some((key) => extensions.includes(key))); + + if (conflict) { + console.log( + `Extension definition of document editor ${name}: [${keys}] conflicts with the one from ${ + conflict[0] + }: [${conflict[1].extensions}]! Using the latter.`, + ); + } + + this.documentEditors.set( + name, + { extensions: keys, callback: () => plug.invoke(name, []) }, + ); + } + } + } + + apply(system: System): void { + this.collectAllDocumentEditors(system); + system.on({ + plugLoaded: () => { + this.collectAllDocumentEditors(system); + }, + }); + } + + validateManifest(manifest: Manifest): string[] { + const errors = []; + for (const functionDef of Object.values(manifest.functions)) { + if (!functionDef.editor) { + continue; + } + if ( + typeof functionDef.editor !== "string" && + !Array.isArray(functionDef.editor) + ) { + errors.push( + `Document editors require a string name or an array of string names.`, + ); + } + } + return errors; + } +} diff --git a/web/navigator.ts b/web/navigator.ts index 5a81df2b..06223360 100644 --- a/web/navigator.ts +++ b/web/navigator.ts @@ -1,26 +1,30 @@ import { + type DocumentRef, encodePageURI, + type LocationRef, type PageRef, - parsePageRef, + parseLocationRef, } from "../plug-api/lib/page_ref.ts"; import type { Client } from "./client.ts"; import { cleanPageRef } from "@silverbulletmd/silverbullet/lib/resolve"; import { renderTheTemplate } from "$common/syscalls/template.ts"; import { safeRun } from "../lib/async.ts"; -export type PageState = PageRef & { - scrollTop?: number; - selection?: { - anchor: number; - head?: number; - }; -}; +export type LocationState = + | PageRef & { + scrollTop?: number; + selection?: { + anchor: number; + head?: number; + }; + } + | DocumentRef; export class PathPageNavigator { navigationResolve?: () => void; indexPage!: string; - openPages = new Map(); + openLocations = new Map(); constructor( private client: Client, @@ -47,16 +51,24 @@ export class PathPageNavigator { * @param replaceState whether to update the state in place (rather than to push a new state) */ async navigate( - pageRef: PageRef, + pathRef: LocationRef, replaceState = false, ) { - if (pageRef.page === this.indexPage) { - pageRef.page = ""; + if (pathRef.kind === "page" && pathRef.page === this.indexPage) { + pathRef.page = ""; } - const currentState = this.buildCurrentPageState(); + const currentState = this.buildCurrentLocationState(); // No need to keep pos and anchor if we already have scrollTop and selection - const cleanState = { ...currentState, pos: undefined, anchor: undefined }; - this.openPages.set(currentState.page || this.indexPage, cleanState); + const cleanState: LocationState = currentState.kind === "page" + ? { + ...currentState, + pos: undefined, + anchor: undefined, + } + : currentState; + + this.openLocations.set(currentState.page || this.indexPage, cleanState); + if (!replaceState) { globalThis.history.replaceState( cleanState, @@ -64,60 +76,72 @@ export class PathPageNavigator { `/${encodePageURI(currentState.page)}`, ); globalThis.history.pushState( - pageRef, + pathRef, "", - `/${encodePageURI(pageRef.page)}`, + `/${encodePageURI(pathRef.page)}`, ); } else { globalThis.history.replaceState( - pageRef, + pathRef, "", - `/${encodePageURI(pageRef.page)}`, + `/${encodePageURI(pathRef.page)}`, ); } + globalThis.dispatchEvent( new PopStateEvent("popstate", { - state: pageRef, + state: pathRef, }), ); + await new Promise((resolve) => { this.navigationResolve = resolve; }); this.navigationResolve = undefined; } - buildCurrentPageState(): PageState { - const pageState: PageState = parsePageRefFromURI(); - const mainSelection = this.client.editorView.state.selection.main; - pageState.scrollTop = this.client.editorView.scrollDOM.scrollTop; - pageState.selection = { - head: mainSelection.head, - anchor: mainSelection.anchor, - }; - return pageState; + buildCurrentLocationState(): LocationState { + const locationState: LocationState = parseLocationRefFromURI(); + + if ( + locationState.kind === "page" + ) { + const editorView = this.client.editorView; + + const mainSelection = editorView.state.selection.main; + locationState.scrollTop = editorView.scrollDOM.scrollTop; + locationState.selection = { + head: mainSelection.head, + anchor: mainSelection.anchor, + }; + } + + return locationState; } subscribe( pageLoadCallback: ( - pageState: PageState, + locationState: LocationState, ) => Promise, ): void { const cb = (event: PopStateEvent) => { safeRun(async () => { - const popState = event.state; + const popState = event.state as LocationState; if (popState) { // This is the usual case if (!popState.page) { + popState.kind = "page"; popState.page = this.indexPage; } if ( + popState.kind === "page" && popState.anchor === undefined && popState.pos === undefined && popState.selection === undefined && popState.scrollTop === undefined ) { // Pretty low-context popstate, so let's leverage openPages - const openPage = this.openPages.get(popState.page); - if (openPage) { + const openPage = this.openLocations.get(popState.page); + if (openPage && openPage.kind === "page") { popState.selection = openPage.selection; popState.scrollTop = openPage.scrollTop; } @@ -125,8 +149,9 @@ export class PathPageNavigator { await pageLoadCallback(popState); } else { // This occurs when the page is loaded completely fresh with no browser history around it - const pageRef = parsePageRefFromURI(); + const pageRef = parseLocationRefFromURI(); if (!pageRef.page) { + pageRef.kind = "page"; pageRef.page = this.indexPage; } await pageLoadCallback(pageRef); @@ -140,20 +165,20 @@ export class PathPageNavigator { cb( new PopStateEvent("popstate", { - state: this.buildCurrentPageState(), + state: this.buildCurrentLocationState(), }), ); } } -export function parsePageRefFromURI(): PageRef { - const pageRef = parsePageRef(decodeURIComponent( +export function parseLocationRefFromURI(): LocationRef { + const locationRef = parseLocationRef(decodeURIComponent( location.pathname.substring(1), )); - if (location.hash) { - pageRef.header = decodeURIComponent(location.hash.substring(1)); + if (location.hash && locationRef.kind === "page") { + locationRef.header = decodeURIComponent(location.hash.substring(1)); } - return pageRef; + return locationRef; } diff --git a/web/reducer.ts b/web/reducer.ts index f682398f..a7e8f143 100644 --- a/web/reducer.ts +++ b/web/reducer.ts @@ -7,12 +7,55 @@ export default function reducer( ): AppViewState { // console.log("Got action", action); switch (action.type) { + case "document-editor-loading": + return { + ...state, + isLoading: false, + current: { + kind: "document", + path: action.name, + // Do a best effort job of filling in the meta data, as the page is not loaded yet + meta: { + ref: action.name, + tag: "document", + name: action.name, + contentType: "", + created: "", + lastModified: "", + size: 0, + perm: "rw", + extension: "", + }, + }, + }; + case "document-editor-loaded": + return { + ...state, + isLoading: false, + current: { + kind: "document", + path: action.meta.name, + meta: action.meta, + }, + }; case "page-loading": return { ...state, isLoading: true, - currentPage: action.name, - panels: state.currentPage === action.name ? state.panels : { + current: { + kind: "page", + path: action.name, + // Do a best effort job of filling in the meta data + meta: { + ref: action.name, + tag: "page", + name: action.name, + lastModified: "", + created: "", + perm: "rw", + }, + }, + panels: state.current?.path === action.name ? state.panels : { ...state.panels, // Hide these by default to avoid flickering top: {}, @@ -30,15 +73,20 @@ export default function reducer( ? { ...pageMeta, lastOpened: Date.now() } : pageMeta ), - currentPage: action.meta.name, - currentPageMeta: action.meta, + current: { + kind: "page", + path: action.meta.name, + meta: action.meta as PageMeta, + }, }; } + case "document-editor-changed": case "page-changed": return { ...state, unsavedChanges: true, }; + case "document-editor-saved": case "page-saved": { return { ...state, @@ -50,9 +98,14 @@ export default function reducer( state.allPages = state.allPages.map((pageMeta) => pageMeta.name === action.meta.name ? action.meta : pageMeta ); + // Can't update page meta if not on a page + if (state.current?.kind !== "page") return state; return { ...state, - currentPageMeta: action.meta, + current: { + ...state.current, + meta: action.meta, + }, }; } case "sync-change": @@ -76,7 +129,7 @@ export default function reducer( if (oldPageMetaItem && oldPageMetaItem.lastOpened) { pageMeta.lastOpened = oldPageMetaItem.lastOpened; } - if (pageMeta.name === state.currentPage) { + if (pageMeta.name === state.current?.path) { currPageMeta = pageMeta; } } @@ -85,10 +138,16 @@ export default function reducer( allPages: action.allPages, }; if (currPageMeta) { - newState.currentPageMeta = currPageMeta; + newState.current!.meta = currPageMeta; } return newState; } + case "update-document-list": { + return { + ...state, + allDocuments: action.allDocuments, + }; + } case "start-navigate": { return { ...state, diff --git a/web/service_worker.ts b/web/service_worker.ts index eb8132e7..6677b42f 100644 --- a/web/service_worker.ts +++ b/web/service_worker.ts @@ -108,8 +108,18 @@ self.addEventListener("fetch", (event: any) => { pathname.startsWith("/!") ) { return fetch(request); - } else if (looksLikePathWithExtension(pathname)) { - // If this is a /*.* request, this can either be a plug worker load or an attachment load + } else if ( + pathname.endsWith(".md") && + request.headers.get("accept") !== "application/octet-stream" && + request.headers.get("sec-fetch-mode") !== "cors" + ) { + return Response.redirect(`${pathname.slice(0, -3)}`); + } else if ( + (looksLikePathWithExtension(pathname) && + !request.headers.get("accept").includes("text/html")) || + requestUrl.searchParams.get("raw") === "true" + ) { + // If this is a /*.* request, this can either be a plug worker load or an document load return handleLocalFileRequest(request, pathname); } else { // Must be a page URL, let's serve index.html which will handle it diff --git a/web/styles/colors.scss b/web/styles/colors.scss index 9bc85f1c..1104a1e3 100644 --- a/web/styles/colors.scss +++ b/web/styles/colors.scss @@ -162,11 +162,16 @@ } .sb-result-list { - .sb-hint { + .sb-hint:not(.sb-hint-inactive) { color: var(--modal-hint-color); background-color: var(--modal-hint-background-color); } + .sb-hint.sb-hint-inactive { + color: var(--modal-hint-inactive-color); + background-color: var(--modal-hint-inactive-background-color); + } + .sb-description { color: var(--modal-description-color); } diff --git a/web/styles/main.scss b/web/styles/main.scss index b02dcfe8..63a53955 100644 --- a/web/styles/main.scss +++ b/web/styles/main.scss @@ -178,6 +178,17 @@ body { height: 100%; width: 100%; + + iframe { + width: 100%; + height: 100%; + } +} + +#sb-editor.hide-cm { + .cm-editor { + display: none !important; + } } .sb-bhs { diff --git a/web/styles/theme.scss b/web/styles/theme.scss index 805e12af..94b4ef1a 100644 --- a/web/styles/theme.scss +++ b/web/styles/theme.scss @@ -39,6 +39,8 @@ html { --modal-selected-option-color: var(--ui-accent-contrast-color); --modal-hint-background-color: #212476; --modal-hint-color: #eee; + --modal-hint-inactive-background-color: #e1e1e1; + --modal-hint-inactive-color: #111; --modal-description-color: #aaa; --notifications-background-color: inherit; @@ -179,6 +181,8 @@ html[data-theme="dark"] { --modal-selected-option-color: #eee; --modal-hint-background-color: #212476; --modal-hint-color: #eee; + --modal-hint-inactive-background-color: #353535; + --modal-hint-inactive-color: #ccc; --modal-description-color: #aaa; --notifications-background-color: #333; diff --git a/web/sync_service.ts b/web/sync_service.ts index 71e0663b..cb576ab8 100644 --- a/web/sync_service.ts +++ b/web/sync_service.ts @@ -81,11 +81,19 @@ export class SyncService implements ISyncService { }, ); - eventHook.addLocalListener("editor:pageSaving", () => { + eventHook.addLocalListener("editor:documentLoaded", (name, _prevPage) => { + this.scheduleFileSync(name).catch(console.error); + }); + + const setSavingTimeout = () => { this.savingTimeout = setTimeout(() => { this.savingTimeout = undefined; }, 1000 * 5); - }); + }; + + eventHook.addLocalListener("editor:pageSaving", setSavingTimeout); + + eventHook.addLocalListener("editor:documentSaving", setSavingTimeout); eventHook.addLocalListener("editor:pageSaved", (name) => { if (this.savingTimeout) { @@ -98,6 +106,18 @@ export class SyncService implements ISyncService { this.scheduleFileSync(path).catch(console.error); }); + eventHook.addLocalListener("editor:documentSaved", (name) => { + if (this.savingTimeout) { + clearTimeout(this.savingTimeout); + this.savingTimeout = undefined; + } else { + console.warn( + "This should not happen, savingTimeout was not set. This could be a slow document editor", + ); + } + this.scheduleFileSync(name).catch(console.error); + }); + this.spaceSync.on({ fileSynced: (meta, direction) => { eventHook.dispatchEvent("file:synced", meta, direction); diff --git a/web/syscalls/editor.ts b/web/syscalls/editor.ts index f6824dc6..3a4d8449 100644 --- a/web/syscalls/editor.ts +++ b/web/syscalls/editor.ts @@ -19,7 +19,10 @@ import { getCM as vimGetCm, Vim } from "@replit/codemirror-vim"; import type { SysCallMapping } from "$lib/plugos/system.ts"; import type { FilterOption } from "@silverbulletmd/silverbullet/type/client"; import type { PageMeta, UploadFile } from "../../plug-api/types.ts"; -import type { PageRef } from "@silverbulletmd/silverbullet/lib/page_ref"; +import { + type LocationRef, + parseLocationRef, +} from "@silverbulletmd/silverbullet/lib/page_ref"; import { openSearchPanel } from "@codemirror/search"; export function editorSyscalls(client: Client): SysCallMapping { @@ -28,7 +31,13 @@ export function editorSyscalls(client: Client): SysCallMapping { return client.currentPage; }, "editor.getCurrentPageMeta": (): PageMeta | undefined => { - return client.ui.viewState.currentPageMeta; + return client.ui.viewState.current?.meta; + }, + "editor.getCurrentPath": (_ctx, extension: boolean): string => { + return client.currentPath(extension); + }, + "editor.getCurrentEditor": (): string => { + return client.documentEditor?.name || "page"; }, "editor.getText": () => { return client.editorView.state.sliceDoc(); @@ -47,14 +56,17 @@ export function editorSyscalls(client: Client): SysCallMapping { }, "editor.navigate": async ( _ctx, - pageRef: PageRef | string, + locationRef: LocationRef | string, replaceState = false, newWindow = false, ) => { - if (typeof pageRef === "string") { - pageRef = { page: pageRef }; + if (typeof locationRef === "string") { + locationRef = parseLocationRef(locationRef); + } else if (locationRef.kind === undefined) { + // @ts-ignore: This is for legacy support + locationRef.kind === "page"; } - await client.navigate(pageRef, replaceState, newWindow); + await client.navigate(locationRef, replaceState, newWindow); }, "editor.reloadPage": async () => { await client.reloadPage(); @@ -78,14 +90,7 @@ export function editorSyscalls(client: Client): SysCallMapping { ); }, "editor.openUrl": (_ctx, url: string, existingWindow = false) => { - if (!existingWindow) { - const win = globalThis.open(url, "_blank"); - if (win) { - win.focus(); - } - } else { - location.href = url; - } + client.openUrl(url, existingWindow); }, "editor.newWindow": () => { globalThis.open( @@ -295,7 +300,7 @@ export function editorSyscalls(client: Client): SysCallMapping { }, "editor.openPageNavigator": ( _ctx, - mode: "page" | "meta" | "all" = "page", + mode: "page" | "meta" | "document" | "all" = "page", ) => { client.startPageNavigate(mode); }, diff --git a/web/syscalls/space.ts b/web/syscalls/space.ts index 263cda53..314a1bc7 100644 --- a/web/syscalls/space.ts +++ b/web/syscalls/space.ts @@ -1,10 +1,6 @@ import type { Client } from "../client.ts"; import type { SysCallMapping } from "../../lib/plugos/system.ts"; -import type { - AttachmentMeta, - FileMeta, - PageMeta, -} from "../../plug-api/types.ts"; +import type { DocumentMeta, FileMeta, PageMeta } from "../../plug-api/types.ts"; export function spaceReadSyscalls(editor: Client): SysCallMapping { return { @@ -20,17 +16,17 @@ export function spaceReadSyscalls(editor: Client): SysCallMapping { "space.listPlugs": (): Promise => { return editor.space.listPlugs(); }, - "space.listAttachments": async (): Promise => { - return await editor.space.fetchAttachmentList(); + "space.listDocuments": async (): Promise => { + return await editor.space.fetchDocumentList(); }, - "space.readAttachment": async (_ctx, name: string): Promise => { - return (await editor.space.readAttachment(name)).data; + "space.readDocument": async (_ctx, name: string): Promise => { + return (await editor.space.readDocument(name)).data; }, - "space.getAttachmentMeta": async ( + "space.getDocumentMeta": async ( _ctx, name: string, - ): Promise => { - return await editor.space.getAttachmentMeta(name); + ): Promise => { + return await editor.space.getDocumentMeta(name); }, // FS "space.listFiles": (): Promise => { @@ -60,22 +56,22 @@ export function spaceWriteSyscalls(editor: Client): SysCallMapping { "space.deletePage": async (_ctx, name: string) => { // If we're deleting the current page, navigate to the index page if (editor.currentPage === name) { - await editor.navigate({ page: "" }); + await editor.navigate({ kind: "page", page: "" }); } // Remove page from open pages in editor // editor.openPages.openPages.delete(name); console.log("Deleting page"); await editor.space.deletePage(name); }, - "space.writeAttachment": ( + "space.writeDocument": ( _ctx, name: string, data: Uint8Array, - ): Promise => { - return editor.space.writeAttachment(name, data); + ): Promise => { + return editor.space.writeDocument(name, data); }, - "space.deleteAttachment": async (_ctx, name: string) => { - await editor.space.deleteAttachment(name); + "space.deleteDocument": async (_ctx, name: string) => { + await editor.space.deleteDocument(name); }, "space.writeFile": ( _ctx, diff --git a/web/type.ts b/web/type.ts index 7fb16899..93ef4b16 100644 --- a/web/type.ts +++ b/web/type.ts @@ -1,7 +1,10 @@ import type { AppCommand } from "../lib/command.ts"; import type { FilterOption, Notification, PanelMode } from "../type/client.ts"; import { type Config, defaultConfig } from "../type/config.ts"; -import type { PageMeta } from "@silverbulletmd/silverbullet/types"; +import type { + DocumentMeta, + PageMeta, +} from "@silverbulletmd/silverbullet/types"; export type PanelConfig = { mode?: PanelMode; @@ -10,9 +13,20 @@ export type PanelConfig = { }; export type AppViewState = { - currentPage?: string; - currentPageMeta?: PageMeta; + current?: + | { + kind: "page"; + meta: PageMeta; + path: string; + } + | { + kind: "document"; + meta: DocumentMeta; + path: string; + }; + allPages: PageMeta[]; + allDocuments: DocumentMeta[]; isLoading: boolean; isMobile: boolean; @@ -37,7 +51,7 @@ export type AppViewState = { }; // Page navigator mode - pageNavigatorMode: "page" | "meta" | "all"; + pageNavigatorMode: "page" | "meta" | "document" | "all"; // Filter box showFilterBox: boolean; @@ -80,6 +94,7 @@ export const initialViewState: AppViewState = { }, config: defaultConfig, allPages: [], + allDocuments: [], commands: new Map(), recentCommands: new Map(), notifications: [], @@ -99,11 +114,16 @@ export type Action = | { type: "page-loading"; name: string } | { type: "page-changed" } | { type: "page-saved" } + | { type: "document-editor-loaded"; meta: DocumentMeta } + | { type: "document-editor-loading"; name: string } + | { type: "document-editor-changed" } + | { type: "document-editor-saved" } | { type: "sync-change"; syncSuccess: boolean } | { type: "update-current-page-meta"; meta: PageMeta } | { type: "update-page-list"; allPages: PageMeta[] } + | { type: "update-document-list"; allDocuments: DocumentMeta[] } | { type: "config-loaded"; config: Config } - | { type: "start-navigate"; mode: "page" | "meta" | "all" } + | { type: "start-navigate"; mode: "page" | "meta" | "document" | "all" } | { type: "stop-navigate" } | { type: "update-commands"; diff --git a/website/API/space.md b/website/API/space.md index 6cb93022..d334e6e9 100644 --- a/website/API/space.md +++ b/website/API/space.md @@ -1,4 +1,4 @@ -The Space API provides functions for interacting with pages, attachments, and files in the space. +The Space API provides functions for interacting with pages, documents, and files in the space. # Page Operations @@ -48,44 +48,44 @@ Example: space.deletePage("old-notes") ``` -# Attachment Operations +# Document Operations -## space.listAttachments() -Returns a list of all attachments in the space. +## space.listDocuments() +Returns a list of all documents in the space. Example: ```lua -local attachments = space.listAttachments() -for att in each(attachments) do - print(att.name, att.size) +local documents = space.listDocuments() +for doc in each(documents) do + print(doc.name, doc.size) end ``` -## space.readAttachment(name) -Reads the content of an attachment. +## space.readDocument(name) +Reads the content of a document. Example: ```lua -local data = space.readAttachment("image.png") -print("Attachment size: " .. #data .. " bytes") +local data = space.readDocument("image.png") +print("Document size: " .. #data .. " bytes") ``` -## space.writeAttachment(name, data) -Writes binary data to an attachment. +## space.writeDocument(name, data) +Writes binary data to a document. Example: ```lua local binaryData = string.char(72, 69, 76, 76, 79) -- "HELLO" in binary -local meta = space.writeAttachment("test.bin", binaryData) -print("Attachment saved with size: " .. meta.size) +local meta = space.writeDocument("test.bin", binaryData) +print("Document saved with size: " .. meta.size) ``` -## space.deleteAttachment(name) -Deletes an attachment from the space. +## space.deleteDocument(name) +Deletes a document from the space. Example: ```lua -space.deleteAttachment("old-image.png") +space.deleteDocument("old-image.png") ``` # File Operations diff --git a/website/Attachments.md b/website/Attachments.md deleted file mode 100644 index 2abfd5e5..00000000 --- a/website/Attachments.md +++ /dev/null @@ -1,36 +0,0 @@ -#level/beginner - -While SilverBullet for sure is aimed at primarily text-based content, life can not fully be represented in text always. Therefore, SilverBullet supports attachments. Attachments, like [[Pages]] ultimately are — once again — just files on disk. - -# Uploading -To create an attachment, you have a few options: - -* Use the {[Upload: File]} command (especially useful on mobile devices) -* Drag & drop files or images onto a page -* Copy & paste files or images onto a page - -All options will prompt you for a file name to use to store the attachment, and then include the attachment as an embedded image (if it was an image) or link to the file. - -# Linking -Attachments can be linked to in two ways: -* Via the regular link syntax: `[link text](attachment.pdf)`. URLs here are relative to the page, so on a page named `MyFolder/Hello`, `[link text](attachment.pdf)` would refer to an attachment stored in `MyFolder/attachment.pdf`. -* Via the wiki link syntax: `[[attachment.pdf]]`. These paths are absolute and relative to your space’s root, just like regular page links. That is: on a page `MyFolder/Hello` an attachment link `[[attachment.pdf]]` would link to the file `attachment.pdf` in the space’s root folder. - -# Embedding -Media can also be embedded using the [[#Linking]] syntax, but prefixed with an `!`: -Images, videos, audio and PDFs are currently supported. - -* `![alternate text](image.png)` -* `![[image.png]]` - -These follow the same relative/absolute path rules as links described before. - -## Media resizing - -In addition, media can be _sized_ using the following syntax: -* Specifying only a width: `![Alt text|300](image.png)` or `![[image.png|300]]` -* Specifying only a height: `![Alt text|x300](image.png)` or `![[image.png|x300]]` -* Specifying both width and height: `![Hello|300x300](image.png)` or `![[image.png|300x300]]` - -# Management -SilverBullet [does not currently support](https://github.com/silverbulletmd/silverbullet/issues/72) any operations like renaming or deleting of attachments. You’ll have to do this outside of SilverBullet somehow. \ No newline at end of file diff --git a/website/Document Editor.md b/website/Document Editor.md new file mode 100644 index 00000000..b0a7c02c --- /dev/null +++ b/website/Document Editor.md @@ -0,0 +1,32 @@ +Document editors allow users to not only view but also edit documents. This means that Silverbullet can (theoretically) be used for much more than just markdown, e.g. PDF, asciidoc, org and a lot more. Currently the feature is still very young and the only editors available are proof of concepts, but this should hopefully change soon. If you want to contribute and make your own editor look at [[#Internals]] + +# Usage +Document editors are provided by [[Plugs]]. To install a document editor just install the plug. The editor will then be available for documents with certain file extensions which are specified by the plug, e.g. an image viewer would be available for `jpg`, `jpeg` and `png`. +To edit or view a document, use `Ctrl-o` to open the document navigator. Files for which a document editor is available will have their extension highlighted in blue. + +# Available editors +Currently only small demos are available +- [TXT editor](https://github.com/MrMugame/silverbullet-txt/) +- [PDF viewer](https://github.com/MrMugame/silverbullet-pdf/) + +# Internals +Internally document editors work similarly to [[Markdown/Code Widgets]]. First the editor is defined inside the plug manifest. The `editor` field is used to specify the file extensions this editor can handle. + +```yaml +name: txteditor +functions: + TXTEditor: + path: ./editor.ts:editor + editor: ["txt"] +``` + +The function provided should look like this, again similar to code widgets. The provided html and script will be injected into an iframe. To communicate with silverbullet events are used which are best described in the example TXT editor under [[#Available editors]] + +```typescript +export async function editor(): Promise<{ html: string, script: string }> { + return { + html: "

Document Editor example

" + script: "console.log('Initalised')" + } +} +``` \ No newline at end of file diff --git a/website/Documents.md b/website/Documents.md new file mode 100644 index 00000000..2bedb055 --- /dev/null +++ b/website/Documents.md @@ -0,0 +1,37 @@ +#level/beginner + +While SilverBullet for sure is aimed at primarily text-based content, life can not fully be represented in text always. Therefore, SilverBullet supports documents. Documents, like [[Pages]] ultimately are — once again — just files on disk. Using plugs, documents of specific file types can also be viewed and edited. + +# Uploading +To create a document, you have a few options: + +* Use the {[Upload: File]} command (especially useful on mobile devices) +* Drag & drop files or images onto a page +* Copy & paste files or images onto a page + +All options will prompt you for a file name to use to store the document, and then include the document as an embedded image (if it was an image) or link to the file. + +# Managment +Using `Ctrl-o` you can open the document navigator, which will display all documents in your Silverbullet space. You can either: + - Delete or rename documents for which no [[Document Editor]] is available (indicated by the grey hint) + - Open and view documents for which a [[Document Editor]] is available (indicated by the blue hint) + +# Linking +Documents can be linked to in two ways: +* Via the regular link syntax: `[link text](document.pdf)`. URLs here are relative to the page, so on a page named `MyFolder/Hello`, `[link text](document.pdf)` would refer to a document stored in `MyFolder/document.pdf`. +* Via the wiki link syntax: `[[document.pdf]]`. These paths are absolute and relative to your space’s root, just like regular page links. That is: on a page `MyFolder/Hello` a document link `[[document.pdf]]` would link to the file `document.pdf` in the space’s root folder. + +# Embedding +Media can also be embedded using the [[#Linking]] syntax, but prefixed with an `!`: +Images, videos, audio and PDFs are currently supported. + +* `![alternate text](image.png)` +* `![[image.png]]` + +These follow the same relative/absolute path rules as links described before. + +## Media resizing +In addition, media can be _sized_ using the following syntax: +* Specifying only a width: `![Alt text|300](image.png)` or `![[image.png|300]]` +* Specifying only a height: `![Alt text|x300](image.png)` or `![[image.png|x300]]` +* Specifying both width and height: `![Hello|300x300](image.png)` or `![[image.png|300x300]]` \ No newline at end of file diff --git a/website/Library/Core/Page/Space Overview.md b/website/Library/Core/Page/Space Overview.md index f2508812..d5439586 100644 --- a/website/Library/Core/Page/Space Overview.md +++ b/website/Library/Core/Page/Space Overview.md @@ -5,7 +5,7 @@ This page compiles some useful things about your space and may also be useful fo # Stats ```template **Total pages:** {{count({page select name})}} -**Total attachments:** {{count({attachment select name})}} +**Total documents:** {{count({document select name})}} **Total tags:** {{count({tag select name})}} ``` diff --git a/website/Manual.md b/website/Manual.md index 95404056..d5881adb 100644 --- a/website/Manual.md +++ b/website/Manual.md @@ -30,7 +30,7 @@ These are the core concepts used in SilverBullet: * [[Frontmatter]] * [[Page Name Rules]] * [[Folders]] -* [[Attachments]] +* [[Documents]] * [[Templates]] # Editing and Formatting diff --git a/website/SETTINGS.md b/website/SETTINGS.md index ba908316..f887c34f 100644 --- a/website/SETTINGS.md +++ b/website/SETTINGS.md @@ -75,7 +75,7 @@ spaceIgnore: | *.mp4 # Defines the maximum size of a file you can upload to the space (in MiB) -maximumAttachmentSize: 10 +maximumDocumentSize: 10 # Add alternative names to emoji picker emoji: diff --git a/website/Spaces.md b/website/Spaces.md index 01de7acc..3782ece5 100644 --- a/website/Spaces.md +++ b/website/Spaces.md @@ -7,7 +7,7 @@ Feel free to back up or manipulate your space’s folder and its files with what A space consists of * [[Folders]] * [[Pages]] -* [[Attachments]] +* [[Documents]] # Folder layout Every space in SilverBullet at the very least has an index page (by default named `index.md`) and a [[SETTINGS]] page (named `SETTINGS.md`). If you install custom [[Plugs]], these will be kept under `_plug` in your space’s folder. diff --git a/website/Transclusions.md b/website/Transclusions.md index 8b10f5c1..e27d7286 100644 --- a/website/Transclusions.md +++ b/website/Transclusions.md @@ -3,10 +3,10 @@ Transclusions are an extension of the [[Markdown]] syntax enabling inline embedd The general syntax is `![[path]]`. Two types of transclusions are currently supported: # Media -Syntax: `![[path/to/image.jpg]]` see [[Attachments#Embedding]] for more details. +Syntax: `![[path/to/image.jpg]]` see [[Documents#Embedding]] for more details. Media resizing is also supported: -![[Attachments#Media resizing]] +![[Documents#Media resizing]] # Pages Syntax: * `![[page name]]` embed an entire page