@plannotator/ui 0.38.2 → 0.40.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.
- package/HANDOFF.md +99 -9
- package/README.md +4 -4
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DecisionControl.tsx +0 -10
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +39 -5
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +25 -1
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +533 -9
- package/components/html-viewer/bridge-script.ts +533 -9
- package/components/html-viewer/useHtmlAnnotation.ts +71 -4
- package/hooks/useAnnotationHighlighter.ts +464 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +3 -3
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +45 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/decisionSpec.ts +1 -4
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +150 -4
- package/utils/terminalToolsAnnouncement.ts +76 -0
|
@@ -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
|
-
|
|
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,12 @@ 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
|
+
attempted.push(ann.id);
|
|
664
1040
|
|
|
665
1041
|
// Skip if already highlighted
|
|
666
1042
|
try {
|
|
@@ -688,10 +1064,10 @@ export function useAnnotationHighlighter({
|
|
|
688
1064
|
highlighter.fromStore(ann.startMeta, ann.endMeta, ann.originalText, ann.id);
|
|
689
1065
|
const restoredDoms = highlighter.getDoms(ann.id);
|
|
690
1066
|
if (restoredDoms && restoredDoms.length > 0) {
|
|
691
|
-
const restoredText = restoredDoms
|
|
1067
|
+
const restoredText = paintedTextOf(restoredDoms as HTMLElement[]);
|
|
692
1068
|
if (
|
|
693
1069
|
verifyRestoredContent &&
|
|
694
|
-
|
|
1070
|
+
compactText(restoredText) !== compactText(ann.originalText)
|
|
695
1071
|
) {
|
|
696
1072
|
// Positions resolved, but onto the WRONG text — remove the bad
|
|
697
1073
|
// highlight and fall through to the text-search fallback.
|
|
@@ -712,6 +1088,7 @@ export function useAnnotationHighlighter({
|
|
|
712
1088
|
const range = findTextInDOM(ann.originalText);
|
|
713
1089
|
if (!range) {
|
|
714
1090
|
if (rejectedRestoreText !== null) {
|
|
1091
|
+
unanchored.push(ann.id);
|
|
715
1092
|
onRestoreMismatchRef.current?.(ann, rejectedRestoreText);
|
|
716
1093
|
}
|
|
717
1094
|
console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`);
|
|
@@ -720,12 +1097,20 @@ export function useAnnotationHighlighter({
|
|
|
720
1097
|
|
|
721
1098
|
try {
|
|
722
1099
|
const textNodes: { node: Text; start: number; end: number }[] = [];
|
|
1100
|
+
// Excluded chrome is rejected by the search that produced this range,
|
|
1101
|
+
// so a run of it inside the range is not part of the quote; wrapping it
|
|
1102
|
+
// anyway would paint a list marker the reviewer never selected (and,
|
|
1103
|
+
// on the next reload, make the painted text disagree with the quote).
|
|
723
1104
|
const walker = document.createTreeWalker(
|
|
724
1105
|
range.commonAncestorContainer.nodeType === Node.TEXT_NODE
|
|
725
1106
|
? range.commonAncestorContainer.parentNode!
|
|
726
1107
|
: range.commonAncestorContainer,
|
|
727
1108
|
NodeFilter.SHOW_TEXT,
|
|
728
|
-
|
|
1109
|
+
{
|
|
1110
|
+
acceptNode: (node) => isAnnotationExcludedTextNode(node)
|
|
1111
|
+
? NodeFilter.FILTER_REJECT
|
|
1112
|
+
: NodeFilter.FILTER_ACCEPT,
|
|
1113
|
+
},
|
|
729
1114
|
);
|
|
730
1115
|
|
|
731
1116
|
let node: Text | null;
|
|
@@ -792,6 +1177,8 @@ export function useAnnotationHighlighter({
|
|
|
792
1177
|
console.warn(`Failed to apply highlight for annotation ${ann.id}:`, e);
|
|
793
1178
|
}
|
|
794
1179
|
});
|
|
1180
|
+
|
|
1181
|
+
if (attempted.length > 0) onRestoreReportRef.current?.({ attempted, unanchored });
|
|
795
1182
|
}, [findMathElementsForAnnotation, findTextInDOM, verifyRestoredContent]);
|
|
796
1183
|
|
|
797
1184
|
const removeHighlight = useCallback((id: string) => {
|
|
@@ -859,12 +1246,35 @@ export function useAnnotationHighlighter({
|
|
|
859
1246
|
|
|
860
1247
|
highlighterRef.current = highlighter;
|
|
861
1248
|
|
|
1249
|
+
// Stored positions can resolve into chrome the reviewer could never have
|
|
1250
|
+
// selected; painting never enters such a subtree, so a boundary left there
|
|
1251
|
+
// silently loses every run up to it. Snap both ends onto annotatable text
|
|
1252
|
+
// before the range is built.
|
|
1253
|
+
highlighter.hooks.Serialize.Restore.tap((...args: unknown[]) => {
|
|
1254
|
+
const [, storedStart, storedEnd] = args as [unknown, RestoreBoundary, RestoreBoundary];
|
|
1255
|
+
const root = containerRef.current;
|
|
1256
|
+
if (!root || !storedStart || !storedEnd) return [storedStart, storedEnd];
|
|
1257
|
+
const start = snapRestoredBoundary(root, storedStart, 1);
|
|
1258
|
+
const end = snapRestoredBoundary(root, storedEnd, -1);
|
|
1259
|
+
if (start === storedStart && end === storedEnd) return [storedStart, storedEnd];
|
|
1260
|
+
if (restoreBoundariesCross(start, end)) return [storedStart, storedEnd];
|
|
1261
|
+
return [start, end];
|
|
1262
|
+
});
|
|
1263
|
+
|
|
862
1264
|
highlighter.on(Highlighter.event.CREATE, ({ sources, type }: { sources: any[]; type?: string }) => {
|
|
863
1265
|
if (type === 'from-store') return;
|
|
864
1266
|
if (sources.length > 0) {
|
|
865
1267
|
const source = sources[0];
|
|
866
1268
|
const doms = highlighter.getDoms(source.id);
|
|
867
1269
|
if (doms?.length > 0) {
|
|
1270
|
+
// Repair the quote before anything reads it: the popover preview,
|
|
1271
|
+
// the comment draft key, and the annotation's own `originalText` all
|
|
1272
|
+
// come from `source.text`.
|
|
1273
|
+
source.text = quoteWithoutExcludedText(
|
|
1274
|
+
pendingRangeRunsRef.current,
|
|
1275
|
+
source.text,
|
|
1276
|
+
doms.map((dom: HTMLElement) => dom.textContent ?? '').join(''),
|
|
1277
|
+
);
|
|
868
1278
|
// Clean up previous pending
|
|
869
1279
|
if (pendingSourceRef.current) {
|
|
870
1280
|
highlighter.remove(pendingSourceRef.current.id);
|
|
@@ -913,6 +1323,33 @@ export function useAnnotationHighlighter({
|
|
|
913
1323
|
onSelectAnnotationRef.current?.(id);
|
|
914
1324
|
});
|
|
915
1325
|
|
|
1326
|
+
// web-highlighter's own pointer-end handler reads the LIVE selection, so
|
|
1327
|
+
// the range it paints and quotes has to be corrected before that handler
|
|
1328
|
+
// runs: registered on the capture phase of the same element, and before
|
|
1329
|
+
// `run()` so registration order settles the at-target case too.
|
|
1330
|
+
const handlePointerEndCapture = () => {
|
|
1331
|
+
const container = containerRef.current;
|
|
1332
|
+
const selection = window.getSelection();
|
|
1333
|
+
if (!container || !selection || selection.isCollapsed || selection.rangeCount === 0) {
|
|
1334
|
+
pendingRangeRunsRef.current = null;
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const range = selection.getRangeAt(0).cloneRange();
|
|
1338
|
+
if (!container.contains(range.commonAncestorContainer)) {
|
|
1339
|
+
pendingRangeRunsRef.current = null;
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (snapRangeStartPastExcluded(range)) {
|
|
1343
|
+
selection.removeAllRanges();
|
|
1344
|
+
selection.addRange(range);
|
|
1345
|
+
}
|
|
1346
|
+
pendingRangeRunsRef.current = rangeTextPieces(range);
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
const container = containerRef.current;
|
|
1350
|
+
container.addEventListener('mouseup', handlePointerEndCapture, true);
|
|
1351
|
+
container.addEventListener('touchend', handlePointerEndCapture, true);
|
|
1352
|
+
|
|
916
1353
|
highlighter.run();
|
|
917
1354
|
|
|
918
1355
|
const handleMathMouseDown = (event: MouseEvent) => {
|
|
@@ -1011,7 +1448,10 @@ export function useAnnotationHighlighter({
|
|
|
1011
1448
|
const sel = window.getSelection();
|
|
1012
1449
|
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
|
|
1013
1450
|
if (!containerRef.current?.contains(sel.anchorNode)) return;
|
|
1014
|
-
|
|
1451
|
+
const range = sel.getRangeAt(0).cloneRange();
|
|
1452
|
+
snapRangeStartPastExcluded(range);
|
|
1453
|
+
pendingRangeRunsRef.current = rangeTextPieces(range);
|
|
1454
|
+
highlighter.fromRange(range);
|
|
1015
1455
|
}, 400);
|
|
1016
1456
|
}
|
|
1017
1457
|
: null;
|
|
@@ -1027,6 +1467,8 @@ export function useAnnotationHighlighter({
|
|
|
1027
1467
|
}
|
|
1028
1468
|
containerRef.current?.removeEventListener('mousedown', handleMathMouseDown, true);
|
|
1029
1469
|
containerRef.current?.removeEventListener('mouseup', handleMathMouseUp, true);
|
|
1470
|
+
container.removeEventListener('mouseup', handlePointerEndCapture, true);
|
|
1471
|
+
container.removeEventListener('touchend', handlePointerEndCapture, true);
|
|
1030
1472
|
highlighter.dispose();
|
|
1031
1473
|
};
|
|
1032
1474
|
}, [clearPendingSelection, enabled]);
|
|
@@ -1037,13 +1479,21 @@ export function useAnnotationHighlighter({
|
|
|
1037
1479
|
if (!highlighter || !container || range.collapsed) return;
|
|
1038
1480
|
if (!container.contains(range.commonAncestorContainer)) return;
|
|
1039
1481
|
|
|
1482
|
+
// Pinpoint clicks and vim visual selections anchor on the first
|
|
1483
|
+
// annotatable text node of a block, which for a titled GitHub alert is the
|
|
1484
|
+
// visually hidden type word; snap off it before painting (the caller's own
|
|
1485
|
+
// range is left untouched).
|
|
1486
|
+
const painted = range.cloneRange();
|
|
1487
|
+
snapRangeStartPastExcluded(painted);
|
|
1488
|
+
pendingRangeRunsRef.current = rangeTextPieces(painted);
|
|
1489
|
+
|
|
1040
1490
|
const selection = window.getSelection();
|
|
1041
1491
|
selection?.removeAllRanges();
|
|
1042
|
-
selection?.addRange(
|
|
1492
|
+
selection?.addRange(painted.cloneRange());
|
|
1043
1493
|
pendingModeOverrideRef.current = modeOverride ?? null;
|
|
1044
1494
|
|
|
1045
1495
|
try {
|
|
1046
|
-
highlighter.fromRange(
|
|
1496
|
+
highlighter.fromRange(painted);
|
|
1047
1497
|
} finally {
|
|
1048
1498
|
pendingModeOverrideRef.current = null;
|
|
1049
1499
|
selection?.removeAllRanges();
|