@peaske7/readit 0.2.0 → 0.3.0-rc.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 (179) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +152 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +890 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +233 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/MermaidEnhancer.svelte +218 -0
  68. package/src/components/MermaidModal.svelte +67 -0
  69. package/src/components/RawModal.svelte +126 -0
  70. package/src/components/ReanchorConfirm.svelte +30 -0
  71. package/src/components/SettingsModal.svelte +220 -0
  72. package/src/components/ShortcutCapture.svelte +82 -0
  73. package/src/components/ShortcutList.svelte +145 -0
  74. package/src/components/TabBar.svelte +52 -0
  75. package/src/components/TableOfContents.svelte +125 -0
  76. package/src/components/ui/ActionLink.svelte +40 -0
  77. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  78. package/src/components/ui/Dialog.svelte +97 -0
  79. package/src/components/ui/DropdownMenu.svelte +85 -0
  80. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  81. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  82. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  83. package/src/env.d.ts +6 -0
  84. package/src/index.css +141 -166
  85. package/src/lib/__fixtures__/bench-data.ts +0 -13
  86. package/src/lib/anchor.bench.ts +1 -12
  87. package/src/lib/anchor.test.ts +0 -8
  88. package/src/lib/anchor.ts +0 -4
  89. package/src/lib/comment-storage.bench.ts +49 -0
  90. package/src/lib/comment-storage.test.ts +103 -33
  91. package/src/lib/comment-storage.ts +25 -18
  92. package/src/lib/export.bench.ts +21 -0
  93. package/src/lib/export.ts +0 -1
  94. package/src/lib/fetch-or-throw.test.ts +59 -0
  95. package/src/lib/fetch-or-throw.ts +12 -0
  96. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  97. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  98. package/src/lib/highlight/core.test.ts +0 -5
  99. package/src/lib/highlight/dom.ts +52 -216
  100. package/src/lib/highlight/highlight-registry.ts +221 -0
  101. package/src/lib/highlight/highlight.bench.ts +92 -0
  102. package/src/lib/highlight/highlighter.ts +112 -132
  103. package/src/lib/highlight/resolver.ts +5 -79
  104. package/src/lib/highlight/types.ts +0 -5
  105. package/src/lib/html-text.test.ts +162 -0
  106. package/src/lib/html-text.ts +161 -0
  107. package/src/lib/i18n/en.ts +34 -0
  108. package/src/lib/i18n/ja.ts +34 -0
  109. package/src/lib/i18n/types.ts +33 -0
  110. package/src/lib/key-lock.test.ts +104 -0
  111. package/src/lib/key-lock.ts +23 -0
  112. package/src/lib/margin-layout.bench.ts +61 -0
  113. package/src/lib/margin-layout.ts +0 -7
  114. package/src/lib/markdown-renderer.test.ts +154 -0
  115. package/src/lib/markdown-renderer.ts +178 -0
  116. package/src/lib/mermaid-config.ts +38 -0
  117. package/src/lib/mermaid-renderer.ts +162 -0
  118. package/src/lib/mermaid-worker.ts +60 -0
  119. package/src/lib/positions.ts +31 -24
  120. package/src/lib/shortcut-registry.ts +244 -0
  121. package/src/lib/utils.ts +0 -29
  122. package/src/main.ts +16 -0
  123. package/src/schema.ts +16 -5
  124. package/src/server.ts +355 -95
  125. package/src/stores/app.svelte.ts +231 -0
  126. package/src/stores/locale.svelte.ts +46 -0
  127. package/src/stores/settings.svelte.ts +90 -0
  128. package/src/stores/shortcuts.svelte.ts +104 -0
  129. package/src/stores/ui.svelte.ts +12 -0
  130. package/src/template.ts +104 -0
  131. package/src/test-setup.ts +47 -0
  132. package/svelte.config.js +5 -0
  133. package/tsconfig.json +2 -2
  134. package/vite.config.ts +23 -3
  135. package/vscode-readit/.mcp.json +7 -0
  136. package/vscode-readit/.vscodeignore +7 -0
  137. package/vscode-readit/bun.lock +78 -0
  138. package/vscode-readit/icon.svg +10 -0
  139. package/vscode-readit/package.json +110 -0
  140. package/vscode-readit/src/extension.ts +117 -0
  141. package/vscode-readit/src/server-manager.ts +272 -0
  142. package/vscode-readit/src/webview-provider.ts +204 -0
  143. package/vscode-readit/tsconfig.json +20 -0
  144. package/e2e/fixtures/sample.html +0 -13
  145. package/src/App.tsx +0 -368
  146. package/src/components/ActionsMenu.tsx +0 -91
  147. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  148. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  149. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  150. package/src/components/Header.tsx +0 -54
  151. package/src/components/InlineEditor.tsx +0 -74
  152. package/src/components/MarginNote.tsx +0 -185
  153. package/src/components/MarginNotes.tsx +0 -23
  154. package/src/components/RawModal.tsx +0 -144
  155. package/src/components/ReanchorConfirm.tsx +0 -36
  156. package/src/components/SettingsModal.tsx +0 -232
  157. package/src/components/TabBar.tsx +0 -60
  158. package/src/components/TableOfContents.tsx +0 -108
  159. package/src/components/comments/CommentBadge.tsx +0 -49
  160. package/src/components/comments/CommentInput.tsx +0 -86
  161. package/src/components/comments/CommentListItem.tsx +0 -90
  162. package/src/components/comments/CommentManager.tsx +0 -129
  163. package/src/components/comments/CommentNav.tsx +0 -109
  164. package/src/components/ui/ActionLink.tsx +0 -28
  165. package/src/components/ui/Dialog.tsx +0 -116
  166. package/src/components/ui/DropdownMenu.tsx +0 -158
  167. package/src/contexts/CommentContext.tsx +0 -198
  168. package/src/contexts/LocaleContext.tsx +0 -76
  169. package/src/contexts/PositionsContext.tsx +0 -16
  170. package/src/contexts/SettingsContext.tsx +0 -133
  171. package/src/hooks/useClickOutside.ts +0 -31
  172. package/src/hooks/useCommentNavigation.ts +0 -107
  173. package/src/hooks/useComments.ts +0 -311
  174. package/src/hooks/useDocument.ts +0 -157
  175. package/src/hooks/useScrollSpy.ts +0 -77
  176. package/src/hooks/useTextSelection.ts +0 -86
  177. package/src/lib/highlight/worker.ts +0 -45
  178. package/src/main.tsx +0 -13
  179. package/src/store.ts +0 -222
@@ -1,4 +1,4 @@
1
- import type { HighlightStyle, TextNodeInfo } from "./types";
1
+ import type { TextNodeInfo } from "./types";
2
2
 
3
3
  const BLOCK_ELEMENTS = new Set([
4
4
  "P",
@@ -24,7 +24,6 @@ function findBlockParent(node: Node): Element | null {
24
24
  return parent;
25
25
  }
26
26
 
27
- /** Accounts for newlines between block elements to match getDOMTextContent. */
28
27
  export function getTextOffset(
29
28
  root: Node,
30
29
  targetNode: Node,
@@ -58,7 +57,6 @@ export function getTextOffset(
58
57
  return offset;
59
58
  }
60
59
 
61
- /** Inserts newlines between block-level elements to match browser selection behavior. */
62
60
  export function getDOMTextContent(root: Node): string {
63
61
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
64
62
  let text = "";
@@ -69,7 +67,6 @@ export function getDOMTextContent(root: Node): string {
69
67
  const blockParent = findBlockParent(node);
70
68
 
71
69
  if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
72
- // Only add newline if blocks are siblings (not nested)
73
70
  if (
74
71
  !lastBlockParent.contains(blockParent) &&
75
72
  !blockParent.contains(lastBlockParent)
@@ -120,232 +117,71 @@ export function collectTextNodes(root: Node): TextNodeInfo[] {
120
117
  return textNodes;
121
118
  }
122
119
 
123
- export interface ExtendedHighlightStyle extends HighlightStyle {
124
- colorIndex?: number;
125
- isBracketMode?: boolean;
126
- isBracketStart?: boolean;
127
- isBracketEnd?: boolean;
128
- }
129
-
130
- interface BatchedHighlightSegment {
131
- startOffset: number;
132
- endOffset: number;
133
- style: ExtendedHighlightStyle;
134
- }
135
-
136
- interface NodeSegment {
137
- start: number;
138
- end: number;
139
- style: ExtendedHighlightStyle;
140
- order: number;
141
- }
142
-
143
- const BRACKET_MODE_LINE_THRESHOLD = 5;
144
-
145
- export function countLinesInRange(
146
- textContent: string,
147
- startOffset: number,
148
- endOffset: number,
149
- ): number {
150
- const slice = textContent.slice(startOffset, endOffset);
151
- return (slice.match(/\n/g) || []).length + 1;
152
- }
153
-
154
- // Note: applyHighlightToRange and applyHighlightWithStyle share similar logic intentionally.
155
- // They differ in styling: applyHighlightToRange is for simple pending selections,
156
- // applyHighlightWithStyle adds color indices and bracket mode for saved comments.
157
- // Keeping them separate avoids unnecessary complexity in a shared abstraction.
158
-
159
- export function applyHighlightToRange(
160
- root: HTMLElement,
161
- startOffset: number,
162
- endOffset: number,
163
- style: HighlightStyle,
164
- ): void {
165
- const textNodes = collectTextNodes(root);
166
- const overlappingNodes = textNodes.filter(
167
- (n) => n.end > startOffset && n.start < endOffset,
168
- );
169
-
170
- if (overlappingNodes.length === 0) {
171
- return;
172
- }
173
-
174
- for (const { node: textNode, start } of overlappingNodes) {
175
- const nodeStart = Math.max(0, startOffset - start);
176
- const nodeEnd = Math.min(textNode.length, endOffset - start);
177
-
178
- if (nodeStart >= nodeEnd) {
179
- continue;
180
- }
120
+ export function collectTextNodesWithContent(root: Node): {
121
+ text: string;
122
+ nodes: TextNodeInfo[];
123
+ } {
124
+ const nodes: TextNodeInfo[] = [];
125
+ let text = "";
126
+ let currentOffset = 0;
127
+ let lastBlockParent: Element | null = null;
181
128
 
182
- const range = document.createRange();
183
- range.setStart(textNode, nodeStart);
184
- range.setEnd(textNode, nodeEnd);
129
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
130
+ let node = walker.nextNode();
185
131
 
186
- const mark = document.createElement("mark");
187
- mark.setAttribute(style.attribute, style.attributeValue);
132
+ while (node) {
133
+ const blockParent = findBlockParent(node);
188
134
 
189
- try {
190
- range.surroundContents(mark);
191
- } catch {
192
- // Range crosses element boundaries - use extractContents fallback
193
- try {
194
- const fragment = range.extractContents();
195
- mark.appendChild(fragment);
196
- range.insertNode(mark);
197
- } catch (err) {
198
- console.warn("[highlight] Failed to apply highlight to range:", err);
135
+ if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
136
+ if (
137
+ !lastBlockParent.contains(blockParent) &&
138
+ !blockParent.contains(lastBlockParent)
139
+ ) {
140
+ text += "\n";
141
+ currentOffset += 1;
199
142
  }
200
143
  }
201
- }
202
- }
203
144
 
204
- function createStyledMark(
205
- text: string,
206
- style: ExtendedHighlightStyle,
207
- ): HTMLElement {
208
- const mark = document.createElement("mark");
209
- mark.setAttribute(style.attribute, style.attributeValue);
210
-
211
- if (style.colorIndex !== undefined) {
212
- mark.setAttribute("data-color-index", String(style.colorIndex % 4));
213
- }
214
-
215
- if (style.isBracketMode) {
216
- mark.setAttribute("data-bracket-mode", "true");
217
- if (style.isBracketStart) {
218
- mark.setAttribute("data-bracket-start", "true");
219
- }
220
- if (style.isBracketEnd) {
221
- mark.setAttribute("data-bracket-end", "true");
222
- }
223
- }
224
-
225
- mark.textContent = text;
226
- return mark;
227
- }
228
-
229
- function normalizeNodeSegments(segments: NodeSegment[]): NodeSegment[] {
230
- const sorted = [...segments].sort((a, b) => {
231
- if (a.start !== b.start) return a.start - b.start;
232
- if (a.end !== b.end) return b.end - a.end;
233
- return a.order - b.order;
234
- });
235
-
236
- const normalized: NodeSegment[] = [];
237
- let coveredUntil = 0;
238
-
239
- for (const segment of sorted) {
240
- const start = Math.max(segment.start, coveredUntil);
241
- if (start >= segment.end) continue;
242
-
243
- normalized.push({ ...segment, start });
244
- coveredUntil = segment.end;
145
+ const content = node.textContent ?? "";
146
+ text += content;
147
+ const length = content.length;
148
+ nodes.push({
149
+ node: node as Text,
150
+ start: currentOffset,
151
+ end: currentOffset + length,
152
+ });
153
+ currentOffset += length;
154
+ lastBlockParent = blockParent;
155
+ node = walker.nextNode();
245
156
  }
246
157
 
247
- return normalized;
158
+ return { text, nodes };
248
159
  }
249
160
 
250
- export function applyHighlightBatch(
251
- root: HTMLElement,
252
- textContent: string,
253
- highlights: BatchedHighlightSegment[],
254
- ): void {
255
- if (highlights.length === 0) return;
256
-
161
+ export function createRangesForHighlight(
162
+ root: Node,
163
+ startOffset: number,
164
+ endOffset: number,
165
+ ): Range[] {
257
166
  const textNodes = collectTextNodes(root);
258
- const segmentsByNode = new Map<Text, NodeSegment[]>();
259
-
260
- for (
261
- let highlightIndex = 0;
262
- highlightIndex < highlights.length;
263
- highlightIndex++
264
- ) {
265
- const highlight = highlights[highlightIndex];
266
- const overlappingNodes = textNodes.filter(
267
- (n) => n.end > highlight.startOffset && n.start < highlight.endOffset,
268
- );
269
-
270
- if (overlappingNodes.length === 0) continue;
271
-
272
- const lineCount = countLinesInRange(
273
- textContent,
274
- highlight.startOffset,
275
- highlight.endOffset,
276
- );
277
- const useBracketMode =
278
- highlight.style.isBracketMode ?? lineCount >= BRACKET_MODE_LINE_THRESHOLD;
279
-
280
- for (let nodeIndex = 0; nodeIndex < overlappingNodes.length; nodeIndex++) {
281
- const { node, start } = overlappingNodes[nodeIndex];
282
- const localStart = Math.max(0, highlight.startOffset - start);
283
- const localEnd = Math.min(node.length, highlight.endOffset - start);
284
-
285
- if (localStart >= localEnd) continue;
286
-
287
- const nodeSegments = segmentsByNode.get(node) ?? [];
288
- nodeSegments.push({
289
- start: localStart,
290
- end: localEnd,
291
- order: highlightIndex,
292
- style: {
293
- ...highlight.style,
294
- isBracketMode: useBracketMode,
295
- isBracketStart: useBracketMode && nodeIndex === 0,
296
- isBracketEnd:
297
- useBracketMode && nodeIndex === overlappingNodes.length - 1,
298
- },
299
- });
300
- segmentsByNode.set(node, nodeSegments);
301
- }
302
- }
303
-
304
- for (const { node } of textNodes) {
305
- const segments = segmentsByNode.get(node);
306
- if (!segments || segments.length === 0) continue;
307
-
308
- const normalized = normalizeNodeSegments(segments);
309
- if (normalized.length === 0) continue;
310
-
311
- const text = node.textContent ?? "";
312
- const fragment = document.createDocumentFragment();
313
- let cursor = 0;
314
-
315
- for (const segment of normalized) {
316
- if (cursor < segment.start) {
317
- fragment.appendChild(
318
- document.createTextNode(text.slice(cursor, segment.start)),
319
- );
320
- }
167
+ return createRangesFromNodes(textNodes, startOffset, endOffset);
168
+ }
321
169
 
322
- fragment.appendChild(
323
- createStyledMark(text.slice(segment.start, segment.end), segment.style),
324
- );
325
- cursor = segment.end;
326
- }
170
+ export function createRangesFromNodes(
171
+ textNodes: TextNodeInfo[],
172
+ startOffset: number,
173
+ endOffset: number,
174
+ ): Range[] {
175
+ const ranges: Range[] = [];
327
176
 
328
- if (cursor < text.length) {
329
- fragment.appendChild(document.createTextNode(text.slice(cursor)));
330
- }
177
+ for (const { node, start, end } of textNodes) {
178
+ if (end <= startOffset || start >= endOffset) continue;
331
179
 
332
- node.replaceWith(fragment);
180
+ const range = document.createRange();
181
+ range.setStart(node, Math.max(0, startOffset - start));
182
+ range.setEnd(node, Math.min(node.length, endOffset - start));
183
+ ranges.push(range);
333
184
  }
334
- }
335
-
336
- export function clearHighlights(
337
- root: HTMLElement,
338
- selector = "mark[data-comment-id], mark[data-pending]",
339
- ): void {
340
- const marks = root.querySelectorAll(selector);
341
185
 
342
- for (const mark of marks) {
343
- const parent = mark.parentNode;
344
- if (parent) {
345
- while (mark.firstChild) {
346
- parent.insertBefore(mark.firstChild, mark);
347
- }
348
- parent.removeChild(mark);
349
- }
350
- }
186
+ return ranges;
351
187
  }
@@ -0,0 +1,221 @@
1
+ const COLOR_COUNT = 4;
2
+
3
+ interface CommentEntry {
4
+ ranges: Range[];
5
+ colorIndex: number;
6
+ }
7
+
8
+ export class HighlightRegistry {
9
+ private comments = new Map<string, CommentEntry>();
10
+ private pendingRanges: Range[] = [];
11
+ private focusedId: string | undefined;
12
+
13
+ setHighlights(
14
+ entries: Map<string, { ranges: Range[]; colorIndex: number }>,
15
+ ): void {
16
+ this.comments.clear();
17
+ for (const [id, entry] of entries) {
18
+ this.comments.set(id, entry);
19
+ }
20
+ this.syncCommentHighlights();
21
+ }
22
+
23
+ updateComment(commentId: string, ranges: Range[], colorIndex: number): void {
24
+ this.comments.set(commentId, { ranges, colorIndex });
25
+ this.syncCommentHighlights();
26
+ }
27
+
28
+ removeComment(commentId: string): void {
29
+ this.comments.delete(commentId);
30
+ if (this.focusedId === commentId) {
31
+ this.focusedId = undefined;
32
+ this.syncFocused();
33
+ }
34
+ this.syncCommentHighlights();
35
+ }
36
+
37
+ clearAll(): void {
38
+ this.comments.clear();
39
+ this.focusedId = undefined;
40
+
41
+ for (let i = 0; i < COLOR_COUNT; i++) {
42
+ CSS.highlights.delete(`comment-color-${i}`);
43
+ }
44
+ CSS.highlights.delete("comment-focused");
45
+ this.exposeIds();
46
+ }
47
+
48
+ setPending(ranges: Range[]): void {
49
+ this.pendingRanges = ranges;
50
+ if (ranges.length > 0) {
51
+ CSS.highlights.set("pending-selection", new Highlight(...ranges));
52
+ } else {
53
+ CSS.highlights.delete("pending-selection");
54
+ }
55
+ }
56
+
57
+ clearPending(): void {
58
+ this.pendingRanges = [];
59
+ CSS.highlights.delete("pending-selection");
60
+ }
61
+
62
+ setFocused(commentId: string | undefined): void {
63
+ this.focusedId = commentId;
64
+ this.syncFocused();
65
+ }
66
+
67
+ getBoundingRect(commentId: string): DOMRect | null {
68
+ const entry = this.comments.get(commentId);
69
+ if (!entry || entry.ranges.length === 0) return null;
70
+ return entry.ranges[0].getBoundingClientRect();
71
+ }
72
+
73
+ getPositions(containerRect: DOMRect): Map<string, number> {
74
+ const positions = new Map<string, number>();
75
+ for (const [id, entry] of this.comments) {
76
+ if (entry.ranges.length === 0) continue;
77
+ const rect = entry.ranges[0].getBoundingClientRect();
78
+ positions.set(id, rect.top - containerRect.top);
79
+ }
80
+ return positions;
81
+ }
82
+
83
+ scrollToComment(commentId: string): void {
84
+ const entry = this.comments.get(commentId);
85
+ if (!entry || entry.ranges.length === 0) return;
86
+
87
+ const range = entry.ranges[0];
88
+ const el = range.startContainer.parentElement;
89
+ if (el) {
90
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
91
+ }
92
+ }
93
+
94
+ hitTest(x: number, y: number): string | undefined {
95
+ const pos = caretPositionFromPointCompat(x, y);
96
+ if (!pos) return undefined;
97
+
98
+ for (const [id, entry] of this.comments) {
99
+ for (const range of entry.ranges) {
100
+ if (rangeContainsPosition(range, pos.node, pos.offset)) {
101
+ return id;
102
+ }
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ isPointInHighlight(x: number, y: number): boolean {
109
+ const pos = caretPositionFromPointCompat(x, y);
110
+ if (!pos) return false;
111
+
112
+ for (const range of this.pendingRanges) {
113
+ if (rangeContainsPosition(range, pos.node, pos.offset)) return true;
114
+ }
115
+
116
+ for (const [, entry] of this.comments) {
117
+ for (const range of entry.ranges) {
118
+ if (rangeContainsPosition(range, pos.node, pos.offset)) return true;
119
+ }
120
+ }
121
+
122
+ return false;
123
+ }
124
+
125
+ getHighlightedIds(): string[] {
126
+ return [...this.comments.keys()];
127
+ }
128
+
129
+ dispose(): void {
130
+ this.clearAll();
131
+ this.clearPending();
132
+ }
133
+
134
+ private syncCommentHighlights(): void {
135
+ const byColor = new Map<number, Range[]>();
136
+ for (let i = 0; i < COLOR_COUNT; i++) {
137
+ byColor.set(i, []);
138
+ }
139
+
140
+ for (const entry of this.comments.values()) {
141
+ const bucket = byColor.get(entry.colorIndex % COLOR_COUNT)!;
142
+ bucket.push(...entry.ranges);
143
+ }
144
+
145
+ for (let i = 0; i < COLOR_COUNT; i++) {
146
+ const ranges = byColor.get(i)!;
147
+ if (ranges.length > 0) {
148
+ CSS.highlights.set(`comment-color-${i}`, new Highlight(...ranges));
149
+ } else {
150
+ CSS.highlights.delete(`comment-color-${i}`);
151
+ }
152
+ }
153
+
154
+ this.syncFocused();
155
+ this.exposeIds();
156
+ }
157
+
158
+ private syncFocused(): void {
159
+ if (!this.focusedId) {
160
+ CSS.highlights.delete("comment-focused");
161
+ return;
162
+ }
163
+
164
+ const entry = this.comments.get(this.focusedId);
165
+ if (!entry || entry.ranges.length === 0) {
166
+ CSS.highlights.delete("comment-focused");
167
+ return;
168
+ }
169
+
170
+ CSS.highlights.set("comment-focused", new Highlight(...entry.ranges));
171
+ }
172
+
173
+ private exposeIds(): void {
174
+ if (typeof window !== "undefined") {
175
+ (window as unknown as Record<string, unknown>).__readitHighlights = {
176
+ commentIds: this.getHighlightedIds(),
177
+ };
178
+ }
179
+ }
180
+ }
181
+
182
+ interface CaretPosition {
183
+ node: Node;
184
+ offset: number;
185
+ }
186
+
187
+ function caretPositionFromPointCompat(
188
+ x: number,
189
+ y: number,
190
+ ): CaretPosition | null {
191
+ if ("caretPositionFromPoint" in document) {
192
+ const pos = document.caretPositionFromPoint(x, y);
193
+ if (pos) return { node: pos.offsetNode, offset: pos.offset };
194
+ }
195
+
196
+ if ("caretRangeFromPoint" in document) {
197
+ const range = (
198
+ document as unknown as {
199
+ caretRangeFromPoint(x: number, y: number): Range | null;
200
+ }
201
+ ).caretRangeFromPoint(x, y);
202
+ if (range) {
203
+ return { node: range.startContainer, offset: range.startOffset };
204
+ }
205
+ }
206
+
207
+ return null;
208
+ }
209
+
210
+ function rangeContainsPosition(
211
+ range: Range,
212
+ node: Node,
213
+ offset: number,
214
+ ): boolean {
215
+ try {
216
+ const cmp1 = range.comparePoint(node, offset);
217
+ return cmp1 === 0;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
@@ -0,0 +1,92 @@
1
+ import { bench, describe } from "vitest";
2
+ import {
3
+ COMMENTS_10,
4
+ COMMENTS_50,
5
+ LARGE_DOC,
6
+ MEDIUM_DOC,
7
+ } from "../__fixtures__/bench-data";
8
+ import {
9
+ collectTextNodes,
10
+ collectTextNodesWithContent,
11
+ createRangesFromNodes,
12
+ } from "./dom";
13
+ import { findTextPosition } from "./resolver";
14
+
15
+ describe("findTextPosition", () => {
16
+ const textContent = LARGE_DOC;
17
+
18
+ bench("single comment", () => {
19
+ const c = COMMENTS_10[5];
20
+ findTextPosition(textContent, c.selectedText, c.startOffset);
21
+ });
22
+
23
+ bench("10 comments", () => {
24
+ for (const c of COMMENTS_10) {
25
+ findTextPosition(textContent, c.selectedText, c.startOffset);
26
+ }
27
+ });
28
+
29
+ bench("50 comments", () => {
30
+ for (const c of COMMENTS_50) {
31
+ findTextPosition(textContent, c.selectedText, c.startOffset);
32
+ }
33
+ });
34
+ });
35
+
36
+ function buildDocument(markdown: string): HTMLElement {
37
+ const root = document.createElement("article");
38
+ const paragraphs = markdown.split("\n\n");
39
+ for (const p of paragraphs) {
40
+ if (!p.trim()) continue;
41
+ const el = p.startsWith("#")
42
+ ? document.createElement("h2")
43
+ : document.createElement("p");
44
+ el.textContent = p.replace(/^#+\s*/, "");
45
+ root.appendChild(el);
46
+ }
47
+ return root;
48
+ }
49
+
50
+ describe("collectTextNodes", () => {
51
+ const mediumRoot = buildDocument(MEDIUM_DOC);
52
+ const largeRoot = buildDocument(LARGE_DOC);
53
+
54
+ bench("medium doc (150 lines)", () => {
55
+ collectTextNodes(mediumRoot);
56
+ });
57
+
58
+ bench("large doc (300 lines)", () => {
59
+ collectTextNodes(largeRoot);
60
+ });
61
+ });
62
+
63
+ describe("collectTextNodesWithContent (single-pass)", () => {
64
+ const mediumRoot = buildDocument(MEDIUM_DOC);
65
+ const largeRoot = buildDocument(LARGE_DOC);
66
+
67
+ bench("medium doc (150 lines)", () => {
68
+ collectTextNodesWithContent(mediumRoot);
69
+ });
70
+
71
+ bench("large doc (300 lines)", () => {
72
+ collectTextNodesWithContent(largeRoot);
73
+ });
74
+ });
75
+
76
+ describe("createRangesFromNodes", () => {
77
+ bench("10 highlights on medium doc", () => {
78
+ const nodes = collectTextNodes(buildDocument(MEDIUM_DOC));
79
+ for (const c of COMMENTS_10) {
80
+ const pos = findTextPosition(MEDIUM_DOC, c.selectedText, c.startOffset);
81
+ if (pos) createRangesFromNodes(nodes, pos.start, pos.end);
82
+ }
83
+ });
84
+
85
+ bench("50 highlights on large doc", () => {
86
+ const nodes = collectTextNodes(buildDocument(LARGE_DOC));
87
+ for (const c of COMMENTS_50) {
88
+ const pos = findTextPosition(LARGE_DOC, c.selectedText, c.startOffset);
89
+ if (pos) createRangesFromNodes(nodes, pos.start, pos.end);
90
+ }
91
+ });
92
+ });