diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 89a542a..75051a3 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -3,6 +3,8 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { buildChromePalette, type ThemeSample, type ChromePalette } from "@/lib/docs/theme"; import CommentMarkdown from "@/lib/docs/comments/CommentMarkdown"; +import type { Section } from "@/lib/docs/sections"; +import { fragmentFor, parseHash } from "@/lib/docs/deeplink"; // CommentsShell — the THIRD React surface (birthday.md "Production // architecture", "CHOSEN: variant B"). The google-docs-style comment rail. The @@ -81,6 +83,9 @@ type Props = { initialThreads: Thread[]; initialDocReactions: Reaction[]; initialAnchoredReactions: AnchoredReactionGroup[]; + // Ordered heading list + stable fragment ids for section deeplinks (from + // lib/docs/sections extractSections). Forwarded to the overlay as jh:sections. + initialSections: Section[]; version: number; // Coarse SSR theme (from the stored HTML's unconditional html/body bg). Present // only when the server is confident the doc is dark — gives the shell a dark @@ -109,8 +114,27 @@ function fmtTime(iso: string): string { } } +// Clipboard fallback for when navigator.clipboard is unavailable or rejects (e.g. +// a copy triggered via postMessage from the sandboxed iframe lacks transient +// activation). Returns whether the copy succeeded. +function legacyCopy(text: string): boolean { + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.top = "-9999px"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; + } catch { + return false; + } +} + export default function CommentsShell(props: Props) { - const { slug, title, rawSrc, viewtoken, canComment, canReact, signedIn, me } = props; + const { slug, title, rawSrc, viewtoken, canComment, canReact, signedIn, me, initialSections } = props; const iframeRef = useRef(null); const stageRef = useRef(null); const [threads, setThreads] = useState(props.initialThreads); @@ -123,6 +147,9 @@ export default function CommentsShell(props: Props) { // Latest reactAnchored, refd so the message-listener effect (declared earlier) // can call it without depending on its declaration order or re-subscribing. const reactAnchoredRef = useRef<(emoji: string, anchor: NonNullable) => void>(() => {}); + // Latest focusComment, refd so the deeplink hash router (an effect that must not + // re-subscribe every time threads change) always calls the current version. + const focusCommentRef = useRef<(id: number) => void>(() => {}); const [showResolved, setShowResolved] = useState(false); const [pinnedId, setPinnedId] = useState(null); const [activeId, setActiveId] = useState(null); @@ -133,6 +160,22 @@ export default function CommentsShell(props: Props) { const [docScrollY, setDocScrollY] = useState(0); const [docHeight, setDocHeight] = useState(0); const [overlayReady, setOverlayReady] = useState(false); + // Bumped on every jh:ready (incl. iframe reload / bfcache restore, where + // overlayReady stays true); the hash router depends on it so a deeplink is + // re-applied after a reload, not just on the first ready. + const [overlayReadyNonce, setOverlayReadyNonce] = useState(0); + const [toast, setToast] = useState(null); + // Rail scroll mode. null → follow-doc (desktop: scrollTop tracks docScrollY; + // mobile: stack from the top). A number → "show this card": the rail scrolls to + // it and the follow sync yields — for comment permalinks to doc-level/orphaned + // threads (no in-doc highlight to track) and for every permalink on mobile (the + // drawer stacks cards, so it can't track a highlight Y). Set on permalink + // navigation; cleared back to follow when the user scrolls the doc or navigates + // elsewhere, so the sync is never left disabled. cardBaselineRef records the + // doc scroll at entry (docScrollYRef mirrors docScrollY) to detect that scroll. + const [railCardId, setRailCardId] = useState(null); + const docScrollYRef = useRef(0); + const cardBaselineRef = useRef(0); // Adaptive chrome (variant D). The server may hand us a coarse dark theme for // the initial paint (no flash); the overlay's jh:theme then refines/confirms it. @@ -217,6 +260,38 @@ export default function CommentsShell(props: Props) { iframeRef.current?.contentWindow?.postMessage(msg, "*"); }, []); + // Transient "copied" toast for deeplinks. One timer, reset on each show. + const toastTimer = useRef(null); + const showToast = useCallback((msg: string) => { + setToast(msg); + if (toastTimer.current) window.clearTimeout(toastTimer.current); + toastTimer.current = window.setTimeout(() => setToast(null), 1800); + }, []); + + // Copy a deeplink for a comment or section target to the clipboard and confirm + // with a toast. The shell is a secure same-origin context, + // so the Clipboard API works for a direct click (comment icon); a section-icon + // click arrives via postMessage from the sandboxed iframe and may reject for lack + // of transient activation, so fall back to a hidden-textarea execCommand copy. + const copyLink = useCallback( + async (target: { kind: "comment"; id: number } | { kind: "section"; id: string }) => { + // Carry the ?viewtoken= through (before the #fragment) so a deeplink copied + // from a private doc opened via a capability link still authorizes the + // recipient — without it canViewSession denies and they get a 404. fragmentFor + // encodes the id so it round-trips through the hash router's parseHash. + const url = `${window.location.origin}/d/${encodeURIComponent(slug)}${tokenQuery}#${fragmentFor(target)}`; + let ok = false; + try { + await navigator.clipboard.writeText(url); + ok = true; + } catch { + ok = legacyCopy(url); + } + showToast(ok ? "Copied link to clipboard" : "Copy failed — press ⌘/Ctrl-C"); + }, + [slug, tokenQuery, showToast] + ); + // Compute gravatar URLs (sha256 of the lowercased email) for every reactor + // commenter, so the overlay's chip popover can show avatars. Async (SubtleCrypto) // but cheap; the result is cached in state and re-sent with jh:reactions. @@ -260,6 +335,12 @@ export default function CommentsShell(props: Props) { postToOverlay({ type: "jh:reactions", groups: paintReactionGroups, me, avatars }); }, [overlayReady, paintReactionGroups, me, avatars, postToOverlay]); + // Send sections likewise — on change or when the overlay (re)becomes ready — so + // heading ids + gutter icons don't go stale if initialSections changes in place. + useEffect(() => { + if (overlayReady) postToOverlay({ type: "jh:sections", sections: initialSections }); + }, [overlayReady, initialSections, postToOverlay]); + // Readiness handshake, made robust. The overlay's jh:ready is one-shot and can be // missed if the iframe loads before this shell's message listener mounts (fast/cached // loads) — leaving overlayReady false, which silently suppresses jh:themeMode, anchors @@ -286,6 +367,8 @@ export default function CommentsShell(props: Props) { switch (d.type) { case "jh:ready": setOverlayReady(true); + setOverlayReadyNonce((n) => n + 1); // re-fire the hash router on reloads + // Send the forced theme FIRST, so the overlay applies it before it paints // (no authored-doc flash), and on EVERY ready — including an iframe reload, // where overlayReady stays true so the mode effect below won't re-fire and @@ -293,10 +376,18 @@ export default function CommentsShell(props: Props) { postToOverlay({ type: "jh:themeMode", mode: modeRef.current }); postToOverlay({ type: "jh:anchors", anchors: paintAnchors }); postToOverlay({ type: "jh:reactions", groups: paintReactionGroups, me, avatars }); + // Re-send sections on every ready (incl. iframe reload) so the overlay + // re-assigns heading ids + gutter icons; the effect below also posts them + // when they change while mounted. Sent before any hash-driven scroll so + // the target heading has its id by the time we ask the overlay to scroll. + postToOverlay({ type: "jh:sections", sections: initialSections }); break; case "jh:positions": setPositions(d.positions || {}); - if (typeof d.scrollY === "number") setDocScrollY(d.scrollY); + if (typeof d.scrollY === "number") { + docScrollYRef.current = d.scrollY; + setDocScrollY(d.scrollY); // "show card" mode is released by the effect below + } if (typeof d.docHeight === "number") setDocHeight(d.docHeight); break; case "jh:theme": @@ -357,11 +448,17 @@ export default function CommentsShell(props: Props) { // A chip was clicked inside the iframe → optimistic toggle (add/remove). if (canReact && d.anchor && d.anchor.exact) reactAnchoredRef.current(d.emoji, d.anchor); break; + case "jh:copyLink": + // The section link icon (inside the iframe) was clicked. Clipboard is + // unreliable from the opaque-origin sandbox, so the copy happens here on + // the real origin. + if (typeof d.id === "string") void copyLink({ kind: "section", id: d.id }); + break; } } window.addEventListener("message", onMsg); return () => window.removeEventListener("message", onMsg); - }, [paintAnchors, paintReactionGroups, me, avatars, postToOverlay, canComment, canReact]); + }, [paintAnchors, paintReactionGroups, me, avatars, postToOverlay, canComment, canReact, initialSections, copyLink]); const reload = useCallback(async () => { const r = await fetch(`${apiBase}/comments${tokenQuery}`, { credentials: "same-origin" }); @@ -504,6 +601,81 @@ export default function CommentsShell(props: Props) { if (overlayReady) postToOverlay({ type: "jh:themeMode", mode }); }, [mode, overlayReady, postToOverlay]); + // Clear any pinned/focused comment across the rail, the overlay, and the rail + // scroll mode — so a section link and a comment pin stay mutually exclusive + // selections (navigating to one deselects the other). + const clearCommentFocus = useCallback(() => { + setPinnedId(null); + setActiveId(null); + setRailCardId(null); + postToOverlay({ type: "jh:focus", key: null }); + }, [postToOverlay]); + + // Comment permalinks (#comment-): open + pin the thread and bring it into + // view. A reply id resolves to its root thread; a resolved thread is revealed. + // Anchored threads scroll the doc via the overlay (jh:focus → the highlight) and + // the rail follows on desktop; doc-level/orphaned threads — and everything on + // mobile, where the drawer stacks cards — enter "show card" mode instead. + const focusComment = useCallback( + (id: number) => { + const target = + threads.find((t) => t.id === id) ?? threads.find((t) => t.replies.some((r) => r.id === id)); + if (!target) { + // Unknown / deleted: the URL points at a comment that isn't here, so leave + // nothing selected (don't strand the previous pin) and say so. + clearCommentFocus(); + showToast("That comment couldn't be found."); + return; + } + if (target.resolved) setShowResolved(true); + setRailOpen(true); + setPinnedId(target.id); + setActiveId(target.id); + if (target.group === "anchored") postToOverlay({ type: "jh:focus", key: `c:${target.id}` }); + if (isMobileRef.current || target.group !== "anchored") { + cardBaselineRef.current = docScrollYRef.current; // exit "show card" when the doc scrolls off this + setRailCardId(target.id); + } else { + setRailCardId(null); // desktop anchored → follow the doc to its highlight + } + }, + [threads, postToOverlay, clearCommentFocus, showToast] + ); + focusCommentRef.current = focusComment; + + // Deeplinks: route the URL fragment (via the shared parseHash codec) to a single + // exclusive selection. A comment pins + focuses it; a section clears any comment + // focus then scrolls to the heading — or soft-fails with a toast if that id isn't + // in the doc; an empty hash clears focus, but only on an actual navigation + // (hashchange) — a re-apply on load/reload must not wipe a pin the user set by + // hand. Client-only, never touches auth (private docs still gate on session/token). + useEffect(() => { + if (!overlayReady) return; + const applyHash = (isNav: boolean) => { + const target = parseHash(window.location.hash); + if (target.kind === "none") { + if (isNav) clearCommentFocus(); + return; + } + if (target.kind === "comment") { + focusCommentRef.current(target.id); // handles unknown/deleted itself + return; + } + clearCommentFocus(); + if (initialSections.some((s) => s.id === target.id)) { + postToOverlay({ type: "jh:scrollToSection", id: target.id }); + } else { + showToast("Couldn't find that section."); + } + }; + applyHash(false); + const onHashChange = () => applyHash(true); + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + // overlayReadyNonce: re-run on every jh:ready (iframe reload) so the fragment + // is re-applied after the overlay re-paints, not only on the first ready. + }, [overlayReady, overlayReadyNonce, clearCommentFocus, postToOverlay, initialSections, showToast]); + const visibleThreads = useMemo( () => threads.filter((t) => showResolved || !t.resolved), [threads, showResolved] @@ -552,13 +724,12 @@ export default function CommentsShell(props: Props) { useEffect(() => { const el = railRef.current; if (!el) return; - // Mobile drawer: cards stack from the top (no absolute Y), so clear any leftover - // desktop scroll offset — a stale large scrollTop would open the drawer scrolled - // past the stacked cards onto empty space. - if (isMobile) { - el.scrollTop = 0; - return; - } + // "show card" mode (a doc-level/orphaned permalink): the rail is pinned to that + // card (RailCards owns the scroll), so the doc-follow sync yields to it. + if (railCardId != null) return; + // Mobile drawer stacks cards in thread order (no docScrollY tracking) — its + // scroll is handled by the reset effect below, not this doc-follow sync. + if (isMobile) return; // The cards list starts BELOW the sticky header + doc-reaction/sign-in rows, but // card Y is measured from the document top. Offset the sync by that chrome height // (the list's own offsetTop) so a card lines up with its highlight instead of @@ -569,7 +740,30 @@ export default function CommentsShell(props: Props) { // docHeight is a dep though unused above: when it grows (late layout / resize) the // rail's scrollHeight grows with it, so a scrollTop the browser previously clamped // too low must be reapplied — otherwise cards stay offset until the next doc scroll. - }, [docScrollY, docHeight, isMobile, railOpen]); + }, [railCardId, docScrollY, docHeight, isMobile, railOpen]); + + // Leave "show card" mode once the user scrolls the doc off where they entered it, + // so the rail returns to following the doc — the sync is never left disabled. A + // doc-level card doesn't move the doc on its own, so this fires only on a real + // user scroll; navigation (section / another comment / clear) also exits the mode. + useEffect(() => { + if (railCardId != null && Math.abs(docScrollY - cardBaselineRef.current) > 4) setRailCardId(null); + }, [docScrollY, railCardId]); + + // Mobile drawer: reset to the top only when it (re)opens or we switch to mobile — + // NOT on docScrollY (the drawer doesn't track the doc), which would fight a + // permalink scrolling a card into view. Skip the reset in "show card" mode so the + // card scroll (RailCards) survives the open. + const prevRailOpenRef = useRef(false); + const prevMobileRef = useRef(false); + useEffect(() => { + const el = railRef.current; + const justOpened = railOpen && !prevRailOpenRef.current; + const justMobile = isMobile && !prevMobileRef.current; + prevRailOpenRef.current = railOpen; + prevMobileRef.current = isMobile; + if (el && isMobile && (justOpened || justMobile) && railCardId == null) el.scrollTop = 0; + }, [isMobile, railOpen, railCardId]); // When dark, expose the variant-D palette as CSS custom properties on the // wrapper. Every themed color below reads `var(--jh-x, )`, so @@ -723,9 +917,17 @@ export default function CommentsShell(props: Props) { if (ok) setDraft(null); }} onCancelDraft={() => setDraft(null)} + onCopyLink={(id) => copyLink({ kind: "comment", id })} + railCardId={railCardId} /> + + {toast ? ( +
+ {toast} +
+ ) : null} ); } @@ -888,6 +1090,8 @@ function RailCards(props: { onReact: (emoji: string, commentId: number | null) => void; onSubmitDraft: (body: string) => void; onCancelDraft: () => void; + onCopyLink: (id: number) => void; + railCardId: number | null; }) { const { threads, @@ -906,6 +1110,8 @@ function RailCards(props: { onReact, onSubmitDraft, onCancelDraft, + onCopyLink, + railCardId, } = props; // Compute no-overlap clamped offsets for anchored cards. We don't know exact @@ -948,6 +1154,15 @@ function RailCards(props: { if (changed) force((n) => n + 1); }, [threads, positions, aligned]); + // "show card" mode: bring the pinned card into view. Re-runs on threads change + // (so it lands even if the card wasn't rendered when the mode was set, and stays + // centered while the mode holds). The mode is cleared by the shell (user scrolls + // the doc / navigates elsewhere), never here. + useEffect(() => { + if (railCardId == null) return; + cardRefs.current.get(railCardId)?.scrollIntoView({ block: "center", behavior: "smooth" }); + }, [railCardId, threads]); + return (
{threads.map((t) => ( @@ -968,6 +1183,7 @@ function RailCards(props: { onResolve={(resolved) => onResolve(t.id, resolved)} onDelete={() => onDelete(t.id)} onReact={(emoji) => onReact(emoji, t.id)} + onCopyLink={() => onCopyLink(t.id)} /> ))} @@ -1007,9 +1223,10 @@ const Card = forwardRef< onResolve: (resolved: boolean) => void; onDelete: () => void; onReact: (emoji: string) => void; + onCopyLink: () => void; } >(function Card( - { thread: t, pinned, active, canComment, onPin, onHoverIn, onHoverOut, onReply, onResolve, onDelete, onReact }, + { thread: t, pinned, active, canComment, onPin, onHoverIn, onHoverOut, onReply, onResolve, onDelete, onReact, onCopyLink }, ref ) { const [replyText, setReplyText] = useState(""); @@ -1040,6 +1257,7 @@ const Card = forwardRef< fontSize: 12, cursor: "pointer", overflow: "hidden", + position: "relative", opacity: t.resolved ? 0.6 : 1, boxShadow: pinned ? "var(--jh-card-pin-shadow, 0 3px 14px rgba(0,0,0,.18))" @@ -1049,6 +1267,21 @@ const Card = forwardRef< transition: CHROME_TRANSITION, }} > + {active || pinned ? ( + + ) : null}
{t.author_avatar ? ( // eslint-disable-next-line @next/next/no-img-element @@ -1215,6 +1448,17 @@ function Badge({ kind, children }: { kind: "res" | "orp"; children: React.ReactN ); } +// The chain-link glyph for the comment card's "copy link" affordance (mirrors the +// gutter icon the overlay injects on headings). +function LinkGlyph() { + return ( + + ); +} + // --------------------------- responsive rail (variant A) --------------------------- // The ONLY new chrome. Inline styles can't express media queries, so the @@ -1479,3 +1723,39 @@ function composerBtn(disabled: boolean): React.CSSProperties { opacity: disabled ? 0.4 : 1, }; } + +// Comment card "copy link" icon — top-right, revealed on hover/pin (data-no-pin so +// the click copies without toggling the thread). +const cardLinkBtnStyle: React.CSSProperties = { + position: "absolute", + top: 6, + right: 6, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 22, + height: 22, + padding: 0, + border: "none", + background: "transparent", + color: "var(--jh-card-muted, #999)", + borderRadius: 5, + cursor: "pointer", + lineHeight: 0, +}; + +// Transient "copied" confirmation toast, centered at the bottom of the viewport. +const toastStyle: React.CSSProperties = { + position: "fixed", + bottom: 20, + left: "50%", + transform: "translateX(-50%)", + background: "var(--jh-sel-bg, #111)", + color: "var(--jh-sel-fg, #fff)", + fontFamily: MONO, + fontSize: 12, + padding: "7px 14px", + borderRadius: 7, + boxShadow: "0 4px 14px var(--jh-sel-shadow, rgba(0,0,0,.3))", + zIndex: 100, +}; diff --git a/app/d/[slug]/page.tsx b/app/d/[slug]/page.tsx index 5f359b4..291e22d 100644 --- a/app/d/[slug]/page.tsx +++ b/app/d/[slug]/page.tsx @@ -11,6 +11,7 @@ import { allThreads, } from "@/lib/docs/comments"; import { detectServerTheme } from "@/lib/docs/theme"; +import { extractSections } from "@/lib/docs/sections"; import CommentsShell from "./CommentsShell"; export const dynamic = "force-dynamic"; @@ -96,6 +97,11 @@ export default async function ViewerPage({ params, searchParams }: Props) { const anchoredReactions = threadData.anchored_reactions ?? []; const title = doc.title || doc.slug; + // Section deeplinks: the ordered heading list + stable fragment ids, derived + // from the stored HTML. The shell forwards it to the overlay, which assigns the + // ids to headings in document order and paints the gutter link icon. + const sections = extractSections(doc.html); + // Adaptive chrome (variant D): coarsely detect a DARK doc from the stored // HTML's unconditional html/body background so the shell renders themed at SSR — // CommentsShell uses it as the initial theme to avoid a light→dark flash before the @@ -121,6 +127,7 @@ export default async function ViewerPage({ params, searchParams }: Props) { initialThreads={threadData.threads} initialDocReactions={docReactions} initialAnchoredReactions={anchoredReactions} + initialSections={sections} version={doc.version} initialTheme={serverTheme} /> diff --git a/lib/docs/deeplink.test.ts b/lib/docs/deeplink.test.ts new file mode 100644 index 0000000..1ecfc06 --- /dev/null +++ b/lib/docs/deeplink.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { fragmentFor, parseHash } from "@/lib/docs/deeplink"; + +describe("deeplink codec", () => { + it("routes comment / section / empty hashes", () => { + expect(parseHash("#comment-42")).toEqual({ kind: "comment", id: 42 }); + expect(parseHash("comment-42")).toEqual({ kind: "comment", id: 42 }); // leading # optional + expect(parseHash("#setup")).toEqual({ kind: "section", id: "setup" }); + expect(parseHash("#")).toEqual({ kind: "none" }); + expect(parseHash("")).toEqual({ kind: "none" }); + }); + + it("builds comment fragments without encoding and section fragments with it", () => { + expect(fragmentFor({ kind: "comment", id: 7 })).toBe("comment-7"); + expect(fragmentFor({ kind: "section", id: "setup" })).toBe("setup"); + expect(fragmentFor({ kind: "section", id: "my heading" })).toBe("my%20heading"); + }); + + it("round-trips section ids with spaces, unicode, and reserved characters", () => { + for (const id of ["setup", "my heading", "café", "日本語", "a/b?c#d", "100%", "section-comment-3"]) { + const t = parseHash("#" + fragmentFor({ kind: "section", id })); + expect(t).toEqual({ kind: "section", id }); + } + }); + + it("does not throw on malformed percent-encoding — treats it as a literal section id", () => { + expect(parseHash("#foo%")).toEqual({ kind: "section", id: "foo%" }); + expect(parseHash("#%E0%A4%A")).toEqual({ kind: "section", id: "%E0%A4%A" }); + }); + + it("a comment- hash is a comment, but comment-word is a section", () => { + expect(parseHash("#comment-1")).toEqual({ kind: "comment", id: 1 }); + expect(parseHash("#comment-intro")).toEqual({ kind: "section", id: "comment-intro" }); + }); +}); diff --git a/lib/docs/deeplink.ts b/lib/docs/deeplink.ts new file mode 100644 index 0000000..e4e41d4 --- /dev/null +++ b/lib/docs/deeplink.ts @@ -0,0 +1,38 @@ +// Deeplink fragment contract — the ONE place the copy side and the read side agree +// on how a section/comment id maps to a URL #fragment. copyLink builds the fragment +// with fragmentFor(); the hash router reads it with parseHash(). Keeping both on one +// codec is what makes permalinks round-trip: encode-on-write must mirror +// decode-on-read, and the routing (comment vs section) must match how ids are +// minted (see lib/docs/sections.ts, which never mints a comment- section id, so +// the comment branch here can never swallow a section link). + +export type HashTarget = + | { kind: "comment"; id: number } + | { kind: "section"; id: string } + | { kind: "none" }; + +// The #fragment for a copyable permalink. Comment permalinks are comment- +// (ASCII, no encoding needed); a section id is author- or slug-derived and may hold +// Unicode, spaces, or reserved characters, so it's percent-encoded — parseHash +// decodes it back. +export function fragmentFor(target: { kind: "comment"; id: number } | { kind: "section"; id: string }): string { + return target.kind === "comment" ? `comment-${target.id}` : encodeURIComponent(target.id); +} + +// Parse a URL hash (with or without the leading '#') into a navigation target. +// Decodes percent-encoding; malformed encoding (a lone '%', a truncated escape in a +// pasted link) is treated as a literal section id rather than throwing. An empty +// fragment is "none" (no selection). +export function parseHash(hash: string): HashTarget { + const raw = hash.startsWith("#") ? hash.slice(1) : hash; + let h: string; + try { + h = decodeURIComponent(raw); + } catch { + h = raw; + } + if (!h) return { kind: "none" }; + const m = /^comment-(\d+)$/.exec(h); + if (m) return { kind: "comment", id: Number(m[1]) }; + return { kind: "section", id: h }; +} diff --git a/lib/docs/overlay.ts b/lib/docs/overlay.ts index bb6cfcb..195b5c0 100644 --- a/lib/docs/overlay.ts +++ b/lib/docs/overlay.ts @@ -63,8 +63,12 @@ export const OVERLAY_SCRIPT = String.raw` var byKey = {}; // key -> { item, segEls:[], chipEls:[] } var activeKey = null; // hover-highlighted key (transient) var focusKey = null; // focused (pinned) key + var pendingFocusScroll = null; // jh:focus scroll owed for a key not painted yet; applied on next paint() var lastClickKeys = null; // covering set of the last focus click (for cycle) var lastClickPos = -1; // doc-text offset of the last focus click (cycle reset on move) + var sections = []; // ordered [{id, level, text}] from jh:sections + var secById = {}; // section id -> heading element (for scrollToSection) + var pendingSection = null; // a scrollToSection requested before sections were applied function send(msg){ try { parent.postMessage(msg, "*"); } catch(e){} } function esc(s){ return (s||"").replace(/&/g,"&").replace(//g,">"); } @@ -263,8 +267,9 @@ export const OVERLAY_SCRIPT = String.raw` var n = walker.currentNode; var p = n.parentNode; if (p && (p.nodeName === "SCRIPT" || p.nodeName === "STYLE")) continue; - // skip text inside our own chips so reaction counts never become anchorable text - if (p && p.closest && p.closest("[data-jh-chip]")) continue; + // skip text inside our own chips / section-anchors so injected UI never + // becomes anchorable text + if (p && p.closest && p.closest("[data-jh-chip],[data-jh-sec-anchor]")) continue; nodes.push({ node: n, start: full.length }); full += n.nodeValue; } @@ -403,6 +408,83 @@ export const OVERLAY_SCRIPT = String.raw` (document.head||document.documentElement).appendChild(st); } + // ---- section deeplinks (heading gutter link icon + scroll-to) ---- + // The shell sends the server's ordered section list; we assign each id to the + // heading at the same document-order index (server + DOM agree on order), set + // scroll-margin so a scrolled-to heading isn't flush to the top, and inject a + // hover-reveal link icon in the left gutter. Clicking it asks the shell to copy + // the permalink — clipboard is unreliable from this opaque-origin sandbox, so + // the shell (real origin) does the write. Idempotent: re-running updates in place. + var LINK_SVG = ''; + + function ensureSectionStyle(){ + if (document.getElementById("jh-sec-style")) return; + var st = document.createElement("style"); st.id = "jh-sec-style"; + st.textContent = + "h1,h2,h3,h4,h5,h6{scroll-margin-top:1.5rem}" + // Inline (not absolute) so it rides the heading's first text line and stays + // vertically centered regardless of the doc's own top padding/border/margin; + // the negative-left + right margins net to zero width so text doesn't shift. + + "a.jh-sec-anchor{display:inline-flex;align-items:center;justify-content:center;" + + "width:1em;height:1em;margin:0 .35em 0 -1.35em;vertical-align:middle;opacity:0;" + + "text-decoration:none;color:currentColor;transition:opacity .12s}" + + "h1:hover>a.jh-sec-anchor,h2:hover>a.jh-sec-anchor,h3:hover>a.jh-sec-anchor," + + "h4:hover>a.jh-sec-anchor,h5:hover>a.jh-sec-anchor,h6:hover>a.jh-sec-anchor{opacity:.5}" + + "a.jh-sec-anchor:hover,a.jh-sec-anchor:focus{opacity:.9;outline:none}" + + "@keyframes jh-sec-flash{0%,100%{background-color:transparent}18%{background-color:rgba(245,197,24,.35)}}" + + ".jh-sec-target{animation:jh-sec-flash 1.6s ease;border-radius:3px}"; + (document.head||document.documentElement).appendChild(st); + } + + function addSectionAnchor(h, id){ + var ex = h.querySelector(":scope > a.jh-sec-anchor"); + if (ex){ ex.setAttribute("data-jh-sec", id); ex.setAttribute("href", "#"+id); return; } + var a = document.createElement("a"); + a.className = "jh-sec-anchor"; + a.setAttribute("data-jh-sec-anchor", "1"); + a.setAttribute("data-jh-sec", id); + a.setAttribute("href", "#"+id); + a.setAttribute("aria-label", "Copy link to section"); + a.innerHTML = LINK_SVG; + a.addEventListener("click", function(ev){ + ev.preventDefault(); ev.stopPropagation(); + send({type:"jh:copyLink", id: a.getAttribute("data-jh-sec")}); + }); + h.insertBefore(a, h.firstChild); + } + + function applySections(){ + try { + ensureSectionStyle(); + var heads = document.querySelectorAll("h1,h2,h3,h4,h5,h6"); + secById = {}; + var n = Math.min(sections.length, heads.length); + for (var i=0;i { + it("slugs heading text and records level", () => { + expect(extractSections("

Hello World

")).toEqual([ + { id: "hello-world", level: 1, text: "Hello World" }, + ]); + const levels = extractSections( + "

a

b

c

d

e
f
" + ).map((s) => s.level); + expect(levels).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it("uses the text content of nested inline markup", () => { + const [s] = extractSections("

v0 API shape

"); + expect(s.id).toBe("v0-api-shape"); + expect(s.text).toBe("v0 API shape"); + }); + + it("decodes entities and collapses punctuation to a single hyphen", () => { + expect(extractSections("

Tom & Jerry

")[0].id).toBe("tom-jerry"); + expect(extractSections("

Multiple spaces

")[0]).toEqual({ + id: "multiple-spaces", + level: 2, + text: "Multiple spaces", + }); + }); + + it("de-dupes repeated slugs in document order", () => { + expect(extractSections("

Setup

Setup

Setup

").map((s) => s.id)).toEqual([ + "setup", + "setup-1", + "setup-2", + ]); + }); + + it("falls back to section-N for empty slugs (emoji / punctuation only)", () => { + expect(extractSections("

Intro

🎉

!!!

").map((s) => s.id)).toEqual([ + "intro", + "section-2", + "section-3", + ]); + }); + + it("prefers an author-provided id and ignores other *id attributes", () => { + expect(extractSections('

Whatever

')[0].id).toBe("custom-anchor"); + expect(extractSections('

Title

')[0].id).toBe("real"); + expect(extractSections("

No id here

")[0].id).toBe("no-id-here"); + }); + + it("never mints an id in the reserved comment- namespace", () => { + expect(extractSections("

Comment 12

")[0].id).toBe("section-comment-12"); + // Author ids get the same guard, so a heading id can't hijack a #comment- + // permalink; a non-colliding author id is still used verbatim. + expect(extractSections('

Title

')[0].id).toBe("section-comment-5"); + expect(extractSections('

Title

')[0].id).toBe("intro"); + }); + + it("keeps non-Latin headings instead of emptying them", () => { + const [s] = extractSections("

日本語

"); + expect(s.id).toBe("日本語"); + expect(s.level).toBe(2); + }); + + it("ignores headings inside comments, script, and style", () => { + const html = + "" + + "

Real

"; + expect(extractSections(html).map((s) => s.id)).toEqual(["real"]); + }); + + it("ignores headings in template/noscript so ids don't shift vs the rendered DOM", () => { + // Neither reaches the overlay's document.querySelectorAll (template is inert; + // noscript is text while scripting is on), so counting them would misalign the + // id assigned to every later heading. Both real headings keep their clean ids. + const html = + "

First

" + + "

Second

"; + expect(extractSections(html).map((s) => s.id)).toEqual(["first", "second"]); + }); + + it("returns an empty list when there are no headings", () => { + expect(extractSections("

just a paragraph

")).toEqual([]); + }); +}); diff --git a/lib/docs/sections.ts b/lib/docs/sections.ts new file mode 100644 index 0000000..989a38e --- /dev/null +++ b/lib/docs/sections.ts @@ -0,0 +1,99 @@ +// Section deeplinks — stable per-heading fragment ids derived from the document +// HTML (GitHub/GFM-style slugs). This is the ONE definition of a "section id": +// the server computes the ordered heading list + ids here, the viewer shell +// forwards it to the overlay, and the overlay CONSUMES it (assigns each id to the +// heading at the same document-order index, paints the gutter link icon). The +// overlay is stringified browser JS and cannot import this module — same +// server-owns-identity split as lib/docs/anchor.ts's anchorSignature. +// +// STATELESS: ids are a pure function of the CURRENT html, recomputed each render +// (no storage, no re-anchoring). Editing a heading's text, or adding/removing/ +// reordering headings, can change ids — the same behavior as GitHub anchors. +// Body-text edits that don't touch headings leave every id stable. + +import { htmlToText } from "@/lib/docs/anchor"; + +export type Section = { + id: string; // unique URL fragment (no leading '#') + level: number; // 1..6 (the heading's h-level) + text: string; // visible heading text (for matching + display) +}; + +// Comment permalinks live at #comment- (see the CommentsShell hash router). +// Never mint a section id in that shape, so the two fragment namespaces can't +// collide even when a heading's text happens to slug to "comment-". +const COMMENT_FRAGMENT = /^comment-\d+$/; + +const HEADING_RE = /<(h[1-6])\b([^>]*)>([\s\S]*?)<\/\1>/gi; + +// GFM-style slug of a heading's visible text: lowercase, drop punctuation / +// symbols / emoji, runs of whitespace become a single hyphen. Unicode-aware so +// non-Latin headings keep their letters rather than collapsing to empty. +function slugify(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N}\s-]/gu, "") + .replace(/\s+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, ""); +} + +// An author-provided id on the heading wins over a computed slug (respects any +// anchors the author already set). Matches id="x" / id='x' / id=x, but not +// data-id= or other *id attributes (must be at attribute-list start or preceded +// by whitespace). +function readId(attrs: string): string { + const m = /(?:^|\s)id\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/i.exec(attrs); + if (!m) return ""; + return (m[2] ?? m[3] ?? m[4] ?? "").trim(); +} + +export function extractSections(html: string): Section[] { + // Strip comments + script/style/template/noscript first so headings that only + // appear there don't count — keeps the server heading set aligned with the + // rendered DOM the overlay walks (it assigns ids by document-order index). + //