@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.
- package/.claude/CLAUDE.md +118 -76
- package/.claude/commands/review.md +1 -1
- package/.claude/roadmap.md +32 -9
- package/.claude/user-stories.md +100 -15
- package/AGENTS.md +30 -26
- package/Makefile +32 -0
- package/README.md +90 -5
- package/biome.json +18 -8
- package/bun.lock +426 -710
- package/bunfig.toml +2 -0
- package/docs/perf-baseline.md +130 -0
- package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
- package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
- package/e2e/comments.spec.ts +14 -58
- package/e2e/document-load.spec.ts +1 -23
- package/e2e/export.spec.ts +4 -4
- package/e2e/perf/add-comment.spec.ts +116 -0
- package/e2e/perf/fixtures/generate.ts +327 -0
- package/e2e/perf/initial-load.spec.ts +49 -0
- package/e2e/perf/perf.setup.ts +23 -0
- package/e2e/perf/perf.teardown.ts +9 -0
- package/e2e/perf/screenshot-final.png +0 -0
- package/e2e/perf/scroll.spec.ts +39 -0
- package/e2e/perf/tab-switch.spec.ts +69 -0
- package/e2e/perf/text-selection.spec.ts +119 -0
- package/e2e/perf/utils/metrics.ts +350 -0
- package/e2e/perf/utils/perf-cli.ts +86 -0
- package/e2e/persistence-file.spec.ts +41 -26
- package/e2e/utils/selection.ts +17 -73
- package/go/cmd/readit/main.go +416 -0
- package/go/go.mod +20 -0
- package/go/go.sum +41 -0
- package/go/internal/server/anchor.go +302 -0
- package/go/internal/server/anchor_test.go +111 -0
- package/go/internal/server/comments.go +390 -0
- package/go/internal/server/documents.go +113 -0
- package/go/internal/server/embed.go +17 -0
- package/go/internal/server/headings.go +33 -0
- package/go/internal/server/headings_test.go +75 -0
- package/go/internal/server/htmltext.go +123 -0
- package/go/internal/server/markdown.go +157 -0
- package/go/internal/server/markdown_bench_test.go +42 -0
- package/go/internal/server/markdown_test.go +79 -0
- package/go/internal/server/server.go +453 -0
- package/go/internal/server/server_bench_test.go +122 -0
- package/go/internal/server/settings.go +110 -0
- package/go/internal/server/sse.go +140 -0
- package/go/internal/server/storage.go +275 -0
- package/go/internal/server/storage_test.go +118 -0
- package/go/internal/server/template.go +66 -0
- package/go/internal/server/types.go +101 -0
- package/go/internal/server/watcher.go +74 -0
- package/index.html +4 -14
- package/nvim-readit/lua/readit/health.lua +64 -0
- package/nvim-readit/lua/readit/init.lua +463 -0
- package/nvim-readit/plugin/readit.lua +19 -0
- package/package.json +24 -41
- package/playwright.config.ts +12 -0
- package/shell/_readit +158 -0
- package/shell/readit.zsh +87 -0
- package/src/App.svelte +881 -0
- package/src/{cli/index.ts → cli.ts} +216 -70
- package/src/components/ActionsMenu.svelte +95 -0
- package/src/components/CommentBadge.svelte +67 -0
- package/src/components/CommentErrorBanner.svelte +33 -0
- package/src/components/CommentInput.svelte +75 -0
- package/src/components/CommentListItem.svelte +95 -0
- package/src/components/CommentManager.svelte +129 -0
- package/src/components/CommentNav.svelte +109 -0
- package/src/components/DocumentViewer.svelte +218 -0
- package/src/components/FloatingComment.svelte +107 -0
- package/src/components/Header.svelte +76 -0
- package/src/components/InlineEditor.svelte +72 -0
- package/src/components/MarginNote.svelte +167 -0
- package/src/components/MarginNotesContainer.svelte +33 -0
- package/src/components/RawModal.svelte +126 -0
- package/src/components/ReanchorConfirm.svelte +30 -0
- package/src/components/SettingsModal.svelte +220 -0
- package/src/components/ShortcutCapture.svelte +82 -0
- package/src/components/ShortcutList.svelte +145 -0
- package/src/components/TabBar.svelte +52 -0
- package/src/components/TableOfContents.svelte +125 -0
- package/src/components/ui/ActionLink.svelte +40 -0
- package/src/components/ui/Button.svelte +53 -0
- package/src/components/ui/Dialog.svelte +97 -0
- package/src/components/ui/DropdownMenu.svelte +85 -0
- package/src/components/ui/DropdownMenuItem.svelte +38 -0
- package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
- package/src/components/ui/Text.svelte +42 -0
- package/src/env.d.ts +6 -0
- package/src/index.css +36 -166
- package/src/lib/__fixtures__/bench-data.ts +1 -54
- package/src/lib/anchor.bench.ts +47 -68
- package/src/lib/anchor.test.ts +5 -9
- package/src/lib/anchor.ts +9 -93
- package/src/lib/comment-storage.bench.ts +6 -20
- package/src/lib/comment-storage.test.ts +45 -37
- package/src/lib/comment-storage.ts +23 -64
- package/src/lib/export.bench.ts +9 -23
- package/src/lib/export.ts +7 -14
- package/src/lib/headings.test.ts +103 -0
- package/src/lib/headings.ts +44 -0
- package/src/lib/highlight/core.test.ts +1 -6
- package/src/lib/highlight/dom.ts +53 -280
- package/src/lib/highlight/highlight-registry.ts +221 -0
- package/src/lib/highlight/highlight.bench.ts +92 -0
- package/src/lib/highlight/highlighter.ts +122 -302
- package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
- package/src/lib/highlight/types.ts +0 -40
- package/src/lib/html-text.test.ts +162 -0
- package/src/lib/html-text.ts +161 -0
- package/src/lib/i18n/en.ts +13 -36
- package/src/lib/i18n/ja.ts +14 -37
- package/src/lib/i18n/types.ts +13 -36
- package/src/lib/margin-layout.bench.ts +48 -15
- package/src/lib/margin-layout.ts +2 -31
- package/src/lib/markdown-renderer.test.ts +154 -0
- package/src/lib/markdown-renderer.ts +177 -0
- package/src/lib/mermaid-config.ts +38 -0
- package/src/lib/mermaid-renderer.ts +162 -0
- package/src/lib/mermaid-worker.ts +60 -0
- package/src/lib/positions.ts +157 -0
- package/src/lib/shortcut-registry.ts +138 -103
- package/src/lib/utils.ts +2 -48
- package/src/main.ts +16 -0
- package/src/schema.ts +92 -0
- package/src/{server/index.ts → server.ts} +427 -163
- package/src/stores/app.svelte.ts +231 -0
- package/src/stores/locale.svelte.ts +46 -0
- package/src/stores/settings.svelte.ts +90 -0
- package/src/stores/shortcuts.svelte.ts +104 -0
- package/src/stores/ui.svelte.ts +12 -0
- package/src/template.ts +104 -0
- package/src/test-setup.ts +47 -0
- package/svelte.config.js +5 -0
- package/tsconfig.json +2 -2
- package/vite.config.ts +31 -3
- package/vscode-readit/.mcp.json +7 -0
- package/vscode-readit/.vscodeignore +7 -0
- package/vscode-readit/bun.lock +78 -0
- package/vscode-readit/icon.svg +10 -0
- package/vscode-readit/package.json +110 -0
- package/vscode-readit/src/extension.ts +117 -0
- package/vscode-readit/src/server-manager.ts +272 -0
- package/vscode-readit/src/webview-provider.ts +204 -0
- package/vscode-readit/tsconfig.json +20 -0
- package/e2e/fixtures/sample.html +0 -13
- package/src/App.tsx +0 -416
- package/src/components/ActionsMenu.tsx +0 -112
- package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
- package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
- package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
- package/src/components/DocumentViewer/InlineCode.tsx +0 -60
- package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
- package/src/components/DocumentViewer/index.ts +0 -1
- package/src/components/FloatingTOC.tsx +0 -61
- package/src/components/Header.tsx +0 -65
- package/src/components/InlineEditor.tsx +0 -74
- package/src/components/MarginNote.tsx +0 -207
- package/src/components/MarginNotes.tsx +0 -50
- package/src/components/RawModal.tsx +0 -143
- package/src/components/ReanchorConfirm.tsx +0 -36
- package/src/components/SettingsModal.tsx +0 -310
- package/src/components/ShortcutCapture.tsx +0 -48
- package/src/components/ShortcutList.tsx +0 -198
- package/src/components/TabBar.tsx +0 -60
- package/src/components/TableOfContents.tsx +0 -108
- package/src/components/comments/CommentBadge.tsx +0 -49
- package/src/components/comments/CommentInput.tsx +0 -114
- package/src/components/comments/CommentListItem.tsx +0 -92
- package/src/components/comments/CommentManager.tsx +0 -113
- package/src/components/comments/CommentMinimap.tsx +0 -62
- package/src/components/comments/CommentNav.tsx +0 -109
- package/src/components/ui/ActionBar.tsx +0 -16
- package/src/components/ui/ActionLink.tsx +0 -32
- package/src/components/ui/Button.tsx +0 -55
- package/src/components/ui/Dialog.tsx +0 -156
- package/src/components/ui/DropdownMenu.tsx +0 -114
- package/src/components/ui/SeparatorDot.tsx +0 -9
- package/src/components/ui/Text.tsx +0 -54
- package/src/contexts/CommentContext.tsx +0 -229
- package/src/contexts/LayoutContext.tsx +0 -88
- package/src/contexts/LocaleContext.tsx +0 -35
- package/src/hooks/useClickOutside.ts +0 -35
- package/src/hooks/useClipboard.ts +0 -82
- package/src/hooks/useCommentNavigation.ts +0 -130
- package/src/hooks/useComments.ts +0 -323
- package/src/hooks/useDocument.ts +0 -156
- package/src/hooks/useEditorScheme.ts +0 -51
- package/src/hooks/useFontPreference.ts +0 -59
- package/src/hooks/useHeadings.test.ts +0 -159
- package/src/hooks/useHeadings.ts +0 -129
- package/src/hooks/useKeybindings.ts +0 -108
- package/src/hooks/useKeyboardShortcuts.ts +0 -63
- package/src/hooks/useLayoutMode.ts +0 -44
- package/src/hooks/useLocalePreference.ts +0 -42
- package/src/hooks/useReanchorMode.ts +0 -33
- package/src/hooks/useScrollMetrics.ts +0 -56
- package/src/hooks/useScrollSpy.ts +0 -81
- package/src/hooks/useTextSelection.ts +0 -123
- package/src/hooks/useThemePreference.ts +0 -66
- package/src/lib/context.bench.ts +0 -41
- package/src/lib/context.test.ts +0 -224
- package/src/lib/context.ts +0 -193
- package/src/lib/editor-links.ts +0 -59
- package/src/lib/highlight/colors.ts +0 -37
- package/src/lib/highlight/index.ts +0 -23
- package/src/lib/highlight/script-builder.ts +0 -485
- package/src/lib/html-processor.test.tsx +0 -170
- package/src/lib/html-processor.tsx +0 -95
- package/src/lib/i18n/completeness.test.ts +0 -51
- package/src/lib/i18n/translations.test.ts +0 -39
- package/src/lib/layout-constants.ts +0 -12
- package/src/lib/scroll.test.ts +0 -118
- package/src/lib/scroll.ts +0 -47
- package/src/lib/shortcut-registry.test.ts +0 -173
- package/src/lib/utils.test.ts +0 -110
- package/src/main.tsx +0 -13
- package/src/store/index.test.ts +0 -242
- package/src/store/index.ts +0 -254
- package/src/types/index.ts +0 -127
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { findTextPosition } from "./
|
|
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
|
});
|
package/src/lib/highlight/dom.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import type {
|
|
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;
|
|
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;
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
range.setEnd(textNode, nodeEnd);
|
|
129
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
130
|
+
let node = walker.nextNode();
|
|
215
131
|
|
|
216
|
-
|
|
217
|
-
|
|
132
|
+
while (node) {
|
|
133
|
+
const blockParent = findBlockParent(node);
|
|
218
134
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
158
|
+
return { text, nodes };
|
|
279
159
|
}
|
|
280
160
|
|
|
281
|
-
export function
|
|
282
|
-
root:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
):
|
|
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
|
-
|
|
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
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
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
|
+
}
|