@peaske7/readit 0.2.0 → 0.3.0-rc.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 (179) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +152 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +890 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +233 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/MermaidEnhancer.svelte +218 -0
  68. package/src/components/MermaidModal.svelte +67 -0
  69. package/src/components/RawModal.svelte +126 -0
  70. package/src/components/ReanchorConfirm.svelte +30 -0
  71. package/src/components/SettingsModal.svelte +220 -0
  72. package/src/components/ShortcutCapture.svelte +82 -0
  73. package/src/components/ShortcutList.svelte +145 -0
  74. package/src/components/TabBar.svelte +52 -0
  75. package/src/components/TableOfContents.svelte +125 -0
  76. package/src/components/ui/ActionLink.svelte +40 -0
  77. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  78. package/src/components/ui/Dialog.svelte +97 -0
  79. package/src/components/ui/DropdownMenu.svelte +85 -0
  80. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  81. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  82. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  83. package/src/env.d.ts +6 -0
  84. package/src/index.css +141 -166
  85. package/src/lib/__fixtures__/bench-data.ts +0 -13
  86. package/src/lib/anchor.bench.ts +1 -12
  87. package/src/lib/anchor.test.ts +0 -8
  88. package/src/lib/anchor.ts +0 -4
  89. package/src/lib/comment-storage.bench.ts +49 -0
  90. package/src/lib/comment-storage.test.ts +103 -33
  91. package/src/lib/comment-storage.ts +25 -18
  92. package/src/lib/export.bench.ts +21 -0
  93. package/src/lib/export.ts +0 -1
  94. package/src/lib/fetch-or-throw.test.ts +59 -0
  95. package/src/lib/fetch-or-throw.ts +12 -0
  96. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  97. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  98. package/src/lib/highlight/core.test.ts +0 -5
  99. package/src/lib/highlight/dom.ts +52 -216
  100. package/src/lib/highlight/highlight-registry.ts +221 -0
  101. package/src/lib/highlight/highlight.bench.ts +92 -0
  102. package/src/lib/highlight/highlighter.ts +112 -132
  103. package/src/lib/highlight/resolver.ts +5 -79
  104. package/src/lib/highlight/types.ts +0 -5
  105. package/src/lib/html-text.test.ts +162 -0
  106. package/src/lib/html-text.ts +161 -0
  107. package/src/lib/i18n/en.ts +34 -0
  108. package/src/lib/i18n/ja.ts +34 -0
  109. package/src/lib/i18n/types.ts +33 -0
  110. package/src/lib/key-lock.test.ts +104 -0
  111. package/src/lib/key-lock.ts +23 -0
  112. package/src/lib/margin-layout.bench.ts +61 -0
  113. package/src/lib/margin-layout.ts +0 -7
  114. package/src/lib/markdown-renderer.test.ts +154 -0
  115. package/src/lib/markdown-renderer.ts +178 -0
  116. package/src/lib/mermaid-config.ts +38 -0
  117. package/src/lib/mermaid-renderer.ts +162 -0
  118. package/src/lib/mermaid-worker.ts +60 -0
  119. package/src/lib/positions.ts +31 -24
  120. package/src/lib/shortcut-registry.ts +244 -0
  121. package/src/lib/utils.ts +0 -29
  122. package/src/main.ts +16 -0
  123. package/src/schema.ts +16 -5
  124. package/src/server.ts +355 -95
  125. package/src/stores/app.svelte.ts +231 -0
  126. package/src/stores/locale.svelte.ts +46 -0
  127. package/src/stores/settings.svelte.ts +90 -0
  128. package/src/stores/shortcuts.svelte.ts +104 -0
  129. package/src/stores/ui.svelte.ts +12 -0
  130. package/src/template.ts +104 -0
  131. package/src/test-setup.ts +47 -0
  132. package/svelte.config.js +5 -0
  133. package/tsconfig.json +2 -2
  134. package/vite.config.ts +23 -3
  135. package/vscode-readit/.mcp.json +7 -0
  136. package/vscode-readit/.vscodeignore +7 -0
  137. package/vscode-readit/bun.lock +78 -0
  138. package/vscode-readit/icon.svg +10 -0
  139. package/vscode-readit/package.json +110 -0
  140. package/vscode-readit/src/extension.ts +117 -0
  141. package/vscode-readit/src/server-manager.ts +272 -0
  142. package/vscode-readit/src/webview-provider.ts +204 -0
  143. package/vscode-readit/tsconfig.json +20 -0
  144. package/e2e/fixtures/sample.html +0 -13
  145. package/src/App.tsx +0 -368
  146. package/src/components/ActionsMenu.tsx +0 -91
  147. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  148. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  149. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  150. package/src/components/Header.tsx +0 -54
  151. package/src/components/InlineEditor.tsx +0 -74
  152. package/src/components/MarginNote.tsx +0 -185
  153. package/src/components/MarginNotes.tsx +0 -23
  154. package/src/components/RawModal.tsx +0 -144
  155. package/src/components/ReanchorConfirm.tsx +0 -36
  156. package/src/components/SettingsModal.tsx +0 -232
  157. package/src/components/TabBar.tsx +0 -60
  158. package/src/components/TableOfContents.tsx +0 -108
  159. package/src/components/comments/CommentBadge.tsx +0 -49
  160. package/src/components/comments/CommentInput.tsx +0 -86
  161. package/src/components/comments/CommentListItem.tsx +0 -90
  162. package/src/components/comments/CommentManager.tsx +0 -129
  163. package/src/components/comments/CommentNav.tsx +0 -109
  164. package/src/components/ui/ActionLink.tsx +0 -28
  165. package/src/components/ui/Dialog.tsx +0 -116
  166. package/src/components/ui/DropdownMenu.tsx +0 -158
  167. package/src/contexts/CommentContext.tsx +0 -198
  168. package/src/contexts/LocaleContext.tsx +0 -76
  169. package/src/contexts/PositionsContext.tsx +0 -16
  170. package/src/contexts/SettingsContext.tsx +0 -133
  171. package/src/hooks/useClickOutside.ts +0 -31
  172. package/src/hooks/useCommentNavigation.ts +0 -107
  173. package/src/hooks/useComments.ts +0 -311
  174. package/src/hooks/useDocument.ts +0 -157
  175. package/src/hooks/useScrollSpy.ts +0 -77
  176. package/src/hooks/useTextSelection.ts +0 -86
  177. package/src/lib/highlight/worker.ts +0 -45
  178. package/src/main.tsx +0 -13
  179. package/src/store.ts +0 -222
@@ -0,0 +1,204 @@
1
+ import * as path from "node:path";
2
+ import * as vscode from "vscode";
3
+ import type { ServerManager } from "./server-manager";
4
+
5
+ /**
6
+ * Manages readit webview panels.
7
+ *
8
+ * Each panel contains an iframe pointing at the readit server on 127.0.0.1.
9
+ * This preserves the full interactive experience: SSE live-reload, comment
10
+ * CRUD, keyboard shortcuts, and all Svelte reactivity — without duplicating
11
+ * any rendering logic.
12
+ */
13
+ export class ReaditWebviewProvider implements vscode.Disposable {
14
+ private panels = new Map<string, vscode.WebviewPanel>();
15
+ private disposables: vscode.Disposable[] = [];
16
+
17
+ constructor(
18
+ private readonly extensionUri: vscode.Uri,
19
+ private readonly serverManager: ServerManager,
20
+ private readonly outputChannel: vscode.OutputChannel,
21
+ ) {}
22
+
23
+ /**
24
+ * Opens (or reveals) a readit preview panel for the given file.
25
+ * @param filePath Absolute path to the markdown file.
26
+ * @param readitDistDir Absolute path to the readit dist/ directory.
27
+ * @param column Which editor column to open the panel in.
28
+ */
29
+ async openPreview(
30
+ filePath: string,
31
+ readitDistDir: string,
32
+ column: vscode.ViewColumn = vscode.ViewColumn.Active,
33
+ ): Promise<void> {
34
+ // If a panel for this file already exists, reveal it
35
+ const existing = this.panels.get(filePath);
36
+ if (existing) {
37
+ existing.reveal(column);
38
+ return;
39
+ }
40
+
41
+ // Ensure the server is running with this file loaded
42
+ let port: number;
43
+ try {
44
+ port = await this.serverManager.ensureRunning(filePath, readitDistDir);
45
+ } catch (err) {
46
+ const message = err instanceof Error ? err.message : String(err);
47
+ vscode.window.showErrorMessage(`readit: ${message}`);
48
+ return;
49
+ }
50
+
51
+ const fileName = path.basename(filePath);
52
+
53
+ const panel = vscode.window.createWebviewPanel(
54
+ "readitPreview",
55
+ `readit: ${fileName}`,
56
+ column,
57
+ {
58
+ enableScripts: true,
59
+ retainContextWhenHidden: true,
60
+ localResourceRoots: [this.extensionUri],
61
+ // Allow the webview to access the local readit server
62
+ portMapping: [
63
+ {
64
+ webviewPort: port,
65
+ extensionHostPort: port,
66
+ },
67
+ ],
68
+ },
69
+ );
70
+
71
+ panel.iconPath = vscode.Uri.joinPath(this.extensionUri, "icon.svg");
72
+ panel.webview.html = this.getWebviewContent(port, filePath, panel.webview);
73
+
74
+ this.panels.set(filePath, panel);
75
+
76
+ // Clean up when the panel is closed
77
+ panel.onDidDispose(
78
+ () => {
79
+ this.panels.delete(filePath);
80
+ // If no panels remain, stop the server
81
+ if (this.panels.size === 0) {
82
+ this.outputChannel.appendLine("Last panel closed, stopping server.");
83
+ this.serverManager.stop();
84
+ }
85
+ },
86
+ null,
87
+ this.disposables,
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Generates the HTML content for the webview.
93
+ * Uses an iframe pointing at the local readit server so the full Svelte app
94
+ * runs unmodified — SSE, comments, settings, and all.
95
+ */
96
+ private getWebviewContent(
97
+ port: number,
98
+ _filePath: string,
99
+ webview: vscode.Webview,
100
+ ): string {
101
+ const serverUrl = `http://127.0.0.1:${port}`;
102
+ const nonce = getNonce();
103
+
104
+ // The CSP allows framing the local readit server and running inline scripts
105
+ // with a nonce. The iframe approach means all readit functionality works
106
+ // without any modification to the Svelte frontend.
107
+ const csp = [
108
+ `default-src 'none'`,
109
+ `frame-src http://127.0.0.1:${port}`,
110
+ `style-src 'unsafe-inline'`,
111
+ `script-src 'nonce-${nonce}'`,
112
+ ].join("; ");
113
+
114
+ // Use the VS Code webview API to properly resolve the resource
115
+ void webview;
116
+
117
+ return /* html */ `<!DOCTYPE html>
118
+ <html lang="en">
119
+ <head>
120
+ <meta charset="UTF-8" />
121
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
122
+ <meta http-equiv="Content-Security-Policy" content="${csp}" />
123
+ <title>readit</title>
124
+ <style>
125
+ html, body {
126
+ margin: 0;
127
+ padding: 0;
128
+ width: 100%;
129
+ height: 100%;
130
+ overflow: hidden;
131
+ background: var(--vscode-editor-background, #1e1e1e);
132
+ }
133
+ iframe {
134
+ border: none;
135
+ width: 100%;
136
+ height: 100%;
137
+ }
138
+ .loading {
139
+ display: flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ width: 100%;
143
+ height: 100%;
144
+ color: var(--vscode-foreground, #ccc);
145
+ font-family: var(--vscode-font-family, sans-serif);
146
+ font-size: 14px;
147
+ }
148
+ .loading.hidden {
149
+ display: none;
150
+ }
151
+ </style>
152
+ </head>
153
+ <body>
154
+ <div id="loading" class="loading">Starting readit server...</div>
155
+ <iframe
156
+ id="readit-frame"
157
+ src="${serverUrl}"
158
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
159
+ style="display: none;"
160
+ ></iframe>
161
+ <script nonce="${nonce}">
162
+ const iframe = document.getElementById('readit-frame');
163
+ const loading = document.getElementById('loading');
164
+ iframe.addEventListener('load', () => {
165
+ loading.classList.add('hidden');
166
+ iframe.style.display = 'block';
167
+ });
168
+ // Show iframe after a max timeout even if load event doesn't fire
169
+ setTimeout(() => {
170
+ loading.classList.add('hidden');
171
+ iframe.style.display = 'block';
172
+ }, 10000);
173
+ </script>
174
+ </body>
175
+ </html>`;
176
+ }
177
+
178
+ /** Closes all open panels. */
179
+ closeAll(): void {
180
+ for (const panel of this.panels.values()) {
181
+ panel.dispose();
182
+ }
183
+ this.panels.clear();
184
+ }
185
+
186
+ dispose(): void {
187
+ this.closeAll();
188
+ for (const d of this.disposables) {
189
+ d.dispose();
190
+ }
191
+ this.disposables = [];
192
+ }
193
+ }
194
+
195
+ /** Generate a random nonce for CSP inline script tags. */
196
+ function getNonce(): string {
197
+ const chars =
198
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
199
+ let result = "";
200
+ for (let i = 0; i < 32; i++) {
201
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
202
+ }
203
+ return result;
204
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022"],
5
+ "types": ["node"],
6
+ "module": "Node16",
7
+ "moduleResolution": "Node16",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "noEmit": true,
14
+ "rootDir": "src",
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true
17
+ },
18
+ "include": ["src"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }
@@ -1,13 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>Test Document</title>
6
- </head>
7
- <body>
8
- <h1>Test Document</h1>
9
- <p>This is a paragraph for testing text selection.</p>
10
- <h2>Second Section</h2>
11
- <p>Here is another paragraph with some more text to select and comment on.</p>
12
- </body>
13
- </html>
package/src/App.tsx DELETED
@@ -1,368 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useRef } from "react";
2
- import { Toaster, toast } from "sonner";
3
- import { CommentInput } from "./components/comments/CommentInput";
4
- import { CommentNav } from "./components/comments/CommentNav";
5
- import { DocumentViewer } from "./components/DocumentViewer/DocumentViewer";
6
- import { Header } from "./components/Header";
7
- import { MarginNotes } from "./components/MarginNotes";
8
- import { ReanchorConfirm } from "./components/ReanchorConfirm";
9
- import { TabBar } from "./components/TabBar";
10
- import { TableOfContents } from "./components/TableOfContents";
11
- import {
12
- CommentProvider,
13
- useCommentActions,
14
- useCommentData,
15
- } from "./contexts/CommentContext";
16
- import { useLocale } from "./contexts/LocaleContext";
17
- import { PositionsProvider, usePositions } from "./contexts/PositionsContext";
18
- import { SettingsProvider } from "./contexts/SettingsContext";
19
- import { useDocument } from "./hooks/useDocument";
20
- import { useHeadings } from "./hooks/useHeadings";
21
- import { useScrollSpy } from "./hooks/useScrollSpy";
22
- import { useTextSelection } from "./hooks/useTextSelection";
23
- import { exportCommentsAsJson, generatePrompt } from "./lib/export";
24
- import { cn } from "./lib/utils";
25
- import { appStore, useAppStore } from "./store";
26
-
27
- const TOASTER_ICONS = { success: null, error: null, info: null, warning: null };
28
- const TOASTER_OPTIONS = {
29
- unstyled: true,
30
- duration: 2000,
31
- classNames: {
32
- toast: cn(
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",
34
- "text-xs text-zinc-500 dark:text-zinc-400",
35
- ),
36
- },
37
- };
38
-
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) {
46
- const { t } = useLocale();
47
- const { comments, sortedComments, reanchorTarget } = useCommentData();
48
- const { addComment, reanchorComment, cancelReanchor, setHoveredCommentId } =
49
- useCommentActions();
50
-
51
- const { selection, pendingSelectionTop, onTextSelect, clearSelection } =
52
- useTextSelection();
53
-
54
- const pos = usePositions();
55
-
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
- }, []);
86
-
87
- const handleHighlightClick = useCallback((commentId: string) => {
88
- const marginNote = window.document.querySelector(
89
- `article[data-comment-id="${commentId}"]`,
90
- );
91
- if (marginNote) {
92
- marginNote.scrollIntoView({ behavior: "smooth", block: "center" });
93
- }
94
- }, []);
95
-
96
- // Scroll save/restore for tab switching (visibility-based, not mount-based)
97
- const setScrollY = useAppStore((s) => s.setScrollY);
98
- const savedScrollY = useAppStore(
99
- (s) => s.documents.get(document.filePath)?.scrollY ?? 0,
100
- );
101
- const prevActiveRef = useRef(isActive);
102
-
103
- useEffect(() => {
104
- const wasActive = prevActiveRef.current;
105
- prevActiveRef.current = isActive;
106
-
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
114
- requestAnimationFrame(() => {
115
- requestAnimationFrame(() => {
116
- window.scrollTo(0, savedScrollY);
117
- });
118
- });
119
- }
120
- }, [isActive, savedScrollY, setScrollY, document.filePath]);
121
-
122
- const handleAddComment = useCallback(
123
- (commentText: string) => {
124
- if (!selection) return;
125
-
126
- addComment(
127
- selection.text,
128
- commentText,
129
- selection.startOffset,
130
- selection.endOffset,
131
- );
132
-
133
- clearSelection();
134
- },
135
- [selection, addComment, clearSelection],
136
- );
137
-
138
- const handleConfirmReanchor = useCallback(() => {
139
- if (!selection || !reanchorTarget) return;
140
-
141
- reanchorComment(
142
- reanchorTarget.commentId,
143
- selection.text,
144
- selection.startOffset,
145
- selection.endOffset,
146
- );
147
-
148
- cancelReanchor();
149
- clearSelection();
150
- }, [
151
- selection,
152
- reanchorTarget,
153
- reanchorComment,
154
- cancelReanchor,
155
- clearSelection,
156
- ]);
157
-
158
- const handleCancelReanchor = useCallback(() => {
159
- cancelReanchor();
160
- clearSelection();
161
- }, [cancelReanchor, clearSelection]);
162
-
163
- if (!document) return null;
164
-
165
- return (
166
- <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex flex-col">
167
- <Header
168
- fileName={document.fileName}
169
- onCopyAll={copyAll}
170
- onExportJson={exportJson}
171
- onReload={reload}
172
- />
173
-
174
- <div className="flex-1 flex gap-4 w-full max-w-7xl mx-auto">
175
- {headings.length > 0 && (
176
- <aside className="w-48 flex-shrink-0 py-6 pl-6 hidden xl:block">
177
- <div className="sticky top-64 max-h-[calc(100vh-17rem)] overflow-y-auto">
178
- <TableOfContents
179
- headings={headings}
180
- activeId={activeHeadingId}
181
- onHeadingClick={scrollToHeading}
182
- />
183
- </div>
184
- </aside>
185
- )}
186
-
187
- <div className="flex-1 px-6 py-6">
188
- <DocumentViewer
189
- content={document.content}
190
- comments={comments}
191
- headings={headings}
192
- isActive={isActive}
193
- onTextSelect={onTextSelect}
194
- onHighlightHover={setHoveredCommentId}
195
- onHighlightClick={handleHighlightClick}
196
- />
197
- </div>
198
-
199
- <div className="w-72 flex-shrink-0 py-6 pr-4 relative">
200
- {selection && pendingSelectionTop !== undefined && (
201
- <div
202
- className="absolute left-0 right-0 z-10 bg-white dark:bg-zinc-900"
203
- style={{ top: pendingSelectionTop }}
204
- >
205
- {reanchorTarget !== null ? (
206
- <ReanchorConfirm
207
- selectionText={selection.text}
208
- onConfirm={handleConfirmReanchor}
209
- onCancel={handleCancelReanchor}
210
- />
211
- ) : (
212
- <CommentInput
213
- key={selection.text}
214
- selectedText={selection.text}
215
- onSubmit={handleAddComment}
216
- onCancel={clearSelection}
217
- />
218
- )}
219
- </div>
220
- )}
221
-
222
- <MarginNotes sortedComments={sortedComments} />
223
- </div>
224
- </div>
225
-
226
- <CommentNav />
227
-
228
- <footer className="py-4 text-center text-sm text-zinc-400 dark:text-zinc-500">
229
- {t("app.footer")}
230
- </footer>
231
- </div>
232
- );
233
- }
234
-
235
- function useTabKeyboardShortcuts() {
236
- useEffect(() => {
237
- const handleKeyDown = (event: KeyboardEvent) => {
238
- if (!event.metaKey) return;
239
-
240
- // Cmd+1-9: switch to tab by index
241
- const digit = Number.parseInt(event.key, 10);
242
- if (digit >= 1 && digit <= 9) {
243
- const { documentOrder } = appStore.getState();
244
- if (documentOrder.length <= 1) return;
245
- const targetIndex = Math.min(digit - 1, documentOrder.length - 1);
246
- const targetPath = documentOrder[targetIndex];
247
- if (targetPath) {
248
- event.preventDefault();
249
- appStore.getState().setActiveDocument(targetPath);
250
- }
251
- }
252
- };
253
-
254
- window.addEventListener("keydown", handleKeyDown);
255
- return () => window.removeEventListener("keydown", handleKeyDown);
256
- }, []);
257
- }
258
-
259
- function App() {
260
- const { t } = useLocale();
261
- const { error, isInitialized, reload } = useDocument();
262
- const documentOrder = useAppStore((s) => s.documentOrder);
263
- const activeDocumentPath = useAppStore((s) => s.activeDocumentPath);
264
- const documents = useAppStore((s) => s.documents);
265
-
266
- useTabKeyboardShortcuts();
267
-
268
- useEffect(() => {
269
- const eventSource = new EventSource("/api/heartbeat");
270
- return () => eventSource.close();
271
- }, []);
272
-
273
- if (error) {
274
- return (
275
- <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
276
- <div className="text-red-600">{error}</div>
277
- </div>
278
- );
279
- }
280
-
281
- if (!isInitialized) {
282
- return (
283
- <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
284
- <div className="text-zinc-500 dark:text-zinc-400">
285
- {t("app.loading")}
286
- </div>
287
- </div>
288
- );
289
- }
290
-
291
- if (documentOrder.length === 0) {
292
- return (
293
- <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex flex-col">
294
- <TabBar />
295
- <div className="flex-1 flex flex-col items-center justify-center gap-3">
296
- <p className="text-zinc-400 dark:text-zinc-500 text-sm">
297
- {t("app.noDocuments")}
298
- </p>
299
- <p className="text-zinc-400 dark:text-zinc-500 text-xs">
300
- {t("app.noDocumentsHintPrefix")}
301
- {t("app.noDocumentsHintPrefix") && " "}
302
- <code className="bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded text-xs">
303
- readit open &lt;file.md&gt;
304
- </code>{" "}
305
- {t("app.noDocumentsHintSuffix")}
306
- </p>
307
- </div>
308
- </div>
309
- );
310
- }
311
-
312
- return (
313
- <>
314
- <TabBar />
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>
364
- </>
365
- );
366
- }
367
-
368
- export default App;
@@ -1,91 +0,0 @@
1
- import {
2
- ClipboardCopy,
3
- FileDown,
4
- FileText,
5
- MoreHorizontal,
6
- RefreshCw,
7
- Settings,
8
- } from "lucide-react";
9
- import { useState } from "react";
10
- import { useCommentData } from "../contexts/CommentContext";
11
- import { useLocale } from "../contexts/LocaleContext";
12
- import { RawModal } from "./RawModal";
13
- import { SettingsModal } from "./SettingsModal";
14
- import { Button } from "./ui/Button";
15
- import {
16
- DropdownMenu,
17
- DropdownMenuContent,
18
- DropdownMenuItem,
19
- DropdownMenuSeparator,
20
- DropdownMenuTrigger,
21
- } from "./ui/DropdownMenu";
22
-
23
- interface ActionsMenuProps {
24
- onCopyAll: () => void;
25
- onExportJson: () => void;
26
- onReload: () => void;
27
- }
28
-
29
- export function ActionsMenu({
30
- onCopyAll,
31
- onExportJson,
32
- onReload,
33
- }: ActionsMenuProps) {
34
- const { commentCount } = useCommentData();
35
- const { t } = useLocale();
36
-
37
- const [menuOpen, setMenuOpen] = useState(false);
38
- const [rawModalOpen, setRawModalOpen] = useState(false);
39
- const [settingsOpen, setSettingsOpen] = useState(false);
40
-
41
- return (
42
- <>
43
- <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
44
- <DropdownMenuTrigger asChild>
45
- <Button
46
- variant="ghost"
47
- size="icon"
48
- className="size-7"
49
- aria-label={t("actions.ariaLabel")}
50
- >
51
- <MoreHorizontal className="w-4 h-4" />
52
- </Button>
53
- </DropdownMenuTrigger>
54
- <DropdownMenuContent align="end" className="min-w-[160px]">
55
- <DropdownMenuItem onSelect={() => setSettingsOpen(true)}>
56
- <Settings />
57
- {t("actions.settings")}
58
- </DropdownMenuItem>
59
- <DropdownMenuSeparator />
60
- <DropdownMenuItem onSelect={() => onReload()}>
61
- <RefreshCw />
62
- {t("actions.reload")}
63
- </DropdownMenuItem>
64
- {commentCount > 0 && (
65
- <>
66
- <DropdownMenuItem onSelect={() => onCopyAll()}>
67
- <ClipboardCopy />
68
- {t("actions.copyAll")}
69
- </DropdownMenuItem>
70
- <DropdownMenuItem onSelect={() => onExportJson()}>
71
- <FileDown />
72
- {t("actions.exportJson")}
73
- </DropdownMenuItem>
74
- <DropdownMenuItem onSelect={() => setRawModalOpen(true)}>
75
- <FileText />
76
- {t("actions.viewRaw")}
77
- </DropdownMenuItem>
78
- </>
79
- )}
80
- </DropdownMenuContent>
81
- </DropdownMenu>
82
-
83
- <RawModal isOpen={rawModalOpen} onClose={() => setRawModalOpen(false)} />
84
-
85
- <SettingsModal
86
- isOpen={settingsOpen}
87
- onClose={() => setSettingsOpen(false)}
88
- />
89
- </>
90
- );
91
- }