@peaske7/readit 0.1.8 → 0.2.1

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 (221) 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 -5
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -710
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +130 -0
  12. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  13. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  14. package/e2e/comments.spec.ts +14 -58
  15. package/e2e/document-load.spec.ts +1 -23
  16. package/e2e/export.spec.ts +4 -4
  17. package/e2e/perf/add-comment.spec.ts +116 -0
  18. package/e2e/perf/fixtures/generate.ts +327 -0
  19. package/e2e/perf/initial-load.spec.ts +49 -0
  20. package/e2e/perf/perf.setup.ts +23 -0
  21. package/e2e/perf/perf.teardown.ts +9 -0
  22. package/e2e/perf/screenshot-final.png +0 -0
  23. package/e2e/perf/scroll.spec.ts +39 -0
  24. package/e2e/perf/tab-switch.spec.ts +69 -0
  25. package/e2e/perf/text-selection.spec.ts +119 -0
  26. package/e2e/perf/utils/metrics.ts +350 -0
  27. package/e2e/perf/utils/perf-cli.ts +86 -0
  28. package/e2e/persistence-file.spec.ts +41 -26
  29. package/e2e/utils/selection.ts +17 -73
  30. package/go/cmd/readit/main.go +416 -0
  31. package/go/go.mod +20 -0
  32. package/go/go.sum +41 -0
  33. package/go/internal/server/anchor.go +302 -0
  34. package/go/internal/server/anchor_test.go +111 -0
  35. package/go/internal/server/comments.go +390 -0
  36. package/go/internal/server/documents.go +113 -0
  37. package/go/internal/server/embed.go +17 -0
  38. package/go/internal/server/headings.go +33 -0
  39. package/go/internal/server/headings_test.go +75 -0
  40. package/go/internal/server/htmltext.go +123 -0
  41. package/go/internal/server/markdown.go +157 -0
  42. package/go/internal/server/markdown_bench_test.go +42 -0
  43. package/go/internal/server/markdown_test.go +79 -0
  44. package/go/internal/server/server.go +453 -0
  45. package/go/internal/server/server_bench_test.go +122 -0
  46. package/go/internal/server/settings.go +110 -0
  47. package/go/internal/server/sse.go +140 -0
  48. package/go/internal/server/storage.go +275 -0
  49. package/go/internal/server/storage_test.go +118 -0
  50. package/go/internal/server/template.go +66 -0
  51. package/go/internal/server/types.go +101 -0
  52. package/go/internal/server/watcher.go +74 -0
  53. package/index.html +4 -14
  54. package/nvim-readit/lua/readit/health.lua +64 -0
  55. package/nvim-readit/lua/readit/init.lua +463 -0
  56. package/nvim-readit/plugin/readit.lua +19 -0
  57. package/package.json +24 -41
  58. package/playwright.config.ts +12 -0
  59. package/shell/_readit +158 -0
  60. package/shell/readit.zsh +87 -0
  61. package/src/App.svelte +881 -0
  62. package/src/{cli/index.ts → cli.ts} +216 -70
  63. package/src/components/ActionsMenu.svelte +95 -0
  64. package/src/components/CommentBadge.svelte +67 -0
  65. package/src/components/CommentErrorBanner.svelte +33 -0
  66. package/src/components/CommentInput.svelte +75 -0
  67. package/src/components/CommentListItem.svelte +95 -0
  68. package/src/components/CommentManager.svelte +129 -0
  69. package/src/components/CommentNav.svelte +109 -0
  70. package/src/components/DocumentViewer.svelte +218 -0
  71. package/src/components/FloatingComment.svelte +107 -0
  72. package/src/components/Header.svelte +76 -0
  73. package/src/components/InlineEditor.svelte +72 -0
  74. package/src/components/MarginNote.svelte +167 -0
  75. package/src/components/MarginNotesContainer.svelte +33 -0
  76. package/src/components/RawModal.svelte +126 -0
  77. package/src/components/ReanchorConfirm.svelte +30 -0
  78. package/src/components/SettingsModal.svelte +220 -0
  79. package/src/components/ShortcutCapture.svelte +82 -0
  80. package/src/components/ShortcutList.svelte +145 -0
  81. package/src/components/TabBar.svelte +52 -0
  82. package/src/components/TableOfContents.svelte +125 -0
  83. package/src/components/ui/ActionLink.svelte +40 -0
  84. package/src/components/ui/Button.svelte +53 -0
  85. package/src/components/ui/Dialog.svelte +97 -0
  86. package/src/components/ui/DropdownMenu.svelte +85 -0
  87. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  88. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  89. package/src/components/ui/Text.svelte +42 -0
  90. package/src/env.d.ts +6 -0
  91. package/src/index.css +36 -166
  92. package/src/lib/__fixtures__/bench-data.ts +1 -54
  93. package/src/lib/anchor.bench.ts +47 -68
  94. package/src/lib/anchor.test.ts +5 -9
  95. package/src/lib/anchor.ts +9 -93
  96. package/src/lib/comment-storage.bench.ts +6 -20
  97. package/src/lib/comment-storage.test.ts +45 -37
  98. package/src/lib/comment-storage.ts +23 -64
  99. package/src/lib/export.bench.ts +9 -23
  100. package/src/lib/export.ts +7 -14
  101. package/src/lib/headings.test.ts +103 -0
  102. package/src/lib/headings.ts +44 -0
  103. package/src/lib/highlight/core.test.ts +1 -6
  104. package/src/lib/highlight/dom.ts +53 -280
  105. package/src/lib/highlight/highlight-registry.ts +221 -0
  106. package/src/lib/highlight/highlight.bench.ts +92 -0
  107. package/src/lib/highlight/highlighter.ts +122 -302
  108. package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
  109. package/src/lib/highlight/types.ts +0 -40
  110. package/src/lib/html-text.test.ts +162 -0
  111. package/src/lib/html-text.ts +161 -0
  112. package/src/lib/i18n/en.ts +13 -36
  113. package/src/lib/i18n/ja.ts +14 -37
  114. package/src/lib/i18n/types.ts +13 -36
  115. package/src/lib/margin-layout.bench.ts +48 -15
  116. package/src/lib/margin-layout.ts +2 -31
  117. package/src/lib/markdown-renderer.test.ts +154 -0
  118. package/src/lib/markdown-renderer.ts +177 -0
  119. package/src/lib/mermaid-config.ts +38 -0
  120. package/src/lib/mermaid-renderer.ts +162 -0
  121. package/src/lib/mermaid-worker.ts +60 -0
  122. package/src/lib/positions.ts +157 -0
  123. package/src/lib/shortcut-registry.ts +138 -103
  124. package/src/lib/utils.ts +2 -48
  125. package/src/main.ts +16 -0
  126. package/src/schema.ts +92 -0
  127. package/src/{server/index.ts → server.ts} +427 -163
  128. package/src/stores/app.svelte.ts +231 -0
  129. package/src/stores/locale.svelte.ts +46 -0
  130. package/src/stores/settings.svelte.ts +90 -0
  131. package/src/stores/shortcuts.svelte.ts +104 -0
  132. package/src/stores/ui.svelte.ts +12 -0
  133. package/src/template.ts +104 -0
  134. package/src/test-setup.ts +47 -0
  135. package/svelte.config.js +5 -0
  136. package/tsconfig.json +2 -2
  137. package/vite.config.ts +31 -3
  138. package/vscode-readit/.mcp.json +7 -0
  139. package/vscode-readit/.vscodeignore +7 -0
  140. package/vscode-readit/bun.lock +78 -0
  141. package/vscode-readit/icon.svg +10 -0
  142. package/vscode-readit/package.json +110 -0
  143. package/vscode-readit/src/extension.ts +117 -0
  144. package/vscode-readit/src/server-manager.ts +272 -0
  145. package/vscode-readit/src/webview-provider.ts +204 -0
  146. package/vscode-readit/tsconfig.json +20 -0
  147. package/e2e/fixtures/sample.html +0 -13
  148. package/src/App.tsx +0 -416
  149. package/src/components/ActionsMenu.tsx +0 -112
  150. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  151. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
  152. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  153. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  154. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
  155. package/src/components/DocumentViewer/index.ts +0 -1
  156. package/src/components/FloatingTOC.tsx +0 -61
  157. package/src/components/Header.tsx +0 -65
  158. package/src/components/InlineEditor.tsx +0 -74
  159. package/src/components/MarginNote.tsx +0 -207
  160. package/src/components/MarginNotes.tsx +0 -50
  161. package/src/components/RawModal.tsx +0 -143
  162. package/src/components/ReanchorConfirm.tsx +0 -36
  163. package/src/components/SettingsModal.tsx +0 -310
  164. package/src/components/ShortcutCapture.tsx +0 -48
  165. package/src/components/ShortcutList.tsx +0 -198
  166. package/src/components/TabBar.tsx +0 -60
  167. package/src/components/TableOfContents.tsx +0 -108
  168. package/src/components/comments/CommentBadge.tsx +0 -49
  169. package/src/components/comments/CommentInput.tsx +0 -114
  170. package/src/components/comments/CommentListItem.tsx +0 -92
  171. package/src/components/comments/CommentManager.tsx +0 -113
  172. package/src/components/comments/CommentMinimap.tsx +0 -62
  173. package/src/components/comments/CommentNav.tsx +0 -109
  174. package/src/components/ui/ActionBar.tsx +0 -16
  175. package/src/components/ui/ActionLink.tsx +0 -32
  176. package/src/components/ui/Button.tsx +0 -55
  177. package/src/components/ui/Dialog.tsx +0 -156
  178. package/src/components/ui/DropdownMenu.tsx +0 -114
  179. package/src/components/ui/SeparatorDot.tsx +0 -9
  180. package/src/components/ui/Text.tsx +0 -54
  181. package/src/contexts/CommentContext.tsx +0 -229
  182. package/src/contexts/LayoutContext.tsx +0 -88
  183. package/src/contexts/LocaleContext.tsx +0 -35
  184. package/src/hooks/useClickOutside.ts +0 -35
  185. package/src/hooks/useClipboard.ts +0 -82
  186. package/src/hooks/useCommentNavigation.ts +0 -130
  187. package/src/hooks/useComments.ts +0 -323
  188. package/src/hooks/useDocument.ts +0 -156
  189. package/src/hooks/useEditorScheme.ts +0 -51
  190. package/src/hooks/useFontPreference.ts +0 -59
  191. package/src/hooks/useHeadings.test.ts +0 -159
  192. package/src/hooks/useHeadings.ts +0 -129
  193. package/src/hooks/useKeybindings.ts +0 -108
  194. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  195. package/src/hooks/useLayoutMode.ts +0 -44
  196. package/src/hooks/useLocalePreference.ts +0 -42
  197. package/src/hooks/useReanchorMode.ts +0 -33
  198. package/src/hooks/useScrollMetrics.ts +0 -56
  199. package/src/hooks/useScrollSpy.ts +0 -81
  200. package/src/hooks/useTextSelection.ts +0 -123
  201. package/src/hooks/useThemePreference.ts +0 -66
  202. package/src/lib/context.bench.ts +0 -41
  203. package/src/lib/context.test.ts +0 -224
  204. package/src/lib/context.ts +0 -193
  205. package/src/lib/editor-links.ts +0 -59
  206. package/src/lib/highlight/colors.ts +0 -37
  207. package/src/lib/highlight/index.ts +0 -23
  208. package/src/lib/highlight/script-builder.ts +0 -485
  209. package/src/lib/html-processor.test.tsx +0 -170
  210. package/src/lib/html-processor.tsx +0 -95
  211. package/src/lib/i18n/completeness.test.ts +0 -51
  212. package/src/lib/i18n/translations.test.ts +0 -39
  213. package/src/lib/layout-constants.ts +0 -12
  214. package/src/lib/scroll.test.ts +0 -118
  215. package/src/lib/scroll.ts +0 -47
  216. package/src/lib/shortcut-registry.test.ts +0 -173
  217. package/src/lib/utils.test.ts +0 -110
  218. package/src/main.tsx +0 -13
  219. package/src/store/index.test.ts +0 -242
  220. package/src/store/index.ts +0 -254
  221. package/src/types/index.ts +0 -127
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { findTextPosition } from "./core";
2
+ import { findTextPosition } from "./resolver";
3
3
 
4
4
  describe("findTextPosition", () => {
5
5
  it("finds single occurrence", () => {
@@ -31,18 +31,14 @@ describe("findTextPosition", () => {
31
31
  describe("multiple occurrences", () => {
32
32
  it("finds closest occurrence to hint (before)", () => {
33
33
  const text = "the cat and the dog and the bird";
34
- // "the" occurs at: 0, 12, 24
35
34
 
36
- // Hint at 10 should find "the" at 12 (closest)
37
35
  const result = findTextPosition(text, "the", 10);
38
36
  expect(result?.start).toBe(12);
39
37
  });
40
38
 
41
39
  it("finds closest occurrence to hint (after)", () => {
42
40
  const text = "the cat and the dog and the bird";
43
- // "the" occurs at: 0, 12, 24
44
41
 
45
- // Hint at 30 should find "the" at 24 (closest)
46
42
  const result = findTextPosition(text, "the", 30);
47
43
  expect(result?.start).toBe(24);
48
44
  });
@@ -61,7 +57,6 @@ describe("findTextPosition", () => {
61
57
 
62
58
  it("handles exact match at hint position", () => {
63
59
  const text = "abc abc abc";
64
- // "abc" occurs at: 0, 4, 8
65
60
  const result = findTextPosition(text, "abc", 4);
66
61
  expect(result?.start).toBe(4);
67
62
  });
@@ -1,9 +1,5 @@
1
- import type { HighlightPositions, HighlightStyle, TextNodeInfo } from "./types";
1
+ import type { 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,6 @@ 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
- */
38
27
  export function getTextOffset(
39
28
  root: Node,
40
29
  targetNode: Node,
@@ -48,13 +37,12 @@ export function getTextOffset(
48
37
  while (node) {
49
38
  const blockParent = findBlockParent(node);
50
39
 
51
- // Add newline when transitioning between different block parents
52
40
  if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
53
41
  if (
54
42
  !lastBlockParent.contains(blockParent) &&
55
43
  !blockParent.contains(lastBlockParent)
56
44
  ) {
57
- offset += 1; // Account for the newline
45
+ offset += 1;
58
46
  }
59
47
  }
60
48
 
@@ -69,10 +57,6 @@ export function getTextOffset(
69
57
  return offset;
70
58
  }
71
59
 
72
- /**
73
- * Extract all text content from a DOM tree.
74
- * Inserts newlines between block-level elements to match browser selection behavior.
75
- */
76
60
  export function getDOMTextContent(root: Node): string {
77
61
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
78
62
  let text = "";
@@ -82,9 +66,7 @@ export function getDOMTextContent(root: Node): string {
82
66
  while (node) {
83
67
  const blockParent = findBlockParent(node);
84
68
 
85
- // Insert newline when transitioning between different block parents
86
69
  if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
87
- // Only add newline if blocks are siblings (not nested)
88
70
  if (
89
71
  !lastBlockParent.contains(blockParent) &&
90
72
  !blockParent.contains(lastBlockParent)
@@ -101,10 +83,6 @@ export function getDOMTextContent(root: Node): string {
101
83
  return text;
102
84
  }
103
85
 
104
- /**
105
- * Collect all text nodes with their cumulative offset ranges.
106
- * Accounts for newlines between block elements to match getDOMTextContent.
107
- */
108
86
  export function collectTextNodes(root: Node): TextNodeInfo[] {
109
87
  const textNodes: TextNodeInfo[] = [];
110
88
  let currentOffset = 0;
@@ -116,14 +94,12 @@ export function collectTextNodes(root: Node): TextNodeInfo[] {
116
94
  while (node) {
117
95
  const blockParent = findBlockParent(node);
118
96
 
119
- // Account for newline when transitioning between different block parents
120
- // (same logic as getDOMTextContent)
121
97
  if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
122
98
  if (
123
99
  !lastBlockParent.contains(blockParent) &&
124
100
  !blockParent.contains(lastBlockParent)
125
101
  ) {
126
- currentOffset += 1; // Account for the newline
102
+ currentOffset += 1;
127
103
  }
128
104
  }
129
105
 
@@ -141,274 +117,71 @@ export function collectTextNodes(root: Node): TextNodeInfo[] {
141
117
  return textNodes;
142
118
  }
143
119
 
144
- /**
145
- * Extended style configuration for highlight marks with color and bracket mode support.
146
- */
147
- export interface ExtendedHighlightStyle extends HighlightStyle {
148
- colorIndex?: number;
149
- isBracketMode?: boolean;
150
- isBracketStart?: boolean;
151
- isBracketEnd?: boolean;
152
- }
153
-
154
- interface BatchedHighlightSegment {
155
- startOffset: number;
156
- endOffset: number;
157
- style: ExtendedHighlightStyle;
158
- }
159
-
160
- interface NodeSegment {
161
- start: number;
162
- end: number;
163
- style: ExtendedHighlightStyle;
164
- order: number;
165
- }
166
-
167
- /**
168
- * Line threshold for bracket mode (selections spanning this many lines or more)
169
- */
170
- const BRACKET_MODE_LINE_THRESHOLD = 5;
171
-
172
- export function countLinesInRange(
173
- textContent: string,
174
- startOffset: number,
175
- endOffset: number,
176
- ): number {
177
- const slice = textContent.slice(startOffset, endOffset);
178
- return (slice.match(/\n/g) || []).length + 1;
179
- }
180
-
181
- // Note: applyHighlightToRange and applyHighlightWithStyle share similar logic intentionally.
182
- // They differ in styling: applyHighlightToRange is for simple pending selections,
183
- // applyHighlightWithStyle adds color indices and bracket mode for saved comments.
184
- // Keeping them separate avoids unnecessary complexity in a shared abstraction.
185
-
186
- /**
187
- * Apply highlight mark elements to a text range (for pending selections).
188
- */
189
- export function applyHighlightToRange(
190
- root: HTMLElement,
191
- startOffset: number,
192
- endOffset: number,
193
- style: HighlightStyle,
194
- ): void {
195
- const textNodes = collectTextNodes(root);
196
- const overlappingNodes = textNodes.filter(
197
- (n) => n.end > startOffset && n.start < endOffset,
198
- );
199
-
200
- if (overlappingNodes.length === 0) {
201
- return;
202
- }
203
-
204
- for (const { node: textNode, start } of overlappingNodes) {
205
- const nodeStart = Math.max(0, startOffset - start);
206
- const nodeEnd = Math.min(textNode.length, endOffset - start);
207
-
208
- if (nodeStart >= nodeEnd) {
209
- continue;
210
- }
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;
211
128
 
212
- const range = document.createRange();
213
- range.setStart(textNode, nodeStart);
214
- range.setEnd(textNode, nodeEnd);
129
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
130
+ let node = walker.nextNode();
215
131
 
216
- const mark = document.createElement("mark");
217
- mark.setAttribute(style.attribute, style.attributeValue);
132
+ while (node) {
133
+ const blockParent = findBlockParent(node);
218
134
 
219
- try {
220
- range.surroundContents(mark);
221
- } catch {
222
- // Range crosses element boundaries - use extractContents fallback
223
- try {
224
- const fragment = range.extractContents();
225
- mark.appendChild(fragment);
226
- range.insertNode(mark);
227
- } catch (err) {
228
- // Skip if fallback also fails, but log for debugging
229
- 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;
230
142
  }
231
143
  }
232
- }
233
- }
234
-
235
- function createStyledMark(
236
- text: string,
237
- style: ExtendedHighlightStyle,
238
- ): HTMLElement {
239
- const mark = document.createElement("mark");
240
- mark.setAttribute(style.attribute, style.attributeValue);
241
-
242
- if (style.colorIndex !== undefined) {
243
- mark.setAttribute("data-color-index", String(style.colorIndex % 4));
244
- }
245
-
246
- if (style.isBracketMode) {
247
- mark.setAttribute("data-bracket-mode", "true");
248
- if (style.isBracketStart) {
249
- mark.setAttribute("data-bracket-start", "true");
250
- }
251
- if (style.isBracketEnd) {
252
- mark.setAttribute("data-bracket-end", "true");
253
- }
254
- }
255
-
256
- mark.textContent = text;
257
- return mark;
258
- }
259
-
260
- function normalizeNodeSegments(segments: NodeSegment[]): NodeSegment[] {
261
- const sorted = [...segments].sort((a, b) => {
262
- if (a.start !== b.start) return a.start - b.start;
263
- if (a.end !== b.end) return b.end - a.end;
264
- return a.order - b.order;
265
- });
266
-
267
- const normalized: NodeSegment[] = [];
268
- let coveredUntil = 0;
269
144
 
270
- for (const segment of sorted) {
271
- const start = Math.max(segment.start, coveredUntil);
272
- if (start >= segment.end) continue;
273
-
274
- normalized.push({ ...segment, start });
275
- 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();
276
156
  }
277
157
 
278
- return normalized;
158
+ return { text, nodes };
279
159
  }
280
160
 
281
- export function applyHighlightBatch(
282
- root: HTMLElement,
283
- textContent: string,
284
- highlights: BatchedHighlightSegment[],
285
- ): void {
286
- if (highlights.length === 0) return;
287
-
161
+ export function createRangesForHighlight(
162
+ root: Node,
163
+ startOffset: number,
164
+ endOffset: number,
165
+ ): Range[] {
288
166
  const textNodes = collectTextNodes(root);
289
- const segmentsByNode = new Map<Text, NodeSegment[]>();
290
-
291
- for (
292
- let highlightIndex = 0;
293
- highlightIndex < highlights.length;
294
- highlightIndex++
295
- ) {
296
- const highlight = highlights[highlightIndex];
297
- const overlappingNodes = textNodes.filter(
298
- (n) => n.end > highlight.startOffset && n.start < highlight.endOffset,
299
- );
300
-
301
- if (overlappingNodes.length === 0) continue;
302
-
303
- const lineCount = countLinesInRange(
304
- textContent,
305
- highlight.startOffset,
306
- highlight.endOffset,
307
- );
308
- const useBracketMode =
309
- highlight.style.isBracketMode ?? lineCount >= BRACKET_MODE_LINE_THRESHOLD;
310
-
311
- for (let nodeIndex = 0; nodeIndex < overlappingNodes.length; nodeIndex++) {
312
- const { node, start } = overlappingNodes[nodeIndex];
313
- const localStart = Math.max(0, highlight.startOffset - start);
314
- const localEnd = Math.min(node.length, highlight.endOffset - start);
315
-
316
- if (localStart >= localEnd) continue;
317
-
318
- const nodeSegments = segmentsByNode.get(node) ?? [];
319
- nodeSegments.push({
320
- start: localStart,
321
- end: localEnd,
322
- order: highlightIndex,
323
- style: {
324
- ...highlight.style,
325
- isBracketMode: useBracketMode,
326
- isBracketStart: useBracketMode && nodeIndex === 0,
327
- isBracketEnd:
328
- useBracketMode && nodeIndex === overlappingNodes.length - 1,
329
- },
330
- });
331
- segmentsByNode.set(node, nodeSegments);
332
- }
333
- }
334
-
335
- for (const { node } of textNodes) {
336
- const segments = segmentsByNode.get(node);
337
- if (!segments || segments.length === 0) continue;
338
-
339
- const normalized = normalizeNodeSegments(segments);
340
- if (normalized.length === 0) continue;
341
-
342
- const text = node.textContent ?? "";
343
- const fragment = document.createDocumentFragment();
344
- let cursor = 0;
345
-
346
- for (const segment of normalized) {
347
- if (cursor < segment.start) {
348
- fragment.appendChild(
349
- document.createTextNode(text.slice(cursor, segment.start)),
350
- );
351
- }
352
-
353
- fragment.appendChild(
354
- createStyledMark(text.slice(segment.start, segment.end), segment.style),
355
- );
356
- cursor = segment.end;
357
- }
358
-
359
- if (cursor < text.length) {
360
- fragment.appendChild(document.createTextNode(text.slice(cursor)));
361
- }
362
-
363
- node.replaceWith(fragment);
364
- }
167
+ return createRangesFromNodes(textNodes, startOffset, endOffset);
365
168
  }
366
169
 
367
- export function clearHighlights(
368
- root: HTMLElement,
369
- selector = "mark[data-comment-id], mark[data-pending]",
370
- ): void {
371
- const marks = root.querySelectorAll(selector);
372
-
373
- for (const mark of marks) {
374
- const parent = mark.parentNode;
375
- if (parent) {
376
- while (mark.firstChild) {
377
- parent.insertBefore(mark.firstChild, mark);
378
- }
379
- parent.removeChild(mark);
380
- }
381
- }
382
- }
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;
170
+ export function createRangesFromNodes(
171
+ textNodes: TextNodeInfo[],
172
+ startOffset: number,
173
+ endOffset: number,
174
+ ): Range[] {
175
+ const ranges: Range[] = [];
400
176
 
401
- // Get position relative to container (for margin notes)
402
- const markRect = mark.getBoundingClientRect();
403
- const relativeTop = markRect.top - containerRect.top;
177
+ for (const { node, start, end } of textNodes) {
178
+ if (end <= startOffset || start >= endOffset) continue;
404
179
 
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
- }
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);
411
184
  }
412
185
 
413
- return { positions, documentPositions };
186
+ return ranges;
414
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
+ }