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(
"Got error fetching, throwing offline",
url,
e.errorMessage,
e,
);
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(".")) {
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;
}

View File

@ -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<void> {
}
export function navigate(
name: string,
pos?: string | number,
pageRef: PageRef,
replaceState = false,
newWindow = false,
): Promise<void> {
return syscall("editor.navigate", name, pos, replaceState, newWindow);
return syscall("editor.navigate", pageRef, replaceState, newWindow);
}
export function openPageNavigator(

View File

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

View File

@ -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;
}
// This is an explicit navigate, move to the top
if (pageRef.pos === undefined) {
pageRef.pos = 0;
}
pageLink = resolvePath(currentPage, pageLink);
if (!pageLink) {
pageLink = currentPage;
}
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 });
}

View File

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

View File

@ -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,

View File

@ -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}`,

View File

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

View File

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

View File

@ -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();

View File

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

View File

@ -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();

View File

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

View File

@ -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,50 +311,61 @@ export class Client {
);
}
private initNavigator() {
this.pageNavigator = new PathPageNavigator(
cleanPageRef(renderHandlebarsTemplate(this.settings.indexPage, {}, {})),
);
private navigateWithinPage(pageState: PageState) {
// Did we end up doing anything in terms of internal navigation?
let adjustedPosition = false;
this.pageNavigator.subscribe(
async (pageName, pos: number | string | undefined) => {
console.log("Now navigating to", pageName);
// 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;
}
const stateRestored = await this.loadPage(pageName, pos === undefined);
if (pos) {
if (typeof pos === "string") {
console.log("Navigating to anchor", pos);
// 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;
}
// 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}`,
],
);
// 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();
if (!matchingAnchor) {
pos = pageText.indexOf(`$${pageState.anchor}`);
if (pos === -1) {
return this.flashNotification(
`Could not find anchor $${pos}`,
`Could not find anchor $${pageState.anchor}`,
"error",
);
} else {
pos = matchingAnchor.pos as number;
}
adjustedPosition = true;
}
setTimeout(() => {
if (pos !== undefined) {
// setTimeout(() => {
console.log("Doing this pos set to", pos);
this.editorView.dispatch({
selection: { anchor: pos as number },
effects: EditorView.scrollIntoView(pos as number, {
selection: { anchor: pos! },
effects: EditorView.scrollIntoView(pos!, {
y: "start",
yMargin: 5,
}),
});
});
} else if (!stateRestored) {
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();
@ -367,6 +377,7 @@ export class Client {
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 },
@ -374,9 +385,24 @@ export class Client {
scrollIntoView: true,
});
}
await this.stateDataStore.set(["client", "lastOpenedPage"], pageName);
},
}
private initNavigator() {
this.pageNavigator = new PathPageNavigator(this);
this.pageNavigator.subscribe(async (pageState) => {
console.log("Now navigating to", pageState);
await this.loadPage(pageState.page);
// Setup scroll position, cursor position, etc
this.navigateWithinPage(pageState);
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<boolean> {
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) {

View File

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

View File

@ -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) {

View File

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

View File

@ -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<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(
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<void>((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>,
): 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;
}
}

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 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();

View File

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

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.
* **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.

View File

@ -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?