mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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[] {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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)];
|
||||
}
|
||||
Reference in New Issue
Block a user