@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
package/src/App.tsx CHANGED
@@ -1,28 +1,26 @@
1
- import { use, useCallback, useEffect, useRef } from "react";
2
- import { Toaster } from "sonner";
1
+ import { useCallback, useEffect, useMemo, useRef } from "react";
2
+ import { Toaster, toast } from "sonner";
3
3
  import { CommentInput } from "./components/comments/CommentInput";
4
- import { CommentMinimap } from "./components/comments/CommentMinimap";
5
4
  import { CommentNav } from "./components/comments/CommentNav";
6
- import { DocumentViewer } from "./components/DocumentViewer";
7
- import { FloatingTOC } from "./components/FloatingTOC";
5
+ import { DocumentViewer } from "./components/DocumentViewer/DocumentViewer";
8
6
  import { Header } from "./components/Header";
9
7
  import { MarginNotes } from "./components/MarginNotes";
10
8
  import { ReanchorConfirm } from "./components/ReanchorConfirm";
11
9
  import { TabBar } from "./components/TabBar";
12
10
  import { TableOfContents } from "./components/TableOfContents";
13
- import { textVariants } from "./components/ui/Text";
14
- import { CommentContext, CommentProvider } from "./contexts/CommentContext";
15
- import { LayoutContext, LayoutProvider } from "./contexts/LayoutContext";
11
+ import {
12
+ CommentProvider,
13
+ useCommentActions,
14
+ useCommentData,
15
+ } from "./contexts/CommentContext";
16
16
  import { useLocale } from "./contexts/LocaleContext";
17
- import { useClipboard } from "./hooks/useClipboard";
17
+ import { PositionsProvider, usePositions } from "./contexts/PositionsContext";
18
+ import { SettingsProvider } from "./contexts/SettingsContext";
18
19
  import { useDocument } from "./hooks/useDocument";
19
20
  import { useHeadings } from "./hooks/useHeadings";
20
- import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
21
- import { useScrollMetrics } from "./hooks/useScrollMetrics";
22
21
  import { useScrollSpy } from "./hooks/useScrollSpy";
23
22
  import { useTextSelection } from "./hooks/useTextSelection";
24
- import { calculateScrollTarget, getElementTopInDocument } from "./lib/scroll";
25
- import { ShortcutActions } from "./lib/shortcut-registry";
23
+ import { exportCommentsAsJson, generatePrompt } from "./lib/export";
26
24
  import { cn } from "./lib/utils";
27
25
  import { appStore, useAppStore } from "./store";
28
26
 
@@ -33,103 +31,58 @@ const TOASTER_OPTIONS = {
33
31
  classNames: {
34
32
  toast: cn(
35
33
  "backdrop-blur-sm bg-white/90 dark:bg-zinc-900/90 border border-zinc-100 dark:border-zinc-800 px-3 py-2 shadow-sm rounded-md",
36
- textVariants({ variant: "caption" }),
34
+ "text-xs text-zinc-500 dark:text-zinc-400",
37
35
  ),
38
36
  },
39
37
  };
40
38
 
41
- function AppContent() {
39
+ interface AppContentProps {
40
+ document: NonNullable<ReturnType<typeof useDocument>["document"]>;
41
+ reload: ReturnType<typeof useDocument>["reload"];
42
+ isActive: boolean;
43
+ }
44
+
45
+ function AppContent({ document, reload, isActive }: AppContentProps) {
42
46
  const { t } = useLocale();
43
- const {
44
- comments,
45
- sortedComments,
46
- addComment,
47
- reanchorComment,
48
- reanchorTarget,
49
- cancelReanchor,
50
- hoveredCommentId,
51
- setHoveredCommentId,
52
- navigatePrevious,
53
- navigateNext,
54
- } = use(CommentContext)!;
47
+ const { comments, sortedComments, reanchorTarget } = useCommentData();
48
+ const { addComment, reanchorComment, cancelReanchor, setHoveredCommentId } =
49
+ useCommentActions();
55
50
 
56
- const { document, reload } = useDocument();
51
+ const { selection, pendingSelectionTop, onTextSelect, clearSelection } =
52
+ useTextSelection();
57
53
 
58
- const {
59
- selection,
60
- highlightPositions,
61
- documentPositions,
62
- pendingSelectionTop,
63
- onTextSelect,
64
- onPositionsChange,
65
- clearSelection,
66
- } = useTextSelection();
67
-
68
- const {
69
- copyAll,
70
- copyAllRaw,
71
- exportJson,
72
- copySelectionRaw,
73
- copySelectionForLLM,
74
- } = useClipboard({
75
- comments,
76
- document: document ?? undefined,
77
- selection: selection ?? undefined,
78
- clearSelection,
79
- t,
80
- });
81
-
82
- const { shortcuts, isFullscreen } = use(LayoutContext)!;
83
-
84
- useKeyboardShortcuts(shortcuts, {
85
- [ShortcutActions.COPY_ALL]: copyAll,
86
- [ShortcutActions.COPY_ALL_RAW]: copyAllRaw,
87
- [ShortcutActions.NAVIGATE_NEXT]: navigateNext,
88
- [ShortcutActions.NAVIGATE_PREVIOUS]: navigatePrevious,
89
- [ShortcutActions.COPY_SELECTION_RAW]: copySelectionRaw,
90
- [ShortcutActions.COPY_SELECTION_LLM]: copySelectionForLLM,
91
- [ShortcutActions.CLEAR_SELECTION]: clearSelection,
92
- });
93
-
94
- const scrollMetrics = useScrollMetrics();
95
-
96
- const headings = useHeadings(
97
- document?.content ?? null,
98
- document?.type ?? null,
99
- );
100
- const activeHeadingId = useScrollSpy(headings.map((h) => h.id));
101
-
102
- const scrollToHeading = useCallback(
103
- (id: string) => {
104
- let elementRect: DOMRect | undefined;
105
- let iframeTopOffset: number | undefined;
106
-
107
- if (document?.type === "html") {
108
- const iframe = window.document.querySelector("iframe");
109
- const el = iframe?.contentDocument?.getElementById(id);
110
- if (!el || !iframe) return;
111
- elementRect = el.getBoundingClientRect();
112
- iframeTopOffset = iframe.getBoundingClientRect().top;
113
- } else {
114
- elementRect = window.document
115
- .getElementById(id)
116
- ?.getBoundingClientRect();
117
- }
118
- if (!elementRect) return;
54
+ const pos = usePositions();
119
55
 
120
- const elementTop = getElementTopInDocument({
121
- elementRect,
122
- scrollY: window.scrollY,
123
- iframeTopOffset,
124
- });
125
- const scrollTarget = calculateScrollTarget({
126
- elementTop,
127
- viewportHeight: window.innerHeight,
128
- });
129
- window.scrollTo({ top: scrollTarget, behavior: "smooth" });
130
- },
131
- [document?.type],
132
- );
56
+ useEffect(() => {
57
+ pos.setIds(sortedComments.map((c) => c.id));
58
+ }, [pos, sortedComments]);
59
+ useEffect(() => {
60
+ pos.setPending(selection ? pendingSelectionTop : undefined);
61
+ }, [pos, selection, pendingSelectionTop]);
62
+
63
+ const copyAll = useCallback(() => {
64
+ if (!document) return;
65
+ navigator.clipboard.writeText(generatePrompt(comments, document.fileName));
66
+ toast.success(t("toast.copiedAllComments"));
67
+ }, [comments, document, t]);
68
+
69
+ const exportJson = useCallback(() => {
70
+ if (!document) return;
71
+ exportCommentsAsJson(comments, document);
72
+ }, [comments, document]);
73
+
74
+ const headings = useHeadings(document?.content ?? null);
75
+ const headingIds = useMemo(() => headings.map((h) => h.id), [headings]);
76
+ const activeHeadingId = useScrollSpy(headingIds, isActive);
77
+
78
+ const scrollToHeading = useCallback((id: string) => {
79
+ const rect = window.document.getElementById(id)?.getBoundingClientRect();
80
+ if (!rect) return;
81
+
82
+ const elementTop = window.scrollY + rect.top;
83
+ const scrollTarget = Math.max(0, elementTop - window.innerHeight * 0.25);
84
+ window.scrollTo({ top: scrollTarget, behavior: "smooth" });
85
+ }, []);
133
86
 
134
87
  const handleHighlightClick = useCallback((commentId: string) => {
135
88
  const marginNote = window.document.querySelector(
@@ -140,35 +93,31 @@ function AppContent() {
140
93
  }
141
94
  }, []);
142
95
 
143
- useEffect(() => {
144
- const eventSource = new EventSource("/api/heartbeat");
145
- return () => eventSource.close();
146
- }, []);
147
-
148
- // Scroll save/restore for tab switching
96
+ // Scroll save/restore for tab switching (visibility-based, not mount-based)
149
97
  const setScrollY = useAppStore((s) => s.setScrollY);
150
98
  const savedScrollY = useAppStore(
151
- (s) => s.getActiveDocumentState()?.scrollY ?? 0,
99
+ (s) => s.documents.get(document.filePath)?.scrollY ?? 0,
152
100
  );
153
- const scrollRestored = useRef(false);
101
+ const prevActiveRef = useRef(isActive);
154
102
 
155
- // Save scroll position on unmount
156
103
  useEffect(() => {
157
- return () => {
158
- setScrollY(window.scrollY);
159
- };
160
- }, [setScrollY]);
104
+ const wasActive = prevActiveRef.current;
105
+ prevActiveRef.current = isActive;
161
106
 
162
- // Restore scroll position on mount (after highlights paint)
163
- useEffect(() => {
164
- if (savedScrollY === 0 || scrollRestored.current) return;
165
- scrollRestored.current = true;
166
- requestAnimationFrame(() => {
107
+ if (wasActive && !isActive) {
108
+ // Tab becoming hidden — save scroll position
109
+ setScrollY(window.scrollY, document.filePath);
110
+ }
111
+
112
+ if (!wasActive && isActive) {
113
+ // Tab becoming visible — restore scroll after layout recalc
167
114
  requestAnimationFrame(() => {
168
- window.scrollTo(0, savedScrollY);
115
+ requestAnimationFrame(() => {
116
+ window.scrollTo(0, savedScrollY);
117
+ });
169
118
  });
170
- });
171
- }, [savedScrollY]);
119
+ }
120
+ }, [isActive, savedScrollY, setScrollY, document.filePath]);
172
121
 
173
122
  const handleAddComment = useCallback(
174
123
  (commentText: string) => {
@@ -215,23 +164,15 @@ function AppContent() {
215
164
 
216
165
  return (
217
166
  <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex flex-col">
218
- <Toaster
219
- position="bottom-right"
220
- icons={TOASTER_ICONS}
221
- toastOptions={TOASTER_OPTIONS}
222
- />
223
167
  <Header
224
168
  fileName={document.fileName}
225
169
  onCopyAll={copyAll}
226
- onCopyAllRaw={copyAllRaw}
227
170
  onExportJson={exportJson}
228
171
  onReload={reload}
229
172
  />
230
173
 
231
- <div
232
- className={`flex-1 flex gap-4 w-full ${!isFullscreen ? "max-w-7xl mx-auto" : ""} ${hoveredCommentId ? "has-comment-focus" : ""}`}
233
- >
234
- {!isFullscreen && headings.length > 0 && (
174
+ <div className="flex-1 flex gap-4 w-full max-w-7xl mx-auto">
175
+ {headings.length > 0 && (
235
176
  <aside className="w-48 flex-shrink-0 py-6 pl-6 hidden xl:block">
236
177
  <div className="sticky top-64 max-h-[calc(100vh-17rem)] overflow-y-auto">
237
178
  <TableOfContents
@@ -242,23 +183,14 @@ function AppContent() {
242
183
  </div>
243
184
  </aside>
244
185
  )}
245
- {isFullscreen && (
246
- <FloatingTOC
247
- headings={headings}
248
- activeId={activeHeadingId}
249
- onHeadingClick={scrollToHeading}
250
- />
251
- )}
252
186
 
253
187
  <div className="flex-1 px-6 py-6">
254
188
  <DocumentViewer
255
189
  content={document.content}
256
- type={document.type}
257
190
  comments={comments}
258
191
  headings={headings}
259
- pendingSelection={selection ?? undefined}
192
+ isActive={isActive}
260
193
  onTextSelect={onTextSelect}
261
- onHighlightPositionsChange={onPositionsChange}
262
194
  onHighlightHover={setHoveredCommentId}
263
195
  onHighlightClick={handleHighlightClick}
264
196
  />
@@ -282,27 +214,15 @@ function AppContent() {
282
214
  selectedText={selection.text}
283
215
  onSubmit={handleAddComment}
284
216
  onCancel={clearSelection}
285
- onCopyRaw={copySelectionRaw}
286
- onCopyForLLM={copySelectionForLLM}
287
217
  />
288
218
  )}
289
219
  </div>
290
220
  )}
291
221
 
292
- <MarginNotes
293
- sortedComments={sortedComments}
294
- highlightPositions={highlightPositions}
295
- pendingSelectionTop={selection ? pendingSelectionTop : undefined}
296
- />
222
+ <MarginNotes sortedComments={sortedComments} />
297
223
  </div>
298
224
  </div>
299
225
 
300
- <CommentMinimap
301
- documentPositions={documentPositions}
302
- documentHeight={scrollMetrics.documentHeight}
303
- viewportHeight={scrollMetrics.viewportHeight}
304
- />
305
-
306
226
  <CommentNav />
307
227
 
308
228
  <footer className="py-4 text-center text-sm text-zinc-400 dark:text-zinc-500">
@@ -338,11 +258,18 @@ function useTabKeyboardShortcuts() {
338
258
 
339
259
  function App() {
340
260
  const { t } = useLocale();
341
- const { document, error, isInitialized } = useDocument();
261
+ const { error, isInitialized, reload } = useDocument();
342
262
  const documentOrder = useAppStore((s) => s.documentOrder);
263
+ const activeDocumentPath = useAppStore((s) => s.activeDocumentPath);
264
+ const documents = useAppStore((s) => s.documents);
343
265
 
344
266
  useTabKeyboardShortcuts();
345
267
 
268
+ useEffect(() => {
269
+ const eventSource = new EventSource("/api/heartbeat");
270
+ return () => eventSource.close();
271
+ }, []);
272
+
346
273
  if (error) {
347
274
  return (
348
275
  <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
@@ -382,30 +309,58 @@ function App() {
382
309
  );
383
310
  }
384
311
 
385
- if (!document) {
386
- return (
387
- <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
388
- <div className="text-zinc-500 dark:text-zinc-400">
389
- {t("app.loading")}
390
- </div>
391
- </div>
392
- );
393
- }
394
-
395
312
  return (
396
313
  <>
397
314
  <TabBar />
398
- <LayoutProvider>
399
- <CommentProvider
400
- filePath={document.filePath}
401
- clean={document.clean}
402
- documentContent={document.content}
403
- fileName={document.fileName}
404
- documentType={document.type}
405
- >
406
- <AppContent />
407
- </CommentProvider>
408
- </LayoutProvider>
315
+ <Toaster
316
+ position="bottom-right"
317
+ icons={TOASTER_ICONS}
318
+ toastOptions={TOASTER_OPTIONS}
319
+ />
320
+ <SettingsProvider>
321
+ {documentOrder.map((filePath) => {
322
+ const docState = documents.get(filePath);
323
+ const isActive = filePath === activeDocumentPath;
324
+ const hasContent = !!docState?.document.content;
325
+
326
+ // Don't mount inactive tabs that haven't loaded content yet
327
+ if (!hasContent && !isActive) return null;
328
+
329
+ // Active tab without content — show loading placeholder
330
+ if (!hasContent) {
331
+ return (
332
+ <div
333
+ key={filePath}
334
+ className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center"
335
+ >
336
+ <div className="text-zinc-500 dark:text-zinc-400">
337
+ {t("app.loading")}
338
+ </div>
339
+ </div>
340
+ );
341
+ }
342
+
343
+ return (
344
+ <div
345
+ key={filePath}
346
+ style={isActive ? undefined : { display: "none" }}
347
+ >
348
+ <PositionsProvider>
349
+ <CommentProvider
350
+ filePath={filePath}
351
+ clean={docState.document.clean}
352
+ >
353
+ <AppContent
354
+ document={docState.document}
355
+ reload={reload}
356
+ isActive={isActive}
357
+ />
358
+ </CommentProvider>
359
+ </PositionsProvider>
360
+ </div>
361
+ );
362
+ })}
363
+ </SettingsProvider>
409
364
  </>
410
365
  );
411
366
  }