fix(ui): in-page anchor navigation for headings

Intercept `#`-prefixed link clicks and smooth-scroll inside the sticky
scroll viewport instead of letting the browser jump (which broke when the
content lives inside a scrollable container). Headings now expose both a
canonical slug and a legacy slug via `data-anchor-aliases` so older shared
URLs keep resolving. Also listen for `hashchange` / initial hash so direct
links land on the right heading.

Adds `packages/ui/utils/anchors.ts` with `normalizeAnchorText`,
`decodeAnchorHash`, `slugifyHeadingAnchor`, `legacySlugifyHeadingAnchor`,
and `getHeadingAnchorAliases` (+ tests).

Also ignore `.serena/` tooling state.
This commit is contained in:
Darrell Grissen
2026-04-23 12:33:08 +02:00
parent 289a2aa494
commit 9978a40006
11 changed files with 230 additions and 20 deletions
+1
View File
@@ -42,6 +42,7 @@ apps/pi-extension/review-core.ts
# Claude Code session-local runtime state (lock files, scheduled-task state).
# Machine-specific; never belongs in the repo.
.claude/
.serena/
*.ntvs*
*.njsproj
*.sln
+28 -5
View File
@@ -7,6 +7,7 @@ import { HtmlBlock } from "./blocks/HtmlBlock";
import { Callout } from "./blocks/Callout";
import { AlertBlock } from "./blocks/AlertBlock";
import { TableBlock } from "./blocks/TableBlock";
import { getHeadingAnchorAliases } from "../utils/anchors";
export const BlockRenderer: React.FC<{
block: Block;
@@ -18,7 +19,8 @@ export const BlockRenderer: React.FC<{
orderedIndex?: number | null;
githubRepo?: string;
headingAnchorId?: string;
}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId }) => {
onNavigateAnchor?: (hash: string) => void;
}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId, onNavigateAnchor }) => {
switch (block.type) {
case 'heading': {
const Tag = `h${block.level || 1}` as React.ElementType;
@@ -27,7 +29,25 @@ export const BlockRenderer: React.FC<{
2: 'text-xl font-semibold mb-3 mt-8 text-foreground/90',
3: 'text-base font-semibold mb-2 mt-6 text-foreground/80',
}[block.level || 1] || 'text-base font-semibold mb-2 mt-4';
return <Tag id={headingAnchorId} className={styles} data-block-id={block.id} data-block-type="heading"><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} /></Tag>;
const anchorAliases = [
headingAnchorId,
...getHeadingAnchorAliases(block.content),
].filter(Boolean);
const anchorAliasTokens = anchorAliases.length > 0
? [...new Set(anchorAliases)].join(' ')
: undefined;
return (
<Tag
id={headingAnchorId}
className={styles}
data-block-id={block.id}
data-block-type="heading"
data-anchor-aliases={anchorAliasTokens}
>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
</Tag>
);
}
case 'blockquote': {
@@ -41,6 +61,7 @@ export const BlockRenderer: React.FC<{
imageBaseDir={imageBaseDir}
onImageClick={onImageClick}
githubRepo={githubRepo}
onNavigateAnchor={onNavigateAnchor}
/>
);
}
@@ -54,7 +75,7 @@ export const BlockRenderer: React.FC<{
>
{paragraphs.map((para, i) => (
<p key={i} className={i > 0 ? 'mt-2' : ''}>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={para} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} />
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={para} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
</p>
))}
</blockquote>
@@ -83,7 +104,7 @@ export const BlockRenderer: React.FC<{
onToggle={isInteractive ? () => onToggleCheckbox!(block.id, !isChecked) : undefined}
/>
<span className={`text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`}>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} />
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
</span>
</div>
);
@@ -100,6 +121,7 @@ export const BlockRenderer: React.FC<{
onImageClick={onImageClick}
onOpenLinkedDoc={onOpenLinkedDoc}
githubRepo={githubRepo}
onNavigateAnchor={onNavigateAnchor}
/>
);
@@ -123,6 +145,7 @@ export const BlockRenderer: React.FC<{
imageBaseDir={imageBaseDir}
onImageClick={onImageClick}
githubRepo={githubRepo}
onNavigateAnchor={onNavigateAnchor}
/>
);
}
@@ -133,7 +156,7 @@ export const BlockRenderer: React.FC<{
className="mb-4 leading-relaxed text-foreground/90 text-[15px]"
data-block-id={block.id}
>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} />
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
</p>
);
}
+31 -9
View File
@@ -85,10 +85,11 @@ function emitPlainTextWithBareUrls(
export const InlineMarkdown: React.FC<{
text: string;
onOpenLinkedDoc?: (path: string) => void;
onNavigateAnchor?: (hash: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
}> = ({ text, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo }) => {
}> = ({ text, onOpenLinkedDoc, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo }) => {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
@@ -179,8 +180,9 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
/>
</del>,
);
remaining = remaining.slice(match[0].length);
@@ -199,8 +201,9 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
githubRepo={githubRepo}
/>
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
</em>
</strong>,
);
@@ -219,8 +222,9 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
/>
</strong>,
);
remaining = remaining.slice(match[0].length);
@@ -238,8 +242,9 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
/>
</em>,
);
remaining = remaining.slice(match[0].length);
@@ -258,8 +263,9 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
/>
</em>,
);
remaining = remaining.slice(match[0].length);
@@ -500,8 +506,23 @@ export const InlineMarkdown: React.FC<{
!linkUrl.startsWith("http://") &&
!linkUrl.startsWith("https://");
const linkedDocPath = isLocalDoc ? linkUrl.replace(/#.*$/, '') : linkUrl;
const isInPageAnchor = safeLinkUrl.startsWith('#');
if (isLocalDoc && onOpenLinkedDoc) {
if (isInPageAnchor) {
parts.push(
<a
key={key++}
href={safeLinkUrl}
onClick={onNavigateAnchor ? (e) => {
e.preventDefault();
onNavigateAnchor(safeLinkUrl);
} : undefined}
className="text-primary underline underline-offset-2 hover:text-primary/80"
>
{linkText}
</a>,
);
} else if (isLocalDoc && onOpenLinkedDoc) {
parts.push(
<a
key={key++}
@@ -569,8 +590,9 @@ export const InlineMarkdown: React.FC<{
key={key++}
text={before}
onOpenLinkedDoc={onOpenLinkedDoc}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
imageBaseDir={imageBaseDir}
imageBaseDir={imageBaseDir}
onImageClick={onImageClick}
/>,
);
+62 -3
View File
@@ -45,6 +45,7 @@ import { PinpointOverlay } from './PinpointOverlay';
import { usePinpoint } from '../hooks/usePinpoint';
import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
import { useScrollViewport } from '../hooks/useScrollViewport';
import { decodeAnchorHash } from '../utils/anchors';
interface ViewerProps {
blocks: Block[];
@@ -163,6 +164,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
}, ref) => {
const [copied, setCopied] = useState(false);
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
const [locationHash, setLocationHash] = useState(() => window.location.hash);
const globalCommentButtonRef = useRef<HTMLButtonElement>(null);
const handleCopyPlan = async () => {
@@ -200,6 +202,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
} | null>(null);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const stickySentinelRef = useRef<HTMLDivElement>(null);
const lastAutoScrolledHashRef = useRef<string | null>(null);
const [isStuck, setIsStuck] = useState(false);
// Shared annotation infrastructure via hook
@@ -294,6 +297,61 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
return () => observer.disconnect();
}, [stickyActions, stickyScrollViewport]);
useEffect(() => {
const handleHashChange = () => {
lastAutoScrolledHashRef.current = null;
setLocationHash(window.location.hash);
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
const scrollToAnchor = useCallback((hash: string) => {
const anchor = decodeAnchorHash(hash);
if (!anchor) return false;
const container = containerRef.current;
if (!container || !stickyScrollViewport) return false;
const targetById = document.getElementById(anchor);
const target = (targetById && container.contains(targetById))
? targetById
: Array.from(container.querySelectorAll<HTMLElement>('[data-anchor-aliases]')).find(element =>
element.dataset.anchorAliases?.split(/\s+/).includes(anchor)
);
if (!target) return false;
const stickyActionsEl = container.querySelector<HTMLElement>('[data-sticky-actions]');
const stickyTop = stickyActionsEl
? Number.parseFloat(window.getComputedStyle(stickyActionsEl).top || '0') || 0
: 0;
const headerOffset = stickyActionsEl
? stickyActionsEl.getBoundingClientRect().height + stickyTop
: 0;
const containerRect = stickyScrollViewport.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const relativeTop = targetRect.top - containerRect.top;
const offsetPosition = stickyScrollViewport.scrollTop + relativeTop - headerOffset;
stickyScrollViewport.scrollTo({
top: Math.max(0, offsetPosition),
behavior: 'smooth',
});
return true;
}, [stickyScrollViewport]);
useEffect(() => {
if (!stickyScrollViewport || !locationHash || lastAutoScrolledHashRef.current === locationHash) return;
const timer = window.setTimeout(() => {
if (scrollToAnchor(locationHash)) {
lastAutoScrolledHashRef.current = locationHash;
}
}, 0);
return () => window.clearTimeout(timer);
}, [blocks, locationHash, scrollToAnchor, stickyScrollViewport]);
// Cmd+C / Ctrl+C keyboard shortcut for copying selected text
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
@@ -563,6 +621,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
checkboxOverrides={checkboxOverrides}
githubRepo={repoInfo?.display}
headingAnchorId={headingSlugMap.get(block.id)}
onNavigateAnchor={scrollToAnchor}
/>
))}
</div>
@@ -580,6 +639,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
onImageClick={(src, alt) => setLightbox({ src, alt })}
onOpenLinkedDoc={onOpenLinkedDoc}
githubRepo={repoInfo?.display}
onNavigateAnchor={scrollToAnchor}
onHover={(element) => {
if (tableHoverTimeoutRef.current) {
clearTimeout(tableHoverTimeoutRef.current);
@@ -631,7 +691,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id}
/>
) : (
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
)
)}
@@ -728,6 +788,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
onImageClick={(src, alt) => setLightbox({ src, alt })}
onOpenLinkedDoc={onOpenLinkedDoc}
githubRepo={repoInfo?.display}
onNavigateAnchor={scrollToAnchor}
/>
)}
@@ -855,5 +916,3 @@ function groupBlocks(blocks: Block[]): RenderGroup[] {
}
+3 -2
View File
@@ -10,6 +10,7 @@ interface AlertBlockProps {
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
onNavigateAnchor?: (hash: string) => void;
}
const TITLE: Record<AlertKind, string> = {
@@ -37,7 +38,7 @@ const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
};
export const AlertBlock: React.FC<AlertBlockProps> = ({
blockId, kind, body, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo,
blockId, kind, body, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo, onNavigateAnchor,
}) => {
return (
<div
@@ -50,7 +51,7 @@ export const AlertBlock: React.FC<AlertBlockProps> = ({
<Icon kind={kind} />
<span>{TITLE[kind]}</span>
</div>
{renderProseBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, githubRepo })}
{renderProseBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, onNavigateAnchor, githubRepo })}
</div>
);
};
@@ -12,6 +12,7 @@ interface CalloutProps {
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
onNavigateAnchor?: (hash: string) => void;
}
export const Callout: React.FC<CalloutProps> = ({
@@ -25,6 +26,7 @@ export const Callout: React.FC<CalloutProps> = ({
imageBaseDir,
onImageClick,
githubRepo,
onNavigateAnchor,
}) => {
const kindAttr =
blockType === 'alert' ? { 'data-alert-kind': kindAttribute } : { 'data-directive-kind': kindAttribute };
@@ -47,6 +49,7 @@ export const Callout: React.FC<CalloutProps> = ({
imageBaseDir,
onImageClick,
onOpenLinkedDoc,
onNavigateAnchor,
githubRepo,
})}
</div>
@@ -7,6 +7,7 @@ interface TableBlockProps {
onHover?: (element: HTMLElement) => void;
onLeave?: () => void;
onOpenLinkedDoc?: (path: string) => void;
onNavigateAnchor?: (hash: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
@@ -80,6 +81,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
onHover,
onLeave,
onOpenLinkedDoc,
onNavigateAnchor,
imageBaseDir,
onImageClick,
githubRepo,
@@ -109,6 +111,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
onImageClick={onImageClick}
text={header}
onOpenLinkedDoc={onOpenLinkedDoc}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
</th>
@@ -125,6 +128,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
onImageClick={onImageClick}
text={cell}
onOpenLinkedDoc={onOpenLinkedDoc}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
</td>
@@ -22,6 +22,7 @@ interface TablePopoutProps {
/** hooks can walk into the popout's text nodes. Null falls back to body. */
container?: HTMLElement | null;
onOpenLinkedDoc?: (path: string) => void;
onNavigateAnchor?: (hash: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
@@ -38,6 +39,7 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
onClose,
container,
onOpenLinkedDoc,
onNavigateAnchor,
imageBaseDir,
onImageClick,
githubRepo,
@@ -80,12 +82,13 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
onImageClick={onImageClick}
text={info.getValue()}
onOpenLinkedDoc={onOpenLinkedDoc}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
),
}),
);
}, [columnIds, headers, imageBaseDir, onImageClick, onOpenLinkedDoc, githubRepo]);
}, [columnIds, headers, imageBaseDir, onImageClick, onOpenLinkedDoc, onNavigateAnchor, githubRepo]);
const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState('');
@@ -16,6 +16,7 @@ export function renderProseBody(args: {
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
onOpenLinkedDoc?: (path: string) => void;
onNavigateAnchor?: (hash: string) => void;
githubRepo?: string;
}): React.ReactNode {
const {
@@ -25,6 +26,7 @@ export function renderProseBody(args: {
imageBaseDir,
onImageClick,
onOpenLinkedDoc,
onNavigateAnchor,
githubRepo,
} = args;
@@ -34,6 +36,7 @@ export function renderProseBody(args: {
onImageClick={onImageClick}
text={text}
onOpenLinkedDoc={onOpenLinkedDoc}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
);
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'bun:test';
import {
decodeAnchorHash,
getHeadingAnchorAliases,
legacySlugifyHeadingAnchor,
normalizeAnchorText,
slugifyHeadingAnchor,
} from './anchors';
describe('anchor helpers', () => {
test('normalizes inline markdown before slugging', () => {
expect(normalizeAnchorText('Part I: **Project** `Overview`')).toBe('Part I: Project Overview');
expect(normalizeAnchorText('[Docs](./docs.md) and [[notes|Notes Page]]')).toBe('Docs and Notes Page');
});
test('decodes anchor hashes and strips callback suffixes', () => {
expect(decodeAnchorHash('#section--overview')).toBe('section--overview');
expect(decodeAnchorHash('#section--overview?cb=https://example.com&ct=token')).toBe(
'section--overview',
);
});
test('keeps malformed percent-encoded hashes non-fatal', () => {
expect(decodeAnchorHash('#bad%E0%A4%A')).toBe('bad%E0%A4%A');
});
test('creates a canonical collapsed slug', () => {
expect(slugifyHeadingAnchor('Part I: Project Overview: Goals and Scope')).toBe(
'part-i-project-overview-goals-and-scope',
);
});
test('creates a legacy slug variant that preserves repeated separators', () => {
expect(legacySlugifyHeadingAnchor('Part I: Project Overview: Goals and Scope')).toBe(
'part-i--project-overview--goals-and-scope',
);
});
test('returns both canonical and legacy aliases without duplicates', () => {
expect(getHeadingAnchorAliases('Simple Heading')).toEqual(['simple-heading']);
expect(getHeadingAnchorAliases('Part I: Project Overview: Goals and Scope')).toEqual([
'part-i-project-overview-goals-and-scope',
'part-i--project-overview--goals-and-scope',
]);
});
});
+45
View File
@@ -0,0 +1,45 @@
export function normalizeAnchorText(text: string): string {
return text
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1')
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2')
.replace(/\[\[([^\]]+)\]\]/g, '$1')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/[*_~]/g, '')
.trim();
}
export function decodeAnchorHash(hash: string): string | null {
const raw = hash.replace(/^#/, '').split('?')[0]?.trim();
if (!raw) return null;
try {
const decoded = decodeURIComponent(raw).trim();
return decoded || null;
} catch {
// Keep malformed fragments non-fatal; the caller can still attempt lookup.
return raw;
}
}
export function slugifyHeadingAnchor(text: string): string {
return normalizeAnchorText(text)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function legacySlugifyHeadingAnchor(text: string): string {
return normalizeAnchorText(text)
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/^-+|-+$/g, '');
}
export function getHeadingAnchorAliases(text: string): string[] {
const aliases = [slugifyHeadingAnchor(text), legacySlugifyHeadingAnchor(text)]
.filter(Boolean);
return [...new Set(aliases)];
}