feat: coordinate sections signs and native navigation
This commit is contained in:
@@ -1,43 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionId } from '@/content/types';
|
||||
import { watch } from "vue";
|
||||
import type { SectionId } from "@/content/types";
|
||||
import { useJourneySections } from "@/composables/useJourneySections";
|
||||
import { useKeyboardNavigation } from "@/composables/useKeyboardNavigation";
|
||||
|
||||
defineProps<{ items: ReadonlyArray<{ id: SectionId; label: string }> }>();
|
||||
const props = defineProps<{
|
||||
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
||||
}>();
|
||||
const canonicalIds = props.items.map((item) => item.id);
|
||||
const navigation = useKeyboardNavigation(canonicalIds);
|
||||
const journey = useJourneySections(canonicalIds, navigation.state);
|
||||
|
||||
const focusActivatedSection = (event: MouseEvent): void => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey ||
|
||||
!(event.currentTarget instanceof HTMLAnchorElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let sectionId: string;
|
||||
try {
|
||||
sectionId = decodeURIComponent(event.currentTarget.hash.slice(1));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const section = document.getElementById(sectionId);
|
||||
const heading = section?.querySelector<HTMLElement>(
|
||||
':scope > h2[tabindex="-1"]',
|
||||
);
|
||||
if (heading === undefined || heading === null) return;
|
||||
|
||||
requestAnimationFrame(() => heading.focus({ preventScroll: true }));
|
||||
};
|
||||
watch(
|
||||
() => journey.value.activeSectionId,
|
||||
(id) => navigation.notePassiveSection(id),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="site-navigation" aria-label="Portfolio sections">
|
||||
<ul>
|
||||
<li v-for="item in items" :key="item.id">
|
||||
<a :href="`#${item.id}`" @click="focusActivatedSection">{{ item.label }}</a>
|
||||
<a
|
||||
:href="`#${item.id}`"
|
||||
:aria-current="
|
||||
journey.activeSectionId === item.id ? 'page' : undefined
|
||||
"
|
||||
@click="navigation.onSectionLinkClick"
|
||||
>{{ item.label }}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
243
src/composables/useJourneySections.ts
Normal file
243
src/composables/useJourneySections.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
readonly,
|
||||
shallowRef,
|
||||
watch,
|
||||
type Ref,
|
||||
} from "vue";
|
||||
import type { SectionId } from "@/content/types";
|
||||
import type { NavigationState } from "@/journey/navigation-controller";
|
||||
import {
|
||||
activationLineDocumentY,
|
||||
deriveSectionState,
|
||||
isAtDocumentEnd,
|
||||
selectActiveSection,
|
||||
type SectionHeadingGeometry,
|
||||
} from "@/journey/section-controller";
|
||||
import { nextSign } from "@/journey/sign-controller";
|
||||
import type { JourneySnapshot } from "@/journey/types";
|
||||
import { useScrollDrive } from "./useScrollDrive";
|
||||
|
||||
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
|
||||
|
||||
const initialJourney = (activeSectionId: SectionId): JourneySnapshot => ({
|
||||
scrollY: 0,
|
||||
routeProgress: 0,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: 0.18,
|
||||
...deriveSectionState(activeSectionId),
|
||||
sign: null,
|
||||
});
|
||||
|
||||
export function useJourneySections(
|
||||
canonicalIds: readonly SectionId[],
|
||||
navigationState: Readonly<Ref<NavigationState>>,
|
||||
motionAllowed?: Readonly<Ref<boolean>>,
|
||||
): Readonly<Ref<JourneySnapshot>> {
|
||||
const firstId = canonicalIds[0];
|
||||
if (firstId === undefined) {
|
||||
throw new Error("At least one canonical journey section is required.");
|
||||
}
|
||||
const drive = useScrollDrive();
|
||||
const snapshot = shallowRef<JourneySnapshot>(initialJourney(firstId));
|
||||
let scheduleMeasurement = (): void => {};
|
||||
let teardown = (): void => {};
|
||||
|
||||
const stopNavigationWatch = watch(
|
||||
() => navigationState.value,
|
||||
() => scheduleMeasurement(),
|
||||
);
|
||||
const stopMotionWatch =
|
||||
motionAllowed === undefined
|
||||
? (): void => {}
|
||||
: watch(motionAllowed, () => scheduleMeasurement());
|
||||
|
||||
onMounted(() => {
|
||||
const visualViewport = window.visualViewport;
|
||||
let frameId: number | undefined;
|
||||
let disposed = false;
|
||||
let previousScrollY = window.scrollY;
|
||||
let previousActiveId = firstId;
|
||||
let headings: readonly SectionHeadingGeometry[] = [];
|
||||
|
||||
const readHeadings = (): readonly SectionHeadingGeometry[] =>
|
||||
canonicalIds.flatMap((id) => {
|
||||
const heading = document.getElementById(`${id}-heading`);
|
||||
return heading instanceof HTMLElement
|
||||
? [{ id, top: heading.getBoundingClientRect().top + window.scrollY }]
|
||||
: [];
|
||||
});
|
||||
|
||||
const measure = (): void => {
|
||||
frameId = undefined;
|
||||
headings = readHeadings();
|
||||
if (headings.length === 0) return;
|
||||
|
||||
const scrollingElement =
|
||||
document.scrollingElement ?? document.documentElement;
|
||||
const viewportHeight =
|
||||
visualViewport?.height ?? document.documentElement.clientHeight;
|
||||
const viewportOffsetTop = visualViewport?.offsetTop ?? 0;
|
||||
const scrollY = scrollingElement.scrollTop;
|
||||
const activationY = activationLineDocumentY({
|
||||
scrollY,
|
||||
visualViewportOffsetTop: viewportOffsetTop,
|
||||
visualViewportHeight: viewportHeight,
|
||||
});
|
||||
const atDocumentEnd = isAtDocumentEnd({
|
||||
scrollY,
|
||||
scrollHeight: scrollingElement.scrollHeight,
|
||||
visualViewportHeight: viewportHeight,
|
||||
});
|
||||
const activeSectionId = selectActiveSection({
|
||||
headings,
|
||||
activationY,
|
||||
atDocumentEnd,
|
||||
});
|
||||
const previousActiveIndex = canonicalIds.indexOf(previousActiveId);
|
||||
const activeIndex = canonicalIds.indexOf(activeSectionId);
|
||||
const crossedBoundaries = Math.abs(activeIndex - previousActiveIndex);
|
||||
const deltaY = scrollY - previousScrollY;
|
||||
const currentSign = snapshot.value.sign;
|
||||
const signTargetId =
|
||||
currentSign?.targetId ?? canonicalIds[activeIndex + 1] ?? null;
|
||||
const signTarget = headings.find(
|
||||
(heading) => heading.id === signTargetId,
|
||||
);
|
||||
const sign =
|
||||
signTargetId === null || signTarget === undefined || viewportHeight <= 0
|
||||
? null
|
||||
: nextSign(currentSign, {
|
||||
source: navigationState.value.source,
|
||||
deltaY,
|
||||
viewportHeight,
|
||||
crossedBoundaries,
|
||||
candidateId: signTargetId,
|
||||
distanceVh: (signTarget.top - activationY) / viewportHeight,
|
||||
motionAllowed: motionAllowed?.value ?? true,
|
||||
});
|
||||
const sectionState = deriveSectionState(activeSectionId);
|
||||
snapshot.value = {
|
||||
...drive.value,
|
||||
routeProgress: atDocumentEnd ? 1 : drive.value.routeProgress,
|
||||
...sectionState,
|
||||
sign,
|
||||
};
|
||||
previousScrollY = scrollY;
|
||||
previousActiveId = activeSectionId;
|
||||
};
|
||||
|
||||
scheduleMeasurement = (): void => {
|
||||
if (
|
||||
!disposed &&
|
||||
frameId === undefined &&
|
||||
document.visibilityState !== "hidden"
|
||||
) {
|
||||
frameId = window.requestAnimationFrame(measure);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleMeasurement);
|
||||
resizeObserver.observe(document.documentElement);
|
||||
for (const id of canonicalIds) {
|
||||
const section = document.getElementById(id);
|
||||
const heading = document.getElementById(`${id}-heading`);
|
||||
if (section !== null) resizeObserver.observe(section);
|
||||
if (heading !== null) resizeObserver.observe(heading);
|
||||
}
|
||||
for (const media of document.querySelectorAll("img, video")) {
|
||||
resizeObserver.observe(media);
|
||||
}
|
||||
|
||||
const handleVisibility = (): void => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
if (frameId !== undefined) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
frameId = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
previousScrollY = window.scrollY;
|
||||
scheduleMeasurement();
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", scheduleMeasurement, PASSIVE_LISTENER);
|
||||
window.addEventListener("resize", scheduleMeasurement, PASSIVE_LISTENER);
|
||||
window.addEventListener(
|
||||
"orientationchange",
|
||||
scheduleMeasurement,
|
||||
PASSIVE_LISTENER,
|
||||
);
|
||||
visualViewport?.addEventListener(
|
||||
"resize",
|
||||
scheduleMeasurement,
|
||||
PASSIVE_LISTENER,
|
||||
);
|
||||
visualViewport?.addEventListener(
|
||||
"scroll",
|
||||
scheduleMeasurement,
|
||||
PASSIVE_LISTENER,
|
||||
);
|
||||
document.addEventListener("load", scheduleMeasurement, true);
|
||||
document.addEventListener("loadedmetadata", scheduleMeasurement, true);
|
||||
document.addEventListener(
|
||||
"visibilitychange",
|
||||
handleVisibility,
|
||||
PASSIVE_LISTENER,
|
||||
);
|
||||
|
||||
scheduleMeasurement();
|
||||
void document.fonts.ready.then(() => scheduleMeasurement());
|
||||
|
||||
teardown = (): void => {
|
||||
disposed = true;
|
||||
window.removeEventListener(
|
||||
"scroll",
|
||||
scheduleMeasurement,
|
||||
PASSIVE_LISTENER,
|
||||
);
|
||||
window.removeEventListener(
|
||||
"resize",
|
||||
scheduleMeasurement,
|
||||
PASSIVE_LISTENER,
|
||||
);
|
||||
window.removeEventListener(
|
||||
"orientationchange",
|
||||
scheduleMeasurement,
|
||||
PASSIVE_LISTENER,
|
||||
);
|
||||
visualViewport?.removeEventListener(
|
||||
"resize",
|
||||
scheduleMeasurement,
|
||||
PASSIVE_LISTENER,
|
||||
);
|
||||
visualViewport?.removeEventListener(
|
||||
"scroll",
|
||||
scheduleMeasurement,
|
||||
PASSIVE_LISTENER,
|
||||
);
|
||||
document.removeEventListener("load", scheduleMeasurement, true);
|
||||
document.removeEventListener("loadedmetadata", scheduleMeasurement, true);
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
handleVisibility,
|
||||
PASSIVE_LISTENER,
|
||||
);
|
||||
resizeObserver.disconnect();
|
||||
if (frameId !== undefined) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
frameId = undefined;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopNavigationWatch();
|
||||
stopMotionWatch();
|
||||
teardown();
|
||||
});
|
||||
|
||||
return readonly(snapshot);
|
||||
}
|
||||
142
src/composables/useKeyboardNavigation.ts
Normal file
142
src/composables/useKeyboardNavigation.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
321
src/journey/navigation-controller.ts
Normal file
321
src/journey/navigation-controller.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import type { SectionId } from "@/content/types";
|
||||
import type { JourneySource } from "./types";
|
||||
|
||||
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>(
|
||||
':scope > 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>(':scope > 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,
|
||||
};
|
||||
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;
|
||||
}
|
||||
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 => {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
78
src/journey/section-controller.ts
Normal file
78
src/journey/section-controller.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { SectionId } from "@/content/types";
|
||||
import type { EnvironmentChapter } from "./types";
|
||||
|
||||
export interface SectionHeadingGeometry {
|
||||
id: SectionId;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export interface ActiveSectionInput {
|
||||
headings: readonly SectionHeadingGeometry[];
|
||||
activationY: number;
|
||||
atDocumentEnd: boolean;
|
||||
}
|
||||
|
||||
export interface DerivedSectionState {
|
||||
activeSectionId: SectionId;
|
||||
chapter: EnvironmentChapter;
|
||||
arrived: boolean;
|
||||
}
|
||||
|
||||
export interface ActivationLineInput {
|
||||
scrollY: number;
|
||||
visualViewportOffsetTop: number;
|
||||
visualViewportHeight: number;
|
||||
}
|
||||
|
||||
export const activationLineDocumentY = (input: ActivationLineInput): number =>
|
||||
input.scrollY +
|
||||
input.visualViewportOffsetTop +
|
||||
input.visualViewportHeight * 0.35;
|
||||
|
||||
export interface DocumentEndInput {
|
||||
scrollY: number;
|
||||
scrollHeight: number;
|
||||
visualViewportHeight: number;
|
||||
}
|
||||
|
||||
export const isAtDocumentEnd = (input: DocumentEndInput): boolean =>
|
||||
Math.max(0, input.scrollHeight - input.visualViewportHeight) -
|
||||
input.scrollY <=
|
||||
2;
|
||||
|
||||
export function selectActiveSection(input: ActiveSectionInput): SectionId {
|
||||
const first = input.headings[0];
|
||||
if (first === undefined) {
|
||||
throw new Error("At least one canonical section heading is required.");
|
||||
}
|
||||
if (input.atDocumentEnd) {
|
||||
return input.headings[input.headings.length - 1]?.id ?? first.id;
|
||||
}
|
||||
|
||||
let active = first.id;
|
||||
for (const heading of input.headings) {
|
||||
if (heading.top <= input.activationY) active = heading.id;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
const chapters: Readonly<Record<SectionId, EnvironmentChapter>> = {
|
||||
start: "deep-night",
|
||||
profile: "deep-night",
|
||||
experience: "systems",
|
||||
infrastructure: "infrastructure",
|
||||
"selected-builds": "builds",
|
||||
"additional-work": "builds",
|
||||
toolkit: "pre-dawn",
|
||||
contact: "destination",
|
||||
};
|
||||
|
||||
export function deriveSectionState(
|
||||
activeSectionId: SectionId,
|
||||
): DerivedSectionState {
|
||||
return {
|
||||
activeSectionId,
|
||||
chapter: chapters[activeSectionId],
|
||||
arrived: activeSectionId === "contact",
|
||||
};
|
||||
}
|
||||
53
src/journey/sign-controller.ts
Normal file
53
src/journey/sign-controller.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { SectionId } from "@/content/types";
|
||||
import type { JourneySource, SignSnapshot } from "./types";
|
||||
|
||||
export interface SignSample {
|
||||
source: JourneySource;
|
||||
deltaY: number;
|
||||
viewportHeight: number;
|
||||
crossedBoundaries: number;
|
||||
candidateId: SectionId | null;
|
||||
distanceVh: number;
|
||||
motionAllowed: boolean;
|
||||
}
|
||||
|
||||
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
||||
|
||||
export function nextSign(
|
||||
current: SignSnapshot | null,
|
||||
input: SignSample,
|
||||
): SignSnapshot | null {
|
||||
const eligible =
|
||||
input.motionAllowed &&
|
||||
input.source === "passive-scroll" &&
|
||||
Number.isFinite(input.deltaY) &&
|
||||
Number.isFinite(input.viewportHeight) &&
|
||||
Number.isFinite(input.distanceVh) &&
|
||||
input.viewportHeight > 0 &&
|
||||
input.deltaY > 0 &&
|
||||
input.deltaY <= input.viewportHeight * 0.75 &&
|
||||
input.crossedBoundaries <= 1;
|
||||
|
||||
if (!eligible || input.distanceVh > 1.2 || input.distanceVh <= -0.25) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetId = current?.targetId ?? input.candidateId;
|
||||
if (targetId === null) return null;
|
||||
|
||||
if (input.distanceVh > 0.55) {
|
||||
return {
|
||||
targetId,
|
||||
phase: "approach",
|
||||
progress: clamp01((1.2 - input.distanceVh) / 0.65),
|
||||
};
|
||||
}
|
||||
if (input.distanceVh > 0.15) {
|
||||
return { targetId, phase: "stabilized", progress: 1 };
|
||||
}
|
||||
return {
|
||||
targetId,
|
||||
phase: "pass",
|
||||
progress: clamp01((0.15 - input.distanceVh) / 0.4),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SectionId } from "@/content/types";
|
||||
|
||||
export interface ScrollDriveSample {
|
||||
scrollY: number;
|
||||
now: number;
|
||||
@@ -16,3 +18,27 @@ export interface ScrollDriveController {
|
||||
sample(input: ScrollDriveSample): ScrollDriveSnapshot;
|
||||
reset(input?: ScrollDriveSample): ScrollDriveSnapshot;
|
||||
}
|
||||
|
||||
export type JourneySource =
|
||||
"initial" | "explicit-navigation" | "passive-scroll" | "history-restoration";
|
||||
|
||||
export type EnvironmentChapter =
|
||||
| "deep-night"
|
||||
| "systems"
|
||||
| "infrastructure"
|
||||
| "builds"
|
||||
| "pre-dawn"
|
||||
| "destination";
|
||||
|
||||
export interface SignSnapshot {
|
||||
targetId: SectionId;
|
||||
phase: "approach" | "stabilized" | "pass";
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface JourneySnapshot extends ScrollDriveSnapshot {
|
||||
activeSectionId: SectionId;
|
||||
chapter: EnvironmentChapter;
|
||||
sign: SignSnapshot | null;
|
||||
arrived: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user