@plannotator/ui 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/HANDOFF.md +140 -10
  2. package/README.md +9 -5
  3. package/components/AnnotationPanel.tsx +244 -13
  4. package/components/CommentPopover.tsx +91 -2
  5. package/components/DiagramBlock.tsx +376 -0
  6. package/components/GraphvizBlock.tsx +22 -597
  7. package/components/HtmlSurfaceControls.tsx +188 -23
  8. package/components/ListMarker.tsx +10 -1
  9. package/components/MermaidBlock.tsx +17 -630
  10. package/components/TableOfContents.tsx +5 -1
  11. package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
  12. package/components/Viewer.tsx +67 -3
  13. package/components/blocks/AlertBlock.tsx +7 -2
  14. package/components/diagram/DiagramCanvas.tsx +431 -0
  15. package/components/diagram/DiagramComposer.tsx +135 -0
  16. package/components/diagram/DiagramOverlay.tsx +215 -0
  17. package/components/diagram/DiagramPopout.tsx +70 -0
  18. package/components/diagram/DiagramSourcePane.tsx +244 -0
  19. package/components/diagram/DiagramViewer.tsx +276 -0
  20. package/components/diagram/anchorClaims.ts +71 -0
  21. package/components/diagram/index.ts +36 -0
  22. package/components/diagram/useDiagramComments.ts +341 -0
  23. package/components/diagram/useDiagramRender.ts +91 -0
  24. package/components/diagram/useDiagramSourceDraft.ts +143 -0
  25. package/components/diagram/useDiagramViewport.ts +156 -0
  26. package/components/html-viewer/HtmlViewer.tsx +32 -0
  27. package/components/html-viewer/bridge-script.asset.js +121 -9
  28. package/components/html-viewer/bridge-script.lite.ts +1 -1
  29. package/components/html-viewer/bridge-script.ts +133 -9
  30. package/components/html-viewer/useHtmlAnnotation.ts +44 -151
  31. package/hooks/useAnnotationHighlighter.ts +469 -14
  32. package/hooks/useLinkedDoc.ts +100 -9
  33. package/package.json +6 -4
  34. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
  35. package/styles.css +1 -1
  36. package/theme.css +62 -0
  37. package/types.ts +5 -0
  38. package/utils/annotationScope.ts +159 -0
  39. package/utils/cssColor.ts +463 -0
  40. package/utils/diagram-anchor-graphviz.ts +143 -0
  41. package/utils/diagram-anchor.ts +401 -0
  42. package/utils/diagram-projection.ts +66 -0
  43. package/utils/diagram-render.ts +668 -0
  44. package/utils/graphviz.ts +93 -0
  45. package/utils/htmlChrome.ts +70 -5
  46. package/utils/htmlLinkNavigation.ts +196 -0
  47. package/utils/mermaid-eager.ts +13 -11
  48. package/utils/mermaid.ts +19 -10
  49. package/utils/mermaidTheme.ts +732 -0
  50. package/utils/parser.ts +36 -7
  51. package/utils/terminalToolsAnnouncement.ts +76 -0
  52. package/components/mermaidSvg.ts +0 -33
@@ -190,18 +190,358 @@ const escapeAttrValue = (value: string): string => {
190
190
  return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
191
191
  };
192
192
 
193
- /** Whitespace-insensitive comparison for restore verification: a highlight
194
- * spanning element boundaries legitimately differs from `originalText` in
195
- * whitespace, so only content differences count as a mismatch. */
196
- const normalizeForRestoreCompare = (value: string): string =>
197
- value.replace(/\s+/g, ' ').trim();
198
-
199
193
  // web-highlighter 0.8.x accepts only class, ID, and tag exclusions.
200
194
  const ANNOTATION_EXCLUDED_SELECTOR = '.annotation-exclude';
201
195
 
202
196
  const isAnnotationExcludedTextNode = (node: Node): boolean =>
203
197
  Boolean(node.parentElement?.closest(ANNOTATION_EXCLUDED_SELECTOR));
204
198
 
199
+ /**
200
+ * Content-only comparison: the painted highlight and the browser's selection
201
+ * string legitimately differ in whitespace (`Selection.toString()` inserts a
202
+ * blank line between block elements, the wrapper `<mark>`s concatenated with
203
+ * no separator do not), so only the characters themselves are compared.
204
+ *
205
+ * Used by BOTH the quote repair below and the restore verification, which
206
+ * compares `originalText` against the text the stored positions actually
207
+ * painted. Whitespace must be REMOVED there rather than collapsed: a quote
208
+ * spanning two blocks carries the browser's "\n\n" where the painted marks
209
+ * carry nothing at all, so collapsing to a single space rejects every correct
210
+ * cross-block restore. Content drift — the case that verification exists for
211
+ * (#1509) — still differs once whitespace is gone.
212
+ */
213
+ const compactText = (value: string): string => value.replace(/\s+/g, '');
214
+
215
+ /**
216
+ * The text a set of painted highlight wrappers shows the reader.
217
+ *
218
+ * Not `textContent`: a wrapper can legitimately contain `.annotation-exclude`
219
+ * chrome (a list marker the selection crossed, an alert's visually hidden type
220
+ * word), and that chrome is in neither the browser's selection string nor the
221
+ * quote derived from it. Comparing raw `textContent` against `originalText`
222
+ * therefore rejected correct restores over anything non-selectable.
223
+ */
224
+ const paintedTextOf = (doms: readonly HTMLElement[]): string => {
225
+ let painted = '';
226
+ for (const dom of doms) {
227
+ if (!dom) continue;
228
+ if (dom.closest?.(ANNOTATION_EXCLUDED_SELECTOR)) continue;
229
+ const walker = document.createTreeWalker(dom, NodeFilter.SHOW_TEXT);
230
+ let node: Node | null;
231
+ while ((node = walker.nextNode())) {
232
+ if (isAnnotationExcludedTextNode(node)) continue;
233
+ painted += node.textContent ?? '';
234
+ }
235
+ }
236
+ return painted;
237
+ };
238
+
239
+ /**
240
+ * Tags whose boxes a browser separates with a line break in a selection string.
241
+ *
242
+ * Read by {@link blockBoundaryOffsets} only; a computed-style check would be
243
+ * more precise but is unavailable under the test DOM and would cost a layout
244
+ * read per text node on every restore.
245
+ */
246
+ const BLOCK_LEVEL_TAGS = new Set([
247
+ 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'BODY', 'DD', 'DETAILS', 'DIALOG',
248
+ 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM',
249
+ 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HGROUP', 'HR', 'LI', 'MAIN',
250
+ 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'SUMMARY', 'TABLE', 'TBODY', 'TD',
251
+ 'TFOOT', 'TH', 'THEAD', 'TR', 'UL',
252
+ ]);
253
+
254
+ const nearestBlockAncestor = (node: Node): Element | null => {
255
+ let element = node.parentElement;
256
+ while (element) {
257
+ if (BLOCK_LEVEL_TAGS.has(element.tagName)) return element;
258
+ element = element.parentElement;
259
+ }
260
+ return null;
261
+ };
262
+
263
+ /**
264
+ * Offsets in the concatenated text stream at which a new block box starts.
265
+ *
266
+ * The document's text nodes are joined with nothing between them, but the
267
+ * browser's selection string puts a blank line between two block elements —
268
+ * so a quote spanning two blocks carries whitespace the search stream does
269
+ * not, and the whitespace-collapsing fallback could never match it. Every
270
+ * cross-block annotation therefore lost its highlight the moment it had to
271
+ * fall back to text search (which is every one of them after an Edit Mode
272
+ * commit, where `applyEditedDocument` strips the stored positions of any
273
+ * annotation whose quote is not contained in one block).
274
+ *
275
+ * Reported as offsets rather than inserted into the stream so the existing
276
+ * offset-to-node mapping keeps working untouched.
277
+ */
278
+ const blockBoundaryOffsets = (textNodes: readonly Text[]): Set<number> => {
279
+ const boundaries = new Set<number>();
280
+ let offset = 0;
281
+ let previousBlock: Element | null = null;
282
+ let seenAny = false;
283
+ for (const node of textNodes) {
284
+ const block = nearestBlockAncestor(node);
285
+ if (seenAny && block !== previousBlock && offset > 0) boundaries.add(offset);
286
+ previousBlock = block;
287
+ seenAny = true;
288
+ offset += node.textContent?.length ?? 0;
289
+ }
290
+ return boundaries;
291
+ };
292
+
293
+ /** The node a range's start boundary actually addresses: an element boundary
294
+ * addresses the child at its offset, which is where web-highlighter descends
295
+ * (`formatDomNode`). */
296
+ const startBoundaryNode = (range: Range): Node => {
297
+ const { startContainer, startOffset } = range;
298
+ if (startContainer.nodeType === Node.ELEMENT_NODE) {
299
+ return startContainer.childNodes[startOffset] ?? startContainer;
300
+ }
301
+ return startContainer;
302
+ };
303
+
304
+ const excludedAncestor = (node: Node | null): HTMLElement | null => {
305
+ if (!node) return null;
306
+ const element = node.nodeType === Node.ELEMENT_NODE
307
+ ? (node as HTMLElement)
308
+ : node.parentElement;
309
+ return element?.closest<HTMLElement>(ANNOTATION_EXCLUDED_SELECTOR) ?? null;
310
+ };
311
+
312
+ /**
313
+ * Move a range's start off any `.annotation-exclude` subtree it begins inside,
314
+ * onto the first annotatable text position the range covers.
315
+ *
316
+ * web-highlighter never ENTERS an excluded subtree (`painter/dom.ts` skips it
317
+ * before the "are we at the start node" check), so a range that starts inside
318
+ * one never flips its in-selection flag: every intermediate run is dropped and
319
+ * only the trailing text node is painted. A drag from a GitHub alert's icon —
320
+ * where the visually hidden "Tip: " lives — through the alert body therefore
321
+ * highlighted the body alone and left the title unpainted, while the quote
322
+ * still carried the invisible word. Snapping fixes both at once: the painted
323
+ * extent covers what the reviewer dragged over, and the quote (which web-
324
+ * highlighter derives from this same range/selection) no longer contains the
325
+ * hidden chrome.
326
+ *
327
+ * Snapping is by NODE IDENTITY, never by matching text. Returns whether the
328
+ * range was changed.
329
+ */
330
+ const snapRangeStartPastExcluded = (range: Range): boolean => {
331
+ const excluded = excludedAncestor(startBoundaryNode(range));
332
+ if (!excluded) return false;
333
+
334
+ const scopeNode = range.commonAncestorContainer;
335
+ const scope = scopeNode.nodeType === Node.ELEMENT_NODE
336
+ ? (scopeNode as Element)
337
+ : scopeNode.parentElement;
338
+ // A range that lies entirely inside the excluded subtree has nothing to snap
339
+ // to; leave it alone (it paints nothing, exactly as before).
340
+ if (!scope || excluded.contains(scope)) return false;
341
+
342
+ const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT);
343
+ let node: Node | null;
344
+ while ((node = walker.nextNode())) {
345
+ const text = node as Text;
346
+ if (!text.length) continue;
347
+ // Skip everything at or before the excluded subtree, and any other
348
+ // excluded run that follows it.
349
+ if (excluded.contains(text)) continue;
350
+ const position = excluded.compareDocumentPosition(text);
351
+ if (!(position & Node.DOCUMENT_POSITION_FOLLOWING)) continue;
352
+ if (isAnnotationExcludedTextNode(text)) continue;
353
+ // Never past the range's own end.
354
+ if (text === range.endContainer) {
355
+ if (range.endOffset === 0) return false;
356
+ range.setStart(text, 0);
357
+ return true;
358
+ }
359
+ const toEnd = text.compareDocumentPosition(range.endContainer);
360
+ if (!(toEnd & Node.DOCUMENT_POSITION_FOLLOWING)) return false;
361
+ range.setStart(text, 0);
362
+ return true;
363
+ }
364
+ return false;
365
+ };
366
+
367
+ /** A resolved restore boundary: web-highlighter's own `DomNode` shape. */
368
+ interface RestoreBoundary {
369
+ $node: Node;
370
+ offset: number;
371
+ }
372
+
373
+ /** The nearest text node on one side of `node` that annotation painting can
374
+ * reach, skipping every `.annotation-exclude` run. */
375
+ const annotatableTextNeighbour = (
376
+ root: Element,
377
+ node: Node,
378
+ direction: 1 | -1,
379
+ ): Text | null => {
380
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
381
+ const all: Text[] = [];
382
+ let current: Node | null;
383
+ while ((current = walker.nextNode())) all.push(current as Text);
384
+
385
+ const index = all.indexOf(node as Text);
386
+ if (index < 0) return null;
387
+ for (let i = index + direction; i >= 0 && i < all.length; i += direction) {
388
+ const text = all[i]!;
389
+ if (!text.length) continue;
390
+ if (isAnnotationExcludedTextNode(text)) continue;
391
+ return text;
392
+ }
393
+ return null;
394
+ };
395
+
396
+ /**
397
+ * Move a RESTORED boundary off any `.annotation-exclude` subtree it resolved
398
+ * into, the same snap {@link snapRangeStartPastExcluded} applies to a live
399
+ * selection.
400
+ *
401
+ * A stored `textOffset` counts every text node under the recorded parent,
402
+ * excluded chrome included — and so does the resolver that reads it back
403
+ * (`getTextChildByOffset`), which resolves a boundary sitting exactly at the
404
+ * end of one text node onto THAT node rather than the start of the next. A
405
+ * drag from a GitHub alert's icon therefore stores a start of 5, the length of
406
+ * the hidden "Tip: ", and restores onto the hidden span — where painting never
407
+ * enters, so every run before the last one was dropped, the verification
408
+ * rejected what was left, and the text search could not bridge the title into
409
+ * the body either. The annotation came back from its own draft unpainted.
410
+ *
411
+ * Normalizing the stored metas at creation time instead is not available: they
412
+ * are only meaningful in the resolver's own coordinates, which count the
413
+ * excluded text. Snapping on restore also covers every draft already on disk.
414
+ */
415
+ const snapRestoredBoundary = (
416
+ root: Element,
417
+ boundary: RestoreBoundary,
418
+ direction: 1 | -1,
419
+ ): RestoreBoundary => {
420
+ const node = boundary.$node;
421
+ if (!node || node.nodeType !== Node.TEXT_NODE) return boundary;
422
+ if (!isAnnotationExcludedTextNode(node)) return boundary;
423
+ const neighbour = annotatableTextNeighbour(root, node, direction);
424
+ if (!neighbour) return boundary;
425
+ return { $node: neighbour, offset: direction === 1 ? 0 : neighbour.length };
426
+ };
427
+
428
+ /** Whether a snapped pair would describe a backwards range. */
429
+ const restoreBoundariesCross = (start: RestoreBoundary, end: RestoreBoundary): boolean => {
430
+ if (start.$node === end.$node) return start.offset > end.offset;
431
+ const position = start.$node.compareDocumentPosition(end.$node);
432
+ return !(position & Node.DOCUMENT_POSITION_FOLLOWING);
433
+ };
434
+
435
+ /** One clipped text run of a range, and whether it is excluded chrome. */
436
+ interface RangeTextPiece {
437
+ text: string;
438
+ excluded: boolean;
439
+ }
440
+
441
+ /**
442
+ * The range's own text runs in document order, clipped to its boundaries.
443
+ *
444
+ * Read BEFORE the highlight is painted: painting splits and re-parents text
445
+ * nodes, which leaves the range's boundaries stale. The runs are plain strings
446
+ * and survive that.
447
+ *
448
+ * Returns null for a shape this cannot read (an element-node boundary, or an
449
+ * end boundary the walk never reaches), so callers leave the quote alone.
450
+ */
451
+ const rangeTextPieces = (range: Range): RangeTextPiece[] | null => {
452
+ const { startContainer, endContainer, startOffset, endOffset } = range;
453
+ if (startContainer?.nodeType !== Node.TEXT_NODE) return null;
454
+ if (endContainer?.nodeType !== Node.TEXT_NODE) return null;
455
+
456
+ const scopeNode = range.commonAncestorContainer;
457
+ const scope = scopeNode?.nodeType === Node.ELEMENT_NODE
458
+ ? (scopeNode as Element)
459
+ : scopeNode?.parentElement;
460
+ if (!scope) return null;
461
+
462
+ const pieces: RangeTextPiece[] = [];
463
+ const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT);
464
+ let started = false;
465
+ let node: Node | null;
466
+ while ((node = walker.nextNode())) {
467
+ const text = node as Text;
468
+ if (!started) {
469
+ if (text !== startContainer) continue;
470
+ started = true;
471
+ }
472
+ const from = text === startContainer ? startOffset : 0;
473
+ const to = text === endContainer ? endOffset : text.length;
474
+ const slice = (text.textContent ?? '').slice(from, to);
475
+ if (slice) pieces.push({ text: slice, excluded: isAnnotationExcludedTextNode(text) });
476
+ if (text === endContainer) return pieces;
477
+ }
478
+ return null;
479
+ };
480
+
481
+ /**
482
+ * Drop `.annotation-exclude` chrome from a new annotation's quote.
483
+ *
484
+ * Excluded nodes are never PAINTED (they are in the highlighter's
485
+ * `exceptSelectors`) and never searched (the restore TreeWalker rejects
486
+ * them), but the browser's selection string still contains them — a selection
487
+ * that runs THROUGH a GitHub alert's title row picks up the visually hidden
488
+ * "Tip: " that keeps the alert type in its accessible name (#1511). That
489
+ * string becomes `originalText`: the quote in the panel, the quote handed to
490
+ * the agent, and the only handle a share link has for re-finding the
491
+ * highlight — which it then never can, because the search stream skips the
492
+ * excluded text. (A selection that BEGINS inside such a node is handled
493
+ * earlier, by {@link snapRangeStartPastExcluded}.)
494
+ *
495
+ * The removal is positional, driven by the range's own nodes rather than by
496
+ * searching the quote for the chrome's text: the excluded runs are located in
497
+ * the quote's whitespace-free coordinate space, where the selection string and
498
+ * the concatenated runs agree character for character. Searching by text
499
+ * removed the wrong occurrence whenever the body prose legitimately contained
500
+ * the same words ("Tip: " as real copy), which then failed the final check and
501
+ * silently kept the invisible word. The painted highlight remains the last
502
+ * word on CONTENT: an unrecognized shape leaves the quote exactly as before.
503
+ */
504
+ const quoteWithoutExcludedText = (
505
+ pieces: RangeTextPiece[] | null,
506
+ selectionText: string,
507
+ paintedText: string,
508
+ ): string => {
509
+ if (!selectionText) return selectionText;
510
+ if (compactText(selectionText) === compactText(paintedText)) return selectionText;
511
+ if (!pieces || !pieces.some(piece => piece.excluded)) return selectionText;
512
+
513
+ // Excluded spans in compacted (whitespace-free) coordinates.
514
+ const spans: { start: number; end: number }[] = [];
515
+ let compactLength = 0;
516
+ for (const piece of pieces) {
517
+ const length = compactText(piece.text).length;
518
+ if (piece.excluded && length > 0) {
519
+ spans.push({ start: compactLength, end: compactLength + length });
520
+ }
521
+ compactLength += length;
522
+ }
523
+ if (spans.length === 0) return selectionText;
524
+ // The selection string and the range's runs must describe the same
525
+ // characters for positions to mean anything.
526
+ if (compactText(selectionText).length !== compactLength) return selectionText;
527
+
528
+ let cursor = 0;
529
+ let quote = '';
530
+ for (const char of selectionText) {
531
+ if (/\s/.test(char)) {
532
+ // Whitespace has no compacted position of its own: drop it only when it
533
+ // sits inside an excluded run or immediately after one, so removing
534
+ // "Tip:" takes the space that followed it with it.
535
+ if (!spans.some(span => cursor > span.start && cursor <= span.end)) quote += char;
536
+ continue;
537
+ }
538
+ if (!spans.some(span => cursor >= span.start && cursor < span.end)) quote += char;
539
+ cursor += 1;
540
+ }
541
+
542
+ return compactText(quote) === compactText(paintedText) ? quote.trim() : selectionText;
543
+ };
544
+
205
545
  const applyMathAnnotationClass = (
206
546
  element: HTMLElement,
207
547
  id: string,
@@ -264,6 +604,19 @@ export interface UseAnnotationHighlighterOptions {
264
604
  /** Fires when a restore was rejected (content mismatch) and the text-search
265
605
  * fallback could not re-anchor the annotation either. */
266
606
  onRestoreMismatch?: (annotation: Annotation, restoredText: string) => void;
607
+ /** Fires once per `applyAnnotations` pass with what that pass tried and what
608
+ * it could not anchor, so a host can mark the leftovers in its panel. */
609
+ onRestoreReport?: (report: AnnotationRestoreReport) => void;
610
+ }
611
+
612
+ /** The outcome of one `applyAnnotations` pass. */
613
+ export interface AnnotationRestoreReport {
614
+ /** Ids the pass considered — including ones already painted, which are
615
+ * anchored by definition. A host clears their unanchored marks. */
616
+ attempted: string[];
617
+ /** Of those, the ones left with no highlight because the stored positions
618
+ * resolved onto the wrong text AND the quote was nowhere in the document. */
619
+ unanchored: string[];
267
620
  }
268
621
 
269
622
  /** Annotation UI state and mutation commands owned by one rendered document. */
@@ -308,6 +661,7 @@ export function useAnnotationHighlighter({
308
661
  enabled = true,
309
662
  verifyRestoredContent = false,
310
663
  onRestoreMismatch,
664
+ onRestoreReport,
311
665
  }: UseAnnotationHighlighterOptions): UseAnnotationHighlighterReturn {
312
666
  const highlighterRef = useRef<Highlighter | null>(null);
313
667
  const modeRef = useRef<EditorMode>(mode);
@@ -320,6 +674,10 @@ export function useAnnotationHighlighter({
320
674
  const justCreatedIdRef = useRef<string | null>(null);
321
675
  const lastMousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
322
676
  const mouseDownMathRef = useRef<HTMLElement | null>(null);
677
+ /** The text runs of the range the highlight about to be created came from,
678
+ * captured before painting (which invalidates the range itself). The CREATE
679
+ * handler repairs the quote from them. */
680
+ const pendingRangeRunsRef = useRef<RangeTextPiece[] | null>(null);
323
681
 
324
682
  const [toolbarState, setToolbarState] = useState<ToolbarState | null>(null);
325
683
  const [commentPopover, setCommentPopover] = useState<CommentPopoverState | null>(null);
@@ -331,6 +689,8 @@ export function useAnnotationHighlighter({
331
689
  useEffect(() => { onSelectAnnotationRef.current = onSelectAnnotation; }, [onSelectAnnotation]);
332
690
  const onRestoreMismatchRef = useRef(onRestoreMismatch);
333
691
  useEffect(() => { onRestoreMismatchRef.current = onRestoreMismatch; }, [onRestoreMismatch]);
692
+ const onRestoreReportRef = useRef(onRestoreReport);
693
+ useEffect(() => { onRestoreReportRef.current = onRestoreReport; }, [onRestoreReport]);
334
694
 
335
695
  const clearPendingSelection = useCallback(() => {
336
696
  pendingSourceRef.current = null;
@@ -415,12 +775,24 @@ export function useAnnotationHighlighter({
415
775
  return null;
416
776
  };
417
777
 
418
- const normalizeWithMap = (text: string): { text: string; map: number[] } => {
778
+ // `boundaries` names offsets at which a new block box starts. They are
779
+ // normalized as if a space stood there, because that is what the
780
+ // browser's selection string carries at the same place — which is the
781
+ // only way a cross-block quote can match this stream.
782
+ const normalizeWithMap = (
783
+ text: string,
784
+ boundaries?: ReadonlySet<number>,
785
+ ): { text: string; map: number[] } => {
419
786
  let normalized = '';
420
787
  const map: number[] = [];
421
788
  let inWhitespace = false;
422
789
 
423
790
  for (let i = 0; i < text.length; i++) {
791
+ if (boundaries?.has(i) && !inWhitespace && normalized.length > 0) {
792
+ normalized += ' ';
793
+ map.push(i);
794
+ inWhitespace = true;
795
+ }
424
796
  const ch = text[i];
425
797
  if (/\s/.test(ch)) {
426
798
  if (!inWhitespace) {
@@ -463,7 +835,7 @@ export function useAnnotationHighlighter({
463
835
  return rangeFromTextOffsets(searchIndex, searchIndex + needle.length);
464
836
  }
465
837
 
466
- const haystack = normalizeWithMap(fullText);
838
+ const haystack = normalizeWithMap(fullText, blockBoundaryOffsets(textNodes));
467
839
  const normalizedNeedle = normalizeWithMap(needle).text;
468
840
  const normalizedIndex = haystack.text.indexOf(normalizedNeedle);
469
841
  if (normalizedNeedle && normalizedIndex !== -1) {
@@ -659,8 +1031,17 @@ export function useAnnotationHighlighter({
659
1031
  const highlighter = highlighterRef.current;
660
1032
  if (!highlighter || !containerRef.current) return;
661
1033
 
1034
+ const attempted: string[] = [];
1035
+ const unanchored: string[] = [];
1036
+
662
1037
  anns.forEach(ann => {
663
1038
  if (ann.type === AnnotationType.GLOBAL_COMMENT) return;
1039
+ // A comment on a rendered diagram part has no text anchor: the
1040
+ // diagram overlay restores it against its render and reports its own
1041
+ // verdict, so it is neither attempted nor unanchored here (the same
1042
+ // rule the raw-HTML pinpoints follow on their surface).
1043
+ if (ann.diagramAnchor) return;
1044
+ attempted.push(ann.id);
664
1045
 
665
1046
  // Skip if already highlighted
666
1047
  try {
@@ -688,10 +1069,10 @@ export function useAnnotationHighlighter({
688
1069
  highlighter.fromStore(ann.startMeta, ann.endMeta, ann.originalText, ann.id);
689
1070
  const restoredDoms = highlighter.getDoms(ann.id);
690
1071
  if (restoredDoms && restoredDoms.length > 0) {
691
- const restoredText = restoredDoms.map(dom => dom.textContent ?? '').join('');
1072
+ const restoredText = paintedTextOf(restoredDoms as HTMLElement[]);
692
1073
  if (
693
1074
  verifyRestoredContent &&
694
- normalizeForRestoreCompare(restoredText) !== normalizeForRestoreCompare(ann.originalText)
1075
+ compactText(restoredText) !== compactText(ann.originalText)
695
1076
  ) {
696
1077
  // Positions resolved, but onto the WRONG text — remove the bad
697
1078
  // highlight and fall through to the text-search fallback.
@@ -712,6 +1093,7 @@ export function useAnnotationHighlighter({
712
1093
  const range = findTextInDOM(ann.originalText);
713
1094
  if (!range) {
714
1095
  if (rejectedRestoreText !== null) {
1096
+ unanchored.push(ann.id);
715
1097
  onRestoreMismatchRef.current?.(ann, rejectedRestoreText);
716
1098
  }
717
1099
  console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`);
@@ -720,12 +1102,20 @@ export function useAnnotationHighlighter({
720
1102
 
721
1103
  try {
722
1104
  const textNodes: { node: Text; start: number; end: number }[] = [];
1105
+ // Excluded chrome is rejected by the search that produced this range,
1106
+ // so a run of it inside the range is not part of the quote; wrapping it
1107
+ // anyway would paint a list marker the reviewer never selected (and,
1108
+ // on the next reload, make the painted text disagree with the quote).
723
1109
  const walker = document.createTreeWalker(
724
1110
  range.commonAncestorContainer.nodeType === Node.TEXT_NODE
725
1111
  ? range.commonAncestorContainer.parentNode!
726
1112
  : range.commonAncestorContainer,
727
1113
  NodeFilter.SHOW_TEXT,
728
- null
1114
+ {
1115
+ acceptNode: (node) => isAnnotationExcludedTextNode(node)
1116
+ ? NodeFilter.FILTER_REJECT
1117
+ : NodeFilter.FILTER_ACCEPT,
1118
+ },
729
1119
  );
730
1120
 
731
1121
  let node: Text | null;
@@ -792,6 +1182,8 @@ export function useAnnotationHighlighter({
792
1182
  console.warn(`Failed to apply highlight for annotation ${ann.id}:`, e);
793
1183
  }
794
1184
  });
1185
+
1186
+ if (attempted.length > 0) onRestoreReportRef.current?.({ attempted, unanchored });
795
1187
  }, [findMathElementsForAnnotation, findTextInDOM, verifyRestoredContent]);
796
1188
 
797
1189
  const removeHighlight = useCallback((id: string) => {
@@ -859,12 +1251,35 @@ export function useAnnotationHighlighter({
859
1251
 
860
1252
  highlighterRef.current = highlighter;
861
1253
 
1254
+ // Stored positions can resolve into chrome the reviewer could never have
1255
+ // selected; painting never enters such a subtree, so a boundary left there
1256
+ // silently loses every run up to it. Snap both ends onto annotatable text
1257
+ // before the range is built.
1258
+ highlighter.hooks.Serialize.Restore.tap((...args: unknown[]) => {
1259
+ const [, storedStart, storedEnd] = args as [unknown, RestoreBoundary, RestoreBoundary];
1260
+ const root = containerRef.current;
1261
+ if (!root || !storedStart || !storedEnd) return [storedStart, storedEnd];
1262
+ const start = snapRestoredBoundary(root, storedStart, 1);
1263
+ const end = snapRestoredBoundary(root, storedEnd, -1);
1264
+ if (start === storedStart && end === storedEnd) return [storedStart, storedEnd];
1265
+ if (restoreBoundariesCross(start, end)) return [storedStart, storedEnd];
1266
+ return [start, end];
1267
+ });
1268
+
862
1269
  highlighter.on(Highlighter.event.CREATE, ({ sources, type }: { sources: any[]; type?: string }) => {
863
1270
  if (type === 'from-store') return;
864
1271
  if (sources.length > 0) {
865
1272
  const source = sources[0];
866
1273
  const doms = highlighter.getDoms(source.id);
867
1274
  if (doms?.length > 0) {
1275
+ // Repair the quote before anything reads it: the popover preview,
1276
+ // the comment draft key, and the annotation's own `originalText` all
1277
+ // come from `source.text`.
1278
+ source.text = quoteWithoutExcludedText(
1279
+ pendingRangeRunsRef.current,
1280
+ source.text,
1281
+ doms.map((dom: HTMLElement) => dom.textContent ?? '').join(''),
1282
+ );
868
1283
  // Clean up previous pending
869
1284
  if (pendingSourceRef.current) {
870
1285
  highlighter.remove(pendingSourceRef.current.id);
@@ -913,6 +1328,33 @@ export function useAnnotationHighlighter({
913
1328
  onSelectAnnotationRef.current?.(id);
914
1329
  });
915
1330
 
1331
+ // web-highlighter's own pointer-end handler reads the LIVE selection, so
1332
+ // the range it paints and quotes has to be corrected before that handler
1333
+ // runs: registered on the capture phase of the same element, and before
1334
+ // `run()` so registration order settles the at-target case too.
1335
+ const handlePointerEndCapture = () => {
1336
+ const container = containerRef.current;
1337
+ const selection = window.getSelection();
1338
+ if (!container || !selection || selection.isCollapsed || selection.rangeCount === 0) {
1339
+ pendingRangeRunsRef.current = null;
1340
+ return;
1341
+ }
1342
+ const range = selection.getRangeAt(0).cloneRange();
1343
+ if (!container.contains(range.commonAncestorContainer)) {
1344
+ pendingRangeRunsRef.current = null;
1345
+ return;
1346
+ }
1347
+ if (snapRangeStartPastExcluded(range)) {
1348
+ selection.removeAllRanges();
1349
+ selection.addRange(range);
1350
+ }
1351
+ pendingRangeRunsRef.current = rangeTextPieces(range);
1352
+ };
1353
+
1354
+ const container = containerRef.current;
1355
+ container.addEventListener('mouseup', handlePointerEndCapture, true);
1356
+ container.addEventListener('touchend', handlePointerEndCapture, true);
1357
+
916
1358
  highlighter.run();
917
1359
 
918
1360
  const handleMathMouseDown = (event: MouseEvent) => {
@@ -1011,7 +1453,10 @@ export function useAnnotationHighlighter({
1011
1453
  const sel = window.getSelection();
1012
1454
  if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
1013
1455
  if (!containerRef.current?.contains(sel.anchorNode)) return;
1014
- highlighter.fromRange(sel.getRangeAt(0));
1456
+ const range = sel.getRangeAt(0).cloneRange();
1457
+ snapRangeStartPastExcluded(range);
1458
+ pendingRangeRunsRef.current = rangeTextPieces(range);
1459
+ highlighter.fromRange(range);
1015
1460
  }, 400);
1016
1461
  }
1017
1462
  : null;
@@ -1027,6 +1472,8 @@ export function useAnnotationHighlighter({
1027
1472
  }
1028
1473
  containerRef.current?.removeEventListener('mousedown', handleMathMouseDown, true);
1029
1474
  containerRef.current?.removeEventListener('mouseup', handleMathMouseUp, true);
1475
+ container.removeEventListener('mouseup', handlePointerEndCapture, true);
1476
+ container.removeEventListener('touchend', handlePointerEndCapture, true);
1030
1477
  highlighter.dispose();
1031
1478
  };
1032
1479
  }, [clearPendingSelection, enabled]);
@@ -1037,13 +1484,21 @@ export function useAnnotationHighlighter({
1037
1484
  if (!highlighter || !container || range.collapsed) return;
1038
1485
  if (!container.contains(range.commonAncestorContainer)) return;
1039
1486
 
1487
+ // Pinpoint clicks and vim visual selections anchor on the first
1488
+ // annotatable text node of a block, which for a titled GitHub alert is the
1489
+ // visually hidden type word; snap off it before painting (the caller's own
1490
+ // range is left untouched).
1491
+ const painted = range.cloneRange();
1492
+ snapRangeStartPastExcluded(painted);
1493
+ pendingRangeRunsRef.current = rangeTextPieces(painted);
1494
+
1040
1495
  const selection = window.getSelection();
1041
1496
  selection?.removeAllRanges();
1042
- selection?.addRange(range.cloneRange());
1497
+ selection?.addRange(painted.cloneRange());
1043
1498
  pendingModeOverrideRef.current = modeOverride ?? null;
1044
1499
 
1045
1500
  try {
1046
- highlighter.fromRange(range);
1501
+ highlighter.fromRange(painted);
1047
1502
  } finally {
1048
1503
  pendingModeOverrideRef.current = null;
1049
1504
  selection?.removeAllRanges();