diff --git a/.gitignore b/.gitignore index 539bf374..b5e156be 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/ui/components/BlockRenderer.tsx b/packages/ui/components/BlockRenderer.tsx index c4bd74e7..65fc2207 100644 --- a/packages/ui/components/BlockRenderer.tsx +++ b/packages/ui/components/BlockRenderer.tsx @@ -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 ; + const anchorAliases = [ + headingAnchorId, + ...getHeadingAnchorAliases(block.content), + ].filter(Boolean); + const anchorAliasTokens = anchorAliases.length > 0 + ? [...new Set(anchorAliases)].join(' ') + : undefined; + + return ( + + + + ); } 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) => (

0 ? 'mt-2' : ''}> - +

))} @@ -83,7 +104,7 @@ export const BlockRenderer: React.FC<{ onToggle={isInteractive ? () => onToggleCheckbox!(block.id, !isChecked) : undefined} /> - + ); @@ -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} > - +

); } diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 797ff3e3..d76e992e 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -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} -/> + /> , ); 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} + /> , ); @@ -219,8 +222,9 @@ export const InlineMarkdown: React.FC<{ onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} + onNavigateAnchor={onNavigateAnchor} githubRepo={githubRepo} -/> + /> , ); 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} -/> + /> , ); 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} -/> + /> , ); 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( + { + e.preventDefault(); + onNavigateAnchor(safeLinkUrl); + } : undefined} + className="text-primary underline underline-offset-2 hover:text-primary/80" + > + {linkText} + , + ); + } else if (isLocalDoc && onOpenLinkedDoc) { parts.push( , ); diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 601feef4..63e7ce1f 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -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(({ }, 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(null); const handleCopyPlan = async () => { @@ -200,6 +202,7 @@ export const Viewer = forwardRef(({ } | null>(null); const hoverTimeoutRef = useRef(null); const stickySentinelRef = useRef(null); + const lastAutoScrolledHashRef = useRef(null); const [isStuck, setIsStuck] = useState(false); // Shared annotation infrastructure via hook @@ -294,6 +297,61 @@ export const Viewer = forwardRef(({ 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('[data-anchor-aliases]')).find(element => + element.dataset.anchorAliases?.split(/\s+/).includes(anchor) + ); + + if (!target) return false; + + const stickyActionsEl = container.querySelector('[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(({ checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(block.id)} + onNavigateAnchor={scrollToAnchor} /> ))} @@ -580,6 +639,7 @@ export const Viewer = forwardRef(({ 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(({ isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id} /> ) : ( - 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)} /> + 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(({ onImageClick={(src, alt) => setLightbox({ src, alt })} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={repoInfo?.display} + onNavigateAnchor={scrollToAnchor} /> )} @@ -855,5 +916,3 @@ function groupBlocks(blocks: Block[]): RenderGroup[] { } - - diff --git a/packages/ui/components/blocks/AlertBlock.tsx b/packages/ui/components/blocks/AlertBlock.tsx index acf1a0ce..f035c82f 100644 --- a/packages/ui/components/blocks/AlertBlock.tsx +++ b/packages/ui/components/blocks/AlertBlock.tsx @@ -10,6 +10,7 @@ interface AlertBlockProps { imageBaseDir?: string; onImageClick?: (src: string, alt: string) => void; githubRepo?: string; + onNavigateAnchor?: (hash: string) => void; } const TITLE: Record = { @@ -37,7 +38,7 @@ const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => { }; export const AlertBlock: React.FC = ({ - blockId, kind, body, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo, + blockId, kind, body, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo, onNavigateAnchor, }) => { return (
= ({ {TITLE[kind]}
- {renderProseBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, githubRepo })} + {renderProseBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, onNavigateAnchor, githubRepo })} ); }; diff --git a/packages/ui/components/blocks/Callout.tsx b/packages/ui/components/blocks/Callout.tsx index 93d5c14e..4ec712bf 100644 --- a/packages/ui/components/blocks/Callout.tsx +++ b/packages/ui/components/blocks/Callout.tsx @@ -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 = ({ @@ -25,6 +26,7 @@ export const Callout: React.FC = ({ 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 = ({ imageBaseDir, onImageClick, onOpenLinkedDoc, + onNavigateAnchor, githubRepo, })} diff --git a/packages/ui/components/blocks/TableBlock.tsx b/packages/ui/components/blocks/TableBlock.tsx index 646b5682..ea861f3a 100644 --- a/packages/ui/components/blocks/TableBlock.tsx +++ b/packages/ui/components/blocks/TableBlock.tsx @@ -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 = ({ onHover, onLeave, onOpenLinkedDoc, + onNavigateAnchor, imageBaseDir, onImageClick, githubRepo, @@ -109,6 +111,7 @@ export const TableBlock: React.FC = ({ onImageClick={onImageClick} text={header} onOpenLinkedDoc={onOpenLinkedDoc} + onNavigateAnchor={onNavigateAnchor} githubRepo={githubRepo} /> @@ -125,6 +128,7 @@ export const TableBlock: React.FC = ({ onImageClick={onImageClick} text={cell} onOpenLinkedDoc={onOpenLinkedDoc} + onNavigateAnchor={onNavigateAnchor} githubRepo={githubRepo} /> diff --git a/packages/ui/components/blocks/TablePopout.tsx b/packages/ui/components/blocks/TablePopout.tsx index 6d84e6a6..a77e66a4 100644 --- a/packages/ui/components/blocks/TablePopout.tsx +++ b/packages/ui/components/blocks/TablePopout.tsx @@ -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 = ({ onClose, container, onOpenLinkedDoc, + onNavigateAnchor, imageBaseDir, onImageClick, githubRepo, @@ -80,12 +82,13 @@ const TablePopoutImpl: React.FC = ({ 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([]); const [globalFilter, setGlobalFilter] = useState(''); diff --git a/packages/ui/components/blocks/proseBody.tsx b/packages/ui/components/blocks/proseBody.tsx index 50e52b5a..37f2c081 100644 --- a/packages/ui/components/blocks/proseBody.tsx +++ b/packages/ui/components/blocks/proseBody.tsx @@ -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} /> ); diff --git a/packages/ui/utils/anchors.test.ts b/packages/ui/utils/anchors.test.ts new file mode 100644 index 00000000..ce092b8e --- /dev/null +++ b/packages/ui/utils/anchors.test.ts @@ -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', + ]); + }); +}); diff --git a/packages/ui/utils/anchors.ts b/packages/ui/utils/anchors.ts new file mode 100644 index 00000000..b916b76c --- /dev/null +++ b/packages/ui/utils/anchors.ts @@ -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)]; +}