parent
9fa52e43e0
commit
aaacec6d61
@ -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
24
plug-api/lib/page.test.ts
Normal 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");
|
||||
});
|
@ -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;
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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");
|
||||
|
@ -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,
|
||||
|
@ -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}`,
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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!;
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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}` });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
191
web/client.ts
191
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<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) {
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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" });
|
||||
},
|
||||
|
165
web/navigator.ts
165
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<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;
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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);
|
||||
},
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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?
|
||||
|
Loading…
Reference in New Issue
Block a user