@peaske7/readit 0.1.7 → 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 +133 -178
  19. package/src/{cli/index.ts → cli.ts} +211 -107
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +78 -105
  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} +111 -81
  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,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
  }
@@ -0,0 +1,112 @@
1
+ import type { HighlightComment, TextPosition } from "./types";
2
+
3
+ export function findTextPosition(
4
+ textContent: string,
5
+ selectedText: string,
6
+ hintOffset?: number,
7
+ ): TextPosition | undefined {
8
+ if (!selectedText || !textContent) return undefined;
9
+
10
+ const occurrences: number[] = [];
11
+ let idx = 0;
12
+ for (;;) {
13
+ idx = textContent.indexOf(selectedText, idx);
14
+ if (idx === -1) break;
15
+ occurrences.push(idx);
16
+ idx += 1;
17
+ }
18
+
19
+ if (occurrences.length === 0) return undefined;
20
+ if (occurrences.length === 1) {
21
+ return { start: occurrences[0], end: occurrences[0] + selectedText.length };
22
+ }
23
+
24
+ const target = hintOffset ?? 0;
25
+ let closest = occurrences[0];
26
+ let minDist = Math.abs(closest - target);
27
+ for (const occ of occurrences) {
28
+ const dist = Math.abs(occ - target);
29
+ if (dist < minDist) {
30
+ minDist = dist;
31
+ closest = occ;
32
+ }
33
+ }
34
+ return { start: closest, end: closest + selectedText.length };
35
+ }
36
+
37
+ export class Resolver {
38
+ private worker: Worker | null = null;
39
+ private seq = 0;
40
+ private pending = new Map<
41
+ number,
42
+ (results: Map<string, TextPosition>) => void
43
+ >();
44
+
45
+ constructor() {
46
+ try {
47
+ this.worker = new Worker(new URL("./worker.ts", import.meta.url), {
48
+ type: "module",
49
+ });
50
+ this.worker.onmessage = this.onMessage;
51
+ this.worker.onerror = () => {
52
+ this.worker?.terminate();
53
+ this.worker = null;
54
+ };
55
+ } catch {
56
+ this.worker = null;
57
+ }
58
+ }
59
+
60
+ resolve(
61
+ text: string,
62
+ comments: HighlightComment[],
63
+ ): Promise<Map<string, TextPosition>> {
64
+ if (!this.worker || comments.length === 0) {
65
+ return Promise.resolve(this.sync(text, comments));
66
+ }
67
+
68
+ return new Promise((resolve) => {
69
+ const id = this.seq++;
70
+ this.pending.set(id, resolve);
71
+ this.worker!.postMessage({
72
+ id,
73
+ textContent: text,
74
+ comments: comments.map((c) => ({
75
+ id: c.id,
76
+ selectedText: c.selectedText,
77
+ startOffset: c.startOffset,
78
+ })),
79
+ });
80
+ });
81
+ }
82
+
83
+ dispose() {
84
+ this.worker?.terminate();
85
+ this.worker = null;
86
+ for (const resolve of this.pending.values()) resolve(new Map());
87
+ this.pending.clear();
88
+ }
89
+
90
+ private onMessage = (e: MessageEvent) => {
91
+ const { id, results } = e.data;
92
+ const resolve = this.pending.get(id);
93
+ if (!resolve) return;
94
+
95
+ this.pending.delete(id);
96
+ const map = new Map<string, TextPosition>();
97
+ for (const r of results) map.set(r.id, { start: r.start, end: r.end });
98
+ resolve(map);
99
+ };
100
+
101
+ private sync(
102
+ text: string,
103
+ comments: HighlightComment[],
104
+ ): Map<string, TextPosition> {
105
+ const map = new Map<string, TextPosition>();
106
+ for (const c of comments) {
107
+ const pos = findTextPosition(text, c.selectedText, c.startOffset);
108
+ if (pos) map.set(c.id, pos);
109
+ }
110
+ return map;
111
+ }
112
+ }
@@ -1,34 +1,13 @@
1
- /**
2
- * Shared types for the highlighting system.
3
- */
4
-
5
- /**
6
- * Style configuration for highlight marks.
7
- */
8
1
  export interface HighlightStyle {
9
2
  attribute: string;
10
3
  attributeValue: string;
11
4
  }
12
5
 
13
- /**
14
- * Text range with character offsets.
15
- */
16
- export interface TextRange {
17
- startOffset: number;
18
- endOffset: number;
19
- }
20
-
21
- /**
22
- * Resolved text position in content.
23
- */
24
6
  export interface TextPosition {
25
7
  start: number;
26
8
  end: number;
27
9
  }
28
10
 
29
- /**
30
- * Comment data needed for highlighting (subset of full Comment type).
31
- */
32
11
  export interface HighlightComment {
33
12
  id: string;
34
13
  selectedText: string;
@@ -36,20 +15,6 @@ export interface HighlightComment {
36
15
  endOffset: number;
37
16
  }
38
17
 
39
- /**
40
- * Highlight positions for margin note alignment and minimap.
41
- * - positions: Y-position relative to container (for margin notes)
42
- * - documentPositions: Y-position from document top (for minimap)
43
- */
44
- export interface HighlightPositions {
45
- positions: Record<string, number>;
46
- documentPositions: Record<string, number>;
47
- pendingTop?: number;
48
- }
49
-
50
- /**
51
- * Text node with its cumulative offset range in the document.
52
- */
53
18
  export interface TextNodeInfo {
54
19
  node: Text;
55
20
  start: number;
@@ -0,0 +1,45 @@
1
+ // Inlined to avoid module import issues in Worker context
2
+ function find(
3
+ text: string,
4
+ needle: string,
5
+ hint?: number,
6
+ ): { start: number; end: number } | undefined {
7
+ if (!needle || !text) return undefined;
8
+
9
+ const hits: number[] = [];
10
+ let i = 0;
11
+ for (;;) {
12
+ i = text.indexOf(needle, i);
13
+ if (i === -1) break;
14
+ hits.push(i);
15
+ i += 1;
16
+ }
17
+
18
+ if (hits.length === 0) return undefined;
19
+ if (hits.length === 1)
20
+ return { start: hits[0], end: hits[0] + needle.length };
21
+
22
+ const target = hint ?? 0;
23
+ let best = hits[0];
24
+ let bestDist = Math.abs(best - target);
25
+ for (const h of hits) {
26
+ const d = Math.abs(h - target);
27
+ if (d < bestDist) {
28
+ bestDist = d;
29
+ best = h;
30
+ }
31
+ }
32
+ return { start: best, end: best + needle.length };
33
+ }
34
+
35
+ self.onmessage = (e: MessageEvent) => {
36
+ const { id, textContent, comments } = e.data;
37
+ const results: { id: string; start: number; end: number }[] = [];
38
+
39
+ for (const c of comments) {
40
+ const pos = find(textContent, c.selectedText, c.startOffset);
41
+ if (pos) results.push({ id: c.id, ...pos });
42
+ }
43
+
44
+ self.postMessage({ id, results });
45
+ };