1
0
silverbullet/web/navigator.ts
2024-01-25 16:48:17 +01:00

148 lines
4.0 KiB
TypeScript

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";
export type PageState = PageRef & {
scrollTop?: number;
selection?: {
anchor: number;
head?: number;
};
};
export class PathPageNavigator {
navigationResolve?: () => void;
indexPage: string;
openPages = new Map<string, PageState>();
constructor(
private client: Client,
) {
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(
pageRef: PageRef,
replaceState = false,
) {
if (pageRef.page === this.indexPage) {
pageRef.page = "";
}
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) {
window.history.replaceState(
cleanState,
"",
`/${currentState.page}`,
);
window.history.pushState(
pageRef,
"",
`/${pageRef.page}`,
);
} else {
window.history.replaceState(
pageRef,
"",
`/${pageRef.page}`,
);
}
globalThis.dispatchEvent(
new PopStateEvent("popstate", {
state: pageRef,
}),
);
await new Promise<void>((resolve) => {
this.navigationResolve = resolve;
});
this.navigationResolve = undefined;
}
buildCurrentPageState(): PageState {
const pageState: PageState = parsePageRefFromURI();
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: (
pageState: PageState,
) => Promise<void>,
): void {
const cb = (event: PopStateEvent) => {
safeRun(async () => {
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) {
popState.selection = openPage.selection;
popState.scrollTop = openPage.scrollTop;
}
}
await pageLoadCallback(popState);
} else {
// This occurs when the page is loaded completely fresh with no browser history around it
const pageRef = parsePageRefFromURI();
if (!pageRef.page) {
pageRef.page = this.indexPage;
}
await pageLoadCallback(pageRef);
}
if (this.navigationResolve) {
this.navigationResolve();
}
});
};
globalThis.addEventListener("popstate", cb);
cb(
new PopStateEvent("popstate", {
state: this.buildCurrentPageState(),
}),
);
}
}
export function parsePageRefFromURI(): PageRef {
const pageRef = parsePageRef(decodeURI(
location.pathname.substring(1),
));
if (location.hash) {
pageRef.header = decodeURI(location.hash.substring(1));
}
return pageRef;
}