mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix(viewer): make in-document links in rendered markdown scroll to their heading
marked emits no heading ids and, with <base href="/">, a bare #section href points at the dashboard root, so [Install](#installation) in the File Viewer did nothing. The shared click delegate now resolves fragment links against the rendered document: GitHub-style slugs in data-md-anchor (never ids, so a heading cannot capture an app element), case-insensitive and percent-decoded, repeats numbered -1/-2, # = top, explicit ids supported, an unmatched fragment ignored instead of navigating. Tests: slug/assign/find unit tests (CI gate) and a real-browser test clicking links in a File Viewer document, which fails without the change. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
This commit is contained in:
1 parent
3a0cee6b90
commit
8da4a07a60
7 files changed
+363
-1
No files matched your search
@@ -2732,6 +2732,23 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// An in-document link (`[Install](#installation)`). The browser must not follow it: with
|
||||
// `<base href="/">` a bare fragment points at the dashboard's root and would navigate the
|
||||
// app away. Resolve it inside this rendered document and scroll there (constants.js).
|
||||
// A fragment that matches nothing is simply ignored, never a navigation.
|
||||
const fragmentLink = ev.target.closest('a[href^="#"]');
|
||||
if (fragmentLink && body.contains(fragmentLink)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const root = fragmentLink.closest('.rv-text') || body;
|
||||
const target = window.CodemanMarkdownAnchors?.find(root, fragmentLink.getAttribute('href'));
|
||||
if (target) {
|
||||
const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
|
||||
target.scrollIntoView({ block: 'start', behavior: calm ? 'auto' : 'smooth' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A `localhost` URL in the agent's answer: from another device that can
|
||||
// only load through the server, so hand it to a proxied web tab
|
||||
// (webview-tabs.js). Every other link keeps its new-tab default.
|
||||
|
||||
@@ -1298,6 +1298,63 @@ function cleanCopiedSelection(text, options) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Markdown heading anchors ────────────────────────────────────────────────
|
||||
// marked emits no `id` on headings, so a rendered document's own `[Install](#installation)` links had
|
||||
// nothing to jump to. And with `<base href="/">` a bare `#installation` href points at the dashboard's
|
||||
// root, not at the page, so letting the browser follow it navigates the app away. The click delegate
|
||||
// (`_bindResponseViewerInteractions`) therefore resolves in-document links itself, with the helpers
|
||||
// below. Anchors are `data-md-anchor` attributes, NOT `id`s: a heading titled "Settings" must not claim
|
||||
// the id of an element in the app's own DOM, and the lookup is scoped to the rendered document.
|
||||
|
||||
/**
|
||||
* GitHub's heading slug: lower-cased, anything that is not a letter, mark, number, `_`, `-` or space
|
||||
* dropped, each space a hyphen (`Why `codeman`? → `why-codeman`, `Über uns` → `über-uns`).
|
||||
*/
|
||||
function markdownHeadingSlug(text) {
|
||||
return String(text ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{M}\p{N}_\- ]/gu, '')
|
||||
.replace(/ /g, '-');
|
||||
}
|
||||
|
||||
/** Give every h1..h6 under `root` its slug in `data-md-anchor`; a repeat gets `-1`, `-2`, ... as on GitHub. Idempotent. */
|
||||
function assignMarkdownHeadingAnchors(root) {
|
||||
const used = new Set();
|
||||
for (const heading of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const base = markdownHeadingSlug(heading.textContent);
|
||||
let slug = base;
|
||||
for (let n = 1; used.has(slug); n += 1) slug = `${base}-${n}`;
|
||||
used.add(slug);
|
||||
heading.dataset.mdAnchor = slug;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The element inside `root` that an in-document link (`#installation`, `#Installation`, `#my%20title`)
|
||||
* points at, or null. An empty fragment (`#`) means the top of the document. Headings are matched by
|
||||
* slug, then a heading the author wrote an explicit `<a id="...">`/`id` for, looked up INSIDE `root`
|
||||
* only (never `document.getElementById`, which could find an app element of the same name).
|
||||
*/
|
||||
function findMarkdownAnchorTarget(root, href) {
|
||||
let fragment = String(href ?? '').replace(/^#/, '');
|
||||
try {
|
||||
fragment = decodeURIComponent(fragment);
|
||||
} catch {
|
||||
/* a malformed escape: use it as written */
|
||||
}
|
||||
if (!fragment) return root;
|
||||
assignMarkdownHeadingAnchors(root);
|
||||
const wanted = [fragment.toLowerCase(), markdownHeadingSlug(fragment)];
|
||||
for (const heading of root.querySelectorAll('[data-md-anchor]')) {
|
||||
if (wanted.includes(heading.dataset.mdAnchor)) return heading;
|
||||
}
|
||||
for (const el of root.querySelectorAll('[id]')) {
|
||||
if (el.id === fragment) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
@@ -1367,6 +1424,11 @@ if (typeof window !== 'undefined') {
|
||||
window.CodemanCopySelection = {
|
||||
clean: cleanCopiedSelection,
|
||||
};
|
||||
window.CodemanMarkdownAnchors = {
|
||||
slug: markdownHeadingSlug,
|
||||
assign: assignMarkdownHeadingAnchors,
|
||||
find: findMarkdownAnchorTarget,
|
||||
};
|
||||
window.CodemanTerminalFont = {
|
||||
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
|
||||
resolve: resolveTerminalFontFamily,
|
||||
|
||||
Reference in new issue
Block a user