@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
@@ -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
+ });
@@ -1,13 +1,11 @@
1
- import { findTextPosition } from "./core";
2
1
  import {
3
- applyHighlightBatch,
4
- applyHighlightToRange,
5
- clearHighlights,
6
- collectHighlightPositions,
7
- getDOMTextContent,
2
+ collectTextNodesWithContent,
3
+ createRangesForHighlight,
4
+ createRangesFromNodes,
8
5
  getTextOffset,
9
6
  } from "./dom";
10
- import type { HighlightComment, HighlightPositions, TextRange } from "./types";
7
+ import { HighlightRegistry } from "./highlight-registry";
8
+ import type { HighlightComment, TextNodeInfo } from "./types";
11
9
 
12
10
  export type SelectionHandler = (
13
11
  text: string,
@@ -15,53 +13,46 @@ export type SelectionHandler = (
15
13
  endOffset: number,
16
14
  selectionTop: number,
17
15
  ) => void;
18
- export type PositionChangeHandler = (positions: HighlightPositions) => void;
19
16
  export type HoverHandler = (commentId: string | undefined) => void;
20
17
  export type ClickHandler = (commentId: string) => void;
21
- export type ContentHeightHandler = (height: number) => void;
18
+ export type CacheHandler = () => void;
22
19
 
23
20
  export interface Highlighter {
24
- applyHighlights(
25
- comments: HighlightComment[],
26
- pendingSelection?: TextRange,
27
- ): void;
21
+ applyHighlights(comments: HighlightComment[]): void;
28
22
  clearHighlights(): void;
29
- getPositions(): HighlightPositions;
30
- onPositionsChange(callback: PositionChangeHandler): () => void;
31
23
  onHighlightHover(callback: HoverHandler): () => void;
32
24
  onHighlightClick(callback: ClickHandler): () => void;
33
- onContentHeightChange?(callback: ContentHeightHandler): () => void;
25
+
26
+ setFocused(commentId: string | undefined): void;
27
+ scrollToComment(commentId: string): void;
28
+ getPositions(containerRect: DOMRect): Map<string, number>;
29
+ getHighlightedIds(): string[];
30
+ isPointInHighlight(x: number, y: number): boolean;
31
+
32
+ onCacheInvalidated(callback: CacheHandler): () => void;
33
+
34
34
  dispose(): void;
35
35
  }
36
36
 
37
- interface MarkdownOptions {
38
- type: "markdown";
37
+ export interface HighlighterOptions {
39
38
  root: HTMLElement;
40
39
  container: HTMLElement;
41
40
  onSelect: SelectionHandler;
42
41
  }
43
42
 
44
- interface IframeOptions {
45
- type: "iframe";
46
- getIframe: () => HTMLIFrameElement | null;
47
- onSelect: SelectionHandler;
48
- }
49
-
50
- export type HighlighterOptions = MarkdownOptions | IframeOptions;
51
-
52
43
  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
44
  const { root, container, onSelect } = options;
60
45
 
61
- let positionCallback: PositionChangeHandler | undefined;
62
46
  let hoverCallback: HoverHandler | undefined;
63
47
  let clickCallback: ClickHandler | undefined;
64
- let scrollRafId: number | null = null;
48
+ let cacheCallback: CacheHandler | undefined;
49
+
50
+ const activePositions = new Map<string, { start: number; end: number }>();
51
+ let lastTextContent = "";
52
+
53
+ const registry = new HighlightRegistry();
54
+
55
+ let lastHoveredId: string | undefined;
65
56
 
66
57
  const handleMouseUp = () => {
67
58
  const selection = window.getSelection();
@@ -75,7 +66,7 @@ function createMarkdownHighlighter(options: MarkdownOptions): Highlighter {
75
66
 
76
67
  const range = selection.getRangeAt(0);
77
68
 
78
- // Reject erroneous whole-document selections (caused by DOM mutation during interaction)
69
+ // Reject whole-document selections caused by DOM mutation
79
70
  if (
80
71
  range.startContainer === root &&
81
72
  range.startOffset === 0 &&
@@ -98,136 +89,107 @@ function createMarkdownHighlighter(options: MarkdownOptions): Highlighter {
98
89
 
99
90
  onSelect(text, startOffset, endOffset, selectionTop);
100
91
 
101
- // Apply pending highlight directly (not through applyHighlights cycle)
102
- // so it persists when native ::selection clears on textarea focus
103
- clearHighlights(root, "mark[data-pending]");
104
- applyHighlightToRange(root, startOffset, endOffset, {
105
- attribute: "data-pending",
106
- attributeValue: "true",
92
+ requestAnimationFrame(() => {
93
+ const pendingRanges = createRangesForHighlight(
94
+ root,
95
+ startOffset,
96
+ endOffset,
97
+ );
98
+ registry.setPending(pendingRanges);
107
99
  });
108
100
  };
109
101
 
110
- const handleMouseOver = (e: Event) => {
102
+ const handleMouseMove = (e: MouseEvent) => {
111
103
  if (!hoverCallback) return;
112
- const target = e.target as HTMLElement;
113
- const mark = target.closest("mark[data-comment-id]");
114
- if (mark) {
115
- hoverCallback(mark.getAttribute("data-comment-id") ?? undefined);
104
+
105
+ const id = registry.hitTest(e.clientX, e.clientY);
106
+
107
+ if (id !== lastHoveredId) {
108
+ lastHoveredId = id;
109
+ hoverCallback(id);
116
110
  }
117
111
  };
118
112
 
119
- const handleMouseOut = (e: Event) => {
120
- if (!hoverCallback) return;
121
- const target = e.target as HTMLElement;
122
- const relatedTarget = (e as MouseEvent).relatedTarget as HTMLElement | null;
123
- const mark = target.closest("mark[data-comment-id]");
124
- if (mark) {
125
- const relatedMark = relatedTarget?.closest("mark[data-comment-id]");
126
- if (
127
- !relatedMark ||
128
- relatedMark.getAttribute("data-comment-id") !==
129
- mark.getAttribute("data-comment-id")
130
- ) {
131
- hoverCallback(undefined);
132
- }
113
+ const handleClick = (e: MouseEvent) => {
114
+ if (!clickCallback) return;
115
+
116
+ const commentId = registry.hitTest(e.clientX, e.clientY);
117
+ if (commentId) {
118
+ clickCallback(commentId);
133
119
  }
134
120
  };
135
121
 
136
- const handleClick = (e: Event) => {
137
- if (!clickCallback) return;
138
- const target = e.target as HTMLElement;
139
- const mark = target.closest("mark[data-comment-id]");
140
- if (mark) {
141
- const commentId = mark.getAttribute("data-comment-id");
142
- if (commentId) {
143
- clickCallback(commentId);
122
+ const applyDiff = (
123
+ resolved: Map<string, { start: number; end: number; colorIndex: number }>,
124
+ textNodes: TextNodeInfo[],
125
+ ) => {
126
+ let changed = false;
127
+
128
+ for (const [id, prev] of activePositions) {
129
+ const next = resolved.get(id);
130
+ if (!next || prev.start !== next.start || prev.end !== next.end) {
131
+ registry.removeComment(id);
132
+ activePositions.delete(id);
133
+ changed = true;
144
134
  }
145
135
  }
146
- };
147
136
 
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
- };
137
+ for (const [id, entry] of resolved) {
138
+ if (!activePositions.has(id)) {
139
+ const ranges = createRangesFromNodes(textNodes, entry.start, entry.end);
140
+ if (ranges.length > 0) {
141
+ registry.updateComment(id, ranges, entry.colorIndex);
142
+ activePositions.set(id, { start: entry.start, end: entry.end });
143
+ changed = true;
144
+ }
145
+ }
146
+ }
158
147
 
159
- const handleScroll = () => {
160
- if (scrollRafId !== null) return;
161
- scrollRafId = requestAnimationFrame(() => {
162
- updatePositions();
163
- scrollRafId = null;
164
- });
148
+ if (changed) {
149
+ cacheCallback?.();
150
+ }
165
151
  };
166
152
 
167
153
  root.addEventListener("mouseup", handleMouseUp);
168
- root.addEventListener("mouseover", handleMouseOver);
169
- root.addEventListener("mouseout", handleMouseOut);
154
+ root.addEventListener("mousemove", handleMouseMove);
170
155
  root.addEventListener("click", handleClick);
171
- window.addEventListener("scroll", handleScroll, { passive: true });
172
- window.addEventListener("resize", updatePositions);
173
156
 
174
157
  return {
175
- applyHighlights(
176
- comments: HighlightComment[],
177
- _pendingSelection?: TextRange,
178
- ) {
179
- clearHighlights(root);
180
-
181
- const textContent = getDOMTextContent(root);
182
-
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);
197
-
198
- applyHighlightBatch(
199
- root,
200
- textContent,
201
- resolved.map((comment) => ({
202
- startOffset: comment.startOffset,
203
- endOffset: comment.endOffset,
204
- style: {
205
- attribute: "data-comment-id",
206
- attributeValue: comment.id,
207
- colorIndex: 0,
208
- },
209
- })),
210
- );
211
-
212
- // Defer position update to next frame to ensure browser has completed layout
213
- // after DOM changes from highlight application
214
- requestAnimationFrame(() => updatePositions());
215
- },
158
+ applyHighlights(comments: HighlightComment[]) {
159
+ const { text: textContent, nodes: textNodes } =
160
+ collectTextNodesWithContent(root);
161
+
162
+ const contentChanged = textContent !== lastTextContent;
163
+ if (contentChanged) {
164
+ registry.clearAll();
165
+ activePositions.clear();
166
+ lastTextContent = textContent;
167
+ }
216
168
 
217
- clearHighlights() {
218
- clearHighlights(root);
219
- },
169
+ const resolved = new Map<
170
+ string,
171
+ { start: number; end: number; colorIndex: number }
172
+ >();
173
+ let colorIdx = 0;
174
+ for (const c of comments) {
175
+ if (c.startOffset >= 0 && c.endOffset > c.startOffset) {
176
+ resolved.set(c.id, {
177
+ start: c.startOffset,
178
+ end: c.endOffset,
179
+ colorIndex: colorIdx % 4,
180
+ });
181
+ colorIdx++;
182
+ }
183
+ }
220
184
 
221
- getPositions(): HighlightPositions {
222
- const containerRect = container.getBoundingClientRect();
223
- return collectHighlightPositions(root, containerRect, window.scrollY);
185
+ applyDiff(resolved, textNodes);
224
186
  },
225
187
 
226
- onPositionsChange(callback: PositionChangeHandler) {
227
- positionCallback = callback;
228
- return () => {
229
- positionCallback = undefined;
230
- };
188
+ clearHighlights() {
189
+ registry.clearAll();
190
+ activePositions.clear();
191
+ lastTextContent = "";
192
+ cacheCallback?.();
231
193
  },
232
194
 
233
195
  onHighlightHover(callback: HoverHandler) {
@@ -244,184 +206,42 @@ function createMarkdownHighlighter(options: MarkdownOptions): Highlighter {
244
206
  };
245
207
  },
246
208
 
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);
256
- }
257
- positionCallback = undefined;
258
- hoverCallback = undefined;
259
- clickCallback = undefined;
260
- },
261
- };
262
- }
263
-
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;
324
-
325
- case "highlightHover":
326
- if (hoverCallback) {
327
- hoverCallback(event.data.commentId);
328
- }
329
- break;
330
-
331
- case "highlightClick":
332
- if (clickCallback && event.data.commentId) {
333
- clickCallback(event.data.commentId);
334
- }
335
- break;
336
-
337
- case "contentHeight":
338
- if (contentHeightCallback && typeof event.data.height === "number") {
339
- contentHeightCallback(event.data.height);
340
- }
341
- break;
342
- }
343
- };
344
-
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
- };
364
-
365
- window.addEventListener("message", handleMessage);
366
-
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
- }
209
+ setFocused(commentId: string | undefined) {
210
+ registry.setFocused(commentId);
377
211
  },
378
212
 
379
- clearHighlights() {
380
- if (isReady) {
381
- sendHighlights([], undefined);
382
- }
383
- },
384
-
385
- getPositions(): HighlightPositions {
386
- return { positions: {}, documentPositions: {} };
213
+ scrollToComment(commentId: string) {
214
+ registry.scrollToComment(commentId);
387
215
  },
388
216
 
389
- onPositionsChange(callback: PositionChangeHandler) {
390
- positionCallback = callback;
391
- return () => {
392
- positionCallback = undefined;
393
- };
217
+ getPositions(containerRect: DOMRect): Map<string, number> {
218
+ return registry.getPositions(containerRect);
394
219
  },
395
220
 
396
- onHighlightHover(callback: HoverHandler) {
397
- hoverCallback = callback;
398
- return () => {
399
- hoverCallback = undefined;
400
- };
221
+ getHighlightedIds(): string[] {
222
+ return registry.getHighlightedIds();
401
223
  },
402
224
 
403
- onHighlightClick(callback: ClickHandler) {
404
- clickCallback = callback;
405
- return () => {
406
- clickCallback = undefined;
407
- };
225
+ isPointInHighlight(x: number, y: number): boolean {
226
+ return registry.isPointInHighlight(x, y);
408
227
  },
409
228
 
410
- onContentHeightChange(callback: ContentHeightHandler) {
411
- contentHeightCallback = callback;
229
+ onCacheInvalidated(callback: CacheHandler) {
230
+ cacheCallback = callback;
412
231
  return () => {
413
- contentHeightCallback = undefined;
232
+ cacheCallback = undefined;
414
233
  };
415
234
  },
416
235
 
417
236
  dispose() {
418
- window.removeEventListener("message", handleMessage);
419
- positionCallback = undefined;
237
+ registry.dispose();
238
+ root.removeEventListener("mouseup", handleMouseUp);
239
+ root.removeEventListener("mousemove", handleMouseMove);
240
+ root.removeEventListener("click", handleClick);
420
241
  hoverCallback = undefined;
421
242
  clickCallback = undefined;
422
- contentHeightCallback = undefined;
423
- isReady = false;
424
- pendingHighlights = undefined;
243
+ cacheCallback = undefined;
244
+ lastHoveredId = undefined;
425
245
  },
426
246
  };
427
247
  }
@@ -1,21 +1,14 @@
1
1
  import type { TextPosition } from "./types";
2
2
 
3
- /**
4
- * Find text position in content, handling duplicate occurrences.
5
- * Returns the occurrence closest to hintOffset when multiple exist.
6
- */
7
3
  export function findTextPosition(
8
4
  textContent: string,
9
5
  selectedText: string,
10
6
  hintOffset?: number,
11
7
  ): TextPosition | undefined {
12
- if (!selectedText || !textContent) {
13
- return undefined;
14
- }
8
+ if (!selectedText || !textContent) return undefined;
15
9
 
16
10
  const occurrences: number[] = [];
17
11
  let idx = 0;
18
-
19
12
  for (;;) {
20
13
  idx = textContent.indexOf(selectedText, idx);
21
14
  if (idx === -1) break;
@@ -23,10 +16,7 @@ export function findTextPosition(
23
16
  idx += 1;
24
17
  }
25
18
 
26
- if (occurrences.length === 0) {
27
- return undefined;
28
- }
29
-
19
+ if (occurrences.length === 0) return undefined;
30
20
  if (occurrences.length === 1) {
31
21
  return {
32
22
  start: occurrences[0],
@@ -34,11 +24,9 @@ export function findTextPosition(
34
24
  };
35
25
  }
36
26
 
37
- // Multiple occurrences: find closest to hint offset
38
27
  const target = hintOffset ?? 0;
39
28
  let closest = occurrences[0];
40
29
  let minDist = Math.abs(closest - target);
41
-
42
30
  for (const occ of occurrences) {
43
31
  const dist = Math.abs(occ - target);
44
32
  if (dist < minDist) {
@@ -46,9 +34,5 @@ export function findTextPosition(
46
34
  closest = occ;
47
35
  }
48
36
  }
49
-
50
- return {
51
- start: closest,
52
- end: closest + selectedText.length,
53
- };
37
+ return { start: closest, end: closest + selectedText.length };
54
38
  }