import type { MouseEvent as ReactMouseEvent } from "react"; import { hastPlainTextDeep } from "./markdownHast.logic"; const SANITIZED_FRAGMENT_PREFIX = "user-content-"; function decodeMarkdownFragmentId(href: string): string { const encodedId = href.slice(1); try { return decodeURIComponent(encodedId); } catch { return encodedId; } } function normalizeSanitizedFragmentId(id: string): string { let normalizedId = id; while (normalizedId.startsWith(SANITIZED_FRAGMENT_PREFIX)) { normalizedId = normalizedId.slice(SANITIZED_FRAGMENT_PREFIX.length); } return normalizedId; } function findMarkdownFragmentTarget(anchor: HTMLAnchorElement, href: string): HTMLElement | null { const decodedId = decodeMarkdownFragmentId(href); const normalizedId = normalizeSanitizedFragmentId(decodedId); const matchesFragment = (element: HTMLElement) => element.id === decodedId || normalizeSanitizedFragmentId(element.id) === normalizedId; const markdownRoot = anchor.closest(".chat-markdown"); if (markdownRoot) { const localTargets = Array.from(markdownRoot.querySelectorAll("[id]")); const localTarget = localTargets.find(matchesFragment); if (localTarget) return localTarget; } return ( document.getElementById(decodedId) ?? Array.from(document.querySelectorAll("[id]")).find(matchesFragment) ?? null ); } export function handleMarkdownFragmentClick( event: ReactMouseEvent, href: string, ) { if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) { return; } // Never let the browser follow the fragment or write it to the URL: desktop keeps // its route in the hash, so replacing the hash navigates away from the thread. event.preventDefault(); findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" }); } type HeadingHastNode = { type?: string; tagName?: string; properties?: Record; children?: HeadingHastNode[]; }; function githubHeadingSlug(text: string): string { return text .trim() .toLowerCase() .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "") .replace(/ /g, "-"); } /** * Like the sanitizer's ids, they carry the `user-content-` prefix so they cannot clobber app * element ids; fragment lookup strips it. */ export function rehypeHeadingIds() { return (tree: HeadingHastNode) => { const taken = new Set(); const collect = (node: HeadingHastNode) => { const id = node.properties?.id; if (typeof id === "string") taken.add(id); node.children?.forEach(collect); }; collect(tree); const nextSuffix = new Map(); const visit = (node: HeadingHastNode) => { if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { const slug = githubHeadingSlug(hastPlainTextDeep(node)); if (node.properties?.id === undefined && slug) { let count = nextSuffix.get(slug) ?? 0; let id = `${SANITIZED_FRAGMENT_PREFIX}${slug}`; while (taken.has(id)) { count += 1; id = `${SANITIZED_FRAGMENT_PREFIX}${slug}-${count}`; } nextSuffix.set(slug, count); taken.add(id); node.properties = { ...node.properties, id }; } return; } node.children?.forEach(visit); }; visit(tree); }; }