@peaske7/readit 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -3
- package/biome.json +1 -1
- package/bun.lock +43 -185
- package/docs/perf-baseline.md +75 -0
- package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
- package/e2e/perf/add-comment.spec.ts +118 -0
- package/e2e/perf/fixtures/generate.ts +331 -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/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 +286 -0
- package/e2e/perf/utils/perf-cli.ts +86 -0
- package/package.json +9 -18
- package/playwright.config.ts +12 -0
- package/src/App.tsx +133 -178
- package/src/{cli/index.ts → cli.ts} +211 -107
- package/src/components/ActionsMenu.tsx +6 -27
- package/src/components/DocumentViewer/DocumentViewer.tsx +78 -105
- package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
- package/src/components/Header.tsx +9 -20
- package/src/components/InlineEditor.tsx +5 -5
- package/src/components/MarginNote.tsx +71 -93
- package/src/components/MarginNotes.tsx +7 -34
- package/src/components/RawModal.tsx +9 -8
- package/src/components/ReanchorConfirm.tsx +2 -2
- package/src/components/SettingsModal.tsx +11 -89
- package/src/components/TabBar.tsx +4 -4
- package/src/components/TableOfContents.tsx +5 -5
- package/src/components/comments/CommentInput.tsx +7 -35
- package/src/components/comments/CommentListItem.tsx +9 -11
- package/src/components/comments/CommentManager.tsx +53 -37
- package/src/components/comments/CommentNav.tsx +14 -14
- package/src/components/ui/ActionLink.tsx +14 -18
- package/src/components/ui/Button.tsx +42 -43
- package/src/components/ui/Dialog.tsx +73 -113
- package/src/components/ui/DropdownMenu.tsx +113 -69
- package/src/components/ui/Text.tsx +30 -37
- package/src/contexts/CommentContext.tsx +75 -106
- package/src/contexts/LocaleContext.tsx +45 -4
- package/src/contexts/PositionsContext.tsx +16 -0
- package/src/contexts/SettingsContext.tsx +133 -0
- package/src/hooks/useClickOutside.ts +0 -4
- package/src/hooks/useCommentNavigation.ts +6 -29
- package/src/hooks/useComments.ts +6 -18
- package/src/hooks/useDocument.ts +35 -34
- package/src/hooks/useHeadings.test.ts +8 -50
- package/src/hooks/useHeadings.ts +5 -88
- package/src/hooks/useScrollSpy.ts +10 -14
- package/src/hooks/useTextSelection.ts +1 -38
- package/src/lib/__fixtures__/bench-data.ts +1 -41
- package/src/lib/anchor.bench.ts +57 -67
- package/src/lib/anchor.test.ts +5 -1
- package/src/lib/anchor.ts +13 -93
- package/src/lib/comment-storage.test.ts +4 -4
- package/src/lib/comment-storage.ts +2 -46
- package/src/lib/export.ts +7 -13
- package/src/lib/highlight/core.test.ts +1 -1
- package/src/lib/highlight/dom.ts +5 -68
- package/src/lib/highlight/highlighter.ts +102 -262
- package/src/lib/highlight/resolver.ts +112 -0
- package/src/lib/highlight/types.ts +0 -35
- package/src/lib/highlight/worker.ts +45 -0
- package/src/lib/i18n/en.ts +1 -50
- package/src/lib/i18n/ja.ts +1 -50
- package/src/lib/i18n/types.ts +1 -49
- package/src/lib/margin-layout.ts +5 -27
- package/src/lib/positions.ts +150 -0
- package/src/lib/utils.ts +2 -19
- package/src/schema.ts +81 -0
- package/src/{server/index.ts → server.ts} +111 -81
- package/src/{store/index.ts → store.ts} +14 -46
- package/vite.config.ts +8 -0
- package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
- package/src/components/DocumentViewer/InlineCode.tsx +0 -60
- package/src/components/DocumentViewer/index.ts +0 -1
- package/src/components/FloatingTOC.tsx +0 -61
- package/src/components/ShortcutCapture.tsx +0 -48
- package/src/components/ShortcutList.tsx +0 -198
- package/src/components/comments/CommentMinimap.tsx +0 -62
- package/src/components/ui/ActionBar.tsx +0 -16
- package/src/components/ui/SeparatorDot.tsx +0 -9
- package/src/contexts/LayoutContext.tsx +0 -88
- package/src/hooks/useClipboard.ts +0 -82
- package/src/hooks/useEditorScheme.ts +0 -51
- package/src/hooks/useFontPreference.ts +0 -59
- 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/useThemePreference.ts +0 -66
- package/src/lib/comment-storage.bench.ts +0 -63
- 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/export.bench.ts +0 -35
- package/src/lib/highlight/colors.ts +0 -37
- package/src/lib/highlight/core.ts +0 -54
- 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/margin-layout.bench.ts +0 -28
- 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/shortcut-registry.ts +0 -209
- package/src/lib/utils.test.ts +0 -110
- package/src/store/index.test.ts +0 -242
- package/src/types/index.ts +0 -127
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import { findTextPosition } from "./core";
|
|
2
1
|
import {
|
|
3
2
|
applyHighlightBatch,
|
|
4
3
|
applyHighlightToRange,
|
|
5
4
|
clearHighlights,
|
|
6
|
-
collectHighlightPositions,
|
|
7
5
|
getDOMTextContent,
|
|
8
6
|
getTextOffset,
|
|
9
7
|
} from "./dom";
|
|
10
|
-
import
|
|
8
|
+
import { Resolver } from "./resolver";
|
|
9
|
+
import type { HighlightComment } from "./types";
|
|
11
10
|
|
|
12
11
|
export type SelectionHandler = (
|
|
13
12
|
text: string,
|
|
@@ -15,53 +14,35 @@ export type SelectionHandler = (
|
|
|
15
14
|
endOffset: number,
|
|
16
15
|
selectionTop: number,
|
|
17
16
|
) => void;
|
|
18
|
-
export type PositionChangeHandler = (positions: HighlightPositions) => void;
|
|
19
17
|
export type HoverHandler = (commentId: string | undefined) => void;
|
|
20
18
|
export type ClickHandler = (commentId: string) => void;
|
|
21
|
-
export type ContentHeightHandler = (height: number) => void;
|
|
22
|
-
|
|
23
19
|
export interface Highlighter {
|
|
24
|
-
applyHighlights(
|
|
25
|
-
comments: HighlightComment[],
|
|
26
|
-
pendingSelection?: TextRange,
|
|
27
|
-
): void;
|
|
20
|
+
applyHighlights(comments: HighlightComment[]): void;
|
|
28
21
|
clearHighlights(): void;
|
|
29
|
-
getPositions(): HighlightPositions;
|
|
30
|
-
onPositionsChange(callback: PositionChangeHandler): () => void;
|
|
31
22
|
onHighlightHover(callback: HoverHandler): () => void;
|
|
32
23
|
onHighlightClick(callback: ClickHandler): () => void;
|
|
33
|
-
onContentHeightChange?(callback: ContentHeightHandler): () => void;
|
|
34
24
|
dispose(): void;
|
|
35
25
|
}
|
|
36
26
|
|
|
37
|
-
interface
|
|
38
|
-
type: "markdown";
|
|
27
|
+
export interface HighlighterOptions {
|
|
39
28
|
root: HTMLElement;
|
|
40
29
|
container: HTMLElement;
|
|
41
30
|
onSelect: SelectionHandler;
|
|
42
31
|
}
|
|
43
32
|
|
|
44
|
-
interface IframeOptions {
|
|
45
|
-
type: "iframe";
|
|
46
|
-
getIframe: () => HTMLIFrameElement | null;
|
|
47
|
-
onSelect: SelectionHandler;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export type HighlighterOptions = MarkdownOptions | IframeOptions;
|
|
51
|
-
|
|
52
33
|
export function createHighlighter(options: HighlighterOptions): Highlighter {
|
|
53
|
-
return options.type === "markdown"
|
|
54
|
-
? createMarkdownHighlighter(options)
|
|
55
|
-
: createIframeHighlighter(options);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function createMarkdownHighlighter(options: MarkdownOptions): Highlighter {
|
|
59
34
|
const { root, container, onSelect } = options;
|
|
60
35
|
|
|
61
|
-
let positionCallback: PositionChangeHandler | undefined;
|
|
62
36
|
let hoverCallback: HoverHandler | undefined;
|
|
63
37
|
let clickCallback: ClickHandler | undefined;
|
|
64
|
-
|
|
38
|
+
|
|
39
|
+
// Incremental diffing state — avoids full clear+rebuild on every comment change
|
|
40
|
+
const activeHighlights = new Map<string, { start: number; end: number }>();
|
|
41
|
+
let lastTextContent = "";
|
|
42
|
+
|
|
43
|
+
// Web Worker for anchor resolution (offloads indexOf from main thread)
|
|
44
|
+
const resolver = new Resolver();
|
|
45
|
+
let resolveGeneration = 0;
|
|
65
46
|
|
|
66
47
|
const handleMouseUp = () => {
|
|
67
48
|
const selection = window.getSelection();
|
|
@@ -145,252 +126,117 @@ function createMarkdownHighlighter(options: MarkdownOptions): Highlighter {
|
|
|
145
126
|
}
|
|
146
127
|
};
|
|
147
128
|
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
129
|
+
const applyDiff = (
|
|
130
|
+
textContent: string,
|
|
131
|
+
resolved: Map<
|
|
132
|
+
string,
|
|
133
|
+
{ start: number; end: number; comment: HighlightComment }
|
|
134
|
+
>,
|
|
135
|
+
) => {
|
|
136
|
+
const toRemove: string[] = [];
|
|
137
|
+
const toAdd: string[] = [];
|
|
138
|
+
|
|
139
|
+
for (const [id, prev] of activeHighlights) {
|
|
140
|
+
const next = resolved.get(id);
|
|
141
|
+
if (!next) {
|
|
142
|
+
toRemove.push(id);
|
|
143
|
+
} else if (prev.start !== next.start || prev.end !== next.end) {
|
|
144
|
+
toRemove.push(id);
|
|
145
|
+
toAdd.push(id);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
166
148
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
window.addEventListener("resize", updatePositions);
|
|
149
|
+
for (const id of resolved.keys()) {
|
|
150
|
+
if (!activeHighlights.has(id)) {
|
|
151
|
+
toAdd.push(id);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
173
154
|
|
|
174
|
-
|
|
175
|
-
applyHighlights(
|
|
176
|
-
comments: HighlightComment[],
|
|
177
|
-
_pendingSelection?: TextRange,
|
|
178
|
-
) {
|
|
179
|
-
clearHighlights(root);
|
|
155
|
+
if (toRemove.length === 0 && toAdd.length === 0) return;
|
|
180
156
|
|
|
181
|
-
|
|
157
|
+
for (const id of toRemove) {
|
|
158
|
+
clearHighlights(root, `mark[data-comment-id="${id}"]`);
|
|
159
|
+
activeHighlights.delete(id);
|
|
160
|
+
}
|
|
182
161
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
c.selectedText,
|
|
188
|
-
c.startOffset,
|
|
189
|
-
);
|
|
190
|
-
if (anchor) {
|
|
191
|
-
return { ...c, startOffset: anchor.start, endOffset: anchor.end };
|
|
192
|
-
}
|
|
193
|
-
return null;
|
|
194
|
-
})
|
|
195
|
-
.filter((c): c is HighlightComment => c !== null)
|
|
196
|
-
.sort((a, b) => a.startOffset - b.startOffset);
|
|
162
|
+
if (toAdd.length > 0) {
|
|
163
|
+
const newHighlights = toAdd
|
|
164
|
+
.map((id) => resolved.get(id)!)
|
|
165
|
+
.sort((a, b) => a.start - b.start);
|
|
197
166
|
|
|
198
167
|
applyHighlightBatch(
|
|
199
168
|
root,
|
|
200
169
|
textContent,
|
|
201
|
-
|
|
202
|
-
startOffset:
|
|
203
|
-
endOffset:
|
|
170
|
+
newHighlights.map((h) => ({
|
|
171
|
+
startOffset: h.start,
|
|
172
|
+
endOffset: h.end,
|
|
204
173
|
style: {
|
|
205
174
|
attribute: "data-comment-id",
|
|
206
|
-
attributeValue: comment.id,
|
|
175
|
+
attributeValue: h.comment.id,
|
|
207
176
|
colorIndex: 0,
|
|
208
177
|
},
|
|
209
178
|
})),
|
|
210
179
|
);
|
|
211
180
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
},
|
|
216
|
-
|
|
217
|
-
clearHighlights() {
|
|
218
|
-
clearHighlights(root);
|
|
219
|
-
},
|
|
220
|
-
|
|
221
|
-
getPositions(): HighlightPositions {
|
|
222
|
-
const containerRect = container.getBoundingClientRect();
|
|
223
|
-
return collectHighlightPositions(root, containerRect, window.scrollY);
|
|
224
|
-
},
|
|
225
|
-
|
|
226
|
-
onPositionsChange(callback: PositionChangeHandler) {
|
|
227
|
-
positionCallback = callback;
|
|
228
|
-
return () => {
|
|
229
|
-
positionCallback = undefined;
|
|
230
|
-
};
|
|
231
|
-
},
|
|
232
|
-
|
|
233
|
-
onHighlightHover(callback: HoverHandler) {
|
|
234
|
-
hoverCallback = callback;
|
|
235
|
-
return () => {
|
|
236
|
-
hoverCallback = undefined;
|
|
237
|
-
};
|
|
238
|
-
},
|
|
239
|
-
|
|
240
|
-
onHighlightClick(callback: ClickHandler) {
|
|
241
|
-
clickCallback = callback;
|
|
242
|
-
return () => {
|
|
243
|
-
clickCallback = undefined;
|
|
244
|
-
};
|
|
245
|
-
},
|
|
246
|
-
|
|
247
|
-
dispose() {
|
|
248
|
-
root.removeEventListener("mouseup", handleMouseUp);
|
|
249
|
-
root.removeEventListener("mouseover", handleMouseOver);
|
|
250
|
-
root.removeEventListener("mouseout", handleMouseOut);
|
|
251
|
-
root.removeEventListener("click", handleClick);
|
|
252
|
-
window.removeEventListener("scroll", handleScroll);
|
|
253
|
-
window.removeEventListener("resize", updatePositions);
|
|
254
|
-
if (scrollRafId !== null) {
|
|
255
|
-
cancelAnimationFrame(scrollRafId);
|
|
181
|
+
for (const id of toAdd) {
|
|
182
|
+
const range = resolved.get(id)!;
|
|
183
|
+
activeHighlights.set(id, { start: range.start, end: range.end });
|
|
256
184
|
}
|
|
257
|
-
|
|
258
|
-
hoverCallback = undefined;
|
|
259
|
-
clickCallback = undefined;
|
|
260
|
-
},
|
|
185
|
+
}
|
|
261
186
|
};
|
|
262
|
-
}
|
|
263
187
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
let positionCallback: PositionChangeHandler | undefined;
|
|
269
|
-
let hoverCallback: HoverHandler | undefined;
|
|
270
|
-
let clickCallback: ClickHandler | undefined;
|
|
271
|
-
let contentHeightCallback: ContentHeightHandler | undefined;
|
|
272
|
-
let pendingHighlights:
|
|
273
|
-
| { comments: HighlightComment[]; pending?: TextRange }
|
|
274
|
-
| undefined;
|
|
275
|
-
|
|
276
|
-
const handleMessage = (event: MessageEvent) => {
|
|
277
|
-
const iframe = getIframe();
|
|
278
|
-
if (!iframe || iframe.contentWindow !== event.source) return;
|
|
279
|
-
|
|
280
|
-
switch (event.data.type) {
|
|
281
|
-
case "iframeReady":
|
|
282
|
-
isReady = true;
|
|
283
|
-
if (pendingHighlights) {
|
|
284
|
-
sendHighlights(pendingHighlights.comments, pendingHighlights.pending);
|
|
285
|
-
pendingHighlights = undefined;
|
|
286
|
-
}
|
|
287
|
-
break;
|
|
288
|
-
|
|
289
|
-
case "textSelection":
|
|
290
|
-
onSelect(
|
|
291
|
-
event.data.text,
|
|
292
|
-
event.data.startOffset,
|
|
293
|
-
event.data.endOffset,
|
|
294
|
-
event.data.selectionTop ?? 0,
|
|
295
|
-
);
|
|
296
|
-
break;
|
|
297
|
-
|
|
298
|
-
case "highlightPositions":
|
|
299
|
-
if (positionCallback) {
|
|
300
|
-
const positions: Record<string, number> = {};
|
|
301
|
-
const documentPositions: Record<string, number> = {};
|
|
302
|
-
for (const [id, top] of Object.entries(event.data.positions)) {
|
|
303
|
-
if (typeof top === "number") {
|
|
304
|
-
positions[id] = top;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
for (const [id, top] of Object.entries(
|
|
308
|
-
event.data.documentPositions || {},
|
|
309
|
-
)) {
|
|
310
|
-
if (typeof top === "number") {
|
|
311
|
-
documentPositions[id] = top;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
positionCallback({
|
|
315
|
-
positions,
|
|
316
|
-
documentPositions,
|
|
317
|
-
pendingTop:
|
|
318
|
-
typeof event.data.pendingTop === "number"
|
|
319
|
-
? event.data.pendingTop
|
|
320
|
-
: undefined,
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
break;
|
|
188
|
+
root.addEventListener("mouseup", handleMouseUp);
|
|
189
|
+
root.addEventListener("mouseover", handleMouseOver);
|
|
190
|
+
root.addEventListener("mouseout", handleMouseOut);
|
|
191
|
+
root.addEventListener("click", handleClick);
|
|
324
192
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
break;
|
|
193
|
+
return {
|
|
194
|
+
applyHighlights(comments: HighlightComment[]) {
|
|
195
|
+
const textContent = getDOMTextContent(root);
|
|
330
196
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
197
|
+
// If DOM content changed (e.g. document reload), full rebuild is required
|
|
198
|
+
const contentChanged = textContent !== lastTextContent;
|
|
199
|
+
if (contentChanged) {
|
|
200
|
+
clearHighlights(root);
|
|
201
|
+
activeHighlights.clear();
|
|
202
|
+
lastTextContent = textContent;
|
|
203
|
+
}
|
|
336
204
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
contentHeightCallback(event.data.height);
|
|
340
|
-
}
|
|
341
|
-
break;
|
|
342
|
-
}
|
|
343
|
-
};
|
|
205
|
+
// Bump generation so stale Worker responses are discarded
|
|
206
|
+
const generation = ++resolveGeneration;
|
|
344
207
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const iframe = getIframe();
|
|
350
|
-
iframe?.contentWindow?.postMessage(
|
|
351
|
-
{
|
|
352
|
-
type: "applyHighlights",
|
|
353
|
-
comments: comments.map((c) => ({
|
|
354
|
-
id: c.id,
|
|
355
|
-
selectedText: c.selectedText,
|
|
356
|
-
startOffset: c.startOffset,
|
|
357
|
-
endOffset: c.endOffset,
|
|
358
|
-
})),
|
|
359
|
-
pendingSelection: pending ?? null,
|
|
360
|
-
},
|
|
361
|
-
"*",
|
|
362
|
-
);
|
|
363
|
-
};
|
|
208
|
+
// Resolve anchors off the main thread, then diff and apply
|
|
209
|
+
resolver.resolve(textContent, comments).then((anchorMap) => {
|
|
210
|
+
// Discard if a newer applyHighlights call has started
|
|
211
|
+
if (generation !== resolveGeneration) return;
|
|
364
212
|
|
|
365
|
-
|
|
213
|
+
const resolved = new Map<
|
|
214
|
+
string,
|
|
215
|
+
{ start: number; end: number; comment: HighlightComment }
|
|
216
|
+
>();
|
|
217
|
+
for (const c of comments) {
|
|
218
|
+
const anchor = anchorMap.get(c.id);
|
|
219
|
+
if (anchor) {
|
|
220
|
+
resolved.set(c.id, {
|
|
221
|
+
start: anchor.start,
|
|
222
|
+
end: anchor.end,
|
|
223
|
+
comment: {
|
|
224
|
+
...c,
|
|
225
|
+
startOffset: anchor.start,
|
|
226
|
+
endOffset: anchor.end,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
366
231
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
comments: HighlightComment[],
|
|
370
|
-
pendingSelection?: TextRange,
|
|
371
|
-
) {
|
|
372
|
-
if (isReady) {
|
|
373
|
-
sendHighlights(comments, pendingSelection);
|
|
374
|
-
} else {
|
|
375
|
-
pendingHighlights = { comments, pending: pendingSelection };
|
|
376
|
-
}
|
|
232
|
+
applyDiff(textContent, resolved);
|
|
233
|
+
});
|
|
377
234
|
},
|
|
378
235
|
|
|
379
236
|
clearHighlights() {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
},
|
|
384
|
-
|
|
385
|
-
getPositions(): HighlightPositions {
|
|
386
|
-
return { positions: {}, documentPositions: {} };
|
|
387
|
-
},
|
|
388
|
-
|
|
389
|
-
onPositionsChange(callback: PositionChangeHandler) {
|
|
390
|
-
positionCallback = callback;
|
|
391
|
-
return () => {
|
|
392
|
-
positionCallback = undefined;
|
|
393
|
-
};
|
|
237
|
+
clearHighlights(root);
|
|
238
|
+
activeHighlights.clear();
|
|
239
|
+
lastTextContent = "";
|
|
394
240
|
},
|
|
395
241
|
|
|
396
242
|
onHighlightHover(callback: HoverHandler) {
|
|
@@ -407,21 +253,15 @@ function createIframeHighlighter(options: IframeOptions): Highlighter {
|
|
|
407
253
|
};
|
|
408
254
|
},
|
|
409
255
|
|
|
410
|
-
onContentHeightChange(callback: ContentHeightHandler) {
|
|
411
|
-
contentHeightCallback = callback;
|
|
412
|
-
return () => {
|
|
413
|
-
contentHeightCallback = undefined;
|
|
414
|
-
};
|
|
415
|
-
},
|
|
416
|
-
|
|
417
256
|
dispose() {
|
|
418
|
-
|
|
419
|
-
|
|
257
|
+
resolveGeneration++;
|
|
258
|
+
resolver.dispose();
|
|
259
|
+
root.removeEventListener("mouseup", handleMouseUp);
|
|
260
|
+
root.removeEventListener("mouseover", handleMouseOver);
|
|
261
|
+
root.removeEventListener("mouseout", handleMouseOut);
|
|
262
|
+
root.removeEventListener("click", handleClick);
|
|
420
263
|
hoverCallback = undefined;
|
|
421
264
|
clickCallback = undefined;
|
|
422
|
-
contentHeightCallback = undefined;
|
|
423
|
-
isReady = false;
|
|
424
|
-
pendingHighlights = undefined;
|
|
425
265
|
},
|
|
426
266
|
};
|
|
427
267
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { HighlightComment, TextPosition } from "./types";
|
|
2
|
+
|
|
3
|
+
export function findTextPosition(
|
|
4
|
+
textContent: string,
|
|
5
|
+
selectedText: string,
|
|
6
|
+
hintOffset?: number,
|
|
7
|
+
): TextPosition | undefined {
|
|
8
|
+
if (!selectedText || !textContent) return undefined;
|
|
9
|
+
|
|
10
|
+
const occurrences: number[] = [];
|
|
11
|
+
let idx = 0;
|
|
12
|
+
for (;;) {
|
|
13
|
+
idx = textContent.indexOf(selectedText, idx);
|
|
14
|
+
if (idx === -1) break;
|
|
15
|
+
occurrences.push(idx);
|
|
16
|
+
idx += 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (occurrences.length === 0) return undefined;
|
|
20
|
+
if (occurrences.length === 1) {
|
|
21
|
+
return { start: occurrences[0], end: occurrences[0] + selectedText.length };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const target = hintOffset ?? 0;
|
|
25
|
+
let closest = occurrences[0];
|
|
26
|
+
let minDist = Math.abs(closest - target);
|
|
27
|
+
for (const occ of occurrences) {
|
|
28
|
+
const dist = Math.abs(occ - target);
|
|
29
|
+
if (dist < minDist) {
|
|
30
|
+
minDist = dist;
|
|
31
|
+
closest = occ;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { start: closest, end: closest + selectedText.length };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class Resolver {
|
|
38
|
+
private worker: Worker | null = null;
|
|
39
|
+
private seq = 0;
|
|
40
|
+
private pending = new Map<
|
|
41
|
+
number,
|
|
42
|
+
(results: Map<string, TextPosition>) => void
|
|
43
|
+
>();
|
|
44
|
+
|
|
45
|
+
constructor() {
|
|
46
|
+
try {
|
|
47
|
+
this.worker = new Worker(new URL("./worker.ts", import.meta.url), {
|
|
48
|
+
type: "module",
|
|
49
|
+
});
|
|
50
|
+
this.worker.onmessage = this.onMessage;
|
|
51
|
+
this.worker.onerror = () => {
|
|
52
|
+
this.worker?.terminate();
|
|
53
|
+
this.worker = null;
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
this.worker = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
resolve(
|
|
61
|
+
text: string,
|
|
62
|
+
comments: HighlightComment[],
|
|
63
|
+
): Promise<Map<string, TextPosition>> {
|
|
64
|
+
if (!this.worker || comments.length === 0) {
|
|
65
|
+
return Promise.resolve(this.sync(text, comments));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const id = this.seq++;
|
|
70
|
+
this.pending.set(id, resolve);
|
|
71
|
+
this.worker!.postMessage({
|
|
72
|
+
id,
|
|
73
|
+
textContent: text,
|
|
74
|
+
comments: comments.map((c) => ({
|
|
75
|
+
id: c.id,
|
|
76
|
+
selectedText: c.selectedText,
|
|
77
|
+
startOffset: c.startOffset,
|
|
78
|
+
})),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
dispose() {
|
|
84
|
+
this.worker?.terminate();
|
|
85
|
+
this.worker = null;
|
|
86
|
+
for (const resolve of this.pending.values()) resolve(new Map());
|
|
87
|
+
this.pending.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private onMessage = (e: MessageEvent) => {
|
|
91
|
+
const { id, results } = e.data;
|
|
92
|
+
const resolve = this.pending.get(id);
|
|
93
|
+
if (!resolve) return;
|
|
94
|
+
|
|
95
|
+
this.pending.delete(id);
|
|
96
|
+
const map = new Map<string, TextPosition>();
|
|
97
|
+
for (const r of results) map.set(r.id, { start: r.start, end: r.end });
|
|
98
|
+
resolve(map);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
private sync(
|
|
102
|
+
text: string,
|
|
103
|
+
comments: HighlightComment[],
|
|
104
|
+
): Map<string, TextPosition> {
|
|
105
|
+
const map = new Map<string, TextPosition>();
|
|
106
|
+
for (const c of comments) {
|
|
107
|
+
const pos = findTextPosition(text, c.selectedText, c.startOffset);
|
|
108
|
+
if (pos) map.set(c.id, pos);
|
|
109
|
+
}
|
|
110
|
+
return map;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -1,34 +1,13 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared types for the highlighting system.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Style configuration for highlight marks.
|
|
7
|
-
*/
|
|
8
1
|
export interface HighlightStyle {
|
|
9
2
|
attribute: string;
|
|
10
3
|
attributeValue: string;
|
|
11
4
|
}
|
|
12
5
|
|
|
13
|
-
/**
|
|
14
|
-
* Text range with character offsets.
|
|
15
|
-
*/
|
|
16
|
-
export interface TextRange {
|
|
17
|
-
startOffset: number;
|
|
18
|
-
endOffset: number;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Resolved text position in content.
|
|
23
|
-
*/
|
|
24
6
|
export interface TextPosition {
|
|
25
7
|
start: number;
|
|
26
8
|
end: number;
|
|
27
9
|
}
|
|
28
10
|
|
|
29
|
-
/**
|
|
30
|
-
* Comment data needed for highlighting (subset of full Comment type).
|
|
31
|
-
*/
|
|
32
11
|
export interface HighlightComment {
|
|
33
12
|
id: string;
|
|
34
13
|
selectedText: string;
|
|
@@ -36,20 +15,6 @@ export interface HighlightComment {
|
|
|
36
15
|
endOffset: number;
|
|
37
16
|
}
|
|
38
17
|
|
|
39
|
-
/**
|
|
40
|
-
* Highlight positions for margin note alignment and minimap.
|
|
41
|
-
* - positions: Y-position relative to container (for margin notes)
|
|
42
|
-
* - documentPositions: Y-position from document top (for minimap)
|
|
43
|
-
*/
|
|
44
|
-
export interface HighlightPositions {
|
|
45
|
-
positions: Record<string, number>;
|
|
46
|
-
documentPositions: Record<string, number>;
|
|
47
|
-
pendingTop?: number;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Text node with its cumulative offset range in the document.
|
|
52
|
-
*/
|
|
53
18
|
export interface TextNodeInfo {
|
|
54
19
|
node: Text;
|
|
55
20
|
start: number;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Inlined to avoid module import issues in Worker context
|
|
2
|
+
function find(
|
|
3
|
+
text: string,
|
|
4
|
+
needle: string,
|
|
5
|
+
hint?: number,
|
|
6
|
+
): { start: number; end: number } | undefined {
|
|
7
|
+
if (!needle || !text) return undefined;
|
|
8
|
+
|
|
9
|
+
const hits: number[] = [];
|
|
10
|
+
let i = 0;
|
|
11
|
+
for (;;) {
|
|
12
|
+
i = text.indexOf(needle, i);
|
|
13
|
+
if (i === -1) break;
|
|
14
|
+
hits.push(i);
|
|
15
|
+
i += 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (hits.length === 0) return undefined;
|
|
19
|
+
if (hits.length === 1)
|
|
20
|
+
return { start: hits[0], end: hits[0] + needle.length };
|
|
21
|
+
|
|
22
|
+
const target = hint ?? 0;
|
|
23
|
+
let best = hits[0];
|
|
24
|
+
let bestDist = Math.abs(best - target);
|
|
25
|
+
for (const h of hits) {
|
|
26
|
+
const d = Math.abs(h - target);
|
|
27
|
+
if (d < bestDist) {
|
|
28
|
+
bestDist = d;
|
|
29
|
+
best = h;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { start: best, end: best + needle.length };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
self.onmessage = (e: MessageEvent) => {
|
|
36
|
+
const { id, textContent, comments } = e.data;
|
|
37
|
+
const results: { id: string; start: number; end: number }[] = [];
|
|
38
|
+
|
|
39
|
+
for (const c of comments) {
|
|
40
|
+
const pos = find(textContent, c.selectedText, c.startOffset);
|
|
41
|
+
if (pos) results.push({ id: c.id, ...pos });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
self.postMessage({ id, results });
|
|
45
|
+
};
|