1
0

Navigator refactor (#648)

Navigation refactor
This commit is contained in:
Zef Hemel 2024-01-24 11:58:33 +01:00 committed by GitHub
parent 9fa52e43e0
commit aaacec6d61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 360 additions and 263 deletions

View File

@ -60,7 +60,7 @@ export class HttpSpacePrimitives implements SpacePrimitives {
console.error( console.error(
"Got error fetching, throwing offline", "Got error fetching, throwing offline",
url, url,
e.errorMessage, e,
); );
throw new Error("Offline"); throw new Error("Offline");
} }

24
plug-api/lib/page.test.ts Normal file
View File

@ -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");
});

View File

@ -6,13 +6,47 @@ export function validatePageName(name: string) {
if (name.startsWith(".")) { if (name.startsWith(".")) {
throw new Error("Page name cannot start with a '.'"); 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)) { if (/\.[a-zA-Z]+$/.test(name)) {
throw new Error("Page name can not end with a file extension"); 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;
}

View File

@ -1,4 +1,5 @@
import type { FilterOption } from "../../web/types.ts"; import type { FilterOption } from "../../web/types.ts";
import { PageRef } from "$sb/lib/page.ts";
import { UploadFile } from "../types.ts"; import { UploadFile } from "../types.ts";
import { syscall } from "./syscall.ts"; import { syscall } from "./syscall.ts";
@ -31,12 +32,11 @@ export function save(): Promise<void> {
} }
export function navigate( export function navigate(
name: string, pageRef: PageRef,
pos?: string | number,
replaceState = false, replaceState = false,
newWindow = false, newWindow = false,
): Promise<void> { ): Promise<void> {
return syscall("editor.navigate", name, pos, replaceState, newWindow); return syscall("editor.navigate", pageRef, replaceState, newWindow);
} }
export function openPageNavigator( export function openPageNavigator(

View File

@ -1,5 +1,6 @@
import { traverseTree } from "../../plug-api/lib/tree.ts"; import { traverseTree } from "../../plug-api/lib/tree.ts";
import { editor, markdown, space } from "$sb/syscalls.ts"; import { editor, markdown, space } from "$sb/syscalls.ts";
import { parsePageRef } from "$sb/lib/page.ts";
export async function brokenLinksCommand() { export async function brokenLinksCommand() {
const pageName = "BROKEN LINKS"; const pageName = "BROKEN LINKS";
@ -13,7 +14,7 @@ export async function brokenLinksCommand() {
traverseTree(tree, (tree) => { traverseTree(tree, (tree) => {
if (tree.type === "WikiLinkPage") { if (tree.type === "WikiLinkPage") {
// Add the prefix in the link text // 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("!")) { if (pageName.startsWith("!")) {
return true; return true;
} }
@ -53,5 +54,5 @@ export async function brokenLinksCommand() {
); );
} }
await space.writePage(pageName, lines.join("\n")); await space.writePage(pageName, lines.join("\n"));
await editor.navigate(pageName); await editor.navigate({ page: pageName });
} }

View File

@ -8,6 +8,7 @@ import {
ParseTree, ParseTree,
} from "$sb/lib/tree.ts"; } from "$sb/lib/tree.ts";
import { resolveAttachmentPath, resolvePath } from "$sb/lib/resolve.ts"; import { resolveAttachmentPath, resolvePath } from "$sb/lib/resolve.ts";
import { parsePageRef } from "$sb/lib/page.ts";
async function actionClickOrActionEnter( async function actionClickOrActionEnter(
mdTree: ParseTree | null, mdTree: ParseTree | null,
@ -39,30 +40,22 @@ async function actionClickOrActionEnter(
const currentPage = await editor.getCurrentPage(); const currentPage = await editor.getCurrentPage();
switch (mdTree.type) { switch (mdTree.type) {
case "WikiLink": { case "WikiLink": {
let pageLink = mdTree.children![1]!.children![0].text!; const pageLink = mdTree.children![1]!.children![0].text!;
let pos: string | number = 0; const pageRef = parsePageRef(pageLink);
if (pageLink.includes("@") || pageLink.includes("$")) { pageRef.page = resolvePath(currentPage, pageRef.page);
[pageLink, pos] = pageLink.split(/[@$]/); if (!pageRef.page) {
if (pos.match(/^\d+$/)) { pageRef.page = currentPage;
pos = +pos;
}
} }
pageLink = resolvePath(currentPage, pageLink); // This is an explicit navigate, move to the top
if (!pageLink) { if (pageRef.pos === undefined) {
pageLink = currentPage; pageRef.pos = 0;
} }
await editor.navigate(pageLink, pos, false, inNewWindow); await editor.navigate(pageRef, false, inNewWindow);
break; break;
} }
case "PageRef": { case "PageRef": {
const bracketedPageRef = mdTree.children![0].text!; const pageName = parsePageRef(mdTree.children![0].text!).page;
await editor.navigate({ page: pageName, pos: 0 }, false, inNewWindow);
// Slicing off the initial [[ and final ]]
const pageName = bracketedPageRef.substring(
2,
bracketedPageRef.length - 2,
);
await editor.navigate(pageName, 0, false, inNewWindow);
break; break;
} }
case "NakedURL": case "NakedURL":
@ -125,5 +118,5 @@ export async function clickNavigate(event: ClickEvent) {
} }
export async function navigateCommand(cmdDef: any) { export async function navigateCommand(cmdDef: any) {
await editor.navigate(cmdDef.page); await editor.navigate({ page: cmdDef.page, pos: 0 });
} }

View File

@ -9,7 +9,7 @@ export async function deletePage() {
return; return;
} }
console.log("Navigating to index page"); console.log("Navigating to index page");
await editor.navigate(""); await editor.navigate({ page: "" });
console.log("Deleting page from space"); console.log("Deleting page from space");
await space.deletePage(pageName); await space.deletePage(pageName);
} }
@ -57,7 +57,7 @@ export async function copyPage(
if (currentPage === fromName) { if (currentPage === fromName) {
// If we're copying the current page, navigate there // If we're copying the current page, navigate there
console.log("Navigating to new page"); console.log("Navigating to new page");
await editor.navigate(newName); await editor.navigate({ page: newName });
} else { } else {
// Otherwise just notify of success // Otherwise just notify of success
await editor.flashNotification("Page copied successfully"); await editor.flashNotification("Page copied successfully");

View File

@ -2,6 +2,7 @@ import { collectNodesOfType } from "$sb/lib/tree.ts";
import type { CompleteEvent, IndexTreeEvent } from "$sb/app_event.ts"; import type { CompleteEvent, IndexTreeEvent } from "$sb/app_event.ts";
import { ObjectValue, QueryExpression } from "$sb/types.ts"; import { ObjectValue, QueryExpression } from "$sb/types.ts";
import { indexObjects, queryObjects } from "./api.ts"; import { indexObjects, queryObjects } from "./api.ts";
import { parsePageRef } from "$sb/lib/page.ts";
type AnchorObject = ObjectValue<{ type AnchorObject = ObjectValue<{
name: string; name: string;
@ -27,14 +28,14 @@ export async function indexAnchors({ name: pageName, tree }: IndexTreeEvent) {
} }
export async function anchorComplete(completeEvent: CompleteEvent) { export async function anchorComplete(completeEvent: CompleteEvent) {
const match = /\[\[([^\]@$:]*[@$][\w\.\-\/]*)$/.exec( const match = /\[\[([^\]$:]*\$[\w\.\-\/]*)$/.exec(
completeEvent.linePrefix, completeEvent.linePrefix,
); );
if (!match) { if (!match) {
return null; return null;
} }
const pageRef = match[1].split(/[@$]/)[0]; const pageRef = parsePageRef(match[1]).page;
let filter: QueryExpression | undefined = ["=", ["attr", "page"], [ let filter: QueryExpression | undefined = ["=", ["attr", "page"], [
"string", "string",
pageRef, pageRef,

View File

@ -5,6 +5,7 @@ import { indexObjects, queryObjects } from "./api.ts";
import { ObjectValue } from "$sb/types.ts"; import { ObjectValue } from "$sb/types.ts";
import { extractFrontmatter } from "$sb/lib/frontmatter.ts"; import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
import { updateITags } from "$sb/lib/tags.ts"; import { updateITags } from "$sb/lib/tags.ts";
import { parsePageRef } from "$sb/lib/page.ts";
const pageRefRegex = /\[\[([^\]]+)\]\]/g; const pageRefRegex = /\[\[([^\]]+)\]\]/g;
@ -56,7 +57,7 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) {
const wikiLinkAlias = findNodeOfType(n, "WikiLinkAlias"); const wikiLinkAlias = findNodeOfType(n, "WikiLinkAlias");
let toPage = resolvePath(name, wikiLinkPage.children![0].text!); let toPage = resolvePath(name, wikiLinkPage.children![0].text!);
const pos = wikiLinkPage.from!; const pos = wikiLinkPage.from!;
toPage = toPage.split(/[@$]/)[0]; toPage = parsePageRef(toPage).page;
const link: LinkObject = { const link: LinkObject = {
ref: `${name}@${pos}`, ref: `${name}@${pos}`,
tag: "link", tag: "link",
@ -89,7 +90,7 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) {
const code = codeText.children![0].text!; const code = codeText.children![0].text!;
const matches = code.matchAll(pageRefRegex); const matches = code.matchAll(pageRefRegex);
for (const match of matches) { 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 pos = codeText.from! + match.index! + 2;
const link = { const link = {
ref: `${name}@${pos}`, ref: `${name}@${pos}`,

View File

@ -67,7 +67,7 @@ async function renamePage(
if (navigateThere) { if (navigateThere) {
console.log("Navigating to new page"); 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); const pagesToUpdate = await getBackLinks(oldName);
@ -101,12 +101,16 @@ async function renamePage(
continue; continue;
} }
// Replace all links found in place following the patterns [[Page]] and [[Page@pos]] as well as [[Page$anchor]]
const newText = text.replaceAll(`[[${oldName}]]`, () => { const newText = text.replaceAll(`[[${oldName}]]`, () => {
updatedReferences++; updatedReferences++;
return `[[${newName}]]`; return `[[${newName}]]`;
}).replaceAll(`[[${oldName}@`, () => { }).replaceAll(`[[${oldName}@`, () => {
updatedReferences++; updatedReferences++;
return `[[${newName}@`; return `[[${newName}@`;
}).replaceAll(`[[${oldName}$`, () => {
updatedReferences++;
return `[[${newName}$`;
}); });
if (text !== newText) { if (text !== newText) {
console.log("Changes made, saving..."); console.log("Changes made, saving...");
@ -207,5 +211,5 @@ export async function extractToPageCommand() {
console.log("Writing new page to space"); console.log("Writing new page to space");
await space.writePage(newName, text); await space.writePage(newName, text);
console.log("Navigating to new page"); console.log("Navigating to new page");
await editor.navigate(newName); await editor.navigate({ page: newName });
} }

View File

@ -7,6 +7,7 @@ import {
renderToText, renderToText,
traverseTree, traverseTree,
} from "$sb/lib/tree.ts"; } from "$sb/lib/tree.ts";
import { parsePageRef } from "$sb/lib/page.ts";
import { Fragment, renderHtml, Tag } from "./html_render.ts"; import { Fragment, renderHtml, Tag } from "./html_render.ts";
export type MarkdownRenderOptions = { export type MarkdownRenderOptions = {
@ -245,11 +246,11 @@ function render(
if (aliasNode) { if (aliasNode) {
linkText = aliasNode.children![0].text!; linkText = aliasNode.children![0].text!;
} }
const [pageName] = ref.split(/[@$]/); const { page: pageName, anchor } = parsePageRef(ref);
return { return {
name: "a", name: "a",
attrs: { attrs: {
href: `/${pageName}`, href: `/${pageName}${anchor ? "#" + anchor : ""}`,
class: "wiki-link", class: "wiki-link",
"data-ref": ref, "data-ref": ref,
}, },
@ -278,9 +279,9 @@ function render(
case "Task": { case "Task": {
let externalTaskRef = ""; let externalTaskRef = "";
collectNodesOfType(t, "WikiLinkPage").forEach((wikilink) => { collectNodesOfType(t, "WikiLinkPage").forEach((wikilink) => {
const ref = wikilink.children![0].text!; const pageRef = parsePageRef(wikilink.children![0].text!);
if (!externalTaskRef && (ref.includes("@") || ref.includes("$"))) { if (!externalTaskRef && (pageRef.pos !== undefined || pageRef.anchor)) {
externalTaskRef = ref; externalTaskRef = wikilink.children![0].text!;
} }
}); });

View File

@ -109,7 +109,7 @@ export async function addPlugCommand() {
plugsPrelude + "```yaml\n" + plugList.map((p) => `- ${p}`).join("\n") + plugsPrelude + "```yaml\n" + plugList.map((p) => `- ${p}`).join("\n") +
"\n```", "\n```",
); );
await editor.navigate("PLUGS"); await editor.navigate({ page: "PLUGS" });
await updatePlugsCommand(); await updatePlugsCommand();
await editor.flashNotification("Plug added!"); await editor.flashNotification("Plug added!");
system.reloadPlugs(); system.reloadPlugs();

View File

@ -48,7 +48,7 @@ export async function queryProvider({
export async function searchCommand() { export async function searchCommand() {
const phrase = await editor.prompt("Search for: "); const phrase = await editor.prompt("Search for: ");
if (phrase) { if (phrase) {
await editor.navigate(`${searchPrefix}${phrase}`); await editor.navigate({ page: `${searchPrefix}${phrase}` });
} }
} }

View File

@ -20,6 +20,7 @@ import { ObjectValue } from "$sb/types.ts";
import { indexObjects, queryObjects } from "../index/plug_api.ts"; import { indexObjects, queryObjects } from "../index/plug_api.ts";
import { updateITags } from "$sb/lib/tags.ts"; import { updateITags } from "$sb/lib/tags.ts";
import { extractFrontmatter } from "$sb/lib/frontmatter.ts"; import { extractFrontmatter } from "$sb/lib/frontmatter.ts";
import { parsePageRef } from "$sb/lib/page.ts";
export type TaskObject = ObjectValue< export type TaskObject = ObjectValue<
{ {
@ -191,8 +192,11 @@ export async function updateTaskState(
newState: string, newState: string,
) { ) {
const currentPage = await editor.getCurrentPage(); const currentPage = await editor.getCurrentPage();
const [page, posS] = ref.split("@"); const { page, pos } = parsePageRef(ref);
const pos = +posS; if (pos === undefined) {
console.error("No position found in page ref, skipping", ref);
return;
}
if (page === currentPage) { if (page === currentPage) {
// In current page, just update the task marker with dispatch // In current page, just update the task marker with dispatch
const editorText = await editor.getText(); const editorText = await editor.getText();

View File

@ -154,7 +154,7 @@ async function instantiatePageTemplate(
// So, page exists // So, page exists
if (newPageConfig.openIfExists) { if (newPageConfig.openIfExists) {
console.log("Page already exists, navigating there"); console.log("Page already exists, navigating there");
await editor.navigate(pageName); await editor.navigate({ page: pageName, pos: 0 });
return; return;
} }
@ -165,7 +165,7 @@ async function instantiatePageTemplate(
) )
) { ) {
// Just navigate there without instantiating // Just navigate there without instantiating
return editor.navigate(pageName); return editor.navigate({ page: pageName, pos: 0 });
} }
} catch { } catch {
// The preferred scenario, let's keep going // The preferred scenario, let's keep going
@ -191,7 +191,10 @@ async function instantiatePageTemplate(
pageName, pageName,
fullPageText, fullPageText,
); );
await editor.navigate(pageName, carretPos !== -1 ? carretPos : undefined); await editor.navigate({
page: pageName,
pos: carretPos !== -1 ? carretPos : undefined,
});
} }
} }

View File

@ -13,7 +13,7 @@ import { FilterOption } from "./types.ts";
import { ensureSettingsAndIndex } from "../common/util.ts"; import { ensureSettingsAndIndex } from "../common/util.ts";
import { EventHook } from "../plugos/hooks/event.ts"; import { EventHook } from "../plugos/hooks/event.ts";
import { AppCommand } from "./hooks/command.ts"; import { AppCommand } from "./hooks/command.ts";
import { PathPageNavigator } from "./navigator.ts"; import { PageState, PathPageNavigator } from "./navigator.ts";
import { AppViewState, BuiltinSettings } from "./types.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 { HttpSpacePrimitives } from "../common/spaces/http_space_primitives.ts";
import { FallbackSpacePrimitives } from "../common/spaces/fallback_space_primitives.ts"; import { FallbackSpacePrimitives } from "../common/spaces/fallback_space_primitives.ts";
import { FilteredSpacePrimitives } from "../common/spaces/filtered_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 { ClientSystem } from "./client_system.ts";
import { createEditorState } from "./editor_state.ts"; import { createEditorState } from "./editor_state.ts";
import { OpenPages } from "./open_pages.ts";
import { MainUI } from "./editor_ui.tsx"; import { MainUI } from "./editor_ui.tsx";
import { cleanPageRef } from "$sb/lib/resolve.ts"; import { cleanPageRef } from "$sb/lib/resolve.ts";
import { SpacePrimitives } from "../common/spaces/space_primitives.ts"; import { SpacePrimitives } from "../common/spaces/space_primitives.ts";
@ -55,6 +54,7 @@ import {
import { LimitedMap } from "$sb/lib/limited_map.ts"; import { LimitedMap } from "$sb/lib/limited_map.ts";
import { renderHandlebarsTemplate } from "../common/syscalls/handlebars.ts"; import { renderHandlebarsTemplate } from "../common/syscalls/handlebars.ts";
import { buildQueryFunctions } from "../common/query_functions.ts"; import { buildQueryFunctions } from "../common/query_functions.ts";
import { PageRef } from "$sb/lib/page.ts";
const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/; const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/;
const autoSaveInterval = 1000; const autoSaveInterval = 1000;
@ -71,6 +71,8 @@ declare global {
} }
} }
// history.scrollRestoration = "manual";
export class Client { export class Client {
system!: ClientSystem; system!: ClientSystem;
editorView!: EditorView; editorView!: EditorView;
@ -113,7 +115,6 @@ export class Client {
eventHook!: EventHook; eventHook!: EventHook;
ui!: MainUI; ui!: MainUI;
openPages!: OpenPages;
stateDataStore!: DataStore; stateDataStore!: DataStore;
spaceDataStore!: DataStore; spaceDataStore!: DataStore;
mq!: DataStoreMQ; mq!: DataStoreMQ;
@ -192,8 +193,6 @@ export class Client {
parent: document.getElementById("sb-editor")!, parent: document.getElementById("sb-editor")!,
}); });
this.openPages = new OpenPages(this);
this.focus(); this.focus();
await this.system.init(); 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() { private initNavigator() {
this.pageNavigator = new PathPageNavigator( this.pageNavigator = new PathPageNavigator(this);
cleanPageRef(renderHandlebarsTemplate(this.settings.indexPage, {}, {})),
);
this.pageNavigator.subscribe( this.pageNavigator.subscribe(async (pageState) => {
async (pageName, pos: number | string | undefined) => { console.log("Now navigating to", pageState);
console.log("Now navigating to", pageName);
const stateRestored = await this.loadPage(pageName, pos === undefined); await this.loadPage(pageState.page);
if (pos) {
if (typeof pos === "string") {
console.log("Navigating to anchor", pos);
// We're going to look up the anchor through a API invocation // Setup scroll position, cursor position, etc
const matchingAnchor = await this.system.system.localSyscall( this.navigateWithinPage(pageState);
"system.invokeFunction",
[
"index.getObjectByRef",
pageName,
"anchor",
`${pageName}$${pos}`,
],
);
if (!matchingAnchor) { await this.stateDataStore.set(
return this.flashNotification( ["client", "lastOpenedPage"],
`Could not find anchor $${pos}`, pageState.page,
"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);
},
);
if (location.hash === "#boot") { if (location.hash === "#boot") {
(async () => { (async () => {
@ -760,7 +786,7 @@ export class Client {
if (this.currentPage) { if (this.currentPage) {
// And update the editor if a page is loaded // And update the editor if a page is loaded
this.openPages.saveState(this.currentPage); // this.openPages.saveState(this.currentPage);
editorView.setState( editorView.setState(
createEditorState( 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( async navigate(
name: string, pageRef: PageRef,
pos?: number | string,
replaceState = false, replaceState = false,
newWindow = false, newWindow = false,
) { ) {
if (!name) { if (!pageRef.page) {
name = cleanPageRef( pageRef.page = cleanPageRef(
renderHandlebarsTemplate(this.settings.indexPage, {}, {}), renderHandlebarsTemplate(this.settings.indexPage, {}, {}),
); );
} }
try { try {
const pagePart = name.split(/[@$]/)[0]; validatePageName(pageRef.page);
validatePageName(pagePart);
} catch (e: any) { } catch (e: any) {
return this.flashNotification(e.message, "error"); return this.flashNotification(e.message, "error");
} }
if (newWindow) { if (newWindow) {
const win = window.open(`${location.origin}/${name}`, "_blank"); const win = window.open(
`${location.origin}/${encodePageRef(pageRef)}`,
"_blank",
);
if (win) { if (win) {
win.focus(); win.focus();
} }
return; return;
} }
await this.pageNavigator!.navigate(name, pos, replaceState);
await this.pageNavigator!.navigate(
pageRef,
replaceState,
);
this.focus();
} }
async loadPage(pageName: string, restoreState = true): Promise<boolean> { async loadPage(pageName: string) {
const loadingDifferentPage = pageName !== this.currentPage; const loadingDifferentPage = pageName !== this.currentPage;
const editorView = this.editorView; const editorView = this.editorView;
const previousPage = this.currentPage; const previousPage = this.currentPage;
@ -902,7 +934,7 @@ export class Client {
// Persist current page state and nicely close page // Persist current page state and nicely close page
if (previousPage) { if (previousPage) {
this.openPages.saveState(previousPage); // this.openPages.saveState(previousPage);
this.space.unwatchPage(previousPage); this.space.unwatchPage(previousPage);
if (previousPage !== pageName) { if (previousPage !== pageName) {
await this.save(true); await this.save(true);
@ -972,7 +1004,6 @@ export class Client {
if (editorView.contentDOM) { if (editorView.contentDOM) {
this.tweakEditorDOM(editorView.contentDOM); this.tweakEditorDOM(editorView.contentDOM);
} }
const stateRestored = restoreState && this.openPages.restoreState(pageName);
this.space.watchPage(pageName); this.space.watchPage(pageName);
// Note: these events are dispatched asynchronously deliberately (not waiting for results) // Note: these events are dispatched asynchronously deliberately (not waiting for results)
@ -986,8 +1017,6 @@ export class Client {
console.error, console.error,
); );
} }
return stateRestored;
} }
tweakEditorDOM(contentDOM: HTMLElement) { tweakEditorDOM(contentDOM: HTMLElement) {

View File

@ -5,6 +5,7 @@ import { renderMarkdownToHtml } from "../../plugs/markdown/markdown_render.ts";
import { resolveAttachmentPath } from "$sb/lib/resolve.ts"; import { resolveAttachmentPath } from "$sb/lib/resolve.ts";
import { parse } from "../../common/markdown_parser/parse_tree.ts"; import { parse } from "../../common/markdown_parser/parse_tree.ts";
import buildMarkdown from "../../common/markdown_parser/parser.ts"; import buildMarkdown from "../../common/markdown_parser/parser.ts";
import { parsePageRef } from "$sb/lib/page.ts";
const activeWidgets = new Set<MarkdownWidget>(); const activeWidgets = new Set<MarkdownWidget>();
@ -152,12 +153,8 @@ export class MarkdownWidget extends WidgetType {
} }
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const [pageName, pos] = el.dataset.ref!.split(/[$@]/); const pageRef = parsePageRef(el.dataset.ref!);
if (pos && pos.match(/^\d+$/)) { this.client.navigate(pageRef);
this.client.navigate(pageName, +pos);
} else {
this.client.navigate(pageName, pos);
}
}); });
}); });

View File

@ -9,6 +9,7 @@ import {
LinkWidget, LinkWidget,
} from "./util.ts"; } from "./util.ts";
import { resolvePath } from "$sb/lib/resolve.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. * 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 pageExists = !client.fullSyncCompleted;
let cleanPage = page; let cleanPage = page;
cleanPage = page.split(/[@$]/)[0]; cleanPage = parsePageRef(page).page;
cleanPage = resolvePath(client.currentPage!, cleanPage); cleanPage = resolvePath(client.currentPage!, cleanPage);
const lowerCasePageName = cleanPage.toLowerCase(); const lowerCasePageName = cleanPage.toLowerCase();
for (const pageName of client.allKnownPages) { for (const pageName of client.allKnownPages) {

View File

@ -21,7 +21,6 @@ import type { Client } from "./client.ts";
import { Panel } from "./components/panel.tsx"; import { Panel } from "./components/panel.tsx";
import { h } from "./deps.ts"; import { h } from "./deps.ts";
import { sleep } from "$sb/lib/async.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 { export class MainUI {
viewState: AppViewState = initialViewState; viewState: AppViewState = initialViewState;
@ -112,7 +111,7 @@ export class MainUI {
}); });
if (page) { if (page) {
safeRun(async () => { safeRun(async () => {
await client.navigate(page); await client.navigate({ page });
}); });
} }
}} }}
@ -246,7 +245,7 @@ export class MainUI {
icon: HomeIcon, icon: HomeIcon,
description: `Go to the index page (Alt-h)`, description: `Go to the index page (Alt-h)`,
callback: () => { callback: () => {
client.navigate("", 0); client.navigate({ page: "", pos: 0 });
// And let's make sure all panels are closed // And let's make sure all panels are closed
dispatch({ type: "hide-filterbox" }); dispatch({ type: "hide-filterbox" });
}, },

View File

@ -1,43 +1,77 @@
import { safeRun } from "../common/util.ts"; 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 { export type PageState = PageRef & {
return name; scrollTop?: number;
} selection?: {
anchor: number;
function decodePageUrl(url: string): string { head?: number;
return url; };
} };
export class PathPageNavigator { export class PathPageNavigator {
navigationResolve?: () => void; navigationResolve?: () => void;
root: string;
indexPage: string;
constructor(readonly indexPage: string, readonly root: string = "") {} openPages = new Map<string, PageState>();
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( async navigate(
page: string, pageRef: PageRef,
pos?: number | string | undefined,
replaceState = false, replaceState = false,
) { ) {
let encodedPage = encodePageUrl(page); if (pageRef.page === this.indexPage) {
if (page === this.indexPage) { pageRef.page = "";
encodedPage = "";
} }
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( window.history.replaceState(
{ page }, cleanState,
page, "",
`${this.root}/${encodedPage}`, `${this.root}/${currentState.page}`,
);
console.log("Pushing new state", pageRef);
window.history.pushState(
pageRef,
"",
`${this.root}/${pageRef.page}`,
); );
} else { } else {
window.history.pushState( // console.log("Replacing state", pageRef);
{ page }, window.history.replaceState(
page, pageRef,
`${this.root}/${encodedPage}`, "",
`${this.root}/${pageRef.page}`,
); );
} }
// console.log("Explicitly dispatching the popstate", pageRef);
globalThis.dispatchEvent( globalThis.dispatchEvent(
new PopStateEvent("popstate", { new PopStateEvent("popstate", {
state: { page, pos }, state: pageRef,
}), }),
); );
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
@ -46,52 +80,77 @@ export class PathPageNavigator {
this.navigationResolve = undefined; 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( subscribe(
pageLoadCallback: ( pageLoadCallback: (
pageName: string, pageState: PageState,
pos: number | string | undefined,
) => Promise<void>, ) => Promise<void>,
): void { ): void {
const cb = (event?: PopStateEvent) => { const cb = (event: PopStateEvent) => {
const gotoPage = this.getCurrentPage();
if (!gotoPage) {
return;
}
safeRun(async () => { safeRun(async () => {
await pageLoadCallback( const popState = event.state;
this.getCurrentPage(), if (popState) {
event?.state?.pos, // 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) { if (this.navigationResolve) {
this.navigationResolve(); this.navigationResolve();
} }
}); });
}; };
globalThis.addEventListener("popstate", cb); globalThis.addEventListener("popstate", cb);
cb();
cb(
new PopStateEvent("popstate", {
state: this.buildCurrentPageState(),
}),
);
} }
decodeURI(): [string, number | string] { parseURI(): PageRef {
const [page, pos] = decodeURI( const pageRef = parsePageRef(decodeURI(
location.pathname.substring(this.root.length + 1), 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 { // if (!pageRef.page) {
return decodePageUrl(this.decodeURI()[0]) || this.indexPage; // pageRef.page = this.indexPage;
} // }
getCurrentPos(): number | string { return pageRef;
// console.log("Pos", this.decodeURI()[1]);
return this.decodeURI()[1];
} }
} }

View File

@ -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<string, PageState>();
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,
),
);
}
}

View File

@ -13,6 +13,7 @@ import {
import { SysCallMapping } from "../../plugos/system.ts"; import { SysCallMapping } from "../../plugos/system.ts";
import type { FilterOption } from "../types.ts"; import type { FilterOption } from "../types.ts";
import { UploadFile } from "../../plug-api/types.ts"; import { UploadFile } from "../../plug-api/types.ts";
import { PageRef } from "$sb/lib/page.ts";
export function editorSyscalls(client: Client): SysCallMapping { export function editorSyscalls(client: Client): SysCallMapping {
const syscalls: SysCallMapping = { const syscalls: SysCallMapping = {
@ -33,12 +34,14 @@ export function editorSyscalls(client: Client): SysCallMapping {
}, },
"editor.navigate": async ( "editor.navigate": async (
_ctx, _ctx,
name: string, pageRef: PageRef | string,
pos: number | string,
replaceState = false, replaceState = false,
newWindow = 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 () => { "editor.reloadPage": async () => {
await client.reloadPage(); await client.reloadPage();

View File

@ -23,10 +23,10 @@ export function spaceSyscalls(editor: Client): SysCallMapping {
"space.deletePage": async (_ctx, name: string) => { "space.deletePage": async (_ctx, name: string) => {
// If we're deleting the current page, navigate to the index page // If we're deleting the current page, navigate to the index page
if (editor.currentPage === name) { if (editor.currentPage === name) {
await editor.navigate(""); await editor.navigate({ page: "" });
} }
// Remove page from open pages in editor // Remove page from open pages in editor
editor.openPages.openPages.delete(name); // editor.openPages.openPages.delete(name);
console.log("Deleting page"); console.log("Deleting page");
await editor.space.deletePage(name); await editor.space.deletePage(name);
}, },

View File

@ -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. * **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: * **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. * _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_. **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 ```yaml
federate: 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. 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.

View File

@ -20,7 +20,7 @@ For your first run, you can run the following:
# Create a local folder "space" to keep files in # Create a local folder "space" to keep files in
$ mkdir -p space $ mkdir -p space
# Run the SilverBullet docker container in the foreground # 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. 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 # Remove the old container
$ docker rm silverbullet $ docker rm silverbullet
# Start a fresh one (same command as before) # 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? 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?