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
25 changed files with 360 additions and 263 deletions
+110 -81
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,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) {
+3 -6
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);
});
});
+2 -1
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) {
+2 -3
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" });
},
+112 -53
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;
}
}
-57
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,
),
);
}
}
+6 -3
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();
+2 -2
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);
},