Files
backnotprop__plannotator/packages/ui/utils/blockTargeting.ts
T
Michael Ramos c08b188812 perf(ui): single Shiki highlighter, palette-matched code blocks, drop highlight.js (#1218)
* perf(build): stub out the dead Oniguruma WASM in every bundle

@pierre/diffs picks its Shiki engine with a runtime ternary:

    engine: preferredHighlighter === "shiki-wasm"
      ? createOnigurumaEngine(import("shiki/wasm"))
      : createJavaScriptRegexEngine()

Plannotator pins `preferredHighlighter: 'shiki-js'` (and Pierre's own
default is 'shiki-js'), so the Oniguruma branch never executes. Because
the choice is a runtime ternary, bundlers keep the `import("shiki/wasm")`
edge anyway and inline `@shikijs/engine-oniguruma/wasm-inlined`, a
~622 KB base64 blob, into the single-file HTML builds. The review app
paid for it twice: once on the main thread (via
`highlighter/shared_highlighter.js`) and once inside the `?worker&inline`
Pierre worker.

Alias `shiki/wasm` to a stub that throws if it is ever reached. Wired via
`resolve.alias` rather than a plugin because `resolve.alias` is shared
with Vite's worker build and `plugins` are not.

Highlighting output is unchanged: the JS regex engine was already the one
doing the work. Opting back into 'shiki-wasm' now fails loudly instead of
silently costing every user a megabyte of dead bytes.

    apps/review/dist/index.html  19,424,646 -> 18,180,545  (-1,244,101 raw / -463,348 gzip)
    apps/hook/dist/index.html    23,032,467 -> 22,410,416    (-622,051 raw / -233,485 gzip)

* perf(ui): consolidate code highlighting onto Shiki, drop highlight.js

The app shipped two highlighters. Shiki already tokenised the code-review
diff pane (via @pierre/diffs, JavaScript regex engine); highlight.js
separately coloured markdown fences and review suggestion snippets at
~982 KB minified for a full build of ~190 grammars. That second
highlighter is now gone.

Every call site moves onto `packages/ui/utils/codeHighlight.ts`, a thin
wrapper over Pierre's SHARED Shiki instance:

  CodeBlock, Viewer, PlanCleanDiffView   markdown fences
  InlineMarkdown                          code-file hover preview
  HighlightedCode                         review suggestion snippets

Reusing Pierre's instance rather than standing up a second fine-grained
one is deliberate. Pierre imports Shiki's full bundle, so every grammar
and theme is ALREADY inlined in the single-file builds: a separate
highlighter with a curated language list would have duplicated a subset
of bytes that are already there. Sharing costs nothing, gives every
language Shiki bundles instead of a shortlist, and — the point of the
change — guarantees fences resolve the exact same theme the diff pane
resolves.

Theming. `SHIKI_THEME_MAP` / `resolveSyntaxTheme` move from
`packages/review-editor/hooks/usePierreTheme.ts` to
`packages/ui/utils/syntaxTheme.ts`; usePierreTheme re-exports them, so
the review editor's imports are unchanged. `useFenceTheme()` feeds the
components and re-highlights on palette or mode change. Code blocks now
follow the active palette across all ~52 themes in both light and dark,
instead of always rendering github-dark and relying on hand-written
`.hljs-*` override stacks to stay legible. Those stacks are deleted:
`packages/editor/index.css`'s light-mode token palette, and
`colorblind.css`'s hand-tuned tokens which existed to APPROXIMATE
@pierre/theme's protanopia-deuteranopia themes that are now simply used.

Behaviour held fixed:

  - Language-less fences stay plain text (#1212). No auto-detection
    anywhere, including the hover preview, which previously called
    `hljs.highlightAuto`. `HighlightedCode` derives its language from
    the caller's file path; an unknown extension renders plain.
  - `applyHighlight(el, ...)` keeps the imperative `hljs.highlightElement`
    DOM contract the annotation layer reaches into, and writes plain text
    at final size first so async highlighting causes no layout shift.
    Already-attached grammars highlight synchronously — no flicker on
    cached highlights.
  - It also verifies the rendered text is byte-identical to the source
    and falls back to plain otherwise, because annotations address code
    blocks by text offset.
  - `@plannotator/ui`'s public API is unchanged: the highlighter is a
    module-level default like the package's other seams, no new props.

The `hljs` class on fenced `<code>` becomes `pn-code` (it is a
structural hook for blockTargeting, vim navigation and print.css, and it
named a library we no longer ship). `language-*` stays.

    apps/review/dist/index.html  18,180,545 -> 17,270,889  (-909,656 raw / -291,921 gzip)
    apps/hook/dist/index.html    22,410,416 -> 21,704,434  (-705,982 raw / -238,096 gzip)

Verified the diff pane is untouched: the rendered Pierre shadow-DOM
markup is byte-for-byte identical between an origin/main build and this
one (SHA-256 aa1ee88a…).

* fix(ui): strip stray NUL bytes from the code-highlight source

Two U+0000 bytes slipped into comments in the previous commit, which made
git treat the file as binary. Replaced with spaces; no behaviour change.

* fix(ui): keep code-block annotation marks across highlight swaps

Fenced code is annotated by hand: one `<mark data-bind-id>` inside the
`<code>` element, which `applyHighlight` also owns. Every highlight swap
(palette change, dark/light toggle, or the first async grammar attach
after load) replaces that element's children, so the mark was silently
wiped and nothing put it back. Annotation state, the sidebar panel and
exports were unaffected; the loss was purely visual, and deterministic.

`applyHighlight` now publishes every write through `onCodeHighlightSwap`,
synchronously, immediately after it. `Viewer` subscribes and re-paints the
fence's mark, so a swapped block ends up with BOTH the new theme's tokens
and its annotation. The shared painter (`paintCodeBlockMark`) moves the
token spans into the mark instead of flattening them to text, so creating
an annotation no longer costs a block its colours either.

Being driven by the swap also fixes the cousin race by ordering rather
than timing: share/draft restore runs on a timer after load, and on a slow
machine the first async swap could land after it and wipe the restored
marks per block. A restore that painted before the swap is now
re-established in the same task the swap ran in, and one that runs after
finds the mark already there.

Removal tombstones the id before re-highlighting, because the host drops
the annotation from state a tick later — without it the swap listener
would paint the just-removed annotation back in, and a fence carrying a
second annotation would end up bare.

Also closes the named gap in the WASM coverage: entry-assets only grepped
source, so a future @pierre/diffs bump could reintroduce the inlined blob
through a different import specifier unnoticed. It now greps the built
`apps/{review,hook}/dist/index.html` for the base64 WASM magic, skipping
on an unbuilt checkout and running for real in the CI job that builds the
bundles.
2026-08-05 21:54:40 -07:00

525 lines
17 KiB
TypeScript

/**
* Semantic document targeting shared by pointer Pinpoint and Vim navigation.
*
* The graph is rebuilt from the live rendered document whenever a consumer
* needs it. Callers persist stable keys, never DOM nodes, across renders.
*/
import { createTextRange } from './domSelection';
/** Elements that never participate in document targeting. */
const SKIP_SELECTORS = [
'.annotation-toolbar',
'.annotation-highlight',
'mark[data-bind-id]',
'button',
'[data-pinpoint-ignore]',
].join(',');
const INLINE_TARGET_SELECTOR = 'strong,em,a,code:not(.pn-code)';
const TABLE_EDGE_ZONE = 22;
/** The semantic kind of a document target. */
export type SemanticTargetKind =
| 'group'
| 'block'
| 'inline'
| 'table'
| 'row'
| 'cell'
| 'code'
| 'math';
/** A stable semantic target resolved to its current live DOM element. */
export interface SemanticTarget {
readonly key: string;
readonly blockId: string;
readonly element: HTMLElement;
readonly label: string;
readonly kind: SemanticTargetKind;
readonly parentKey: string | null;
readonly rowIndex?: number;
readonly columnIndex?: number;
}
/**
* One projection of the rendered document used by pointer hit-testing,
* keyboard traversal, hierarchy refinement, overlays, and annotation actions.
*/
export interface SemanticTargetGraph {
readonly container: HTMLElement;
readonly targets: readonly SemanticTarget[];
readonly byKey: ReadonlyMap<string, SemanticTarget>;
readonly byElement: ReadonlyMap<HTMLElement, SemanticTarget>;
/** One entry per rendered Markdown block, in document order. */
readonly blockKeys: readonly string[];
}
/** Motions available while navigating the semantic target graph. */
export type SemanticTargetMotion =
| 'previous-block'
| 'next-block'
| 'previous-sibling'
| 'next-sibling'
| 'parent'
| 'child'
| 'first-block'
| 'last-block';
/** Pointer coordinates used for table edge-zone targeting. */
export interface SemanticPointerPosition {
readonly clientX: number;
readonly clientY: number;
}
function getBlockElements(container: HTMLElement): HTMLElement[] {
const seen = new Set<string>();
const result: HTMLElement[] = [];
container.querySelectorAll<HTMLElement>('[data-block-id]').forEach((element) => {
const blockId = element.dataset.blockId;
if (!blockId || seen.has(blockId) || element.tagName === 'HR') return;
seen.add(blockId);
result.push(element);
});
return result;
}
function truncate(text: string, max: number): string {
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function inlineLabel(element: HTMLElement): string {
const text = element.textContent?.trim() ?? '';
const excerpt = truncate(text, 30);
if (element.tagName === 'STRONG') return `bold: "${excerpt}"`;
if (element.tagName === 'EM') return `italic: "${excerpt}"`;
if (element.tagName === 'A') return `link: "${truncate(text, 25)}"`;
return element.tagName === 'CODE' ? `code: \`${excerpt}\`` : excerpt;
}
function blockLabel(element: HTMLElement, listItem: boolean): string {
const text = element.textContent?.trim() ?? '';
const tag = element.tagName.toLowerCase();
if (listItem) {
return text ? `list item: "${truncate(text, 30)}"` : 'list item';
}
if (element.dataset.blockType === 'heading' || /^h[1-6]$/.test(tag)) {
return `heading: "${truncate(text, 35)}"`;
}
if (tag === 'blockquote') return `blockquote: "${truncate(text, 30)}"`;
return text ? `paragraph: "${truncate(text, 35)}"` : tag;
}
function codeBlockLabel(block: HTMLElement): string {
const code = block.querySelector('code');
const language = code?.className.match(/language-(\S+)/)?.[1];
return language ? `code block (${language})` : 'code block';
}
function groupKey(group: HTMLElement): string {
const type = group.dataset.pinpointGroup ?? 'group';
const ids = Array.from(group.querySelectorAll<HTMLElement>('[data-block-id]'))
.map((element) => element.dataset.blockId)
.filter((id): id is string => Boolean(id));
return `group:${type}:${ids[0] ?? 'empty'}:${ids.at(-1) ?? 'empty'}`;
}
function groupLabel(group: HTMLElement): string {
if (group.dataset.pinpointGroup === 'list') return 'list';
if (group.dataset.pinpointGroup === 'blockquote') return 'blockquote group';
return 'group';
}
function listContentElement(block: HTMLElement): HTMLElement | null {
if (!block.querySelector('.select-none')) return null;
return block.children[1] instanceof HTMLElement ? block.children[1] : null;
}
function addInlineTargets(
targets: SemanticTarget[],
byElement: Map<HTMLElement, SemanticTarget>,
blockId: string,
parent: SemanticTarget,
root: HTMLElement,
keyPrefix: string,
): void {
const elements = Array.from(root.querySelectorAll<HTMLElement>(INLINE_TARGET_SELECTOR))
.filter((element) => element.textContent?.trim() && !element.closest(SKIP_SELECTORS));
elements.forEach((element, index) => {
const ancestorElement = element.parentElement?.closest<HTMLElement>(INLINE_TARGET_SELECTOR);
const semanticParent = ancestorElement && root.contains(ancestorElement)
? byElement.get(ancestorElement) ?? parent
: parent;
const target: SemanticTarget = {
key: `${keyPrefix}:inline:${index}`,
blockId,
element,
label: inlineLabel(element),
kind: 'inline',
parentKey: semanticParent.key,
};
targets.push(target);
byElement.set(element, target);
});
}
/**
* Build the canonical semantic target graph for a rendered Markdown document.
*
* Each `[data-block-id]` contributes exactly one block-navigation entry.
* Groups, table rows/cells, and inline formatting become hierarchy nodes.
*/
export function buildSemanticTargetGraph(container: HTMLElement): SemanticTargetGraph {
const targets: SemanticTarget[] = [];
const byElement = new Map<HTMLElement, SemanticTarget>();
const blockKeys: string[] = [];
const groupTargets = new Map<HTMLElement, SemanticTarget>();
container.querySelectorAll<HTMLElement>('[data-pinpoint-group]').forEach((group) => {
const firstBlockId = group.querySelector<HTMLElement>('[data-block-id]')?.dataset.blockId;
const target: SemanticTarget = {
key: groupKey(group),
blockId: firstBlockId ?? '',
element: group,
label: groupLabel(group),
kind: 'group',
parentKey: null,
};
targets.push(target);
byElement.set(group, target);
groupTargets.set(group, target);
});
for (const block of getBlockElements(container)) {
const blockId = block.dataset.blockId;
if (!blockId) continue;
const group = block.closest<HTMLElement>('[data-pinpoint-group]');
const parentKey = group ? groupTargets.get(group)?.key ?? null : null;
const codeElement = block.querySelector<HTMLElement>('pre > code.pn-code');
const mathElement = block.matches('.math-annotatable,[data-math-tex]')
? block
: block.querySelector<HTMLElement>('.math-annotatable,[data-math-tex]');
const table = block.querySelector<HTMLTableElement>('table');
if (codeElement) {
const target: SemanticTarget = {
key: `${blockId}:code`,
blockId,
element: block,
label: codeBlockLabel(block),
kind: 'code',
parentKey,
};
targets.push(target);
byElement.set(block, target);
blockKeys.push(target.key);
continue;
}
if (mathElement) {
const target: SemanticTarget = {
key: `${blockId}:math`,
blockId,
element: mathElement,
label: 'formula',
kind: 'math',
parentKey,
};
targets.push(target);
byElement.set(mathElement, target);
blockKeys.push(target.key);
continue;
}
if (table) {
const tableTarget: SemanticTarget = {
key: `${blockId}:table`,
blockId,
element: block,
label: 'table',
kind: 'table',
parentKey,
};
targets.push(tableTarget);
byElement.set(block, tableTarget);
blockKeys.push(tableTarget.key);
Array.from(table.rows).forEach((row, rowIndex) => {
const rowTarget: SemanticTarget = {
key: `${blockId}:row:${rowIndex}`,
blockId,
element: row,
label: rowIndex === 0 ? 'table header row' : `table row ${rowIndex}`,
kind: 'row',
parentKey: tableTarget.key,
rowIndex,
};
targets.push(rowTarget);
byElement.set(row, rowTarget);
Array.from(row.cells).forEach((cell, columnIndex) => {
const cellTarget: SemanticTarget = {
key: `${blockId}:cell:${rowIndex}:${columnIndex}`,
blockId,
element: cell,
label: `table cell ${rowIndex + 1}, ${columnIndex + 1}`,
kind: 'cell',
parentKey: rowTarget.key,
rowIndex,
columnIndex,
};
targets.push(cellTarget);
byElement.set(cell, cellTarget);
addInlineTargets(
targets,
byElement,
blockId,
cellTarget,
cell,
cellTarget.key,
);
});
});
continue;
}
const listContent = listContentElement(block);
const primaryElement = listContent ?? block;
const blockTarget: SemanticTarget = {
key: `${blockId}:block`,
blockId,
element: primaryElement,
label: blockLabel(primaryElement, listContent !== null),
kind: 'block',
parentKey,
};
targets.push(blockTarget);
byElement.set(primaryElement, blockTarget);
blockKeys.push(blockTarget.key);
addInlineTargets(targets, byElement, blockId, blockTarget, primaryElement, blockId);
}
return {
container,
targets,
byKey: new Map(targets.map((target) => [target.key, target])),
byElement,
blockKeys,
};
}
/** Resolve a stable target key against a freshly built graph. */
export function resolveSemanticTarget(
graph: SemanticTargetGraph,
key: string | null,
): SemanticTarget | null {
return key ? graph.byKey.get(key) ?? null : null;
}
/**
* Create the annotation range owned by a semantic target.
*
* Code and math targets use their existing specialized annotation paths;
* every text-bearing graph node resolves through this one range seam.
*/
export function createSemanticTargetRange(target: SemanticTarget): Range | null {
return target.kind === 'code' || target.kind === 'math'
? null
: createTextRange(target.element);
}
/** Return the direct semantic children of a target in document order. */
export function getSemanticTargetChildren(
graph: SemanticTargetGraph,
target: SemanticTarget,
): readonly SemanticTarget[] {
return graph.targets.filter((candidate) => candidate.parentKey === target.key);
}
/** Return the block-navigation target that owns a nested semantic target. */
export function getOwningBlockTarget(
graph: SemanticTargetGraph,
target: SemanticTarget,
): SemanticTarget {
let current = target;
while (!graph.blockKeys.includes(current.key) && current.parentKey) {
const parent = resolveSemanticTarget(graph, current.parentKey);
if (!parent) break;
current = parent;
}
if (graph.blockKeys.includes(current.key)) return current;
return graph.blockKeys
.map((key) => resolveSemanticTarget(graph, key))
.find((candidate) => candidate?.blockId === target.blockId)
?? target;
}
/** Pick the block nearest the visible center of the document viewport. */
export function findInitialSemanticTarget(
graph: SemanticTargetGraph,
): SemanticTarget | null {
const viewport = graph.container.closest<HTMLElement>('[data-overlayscrollbars-viewport]');
const viewportRect = (viewport ?? graph.container).getBoundingClientRect();
const centerY = viewportRect.top + viewportRect.height / 2;
return graph.blockKeys
.map((key) => resolveSemanticTarget(graph, key))
.filter((target): target is SemanticTarget => target !== null)
.sort((left, right) => {
const leftRect = left.element.getBoundingClientRect();
const rightRect = right.element.getBoundingClientRect();
return Math.abs((leftRect.top + leftRect.bottom) / 2 - centerY)
- Math.abs((rightRect.top + rightRect.bottom) / 2 - centerY);
})[0] ?? null;
}
/**
* Move through block order, sibling order, or one hierarchy level.
*/
export function moveSemanticTarget(
graph: SemanticTargetGraph,
current: SemanticTarget,
motion: SemanticTargetMotion,
): SemanticTarget {
if (motion === 'parent') {
return resolveSemanticTarget(graph, current.parentKey) ?? current;
}
if (motion === 'child') {
return getSemanticTargetChildren(graph, current)[0] ?? current;
}
if (motion === 'first-block') {
return resolveSemanticTarget(graph, graph.blockKeys[0] ?? null) ?? current;
}
if (motion === 'last-block') {
return resolveSemanticTarget(graph, graph.blockKeys.at(-1) ?? null) ?? current;
}
if (motion === 'previous-sibling' || motion === 'next-sibling') {
if (!current.parentKey) return current;
const parent = resolveSemanticTarget(graph, current.parentKey);
if (!parent) return current;
const siblings = getSemanticTargetChildren(graph, parent);
const index = siblings.findIndex((candidate) => candidate.key === current.key);
if (index < 0) return current;
const delta = motion === 'previous-sibling' ? -1 : 1;
const nextIndex = Math.max(0, Math.min(siblings.length - 1, index + delta));
return siblings[nextIndex] ?? current;
}
const delta: -1 | 1 = motion === 'previous-block' ? -1 : 1;
const block = getOwningBlockTarget(graph, current);
const index = graph.blockKeys.indexOf(block.key);
if (index < 0) return current;
const nextIndex = Math.max(0, Math.min(graph.blockKeys.length - 1, index + delta));
return resolveSemanticTarget(graph, graph.blockKeys[nextIndex] ?? null) ?? current;
}
function targetForBlock(graph: SemanticTargetGraph, block: HTMLElement): SemanticTarget | null {
const blockId = block.dataset.blockId;
if (!blockId) return null;
return graph.blockKeys
.map((key) => resolveSemanticTarget(graph, key))
.find((target) => target?.blockId === blockId)
?? null;
}
function rowAtY(table: HTMLTableElement, clientY: number): HTMLTableRowElement | null {
return Array.from(table.rows).find((row) => {
const rect = row.getBoundingClientRect();
return clientY >= rect.top && clientY <= rect.bottom;
}) ?? null;
}
/**
* Resolve the pointer's semantic target from the same graph used by keyboard
* navigation. Table edge zones select table/row scope; content selects cells.
*/
export function resolveSemanticTargetAtPoint(
graph: SemanticTargetGraph,
pointerTarget: HTMLElement,
pointer?: SemanticPointerPosition,
): SemanticTarget | null {
if (pointerTarget.closest(SKIP_SELECTORS)) return null;
if (!graph.container.contains(pointerTarget)) return null;
const group = pointerTarget.closest<HTMLElement>('[data-pinpoint-group]');
if (group && !pointerTarget.closest('[data-block-id]')) {
return graph.byElement.get(group) ?? null;
}
const block = pointerTarget.closest<HTMLElement>('[data-block-id]');
if (!block || !graph.container.contains(block) || block.tagName === 'HR') return null;
const blockTarget = targetForBlock(graph, block);
if (!blockTarget) return null;
const code = block.querySelector<HTMLElement>('pre > code.pn-code');
if (
code
&& (pointerTarget === code || code.contains(pointerTarget) || pointerTarget.closest('pre'))
) {
return blockTarget;
}
const table = block.querySelector<HTMLTableElement>('table');
if (table && pointer) {
const rect = table.getBoundingClientRect();
const nearHorizontalEdge = pointer.clientX - rect.left < TABLE_EDGE_ZONE
|| rect.right - pointer.clientX < TABLE_EDGE_ZONE;
const nearVerticalEdge = pointer.clientY - rect.top < TABLE_EDGE_ZONE
|| rect.bottom - pointer.clientY < TABLE_EDGE_ZONE;
if (nearVerticalEdge) return blockTarget;
if (nearHorizontalEdge) {
const row = rowAtY(table, pointer.clientY);
return row ? graph.byElement.get(row) ?? blockTarget : blockTarget;
}
}
const inline = pointerTarget.closest<HTMLElement>(INLINE_TARGET_SELECTOR);
if (inline && block.contains(inline)) {
const inlineTarget = graph.byElement.get(inline);
if (inlineTarget) return inlineTarget;
}
const cell = pointerTarget.closest<HTMLTableCellElement>('td,th');
if (cell && block.contains(cell)) {
return graph.byElement.get(cell) ?? blockTarget;
}
return blockTarget;
}
/**
* Backward-compatible pointer result used by existing Pinpoint consumers.
*
* New code should retain the semantic target itself so pointer and keyboard
* paths share its stable key and hierarchy.
*/
export interface PinpointTarget {
readonly element: HTMLElement;
readonly blockId: string;
readonly label: string;
readonly isCodeBlock: boolean;
}
/** Resolve a pointer target through the canonical semantic graph. */
export function resolvePinpointTarget(
target: HTMLElement,
container: HTMLElement,
pointer?: SemanticPointerPosition,
): PinpointTarget | null {
const semantic = resolveSemanticTargetAtPoint(
buildSemanticTargetGraph(container),
target,
pointer,
);
return semantic
? {
element: semantic.element,
blockId: semantic.blockId,
label: semantic.label,
isCodeBlock: semantic.kind === 'code',
}
: null;
}