@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,38 @@
|
|
|
1
|
+
export const MERMAID_THEME = {
|
|
2
|
+
fontSize: "16px",
|
|
3
|
+
primaryColor: "rgba(245, 222, 160, 0.8)",
|
|
4
|
+
primaryTextColor: "#3f3f46",
|
|
5
|
+
primaryBorderColor: "#c9a84a",
|
|
6
|
+
secondaryColor: "rgba(168, 196, 228, 0.6)",
|
|
7
|
+
secondaryTextColor: "#3f3f46",
|
|
8
|
+
secondaryBorderColor: "#5b7fa8",
|
|
9
|
+
tertiaryColor: "rgba(170, 210, 170, 0.6)",
|
|
10
|
+
tertiaryTextColor: "#3f3f46",
|
|
11
|
+
tertiaryBorderColor: "#5a9a62",
|
|
12
|
+
background: "#ffffff",
|
|
13
|
+
mainBkg: "#ffffff",
|
|
14
|
+
textColor: "#3f3f46",
|
|
15
|
+
lineColor: "#a1a1aa",
|
|
16
|
+
nodeBkg: "rgba(245, 222, 160, 0.6)",
|
|
17
|
+
nodeBorder: "#c9a84a",
|
|
18
|
+
clusterBkg: "rgba(250, 250, 250, 0.8)",
|
|
19
|
+
clusterBorder: "#e4e4e7",
|
|
20
|
+
actorBkg: "rgba(168, 196, 228, 0.5)",
|
|
21
|
+
actorBorder: "#5b7fa8",
|
|
22
|
+
actorTextColor: "#3f3f46",
|
|
23
|
+
signalColor: "#3f3f46",
|
|
24
|
+
signalTextColor: "#3f3f46",
|
|
25
|
+
noteBkgColor: "rgba(245, 222, 160, 0.5)",
|
|
26
|
+
noteBorderColor: "#c9a84a",
|
|
27
|
+
noteTextColor: "#3f3f46",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export function getMermaidInitConfig() {
|
|
31
|
+
return {
|
|
32
|
+
startOnLoad: false,
|
|
33
|
+
theme: "base" as const,
|
|
34
|
+
securityLevel: "strict" as const,
|
|
35
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
36
|
+
themeVariables: MERMAID_THEME,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
interface PendingRequest {
|
|
2
|
+
resolve: (svg: string) => void;
|
|
3
|
+
reject: (err: Error) => void;
|
|
4
|
+
timer: ReturnType<typeof setTimeout>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const RENDER_TIMEOUT_MS = 15_000;
|
|
8
|
+
const MAX_CONSECUTIVE_ERRORS = 3;
|
|
9
|
+
|
|
10
|
+
let worker: Worker | null = null;
|
|
11
|
+
let workerReady: Promise<void> | null = null;
|
|
12
|
+
const pendingRequests = new Map<string, PendingRequest>();
|
|
13
|
+
let requestCounter = 0;
|
|
14
|
+
let consecutiveErrors = 0;
|
|
15
|
+
|
|
16
|
+
function resetWorker(
|
|
17
|
+
reason: Error,
|
|
18
|
+
currentWorker: Worker | null = worker,
|
|
19
|
+
): void {
|
|
20
|
+
if (currentWorker) currentWorker.terminate();
|
|
21
|
+
if (worker === currentWorker) {
|
|
22
|
+
worker = null;
|
|
23
|
+
workerReady = null;
|
|
24
|
+
}
|
|
25
|
+
for (const [, pending] of pendingRequests) {
|
|
26
|
+
clearTimeout(pending.timer);
|
|
27
|
+
pending.reject(reason);
|
|
28
|
+
}
|
|
29
|
+
pendingRequests.clear();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createWorker(): { worker: Worker; ready: Promise<void> } {
|
|
33
|
+
const w = new Worker(new URL("./mermaid-worker.ts", import.meta.url).href, {
|
|
34
|
+
type: "module",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
38
|
+
const timeout = setTimeout(() => {
|
|
39
|
+
w.removeEventListener("message", onReady);
|
|
40
|
+
w.removeEventListener("error", onStartupError);
|
|
41
|
+
w.terminate();
|
|
42
|
+
if (worker === w) {
|
|
43
|
+
worker = null;
|
|
44
|
+
workerReady = null;
|
|
45
|
+
}
|
|
46
|
+
reject(new Error("Mermaid worker failed to start within 30s"));
|
|
47
|
+
}, 30_000);
|
|
48
|
+
|
|
49
|
+
function onStartupError(event: ErrorEvent) {
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
w.removeEventListener("message", onReady);
|
|
52
|
+
w.removeEventListener("error", onStartupError);
|
|
53
|
+
w.terminate();
|
|
54
|
+
if (worker === w) {
|
|
55
|
+
worker = null;
|
|
56
|
+
workerReady = null;
|
|
57
|
+
}
|
|
58
|
+
reject(new Error(`Mermaid worker failed to start: ${event.message}`));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function onReady(event: MessageEvent) {
|
|
62
|
+
if (event.data?.type === "ready") {
|
|
63
|
+
clearTimeout(timeout);
|
|
64
|
+
w.removeEventListener("message", onReady);
|
|
65
|
+
w.removeEventListener("error", onStartupError);
|
|
66
|
+
resolve();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
w.addEventListener("message", onReady);
|
|
70
|
+
w.addEventListener("error", onStartupError);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
w.addEventListener("message", (event: MessageEvent) => {
|
|
74
|
+
const { id, svg, error } = event.data;
|
|
75
|
+
if (!id) return;
|
|
76
|
+
|
|
77
|
+
const pending = pendingRequests.get(id);
|
|
78
|
+
if (!pending) return;
|
|
79
|
+
|
|
80
|
+
clearTimeout(pending.timer);
|
|
81
|
+
pendingRequests.delete(id);
|
|
82
|
+
|
|
83
|
+
if (error) {
|
|
84
|
+
consecutiveErrors++;
|
|
85
|
+
pending.reject(new Error(error));
|
|
86
|
+
} else {
|
|
87
|
+
consecutiveErrors = 0;
|
|
88
|
+
pending.resolve(svg);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
w.addEventListener("error", (event) => {
|
|
93
|
+
resetWorker(new Error(`Worker error: ${event.message}`), w);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return { worker: w, ready };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function ensureWorker(): Promise<Worker> {
|
|
100
|
+
if (worker && workerReady) {
|
|
101
|
+
await workerReady;
|
|
102
|
+
|
|
103
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
104
|
+
resetWorker(
|
|
105
|
+
new Error("Mermaid worker restarted after repeated render failures"),
|
|
106
|
+
);
|
|
107
|
+
consecutiveErrors = 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!worker) {
|
|
112
|
+
const result = createWorker();
|
|
113
|
+
worker = result.worker;
|
|
114
|
+
workerReady = result.ready;
|
|
115
|
+
await workerReady;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return worker;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function renderMermaidSvg(code: string): Promise<string> {
|
|
122
|
+
const w = await ensureWorker();
|
|
123
|
+
|
|
124
|
+
const id = `req-${++requestCounter}`;
|
|
125
|
+
const diagramId = `mermaid-ssr-${requestCounter}`;
|
|
126
|
+
|
|
127
|
+
return new Promise<string>((resolve, reject) => {
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
pendingRequests.delete(id);
|
|
130
|
+
resetWorker(
|
|
131
|
+
new Error(`Mermaid render timed out after ${RENDER_TIMEOUT_MS}ms`),
|
|
132
|
+
w,
|
|
133
|
+
);
|
|
134
|
+
reject(
|
|
135
|
+
new Error(`Mermaid render timed out after ${RENDER_TIMEOUT_MS}ms`),
|
|
136
|
+
);
|
|
137
|
+
}, RENDER_TIMEOUT_MS);
|
|
138
|
+
|
|
139
|
+
pendingRequests.set(id, { resolve, reject, timer });
|
|
140
|
+
|
|
141
|
+
w.postMessage({ id, code, diagramId });
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function renderMermaidBlocks(
|
|
146
|
+
blocks: string[],
|
|
147
|
+
): Promise<(string | null)[]> {
|
|
148
|
+
const results: (string | null)[] = [];
|
|
149
|
+
for (const code of blocks) {
|
|
150
|
+
try {
|
|
151
|
+
const svg = await renderMermaidSvg(code);
|
|
152
|
+
results.push(svg);
|
|
153
|
+
} catch {
|
|
154
|
+
results.push(null);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return results;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function disposeMermaidWorker(): void {
|
|
161
|
+
resetWorker(new Error("Mermaid worker disposed"));
|
|
162
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
|
|
3
|
+
import { JSDOM } from "jsdom";
|
|
4
|
+
import { getMermaidInitConfig } from "./mermaid-config";
|
|
5
|
+
|
|
6
|
+
const dom = new JSDOM(
|
|
7
|
+
'<!DOCTYPE html><html><body><div id="mermaid-container"></div></body></html>',
|
|
8
|
+
{
|
|
9
|
+
url: "http://localhost",
|
|
10
|
+
pretendToBeVisual: true,
|
|
11
|
+
contentType: "text/html",
|
|
12
|
+
},
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const g = globalThis as Record<string, unknown>;
|
|
16
|
+
g.window = dom.window;
|
|
17
|
+
g.document = dom.window.document;
|
|
18
|
+
g.navigator = dom.window.navigator;
|
|
19
|
+
g.DOMParser = dom.window.DOMParser;
|
|
20
|
+
g.XMLSerializer = dom.window.XMLSerializer;
|
|
21
|
+
g.HTMLElement = dom.window.HTMLElement;
|
|
22
|
+
g.SVGElement = (dom.window as unknown as Record<string, unknown>).SVGElement;
|
|
23
|
+
|
|
24
|
+
const mermaid = (await import("mermaid")).default;
|
|
25
|
+
mermaid.initialize(getMermaidInitConfig());
|
|
26
|
+
|
|
27
|
+
interface RenderRequest {
|
|
28
|
+
id: string;
|
|
29
|
+
code: string;
|
|
30
|
+
diagramId: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resetBody() {
|
|
34
|
+
const body = dom.window.document.body;
|
|
35
|
+
while (body.firstChild) body.removeChild(body.firstChild);
|
|
36
|
+
const container = dom.window.document.createElement("div");
|
|
37
|
+
container.id = "mermaid-container";
|
|
38
|
+
body.appendChild(container);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let renderQueue: Promise<void> = Promise.resolve();
|
|
42
|
+
|
|
43
|
+
self.onmessage = (event: MessageEvent<RenderRequest>) => {
|
|
44
|
+
const { id, code, diagramId } = event.data;
|
|
45
|
+
|
|
46
|
+
renderQueue = renderQueue.then(async () => {
|
|
47
|
+
try {
|
|
48
|
+
resetBody();
|
|
49
|
+
const { svg } = await mermaid.render(diagramId, code);
|
|
50
|
+
self.postMessage({ id, svg });
|
|
51
|
+
} catch (err) {
|
|
52
|
+
self.postMessage({
|
|
53
|
+
id,
|
|
54
|
+
error: err instanceof Error ? err.message : String(err),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
self.postMessage({ type: "ready" });
|
package/src/lib/positions.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
+
import type { Highlighter } from "./highlight/highlighter";
|
|
1
2
|
import { resolveMarginNotePositions } from "./margin-layout";
|
|
2
3
|
|
|
3
4
|
type Listener = () => void;
|
|
4
5
|
|
|
5
|
-
/**
|
|
6
|
-
* Positions managed outside React. Scroll-invariant — only recalculates
|
|
7
|
-
* on highlight mutation (MutationObserver) and resize.
|
|
8
|
-
*/
|
|
9
6
|
export class Positions {
|
|
10
7
|
private relative = new Map<string, number>();
|
|
11
8
|
private absolute = new Map<string, number>();
|
|
@@ -16,39 +13,44 @@ export class Positions {
|
|
|
16
13
|
private listeners = new Set<Listener>();
|
|
17
14
|
private root: HTMLElement | null = null;
|
|
18
15
|
private container: HTMLElement | null = null;
|
|
16
|
+
private highlighter: Highlighter | null = null;
|
|
19
17
|
private resizeRaf: number | null = null;
|
|
20
|
-
private
|
|
21
|
-
private
|
|
18
|
+
private cacheRaf: number | null = null;
|
|
19
|
+
private unsubCache: (() => void) | null = null;
|
|
22
20
|
|
|
23
|
-
attach(root: HTMLElement, container: HTMLElement) {
|
|
21
|
+
attach(root: HTMLElement, container: HTMLElement, highlighter: Highlighter) {
|
|
24
22
|
this.root = root;
|
|
25
23
|
this.container = container;
|
|
24
|
+
this.highlighter = highlighter;
|
|
26
25
|
window.addEventListener("resize", this.onResize);
|
|
27
26
|
|
|
28
|
-
this.
|
|
29
|
-
if (this.
|
|
30
|
-
this.
|
|
31
|
-
this.
|
|
27
|
+
this.unsubCache = highlighter.onCacheInvalidated(() => {
|
|
28
|
+
if (this.cacheRaf !== null) return;
|
|
29
|
+
this.cacheRaf = requestAnimationFrame(() => {
|
|
30
|
+
this.cacheRaf = null;
|
|
32
31
|
this.cache();
|
|
33
32
|
});
|
|
34
33
|
});
|
|
35
|
-
this.observer.observe(root, { childList: true, subtree: true });
|
|
36
34
|
}
|
|
37
35
|
|
|
38
36
|
detach() {
|
|
39
37
|
window.removeEventListener("resize", this.onResize);
|
|
40
38
|
if (this.resizeRaf !== null) cancelAnimationFrame(this.resizeRaf);
|
|
41
|
-
if (this.
|
|
39
|
+
if (this.cacheRaf !== null) cancelAnimationFrame(this.cacheRaf);
|
|
42
40
|
this.resizeRaf = null;
|
|
43
|
-
this.
|
|
44
|
-
this.
|
|
45
|
-
this.
|
|
41
|
+
this.cacheRaf = null;
|
|
42
|
+
this.unsubCache?.();
|
|
43
|
+
this.unsubCache = null;
|
|
46
44
|
this.root = null;
|
|
47
45
|
this.container = null;
|
|
46
|
+
this.highlighter = null;
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
cache() {
|
|
51
|
-
if (!this.root || !this.container) return;
|
|
50
|
+
if (!this.root || !this.container || !this.highlighter) return;
|
|
51
|
+
|
|
52
|
+
const highlightedIds = this.highlighter.getHighlightedIds();
|
|
53
|
+
if (highlightedIds.length === 0) return;
|
|
52
54
|
|
|
53
55
|
const ref = this.container.getBoundingClientRect();
|
|
54
56
|
const scrollY = window.scrollY;
|
|
@@ -56,13 +58,10 @@ export class Positions {
|
|
|
56
58
|
this.relative.clear();
|
|
57
59
|
this.absolute.clear();
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const rect = mark.getBoundingClientRect();
|
|
64
|
-
this.relative.set(id, rect.top - ref.top);
|
|
65
|
-
this.absolute.set(id, rect.top + scrollY);
|
|
61
|
+
const positions = this.highlighter.getPositions(ref);
|
|
62
|
+
for (const [id, relTop] of positions) {
|
|
63
|
+
this.relative.set(id, relTop);
|
|
64
|
+
this.absolute.set(id, relTop + ref.top + scrollY);
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
const snap: Record<string, number> = {};
|
|
@@ -71,6 +70,7 @@ export class Positions {
|
|
|
71
70
|
|
|
72
71
|
this.apply();
|
|
73
72
|
this.notify();
|
|
73
|
+
this.exposeReady();
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
setIds(ids: string[]) {
|
|
@@ -147,4 +147,11 @@ export class Positions {
|
|
|
147
147
|
private notify() {
|
|
148
148
|
for (const fn of this.listeners) fn();
|
|
149
149
|
}
|
|
150
|
+
|
|
151
|
+
private exposeReady() {
|
|
152
|
+
if (typeof window !== "undefined") {
|
|
153
|
+
(window as unknown as Record<string, unknown>).__readitPositionsReady =
|
|
154
|
+
performance.now();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
150
157
|
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { KeybindingOverride, ShortcutBinding } from "../schema";
|
|
2
|
+
import type { TranslationKey } from "./i18n/types";
|
|
3
|
+
|
|
4
|
+
const IS_MAC =
|
|
5
|
+
typeof navigator !== "undefined" && navigator.platform.includes("Mac");
|
|
6
|
+
|
|
7
|
+
export const ShortcutActions = {
|
|
8
|
+
COPY_ALL: "copyAll",
|
|
9
|
+
COPY_ALL_RAW: "copyAllRaw",
|
|
10
|
+
NAVIGATE_NEXT: "navigateNext",
|
|
11
|
+
NAVIGATE_PREVIOUS: "navigatePrevious",
|
|
12
|
+
COPY_SELECTION_RAW: "copySelectionRaw",
|
|
13
|
+
COPY_SELECTION_LLM: "copySelectionLLM",
|
|
14
|
+
CLEAR_SELECTION: "clearSelection",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type ShortcutAction =
|
|
18
|
+
(typeof ShortcutActions)[keyof typeof ShortcutActions];
|
|
19
|
+
|
|
20
|
+
export interface ShortcutDefinition {
|
|
21
|
+
id: ShortcutAction;
|
|
22
|
+
label: TranslationKey;
|
|
23
|
+
description: TranslationKey;
|
|
24
|
+
defaultBinding: ShortcutBinding;
|
|
25
|
+
binding: ShortcutBinding;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_SHORTCUTS: ShortcutDefinition[] = [
|
|
30
|
+
{
|
|
31
|
+
id: ShortcutActions.COPY_ALL,
|
|
32
|
+
label: "shortcut.copyAll.label",
|
|
33
|
+
description: "shortcut.copyAll.description",
|
|
34
|
+
defaultBinding: { key: "c", alt: true },
|
|
35
|
+
binding: { key: "c", alt: true },
|
|
36
|
+
enabled: true,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: ShortcutActions.COPY_ALL_RAW,
|
|
40
|
+
label: "shortcut.copyAllRaw.label",
|
|
41
|
+
description: "shortcut.copyAllRaw.description",
|
|
42
|
+
defaultBinding: { key: "c", alt: true, shift: true },
|
|
43
|
+
binding: { key: "c", alt: true, shift: true },
|
|
44
|
+
enabled: true,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: ShortcutActions.NAVIGATE_NEXT,
|
|
48
|
+
label: "shortcut.navigateNext.label",
|
|
49
|
+
description: "shortcut.navigateNext.description",
|
|
50
|
+
defaultBinding: { key: "ArrowDown", alt: true },
|
|
51
|
+
binding: { key: "ArrowDown", alt: true },
|
|
52
|
+
enabled: true,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: ShortcutActions.NAVIGATE_PREVIOUS,
|
|
56
|
+
label: "shortcut.navigatePrevious.label",
|
|
57
|
+
description: "shortcut.navigatePrevious.description",
|
|
58
|
+
defaultBinding: { key: "ArrowUp", alt: true },
|
|
59
|
+
binding: { key: "ArrowUp", alt: true },
|
|
60
|
+
enabled: true,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: ShortcutActions.COPY_SELECTION_RAW,
|
|
64
|
+
label: "shortcut.copySelectionRaw.label",
|
|
65
|
+
description: "shortcut.copySelectionRaw.description",
|
|
66
|
+
defaultBinding: IS_MAC
|
|
67
|
+
? { key: "c", meta: true, shift: true }
|
|
68
|
+
: { key: "c", ctrl: true, shift: true },
|
|
69
|
+
binding: IS_MAC
|
|
70
|
+
? { key: "c", meta: true, shift: true }
|
|
71
|
+
: { key: "c", ctrl: true, shift: true },
|
|
72
|
+
enabled: true,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: ShortcutActions.COPY_SELECTION_LLM,
|
|
76
|
+
label: "shortcut.copySelectionLLM.label",
|
|
77
|
+
description: "shortcut.copySelectionLLM.description",
|
|
78
|
+
defaultBinding: IS_MAC
|
|
79
|
+
? { key: "c", meta: true, alt: true }
|
|
80
|
+
: { key: "c", ctrl: true, alt: true },
|
|
81
|
+
binding: IS_MAC
|
|
82
|
+
? { key: "c", meta: true, alt: true }
|
|
83
|
+
: { key: "c", ctrl: true, alt: true },
|
|
84
|
+
enabled: true,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: ShortcutActions.CLEAR_SELECTION,
|
|
88
|
+
label: "shortcut.clearSelection.label",
|
|
89
|
+
description: "shortcut.clearSelection.description",
|
|
90
|
+
defaultBinding: { key: "Escape" },
|
|
91
|
+
binding: { key: "Escape" },
|
|
92
|
+
enabled: true,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
export function matchesBinding(
|
|
97
|
+
event: KeyboardEvent,
|
|
98
|
+
binding: ShortcutBinding,
|
|
99
|
+
): boolean {
|
|
100
|
+
if (event.key.toLowerCase() !== binding.key.toLowerCase()) return false;
|
|
101
|
+
if (!!binding.alt !== event.altKey) return false;
|
|
102
|
+
if (!!binding.ctrl !== event.ctrlKey) return false;
|
|
103
|
+
if (!!binding.meta !== event.metaKey) return false;
|
|
104
|
+
if (!!binding.shift !== event.shiftKey) return false;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function formatBinding(
|
|
109
|
+
binding: ShortcutBinding,
|
|
110
|
+
isMac: boolean,
|
|
111
|
+
): string {
|
|
112
|
+
const parts: string[] = [];
|
|
113
|
+
|
|
114
|
+
if (binding.ctrl) {
|
|
115
|
+
parts.push(isMac ? "\u2303" : "Ctrl");
|
|
116
|
+
}
|
|
117
|
+
if (binding.meta) {
|
|
118
|
+
parts.push(isMac ? "\u2318" : "Meta");
|
|
119
|
+
}
|
|
120
|
+
if (binding.alt) {
|
|
121
|
+
parts.push(isMac ? "\u2325" : "Alt");
|
|
122
|
+
}
|
|
123
|
+
if (binding.shift) {
|
|
124
|
+
parts.push(isMac ? "\u21E7" : "Shift");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const keyDisplay = KEY_DISPLAY_MAP[binding.key] ?? binding.key.toUpperCase();
|
|
128
|
+
parts.push(keyDisplay);
|
|
129
|
+
|
|
130
|
+
return parts.join(isMac ? "" : "+");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const KEY_DISPLAY_MAP: Record<string, string> = {
|
|
134
|
+
ArrowUp: "\u2191",
|
|
135
|
+
ArrowDown: "\u2193",
|
|
136
|
+
ArrowLeft: "\u2190",
|
|
137
|
+
ArrowRight: "\u2192",
|
|
138
|
+
Escape: "Esc",
|
|
139
|
+
Enter: "\u21B5",
|
|
140
|
+
Backspace: "\u232B",
|
|
141
|
+
Delete: "\u2326",
|
|
142
|
+
Tab: "\u21E5",
|
|
143
|
+
" ": "Space",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export function resolveShortcuts(
|
|
147
|
+
overrides: KeybindingOverride[],
|
|
148
|
+
): ShortcutDefinition[] {
|
|
149
|
+
return DEFAULT_SHORTCUTS.map((def) => {
|
|
150
|
+
const override = overrides.find((o) => o.id === def.id);
|
|
151
|
+
if (!override) return { ...def };
|
|
152
|
+
return {
|
|
153
|
+
...def,
|
|
154
|
+
binding: override.binding ?? def.defaultBinding,
|
|
155
|
+
enabled: override.enabled,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Mac-specific reserved bindings (Cmd+key)
|
|
161
|
+
export const RESERVED_BINDINGS_MAC: ShortcutBinding[] = [
|
|
162
|
+
{ key: "r", meta: true },
|
|
163
|
+
{ key: "w", meta: true },
|
|
164
|
+
{ key: "t", meta: true },
|
|
165
|
+
{ key: "n", meta: true },
|
|
166
|
+
{ key: "q", meta: true },
|
|
167
|
+
{ key: "l", meta: true },
|
|
168
|
+
{ key: "a", meta: true },
|
|
169
|
+
{ key: "f", meta: true },
|
|
170
|
+
{ key: "p", meta: true },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
// Windows/Linux reserved bindings (Ctrl+key consumed by browser)
|
|
174
|
+
export const RESERVED_BINDINGS_OTHER: ShortcutBinding[] = [
|
|
175
|
+
{ key: "r", ctrl: true },
|
|
176
|
+
{ key: "w", ctrl: true },
|
|
177
|
+
{ key: "t", ctrl: true },
|
|
178
|
+
{ key: "n", ctrl: true },
|
|
179
|
+
{ key: "l", ctrl: true },
|
|
180
|
+
{ key: "a", ctrl: true },
|
|
181
|
+
{ key: "f", ctrl: true },
|
|
182
|
+
{ key: "p", ctrl: true },
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
export function isReservedBinding(
|
|
186
|
+
binding: ShortcutBinding,
|
|
187
|
+
isMac: boolean,
|
|
188
|
+
): boolean {
|
|
189
|
+
const reserved = isMac ? RESERVED_BINDINGS_MAC : RESERVED_BINDINGS_OTHER;
|
|
190
|
+
// Ignore shift when checking reserved bindings because browsers also
|
|
191
|
+
// consume the shifted variants (e.g. Cmd+Shift+R, Ctrl+Shift+T).
|
|
192
|
+
return reserved.some(
|
|
193
|
+
(r) =>
|
|
194
|
+
r.key.toLowerCase() === binding.key.toLowerCase() &&
|
|
195
|
+
!!r.alt === !!binding.alt &&
|
|
196
|
+
!!r.ctrl === !!binding.ctrl &&
|
|
197
|
+
!!r.meta === !!binding.meta,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function eventToBinding(event: KeyboardEvent): ShortcutBinding {
|
|
202
|
+
return {
|
|
203
|
+
key: event.key,
|
|
204
|
+
...(event.altKey && { alt: true }),
|
|
205
|
+
...(event.ctrlKey && { ctrl: true }),
|
|
206
|
+
...(event.metaKey && { meta: true }),
|
|
207
|
+
...(event.shiftKey && { shift: true }),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean {
|
|
212
|
+
return (
|
|
213
|
+
a.key.toLowerCase() === b.key.toLowerCase() &&
|
|
214
|
+
!!a.alt === !!b.alt &&
|
|
215
|
+
!!a.ctrl === !!b.ctrl &&
|
|
216
|
+
!!a.meta === !!b.meta &&
|
|
217
|
+
!!a.shift === !!b.shift
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface ShortcutGroup {
|
|
222
|
+
label: TranslationKey;
|
|
223
|
+
ids: ShortcutAction[];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
|
227
|
+
{
|
|
228
|
+
label: "shortcutGroup.copy",
|
|
229
|
+
ids: [
|
|
230
|
+
ShortcutActions.COPY_ALL,
|
|
231
|
+
ShortcutActions.COPY_ALL_RAW,
|
|
232
|
+
ShortcutActions.COPY_SELECTION_RAW,
|
|
233
|
+
ShortcutActions.COPY_SELECTION_LLM,
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
label: "shortcutGroup.navigate",
|
|
238
|
+
ids: [ShortcutActions.NAVIGATE_NEXT, ShortcutActions.NAVIGATE_PREVIOUS],
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
label: "shortcutGroup.other",
|
|
242
|
+
ids: [ShortcutActions.CLEAR_SELECTION],
|
|
243
|
+
},
|
|
244
|
+
];
|
package/src/lib/utils.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type ClassValue, clsx } from "clsx";
|
|
2
|
-
import type { ReactNode } from "react";
|
|
3
2
|
import { twMerge } from "tailwind-merge";
|
|
4
3
|
|
|
5
4
|
export function isMarkdownFile(filePath: string): boolean {
|
|
@@ -14,31 +13,3 @@ export function truncate(text: string, maxLength = 30): string {
|
|
|
14
13
|
if (text.length <= maxLength) return text;
|
|
15
14
|
return `${text.slice(0, maxLength)}…`;
|
|
16
15
|
}
|
|
17
|
-
|
|
18
|
-
export function getTextContent(children: ReactNode): string {
|
|
19
|
-
if (typeof children === "string" || typeof children === "number") {
|
|
20
|
-
return String(children);
|
|
21
|
-
}
|
|
22
|
-
if (Array.isArray(children)) {
|
|
23
|
-
return children.map(getTextContent).join("");
|
|
24
|
-
}
|
|
25
|
-
if (
|
|
26
|
-
typeof children === "object" &&
|
|
27
|
-
children !== null &&
|
|
28
|
-
"props" in children
|
|
29
|
-
) {
|
|
30
|
-
return getTextContent(
|
|
31
|
-
(children as { props: { children?: ReactNode } }).props.children,
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
return "";
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function slugify(text: string): string {
|
|
38
|
-
return text
|
|
39
|
-
.toLowerCase()
|
|
40
|
-
.trim()
|
|
41
|
-
.replace(/[^\w\s-]/g, "")
|
|
42
|
-
.replace(/\s+/g, "-")
|
|
43
|
-
.replace(/-+/g, "-");
|
|
44
|
-
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { mount } from "svelte";
|
|
2
|
+
import "./index.css";
|
|
3
|
+
import App from "./App.svelte";
|
|
4
|
+
import { hydrateFromInlineData } from "./stores/app.svelte";
|
|
5
|
+
import { initSettings } from "./stores/settings.svelte";
|
|
6
|
+
import { initShortcuts } from "./stores/shortcuts.svelte";
|
|
7
|
+
|
|
8
|
+
const dataEl = document.getElementById("__readit");
|
|
9
|
+
if (dataEl) {
|
|
10
|
+
const data = JSON.parse(dataEl.textContent ?? "{}");
|
|
11
|
+
hydrateFromInlineData(data);
|
|
12
|
+
initSettings(data.settings);
|
|
13
|
+
initShortcuts(data.settings?.keybindings ?? []);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
mount(App, { target: document.getElementById("app")! });
|