@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.
Files changed (118) hide show
  1. package/README.md +0 -3
  2. package/biome.json +1 -1
  3. package/bun.lock +43 -185
  4. package/docs/perf-baseline.md +75 -0
  5. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  6. package/e2e/perf/add-comment.spec.ts +118 -0
  7. package/e2e/perf/fixtures/generate.ts +331 -0
  8. package/e2e/perf/initial-load.spec.ts +49 -0
  9. package/e2e/perf/perf.setup.ts +23 -0
  10. package/e2e/perf/perf.teardown.ts +9 -0
  11. package/e2e/perf/scroll.spec.ts +39 -0
  12. package/e2e/perf/tab-switch.spec.ts +69 -0
  13. package/e2e/perf/text-selection.spec.ts +119 -0
  14. package/e2e/perf/utils/metrics.ts +286 -0
  15. package/e2e/perf/utils/perf-cli.ts +86 -0
  16. package/package.json +9 -18
  17. package/playwright.config.ts +12 -0
  18. package/src/App.tsx +124 -172
  19. package/src/{cli/index.ts → cli.ts} +37 -53
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +77 -106
  22. package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
  23. package/src/components/Header.tsx +9 -20
  24. package/src/components/InlineEditor.tsx +5 -5
  25. package/src/components/MarginNote.tsx +71 -93
  26. package/src/components/MarginNotes.tsx +7 -34
  27. package/src/components/RawModal.tsx +9 -8
  28. package/src/components/ReanchorConfirm.tsx +2 -2
  29. package/src/components/SettingsModal.tsx +11 -89
  30. package/src/components/TabBar.tsx +4 -4
  31. package/src/components/TableOfContents.tsx +5 -5
  32. package/src/components/comments/CommentInput.tsx +7 -35
  33. package/src/components/comments/CommentListItem.tsx +9 -11
  34. package/src/components/comments/CommentManager.tsx +53 -37
  35. package/src/components/comments/CommentNav.tsx +14 -14
  36. package/src/components/ui/ActionLink.tsx +14 -18
  37. package/src/components/ui/Button.tsx +42 -43
  38. package/src/components/ui/Dialog.tsx +73 -113
  39. package/src/components/ui/DropdownMenu.tsx +113 -69
  40. package/src/components/ui/Text.tsx +30 -37
  41. package/src/contexts/CommentContext.tsx +75 -106
  42. package/src/contexts/LocaleContext.tsx +45 -4
  43. package/src/contexts/PositionsContext.tsx +16 -0
  44. package/src/contexts/SettingsContext.tsx +133 -0
  45. package/src/hooks/useClickOutside.ts +0 -4
  46. package/src/hooks/useCommentNavigation.ts +6 -29
  47. package/src/hooks/useComments.ts +6 -18
  48. package/src/hooks/useDocument.ts +35 -34
  49. package/src/hooks/useHeadings.test.ts +8 -50
  50. package/src/hooks/useHeadings.ts +5 -88
  51. package/src/hooks/useScrollSpy.ts +10 -14
  52. package/src/hooks/useTextSelection.ts +1 -38
  53. package/src/lib/__fixtures__/bench-data.ts +1 -41
  54. package/src/lib/anchor.bench.ts +57 -67
  55. package/src/lib/anchor.test.ts +5 -1
  56. package/src/lib/anchor.ts +13 -93
  57. package/src/lib/comment-storage.test.ts +4 -4
  58. package/src/lib/comment-storage.ts +2 -46
  59. package/src/lib/export.ts +7 -13
  60. package/src/lib/highlight/core.test.ts +1 -1
  61. package/src/lib/highlight/dom.ts +5 -68
  62. package/src/lib/highlight/highlighter.ts +102 -262
  63. package/src/lib/highlight/resolver.ts +112 -0
  64. package/src/lib/highlight/types.ts +0 -35
  65. package/src/lib/highlight/worker.ts +45 -0
  66. package/src/lib/i18n/en.ts +1 -50
  67. package/src/lib/i18n/ja.ts +1 -50
  68. package/src/lib/i18n/types.ts +1 -49
  69. package/src/lib/margin-layout.ts +5 -27
  70. package/src/lib/positions.ts +150 -0
  71. package/src/lib/utils.ts +2 -19
  72. package/src/schema.ts +81 -0
  73. package/src/{server/index.ts → server.ts} +74 -74
  74. package/src/{store/index.ts → store.ts} +14 -46
  75. package/vite.config.ts +8 -0
  76. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  77. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  78. package/src/components/DocumentViewer/index.ts +0 -1
  79. package/src/components/FloatingTOC.tsx +0 -61
  80. package/src/components/ShortcutCapture.tsx +0 -48
  81. package/src/components/ShortcutList.tsx +0 -198
  82. package/src/components/comments/CommentMinimap.tsx +0 -62
  83. package/src/components/ui/ActionBar.tsx +0 -16
  84. package/src/components/ui/SeparatorDot.tsx +0 -9
  85. package/src/contexts/LayoutContext.tsx +0 -88
  86. package/src/hooks/useClipboard.ts +0 -82
  87. package/src/hooks/useEditorScheme.ts +0 -51
  88. package/src/hooks/useFontPreference.ts +0 -59
  89. package/src/hooks/useKeybindings.ts +0 -108
  90. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  91. package/src/hooks/useLayoutMode.ts +0 -44
  92. package/src/hooks/useLocalePreference.ts +0 -42
  93. package/src/hooks/useReanchorMode.ts +0 -33
  94. package/src/hooks/useScrollMetrics.ts +0 -56
  95. package/src/hooks/useThemePreference.ts +0 -66
  96. package/src/lib/comment-storage.bench.ts +0 -63
  97. package/src/lib/context.bench.ts +0 -41
  98. package/src/lib/context.test.ts +0 -224
  99. package/src/lib/context.ts +0 -193
  100. package/src/lib/editor-links.ts +0 -59
  101. package/src/lib/export.bench.ts +0 -35
  102. package/src/lib/highlight/colors.ts +0 -37
  103. package/src/lib/highlight/core.ts +0 -54
  104. package/src/lib/highlight/index.ts +0 -23
  105. package/src/lib/highlight/script-builder.ts +0 -485
  106. package/src/lib/html-processor.test.tsx +0 -170
  107. package/src/lib/html-processor.tsx +0 -95
  108. package/src/lib/i18n/completeness.test.ts +0 -51
  109. package/src/lib/i18n/translations.test.ts +0 -39
  110. package/src/lib/layout-constants.ts +0 -12
  111. package/src/lib/margin-layout.bench.ts +0 -28
  112. package/src/lib/scroll.test.ts +0 -118
  113. package/src/lib/scroll.ts +0 -47
  114. package/src/lib/shortcut-registry.test.ts +0 -173
  115. package/src/lib/shortcut-registry.ts +0 -209
  116. package/src/lib/utils.test.ts +0 -110
  117. package/src/store/index.test.ts +0 -242
  118. package/src/types/index.ts +0 -127
@@ -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(/&amp;/g, "&")
86
- .replace(/&lt;/g, "<")
87
- .replace(/&gt;/g, ">")
88
- .replace(/&quot;/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 || !type) return [];
122
-
123
- if (type === "markdown") {
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
- * Hook to track which heading is currently in view
5
- * Uses IntersectionObserver to detect when headings enter the "active zone"
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
- setActiveId(null);
14
- hasSetInitialRef.current = false;
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 "../../types";
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 &amp; entities &lt;and&gt; special &quot;chars&quot; 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();
@@ -1,112 +1,102 @@
1
1
  import { bench, describe } from "vitest";
2
- import { LARGE_DOC } from "./__fixtures__/bench-data";
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
- // Text known to exist at specific lines in LARGE_DOC
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
- bench("1 edit distance (20 chars)", () => {
26
- levenshteinDistance("hello world testing!", "hello world testinx!");
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
- const longA = "a".repeat(50) + "b".repeat(50);
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: LARGE_DOC,
45
- selectedText: EXACT_TEXT_L149,
46
- lineHint: "L149",
23
+ source: MEDIUM_DOC,
24
+ selectedText: comment.selectedText,
25
+ lineHint: comment.lineHint ?? "L1",
47
26
  });
48
27
  });
49
28
 
50
- bench("300-line doc, wrong hint (global fallback)", () => {
29
+ bench("large doc (300 lines)", () => {
51
30
  findAnchor({
52
31
  source: LARGE_DOC,
53
- selectedText: EXACT_TEXT_L250,
54
- lineHint: "L10",
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
- bench("300-line doc, whitespace-collapsed match", () => {
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: WHITESPACE_TEXT,
64
- lineHint: "L250",
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
- bench("300-line doc, 1-char typo with hint", () => {
71
- findAnchorFuzzy({
72
- source: LARGE_DOC,
73
- selectedText: FUZZY_TEXT,
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("300-line doc, no hint (full scan)", () => {
62
+ bench("large doc fuzzy (mutated text)", () => {
80
63
  findAnchorFuzzy({
81
64
  source: LARGE_DOC,
82
- selectedText: FUZZY_TEXT,
83
- threshold: 3,
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("exact match (fast path)", () => {
74
+ bench("1 comment exact hit", () => {
75
+ const c = COMMENTS_1[0];
90
76
  findAnchorWithFallback({
91
77
  source: LARGE_DOC,
92
- selectedText: EXACT_TEXT_L149,
93
- lineHint: "L149",
78
+ selectedText: c.selectedText,
79
+ lineHint: c.lineHint ?? "L1",
94
80
  });
95
81
  });
96
82
 
97
- bench("normalized fallback", () => {
98
- findAnchorWithFallback({
99
- source: LARGE_DOC,
100
- selectedText: WHITESPACE_TEXT,
101
- lineHint: "L250",
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("fuzzy fallback", () => {
106
- findAnchorWithFallback({
107
- source: LARGE_DOC,
108
- selectedText: FUZZY_TEXT,
109
- lineHint: "L250",
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
  });
@@ -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", () => {