import { DESCRIBED_BY_ATTRIBUTE, HREF_ATTRIBUTE, OPEN_CLASS } from "#configuration/constants/element.constants"; import { MILLISECONDS, REFERENCE_CHAIN_MS, REFERENCE_CHAIN_PROPERTY, REFERENCE_CLASS, REFERENCE_HIDE_MS, REFERENCE_LINK_SELECTOR, REFERENCE_SHOW_MS, TOUCH_POINTER, } from "#configuration/constants/reference.constants"; import { REFERENCE_PANEL_LABEL, REFERENCE_ROLE } from "#configuration/strings/reference.strings"; import type { ReferenceContent, ReferenceTarget } from "#types/reference.types"; import { armLink, createChapterPanel, createRecordPanel, placePanel, } from "#presentation/components/reference.component"; import { clearChildren, createElement } from "#core/factories/element.factory"; import type { Disposer } from "#types/base.types"; import { ESCAPE_KEY } from "#configuration/constants/overlay.constants"; import { REFERENCE_PANEL_ID } from "#core/ids/reference.ids"; import { loadReference } from "#domain/loaders/reference.loader"; import { targetOfHref } from "#domain/converters/reference.converter"; const NONE = ""; interface Anchored { readonly anchor: HTMLAnchorElement; readonly href: string; readonly target: ReferenceTarget; } const linkOf = function linkOf(node: EventTarget | null): HTMLAnchorElement | null { return node instanceof Element ? node.closest(REFERENCE_LINK_SELECTOR) : null; }; const anchoredOf = function anchoredOf(node: EventTarget | null): Anchored | null { const anchor = linkOf(node); const href = anchor?.getAttribute(HREF_ATTRIBUTE) ?? NONE; const target = href.length === 0 ? null : targetOfHref(href); return anchor === null || target === null ? null : { anchor, href, target }; }; const isInside = function isInside(container: Node, node: EventTarget | null): boolean { return node instanceof Node && container.contains(node); }; const relatedOf = function relatedOf(event: Event): EventTarget | null { return event instanceof MouseEvent || event instanceof FocusEvent ? event.relatedTarget : null; }; const panelsOf = function panelsOf(content: ReferenceContent): readonly HTMLElement[] { return content.kind === "record" ? createRecordPanel(content.record) : createChapterPanel(content.chapter); }; export const mountReference = function mountReference(): Disposer { const panel = createElement("div", { attributes: { "aria-label": REFERENCE_PANEL_LABEL, id: REFERENCE_PANEL_ID, role: REFERENCE_ROLE }, className: REFERENCE_CLASS, }); panel.style.setProperty(REFERENCE_CHAIN_PROPERTY, String(REFERENCE_CHAIN_MS) + MILLISECONDS); document.body.append(panel); let current: Anchored | null = null; let shown: Anchored | null = null; let timer: ReturnType | null = null; let touched: HTMLAnchorElement | null = null; let disarm: Disposer | null = null; const cancel = function cancel(): void { if (timer !== null) { clearTimeout(timer); timer = null; } if (disarm !== null) { disarm(); disarm = null; } }; const settle = function settle(): void { cancel(); current = shown; }; const hide = function hide(): void { cancel(); shown?.anchor.removeAttribute(DESCRIBED_BY_ATTRIBUTE); current = null; shown = null; panel.classList.remove(OPEN_CLASS); clearChildren(panel); }; const reveal = async function reveal(anchored: Anchored, chained: boolean): Promise { const content = await loadReference(anchored.target, anchored.href); if (current !== anchored) { return; } if (content === null) { hide(); return; } cancel(); shown?.anchor.removeAttribute(DESCRIBED_BY_ATTRIBUTE); shown = anchored; anchored.anchor.setAttribute(DESCRIBED_BY_ATTRIBUTE, REFERENCE_PANEL_ID); clearChildren(panel); panel.append(...panelsOf(content)); panel.classList.add(OPEN_CLASS); if (!chained) { placePanel(panel, anchored.anchor); } }; const show = function show(anchored: Anchored): void { cancel(); if (current?.anchor === anchored.anchor) { return; } const chained = isInside(panel, anchored.anchor); current = anchored; if (chained) { disarm = armLink(anchored.anchor); } timer = setTimeout( () => { void reveal(anchored, chained); }, chained ? REFERENCE_CHAIN_MS : REFERENCE_SHOW_MS, ); }; const onEnter = function onEnter(event: Event): void { const anchored = anchoredOf(event.target); if (anchored !== null) { show(anchored); return; } if (isInside(panel, event.target)) { settle(); } }; const onLeave = function onLeave(event: Event): void { const related = relatedOf(event); if (current === null || isInside(current.anchor, related)) { return; } if (isInside(panel, related)) { settle(); return; } cancel(); timer = setTimeout(hide, REFERENCE_HIDE_MS); }; const onKey = function onKey(event: KeyboardEvent): void { if (event.key === ESCAPE_KEY) { hide(); } }; const onPointer = function onPointer(event: PointerEvent): void { if (event.pointerType !== TOUCH_POINTER) { return; } const anchored = anchoredOf(event.target); if (anchored === null) { touched = null; if (!isInside(panel, event.target)) { hide(); } return; } touched = current?.anchor === anchored.anchor ? null : anchored.anchor; show(anchored); }; const onClick = function onClick(event: MouseEvent): void { if (touched !== null && touched === linkOf(event.target)) { event.preventDefault(); event.stopImmediatePropagation(); touched = null; } }; document.addEventListener("mouseover", onEnter); document.addEventListener("mouseout", onLeave); document.addEventListener("focusin", onEnter); document.addEventListener("focusout", onLeave); document.addEventListener("keydown", onKey); document.addEventListener("pointerdown", onPointer); document.addEventListener("click", onClick, true); return () => { hide(); document.removeEventListener("mouseover", onEnter); document.removeEventListener("mouseout", onLeave); document.removeEventListener("focusin", onEnter); document.removeEventListener("focusout", onLeave); document.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onPointer); document.removeEventListener("click", onClick, true); panel.remove(); }; };