143 lines
4.2 KiB
TypeScript
143 lines
4.2 KiB
TypeScript
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<Ref<NavigationState>>;
|
|
onSectionLinkClick(event: MouseEvent): void;
|
|
notePassiveSection(id: SectionId): void;
|
|
}
|
|
|
|
export function useKeyboardNavigation(
|
|
canonicalIds: readonly SectionId[],
|
|
): KeyboardNavigation {
|
|
const state = shallowRef<NavigationState>({
|
|
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);
|
|
},
|
|
};
|
|
}
|