@peaske7/readit 0.2.0 → 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.
- package/.claude/CLAUDE.md +118 -76
- package/.claude/commands/review.md +1 -1
- package/.claude/roadmap.md +32 -9
- package/.claude/user-stories.md +100 -15
- package/AGENTS.md +30 -26
- package/Makefile +32 -0
- package/README.md +90 -2
- package/biome.json +18 -8
- package/bun.lock +426 -568
- package/bunfig.toml +2 -0
- package/docs/perf-baseline.md +56 -1
- package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
- package/e2e/comments.spec.ts +14 -58
- package/e2e/document-load.spec.ts +1 -23
- package/e2e/export.spec.ts +4 -4
- package/e2e/perf/add-comment.spec.ts +9 -11
- package/e2e/perf/fixtures/generate.ts +1 -5
- package/e2e/perf/screenshot-final.png +0 -0
- package/e2e/perf/utils/metrics.ts +73 -9
- package/e2e/persistence-file.spec.ts +41 -26
- package/e2e/utils/selection.ts +17 -73
- package/go/cmd/readit/main.go +416 -0
- package/go/go.mod +20 -0
- package/go/go.sum +41 -0
- package/go/internal/server/anchor.go +302 -0
- package/go/internal/server/anchor_test.go +111 -0
- package/go/internal/server/comments.go +390 -0
- package/go/internal/server/documents.go +113 -0
- package/go/internal/server/embed.go +17 -0
- package/go/internal/server/headings.go +33 -0
- package/go/internal/server/headings_test.go +75 -0
- package/go/internal/server/htmltext.go +123 -0
- package/go/internal/server/markdown.go +157 -0
- package/go/internal/server/markdown_bench_test.go +42 -0
- package/go/internal/server/markdown_test.go +79 -0
- package/go/internal/server/server.go +453 -0
- package/go/internal/server/server_bench_test.go +122 -0
- package/go/internal/server/settings.go +110 -0
- package/go/internal/server/sse.go +140 -0
- package/go/internal/server/storage.go +275 -0
- package/go/internal/server/storage_test.go +118 -0
- package/go/internal/server/template.go +66 -0
- package/go/internal/server/types.go +101 -0
- package/go/internal/server/watcher.go +74 -0
- package/index.html +4 -14
- package/nvim-readit/lua/readit/health.lua +64 -0
- package/nvim-readit/lua/readit/init.lua +463 -0
- package/nvim-readit/plugin/readit.lua +19 -0
- package/package.json +20 -28
- package/shell/_readit +158 -0
- package/shell/readit.zsh +87 -0
- package/src/App.svelte +881 -0
- package/src/cli.ts +183 -21
- package/src/components/ActionsMenu.svelte +95 -0
- package/src/components/CommentBadge.svelte +67 -0
- package/src/components/CommentErrorBanner.svelte +33 -0
- package/src/components/CommentInput.svelte +75 -0
- package/src/components/CommentListItem.svelte +95 -0
- package/src/components/CommentManager.svelte +129 -0
- package/src/components/CommentNav.svelte +109 -0
- package/src/components/DocumentViewer.svelte +218 -0
- package/src/components/FloatingComment.svelte +107 -0
- package/src/components/Header.svelte +76 -0
- package/src/components/InlineEditor.svelte +72 -0
- package/src/components/MarginNote.svelte +167 -0
- package/src/components/MarginNotesContainer.svelte +33 -0
- package/src/components/RawModal.svelte +126 -0
- package/src/components/ReanchorConfirm.svelte +30 -0
- package/src/components/SettingsModal.svelte +220 -0
- package/src/components/ShortcutCapture.svelte +82 -0
- package/src/components/ShortcutList.svelte +145 -0
- package/src/components/TabBar.svelte +52 -0
- package/src/components/TableOfContents.svelte +125 -0
- package/src/components/ui/ActionLink.svelte +40 -0
- package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
- package/src/components/ui/Dialog.svelte +97 -0
- package/src/components/ui/DropdownMenu.svelte +85 -0
- package/src/components/ui/DropdownMenuItem.svelte +38 -0
- package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
- package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
- package/src/env.d.ts +6 -0
- package/src/index.css +36 -166
- package/src/lib/__fixtures__/bench-data.ts +0 -13
- package/src/lib/anchor.bench.ts +1 -12
- package/src/lib/anchor.test.ts +0 -8
- package/src/lib/anchor.ts +0 -4
- package/src/lib/comment-storage.bench.ts +49 -0
- package/src/lib/comment-storage.test.ts +41 -33
- package/src/lib/comment-storage.ts +21 -18
- package/src/lib/export.bench.ts +21 -0
- package/src/lib/export.ts +0 -1
- package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
- package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
- package/src/lib/highlight/core.test.ts +0 -5
- package/src/lib/highlight/dom.ts +52 -216
- package/src/lib/highlight/highlight-registry.ts +221 -0
- package/src/lib/highlight/highlight.bench.ts +92 -0
- package/src/lib/highlight/highlighter.ts +112 -132
- package/src/lib/highlight/resolver.ts +5 -79
- package/src/lib/highlight/types.ts +0 -5
- package/src/lib/html-text.test.ts +162 -0
- package/src/lib/html-text.ts +161 -0
- package/src/lib/i18n/en.ts +26 -0
- package/src/lib/i18n/ja.ts +26 -0
- package/src/lib/i18n/types.ts +25 -0
- package/src/lib/margin-layout.bench.ts +61 -0
- package/src/lib/margin-layout.ts +0 -7
- package/src/lib/markdown-renderer.test.ts +154 -0
- package/src/lib/markdown-renderer.ts +177 -0
- package/src/lib/mermaid-config.ts +38 -0
- package/src/lib/mermaid-renderer.ts +162 -0
- package/src/lib/mermaid-worker.ts +60 -0
- package/src/lib/positions.ts +31 -24
- package/src/lib/shortcut-registry.ts +244 -0
- package/src/lib/utils.ts +0 -29
- package/src/main.ts +16 -0
- package/src/schema.ts +16 -5
- package/src/server.ts +355 -91
- package/src/stores/app.svelte.ts +231 -0
- package/src/stores/locale.svelte.ts +46 -0
- package/src/stores/settings.svelte.ts +90 -0
- package/src/stores/shortcuts.svelte.ts +104 -0
- package/src/stores/ui.svelte.ts +12 -0
- package/src/template.ts +104 -0
- package/src/test-setup.ts +47 -0
- package/svelte.config.js +5 -0
- package/tsconfig.json +2 -2
- package/vite.config.ts +23 -3
- package/vscode-readit/.mcp.json +7 -0
- package/vscode-readit/.vscodeignore +7 -0
- package/vscode-readit/bun.lock +78 -0
- package/vscode-readit/icon.svg +10 -0
- package/vscode-readit/package.json +110 -0
- package/vscode-readit/src/extension.ts +117 -0
- package/vscode-readit/src/server-manager.ts +272 -0
- package/vscode-readit/src/webview-provider.ts +204 -0
- package/vscode-readit/tsconfig.json +20 -0
- package/e2e/fixtures/sample.html +0 -13
- package/src/App.tsx +0 -368
- package/src/components/ActionsMenu.tsx +0 -91
- package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
- package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
- package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
- package/src/components/Header.tsx +0 -54
- package/src/components/InlineEditor.tsx +0 -74
- package/src/components/MarginNote.tsx +0 -185
- package/src/components/MarginNotes.tsx +0 -23
- package/src/components/RawModal.tsx +0 -144
- package/src/components/ReanchorConfirm.tsx +0 -36
- package/src/components/SettingsModal.tsx +0 -232
- package/src/components/TabBar.tsx +0 -60
- package/src/components/TableOfContents.tsx +0 -108
- package/src/components/comments/CommentBadge.tsx +0 -49
- package/src/components/comments/CommentInput.tsx +0 -86
- package/src/components/comments/CommentListItem.tsx +0 -90
- package/src/components/comments/CommentManager.tsx +0 -129
- package/src/components/comments/CommentNav.tsx +0 -109
- package/src/components/ui/ActionLink.tsx +0 -28
- package/src/components/ui/Dialog.tsx +0 -116
- package/src/components/ui/DropdownMenu.tsx +0 -158
- package/src/contexts/CommentContext.tsx +0 -198
- package/src/contexts/LocaleContext.tsx +0 -76
- package/src/contexts/PositionsContext.tsx +0 -16
- package/src/contexts/SettingsContext.tsx +0 -133
- package/src/hooks/useClickOutside.ts +0 -31
- package/src/hooks/useCommentNavigation.ts +0 -107
- package/src/hooks/useComments.ts +0 -311
- package/src/hooks/useDocument.ts +0 -157
- package/src/hooks/useScrollSpy.ts +0 -77
- package/src/hooks/useTextSelection.ts +0 -86
- package/src/lib/highlight/worker.ts +0 -45
- package/src/main.tsx +0 -13
- 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
|
+
}
|
package/e2e/fixtures/sample.html
DELETED
|
@@ -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 <file.md>
|
|
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
|
-
}
|