@peaske7/readit 0.1.8 → 0.2.1

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 (221) 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 -5
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -710
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +130 -0
  12. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  13. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  14. package/e2e/comments.spec.ts +14 -58
  15. package/e2e/document-load.spec.ts +1 -23
  16. package/e2e/export.spec.ts +4 -4
  17. package/e2e/perf/add-comment.spec.ts +116 -0
  18. package/e2e/perf/fixtures/generate.ts +327 -0
  19. package/e2e/perf/initial-load.spec.ts +49 -0
  20. package/e2e/perf/perf.setup.ts +23 -0
  21. package/e2e/perf/perf.teardown.ts +9 -0
  22. package/e2e/perf/screenshot-final.png +0 -0
  23. package/e2e/perf/scroll.spec.ts +39 -0
  24. package/e2e/perf/tab-switch.spec.ts +69 -0
  25. package/e2e/perf/text-selection.spec.ts +119 -0
  26. package/e2e/perf/utils/metrics.ts +350 -0
  27. package/e2e/perf/utils/perf-cli.ts +86 -0
  28. package/e2e/persistence-file.spec.ts +41 -26
  29. package/e2e/utils/selection.ts +17 -73
  30. package/go/cmd/readit/main.go +416 -0
  31. package/go/go.mod +20 -0
  32. package/go/go.sum +41 -0
  33. package/go/internal/server/anchor.go +302 -0
  34. package/go/internal/server/anchor_test.go +111 -0
  35. package/go/internal/server/comments.go +390 -0
  36. package/go/internal/server/documents.go +113 -0
  37. package/go/internal/server/embed.go +17 -0
  38. package/go/internal/server/headings.go +33 -0
  39. package/go/internal/server/headings_test.go +75 -0
  40. package/go/internal/server/htmltext.go +123 -0
  41. package/go/internal/server/markdown.go +157 -0
  42. package/go/internal/server/markdown_bench_test.go +42 -0
  43. package/go/internal/server/markdown_test.go +79 -0
  44. package/go/internal/server/server.go +453 -0
  45. package/go/internal/server/server_bench_test.go +122 -0
  46. package/go/internal/server/settings.go +110 -0
  47. package/go/internal/server/sse.go +140 -0
  48. package/go/internal/server/storage.go +275 -0
  49. package/go/internal/server/storage_test.go +118 -0
  50. package/go/internal/server/template.go +66 -0
  51. package/go/internal/server/types.go +101 -0
  52. package/go/internal/server/watcher.go +74 -0
  53. package/index.html +4 -14
  54. package/nvim-readit/lua/readit/health.lua +64 -0
  55. package/nvim-readit/lua/readit/init.lua +463 -0
  56. package/nvim-readit/plugin/readit.lua +19 -0
  57. package/package.json +24 -41
  58. package/playwright.config.ts +12 -0
  59. package/shell/_readit +158 -0
  60. package/shell/readit.zsh +87 -0
  61. package/src/App.svelte +881 -0
  62. package/src/{cli/index.ts → cli.ts} +216 -70
  63. package/src/components/ActionsMenu.svelte +95 -0
  64. package/src/components/CommentBadge.svelte +67 -0
  65. package/src/components/CommentErrorBanner.svelte +33 -0
  66. package/src/components/CommentInput.svelte +75 -0
  67. package/src/components/CommentListItem.svelte +95 -0
  68. package/src/components/CommentManager.svelte +129 -0
  69. package/src/components/CommentNav.svelte +109 -0
  70. package/src/components/DocumentViewer.svelte +218 -0
  71. package/src/components/FloatingComment.svelte +107 -0
  72. package/src/components/Header.svelte +76 -0
  73. package/src/components/InlineEditor.svelte +72 -0
  74. package/src/components/MarginNote.svelte +167 -0
  75. package/src/components/MarginNotesContainer.svelte +33 -0
  76. package/src/components/RawModal.svelte +126 -0
  77. package/src/components/ReanchorConfirm.svelte +30 -0
  78. package/src/components/SettingsModal.svelte +220 -0
  79. package/src/components/ShortcutCapture.svelte +82 -0
  80. package/src/components/ShortcutList.svelte +145 -0
  81. package/src/components/TabBar.svelte +52 -0
  82. package/src/components/TableOfContents.svelte +125 -0
  83. package/src/components/ui/ActionLink.svelte +40 -0
  84. package/src/components/ui/Button.svelte +53 -0
  85. package/src/components/ui/Dialog.svelte +97 -0
  86. package/src/components/ui/DropdownMenu.svelte +85 -0
  87. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  88. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  89. package/src/components/ui/Text.svelte +42 -0
  90. package/src/env.d.ts +6 -0
  91. package/src/index.css +36 -166
  92. package/src/lib/__fixtures__/bench-data.ts +1 -54
  93. package/src/lib/anchor.bench.ts +47 -68
  94. package/src/lib/anchor.test.ts +5 -9
  95. package/src/lib/anchor.ts +9 -93
  96. package/src/lib/comment-storage.bench.ts +6 -20
  97. package/src/lib/comment-storage.test.ts +45 -37
  98. package/src/lib/comment-storage.ts +23 -64
  99. package/src/lib/export.bench.ts +9 -23
  100. package/src/lib/export.ts +7 -14
  101. package/src/lib/headings.test.ts +103 -0
  102. package/src/lib/headings.ts +44 -0
  103. package/src/lib/highlight/core.test.ts +1 -6
  104. package/src/lib/highlight/dom.ts +53 -280
  105. package/src/lib/highlight/highlight-registry.ts +221 -0
  106. package/src/lib/highlight/highlight.bench.ts +92 -0
  107. package/src/lib/highlight/highlighter.ts +122 -302
  108. package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
  109. package/src/lib/highlight/types.ts +0 -40
  110. package/src/lib/html-text.test.ts +162 -0
  111. package/src/lib/html-text.ts +161 -0
  112. package/src/lib/i18n/en.ts +13 -36
  113. package/src/lib/i18n/ja.ts +14 -37
  114. package/src/lib/i18n/types.ts +13 -36
  115. package/src/lib/margin-layout.bench.ts +48 -15
  116. package/src/lib/margin-layout.ts +2 -31
  117. package/src/lib/markdown-renderer.test.ts +154 -0
  118. package/src/lib/markdown-renderer.ts +177 -0
  119. package/src/lib/mermaid-config.ts +38 -0
  120. package/src/lib/mermaid-renderer.ts +162 -0
  121. package/src/lib/mermaid-worker.ts +60 -0
  122. package/src/lib/positions.ts +157 -0
  123. package/src/lib/shortcut-registry.ts +138 -103
  124. package/src/lib/utils.ts +2 -48
  125. package/src/main.ts +16 -0
  126. package/src/schema.ts +92 -0
  127. package/src/{server/index.ts → server.ts} +427 -163
  128. package/src/stores/app.svelte.ts +231 -0
  129. package/src/stores/locale.svelte.ts +46 -0
  130. package/src/stores/settings.svelte.ts +90 -0
  131. package/src/stores/shortcuts.svelte.ts +104 -0
  132. package/src/stores/ui.svelte.ts +12 -0
  133. package/src/template.ts +104 -0
  134. package/src/test-setup.ts +47 -0
  135. package/svelte.config.js +5 -0
  136. package/tsconfig.json +2 -2
  137. package/vite.config.ts +31 -3
  138. package/vscode-readit/.mcp.json +7 -0
  139. package/vscode-readit/.vscodeignore +7 -0
  140. package/vscode-readit/bun.lock +78 -0
  141. package/vscode-readit/icon.svg +10 -0
  142. package/vscode-readit/package.json +110 -0
  143. package/vscode-readit/src/extension.ts +117 -0
  144. package/vscode-readit/src/server-manager.ts +272 -0
  145. package/vscode-readit/src/webview-provider.ts +204 -0
  146. package/vscode-readit/tsconfig.json +20 -0
  147. package/e2e/fixtures/sample.html +0 -13
  148. package/src/App.tsx +0 -416
  149. package/src/components/ActionsMenu.tsx +0 -112
  150. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  151. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
  152. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  153. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  154. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
  155. package/src/components/DocumentViewer/index.ts +0 -1
  156. package/src/components/FloatingTOC.tsx +0 -61
  157. package/src/components/Header.tsx +0 -65
  158. package/src/components/InlineEditor.tsx +0 -74
  159. package/src/components/MarginNote.tsx +0 -207
  160. package/src/components/MarginNotes.tsx +0 -50
  161. package/src/components/RawModal.tsx +0 -143
  162. package/src/components/ReanchorConfirm.tsx +0 -36
  163. package/src/components/SettingsModal.tsx +0 -310
  164. package/src/components/ShortcutCapture.tsx +0 -48
  165. package/src/components/ShortcutList.tsx +0 -198
  166. package/src/components/TabBar.tsx +0 -60
  167. package/src/components/TableOfContents.tsx +0 -108
  168. package/src/components/comments/CommentBadge.tsx +0 -49
  169. package/src/components/comments/CommentInput.tsx +0 -114
  170. package/src/components/comments/CommentListItem.tsx +0 -92
  171. package/src/components/comments/CommentManager.tsx +0 -113
  172. package/src/components/comments/CommentMinimap.tsx +0 -62
  173. package/src/components/comments/CommentNav.tsx +0 -109
  174. package/src/components/ui/ActionBar.tsx +0 -16
  175. package/src/components/ui/ActionLink.tsx +0 -32
  176. package/src/components/ui/Button.tsx +0 -55
  177. package/src/components/ui/Dialog.tsx +0 -156
  178. package/src/components/ui/DropdownMenu.tsx +0 -114
  179. package/src/components/ui/SeparatorDot.tsx +0 -9
  180. package/src/components/ui/Text.tsx +0 -54
  181. package/src/contexts/CommentContext.tsx +0 -229
  182. package/src/contexts/LayoutContext.tsx +0 -88
  183. package/src/contexts/LocaleContext.tsx +0 -35
  184. package/src/hooks/useClickOutside.ts +0 -35
  185. package/src/hooks/useClipboard.ts +0 -82
  186. package/src/hooks/useCommentNavigation.ts +0 -130
  187. package/src/hooks/useComments.ts +0 -323
  188. package/src/hooks/useDocument.ts +0 -156
  189. package/src/hooks/useEditorScheme.ts +0 -51
  190. package/src/hooks/useFontPreference.ts +0 -59
  191. package/src/hooks/useHeadings.test.ts +0 -159
  192. package/src/hooks/useHeadings.ts +0 -129
  193. package/src/hooks/useKeybindings.ts +0 -108
  194. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  195. package/src/hooks/useLayoutMode.ts +0 -44
  196. package/src/hooks/useLocalePreference.ts +0 -42
  197. package/src/hooks/useReanchorMode.ts +0 -33
  198. package/src/hooks/useScrollMetrics.ts +0 -56
  199. package/src/hooks/useScrollSpy.ts +0 -81
  200. package/src/hooks/useTextSelection.ts +0 -123
  201. package/src/hooks/useThemePreference.ts +0 -66
  202. package/src/lib/context.bench.ts +0 -41
  203. package/src/lib/context.test.ts +0 -224
  204. package/src/lib/context.ts +0 -193
  205. package/src/lib/editor-links.ts +0 -59
  206. package/src/lib/highlight/colors.ts +0 -37
  207. package/src/lib/highlight/index.ts +0 -23
  208. package/src/lib/highlight/script-builder.ts +0 -485
  209. package/src/lib/html-processor.test.tsx +0 -170
  210. package/src/lib/html-processor.tsx +0 -95
  211. package/src/lib/i18n/completeness.test.ts +0 -51
  212. package/src/lib/i18n/translations.test.ts +0 -39
  213. package/src/lib/layout-constants.ts +0 -12
  214. package/src/lib/scroll.test.ts +0 -118
  215. package/src/lib/scroll.ts +0 -47
  216. package/src/lib/shortcut-registry.test.ts +0 -173
  217. package/src/lib/utils.test.ts +0 -110
  218. package/src/main.tsx +0 -13
  219. package/src/store/index.test.ts +0 -242
  220. package/src/store/index.ts +0 -254
  221. package/src/types/index.ts +0 -127
@@ -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,416 +0,0 @@
1
- import { use, useCallback, useEffect, useRef } from "react";
2
- import { Toaster } from "sonner";
3
- import { CommentInput } from "./components/comments/CommentInput";
4
- import { CommentMinimap } from "./components/comments/CommentMinimap";
5
- import { CommentNav } from "./components/comments/CommentNav";
6
- import { DocumentViewer } from "./components/DocumentViewer";
7
- import { FloatingTOC } from "./components/FloatingTOC";
8
- import { Header } from "./components/Header";
9
- import { MarginNotes } from "./components/MarginNotes";
10
- import { ReanchorConfirm } from "./components/ReanchorConfirm";
11
- import { TabBar } from "./components/TabBar";
12
- 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";
16
- import { useLocale } from "./contexts/LocaleContext";
17
- import { useClipboard } from "./hooks/useClipboard";
18
- import { useDocument } from "./hooks/useDocument";
19
- import { useHeadings } from "./hooks/useHeadings";
20
- import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
21
- import { useScrollMetrics } from "./hooks/useScrollMetrics";
22
- import { useScrollSpy } from "./hooks/useScrollSpy";
23
- import { useTextSelection } from "./hooks/useTextSelection";
24
- import { calculateScrollTarget, getElementTopInDocument } from "./lib/scroll";
25
- import { ShortcutActions } from "./lib/shortcut-registry";
26
- import { cn } from "./lib/utils";
27
- import { appStore, useAppStore } from "./store";
28
-
29
- const TOASTER_ICONS = { success: null, error: null, info: null, warning: null };
30
- const TOASTER_OPTIONS = {
31
- unstyled: true,
32
- duration: 2000,
33
- classNames: {
34
- toast: cn(
35
- "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" }),
37
- ),
38
- },
39
- };
40
-
41
- interface AppContentProps {
42
- document: NonNullable<ReturnType<typeof useDocument>["document"]>;
43
- reload: ReturnType<typeof useDocument>["reload"];
44
- }
45
-
46
- function AppContent({ document, reload }: AppContentProps) {
47
- const { t } = useLocale();
48
- const {
49
- comments,
50
- sortedComments,
51
- addComment,
52
- reanchorComment,
53
- reanchorTarget,
54
- cancelReanchor,
55
- hoveredCommentId,
56
- setHoveredCommentId,
57
- navigatePrevious,
58
- navigateNext,
59
- } = use(CommentContext)!;
60
-
61
- const {
62
- selection,
63
- highlightPositions,
64
- documentPositions,
65
- pendingSelectionTop,
66
- onTextSelect,
67
- onPositionsChange,
68
- clearSelection,
69
- } = useTextSelection();
70
-
71
- const {
72
- copyAll,
73
- copyAllRaw,
74
- exportJson,
75
- copySelectionRaw,
76
- copySelectionForLLM,
77
- } = useClipboard({
78
- comments,
79
- document: document ?? undefined,
80
- selection: selection ?? undefined,
81
- clearSelection,
82
- t,
83
- });
84
-
85
- const { shortcuts, isFullscreen } = use(LayoutContext)!;
86
-
87
- useKeyboardShortcuts(shortcuts, {
88
- [ShortcutActions.COPY_ALL]: copyAll,
89
- [ShortcutActions.COPY_ALL_RAW]: copyAllRaw,
90
- [ShortcutActions.NAVIGATE_NEXT]: navigateNext,
91
- [ShortcutActions.NAVIGATE_PREVIOUS]: navigatePrevious,
92
- [ShortcutActions.COPY_SELECTION_RAW]: copySelectionRaw,
93
- [ShortcutActions.COPY_SELECTION_LLM]: copySelectionForLLM,
94
- [ShortcutActions.CLEAR_SELECTION]: clearSelection,
95
- });
96
-
97
- const scrollMetrics = useScrollMetrics();
98
-
99
- const headings = useHeadings(
100
- document?.content ?? null,
101
- document?.type ?? null,
102
- );
103
- const activeHeadingId = useScrollSpy(headings.map((h) => h.id));
104
-
105
- const scrollToHeading = useCallback(
106
- (id: string) => {
107
- let elementRect: DOMRect | undefined;
108
- let iframeTopOffset: number | undefined;
109
-
110
- if (document?.type === "html") {
111
- const iframe = window.document.querySelector("iframe");
112
- const el = iframe?.contentDocument?.getElementById(id);
113
- if (!el || !iframe) return;
114
- elementRect = el.getBoundingClientRect();
115
- iframeTopOffset = iframe.getBoundingClientRect().top;
116
- } else {
117
- elementRect = window.document
118
- .getElementById(id)
119
- ?.getBoundingClientRect();
120
- }
121
- if (!elementRect) return;
122
-
123
- const elementTop = getElementTopInDocument({
124
- elementRect,
125
- scrollY: window.scrollY,
126
- iframeTopOffset,
127
- });
128
- const scrollTarget = calculateScrollTarget({
129
- elementTop,
130
- viewportHeight: window.innerHeight,
131
- });
132
- window.scrollTo({ top: scrollTarget, behavior: "smooth" });
133
- },
134
- [document?.type],
135
- );
136
-
137
- const handleHighlightClick = useCallback((commentId: string) => {
138
- const marginNote = window.document.querySelector(
139
- `article[data-comment-id="${commentId}"]`,
140
- );
141
- if (marginNote) {
142
- marginNote.scrollIntoView({ behavior: "smooth", block: "center" });
143
- }
144
- }, []);
145
-
146
- // Scroll save/restore for tab switching
147
- const setScrollY = useAppStore((s) => s.setScrollY);
148
- const savedScrollY = useAppStore(
149
- (s) => s.getActiveDocumentState()?.scrollY ?? 0,
150
- );
151
- const scrollRestored = useRef(false);
152
-
153
- // Save scroll position on unmount
154
- useEffect(() => {
155
- return () => {
156
- setScrollY(window.scrollY);
157
- };
158
- }, [setScrollY]);
159
-
160
- // Restore scroll position on mount (after highlights paint)
161
- useEffect(() => {
162
- if (savedScrollY === 0 || scrollRestored.current) return;
163
- scrollRestored.current = true;
164
- requestAnimationFrame(() => {
165
- requestAnimationFrame(() => {
166
- window.scrollTo(0, savedScrollY);
167
- });
168
- });
169
- }, [savedScrollY]);
170
-
171
- const handleAddComment = useCallback(
172
- (commentText: string) => {
173
- if (!selection) return;
174
-
175
- addComment(
176
- selection.text,
177
- commentText,
178
- selection.startOffset,
179
- selection.endOffset,
180
- );
181
-
182
- clearSelection();
183
- },
184
- [selection, addComment, clearSelection],
185
- );
186
-
187
- const handleConfirmReanchor = useCallback(() => {
188
- if (!selection || !reanchorTarget) return;
189
-
190
- reanchorComment(
191
- reanchorTarget.commentId,
192
- selection.text,
193
- selection.startOffset,
194
- selection.endOffset,
195
- );
196
-
197
- cancelReanchor();
198
- clearSelection();
199
- }, [
200
- selection,
201
- reanchorTarget,
202
- reanchorComment,
203
- cancelReanchor,
204
- clearSelection,
205
- ]);
206
-
207
- const handleCancelReanchor = useCallback(() => {
208
- cancelReanchor();
209
- clearSelection();
210
- }, [cancelReanchor, clearSelection]);
211
-
212
- if (!document) return null;
213
-
214
- return (
215
- <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex flex-col">
216
- <Toaster
217
- position="bottom-right"
218
- icons={TOASTER_ICONS}
219
- toastOptions={TOASTER_OPTIONS}
220
- />
221
- <Header
222
- fileName={document.fileName}
223
- onCopyAll={copyAll}
224
- onCopyAllRaw={copyAllRaw}
225
- onExportJson={exportJson}
226
- onReload={reload}
227
- />
228
-
229
- <div
230
- className={`flex-1 flex gap-4 w-full ${!isFullscreen ? "max-w-7xl mx-auto" : ""} ${hoveredCommentId ? "has-comment-focus" : ""}`}
231
- >
232
- {!isFullscreen && headings.length > 0 && (
233
- <aside className="w-48 flex-shrink-0 py-6 pl-6 hidden xl:block">
234
- <div className="sticky top-64 max-h-[calc(100vh-17rem)] overflow-y-auto">
235
- <TableOfContents
236
- headings={headings}
237
- activeId={activeHeadingId}
238
- onHeadingClick={scrollToHeading}
239
- />
240
- </div>
241
- </aside>
242
- )}
243
- {isFullscreen && (
244
- <FloatingTOC
245
- headings={headings}
246
- activeId={activeHeadingId}
247
- onHeadingClick={scrollToHeading}
248
- />
249
- )}
250
-
251
- <div className="flex-1 px-6 py-6">
252
- <DocumentViewer
253
- content={document.content}
254
- type={document.type}
255
- comments={comments}
256
- headings={headings}
257
- pendingSelection={selection ?? undefined}
258
- onTextSelect={onTextSelect}
259
- onHighlightPositionsChange={onPositionsChange}
260
- onHighlightHover={setHoveredCommentId}
261
- onHighlightClick={handleHighlightClick}
262
- />
263
- </div>
264
-
265
- <div className="w-72 flex-shrink-0 py-6 pr-4 relative">
266
- {selection && pendingSelectionTop !== undefined && (
267
- <div
268
- className="absolute left-0 right-0 z-10 bg-white dark:bg-zinc-900"
269
- style={{ top: pendingSelectionTop }}
270
- >
271
- {reanchorTarget !== null ? (
272
- <ReanchorConfirm
273
- selectionText={selection.text}
274
- onConfirm={handleConfirmReanchor}
275
- onCancel={handleCancelReanchor}
276
- />
277
- ) : (
278
- <CommentInput
279
- key={selection.text}
280
- selectedText={selection.text}
281
- onSubmit={handleAddComment}
282
- onCancel={clearSelection}
283
- onCopyRaw={copySelectionRaw}
284
- onCopyForLLM={copySelectionForLLM}
285
- />
286
- )}
287
- </div>
288
- )}
289
-
290
- <MarginNotes
291
- sortedComments={sortedComments}
292
- highlightPositions={highlightPositions}
293
- pendingSelectionTop={selection ? pendingSelectionTop : undefined}
294
- />
295
- </div>
296
- </div>
297
-
298
- <CommentMinimap
299
- documentPositions={documentPositions}
300
- documentHeight={scrollMetrics.documentHeight}
301
- viewportHeight={scrollMetrics.viewportHeight}
302
- />
303
-
304
- <CommentNav />
305
-
306
- <footer className="py-4 text-center text-sm text-zinc-400 dark:text-zinc-500">
307
- {t("app.footer")}
308
- </footer>
309
- </div>
310
- );
311
- }
312
-
313
- function useTabKeyboardShortcuts() {
314
- useEffect(() => {
315
- const handleKeyDown = (event: KeyboardEvent) => {
316
- if (!event.metaKey) return;
317
-
318
- // Cmd+1-9: switch to tab by index
319
- const digit = Number.parseInt(event.key, 10);
320
- if (digit >= 1 && digit <= 9) {
321
- const { documentOrder } = appStore.getState();
322
- if (documentOrder.length <= 1) return;
323
- const targetIndex = Math.min(digit - 1, documentOrder.length - 1);
324
- const targetPath = documentOrder[targetIndex];
325
- if (targetPath) {
326
- event.preventDefault();
327
- appStore.getState().setActiveDocument(targetPath);
328
- }
329
- }
330
- };
331
-
332
- window.addEventListener("keydown", handleKeyDown);
333
- return () => window.removeEventListener("keydown", handleKeyDown);
334
- }, []);
335
- }
336
-
337
- function App() {
338
- const { t } = useLocale();
339
- const { document, error, isInitialized, reload } = useDocument();
340
- const documentOrder = useAppStore((s) => s.documentOrder);
341
-
342
- useTabKeyboardShortcuts();
343
-
344
- useEffect(() => {
345
- const eventSource = new EventSource("/api/heartbeat");
346
- return () => eventSource.close();
347
- }, []);
348
-
349
- if (error) {
350
- return (
351
- <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
352
- <div className="text-red-600">{error}</div>
353
- </div>
354
- );
355
- }
356
-
357
- if (!isInitialized) {
358
- return (
359
- <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
360
- <div className="text-zinc-500 dark:text-zinc-400">
361
- {t("app.loading")}
362
- </div>
363
- </div>
364
- );
365
- }
366
-
367
- if (documentOrder.length === 0) {
368
- return (
369
- <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex flex-col">
370
- <TabBar />
371
- <div className="flex-1 flex flex-col items-center justify-center gap-3">
372
- <p className="text-zinc-400 dark:text-zinc-500 text-sm">
373
- {t("app.noDocuments")}
374
- </p>
375
- <p className="text-zinc-400 dark:text-zinc-500 text-xs">
376
- {t("app.noDocumentsHintPrefix")}
377
- {t("app.noDocumentsHintPrefix") && " "}
378
- <code className="bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded text-xs">
379
- readit open &lt;file.md&gt;
380
- </code>{" "}
381
- {t("app.noDocumentsHintSuffix")}
382
- </p>
383
- </div>
384
- </div>
385
- );
386
- }
387
-
388
- if (!document) {
389
- return (
390
- <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
391
- <div className="text-zinc-500 dark:text-zinc-400">
392
- {t("app.loading")}
393
- </div>
394
- </div>
395
- );
396
- }
397
-
398
- return (
399
- <>
400
- <TabBar />
401
- <LayoutProvider>
402
- <CommentProvider
403
- filePath={document.filePath}
404
- clean={document.clean}
405
- documentContent={document.content}
406
- fileName={document.fileName}
407
- documentType={document.type}
408
- >
409
- <AppContent document={document} reload={reload} />
410
- </CommentProvider>
411
- </LayoutProvider>
412
- </>
413
- );
414
- }
415
-
416
- export default App;