@peaske7/readit 0.1.8 → 0.2.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 (118) hide show
  1. package/README.md +0 -3
  2. package/biome.json +1 -1
  3. package/bun.lock +43 -185
  4. package/docs/perf-baseline.md +75 -0
  5. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  6. package/e2e/perf/add-comment.spec.ts +118 -0
  7. package/e2e/perf/fixtures/generate.ts +331 -0
  8. package/e2e/perf/initial-load.spec.ts +49 -0
  9. package/e2e/perf/perf.setup.ts +23 -0
  10. package/e2e/perf/perf.teardown.ts +9 -0
  11. package/e2e/perf/scroll.spec.ts +39 -0
  12. package/e2e/perf/tab-switch.spec.ts +69 -0
  13. package/e2e/perf/text-selection.spec.ts +119 -0
  14. package/e2e/perf/utils/metrics.ts +286 -0
  15. package/e2e/perf/utils/perf-cli.ts +86 -0
  16. package/package.json +9 -18
  17. package/playwright.config.ts +12 -0
  18. package/src/App.tsx +124 -172
  19. package/src/{cli/index.ts → cli.ts} +37 -53
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +77 -106
  22. package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
  23. package/src/components/Header.tsx +9 -20
  24. package/src/components/InlineEditor.tsx +5 -5
  25. package/src/components/MarginNote.tsx +71 -93
  26. package/src/components/MarginNotes.tsx +7 -34
  27. package/src/components/RawModal.tsx +9 -8
  28. package/src/components/ReanchorConfirm.tsx +2 -2
  29. package/src/components/SettingsModal.tsx +11 -89
  30. package/src/components/TabBar.tsx +4 -4
  31. package/src/components/TableOfContents.tsx +5 -5
  32. package/src/components/comments/CommentInput.tsx +7 -35
  33. package/src/components/comments/CommentListItem.tsx +9 -11
  34. package/src/components/comments/CommentManager.tsx +53 -37
  35. package/src/components/comments/CommentNav.tsx +14 -14
  36. package/src/components/ui/ActionLink.tsx +14 -18
  37. package/src/components/ui/Button.tsx +42 -43
  38. package/src/components/ui/Dialog.tsx +73 -113
  39. package/src/components/ui/DropdownMenu.tsx +113 -69
  40. package/src/components/ui/Text.tsx +30 -37
  41. package/src/contexts/CommentContext.tsx +75 -106
  42. package/src/contexts/LocaleContext.tsx +45 -4
  43. package/src/contexts/PositionsContext.tsx +16 -0
  44. package/src/contexts/SettingsContext.tsx +133 -0
  45. package/src/hooks/useClickOutside.ts +0 -4
  46. package/src/hooks/useCommentNavigation.ts +6 -29
  47. package/src/hooks/useComments.ts +6 -18
  48. package/src/hooks/useDocument.ts +35 -34
  49. package/src/hooks/useHeadings.test.ts +8 -50
  50. package/src/hooks/useHeadings.ts +5 -88
  51. package/src/hooks/useScrollSpy.ts +10 -14
  52. package/src/hooks/useTextSelection.ts +1 -38
  53. package/src/lib/__fixtures__/bench-data.ts +1 -41
  54. package/src/lib/anchor.bench.ts +57 -67
  55. package/src/lib/anchor.test.ts +5 -1
  56. package/src/lib/anchor.ts +13 -93
  57. package/src/lib/comment-storage.test.ts +4 -4
  58. package/src/lib/comment-storage.ts +2 -46
  59. package/src/lib/export.ts +7 -13
  60. package/src/lib/highlight/core.test.ts +1 -1
  61. package/src/lib/highlight/dom.ts +5 -68
  62. package/src/lib/highlight/highlighter.ts +102 -262
  63. package/src/lib/highlight/resolver.ts +112 -0
  64. package/src/lib/highlight/types.ts +0 -35
  65. package/src/lib/highlight/worker.ts +45 -0
  66. package/src/lib/i18n/en.ts +1 -50
  67. package/src/lib/i18n/ja.ts +1 -50
  68. package/src/lib/i18n/types.ts +1 -49
  69. package/src/lib/margin-layout.ts +5 -27
  70. package/src/lib/positions.ts +150 -0
  71. package/src/lib/utils.ts +2 -19
  72. package/src/schema.ts +81 -0
  73. package/src/{server/index.ts → server.ts} +74 -74
  74. package/src/{store/index.ts → store.ts} +14 -46
  75. package/vite.config.ts +8 -0
  76. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  77. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  78. package/src/components/DocumentViewer/index.ts +0 -1
  79. package/src/components/FloatingTOC.tsx +0 -61
  80. package/src/components/ShortcutCapture.tsx +0 -48
  81. package/src/components/ShortcutList.tsx +0 -198
  82. package/src/components/comments/CommentMinimap.tsx +0 -62
  83. package/src/components/ui/ActionBar.tsx +0 -16
  84. package/src/components/ui/SeparatorDot.tsx +0 -9
  85. package/src/contexts/LayoutContext.tsx +0 -88
  86. package/src/hooks/useClipboard.ts +0 -82
  87. package/src/hooks/useEditorScheme.ts +0 -51
  88. package/src/hooks/useFontPreference.ts +0 -59
  89. package/src/hooks/useKeybindings.ts +0 -108
  90. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  91. package/src/hooks/useLayoutMode.ts +0 -44
  92. package/src/hooks/useLocalePreference.ts +0 -42
  93. package/src/hooks/useReanchorMode.ts +0 -33
  94. package/src/hooks/useScrollMetrics.ts +0 -56
  95. package/src/hooks/useThemePreference.ts +0 -66
  96. package/src/lib/comment-storage.bench.ts +0 -63
  97. package/src/lib/context.bench.ts +0 -41
  98. package/src/lib/context.test.ts +0 -224
  99. package/src/lib/context.ts +0 -193
  100. package/src/lib/editor-links.ts +0 -59
  101. package/src/lib/export.bench.ts +0 -35
  102. package/src/lib/highlight/colors.ts +0 -37
  103. package/src/lib/highlight/core.ts +0 -54
  104. package/src/lib/highlight/index.ts +0 -23
  105. package/src/lib/highlight/script-builder.ts +0 -485
  106. package/src/lib/html-processor.test.tsx +0 -170
  107. package/src/lib/html-processor.tsx +0 -95
  108. package/src/lib/i18n/completeness.test.ts +0 -51
  109. package/src/lib/i18n/translations.test.ts +0 -39
  110. package/src/lib/layout-constants.ts +0 -12
  111. package/src/lib/margin-layout.bench.ts +0 -28
  112. package/src/lib/scroll.test.ts +0 -118
  113. package/src/lib/scroll.ts +0 -47
  114. package/src/lib/shortcut-registry.test.ts +0 -173
  115. package/src/lib/shortcut-registry.ts +0 -209
  116. package/src/lib/utils.test.ts +0 -110
  117. package/src/store/index.test.ts +0 -242
  118. package/src/types/index.ts +0 -127
@@ -1,9 +1,5 @@
1
- import type { HighlightPositions, HighlightStyle, TextNodeInfo } from "./types";
1
+ import type { HighlightStyle, TextNodeInfo } from "./types";
2
2
 
3
- /**
4
- * Block-level elements that should have newlines between them.
5
- * Used to normalize whitespace in text extraction.
6
- */
7
3
  const BLOCK_ELEMENTS = new Set([
8
4
  "P",
9
5
  "DIV",
@@ -20,9 +16,6 @@ const BLOCK_ELEMENTS = new Set([
20
16
  "BR",
21
17
  ]);
22
18
 
23
- /**
24
- * Find the closest block-level ancestor of a node.
25
- */
26
19
  function findBlockParent(node: Node): Element | null {
27
20
  let parent = node.parentElement;
28
21
  while (parent && !BLOCK_ELEMENTS.has(parent.tagName)) {
@@ -31,10 +24,7 @@ function findBlockParent(node: Node): Element | null {
31
24
  return parent;
32
25
  }
33
26
 
34
- /**
35
- * Calculate text offset from root to a specific node position.
36
- * Accounts for newlines between block elements to match getDOMTextContent.
37
- */
27
+ /** Accounts for newlines between block elements to match getDOMTextContent. */
38
28
  export function getTextOffset(
39
29
  root: Node,
40
30
  targetNode: Node,
@@ -48,13 +38,12 @@ export function getTextOffset(
48
38
  while (node) {
49
39
  const blockParent = findBlockParent(node);
50
40
 
51
- // Add newline when transitioning between different block parents
52
41
  if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
53
42
  if (
54
43
  !lastBlockParent.contains(blockParent) &&
55
44
  !blockParent.contains(lastBlockParent)
56
45
  ) {
57
- offset += 1; // Account for the newline
46
+ offset += 1;
58
47
  }
59
48
  }
60
49
 
@@ -69,10 +58,7 @@ export function getTextOffset(
69
58
  return offset;
70
59
  }
71
60
 
72
- /**
73
- * Extract all text content from a DOM tree.
74
- * Inserts newlines between block-level elements to match browser selection behavior.
75
- */
61
+ /** Inserts newlines between block-level elements to match browser selection behavior. */
76
62
  export function getDOMTextContent(root: Node): string {
77
63
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
78
64
  let text = "";
@@ -82,7 +68,6 @@ export function getDOMTextContent(root: Node): string {
82
68
  while (node) {
83
69
  const blockParent = findBlockParent(node);
84
70
 
85
- // Insert newline when transitioning between different block parents
86
71
  if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
87
72
  // Only add newline if blocks are siblings (not nested)
88
73
  if (
@@ -101,10 +86,6 @@ export function getDOMTextContent(root: Node): string {
101
86
  return text;
102
87
  }
103
88
 
104
- /**
105
- * Collect all text nodes with their cumulative offset ranges.
106
- * Accounts for newlines between block elements to match getDOMTextContent.
107
- */
108
89
  export function collectTextNodes(root: Node): TextNodeInfo[] {
109
90
  const textNodes: TextNodeInfo[] = [];
110
91
  let currentOffset = 0;
@@ -116,14 +97,12 @@ export function collectTextNodes(root: Node): TextNodeInfo[] {
116
97
  while (node) {
117
98
  const blockParent = findBlockParent(node);
118
99
 
119
- // Account for newline when transitioning between different block parents
120
- // (same logic as getDOMTextContent)
121
100
  if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
122
101
  if (
123
102
  !lastBlockParent.contains(blockParent) &&
124
103
  !blockParent.contains(lastBlockParent)
125
104
  ) {
126
- currentOffset += 1; // Account for the newline
105
+ currentOffset += 1;
127
106
  }
128
107
  }
129
108
 
@@ -141,9 +120,6 @@ export function collectTextNodes(root: Node): TextNodeInfo[] {
141
120
  return textNodes;
142
121
  }
143
122
 
144
- /**
145
- * Extended style configuration for highlight marks with color and bracket mode support.
146
- */
147
123
  export interface ExtendedHighlightStyle extends HighlightStyle {
148
124
  colorIndex?: number;
149
125
  isBracketMode?: boolean;
@@ -164,9 +140,6 @@ interface NodeSegment {
164
140
  order: number;
165
141
  }
166
142
 
167
- /**
168
- * Line threshold for bracket mode (selections spanning this many lines or more)
169
- */
170
143
  const BRACKET_MODE_LINE_THRESHOLD = 5;
171
144
 
172
145
  export function countLinesInRange(
@@ -183,9 +156,6 @@ export function countLinesInRange(
183
156
  // applyHighlightWithStyle adds color indices and bracket mode for saved comments.
184
157
  // Keeping them separate avoids unnecessary complexity in a shared abstraction.
185
158
 
186
- /**
187
- * Apply highlight mark elements to a text range (for pending selections).
188
- */
189
159
  export function applyHighlightToRange(
190
160
  root: HTMLElement,
191
161
  startOffset: number,
@@ -225,7 +195,6 @@ export function applyHighlightToRange(
225
195
  mark.appendChild(fragment);
226
196
  range.insertNode(mark);
227
197
  } catch (err) {
228
- // Skip if fallback also fails, but log for debugging
229
198
  console.warn("[highlight] Failed to apply highlight to range:", err);
230
199
  }
231
200
  }
@@ -380,35 +349,3 @@ export function clearHighlights(
380
349
  }
381
350
  }
382
351
  }
383
-
384
- /**
385
- * Collect highlight positions relative to a container and document.
386
- */
387
- export function collectHighlightPositions(
388
- root: HTMLElement,
389
- containerRect: DOMRect,
390
- scrollY = 0,
391
- ): HighlightPositions {
392
- const positions: Record<string, number> = {};
393
- const documentPositions: Record<string, number> = {};
394
-
395
- // Collect comment highlight positions
396
- const marks = root.querySelectorAll("mark[data-comment-id]");
397
- for (const mark of marks) {
398
- const commentId = mark.getAttribute("data-comment-id");
399
- if (!commentId) continue;
400
-
401
- // Get position relative to container (for margin notes)
402
- const markRect = mark.getBoundingClientRect();
403
- const relativeTop = markRect.top - containerRect.top;
404
-
405
- // Use first occurrence of each comment id
406
- if (!(commentId in positions)) {
407
- positions[commentId] = relativeTop;
408
- // Document-absolute position (for minimap)
409
- documentPositions[commentId] = markRect.top + scrollY;
410
- }
411
- }
412
-
413
- return { positions, documentPositions };
414
- }
@@ -1,13 +1,12 @@
1
- import { findTextPosition } from "./core";
2
1
  import {
3
2
  applyHighlightBatch,
4
3
  applyHighlightToRange,
5
4
  clearHighlights,
6
- collectHighlightPositions,
7
5
  getDOMTextContent,
8
6
  getTextOffset,
9
7
  } from "./dom";
10
- import type { HighlightComment, HighlightPositions, TextRange } from "./types";
8
+ import { Resolver } from "./resolver";
9
+ import type { HighlightComment } from "./types";
11
10
 
12
11
  export type SelectionHandler = (
13
12
  text: string,
@@ -15,53 +14,35 @@ export type SelectionHandler = (
15
14
  endOffset: number,
16
15
  selectionTop: number,
17
16
  ) => void;
18
- export type PositionChangeHandler = (positions: HighlightPositions) => void;
19
17
  export type HoverHandler = (commentId: string | undefined) => void;
20
18
  export type ClickHandler = (commentId: string) => void;
21
- export type ContentHeightHandler = (height: number) => void;
22
-
23
19
  export interface Highlighter {
24
- applyHighlights(
25
- comments: HighlightComment[],
26
- pendingSelection?: TextRange,
27
- ): void;
20
+ applyHighlights(comments: HighlightComment[]): void;
28
21
  clearHighlights(): void;
29
- getPositions(): HighlightPositions;
30
- onPositionsChange(callback: PositionChangeHandler): () => void;
31
22
  onHighlightHover(callback: HoverHandler): () => void;
32
23
  onHighlightClick(callback: ClickHandler): () => void;
33
- onContentHeightChange?(callback: ContentHeightHandler): () => void;
34
24
  dispose(): void;
35
25
  }
36
26
 
37
- interface MarkdownOptions {
38
- type: "markdown";
27
+ export interface HighlighterOptions {
39
28
  root: HTMLElement;
40
29
  container: HTMLElement;
41
30
  onSelect: SelectionHandler;
42
31
  }
43
32
 
44
- interface IframeOptions {
45
- type: "iframe";
46
- getIframe: () => HTMLIFrameElement | null;
47
- onSelect: SelectionHandler;
48
- }
49
-
50
- export type HighlighterOptions = MarkdownOptions | IframeOptions;
51
-
52
33
  export function createHighlighter(options: HighlighterOptions): Highlighter {
53
- return options.type === "markdown"
54
- ? createMarkdownHighlighter(options)
55
- : createIframeHighlighter(options);
56
- }
57
-
58
- function createMarkdownHighlighter(options: MarkdownOptions): Highlighter {
59
34
  const { root, container, onSelect } = options;
60
35
 
61
- let positionCallback: PositionChangeHandler | undefined;
62
36
  let hoverCallback: HoverHandler | undefined;
63
37
  let clickCallback: ClickHandler | undefined;
64
- let scrollRafId: number | null = null;
38
+
39
+ // Incremental diffing state — avoids full clear+rebuild on every comment change
40
+ const activeHighlights = new Map<string, { start: number; end: number }>();
41
+ let lastTextContent = "";
42
+
43
+ // Web Worker for anchor resolution (offloads indexOf from main thread)
44
+ const resolver = new Resolver();
45
+ let resolveGeneration = 0;
65
46
 
66
47
  const handleMouseUp = () => {
67
48
  const selection = window.getSelection();
@@ -145,252 +126,117 @@ function createMarkdownHighlighter(options: MarkdownOptions): Highlighter {
145
126
  }
146
127
  };
147
128
 
148
- const updatePositions = () => {
149
- if (!positionCallback) return;
150
- const containerRect = container.getBoundingClientRect();
151
- const positions = collectHighlightPositions(
152
- root,
153
- containerRect,
154
- window.scrollY,
155
- );
156
- positionCallback(positions);
157
- };
158
-
159
- const handleScroll = () => {
160
- if (scrollRafId !== null) return;
161
- scrollRafId = requestAnimationFrame(() => {
162
- updatePositions();
163
- scrollRafId = null;
164
- });
165
- };
129
+ const applyDiff = (
130
+ textContent: string,
131
+ resolved: Map<
132
+ string,
133
+ { start: number; end: number; comment: HighlightComment }
134
+ >,
135
+ ) => {
136
+ const toRemove: string[] = [];
137
+ const toAdd: string[] = [];
138
+
139
+ for (const [id, prev] of activeHighlights) {
140
+ const next = resolved.get(id);
141
+ if (!next) {
142
+ toRemove.push(id);
143
+ } else if (prev.start !== next.start || prev.end !== next.end) {
144
+ toRemove.push(id);
145
+ toAdd.push(id);
146
+ }
147
+ }
166
148
 
167
- root.addEventListener("mouseup", handleMouseUp);
168
- root.addEventListener("mouseover", handleMouseOver);
169
- root.addEventListener("mouseout", handleMouseOut);
170
- root.addEventListener("click", handleClick);
171
- window.addEventListener("scroll", handleScroll, { passive: true });
172
- window.addEventListener("resize", updatePositions);
149
+ for (const id of resolved.keys()) {
150
+ if (!activeHighlights.has(id)) {
151
+ toAdd.push(id);
152
+ }
153
+ }
173
154
 
174
- return {
175
- applyHighlights(
176
- comments: HighlightComment[],
177
- _pendingSelection?: TextRange,
178
- ) {
179
- clearHighlights(root);
155
+ if (toRemove.length === 0 && toAdd.length === 0) return;
180
156
 
181
- const textContent = getDOMTextContent(root);
157
+ for (const id of toRemove) {
158
+ clearHighlights(root, `mark[data-comment-id="${id}"]`);
159
+ activeHighlights.delete(id);
160
+ }
182
161
 
183
- const resolved = comments
184
- .map((c) => {
185
- const anchor = findTextPosition(
186
- textContent,
187
- c.selectedText,
188
- c.startOffset,
189
- );
190
- if (anchor) {
191
- return { ...c, startOffset: anchor.start, endOffset: anchor.end };
192
- }
193
- return null;
194
- })
195
- .filter((c): c is HighlightComment => c !== null)
196
- .sort((a, b) => a.startOffset - b.startOffset);
162
+ if (toAdd.length > 0) {
163
+ const newHighlights = toAdd
164
+ .map((id) => resolved.get(id)!)
165
+ .sort((a, b) => a.start - b.start);
197
166
 
198
167
  applyHighlightBatch(
199
168
  root,
200
169
  textContent,
201
- resolved.map((comment) => ({
202
- startOffset: comment.startOffset,
203
- endOffset: comment.endOffset,
170
+ newHighlights.map((h) => ({
171
+ startOffset: h.start,
172
+ endOffset: h.end,
204
173
  style: {
205
174
  attribute: "data-comment-id",
206
- attributeValue: comment.id,
175
+ attributeValue: h.comment.id,
207
176
  colorIndex: 0,
208
177
  },
209
178
  })),
210
179
  );
211
180
 
212
- // Defer position update to next frame to ensure browser has completed layout
213
- // after DOM changes from highlight application
214
- requestAnimationFrame(() => updatePositions());
215
- },
216
-
217
- clearHighlights() {
218
- clearHighlights(root);
219
- },
220
-
221
- getPositions(): HighlightPositions {
222
- const containerRect = container.getBoundingClientRect();
223
- return collectHighlightPositions(root, containerRect, window.scrollY);
224
- },
225
-
226
- onPositionsChange(callback: PositionChangeHandler) {
227
- positionCallback = callback;
228
- return () => {
229
- positionCallback = undefined;
230
- };
231
- },
232
-
233
- onHighlightHover(callback: HoverHandler) {
234
- hoverCallback = callback;
235
- return () => {
236
- hoverCallback = undefined;
237
- };
238
- },
239
-
240
- onHighlightClick(callback: ClickHandler) {
241
- clickCallback = callback;
242
- return () => {
243
- clickCallback = undefined;
244
- };
245
- },
246
-
247
- dispose() {
248
- root.removeEventListener("mouseup", handleMouseUp);
249
- root.removeEventListener("mouseover", handleMouseOver);
250
- root.removeEventListener("mouseout", handleMouseOut);
251
- root.removeEventListener("click", handleClick);
252
- window.removeEventListener("scroll", handleScroll);
253
- window.removeEventListener("resize", updatePositions);
254
- if (scrollRafId !== null) {
255
- cancelAnimationFrame(scrollRafId);
181
+ for (const id of toAdd) {
182
+ const range = resolved.get(id)!;
183
+ activeHighlights.set(id, { start: range.start, end: range.end });
256
184
  }
257
- positionCallback = undefined;
258
- hoverCallback = undefined;
259
- clickCallback = undefined;
260
- },
185
+ }
261
186
  };
262
- }
263
187
 
264
- function createIframeHighlighter(options: IframeOptions): Highlighter {
265
- const { getIframe, onSelect } = options;
266
-
267
- let isReady = false;
268
- let positionCallback: PositionChangeHandler | undefined;
269
- let hoverCallback: HoverHandler | undefined;
270
- let clickCallback: ClickHandler | undefined;
271
- let contentHeightCallback: ContentHeightHandler | undefined;
272
- let pendingHighlights:
273
- | { comments: HighlightComment[]; pending?: TextRange }
274
- | undefined;
275
-
276
- const handleMessage = (event: MessageEvent) => {
277
- const iframe = getIframe();
278
- if (!iframe || iframe.contentWindow !== event.source) return;
279
-
280
- switch (event.data.type) {
281
- case "iframeReady":
282
- isReady = true;
283
- if (pendingHighlights) {
284
- sendHighlights(pendingHighlights.comments, pendingHighlights.pending);
285
- pendingHighlights = undefined;
286
- }
287
- break;
288
-
289
- case "textSelection":
290
- onSelect(
291
- event.data.text,
292
- event.data.startOffset,
293
- event.data.endOffset,
294
- event.data.selectionTop ?? 0,
295
- );
296
- break;
297
-
298
- case "highlightPositions":
299
- if (positionCallback) {
300
- const positions: Record<string, number> = {};
301
- const documentPositions: Record<string, number> = {};
302
- for (const [id, top] of Object.entries(event.data.positions)) {
303
- if (typeof top === "number") {
304
- positions[id] = top;
305
- }
306
- }
307
- for (const [id, top] of Object.entries(
308
- event.data.documentPositions || {},
309
- )) {
310
- if (typeof top === "number") {
311
- documentPositions[id] = top;
312
- }
313
- }
314
- positionCallback({
315
- positions,
316
- documentPositions,
317
- pendingTop:
318
- typeof event.data.pendingTop === "number"
319
- ? event.data.pendingTop
320
- : undefined,
321
- });
322
- }
323
- break;
188
+ root.addEventListener("mouseup", handleMouseUp);
189
+ root.addEventListener("mouseover", handleMouseOver);
190
+ root.addEventListener("mouseout", handleMouseOut);
191
+ root.addEventListener("click", handleClick);
324
192
 
325
- case "highlightHover":
326
- if (hoverCallback) {
327
- hoverCallback(event.data.commentId);
328
- }
329
- break;
193
+ return {
194
+ applyHighlights(comments: HighlightComment[]) {
195
+ const textContent = getDOMTextContent(root);
330
196
 
331
- case "highlightClick":
332
- if (clickCallback && event.data.commentId) {
333
- clickCallback(event.data.commentId);
334
- }
335
- break;
197
+ // If DOM content changed (e.g. document reload), full rebuild is required
198
+ const contentChanged = textContent !== lastTextContent;
199
+ if (contentChanged) {
200
+ clearHighlights(root);
201
+ activeHighlights.clear();
202
+ lastTextContent = textContent;
203
+ }
336
204
 
337
- case "contentHeight":
338
- if (contentHeightCallback && typeof event.data.height === "number") {
339
- contentHeightCallback(event.data.height);
340
- }
341
- break;
342
- }
343
- };
205
+ // Bump generation so stale Worker responses are discarded
206
+ const generation = ++resolveGeneration;
344
207
 
345
- const sendHighlights = (
346
- comments: HighlightComment[],
347
- pending?: TextRange,
348
- ) => {
349
- const iframe = getIframe();
350
- iframe?.contentWindow?.postMessage(
351
- {
352
- type: "applyHighlights",
353
- comments: comments.map((c) => ({
354
- id: c.id,
355
- selectedText: c.selectedText,
356
- startOffset: c.startOffset,
357
- endOffset: c.endOffset,
358
- })),
359
- pendingSelection: pending ?? null,
360
- },
361
- "*",
362
- );
363
- };
208
+ // Resolve anchors off the main thread, then diff and apply
209
+ resolver.resolve(textContent, comments).then((anchorMap) => {
210
+ // Discard if a newer applyHighlights call has started
211
+ if (generation !== resolveGeneration) return;
364
212
 
365
- window.addEventListener("message", handleMessage);
213
+ const resolved = new Map<
214
+ string,
215
+ { start: number; end: number; comment: HighlightComment }
216
+ >();
217
+ for (const c of comments) {
218
+ const anchor = anchorMap.get(c.id);
219
+ if (anchor) {
220
+ resolved.set(c.id, {
221
+ start: anchor.start,
222
+ end: anchor.end,
223
+ comment: {
224
+ ...c,
225
+ startOffset: anchor.start,
226
+ endOffset: anchor.end,
227
+ },
228
+ });
229
+ }
230
+ }
366
231
 
367
- return {
368
- applyHighlights(
369
- comments: HighlightComment[],
370
- pendingSelection?: TextRange,
371
- ) {
372
- if (isReady) {
373
- sendHighlights(comments, pendingSelection);
374
- } else {
375
- pendingHighlights = { comments, pending: pendingSelection };
376
- }
232
+ applyDiff(textContent, resolved);
233
+ });
377
234
  },
378
235
 
379
236
  clearHighlights() {
380
- if (isReady) {
381
- sendHighlights([], undefined);
382
- }
383
- },
384
-
385
- getPositions(): HighlightPositions {
386
- return { positions: {}, documentPositions: {} };
387
- },
388
-
389
- onPositionsChange(callback: PositionChangeHandler) {
390
- positionCallback = callback;
391
- return () => {
392
- positionCallback = undefined;
393
- };
237
+ clearHighlights(root);
238
+ activeHighlights.clear();
239
+ lastTextContent = "";
394
240
  },
395
241
 
396
242
  onHighlightHover(callback: HoverHandler) {
@@ -407,21 +253,15 @@ function createIframeHighlighter(options: IframeOptions): Highlighter {
407
253
  };
408
254
  },
409
255
 
410
- onContentHeightChange(callback: ContentHeightHandler) {
411
- contentHeightCallback = callback;
412
- return () => {
413
- contentHeightCallback = undefined;
414
- };
415
- },
416
-
417
256
  dispose() {
418
- window.removeEventListener("message", handleMessage);
419
- positionCallback = undefined;
257
+ resolveGeneration++;
258
+ resolver.dispose();
259
+ root.removeEventListener("mouseup", handleMouseUp);
260
+ root.removeEventListener("mouseover", handleMouseOver);
261
+ root.removeEventListener("mouseout", handleMouseOut);
262
+ root.removeEventListener("click", handleClick);
420
263
  hoverCallback = undefined;
421
264
  clickCallback = undefined;
422
- contentHeightCallback = undefined;
423
- isReady = false;
424
- pendingHighlights = undefined;
425
265
  },
426
266
  };
427
267
  }