diff --git a/common/spaces/http_space_primitives.ts b/common/spaces/http_space_primitives.ts index 9c3cc5f..26ee4c1 100644 --- a/common/spaces/http_space_primitives.ts +++ b/common/spaces/http_space_primitives.ts @@ -60,7 +60,7 @@ export class HttpSpacePrimitives implements SpacePrimitives { console.error( "Got error fetching, throwing offline", url, - e.errorMessage, + e, ); throw new Error("Offline"); } diff --git a/plug-api/lib/page.test.ts b/plug-api/lib/page.test.ts new file mode 100644 index 0000000..c18373e --- /dev/null +++ b/plug-api/lib/page.test.ts @@ -0,0 +1,24 @@ +import { encodePageRef, parsePageRef } from "$sb/lib/page.ts"; +import { assertEquals } from "../../test_deps.ts"; + +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$bar"), { page: "foo", anchor: "bar" }); + assertEquals(parsePageRef("foo$bar@1"), { + page: "foo", + anchor: "bar", + pos: 1, + }); + + // Edge cases + assertEquals(parsePageRef(""), { page: "" }); + assertEquals(parsePageRef("user@domain.com"), { page: "user@domain.com" }); + + // Encoding + assertEquals(encodePageRef({ page: "foo" }), "foo"); + assertEquals(encodePageRef({ page: "foo", pos: 10 }), "foo@10"); + assertEquals(encodePageRef({ page: "foo", anchor: "bar" }), "foo$bar"); +}); diff --git a/plug-api/lib/page.ts b/plug-api/lib/page.ts index 4ebc113..0416f0a 100644 --- a/plug-api/lib/page.ts +++ b/plug-api/lib/page.ts @@ -6,13 +6,47 @@ export function validatePageName(name: string) { if (name.startsWith(".")) { throw new Error("Page name cannot start with a '.'"); } - if (name.includes("@")) { - throw new Error("Page name cannot contain '@'"); - } - if (name.includes("$")) { - throw new Error("Page name cannot contain '$'"); - } if (/\.[a-zA-Z]+$/.test(name)) { throw new Error("Page name can not end with a file extension"); } } + +export type PageRef = { + page: string; + pos?: number; + anchor?: string; +}; + +const posRegex = /@(\d+)$/; +// Should be kept in sync with the regex in index.plug.yaml +const anchorRegex = /\$([a-zA-Z\.\-\/]+[\w\.\-\/]*)$/; + +export function parsePageRef(name: string): PageRef { + // Normalize the page name + if (name.startsWith("[[") && name.endsWith("]]")) { + name = name.slice(2, -2); + } + const pageRef: PageRef = { page: name }; + const posMatch = pageRef.page.match(posRegex); + if (posMatch) { + pageRef.pos = parseInt(posMatch[1]); + pageRef.page = pageRef.page.replace(posRegex, ""); + } + const anchorMatch = pageRef.page.match(anchorRegex); + if (anchorMatch) { + pageRef.anchor = anchorMatch[1]; + pageRef.page = pageRef.page.replace(anchorRegex, ""); + } + return pageRef; +} + +export function encodePageRef(pageRef: PageRef): string { + let name = pageRef.page; + if (pageRef.pos) { + name += `@${pageRef.pos}`; + } + if (pageRef.anchor) { + name += `$${pageRef.anchor}`; + } + return name; +} diff --git a/plug-api/silverbullet-syscall/editor.ts b/plug-api/silverbullet-syscall/editor.ts index 5ebaa83..80ff188 100644 --- a/plug-api/silverbullet-syscall/editor.ts +++ b/plug-api/silverbullet-syscall/editor.ts @@ -1,4 +1,5 @@ import type { FilterOption } from "../../web/types.ts"; +import { PageRef } from "$sb/lib/page.ts"; import { UploadFile } from "../types.ts"; import { syscall } from "./syscall.ts"; @@ -31,12 +32,11 @@ export function save(): Promise { } export function navigate( - name: string, - pos?: string | number, + pageRef: PageRef, replaceState = false, newWindow = false, ): Promise { - return syscall("editor.navigate", name, pos, replaceState, newWindow); + return syscall("editor.navigate", pageRef, replaceState, newWindow); } export function openPageNavigator( diff --git a/plugs/editor/broken_links.ts b/plugs/editor/broken_links.ts index aebbdc9..671be0c 100644 --- a/plugs/editor/broken_links.ts +++ b/plugs/editor/broken_links.ts @@ -1,5 +1,6 @@ import { traverseTree } from "../../plug-api/lib/tree.ts"; import { editor, markdown, space } from "$sb/syscalls.ts"; +import { parsePageRef } from "$sb/lib/page.ts"; export async function brokenLinksCommand() { const pageName = "BROKEN LINKS"; @@ -13,7 +14,7 @@ export async function brokenLinksCommand() { traverseTree(tree, (tree) => { if (tree.type === "WikiLinkPage") { // Add the prefix in the link text - const [pageName] = tree.children![0].text!.split(/[@$]/); + const { page: pageName } = parsePageRef(tree.children![0].text!); if (pageName.startsWith("!")) { return true; } @@ -53,5 +54,5 @@ export async function brokenLinksCommand() { ); } await space.writePage(pageName, lines.join("\n")); - await editor.navigate(pageName); + await editor.navigate({ page: pageName }); } diff --git a/plugs/editor/navigate.ts b/plugs/editor/navigate.ts index 2edd1ac..272331b 100644 --- a/plugs/editor/navigate.ts +++ b/plugs/editor/navigate.ts @@ -8,6 +8,7 @@ import { ParseTree, } from "$sb/lib/tree.ts"; import { resolveAttachmentPath, resolvePath } from "$sb/lib/resolve.ts"; +import { parsePageRef } from "$sb/lib/page.ts"; async function actionClickOrActionEnter( mdTree: ParseTree | null, @@ -39,30 +40,22 @@ async function actionClickOrActionEnter( const currentPage = await editor.getCurrentPage(); switch (mdTree.type) { case "WikiLink": { - let pageLink = mdTree.children![1]!.children![0].text!; - let pos: string | number = 0; - if (pageLink.includes("@") || pageLink.includes("$")) { - [pageLink, pos] = pageLink.split(/[@$]/); - if (pos.match(/^\d+$/)) { - pos = +pos; - } + const pageLink = mdTree.children![1]!.children![0].text!; + const pageRef = parsePageRef(pageLink); + pageRef.page = resolvePath(currentPage, pageRef.page); + if (!pageRef.page) { + pageRef.page = currentPage; } - pageLink = resolvePath(currentPage, pageLink); - if (!pageLink) { - pageLink = currentPage; + // This is an explicit navigate, move to the top + if (pageRef.pos === undefined) { + pageRef.pos = 0; } - await editor.navigate(pageLink, pos, false, inNewWindow); + await editor.navigate(pageRef, false, inNewWindow); break; } case "PageRef": { - const bracketedPageRef = mdTree.children![0].text!; - - // Slicing off the initial [[ and final ]] - const pageName = bracketedPageRef.substring( - 2, - bracketedPageRef.length - 2, - ); - await editor.navigate(pageName, 0, false, inNewWindow); + const pageName = parsePageRef(mdTree.children![0].text!).page; + await editor.navigate({ page: pageName, pos: 0 }, false, inNewWindow); break; } case "NakedURL": @@ -125,5 +118,5 @@ export async function clickNavigate(event: ClickEvent) { } export async function navigateCommand(cmdDef: any) { - await editor.navigate(cmdDef.page); + await editor.navigate({ page: cmdDef.page, pos: 0 }); } diff --git a/plugs/editor/page.ts b/plugs/editor/page.ts index ac139c8..7f4dc60 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(""); + await editor.navigate({ 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(newName); + await editor.navigate({ page: newName }); } else { // Otherwise just notify of success await editor.flashNotification("Page copied successfully"); diff --git a/plugs/index/anchor.ts b/plugs/index/anchor.ts index 2f46dbf..e18b136 100644 --- a/plugs/index/anchor.ts +++ b/plugs/index/anchor.ts @@ -2,6 +2,7 @@ import { collectNodesOfType } from "$sb/lib/tree.ts"; import type { CompleteEvent, IndexTreeEvent } from "$sb/app_event.ts"; import { ObjectValue, QueryExpression } from "$sb/types.ts"; import { indexObjects, queryObjects } from "./api.ts"; +import { parsePageRef } from "$sb/lib/page.ts"; type AnchorObject = ObjectValue<{ name: string; @@ -27,14 +28,14 @@ export async function indexAnchors({ name: pageName, tree }: IndexTreeEvent) { } export async function anchorComplete(completeEvent: CompleteEvent) { - const match = /\[\[([^\]@$:]*[@$][\w\.\-\/]*)$/.exec( + const match = /\[\[([^\]$:]*\$[\w\.\-\/]*)$/.exec( completeEvent.linePrefix, ); if (!match) { return null; } - const pageRef = match[1].split(/[@$]/)[0]; + const pageRef = parsePageRef(match[1]).page; let filter: QueryExpression | undefined = ["=", ["attr", "page"], [ "string", pageRef, diff --git a/plugs/index/page_links.ts b/plugs/index/page_links.ts index 96212c4..b1fd932 100644 --- a/plugs/index/page_links.ts +++ b/plugs/index/page_links.ts @@ -5,6 +5,7 @@ import { indexObjects, queryObjects } from "./api.ts"; import { ObjectValue } from "$sb/types.ts"; import { extractFrontmatter } from "$sb/lib/frontmatter.ts"; import { updateITags } from "$sb/lib/tags.ts"; +import { parsePageRef } from "$sb/lib/page.ts"; const pageRefRegex = /\[\[([^\]]+)\]\]/g; @@ -56,7 +57,7 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) { const wikiLinkAlias = findNodeOfType(n, "WikiLinkAlias"); let toPage = resolvePath(name, wikiLinkPage.children![0].text!); const pos = wikiLinkPage.from!; - toPage = toPage.split(/[@$]/)[0]; + toPage = parsePageRef(toPage).page; const link: LinkObject = { ref: `${name}@${pos}`, tag: "link", @@ -89,7 +90,7 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) { const code = codeText.children![0].text!; const matches = code.matchAll(pageRefRegex); for (const match of matches) { - const pageRefName = resolvePath(name, match[1]); + const pageRefName = resolvePath(name, parsePageRef(match[1]).page); const pos = codeText.from! + match.index! + 2; const link = { ref: `${name}@${pos}`, diff --git a/plugs/index/refactor.ts b/plugs/index/refactor.ts index cddeff3..6a17d1e 100644 --- a/plugs/index/refactor.ts +++ b/plugs/index/refactor.ts @@ -67,7 +67,7 @@ async function renamePage( if (navigateThere) { console.log("Navigating to new page"); - await editor.navigate(newName, 0, true); + await editor.navigate({ page: newName, pos: 0 }, true); } const pagesToUpdate = await getBackLinks(oldName); @@ -101,12 +101,16 @@ async function renamePage( continue; } + // Replace all links found in place following the patterns [[Page]] and [[Page@pos]] as well as [[Page$anchor]] const newText = text.replaceAll(`[[${oldName}]]`, () => { updatedReferences++; return `[[${newName}]]`; }).replaceAll(`[[${oldName}@`, () => { updatedReferences++; return `[[${newName}@`; + }).replaceAll(`[[${oldName}$`, () => { + updatedReferences++; + return `[[${newName}$`; }); if (text !== newText) { console.log("Changes made, saving..."); @@ -207,5 +211,5 @@ 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(newName); + await editor.navigate({ page: newName }); } diff --git a/plugs/markdown/markdown_render.ts b/plugs/markdown/markdown_render.ts index 53b9cea..a7ecfb2 100644 --- a/plugs/markdown/markdown_render.ts +++ b/plugs/markdown/markdown_render.ts @@ -7,6 +7,7 @@ import { renderToText, traverseTree, } from "$sb/lib/tree.ts"; +import { parsePageRef } from "$sb/lib/page.ts"; import { Fragment, renderHtml, Tag } from "./html_render.ts"; export type MarkdownRenderOptions = { @@ -245,11 +246,11 @@ function render( if (aliasNode) { linkText = aliasNode.children![0].text!; } - const [pageName] = ref.split(/[@$]/); + const { page: pageName, anchor } = parsePageRef(ref); return { name: "a", attrs: { - href: `/${pageName}`, + href: `/${pageName}${anchor ? "#" + anchor : ""}`, class: "wiki-link", "data-ref": ref, }, @@ -278,9 +279,9 @@ function render( case "Task": { let externalTaskRef = ""; collectNodesOfType(t, "WikiLinkPage").forEach((wikilink) => { - const ref = wikilink.children![0].text!; - if (!externalTaskRef && (ref.includes("@") || ref.includes("$"))) { - externalTaskRef = ref; + const pageRef = parsePageRef(wikilink.children![0].text!); + if (!externalTaskRef && (pageRef.pos !== undefined || pageRef.anchor)) { + externalTaskRef = wikilink.children![0].text!; } }); diff --git a/plugs/plug-manager/plugmanager.ts b/plugs/plug-manager/plugmanager.ts index 12bda32..5e88c8a 100644 --- a/plugs/plug-manager/plugmanager.ts +++ b/plugs/plug-manager/plugmanager.ts @@ -109,7 +109,7 @@ export async function addPlugCommand() { plugsPrelude + "```yaml\n" + plugList.map((p) => `- ${p}`).join("\n") + "\n```", ); - await editor.navigate("PLUGS"); + await editor.navigate({ page: "PLUGS" }); await updatePlugsCommand(); await editor.flashNotification("Plug added!"); system.reloadPlugs(); diff --git a/plugs/search/search.ts b/plugs/search/search.ts index a674335..ecc3f19 100644 --- a/plugs/search/search.ts +++ b/plugs/search/search.ts @@ -48,7 +48,7 @@ export async function queryProvider({ export async function searchCommand() { const phrase = await editor.prompt("Search for: "); if (phrase) { - await editor.navigate(`${searchPrefix}${phrase}`); + await editor.navigate({ page: `${searchPrefix}${phrase}` }); } } diff --git a/plugs/tasks/task.ts b/plugs/tasks/task.ts index 836d602..f280cb9 100644 --- a/plugs/tasks/task.ts +++ b/plugs/tasks/task.ts @@ -20,6 +20,7 @@ import { ObjectValue } from "$sb/types.ts"; import { indexObjects, queryObjects } from "../index/plug_api.ts"; import { updateITags } from "$sb/lib/tags.ts"; import { extractFrontmatter } from "$sb/lib/frontmatter.ts"; +import { parsePageRef } from "$sb/lib/page.ts"; export type TaskObject = ObjectValue< { @@ -191,8 +192,11 @@ export async function updateTaskState( newState: string, ) { const currentPage = await editor.getCurrentPage(); - const [page, posS] = ref.split("@"); - const pos = +posS; + const { page, pos } = parsePageRef(ref); + if (pos === undefined) { + console.error("No position found in page ref, skipping", ref); + return; + } if (page === currentPage) { // In current page, just update the task marker with dispatch const editorText = await editor.getText(); diff --git a/plugs/template/page.ts b/plugs/template/page.ts index 82eaae1..291fe61 100644 --- a/plugs/template/page.ts +++ b/plugs/template/page.ts @@ -154,7 +154,7 @@ async function instantiatePageTemplate( // So, page exists if (newPageConfig.openIfExists) { console.log("Page already exists, navigating there"); - await editor.navigate(pageName); + await editor.navigate({ page: pageName, pos: 0 }); return; } @@ -165,7 +165,7 @@ async function instantiatePageTemplate( ) ) { // Just navigate there without instantiating - return editor.navigate(pageName); + return editor.navigate({ page: pageName, pos: 0 }); } } catch { // The preferred scenario, let's keep going @@ -191,7 +191,10 @@ async function instantiatePageTemplate( pageName, fullPageText, ); - await editor.navigate(pageName, carretPos !== -1 ? carretPos : undefined); + await editor.navigate({ + page: pageName, + pos: carretPos !== -1 ? carretPos : undefined, + }); } } diff --git a/web/client.ts b/web/client.ts index bfdd32f..8bc95f7 100644 --- a/web/client.ts +++ b/web/client.ts @@ -13,7 +13,7 @@ import { FilterOption } from "./types.ts"; import { ensureSettingsAndIndex } from "../common/util.ts"; import { EventHook } from "../plugos/hooks/event.ts"; import { AppCommand } from "./hooks/command.ts"; -import { PathPageNavigator } from "./navigator.ts"; +import { PageState, PathPageNavigator } from "./navigator.ts"; import { AppViewState, BuiltinSettings } from "./types.ts"; @@ -32,10 +32,9 @@ import { SyncStatus } from "../common/spaces/sync.ts"; 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 { validatePageName } from "$sb/lib/page.ts"; +import { encodePageRef, validatePageName } from "$sb/lib/page.ts"; import { ClientSystem } from "./client_system.ts"; import { createEditorState } from "./editor_state.ts"; -import { OpenPages } from "./open_pages.ts"; import { MainUI } from "./editor_ui.tsx"; import { cleanPageRef } from "$sb/lib/resolve.ts"; import { SpacePrimitives } from "../common/spaces/space_primitives.ts"; @@ -55,6 +54,7 @@ import { import { LimitedMap } from "$sb/lib/limited_map.ts"; import { renderHandlebarsTemplate } from "../common/syscalls/handlebars.ts"; import { buildQueryFunctions } from "../common/query_functions.ts"; +import { PageRef } from "$sb/lib/page.ts"; const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/; const autoSaveInterval = 1000; @@ -71,6 +71,8 @@ declare global { } } +// history.scrollRestoration = "manual"; + export class Client { system!: ClientSystem; editorView!: EditorView; @@ -113,7 +115,6 @@ export class Client { eventHook!: EventHook; ui!: MainUI; - openPages!: OpenPages; stateDataStore!: DataStore; spaceDataStore!: DataStore; mq!: DataStoreMQ; @@ -192,8 +193,6 @@ export class Client { parent: document.getElementById("sb-editor")!, }); - this.openPages = new OpenPages(this); - this.focus(); await this.system.init(); @@ -312,71 +311,98 @@ export class Client { ); } + private navigateWithinPage(pageState: PageState) { + // Did we end up doing anything in terms of internal navigation? + let adjustedPosition = false; + + // Was a particular scroll position persisted? + if (pageState.scrollTop !== undefined) { + setTimeout(() => { + console.log("Kicking off scroll to", pageState.scrollTop); + this.editorView.scrollDOM.scrollTop = pageState.scrollTop!; + }); + adjustedPosition = true; + } + + // Was a particular cursor/selection set? + if (pageState.selection?.anchor && !pageState.pos && !pageState.anchor) { // Only do this if we got a specific cursor position + console.log("Changing cursor position to", pageState.selection); + this.editorView.dispatch({ + selection: pageState.selection, + }); + adjustedPosition = true; + } + + // Was there a pos or anchor set? + let pos: number | undefined = pageState.pos; + if (pageState.anchor) { + console.log("Navigating to anchor", pageState.anchor); + const pageText = this.editorView.state.sliceDoc(); + + pos = pageText.indexOf(`$${pageState.anchor}`); + + if (pos === -1) { + return this.flashNotification( + `Could not find anchor $${pageState.anchor}`, + "error", + ); + } + + adjustedPosition = true; + } + if (pos !== undefined) { + // setTimeout(() => { + console.log("Doing this pos set to", pos); + this.editorView.dispatch({ + selection: { anchor: pos! }, + effects: EditorView.scrollIntoView(pos!, { + y: "start", + yMargin: 5, + }), + }); + adjustedPosition = true; + // }); + } + + // If not: just put the cursor at the top of the page, right after the frontmatter + if (!adjustedPosition) { + // Somewhat ad-hoc way to determine if the document contains frontmatter and if so, putting the cursor _after it_. + const pageText = this.editorView.state.sliceDoc(); + + // Default the cursor to be at position 0 + let initialCursorPos = 0; + const match = frontMatterRegex.exec(pageText); + if (match) { + // Frontmatter found, put cursor after it + initialCursorPos = match[0].length; + } + // By default scroll to the top + console.log("Scrolling to place after frontmatter", initialCursorPos); + this.editorView.scrollDOM.scrollTop = 0; + this.editorView.dispatch({ + selection: { anchor: initialCursorPos }, + // And then scroll down if required + scrollIntoView: true, + }); + } + } + private initNavigator() { - this.pageNavigator = new PathPageNavigator( - cleanPageRef(renderHandlebarsTemplate(this.settings.indexPage, {}, {})), - ); + this.pageNavigator = new PathPageNavigator(this); - this.pageNavigator.subscribe( - async (pageName, pos: number | string | undefined) => { - console.log("Now navigating to", pageName); + this.pageNavigator.subscribe(async (pageState) => { + console.log("Now navigating to", pageState); - const stateRestored = await this.loadPage(pageName, pos === undefined); - if (pos) { - if (typeof pos === "string") { - console.log("Navigating to anchor", pos); + await this.loadPage(pageState.page); - // We're going to look up the anchor through a API invocation - const matchingAnchor = await this.system.system.localSyscall( - "system.invokeFunction", - [ - "index.getObjectByRef", - pageName, - "anchor", - `${pageName}$${pos}`, - ], - ); + // Setup scroll position, cursor position, etc + this.navigateWithinPage(pageState); - if (!matchingAnchor) { - return this.flashNotification( - `Could not find anchor $${pos}`, - "error", - ); - } else { - pos = matchingAnchor.pos as number; - } - } - setTimeout(() => { - this.editorView.dispatch({ - selection: { anchor: pos as number }, - effects: EditorView.scrollIntoView(pos as number, { - y: "start", - yMargin: 5, - }), - }); - }); - } else if (!stateRestored) { - // Somewhat ad-hoc way to determine if the document contains frontmatter and if so, putting the cursor _after it_. - const pageText = this.editorView.state.sliceDoc(); - - // Default the cursor to be at position 0 - let initialCursorPos = 0; - const match = frontMatterRegex.exec(pageText); - if (match) { - // Frontmatter found, put cursor after it - initialCursorPos = match[0].length; - } - // By default scroll to the top - this.editorView.scrollDOM.scrollTop = 0; - this.editorView.dispatch({ - selection: { anchor: initialCursorPos }, - // And then scroll down if required - scrollIntoView: true, - }); - } - await this.stateDataStore.set(["client", "lastOpenedPage"], pageName); - }, - ); + await this.stateDataStore.set( + ["client", "lastOpenedPage"], + pageState.page, + ); + }); if (location.hash === "#boot") { (async () => { @@ -760,7 +786,7 @@ export class Client { if (this.currentPage) { // And update the editor if a page is loaded - this.openPages.saveState(this.currentPage); + // this.openPages.saveState(this.currentPage); editorView.setState( createEditorState( @@ -776,7 +802,7 @@ export class Client { ); } - this.openPages.restoreState(this.currentPage); + // this.openPages.restoreState(this.currentPage); } } @@ -865,35 +891,41 @@ export class Client { } async navigate( - name: string, - pos?: number | string, + pageRef: PageRef, replaceState = false, newWindow = false, ) { - if (!name) { - name = cleanPageRef( + if (!pageRef.page) { + pageRef.page = cleanPageRef( renderHandlebarsTemplate(this.settings.indexPage, {}, {}), ); } try { - const pagePart = name.split(/[@$]/)[0]; - validatePageName(pagePart); + validatePageName(pageRef.page); } catch (e: any) { return this.flashNotification(e.message, "error"); } if (newWindow) { - const win = window.open(`${location.origin}/${name}`, "_blank"); + const win = window.open( + `${location.origin}/${encodePageRef(pageRef)}`, + "_blank", + ); if (win) { win.focus(); } return; } - await this.pageNavigator!.navigate(name, pos, replaceState); + + await this.pageNavigator!.navigate( + pageRef, + replaceState, + ); + this.focus(); } - async loadPage(pageName: string, restoreState = true): Promise { + async loadPage(pageName: string) { const loadingDifferentPage = pageName !== this.currentPage; const editorView = this.editorView; const previousPage = this.currentPage; @@ -902,7 +934,7 @@ export class Client { // Persist current page state and nicely close page if (previousPage) { - this.openPages.saveState(previousPage); + // this.openPages.saveState(previousPage); this.space.unwatchPage(previousPage); if (previousPage !== pageName) { await this.save(true); @@ -972,7 +1004,6 @@ export class Client { if (editorView.contentDOM) { this.tweakEditorDOM(editorView.contentDOM); } - const stateRestored = restoreState && this.openPages.restoreState(pageName); this.space.watchPage(pageName); // Note: these events are dispatched asynchronously deliberately (not waiting for results) @@ -986,8 +1017,6 @@ export class Client { console.error, ); } - - return stateRestored; } tweakEditorDOM(contentDOM: HTMLElement) { diff --git a/web/cm_plugins/markdown_widget.ts b/web/cm_plugins/markdown_widget.ts index 512f543..8b5eaf5 100644 --- a/web/cm_plugins/markdown_widget.ts +++ b/web/cm_plugins/markdown_widget.ts @@ -5,6 +5,7 @@ import { renderMarkdownToHtml } from "../../plugs/markdown/markdown_render.ts"; import { resolveAttachmentPath } from "$sb/lib/resolve.ts"; import { parse } from "../../common/markdown_parser/parse_tree.ts"; import buildMarkdown from "../../common/markdown_parser/parser.ts"; +import { parsePageRef } from "$sb/lib/page.ts"; const activeWidgets = new Set(); @@ -152,12 +153,8 @@ export class MarkdownWidget extends WidgetType { } e.preventDefault(); e.stopPropagation(); - const [pageName, pos] = el.dataset.ref!.split(/[$@]/); - if (pos && pos.match(/^\d+$/)) { - this.client.navigate(pageName, +pos); - } else { - this.client.navigate(pageName, pos); - } + const pageRef = parsePageRef(el.dataset.ref!); + this.client.navigate(pageRef); }); }); diff --git a/web/cm_plugins/wiki_link.ts b/web/cm_plugins/wiki_link.ts index 352c0b7..305df23 100644 --- a/web/cm_plugins/wiki_link.ts +++ b/web/cm_plugins/wiki_link.ts @@ -9,6 +9,7 @@ import { LinkWidget, } from "./util.ts"; import { resolvePath } from "$sb/lib/resolve.ts"; +import { parsePageRef } from "$sb/lib/page.ts"; /** * Plugin to hide path prefix when the cursor is not inside. @@ -30,7 +31,7 @@ export function cleanWikiLinkPlugin(client: Client) { let pageExists = !client.fullSyncCompleted; let cleanPage = page; - cleanPage = page.split(/[@$]/)[0]; + cleanPage = parsePageRef(page).page; cleanPage = resolvePath(client.currentPage!, cleanPage); const lowerCasePageName = cleanPage.toLowerCase(); for (const pageName of client.allKnownPages) { diff --git a/web/editor_ui.tsx b/web/editor_ui.tsx index f317ea9..c525857 100644 --- a/web/editor_ui.tsx +++ b/web/editor_ui.tsx @@ -21,7 +21,6 @@ import type { Client } from "./client.ts"; import { Panel } from "./components/panel.tsx"; import { h } from "./deps.ts"; import { sleep } from "$sb/lib/async.ts"; -import { template } from "https://esm.sh/v132/handlebars@4.7.7/runtime.d.ts"; export class MainUI { viewState: AppViewState = initialViewState; @@ -112,7 +111,7 @@ export class MainUI { }); if (page) { safeRun(async () => { - await client.navigate(page); + await client.navigate({ page }); }); } }} @@ -246,7 +245,7 @@ export class MainUI { icon: HomeIcon, description: `Go to the index page (Alt-h)`, callback: () => { - client.navigate("", 0); + client.navigate({ page: "", pos: 0 }); // And let's make sure all panels are closed dispatch({ type: "hide-filterbox" }); }, diff --git a/web/navigator.ts b/web/navigator.ts index e18a489..9ac0e07 100644 --- a/web/navigator.ts +++ b/web/navigator.ts @@ -1,43 +1,77 @@ import { safeRun } from "../common/util.ts"; +import { PageRef, parsePageRef } from "$sb/lib/page.ts"; +import { Client } from "./client.ts"; +import { cleanPageRef } from "$sb/lib/resolve.ts"; +import { renderHandlebarsTemplate } from "../common/syscalls/handlebars.ts"; -function encodePageUrl(name: string): string { - return name; -} - -function decodePageUrl(url: string): string { - return url; -} +export type PageState = PageRef & { + scrollTop?: number; + selection?: { + anchor: number; + head?: number; + }; +}; export class PathPageNavigator { navigationResolve?: () => void; + root: string; + indexPage: string; - constructor(readonly indexPage: string, readonly root: string = "") {} + openPages = new Map(); + constructor( + private client: Client, + ) { + this.root = ""; + this.indexPage = cleanPageRef( + renderHandlebarsTemplate(client.settings.indexPage, {}, {}), + ); + } + + /** + * Navigates the client to the given page, this involves: + * - Patching the current popstate with current state + * - Pushing the new state + * - Dispatching a popstate event + * @param pageRef to navigate to + * @param replaceState whether to update the state in place (rather than to push a new state) + */ async navigate( - page: string, - pos?: number | string | undefined, + pageRef: PageRef, replaceState = false, ) { - let encodedPage = encodePageUrl(page); - if (page === this.indexPage) { - encodedPage = ""; + if (pageRef.page === this.indexPage) { + pageRef.page = ""; } - if (replaceState) { + const currentState = this.buildCurrentPageState(); + // 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); + if (!replaceState) { + console.log("Updating current state", currentState); window.history.replaceState( - { page }, - page, - `${this.root}/${encodedPage}`, + cleanState, + "", + `${this.root}/${currentState.page}`, + ); + console.log("Pushing new state", pageRef); + window.history.pushState( + pageRef, + "", + `${this.root}/${pageRef.page}`, ); } else { - window.history.pushState( - { page }, - page, - `${this.root}/${encodedPage}`, + // console.log("Replacing state", pageRef); + window.history.replaceState( + pageRef, + "", + `${this.root}/${pageRef.page}`, ); } + // console.log("Explicitly dispatching the popstate", pageRef); globalThis.dispatchEvent( new PopStateEvent("popstate", { - state: { page, pos }, + state: pageRef, }), ); await new Promise((resolve) => { @@ -46,52 +80,77 @@ export class PathPageNavigator { this.navigationResolve = undefined; } + buildCurrentPageState(): PageState { + const pageState: PageState = this.parseURI(); + 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; + } + subscribe( pageLoadCallback: ( - pageName: string, - pos: number | string | undefined, + pageState: PageState, ) => Promise, ): void { - const cb = (event?: PopStateEvent) => { - const gotoPage = this.getCurrentPage(); - if (!gotoPage) { - return; - } + const cb = (event: PopStateEvent) => { safeRun(async () => { - await pageLoadCallback( - this.getCurrentPage(), - event?.state?.pos, - ); + const popState = event.state; + if (popState) { + // This is the usual case + if (!popState.page) { + popState.page = this.indexPage; + } + if ( + 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) { + console.log("Pulling open page state", openPage); + popState.selection = openPage.selection; + popState.scrollTop = openPage.scrollTop; + } + } + console.log("Got popstate state, using", popState); + await pageLoadCallback(popState); + } else { + // This occurs when the page is loaded completely fresh with no browser history around it + // console.log("Got null state so using", this.parseURI()); + const pageRef = this.parseURI(); + if (!pageRef.page) { + pageRef.page = this.indexPage; + } + await pageLoadCallback(pageRef); + } if (this.navigationResolve) { this.navigationResolve(); } }); }; globalThis.addEventListener("popstate", cb); - cb(); + + cb( + new PopStateEvent("popstate", { + state: this.buildCurrentPageState(), + }), + ); } - decodeURI(): [string, number | string] { - const [page, pos] = decodeURI( + parseURI(): PageRef { + const pageRef = parsePageRef(decodeURI( location.pathname.substring(this.root.length + 1), - ).split(/[@$]/); - if (pos) { - if (pos.match(/^\d+$/)) { - return [page, +pos]; - } else { - return [page, pos]; - } - } else { - return [page, 0]; - } - } + )); - getCurrentPage(): string { - return decodePageUrl(this.decodeURI()[0]) || this.indexPage; - } + // if (!pageRef.page) { + // pageRef.page = this.indexPage; + // } - getCurrentPos(): number | string { - // console.log("Pos", this.decodeURI()[1]); - return this.decodeURI()[1]; + return pageRef; } } diff --git a/web/open_pages.ts b/web/open_pages.ts deleted file mode 100644 index f85cf2d..0000000 --- a/web/open_pages.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Client } from "./client.ts"; -import { EditorSelection } from "./deps.ts"; - -class PageState { - constructor( - readonly scrollTop: number, - readonly selection: EditorSelection, - ) {} -} - -export class OpenPages { - openPages = new Map(); - - constructor(private client: Client) {} - - restoreState(pageName: string): boolean { - const pageState = this.openPages.get(pageName); - const editorView = this.client.editorView; - if (pageState) { - // Restore state - try { - editorView.dispatch({ - selection: pageState.selection, - // scrollIntoView: true, - }); - } catch { - // This is fine, just go to the top - editorView.dispatch({ - selection: { anchor: 0 }, - scrollIntoView: true, - }); - } - setTimeout(() => { - // Next tick, to allow the editor to process the render - editorView.scrollDOM.scrollTop = pageState.scrollTop; - }); - } else { - editorView.scrollDOM.scrollTop = 0; - editorView.dispatch({ - selection: { anchor: 0 }, - scrollIntoView: true, - }); - } - this.client.focus(); - return !!pageState; - } - - saveState(currentPage: string) { - this.openPages.set( - currentPage, - new PageState( - this.client.editorView.scrollDOM.scrollTop, - this.client.editorView.state.selection, - ), - ); - } -} diff --git a/web/syscalls/editor.ts b/web/syscalls/editor.ts index 6ac4357..c4896a1 100644 --- a/web/syscalls/editor.ts +++ b/web/syscalls/editor.ts @@ -13,6 +13,7 @@ import { import { SysCallMapping } from "../../plugos/system.ts"; import type { FilterOption } from "../types.ts"; import { UploadFile } from "../../plug-api/types.ts"; +import { PageRef } from "$sb/lib/page.ts"; export function editorSyscalls(client: Client): SysCallMapping { const syscalls: SysCallMapping = { @@ -33,12 +34,14 @@ export function editorSyscalls(client: Client): SysCallMapping { }, "editor.navigate": async ( _ctx, - name: string, - pos: number | string, + pageRef: PageRef | string, replaceState = false, newWindow = false, ) => { - await client.navigate(name, pos, replaceState, newWindow); + if (typeof pageRef === "string") { + pageRef = { page: pageRef }; + } + await client.navigate(pageRef, replaceState, newWindow); }, "editor.reloadPage": async () => { await client.reloadPage(); diff --git a/web/syscalls/space.ts b/web/syscalls/space.ts index 8dfbb84..7e5b5ab 100644 --- a/web/syscalls/space.ts +++ b/web/syscalls/space.ts @@ -23,10 +23,10 @@ export function spaceSyscalls(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(""); + await editor.navigate({ page: "" }); } // Remove page from open pages in editor - editor.openPages.openPages.delete(name); + // editor.openPages.openPages.delete(name); console.log("Deleting page"); await editor.space.deletePage(name); }, diff --git a/website/Federation.md b/website/Federation.md index 70b3e7c..c55a19e 100644 --- a/website/Federation.md +++ b/website/Federation.md @@ -4,7 +4,7 @@ This enables a few things: * **Linking and browsing** to other publicly hosted SilverBullet spaces (or websites adhering to its [[API]]). For instance the [[!silverbullet.md/CHANGELOG|SilverBullet CHANGELOG]] without leaving the comfort of your own SilverBullet client. * **Reusing** content from externally hosted sources, such as: - * _Templates_, e.g. by federating with `silverbullet.md/template` will give you access to the example templates hosted there without manually copying and pasting them and automatically pull in the latest version. So you can, for instance, use `render [[!silverbullet.md/template/page]]` to use the [[Library/Core/Query/Page]] template. See [[Libraries]] for more on this use case. + * [[Libraries]] synchronization. By federating with `silverbullet.md/Library/Core`, you will get you access to the templates hosted there without copying ({[Library: Import]}‘ing) them and automatically pull in the latest versions. * _Data_: such as tasks, item, data hosted elsewhere that you want to query from your own space. **Note:** Federation does not support authentication yet, so all federated spaces need to be unauthenticated and will be _read-only_. @@ -17,10 +17,10 @@ To synchronize federated content into your client, you need to list these URIs i ```yaml federate: -- uri: silverbullet.md/template +- uri: silverbullet.md/Library/Core/ ``` -This will synchronize all content under `!silverbullet.md` with a `template` prefix (so all templates hosted there) locally. +This will synchronize all content under `!silverbullet.md` with a `Library/Core/` prefix (so all templates hosted there) locally. Currently, content can only be synchronized in read-only mode, so you can not edit the synchronized files. This will likely change in the future. diff --git a/website/Install/Docker.md b/website/Install/Docker.md index 4e076c5..a6aca02 100644 --- a/website/Install/Docker.md +++ b/website/Install/Docker.md @@ -20,7 +20,7 @@ For your first run, you can run the following: # Create a local folder "space" to keep files in $ mkdir -p space # Run the SilverBullet docker container in the foreground -$ docker run -it -p 3000:3000 -v ./space:/space zefhemel/silverbullet +$ sudo docker run -it -p 3000:3000 -v ./space:/space zefhemel/silverbullet ``` This will run SilverBullet in the foreground, interactively, so you can see the logs and instructions. @@ -49,7 +49,7 @@ $ docker kill silverbullet # Remove the old container $ docker rm silverbullet # Start a fresh one (same command as before) -$ docker run -d --restart unless-stopped --name silverbullet -p 3000:3000 -v ./space:/space zefhemel/silverbullet +$ docker run -d --restart unless-stopped --name silverbullet -p 3000:3000 -v $PW/space:/space zefhemel/silverbullet ``` Since this is somewhat burdensome, it is recommended you use a tool like [watchtower](https://github.com/containrrr/watchtower) to automatically update your docker images and restart them. However, if we go there — we may as well use a tool like _docker compose_ to manage your containers, no?