starlight-digital-garden
Version:
Digital garden for starlight
124 lines (107 loc) • 3.73 kB
text/typescript
import { computePosition, autoPlacement, offset } from "@floating-ui/dom";
const tooltip = document.querySelector("#linkpreview") as HTMLElement;
const noPreviewClass = "no-preview";
const elements = document.querySelectorAll(
".sl-markdown-content a"
) as NodeListOf<HTMLAnchorElement>;
// response may arrive after cursor left the link
let currentHref: string;
// it is anoying that preview shows up before user ends mouse movement
// if cursor stays long enough above the link - consider it as intentional
let showPreviewTimer: NodeJS.Timeout | undefined;
// if cursor moves out for a short period of time and comes back we should not hide preview
// if cursor moves out from link to preview window we should we should not hide preview
let hidePreviewTimer: NodeJS.Timeout | undefined;
function hideLinkPreview() {
clearTimeout(showPreviewTimer);
if (hidePreviewTimer !== undefined) return;
hidePreviewTimer = setTimeout(() => {
currentHref = "";
tooltip.style.display = "";
hidePreviewTimer = undefined;
}, 200);
}
function clearTimers() {
clearTimeout(showPreviewTimer);
clearTimeout(hidePreviewTimer);
hidePreviewTimer = undefined;
}
async function showLinkPreview(e: MouseEvent | FocusEvent) {
const start = `${window.location.protocol}//${window.location.host}`;
const target = e.target as HTMLElement;
const link = target?.closest("a");
const hrefRaw = (link?.href || "") as string | SVGAnimatedString;
let href = "";
let local = false;
let hash = "";
let svg = false;
if (typeof hrefRaw === "string") {
href = hrefRaw;
hash = new URL(href).hash;
local = href.startsWith(start);
} else {
href = hrefRaw.baseVal;
hash = new URL(href, window.location.origin).hash;
local = href.startsWith("/");
svg = true;
}
const hrefWithoutAnchor = href.replace(hash, "");
const locationWithoutAnchor = window.location.href.replace(
window.location.hash,
""
);
currentHref = href;
if (hrefWithoutAnchor === locationWithoutAnchor || !local) {
hideLinkPreview();
return;
}
// maybe use https://developer.mozilla.org/en-US/docs/Web/API/Element/matches ?
const noPreview =
link?.classList.contains(noPreviewClass) ||
!!target.closest(`.${noPreviewClass}`);
if (noPreview) {
hideLinkPreview();
return;
}
clearTimers();
const text = await fetch(href).then((x) => x.text());
if (currentHref !== href) return;
showPreviewTimer = setTimeout(() => {
if (currentHref !== href) return;
const doc = new DOMParser().parseFromString(text, "text/html");
const content = (doc.querySelector(".sl-markdown-content") as HTMLElement)
?.outerHTML;
tooltip.innerHTML = svg
? `${doc.querySelector("h1")?.outerHTML}${content}`
: content;
tooltip.style.display = "block";
let offsetTop = 0;
if (hash !== "") {
const heading = tooltip.querySelector(hash) as HTMLElement | null;
if (heading) offsetTop = heading.offsetTop;
}
tooltip.scroll({ top: offsetTop, behavior: "instant" });
computePosition(target, tooltip, {
middleware: [offset(10), autoPlacement()],
}).then(({ x, y }) => {
Object.assign(tooltip.style, {
left: `${x}px`,
top: `${y}px`,
});
});
}, 400);
}
// TODO: astro:page-load
tooltip.addEventListener("mouseenter", clearTimers);
tooltip.addEventListener("mouseleave", hideLinkPreview);
const events = [
["mouseenter", showLinkPreview],
["mouseleave", hideLinkPreview],
["focus", showLinkPreview],
["blur", hideLinkPreview],
] as const;
Array.from(elements).forEach((element) => {
events.forEach(([event, listener]) => {
element.addEventListener(event, listener);
});
});