@peaske7/readit 0.1.8 → 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 +124 -172
- package/src/{cli/index.ts → cli.ts} +37 -53
- package/src/components/ActionsMenu.tsx +6 -27
- package/src/components/DocumentViewer/DocumentViewer.tsx +77 -106
- 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} +74 -74
- 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
package/src/hooks/useHeadings.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import { slugify } from "../lib/utils";
|
|
3
|
-
import type { DocumentType } from "../types";
|
|
4
3
|
|
|
5
4
|
export interface Heading {
|
|
6
5
|
id: string;
|
|
@@ -8,29 +7,17 @@ export interface Heading {
|
|
|
8
7
|
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
/**
|
|
12
|
-
* Remove code blocks from markdown content.
|
|
13
|
-
* Handles both fenced (```) and indented (4 spaces) code blocks.
|
|
14
|
-
*/
|
|
15
10
|
function stripCodeBlocks(content: string): string {
|
|
16
|
-
// Remove fenced code blocks (``` or ~~~)
|
|
17
11
|
let result = content.replace(/^(`{3,}|~{3,}).*$[\s\S]*?^\1\s*$/gm, "");
|
|
18
|
-
|
|
19
|
-
// Remove indented code blocks (4 spaces or 1 tab at start of line)
|
|
20
|
-
// Only remove if preceded by a blank line (to avoid removing list items)
|
|
12
|
+
// Only remove indented blocks preceded by a blank line (avoids removing list items)
|
|
21
13
|
result = result.replace(/(?:^|\n\n)((?:(?:[ ]{4}|\t).+\n?)+)/g, "\n\n");
|
|
22
14
|
|
|
23
15
|
return result;
|
|
24
16
|
}
|
|
25
17
|
|
|
26
|
-
/**
|
|
27
|
-
* Extract headings from markdown content
|
|
28
|
-
*/
|
|
29
18
|
function parseMarkdownHeadings(content: string): Heading[] {
|
|
30
19
|
const headings: Heading[] = [];
|
|
31
20
|
const seenIds = new Map<string, number>();
|
|
32
|
-
|
|
33
|
-
// Strip code blocks to avoid matching # comments in code
|
|
34
21
|
const contentWithoutCode = stripCodeBlocks(content);
|
|
35
22
|
|
|
36
23
|
const regex = /^(#{1,6})\s+(.+)$/gm;
|
|
@@ -51,79 +38,9 @@ function parseMarkdownHeadings(content: string): Heading[] {
|
|
|
51
38
|
return headings;
|
|
52
39
|
}
|
|
53
40
|
|
|
54
|
-
|
|
55
|
-
* Generate ID matching the iframe's ensureHeadingIds algorithm.
|
|
56
|
-
* Note: This differs from utils/slugify - it strips underscores to match
|
|
57
|
-
* the iframe script's ID generation exactly.
|
|
58
|
-
*/
|
|
59
|
-
function generateHeadingId(text: string): string {
|
|
60
|
-
return text
|
|
61
|
-
.toLowerCase()
|
|
62
|
-
.trim()
|
|
63
|
-
.replace(/[^a-z0-9 -]/g, "")
|
|
64
|
-
.replace(/ +/g, "-")
|
|
65
|
-
.replace(/-+/g, "-");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Extract headings from HTML content
|
|
70
|
-
*/
|
|
71
|
-
function parseHtmlHeadings(content: string): Heading[] {
|
|
72
|
-
const headings: Heading[] = [];
|
|
73
|
-
const seenIds = new Map<string, number>();
|
|
74
|
-
|
|
75
|
-
// Match h1-h6 tags, capturing attributes and text content
|
|
76
|
-
const regex = /<h([1-6])([^>]*)>([^<]+)<\/h\1>/gi;
|
|
77
|
-
let match = regex.exec(content);
|
|
78
|
-
|
|
79
|
-
while (match !== null) {
|
|
80
|
-
const level = Number.parseInt(match[1], 10) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
81
|
-
const attributes = match[2];
|
|
82
|
-
// Strip any remaining HTML tags and decode entities
|
|
83
|
-
const text = match[3]
|
|
84
|
-
.replace(/<[^>]+>/g, "")
|
|
85
|
-
.replace(/&/g, "&")
|
|
86
|
-
.replace(/</g, "<")
|
|
87
|
-
.replace(/>/g, ">")
|
|
88
|
-
.replace(/"/g, '"')
|
|
89
|
-
.trim();
|
|
90
|
-
|
|
91
|
-
if (text) {
|
|
92
|
-
// Extract existing id attribute if present
|
|
93
|
-
const idMatch = /\sid=["']([^"']+)["']/i.exec(attributes);
|
|
94
|
-
|
|
95
|
-
// Use existing ID or generate one with duplicate handling
|
|
96
|
-
const id = idMatch
|
|
97
|
-
? idMatch[1]
|
|
98
|
-
: (() => {
|
|
99
|
-
const baseId = generateHeadingId(text);
|
|
100
|
-
const count = seenIds.get(baseId) ?? 0;
|
|
101
|
-
seenIds.set(baseId, count + 1);
|
|
102
|
-
return count > 0 ? `${baseId}-${count}` : baseId;
|
|
103
|
-
})();
|
|
104
|
-
|
|
105
|
-
headings.push({ id, text, level });
|
|
106
|
-
}
|
|
107
|
-
match = regex.exec(content);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return headings;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Hook to extract headings from document content
|
|
115
|
-
*/
|
|
116
|
-
export function useHeadings(
|
|
117
|
-
content: string | null,
|
|
118
|
-
type: DocumentType | null,
|
|
119
|
-
): Heading[] {
|
|
41
|
+
export function useHeadings(content: string | null): Heading[] {
|
|
120
42
|
return useMemo(() => {
|
|
121
|
-
if (!content
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return parseMarkdownHeadings(content);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return parseHtmlHeadings(content);
|
|
128
|
-
}, [content, type]);
|
|
43
|
+
if (!content) return [];
|
|
44
|
+
return parseMarkdownHeadings(content);
|
|
45
|
+
}, [content]);
|
|
129
46
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export function useScrollSpy(headingIds: string[]): string | null {
|
|
3
|
+
export function useScrollSpy(
|
|
4
|
+
headingIds: string[],
|
|
5
|
+
enabled = true,
|
|
6
|
+
): string | null {
|
|
8
7
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
9
8
|
const hasSetInitialRef = useRef(false);
|
|
10
9
|
|
|
11
10
|
useEffect(() => {
|
|
12
|
-
if (headingIds.length === 0) {
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
if (!enabled || headingIds.length === 0) {
|
|
12
|
+
if (headingIds.length === 0) {
|
|
13
|
+
setActiveId(null);
|
|
14
|
+
hasSetInitialRef.current = false;
|
|
15
|
+
}
|
|
15
16
|
return;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
// Track visible headings and their positions
|
|
19
19
|
const visibleHeadings = new Map<string, number>();
|
|
20
20
|
|
|
21
21
|
const observer = new IntersectionObserver(
|
|
@@ -24,20 +24,17 @@ export function useScrollSpy(headingIds: string[]): string | null {
|
|
|
24
24
|
const id = entry.target.id;
|
|
25
25
|
|
|
26
26
|
if (entry.isIntersecting) {
|
|
27
|
-
// Store the top position when heading becomes visible
|
|
28
27
|
visibleHeadings.set(id, entry.boundingClientRect.top);
|
|
29
28
|
} else {
|
|
30
29
|
visibleHeadings.delete(id);
|
|
31
30
|
}
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
// Find the heading closest to the top of the viewport
|
|
35
33
|
if (visibleHeadings.size > 0) {
|
|
36
34
|
let closestId: string | null = null;
|
|
37
35
|
let closestDistance = Number.POSITIVE_INFINITY;
|
|
38
36
|
|
|
39
37
|
for (const [id, top] of visibleHeadings) {
|
|
40
|
-
// Prefer headings that are near the top but still visible
|
|
41
38
|
const distance = Math.abs(top);
|
|
42
39
|
if (distance < closestDistance) {
|
|
43
40
|
closestDistance = distance;
|
|
@@ -64,7 +61,6 @@ export function useScrollSpy(headingIds: string[]): string | null {
|
|
|
64
61
|
hasSetInitialRef.current = true;
|
|
65
62
|
}
|
|
66
63
|
|
|
67
|
-
// Observe all headings
|
|
68
64
|
for (const id of headingIds) {
|
|
69
65
|
const element = document.getElementById(id);
|
|
70
66
|
if (element) {
|
|
@@ -75,7 +71,7 @@ export function useScrollSpy(headingIds: string[]): string | null {
|
|
|
75
71
|
return () => {
|
|
76
72
|
observer.disconnect();
|
|
77
73
|
};
|
|
78
|
-
}, [headingIds]);
|
|
74
|
+
}, [headingIds, enabled]);
|
|
79
75
|
|
|
80
76
|
return activeId;
|
|
81
77
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect } from "react";
|
|
2
|
+
import type { Selection } from "../schema";
|
|
2
3
|
import { appStore, useAppStore } from "../store";
|
|
3
|
-
import type { Selection } from "../types";
|
|
4
4
|
|
|
5
5
|
/** Remove pending highlight marks from the DOM without triggering a full clear/reapply cycle. */
|
|
6
6
|
function clearPendingMarks() {
|
|
@@ -14,8 +14,6 @@ function clearPendingMarks() {
|
|
|
14
14
|
|
|
15
15
|
interface UseTextSelectionResult {
|
|
16
16
|
selection: Selection | null;
|
|
17
|
-
highlightPositions: Record<string, number>;
|
|
18
|
-
documentPositions: Record<string, number>;
|
|
19
17
|
pendingSelectionTop: number | undefined;
|
|
20
18
|
onTextSelect: (
|
|
21
19
|
text: string,
|
|
@@ -23,28 +21,13 @@ interface UseTextSelectionResult {
|
|
|
23
21
|
endOffset: number,
|
|
24
22
|
selectionTop: number,
|
|
25
23
|
) => void;
|
|
26
|
-
onPositionsChange: (
|
|
27
|
-
positions: Record<string, number>,
|
|
28
|
-
docPositions: Record<string, number>,
|
|
29
|
-
pendingTop?: number,
|
|
30
|
-
) => void;
|
|
31
24
|
clearSelection: () => void;
|
|
32
25
|
}
|
|
33
26
|
|
|
34
|
-
/**
|
|
35
|
-
* Manage text selection state, highlight positions, and click-outside dismissal.
|
|
36
|
-
* State lives in the Zustand store for tab-switch preservation.
|
|
37
|
-
*/
|
|
38
27
|
export function useTextSelection(): UseTextSelectionResult {
|
|
39
28
|
const selection = useAppStore(
|
|
40
29
|
(s) => s.getActiveDocumentState()?.selection ?? null,
|
|
41
30
|
);
|
|
42
|
-
const highlightPositions = useAppStore(
|
|
43
|
-
(s) => s.getActiveDocumentState()?.highlightPositions ?? {},
|
|
44
|
-
);
|
|
45
|
-
const documentPositions = useAppStore(
|
|
46
|
-
(s) => s.getActiveDocumentState()?.documentPositions ?? {},
|
|
47
|
-
);
|
|
48
31
|
const pendingSelectionTop = useAppStore(
|
|
49
32
|
(s) => s.getActiveDocumentState()?.pendingSelectionTop,
|
|
50
33
|
);
|
|
@@ -54,15 +37,10 @@ export function useTextSelection(): UseTextSelectionResult {
|
|
|
54
37
|
|
|
55
38
|
const handleClickOutside = (e: MouseEvent) => {
|
|
56
39
|
const target = e.target as HTMLElement;
|
|
57
|
-
|
|
58
|
-
// Don't clear if clicking inside the comment input area
|
|
59
40
|
if (target.closest("[data-comment-input]")) return;
|
|
60
|
-
|
|
61
|
-
// Don't clear if clicking on any highlight (pending or comment)
|
|
62
41
|
if (target.closest("mark[data-pending]")) return;
|
|
63
42
|
if (target.closest("mark[data-comment-id]")) return;
|
|
64
43
|
|
|
65
|
-
// Clear selection state and pending marks
|
|
66
44
|
appStore.getState().setSelection(null);
|
|
67
45
|
appStore.getState().setPendingSelectionTop(undefined);
|
|
68
46
|
clearPendingMarks();
|
|
@@ -92,18 +70,6 @@ export function useTextSelection(): UseTextSelectionResult {
|
|
|
92
70
|
[],
|
|
93
71
|
);
|
|
94
72
|
|
|
95
|
-
const onPositionsChange = useCallback(
|
|
96
|
-
(
|
|
97
|
-
positions: Record<string, number>,
|
|
98
|
-
docPositions: Record<string, number>,
|
|
99
|
-
_pendingTop?: number,
|
|
100
|
-
) => {
|
|
101
|
-
appStore.getState().setHighlightPositions(positions);
|
|
102
|
-
appStore.getState().setDocumentPositions(docPositions);
|
|
103
|
-
},
|
|
104
|
-
[],
|
|
105
|
-
);
|
|
106
|
-
|
|
107
73
|
const clearSelection = useCallback(() => {
|
|
108
74
|
appStore.getState().setSelection(null);
|
|
109
75
|
appStore.getState().setPendingSelectionTop(undefined);
|
|
@@ -113,11 +79,8 @@ export function useTextSelection(): UseTextSelectionResult {
|
|
|
113
79
|
|
|
114
80
|
return {
|
|
115
81
|
selection,
|
|
116
|
-
highlightPositions,
|
|
117
|
-
documentPositions,
|
|
118
82
|
pendingSelectionTop,
|
|
119
83
|
onTextSelect,
|
|
120
|
-
onPositionsChange,
|
|
121
84
|
clearSelection,
|
|
122
85
|
};
|
|
123
86
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Comment, CommentFile } from "../../
|
|
1
|
+
import type { Comment, CommentFile } from "../../schema";
|
|
2
2
|
import { serializeComments } from "../comment-storage";
|
|
3
3
|
|
|
4
4
|
// --- Document fixtures ---
|
|
@@ -125,43 +125,3 @@ export const COMMENT_FILE_OBJ_LARGE = makeCommentFile(COMMENTS_50);
|
|
|
125
125
|
export const COMMENT_FILE_SMALL = serializeComments(COMMENT_FILE_OBJ_SMALL);
|
|
126
126
|
export const COMMENT_FILE_MEDIUM = serializeComments(COMMENT_FILE_OBJ_MEDIUM);
|
|
127
127
|
export const COMMENT_FILE_LARGE = serializeComments(COMMENT_FILE_OBJ_LARGE);
|
|
128
|
-
|
|
129
|
-
// --- Highlight position fixtures ---
|
|
130
|
-
|
|
131
|
-
export function makeHighlightPositions(count: number): Record<string, number> {
|
|
132
|
-
const positions: Record<string, number> = {};
|
|
133
|
-
for (let i = 0; i < count; i++) {
|
|
134
|
-
// ~200px spacing with clustering every 3rd note for overlap scenarios
|
|
135
|
-
positions[`c${i}`] = i * 200 + (i % 3 === 0 ? 50 : 0);
|
|
136
|
-
}
|
|
137
|
-
return positions;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// --- HTML fixture ---
|
|
141
|
-
|
|
142
|
-
function generateHtmlDoc(): string {
|
|
143
|
-
const parts: string[] = [
|
|
144
|
-
"<!DOCTYPE html>",
|
|
145
|
-
"<html><head>",
|
|
146
|
-
"<style>.main { color: red; font-size: 16px; }</style>",
|
|
147
|
-
"<script>console.log('test script');</script>",
|
|
148
|
-
"</head><body>",
|
|
149
|
-
];
|
|
150
|
-
|
|
151
|
-
for (let i = 0; i < 30; i++) {
|
|
152
|
-
parts.push(`<section id="s${i}">`);
|
|
153
|
-
parts.push(`<h2>Section ${i}</h2>`);
|
|
154
|
-
parts.push(
|
|
155
|
-
`<p>Paragraph with & entities <and> special "chars" in section ${i}.</p>`,
|
|
156
|
-
);
|
|
157
|
-
parts.push(
|
|
158
|
-
`<ul><li>Item ${i}.1</li><li>Item ${i}.2</li><li>Item ${i}.3</li></ul>`,
|
|
159
|
-
);
|
|
160
|
-
parts.push("</section>");
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
parts.push("</body></html>");
|
|
164
|
-
return parts.join("\n");
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export const HTML_DOC = generateHtmlDoc();
|
package/src/lib/anchor.bench.ts
CHANGED
|
@@ -1,112 +1,102 @@
|
|
|
1
1
|
import { bench, describe } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
COMMENTS_1,
|
|
4
|
+
COMMENTS_10,
|
|
5
|
+
COMMENTS_50,
|
|
6
|
+
LARGE_DOC,
|
|
7
|
+
MEDIUM_DOC,
|
|
8
|
+
} from "./__fixtures__/bench-data";
|
|
3
9
|
import {
|
|
4
10
|
findAnchor,
|
|
5
11
|
findAnchorFuzzy,
|
|
6
12
|
findAnchorNormalized,
|
|
7
13
|
findAnchorWithFallback,
|
|
8
|
-
levenshteinDistance,
|
|
9
14
|
} from "./anchor";
|
|
10
15
|
|
|
11
|
-
//
|
|
12
|
-
const EXACT_TEXT_L149 =
|
|
13
|
-
"The conclusion of section 8 summarizes the key findings";
|
|
14
|
-
const EXACT_TEXT_L250 = "- Item 1 in section 14";
|
|
15
|
-
const WHITESPACE_TEXT = "Item 1 in\n section 14";
|
|
16
|
-
const FUZZY_TEXT = "- Item 1 in section 1x"; // 1-char typo
|
|
17
|
-
|
|
18
|
-
describe("levenshteinDistance", () => {
|
|
19
|
-
const str20 = "hello world testing!";
|
|
20
|
-
|
|
21
|
-
bench("identical strings (20 chars)", () => {
|
|
22
|
-
levenshteinDistance(str20, str20);
|
|
23
|
-
});
|
|
16
|
+
// --- Exact match (best case) ---
|
|
24
17
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
bench("maxDistance early exit", () => {
|
|
30
|
-
levenshteinDistance("completely different", "nothing alike here!", 3);
|
|
31
|
-
});
|
|
18
|
+
describe("findAnchor — exact match", () => {
|
|
19
|
+
const comment = COMMENTS_10[5];
|
|
32
20
|
|
|
33
|
-
|
|
34
|
-
const longB = "a".repeat(50) + "c".repeat(50);
|
|
35
|
-
|
|
36
|
-
bench("longer strings (100 chars)", () => {
|
|
37
|
-
levenshteinDistance(longA, longB);
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("findAnchor (exact)", () => {
|
|
42
|
-
bench("300-line doc, correct hint", () => {
|
|
21
|
+
bench("medium doc (150 lines)", () => {
|
|
43
22
|
findAnchor({
|
|
44
|
-
source:
|
|
45
|
-
selectedText:
|
|
46
|
-
lineHint: "
|
|
23
|
+
source: MEDIUM_DOC,
|
|
24
|
+
selectedText: comment.selectedText,
|
|
25
|
+
lineHint: comment.lineHint ?? "L1",
|
|
47
26
|
});
|
|
48
27
|
});
|
|
49
28
|
|
|
50
|
-
bench("
|
|
29
|
+
bench("large doc (300 lines)", () => {
|
|
51
30
|
findAnchor({
|
|
52
31
|
source: LARGE_DOC,
|
|
53
|
-
selectedText:
|
|
54
|
-
lineHint: "
|
|
32
|
+
selectedText: comment.selectedText,
|
|
33
|
+
lineHint: comment.lineHint ?? "L1",
|
|
55
34
|
});
|
|
56
35
|
});
|
|
57
36
|
});
|
|
58
37
|
|
|
38
|
+
// --- Normalized match ---
|
|
39
|
+
|
|
59
40
|
describe("findAnchorNormalized", () => {
|
|
60
|
-
|
|
41
|
+
// Create text with extra whitespace to force normalization path
|
|
42
|
+
const comment = COMMENTS_10[5];
|
|
43
|
+
const normalizedText = comment.selectedText.replace(/ /g, " ");
|
|
44
|
+
|
|
45
|
+
bench("large doc — normalized whitespace", () => {
|
|
61
46
|
findAnchorNormalized({
|
|
62
47
|
source: LARGE_DOC,
|
|
63
|
-
selectedText:
|
|
64
|
-
lineHint: "
|
|
48
|
+
selectedText: normalizedText,
|
|
49
|
+
lineHint: comment.lineHint ?? "L1",
|
|
65
50
|
});
|
|
66
51
|
});
|
|
67
52
|
});
|
|
68
53
|
|
|
54
|
+
// --- Fuzzy match (worst case) ---
|
|
55
|
+
|
|
69
56
|
describe("findAnchorFuzzy", () => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
lineHint: "L250",
|
|
75
|
-
threshold: 3,
|
|
76
|
-
});
|
|
77
|
-
});
|
|
57
|
+
// Slightly mutate text to force Levenshtein search
|
|
58
|
+
const comment = COMMENTS_10[5];
|
|
59
|
+
const mutated =
|
|
60
|
+
"X" + comment.selectedText.slice(1, -1) + "Z";
|
|
78
61
|
|
|
79
|
-
bench("
|
|
62
|
+
bench("large doc — fuzzy (mutated text)", () => {
|
|
80
63
|
findAnchorFuzzy({
|
|
81
64
|
source: LARGE_DOC,
|
|
82
|
-
selectedText:
|
|
83
|
-
|
|
65
|
+
selectedText: mutated.slice(0, 50), // Keep within MAX_FUZZY_TEXT_LENGTH
|
|
66
|
+
lineHint: comment.lineHint ?? "L1",
|
|
84
67
|
});
|
|
85
68
|
});
|
|
86
69
|
});
|
|
87
70
|
|
|
71
|
+
// --- Full fallback chain ---
|
|
72
|
+
|
|
88
73
|
describe("findAnchorWithFallback", () => {
|
|
89
|
-
bench("
|
|
74
|
+
bench("1 comment — exact hit", () => {
|
|
75
|
+
const c = COMMENTS_1[0];
|
|
90
76
|
findAnchorWithFallback({
|
|
91
77
|
source: LARGE_DOC,
|
|
92
|
-
selectedText:
|
|
93
|
-
lineHint: "
|
|
78
|
+
selectedText: c.selectedText,
|
|
79
|
+
lineHint: c.lineHint ?? "L1",
|
|
94
80
|
});
|
|
95
81
|
});
|
|
96
82
|
|
|
97
|
-
bench("
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
83
|
+
bench("10 comments — exact hits", () => {
|
|
84
|
+
for (const c of COMMENTS_10) {
|
|
85
|
+
findAnchorWithFallback({
|
|
86
|
+
source: LARGE_DOC,
|
|
87
|
+
selectedText: c.selectedText,
|
|
88
|
+
lineHint: c.lineHint ?? "L1",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
103
91
|
});
|
|
104
92
|
|
|
105
|
-
bench("
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
93
|
+
bench("50 comments — exact hits", () => {
|
|
94
|
+
for (const c of COMMENTS_50) {
|
|
95
|
+
findAnchorWithFallback({
|
|
96
|
+
source: LARGE_DOC,
|
|
97
|
+
selectedText: c.selectedText,
|
|
98
|
+
lineHint: c.lineHint ?? "L1",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
111
101
|
});
|
|
112
102
|
});
|
package/src/lib/anchor.test.ts
CHANGED
|
@@ -135,8 +135,12 @@ describe("parseLineHint", () => {
|
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
it("parses line range hint", () => {
|
|
138
|
+
expect(parseLineHint("L42-L45")).toEqual({ start: 42, end: 45 });
|
|
139
|
+
expect(parseLineHint("L10-L20")).toEqual({ start: 10, end: 20 });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("parses legacy format without L prefix on end line", () => {
|
|
138
143
|
expect(parseLineHint("L42-45")).toEqual({ start: 42, end: 45 });
|
|
139
|
-
expect(parseLineHint("L10-20")).toEqual({ start: 10, end: 20 });
|
|
140
144
|
});
|
|
141
145
|
|
|
142
146
|
it("returns default for invalid hint", () => {
|