import { onMounted, onUnmounted, readonly, shallowRef, type Ref } from "vue"; import type { SectionId } from "@/content/types"; import { canonicalSectionIdFromHash, createNavigationController, validateSectionAnchor, type NavigationController, type NavigationState, } from "@/journey/navigation-controller"; const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true }; const SCROLL_KEYS = new Set([ "PageUp", "PageDown", "Home", "End", " ", "Spacebar", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", ]); export const isScrollIntentKey = (key: string): boolean => SCROLL_KEYS.has(key); const isEditableTarget = (target: EventTarget | null): boolean => target instanceof HTMLElement && (target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement); export interface KeyboardNavigation { state: Readonly>; onSectionLinkClick(event: MouseEvent): void; notePassiveSection(id: SectionId): void; } export function useKeyboardNavigation( canonicalIds: readonly SectionId[], ): KeyboardNavigation { const state = shallowRef({ epoch: 0, source: "initial", destinationId: null, }); let controller: NavigationController | undefined; let teardown = (): void => {}; const restoreCurrentFragment = ( source: "initial" | "history-restoration", ): void => { if (controller === undefined) return; const id = canonicalSectionIdFromHash(window.location.hash, canonicalIds); if (id === null) return; const target = document.getElementById(id); if (!(target instanceof HTMLElement)) return; if (source === "initial") controller.initialize(id, target); else controller.restore(id, target); }; const onSectionLinkClick = (event: MouseEvent): void => { if ( event.defaultPrevented || event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || controller === undefined || !(event.currentTarget instanceof HTMLAnchorElement) ) { return; } const validated = validateSectionAnchor(event.currentTarget, canonicalIds); if (validated === null) return; event.preventDefault(); controller.navigate(validated.id, validated.target); }; onMounted(() => { controller = createNavigationController(canonicalIds); const unsubscribe = controller.subscribe((nextState) => { state.value = nextState; }); const noteScrollIntent = (): void => { controller?.noteUserScrollIntent(); }; const handleKeydown = (event: KeyboardEvent): void => { if ( event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || isEditableTarget(event.target) || !isScrollIntentKey(event.key) ) { return; } noteScrollIntent(); }; const handlePopstate = (): void => restoreCurrentFragment("history-restoration"); const handlePageshow = (event: PageTransitionEvent): void => { if (event.persisted) restoreCurrentFragment("history-restoration"); }; window.addEventListener("wheel", noteScrollIntent, PASSIVE_LISTENER); window.addEventListener("touchmove", noteScrollIntent, PASSIVE_LISTENER); window.addEventListener("keydown", handleKeydown); window.addEventListener("popstate", handlePopstate); window.addEventListener("pageshow", handlePageshow); if (window.location.hash !== "") restoreCurrentFragment("initial"); teardown = (): void => { window.removeEventListener("wheel", noteScrollIntent, PASSIVE_LISTENER); window.removeEventListener( "touchmove", noteScrollIntent, PASSIVE_LISTENER, ); window.removeEventListener("keydown", handleKeydown); window.removeEventListener("popstate", handlePopstate); window.removeEventListener("pageshow", handlePageshow); unsubscribe(); controller?.dispose(); controller = undefined; }; }); onUnmounted(() => teardown()); return { state: readonly(state), onSectionLinkClick, notePassiveSection(id) { controller?.notePassiveSection(id); }, }; }