@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.
Files changed (173) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +118 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +881 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +218 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/RawModal.svelte +126 -0
  68. package/src/components/ReanchorConfirm.svelte +30 -0
  69. package/src/components/SettingsModal.svelte +220 -0
  70. package/src/components/ShortcutCapture.svelte +82 -0
  71. package/src/components/ShortcutList.svelte +145 -0
  72. package/src/components/TabBar.svelte +52 -0
  73. package/src/components/TableOfContents.svelte +125 -0
  74. package/src/components/ui/ActionLink.svelte +40 -0
  75. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  76. package/src/components/ui/Dialog.svelte +97 -0
  77. package/src/components/ui/DropdownMenu.svelte +85 -0
  78. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  79. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  80. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  81. package/src/env.d.ts +6 -0
  82. package/src/index.css +36 -166
  83. package/src/lib/__fixtures__/bench-data.ts +0 -13
  84. package/src/lib/anchor.bench.ts +1 -12
  85. package/src/lib/anchor.test.ts +0 -8
  86. package/src/lib/anchor.ts +0 -4
  87. package/src/lib/comment-storage.bench.ts +49 -0
  88. package/src/lib/comment-storage.test.ts +41 -33
  89. package/src/lib/comment-storage.ts +21 -18
  90. package/src/lib/export.bench.ts +21 -0
  91. package/src/lib/export.ts +0 -1
  92. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  93. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  94. package/src/lib/highlight/core.test.ts +0 -5
  95. package/src/lib/highlight/dom.ts +52 -216
  96. package/src/lib/highlight/highlight-registry.ts +221 -0
  97. package/src/lib/highlight/highlight.bench.ts +92 -0
  98. package/src/lib/highlight/highlighter.ts +112 -132
  99. package/src/lib/highlight/resolver.ts +5 -79
  100. package/src/lib/highlight/types.ts +0 -5
  101. package/src/lib/html-text.test.ts +162 -0
  102. package/src/lib/html-text.ts +161 -0
  103. package/src/lib/i18n/en.ts +26 -0
  104. package/src/lib/i18n/ja.ts +26 -0
  105. package/src/lib/i18n/types.ts +25 -0
  106. package/src/lib/margin-layout.bench.ts +61 -0
  107. package/src/lib/margin-layout.ts +0 -7
  108. package/src/lib/markdown-renderer.test.ts +154 -0
  109. package/src/lib/markdown-renderer.ts +177 -0
  110. package/src/lib/mermaid-config.ts +38 -0
  111. package/src/lib/mermaid-renderer.ts +162 -0
  112. package/src/lib/mermaid-worker.ts +60 -0
  113. package/src/lib/positions.ts +31 -24
  114. package/src/lib/shortcut-registry.ts +244 -0
  115. package/src/lib/utils.ts +0 -29
  116. package/src/main.ts +16 -0
  117. package/src/schema.ts +16 -5
  118. package/src/server.ts +355 -91
  119. package/src/stores/app.svelte.ts +231 -0
  120. package/src/stores/locale.svelte.ts +46 -0
  121. package/src/stores/settings.svelte.ts +90 -0
  122. package/src/stores/shortcuts.svelte.ts +104 -0
  123. package/src/stores/ui.svelte.ts +12 -0
  124. package/src/template.ts +104 -0
  125. package/src/test-setup.ts +47 -0
  126. package/svelte.config.js +5 -0
  127. package/tsconfig.json +2 -2
  128. package/vite.config.ts +23 -3
  129. package/vscode-readit/.mcp.json +7 -0
  130. package/vscode-readit/.vscodeignore +7 -0
  131. package/vscode-readit/bun.lock +78 -0
  132. package/vscode-readit/icon.svg +10 -0
  133. package/vscode-readit/package.json +110 -0
  134. package/vscode-readit/src/extension.ts +117 -0
  135. package/vscode-readit/src/server-manager.ts +272 -0
  136. package/vscode-readit/src/webview-provider.ts +204 -0
  137. package/vscode-readit/tsconfig.json +20 -0
  138. package/e2e/fixtures/sample.html +0 -13
  139. package/src/App.tsx +0 -368
  140. package/src/components/ActionsMenu.tsx +0 -91
  141. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  142. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  143. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  144. package/src/components/Header.tsx +0 -54
  145. package/src/components/InlineEditor.tsx +0 -74
  146. package/src/components/MarginNote.tsx +0 -185
  147. package/src/components/MarginNotes.tsx +0 -23
  148. package/src/components/RawModal.tsx +0 -144
  149. package/src/components/ReanchorConfirm.tsx +0 -36
  150. package/src/components/SettingsModal.tsx +0 -232
  151. package/src/components/TabBar.tsx +0 -60
  152. package/src/components/TableOfContents.tsx +0 -108
  153. package/src/components/comments/CommentBadge.tsx +0 -49
  154. package/src/components/comments/CommentInput.tsx +0 -86
  155. package/src/components/comments/CommentListItem.tsx +0 -90
  156. package/src/components/comments/CommentManager.tsx +0 -129
  157. package/src/components/comments/CommentNav.tsx +0 -109
  158. package/src/components/ui/ActionLink.tsx +0 -28
  159. package/src/components/ui/Dialog.tsx +0 -116
  160. package/src/components/ui/DropdownMenu.tsx +0 -158
  161. package/src/contexts/CommentContext.tsx +0 -198
  162. package/src/contexts/LocaleContext.tsx +0 -76
  163. package/src/contexts/PositionsContext.tsx +0 -16
  164. package/src/contexts/SettingsContext.tsx +0 -133
  165. package/src/hooks/useClickOutside.ts +0 -31
  166. package/src/hooks/useCommentNavigation.ts +0 -107
  167. package/src/hooks/useComments.ts +0 -311
  168. package/src/hooks/useDocument.ts +0 -157
  169. package/src/hooks/useScrollSpy.ts +0 -77
  170. package/src/hooks/useTextSelection.ts +0 -86
  171. package/src/lib/highlight/worker.ts +0 -45
  172. package/src/main.tsx +0 -13
  173. 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" });
@@ -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 mutationRaf: number | null = null;
21
- private observer: MutationObserver | null = null;
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.observer = new MutationObserver(() => {
29
- if (this.mutationRaf !== null) return;
30
- this.mutationRaf = requestAnimationFrame(() => {
31
- this.mutationRaf = null;
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.mutationRaf !== null) cancelAnimationFrame(this.mutationRaf);
39
+ if (this.cacheRaf !== null) cancelAnimationFrame(this.cacheRaf);
42
40
  this.resizeRaf = null;
43
- this.mutationRaf = null;
44
- this.observer?.disconnect();
45
- this.observer = null;
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
- for (const mark of this.root.querySelectorAll("mark[data-comment-id]")) {
60
- const id = mark.getAttribute("data-comment-id");
61
- if (!id || this.relative.has(id)) continue;
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")! });