342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
import type { SectionId } from "@/content/types";
|
|
import type { JourneySource } from "./types";
|
|
|
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
|
const TEST_BUILD =
|
|
typeof __PORTFOLIO_TEST_BUILD__ !== "undefined" && __PORTFOLIO_TEST_BUILD__;
|
|
|
|
export interface NavigationState {
|
|
epoch: number;
|
|
source: JourneySource;
|
|
destinationId: SectionId | null;
|
|
}
|
|
|
|
export interface NavigationRuntime {
|
|
getHash(): string;
|
|
pushHash(hash: string): void;
|
|
replaceHash(hash: string): void;
|
|
scrollTarget(target: HTMLElement, behavior: ScrollBehavior): void;
|
|
focusHeading(target: HTMLElement): void;
|
|
getScrollY(): number;
|
|
requestFrame(callback: FrameRequestCallback): number;
|
|
cancelFrame(id: number): void;
|
|
setTimer(callback: () => void, delay: number): number;
|
|
clearTimer(id: number): void;
|
|
supportsScrollEnd: boolean;
|
|
addScrollEndListener(listener: EventListener): void;
|
|
removeScrollEndListener(listener: EventListener): void;
|
|
}
|
|
|
|
export interface NavigationController {
|
|
navigate(id: SectionId, target: HTMLElement): number;
|
|
noteUserScrollIntent(): number;
|
|
notePassiveSection(id: SectionId): void;
|
|
initialize(id: SectionId, target: HTMLElement): number;
|
|
restore(id: SectionId, target: HTMLElement): number;
|
|
state(): NavigationState;
|
|
subscribe(listener: (state: NavigationState) => void): () => void;
|
|
dispose(): void;
|
|
}
|
|
|
|
const SETTLE_TIMEOUT_MS = 1500;
|
|
const PASSIVE_REPLACE_DELAY_MS = 150;
|
|
const STABLE_FRAME_COUNT = 3;
|
|
const STABLE_SCROLL_DELTA_PX = 1;
|
|
|
|
export function canonicalSectionIdFromHash(
|
|
hash: string,
|
|
canonicalIds: readonly SectionId[],
|
|
): SectionId | null {
|
|
const encoded = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
if (encoded === "") return canonicalIds[0] ?? null;
|
|
let decoded: string;
|
|
try {
|
|
decoded = decodeURIComponent(encoded);
|
|
} catch {
|
|
return null;
|
|
}
|
|
return canonicalIds.find((id) => id === decoded) ?? null;
|
|
}
|
|
|
|
export function validateSectionAnchor(
|
|
anchor: HTMLAnchorElement,
|
|
canonicalIds: readonly SectionId[],
|
|
): { id: SectionId; target: HTMLElement } | null {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(anchor.href, window.location.href);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (
|
|
url.origin !== window.location.origin ||
|
|
url.pathname !== window.location.pathname ||
|
|
url.search !== window.location.search
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const id = canonicalSectionIdFromHash(url.hash, canonicalIds);
|
|
if (id === null) return null;
|
|
const target = document.getElementById(id);
|
|
if (!(target instanceof HTMLElement) || target.id !== id) return null;
|
|
const heading = target.querySelector<HTMLElement>(
|
|
'h1[tabindex="-1"], h2[tabindex="-1"]',
|
|
);
|
|
return heading === null ? null : { id, target };
|
|
}
|
|
|
|
const browserRuntime = (): NavigationRuntime => ({
|
|
getHash: () => window.location.hash,
|
|
pushHash: (hash) => window.history.pushState(window.history.state, "", hash),
|
|
replaceHash: (hash) =>
|
|
window.history.replaceState(window.history.state, "", hash),
|
|
scrollTarget: (target, behavior) =>
|
|
target.scrollIntoView({ behavior, block: "start" }),
|
|
focusHeading: (target) => {
|
|
target
|
|
.querySelector<HTMLElement>('h1[tabindex="-1"], h2[tabindex="-1"]')
|
|
?.focus({ preventScroll: true });
|
|
},
|
|
getScrollY: () => window.scrollY,
|
|
requestFrame: (callback) => window.requestAnimationFrame(callback),
|
|
cancelFrame: (id) => window.cancelAnimationFrame(id),
|
|
setTimer: (callback, delay) => window.setTimeout(callback, delay),
|
|
clearTimer: (id) => window.clearTimeout(id),
|
|
supportsScrollEnd: "onscrollend" in window,
|
|
addScrollEndListener: (listener) =>
|
|
window.addEventListener("scrollend", listener),
|
|
removeScrollEndListener: (listener) =>
|
|
window.removeEventListener("scrollend", listener),
|
|
});
|
|
|
|
export function createNavigationController(
|
|
canonicalIds: readonly SectionId[],
|
|
runtime: NavigationRuntime = browserRuntime(),
|
|
): NavigationController {
|
|
let currentState: NavigationState = {
|
|
epoch: 0,
|
|
source: "initial",
|
|
destinationId: null,
|
|
};
|
|
const subscribers = new Set<(state: NavigationState) => void>();
|
|
let frameId: number | undefined;
|
|
let settleTimerId: number | undefined;
|
|
let passiveTimerId: number | undefined;
|
|
let scrollEndListener: EventListener | undefined;
|
|
|
|
const emit = (): void => {
|
|
for (const subscriber of subscribers) subscriber({ ...currentState });
|
|
};
|
|
|
|
const cancelSettle = (): void => {
|
|
if (frameId !== undefined) {
|
|
runtime.cancelFrame(frameId);
|
|
frameId = undefined;
|
|
}
|
|
if (settleTimerId !== undefined) {
|
|
runtime.clearTimer(settleTimerId);
|
|
settleTimerId = undefined;
|
|
}
|
|
if (scrollEndListener !== undefined) {
|
|
runtime.removeScrollEndListener(scrollEndListener);
|
|
scrollEndListener = undefined;
|
|
}
|
|
};
|
|
|
|
const cancelPassiveReplace = (): void => {
|
|
if (passiveTimerId !== undefined) {
|
|
runtime.clearTimer(passiveTimerId);
|
|
passiveTimerId = undefined;
|
|
}
|
|
};
|
|
|
|
const cancelPending = (): void => {
|
|
cancelSettle();
|
|
cancelPassiveReplace();
|
|
};
|
|
|
|
const beginEpoch = (
|
|
source: JourneySource,
|
|
destinationId: SectionId | null,
|
|
): number => {
|
|
cancelPending();
|
|
currentState = {
|
|
epoch: currentState.epoch + 1,
|
|
source,
|
|
destinationId,
|
|
};
|
|
if (TEST_BUILD)
|
|
window.__PORTFOLIO_PERFORMANCE__?.noteNavigationEpoch(currentState.epoch);
|
|
if (
|
|
TEST_BUILD &&
|
|
source === "explicit-navigation" &&
|
|
(destinationId === "toolkit" || destinationId === "contact")
|
|
) {
|
|
window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation(destinationId);
|
|
}
|
|
emit();
|
|
return currentState.epoch;
|
|
};
|
|
|
|
const requireTarget = (id: SectionId, target: HTMLElement): void => {
|
|
if (!canonicalIds.includes(id) || target.id !== id) {
|
|
throw new Error(`Invalid canonical navigation target: ${id}`);
|
|
}
|
|
};
|
|
|
|
const settleExplicit = (
|
|
epoch: number,
|
|
id: SectionId,
|
|
target: HTMLElement,
|
|
): void => {
|
|
if (
|
|
currentState.epoch !== epoch ||
|
|
currentState.source !== "explicit-navigation" ||
|
|
currentState.destinationId !== id
|
|
) {
|
|
return;
|
|
}
|
|
if (
|
|
TEST_BUILD &&
|
|
window.__PORTFOLIO_PERFORMANCE__?.holdNavigationSettlement(epoch)
|
|
)
|
|
return;
|
|
cancelSettle();
|
|
frameId = runtime.requestFrame(() => {
|
|
frameId = undefined;
|
|
if (
|
|
currentState.epoch === epoch &&
|
|
currentState.source === "explicit-navigation" &&
|
|
currentState.destinationId === id
|
|
) {
|
|
runtime.focusHeading(target);
|
|
}
|
|
});
|
|
};
|
|
|
|
const scheduleSettle = (
|
|
epoch: number,
|
|
id: SectionId,
|
|
target: HTMLElement,
|
|
): void => {
|
|
if (TEST_BUILD)
|
|
window.__PORTFOLIO_PERFORMANCE__?.registerNavigationSettlement(epoch);
|
|
settleTimerId = runtime.setTimer(() => {
|
|
settleTimerId = undefined;
|
|
if (
|
|
currentState.epoch !== epoch ||
|
|
currentState.source !== "explicit-navigation" ||
|
|
currentState.destinationId !== id
|
|
) {
|
|
return;
|
|
}
|
|
runtime.scrollTarget(target, "instant");
|
|
settleExplicit(epoch, id, target);
|
|
}, SETTLE_TIMEOUT_MS);
|
|
|
|
if (runtime.supportsScrollEnd) {
|
|
scrollEndListener = (): void => settleExplicit(epoch, id, target);
|
|
runtime.addScrollEndListener(scrollEndListener);
|
|
return;
|
|
}
|
|
|
|
let previousScrollY = runtime.getScrollY();
|
|
let stableFrames = 0;
|
|
const inspectFrame = (): void => {
|
|
frameId = undefined;
|
|
if (
|
|
currentState.epoch !== epoch ||
|
|
currentState.source !== "explicit-navigation" ||
|
|
currentState.destinationId !== id
|
|
) {
|
|
return;
|
|
}
|
|
const scrollY = runtime.getScrollY();
|
|
stableFrames =
|
|
Math.abs(scrollY - previousScrollY) < STABLE_SCROLL_DELTA_PX
|
|
? stableFrames + 1
|
|
: 0;
|
|
previousScrollY = scrollY;
|
|
if (stableFrames >= STABLE_FRAME_COUNT) {
|
|
settleExplicit(epoch, id, target);
|
|
} else {
|
|
frameId = runtime.requestFrame(inspectFrame);
|
|
}
|
|
};
|
|
frameId = runtime.requestFrame(inspectFrame);
|
|
};
|
|
|
|
const alignWithoutFocus = (
|
|
source: "initial" | "history-restoration",
|
|
id: SectionId,
|
|
target: HTMLElement,
|
|
): number => {
|
|
requireTarget(id, target);
|
|
const epoch = beginEpoch(source, id);
|
|
if (source === "initial") {
|
|
runtime.scrollTarget(target, "instant");
|
|
} else {
|
|
frameId = runtime.requestFrame(() => {
|
|
frameId = undefined;
|
|
if (
|
|
currentState.epoch === epoch &&
|
|
currentState.source === source &&
|
|
currentState.destinationId === id
|
|
) {
|
|
runtime.scrollTarget(target, "instant");
|
|
}
|
|
});
|
|
}
|
|
return epoch;
|
|
};
|
|
|
|
return {
|
|
navigate(id, target) {
|
|
requireTarget(id, target);
|
|
const epoch = beginEpoch("explicit-navigation", id);
|
|
if (canonicalSectionIdFromHash(runtime.getHash(), canonicalIds) !== id) {
|
|
runtime.pushHash(`#${id}`);
|
|
}
|
|
scheduleSettle(epoch, id, target);
|
|
runtime.scrollTarget(target, "smooth");
|
|
return epoch;
|
|
},
|
|
noteUserScrollIntent() {
|
|
return beginEpoch("passive-scroll", null);
|
|
},
|
|
notePassiveSection(id) {
|
|
if (currentState.source !== "passive-scroll") return;
|
|
cancelPassiveReplace();
|
|
const epoch = currentState.epoch;
|
|
passiveTimerId = runtime.setTimer(() => {
|
|
passiveTimerId = undefined;
|
|
if (
|
|
currentState.epoch === epoch &&
|
|
currentState.source === "passive-scroll" &&
|
|
canonicalSectionIdFromHash(runtime.getHash(), canonicalIds) !== id
|
|
) {
|
|
runtime.replaceHash(`#${id}`);
|
|
}
|
|
}, PASSIVE_REPLACE_DELAY_MS);
|
|
},
|
|
initialize(id, target) {
|
|
return alignWithoutFocus("initial", id, target);
|
|
},
|
|
restore(id, target) {
|
|
return alignWithoutFocus("history-restoration", id, target);
|
|
},
|
|
state: () => ({ ...currentState }),
|
|
subscribe(listener) {
|
|
subscribers.add(listener);
|
|
listener({ ...currentState });
|
|
return () => subscribers.delete(listener);
|
|
},
|
|
dispose() {
|
|
cancelPending();
|
|
currentState = { ...currentState, epoch: currentState.epoch + 1 };
|
|
subscribers.clear();
|
|
},
|
|
};
|
|
}
|