@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.
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 +133 -178
  19. package/src/{cli/index.ts → cli.ts} +211 -107
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +78 -105
  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} +111 -81
  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,6 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
+ import { AnchorConfidences, type Comment } from "../schema";
2
3
  import { appStore, useAppStore } from "../store";
3
- import { AnchorConfidences, type Comment } from "../types";
4
4
 
5
5
  interface UseCommentsOptions {
6
6
  clean?: boolean;
@@ -26,17 +26,12 @@ interface UseCommentsResult {
26
26
  ) => void;
27
27
  }
28
28
 
29
- /**
30
- * Hook for managing comments with optimistic updates.
31
- * State lives in the Zustand store; this hook coordinates API mutations.
32
- */
33
29
  export function useComments(
34
30
  filePath: string | null,
35
31
  options: UseCommentsOptions = {},
36
32
  ): UseCommentsResult {
37
33
  const { clean = false } = options;
38
34
 
39
- // Read comments and error from the store
40
35
  const comments = useAppStore(
41
36
  (s) => s.documents.get(filePath ?? "")?.comments ?? [],
42
37
  );
@@ -44,16 +39,12 @@ export function useComments(
44
39
  (s) => s.documents.get(filePath ?? "")?.commentsError ?? undefined,
45
40
  );
46
41
 
47
- // Track pending operations for rollback on error
48
42
  const pendingOperations = useRef<Map<string, Comment[]>>(new Map());
49
43
 
50
- // Capture filePath at call time for stable closures
44
+ // Capture filePath at call time so callbacks stay stable across renders
51
45
  const filePathRef = useRef(filePath);
52
46
  filePathRef.current = filePath;
53
47
 
54
- /**
55
- * Execute an optimistic mutation with automatic rollback on error.
56
- */
57
48
  const executeMutation = useCallback(
58
49
  async <T>({
59
50
  operationId,
@@ -71,19 +62,16 @@ export function useComments(
71
62
  const fp = filePathRef.current;
72
63
  if (!fp) return;
73
64
 
74
- // Read current comments from store for rollback
75
65
  const currentDocState = appStore.getState().documents.get(fp);
76
66
  const previousComments = [...(currentDocState?.comments ?? [])];
77
67
  pendingOperations.current.set(operationId, previousComments);
78
68
 
79
- // Apply optimistic update
80
69
  appStore.getState().setComments(optimisticUpdate(previousComments), fp);
81
70
  appStore.getState().setCommentsError(null, fp);
82
71
 
83
72
  try {
84
73
  const result = await apiCall();
85
74
 
86
- // Apply server response transformation if provided
87
75
  if (onSuccess) {
88
76
  const current = appStore.getState().documents.get(fp)?.comments ?? [];
89
77
  appStore.getState().setComments(onSuccess(result, current), fp);
@@ -97,7 +85,6 @@ export function useComments(
97
85
  fp,
98
86
  );
99
87
 
100
- // Rollback on error
101
88
  const rollback = pendingOperations.current.get(operationId);
102
89
  if (rollback) {
103
90
  appStore.getState().setComments(rollback, fp);
@@ -109,23 +96,24 @@ export function useComments(
109
96
  [],
110
97
  );
111
98
 
112
- // Build path-scoped API URL
113
99
  const pathQuery = useCallback((base: string) => {
114
100
  const fp = filePathRef.current;
115
101
  if (!fp) return base;
116
102
  return `${base}?path=${encodeURIComponent(fp)}`;
117
103
  }, []);
118
104
 
119
- // Load comments from API
120
105
  useEffect(() => {
121
106
  if (!filePath) return;
122
107
 
108
+ // Skip fetch if comments were already pre-fetched by useDocument (parallel loading)
109
+ const existing = appStore.getState().documents.get(filePath);
110
+ if (!clean && existing && existing.comments.length > 0) return;
111
+
123
112
  const loadComments = async () => {
124
113
  appStore.getState().setCommentsError(null, filePath);
125
114
  const query = `?path=${encodeURIComponent(filePath)}`;
126
115
 
127
116
  try {
128
- // If clean flag is set, clear comments first
129
117
  if (clean) {
130
118
  await fetch(`/api/comments${query}`, { method: "DELETE" });
131
119
  appStore.getState().setComments([], filePath);
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { toast } from "sonner";
3
+ import type { Document } from "../schema";
3
4
  import { appStore, useAppStore } from "../store";
4
- import type { Document } from "../types";
5
5
 
6
6
  interface UseDocumentResult {
7
7
  document: Document | null;
@@ -13,30 +13,20 @@ interface UseDocumentResult {
13
13
  interface DocListItem {
14
14
  path: string;
15
15
  fileName: string;
16
- type: Document["type"];
17
16
  }
18
17
 
19
- /**
20
- * Manage multi-document loading, lazy content fetching, and live reloading.
21
- *
22
- * On mount: fetches the document list from `/api/documents` and opens all
23
- * files in the store. Content is loaded lazily when a tab becomes active.
24
- * SSE events trigger content updates for already-loaded documents.
25
- */
26
18
  export function useDocument(): UseDocumentResult {
27
19
  const [error, setError] = useState<string | null>(null);
28
20
  const [isInitialized, setIsInitialized] = useState(false);
29
21
 
30
22
  const activeDocumentPath = useAppStore((s) => s.activeDocumentPath);
31
23
 
32
- // Active document — null until content is loaded
33
24
  const document = useAppStore((s) => {
34
25
  const ds = s.getActiveDocumentState();
35
- if (!ds || !ds.document.content) return null;
26
+ if (!ds?.document.content) return null;
36
27
  return ds.document;
37
28
  });
38
29
 
39
- // Fetch document list on mount, populate store
40
30
  useEffect(() => {
41
31
  async function init() {
42
32
  try {
@@ -51,8 +41,7 @@ export function useDocument(): UseDocumentResult {
51
41
  data.files.forEach((file: DocListItem, index: number) => {
52
42
  appStore.getState().openDocument(
53
43
  {
54
- content: "", // Content loaded lazily on tab activation
55
- type: file.type,
44
+ content: "",
56
45
  filePath: file.path,
57
46
  fileName: file.fileName,
58
47
  clean,
@@ -71,32 +60,46 @@ export function useDocument(): UseDocumentResult {
71
60
  init();
72
61
  }, []);
73
62
 
74
- // Load content when active document changes and has no content yet
75
63
  useEffect(() => {
76
64
  if (!activeDocumentPath) return;
77
65
  const state = appStore.getState().documents.get(activeDocumentPath);
78
66
  if (!state || state.document.content) return;
79
67
 
80
- async function loadContent() {
81
- try {
82
- const res = await fetch(
83
- `/api/document?path=${encodeURIComponent(activeDocumentPath!)}`,
84
- );
85
- if (!res.ok) throw new Error(`Server error: ${res.status}`);
86
- const data = await res.json();
87
- appStore
88
- .getState()
89
- .updateDocumentContent(data.content, activeDocumentPath!);
90
- } catch (err) {
68
+ const path = activeDocumentPath;
69
+ const query = `?path=${encodeURIComponent(path)}`;
70
+ const isClean = state.document.clean;
71
+
72
+ // Fetch document content and comments in parallel so highlights
73
+ // can apply immediately when CommentProvider mounts.
74
+ const docFetch = fetch(`/api/document${query}`).then((r) => {
75
+ if (!r.ok) throw new Error(`Server error: ${r.status}`);
76
+ return r.json();
77
+ });
78
+
79
+ const commentsFetch = isClean
80
+ ? fetch(`/api/comments${query}`, { method: "DELETE" }).then(
81
+ () => [] as unknown[],
82
+ )
83
+ : fetch(`/api/comments${query}`)
84
+ .then((r) => (r.ok ? r.json() : { comments: [] }))
85
+ .then((d) => d.comments || []);
86
+
87
+ Promise.all([docFetch, commentsFetch]).then(
88
+ ([docData, comments]) => {
89
+ // Set comments BEFORE content: content triggers CommentProvider mount,
90
+ // so comments must already be in the store to avoid a wasted empty render.
91
+ appStore.getState().setComments(comments, path);
92
+ appStore.getState().updateDocumentContent(docData.content, path);
93
+ },
94
+ (err) => {
91
95
  setError(
92
96
  err instanceof Error ? err.message : "Failed to load document",
93
97
  );
94
- }
95
- }
96
- loadContent();
98
+ },
99
+ );
97
100
  }, [activeDocumentPath]);
98
101
 
99
- // SSE: register new documents without stealing focus; reload loaded docs on updates
102
+ // SSE: register new documents without stealing focus; reload already-loaded docs on updates
100
103
  useEffect(() => {
101
104
  const eventSource = new EventSource("/api/document/stream");
102
105
  eventSource.onmessage = async (e) => {
@@ -105,8 +108,7 @@ export function useDocument(): UseDocumentResult {
105
108
  if (data.type === "document-added" && data.path) {
106
109
  appStore.getState().openDocument(
107
110
  {
108
- content: "", // Lazy-loaded when tab activated
109
- type: data.fileType,
111
+ content: "",
110
112
  filePath: data.path,
111
113
  fileName: data.fileName,
112
114
  clean: false,
@@ -116,9 +118,8 @@ export function useDocument(): UseDocumentResult {
116
118
  return;
117
119
  }
118
120
  if (data.type === "document-updated" && data.path) {
119
- // Only reload if content was previously loaded
120
121
  const state = appStore.getState().documents.get(data.path);
121
- if (!state || !state.document.content) return;
122
+ if (!state?.document.content) return;
122
123
 
123
124
  const res = await fetch(
124
125
  `/api/document?path=${encodeURIComponent(data.path)}`,
@@ -2,13 +2,13 @@ import { renderHook } from "@testing-library/react";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { useHeadings } from "./useHeadings";
4
4
 
5
- describe("useHeadings - markdown", () => {
5
+ describe("useHeadings", () => {
6
6
  it("extracts basic headings", () => {
7
7
  const content = `# Heading 1
8
8
  ## Heading 2
9
9
  ### Heading 3`;
10
10
 
11
- const { result } = renderHook(() => useHeadings(content, "markdown"));
11
+ const { result } = renderHook(() => useHeadings(content));
12
12
 
13
13
  expect(result.current).toEqual([
14
14
  { id: "heading-1", text: "Heading 1", level: 1 },
@@ -22,7 +22,7 @@ describe("useHeadings - markdown", () => {
22
22
  ## Section
23
23
  ## Section`;
24
24
 
25
- const { result } = renderHook(() => useHeadings(content, "markdown"));
25
+ const { result } = renderHook(() => useHeadings(content));
26
26
 
27
27
  expect(result.current).toEqual([
28
28
  { id: "section", text: "Section", level: 2 },
@@ -41,7 +41,7 @@ echo "hello"
41
41
 
42
42
  ## Another Real Heading`;
43
43
 
44
- const { result } = renderHook(() => useHeadings(content, "markdown"));
44
+ const { result } = renderHook(() => useHeadings(content));
45
45
 
46
46
  expect(result.current).toEqual([
47
47
  { id: "real-heading", text: "Real Heading", level: 1 },
@@ -60,7 +60,7 @@ def foo():
60
60
 
61
61
  ## Another Real Heading`;
62
62
 
63
- const { result } = renderHook(() => useHeadings(content, "markdown"));
63
+ const { result } = renderHook(() => useHeadings(content));
64
64
 
65
65
  expect(result.current).toEqual([
66
66
  { id: "real-heading", text: "Real Heading", level: 1 },
@@ -83,7 +83,7 @@ def foo():
83
83
 
84
84
  ## Results`;
85
85
 
86
- const { result } = renderHook(() => useHeadings(content, "markdown"));
86
+ const { result } = renderHook(() => useHeadings(content));
87
87
 
88
88
  expect(result.current).toEqual([
89
89
  { id: "introduction", text: "Introduction", level: 1 },
@@ -102,7 +102,7 @@ npx readit document.md --port 3000
102
102
 
103
103
  ## Usage`;
104
104
 
105
- const { result } = renderHook(() => useHeadings(content, "markdown"));
105
+ const { result } = renderHook(() => useHeadings(content));
106
106
 
107
107
  expect(result.current).toEqual([
108
108
  { id: "setup", text: "Setup", level: 1 },
@@ -111,49 +111,7 @@ npx readit document.md --port 3000
111
111
  });
112
112
 
113
113
  it("returns empty array for null content", () => {
114
- const { result } = renderHook(() => useHeadings(null, "markdown"));
114
+ const { result } = renderHook(() => useHeadings(null));
115
115
  expect(result.current).toEqual([]);
116
116
  });
117
-
118
- it("returns empty array for null type", () => {
119
- const { result } = renderHook(() => useHeadings("# Heading", null));
120
- expect(result.current).toEqual([]);
121
- });
122
- });
123
-
124
- describe("useHeadings - html", () => {
125
- it("extracts basic headings", () => {
126
- const content = `<h1>Heading 1</h1>
127
- <h2>Heading 2</h2>
128
- <h3>Heading 3</h3>`;
129
-
130
- const { result } = renderHook(() => useHeadings(content, "html"));
131
-
132
- expect(result.current).toEqual([
133
- { id: "heading-1", text: "Heading 1", level: 1 },
134
- { id: "heading-2", text: "Heading 2", level: 2 },
135
- { id: "heading-3", text: "Heading 3", level: 3 },
136
- ]);
137
- });
138
-
139
- it("uses existing id attribute", () => {
140
- const content = `<h1 id="custom-id">Heading 1</h1>`;
141
-
142
- const { result } = renderHook(() => useHeadings(content, "html"));
143
-
144
- expect(result.current).toEqual([
145
- { id: "custom-id", text: "Heading 1", level: 1 },
146
- ]);
147
- });
148
-
149
- it("decodes HTML entities", () => {
150
- const content = `<h1>Hello &amp; World</h1>`;
151
-
152
- const { result } = renderHook(() => useHeadings(content, "html"));
153
-
154
- // Note: & is stripped, leaving "Hello World" → "hello-world" (hyphens collapsed)
155
- expect(result.current).toEqual([
156
- { id: "hello-world", text: "Hello & World", level: 1 },
157
- ]);
158
- });
159
117
  });
@@ -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();