Files
backnotprop__plannotator/packages/ui/utils/anchors.ts
dgrissen2 5a41a21861 fix(ui): in-page anchor navigation for headings (#605)
* 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.

* refactor(ui): drop unjustified slug aliasing

Anchor navigation didn't work before this branch, so no shared URLs exist
using any slug format — the legacy slug alias was preserving nothing.

The canonical slug was also a redefinition: heading ids are already
written by `utils/slugify.ts` (unicode-aware, `\p{L}\p{N}`). The new
ASCII-only `slugifyHeadingAnchor` would miss unicode headings ("Café"
writes `id="café"` but the nav code looked for `cafe`).

Nav path is now just: decode the hash and `getElementById`. The
sticky-header offset math — the part that actually mattered — stays.

* fix(ui): anchor-nav edge cases — share-hash collision and raw HTML blocks

Two issues flagged by cross-model review of 9978a40:

1. `useSharing` treated any non-empty `window.location.hash` as a share
   payload. Plain anchor hashes like `#section-overview` fell into
   `parseShareHash`, failed decompression, and popped the "Shared Plan
   Could Not Be Loaded" dialog. Added an `isPlainAnchorHash` guard
   (lowercase ASCII + digits + hyphen) that short-circuits before the
   share path in both the initial load and the hashchange listener.
   Share payloads are base64url-encoded deflate output, so they'll
   always contain uppercase/`=`/`_` and never match the guard.

2. `HtmlBlock` (raw `<details>`, `<summary>`, etc.) skipped the new
   nav handler. `rewriteRelativeRefs` returned early for any `#` href,
   so anchors inside raw HTML fell back to native browser jump which
   doesn't target the OverlayScrollArea. Now intercepts those clicks
   and routes through `onNavigateAnchor` to match the `InlineMarkdown`
   path. Threaded the callback through `BlockRenderer`.

* fix(ui): widen share-payload detection to cover Unicode + uppercase ids

Previous guard rejected any hash that wasn't lowercase ASCII + digits +
hyphen, so Unicode heading ids (`#café`, `#中文-标题`) and raw HTML ids
(`#MySection`) still fell into `parseShareHash` and popped the shared-
plan error dialog.

Flip the predicate: a share payload is base64url (`[A-Za-z0-9_-]`),
realistically ≥30 chars, and virtually always contains at least one
uppercase letter because deflate output has high entropy. Anything else
is handed back to Viewer to scroll to (or silently ignored).

Also add tests/test-fixtures/14-anchor-links.md exercising the full
character range — ASCII, Unicode (accented, CJK, Cyrillic), numeric
start, raw HTML ids — across paragraph, list, blockquote, alert,
directive, table, and raw `<details>` contexts.
2026-04-23 13:39:22 -07:00

12 lines
272 B
TypeScript

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 {
return raw;
}
}