@peaske7/readit 0.2.0 → 0.3.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +152 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +890 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +233 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/MermaidEnhancer.svelte +218 -0
  68. package/src/components/MermaidModal.svelte +67 -0
  69. package/src/components/RawModal.svelte +126 -0
  70. package/src/components/ReanchorConfirm.svelte +30 -0
  71. package/src/components/SettingsModal.svelte +220 -0
  72. package/src/components/ShortcutCapture.svelte +82 -0
  73. package/src/components/ShortcutList.svelte +145 -0
  74. package/src/components/TabBar.svelte +52 -0
  75. package/src/components/TableOfContents.svelte +125 -0
  76. package/src/components/ui/ActionLink.svelte +40 -0
  77. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  78. package/src/components/ui/Dialog.svelte +97 -0
  79. package/src/components/ui/DropdownMenu.svelte +85 -0
  80. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  81. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  82. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  83. package/src/env.d.ts +6 -0
  84. package/src/index.css +141 -166
  85. package/src/lib/__fixtures__/bench-data.ts +0 -13
  86. package/src/lib/anchor.bench.ts +1 -12
  87. package/src/lib/anchor.test.ts +0 -8
  88. package/src/lib/anchor.ts +0 -4
  89. package/src/lib/comment-storage.bench.ts +49 -0
  90. package/src/lib/comment-storage.test.ts +103 -33
  91. package/src/lib/comment-storage.ts +25 -18
  92. package/src/lib/export.bench.ts +21 -0
  93. package/src/lib/export.ts +0 -1
  94. package/src/lib/fetch-or-throw.test.ts +59 -0
  95. package/src/lib/fetch-or-throw.ts +12 -0
  96. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  97. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  98. package/src/lib/highlight/core.test.ts +0 -5
  99. package/src/lib/highlight/dom.ts +52 -216
  100. package/src/lib/highlight/highlight-registry.ts +221 -0
  101. package/src/lib/highlight/highlight.bench.ts +92 -0
  102. package/src/lib/highlight/highlighter.ts +112 -132
  103. package/src/lib/highlight/resolver.ts +5 -79
  104. package/src/lib/highlight/types.ts +0 -5
  105. package/src/lib/html-text.test.ts +162 -0
  106. package/src/lib/html-text.ts +161 -0
  107. package/src/lib/i18n/en.ts +34 -0
  108. package/src/lib/i18n/ja.ts +34 -0
  109. package/src/lib/i18n/types.ts +33 -0
  110. package/src/lib/key-lock.test.ts +104 -0
  111. package/src/lib/key-lock.ts +23 -0
  112. package/src/lib/margin-layout.bench.ts +61 -0
  113. package/src/lib/margin-layout.ts +0 -7
  114. package/src/lib/markdown-renderer.test.ts +154 -0
  115. package/src/lib/markdown-renderer.ts +178 -0
  116. package/src/lib/mermaid-config.ts +38 -0
  117. package/src/lib/mermaid-renderer.ts +162 -0
  118. package/src/lib/mermaid-worker.ts +60 -0
  119. package/src/lib/positions.ts +31 -24
  120. package/src/lib/shortcut-registry.ts +244 -0
  121. package/src/lib/utils.ts +0 -29
  122. package/src/main.ts +16 -0
  123. package/src/schema.ts +16 -5
  124. package/src/server.ts +355 -95
  125. package/src/stores/app.svelte.ts +231 -0
  126. package/src/stores/locale.svelte.ts +46 -0
  127. package/src/stores/settings.svelte.ts +90 -0
  128. package/src/stores/shortcuts.svelte.ts +104 -0
  129. package/src/stores/ui.svelte.ts +12 -0
  130. package/src/template.ts +104 -0
  131. package/src/test-setup.ts +47 -0
  132. package/svelte.config.js +5 -0
  133. package/tsconfig.json +2 -2
  134. package/vite.config.ts +23 -3
  135. package/vscode-readit/.mcp.json +7 -0
  136. package/vscode-readit/.vscodeignore +7 -0
  137. package/vscode-readit/bun.lock +78 -0
  138. package/vscode-readit/icon.svg +10 -0
  139. package/vscode-readit/package.json +110 -0
  140. package/vscode-readit/src/extension.ts +117 -0
  141. package/vscode-readit/src/server-manager.ts +272 -0
  142. package/vscode-readit/src/webview-provider.ts +204 -0
  143. package/vscode-readit/tsconfig.json +20 -0
  144. package/e2e/fixtures/sample.html +0 -13
  145. package/src/App.tsx +0 -368
  146. package/src/components/ActionsMenu.tsx +0 -91
  147. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  148. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  149. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  150. package/src/components/Header.tsx +0 -54
  151. package/src/components/InlineEditor.tsx +0 -74
  152. package/src/components/MarginNote.tsx +0 -185
  153. package/src/components/MarginNotes.tsx +0 -23
  154. package/src/components/RawModal.tsx +0 -144
  155. package/src/components/ReanchorConfirm.tsx +0 -36
  156. package/src/components/SettingsModal.tsx +0 -232
  157. package/src/components/TabBar.tsx +0 -60
  158. package/src/components/TableOfContents.tsx +0 -108
  159. package/src/components/comments/CommentBadge.tsx +0 -49
  160. package/src/components/comments/CommentInput.tsx +0 -86
  161. package/src/components/comments/CommentListItem.tsx +0 -90
  162. package/src/components/comments/CommentManager.tsx +0 -129
  163. package/src/components/comments/CommentNav.tsx +0 -109
  164. package/src/components/ui/ActionLink.tsx +0 -28
  165. package/src/components/ui/Dialog.tsx +0 -116
  166. package/src/components/ui/DropdownMenu.tsx +0 -158
  167. package/src/contexts/CommentContext.tsx +0 -198
  168. package/src/contexts/LocaleContext.tsx +0 -76
  169. package/src/contexts/PositionsContext.tsx +0 -16
  170. package/src/contexts/SettingsContext.tsx +0 -133
  171. package/src/hooks/useClickOutside.ts +0 -31
  172. package/src/hooks/useCommentNavigation.ts +0 -107
  173. package/src/hooks/useComments.ts +0 -311
  174. package/src/hooks/useDocument.ts +0 -157
  175. package/src/hooks/useScrollSpy.ts +0 -77
  176. package/src/hooks/useTextSelection.ts +0 -86
  177. package/src/lib/highlight/worker.ts +0 -45
  178. package/src/main.tsx +0 -13
  179. package/src/store.ts +0 -222
@@ -0,0 +1,72 @@
1
+ <script lang="ts">
2
+ import { cn } from "../lib/utils";
3
+ import { FontFamilies } from "../schema";
4
+ import { t } from "../stores/locale.svelte";
5
+ import { settings } from "../stores/settings.svelte";
6
+ import Button from "./ui/Button.svelte";
7
+
8
+ interface Props {
9
+ initialText: string;
10
+ onsave: (text: string) => void;
11
+ oncancel: () => void;
12
+ rows?: number;
13
+ class?: string;
14
+ }
15
+
16
+ let {
17
+ initialText,
18
+ onsave,
19
+ oncancel,
20
+ rows = 2,
21
+ class: className,
22
+ }: Props = $props();
23
+
24
+ let fontClass = $derived(
25
+ settings.fontFamily === FontFamilies.SANS_SERIF ? "font-sans" : "font-serif",
26
+ );
27
+
28
+ // svelte-ignore state_referenced_locally — intentionally capture initial prop value
29
+ let editText = $state(initialText);
30
+ let textareaEl: HTMLTextAreaElement | undefined = $state();
31
+
32
+ $effect(() => {
33
+ textareaEl?.focus();
34
+ });
35
+
36
+ function handleSave() {
37
+ if (editText.trim()) {
38
+ onsave(editText);
39
+ }
40
+ }
41
+
42
+ function handleKeydown(e: KeyboardEvent) {
43
+ if (e.key === "Enter" && e.metaKey) {
44
+ handleSave();
45
+ }
46
+ if (e.key === "Escape") {
47
+ oncancel();
48
+ }
49
+ }
50
+ </script>
51
+
52
+ <div class="space-y-2">
53
+ <textarea
54
+ bind:this={textareaEl}
55
+ bind:value={editText}
56
+ class={cn(
57
+ fontClass,
58
+ "w-full px-2 py-1.5 text-sm border border-zinc-200 dark:border-zinc-700 dark:bg-zinc-800 resize-none focus:outline-none focus:border-zinc-400 dark:focus:border-zinc-500",
59
+ className,
60
+ )}
61
+ {rows}
62
+ onkeydown={handleKeydown}
63
+ ></textarea>
64
+ <div class="flex gap-3 text-sm">
65
+ <Button variant="link" size="sm" onclick={handleSave}>
66
+ {t("editor.save")}
67
+ </Button>
68
+ <Button variant="ghost" size="sm" onclick={oncancel}>
69
+ {t("editor.cancel")}
70
+ </Button>
71
+ </div>
72
+ </div>
@@ -0,0 +1,167 @@
1
+ <script lang="ts">
2
+ import type { Positions } from "../lib/positions";
3
+ import { cn } from "../lib/utils";
4
+ import { type Comment, FontFamilies } from "../schema";
5
+ import { t } from "../stores/locale.svelte";
6
+ import { settings } from "../stores/settings.svelte";
7
+ import { setHoveredCommentId, ui } from "../stores/ui.svelte";
8
+ import InlineEditor from "./InlineEditor.svelte";
9
+ import ActionLink from "./ui/ActionLink.svelte";
10
+
11
+ interface Props {
12
+ comment: Comment;
13
+ commentIndex?: number;
14
+ positions: Positions;
15
+ onedit: (id: string, text: string) => void;
16
+ ondelete: (id: string) => void;
17
+ oncopy: (comment: Comment) => void;
18
+ onnavigate: (commentId: string) => void;
19
+ }
20
+
21
+ let {
22
+ comment,
23
+ commentIndex = 0,
24
+ positions,
25
+ onedit,
26
+ ondelete,
27
+ oncopy,
28
+ onnavigate,
29
+ }: Props = $props();
30
+
31
+ let isEditing = $state(false);
32
+ let articleEl: HTMLElement | undefined = $state();
33
+
34
+ let isHovered = $derived(ui.hoveredCommentId === comment.id);
35
+ let fontClass = $derived(
36
+ settings.fontFamily === FontFamilies.SANS_SERIF ? "font-sans" : "font-serif",
37
+ );
38
+ let hasNote = $derived(comment.comment.trim().length > 0);
39
+
40
+ $effect(() => {
41
+ if (articleEl) {
42
+ positions.register(comment.id, articleEl);
43
+ }
44
+
45
+ return () => {
46
+ positions.unregister(comment.id);
47
+ };
48
+ });
49
+
50
+ function selectedTextClass(hovered: boolean): string {
51
+ return cn(
52
+ "text-sm italic mb-1 line-clamp-1 flex items-center gap-1 transition-colors duration-150",
53
+ hovered
54
+ ? "text-zinc-600 dark:text-zinc-400"
55
+ : "text-zinc-400 dark:text-zinc-500",
56
+ );
57
+ }
58
+
59
+ function commentTextClass(hovered: boolean): string {
60
+ return cn(
61
+ "text-sm whitespace-pre-wrap transition-colors duration-150",
62
+ hovered
63
+ ? "text-zinc-800 dark:text-zinc-200"
64
+ : "text-zinc-500 dark:text-zinc-400",
65
+ );
66
+ }
67
+
68
+ function badgeClass(hovered: boolean): string {
69
+ return cn(
70
+ "absolute -left-4 top-2 text-xs tabular-nums transition-colors duration-150",
71
+ hovered
72
+ ? "text-zinc-600 dark:text-zinc-400"
73
+ : "text-zinc-400 dark:text-zinc-500",
74
+ );
75
+ }
76
+ </script>
77
+
78
+ {#if !hasNote && !isEditing}
79
+ <!-- Highlight-only (no note): minimal em-dash marker -->
80
+ <article
81
+ bind:this={articleEl}
82
+ class="absolute left-0 right-0 group"
83
+ style="visibility: hidden; content-visibility: auto; contain-intrinsic-size: auto 80px;"
84
+ data-comment-id={comment.id}
85
+ onmouseenter={() => setHoveredCommentId(comment.id)}
86
+ onmouseleave={() => setHoveredCommentId(undefined)}
87
+ >
88
+ <span class={badgeClass(isHovered)}>&mdash;</span>
89
+
90
+ <div class="pt-2 pb-2 pl-3">
91
+ <div
92
+ class={cn(
93
+ "flex items-center text-xs text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity",
94
+ "gap-1.5 duration-150",
95
+ isHovered && "opacity-100",
96
+ )}
97
+ >
98
+ <ActionLink onclick={() => (isEditing = true)}>
99
+ {t("marginNote.addNote")}
100
+ </ActionLink>
101
+ <span aria-hidden="true">&middot;</span>
102
+ <ActionLink variant="destructive" onclick={() => ondelete(comment.id)}>
103
+ {t("marginNote.delete")}
104
+ </ActionLink>
105
+ </div>
106
+ </div>
107
+ </article>
108
+ {:else}
109
+ <!-- Comment with note -->
110
+ <article
111
+ bind:this={articleEl}
112
+ class="absolute left-0 right-0 group"
113
+ style="visibility: hidden"
114
+ data-comment-id={comment.id}
115
+ onmouseenter={() => setHoveredCommentId(comment.id)}
116
+ onmouseleave={() => setHoveredCommentId(undefined)}
117
+ >
118
+ <span class={badgeClass(isHovered)}>{commentIndex + 1}</span>
119
+
120
+ <div
121
+ class={cn(
122
+ "relative border-t border-zinc-100 dark:border-zinc-800 pt-3 pb-2 pl-3 transition-colors duration-150",
123
+ comment.anchorConfidence === "unresolved" && "opacity-60",
124
+ )}
125
+ >
126
+ {#if !isEditing}
127
+ <div class={cn(fontClass, selectedTextClass(isHovered))}>
128
+ <button
129
+ type="button"
130
+ onclick={() => onnavigate(comment.id)}
131
+ class="cursor-pointer hover:underline text-left"
132
+ >
133
+ "{comment.selectedText}"
134
+ </button>
135
+ </div>
136
+ {/if}
137
+
138
+ {#if isEditing}
139
+ <InlineEditor
140
+ initialText={comment.comment}
141
+ onsave={(text) => {
142
+ onedit(comment.id, text);
143
+ isEditing = false;
144
+ }}
145
+ oncancel={() => (isEditing = false)}
146
+ />
147
+ {:else}
148
+ <p class={cn(fontClass, commentTextClass(isHovered))}>
149
+ {comment.comment}
150
+ </p>
151
+ <div class="flex items-center text-xs text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity gap-1.5 mt-2">
152
+ <ActionLink onclick={() => (isEditing = true)}>
153
+ {t("marginNote.edit")}
154
+ </ActionLink>
155
+ <span aria-hidden="true">&middot;</span>
156
+ <ActionLink variant="destructive" onclick={() => ondelete(comment.id)}>
157
+ {t("marginNote.delete")}
158
+ </ActionLink>
159
+ <span aria-hidden="true">&middot;</span>
160
+ <ActionLink onclick={() => oncopy(comment)}>
161
+ {t("marginNote.copy")}
162
+ </ActionLink>
163
+ </div>
164
+ {/if}
165
+ </div>
166
+ </article>
167
+ {/if}
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import type { Positions } from "../lib/positions";
3
+ import type { Comment } from "../schema";
4
+ import MarginNote from "./MarginNote.svelte";
5
+
6
+ interface Props {
7
+ sortedComments: Comment[];
8
+ positions: Positions;
9
+ onedit: (id: string, text: string) => void;
10
+ ondelete: (id: string) => void;
11
+ oncopy: (comment: Comment) => void;
12
+ onnavigate: (commentId: string) => void;
13
+ }
14
+
15
+ let { sortedComments, positions, onedit, ondelete, oncopy, onnavigate }: Props =
16
+ $props();
17
+ </script>
18
+
19
+ {#if sortedComments.length > 0}
20
+ <div class="relative w-64">
21
+ {#each sortedComments as comment, index (comment.id)}
22
+ <MarginNote
23
+ {comment}
24
+ commentIndex={index}
25
+ {positions}
26
+ {onedit}
27
+ {ondelete}
28
+ {oncopy}
29
+ {onnavigate}
30
+ />
31
+ {/each}
32
+ </div>
33
+ {/if}
@@ -0,0 +1,218 @@
1
+ <script lang="ts">
2
+ import { Code2, Image as ImageIcon, Maximize2 } from "lucide-svelte";
3
+ import { mount, unmount } from "svelte";
4
+ import { localeState, t } from "../stores/locale.svelte";
5
+ import MermaidModal from "./MermaidModal.svelte";
6
+
7
+ interface Props {
8
+ /** The article element that contains all rendered mermaid containers. */
9
+ root: HTMLElement | undefined;
10
+ /** Bumped whenever document content is re-rendered (rescan triggers). */
11
+ contentVersion: number;
12
+ /** Called after an inline toggle so margin notes / positions can recache. */
13
+ notifyContentChanged?: () => void;
14
+ }
15
+
16
+ let { root, contentVersion, notifyContentChanged }: Props = $props();
17
+
18
+ let modalOpen = $state(false);
19
+ let modalSvg = $state("");
20
+ let modalSource = $state("");
21
+
22
+ const ENHANCED_FLAG = "readitMermaidEnhanced";
23
+
24
+ function decodeSource(container: HTMLElement): string {
25
+ const encoded = container.dataset.mermaidSource ?? "";
26
+ try {
27
+ return decodeURIComponent(encoded);
28
+ } catch {
29
+ return encoded;
30
+ }
31
+ }
32
+
33
+ function openModal(container: HTMLElement) {
34
+ const svgEl = container.querySelector("svg");
35
+ modalSvg = svgEl ? svgEl.outerHTML : "";
36
+ modalSource = decodeSource(container);
37
+ modalOpen = true;
38
+ }
39
+
40
+ function ensureSourceView(container: HTMLElement, source: string): HTMLElement {
41
+ let pre = container.querySelector<HTMLElement>(".mermaid-source-view");
42
+ if (pre) return pre;
43
+ pre = document.createElement("pre");
44
+ pre.className = "mermaid-source-view";
45
+ const codeEl = document.createElement("code");
46
+ codeEl.className = "language-mermaid";
47
+ codeEl.textContent = source;
48
+ pre.appendChild(codeEl);
49
+ pre.style.display = "none";
50
+ // Insert before the toolbar so the toolbar stays the last child.
51
+ const toolbar = container.querySelector(".mermaid-toolbar");
52
+ container.insertBefore(pre, toolbar);
53
+ return pre;
54
+ }
55
+
56
+ function toggleInline(container: HTMLElement, source: string): void {
57
+ const svg = container.querySelector<SVGElement>("svg");
58
+ const pre = ensureSourceView(container, source);
59
+ const showingCode = container.dataset.mermaidView === "code";
60
+ const next = showingCode ? "graph" : "code";
61
+
62
+ if (svg) svg.style.display = next === "code" ? "none" : "";
63
+ pre.style.display = next === "code" ? "block" : "none";
64
+ container.dataset.mermaidView = next;
65
+
66
+ const toolbar = container.querySelector<HTMLElement>(".mermaid-toolbar");
67
+ const toggleBtn = toolbar?.querySelector<HTMLButtonElement>(
68
+ '[data-action="toggle"]',
69
+ );
70
+ toggleBtn?.setAttribute(
71
+ "aria-label",
72
+ next === "code" ? t("mermaid.showDiagram") : t("mermaid.showSource"),
73
+ );
74
+ toggleBtn?.setAttribute("aria-pressed", next === "code" ? "true" : "false");
75
+
76
+ const handle = (
77
+ toolbar as (HTMLElement & { _readitHandle?: ToolbarHandle }) | null
78
+ )?._readitHandle;
79
+ handle?.remountToggleIcon(next);
80
+
81
+ notifyContentChanged?.();
82
+ }
83
+
84
+ interface ToolbarHandle {
85
+ cleanup: () => void;
86
+ remountToggleIcon: (view: "graph" | "code") => void;
87
+ }
88
+
89
+ function mountToggleIcon(target: HTMLElement, view: "graph" | "code") {
90
+ // Show "code" icon when graph is visible (click to see code), and vice versa.
91
+ const Icon = view === "graph" ? Code2 : ImageIcon;
92
+ return mount(Icon, { target, props: { size: 14 } });
93
+ }
94
+
95
+ function buildToolbar(container: HTMLElement): HTMLElement {
96
+ const toolbar = document.createElement("div");
97
+ toolbar.className = "mermaid-toolbar";
98
+ toolbar.setAttribute("contenteditable", "false");
99
+
100
+ const toggleBtn = document.createElement("button");
101
+ toggleBtn.type = "button";
102
+ toggleBtn.dataset.action = "toggle";
103
+ toggleBtn.setAttribute("aria-label", t("mermaid.showSource"));
104
+ toggleBtn.setAttribute("aria-pressed", "false");
105
+
106
+ const expandBtn = document.createElement("button");
107
+ expandBtn.type = "button";
108
+ expandBtn.dataset.action = "expand";
109
+ expandBtn.setAttribute("aria-label", t("mermaid.expand"));
110
+
111
+ toolbar.appendChild(toggleBtn);
112
+ toolbar.appendChild(expandBtn);
113
+
114
+ let toggleIcon = mountToggleIcon(toggleBtn, "graph");
115
+ const expandIcon = mount(Maximize2, {
116
+ target: expandBtn,
117
+ props: { size: 14 },
118
+ });
119
+
120
+ toggleBtn.addEventListener("click", (e) => {
121
+ e.preventDefault();
122
+ e.stopPropagation();
123
+ toggleInline(container, decodeSource(container));
124
+ });
125
+
126
+ expandBtn.addEventListener("click", (e) => {
127
+ e.preventDefault();
128
+ e.stopPropagation();
129
+ openModal(container);
130
+ });
131
+
132
+ const handle: ToolbarHandle = {
133
+ cleanup: () => {
134
+ void unmount(toggleIcon);
135
+ void unmount(expandIcon);
136
+ },
137
+ remountToggleIcon: (view) => {
138
+ void unmount(toggleIcon);
139
+ toggleIcon = mountToggleIcon(toggleBtn, view);
140
+ },
141
+ };
142
+ (toolbar as HTMLElement & { _readitHandle?: ToolbarHandle })._readitHandle =
143
+ handle;
144
+
145
+ return toolbar;
146
+ }
147
+
148
+ function enhance(target: HTMLElement) {
149
+ const containers = target.querySelectorAll<HTMLElement>(
150
+ ".mermaid-container[data-mermaid-source]",
151
+ );
152
+
153
+ for (const container of containers) {
154
+ if (container.dataset[ENHANCED_FLAG] === "true") continue;
155
+ container.dataset[ENHANCED_FLAG] = "true";
156
+
157
+ // Ensure the container can host the absolutely-positioned toolbar.
158
+ container.style.position = "relative";
159
+
160
+ const toolbar = buildToolbar(container);
161
+ container.appendChild(toolbar);
162
+ }
163
+ }
164
+
165
+ function cleanup(target: HTMLElement) {
166
+ const toolbars = target.querySelectorAll<HTMLElement>(".mermaid-toolbar");
167
+ for (const tb of toolbars) {
168
+ (
169
+ tb as HTMLElement & { _readitHandle?: ToolbarHandle }
170
+ )._readitHandle?.cleanup();
171
+ tb.remove();
172
+ }
173
+ const enhanced = target.querySelectorAll<HTMLElement>(
174
+ ".mermaid-container[data-readit-mermaid-enhanced]",
175
+ );
176
+ for (const c of enhanced) {
177
+ delete c.dataset[ENHANCED_FLAG];
178
+ }
179
+ }
180
+
181
+ $effect(() => {
182
+ if (!root) return;
183
+ // Re-scan whenever contentVersion changes; declared so Svelte tracks it.
184
+ void contentVersion;
185
+ enhance(root);
186
+ return () => {
187
+ if (root) cleanup(root);
188
+ };
189
+ });
190
+
191
+ // Refresh aria-labels on existing imperative toolbars when locale changes.
192
+ $effect(() => {
193
+ if (!root) return;
194
+ void localeState.locale;
195
+ const toolbars = root.querySelectorAll<HTMLElement>(".mermaid-toolbar");
196
+ for (const tb of toolbars) {
197
+ const container = tb.closest<HTMLElement>(".mermaid-container");
198
+ const showingCode = container?.dataset.mermaidView === "code";
199
+ tb.querySelector<HTMLButtonElement>('[data-action="toggle"]')?.setAttribute(
200
+ "aria-label",
201
+ showingCode ? t("mermaid.showDiagram") : t("mermaid.showSource"),
202
+ );
203
+ tb.querySelector<HTMLButtonElement>('[data-action="expand"]')?.setAttribute(
204
+ "aria-label",
205
+ t("mermaid.expand"),
206
+ );
207
+ }
208
+ });
209
+ </script>
210
+
211
+ <MermaidModal
212
+ bind:open={modalOpen}
213
+ svg={modalSvg}
214
+ source={modalSource}
215
+ onclose={() => {
216
+ modalOpen = false;
217
+ }}
218
+ />
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import { Code2, Image as ImageIcon } from "lucide-svelte";
3
+ import { t } from "../stores/locale.svelte";
4
+ import Dialog from "./ui/Dialog.svelte";
5
+
6
+ interface Props {
7
+ open: boolean;
8
+ svg: string;
9
+ source: string;
10
+ onclose: () => void;
11
+ }
12
+
13
+ let { open = $bindable(false), svg, source, onclose }: Props = $props();
14
+
15
+ type View = "graph" | "code";
16
+ let view = $state<View>("graph");
17
+
18
+ // Reset to graph view each time the modal opens so the user always
19
+ // lands on the diagram first.
20
+ $effect(() => {
21
+ if (open) view = "graph";
22
+ });
23
+ </script>
24
+
25
+ <Dialog
26
+ bind:open
27
+ {onclose}
28
+ contentClass="w-[90vw] h-[90vh] max-w-[90vw]"
29
+ >
30
+ {#snippet header()}
31
+ {t("mermaid.modalTitle")}
32
+ {/snippet}
33
+
34
+ {#snippet headerActions()}
35
+ <div class="flex items-center gap-1 mr-8">
36
+ <button
37
+ type="button"
38
+ onclick={() => (view = "graph")}
39
+ aria-pressed={view === "graph"}
40
+ class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 aria-pressed:bg-zinc-100 dark:aria-pressed:bg-zinc-800 aria-pressed:text-zinc-900 dark:aria-pressed:text-zinc-100"
41
+ >
42
+ <ImageIcon class="w-3.5 h-3.5" />
43
+ {t("mermaid.viewGraph")}
44
+ </button>
45
+ <button
46
+ type="button"
47
+ onclick={() => (view = "code")}
48
+ aria-pressed={view === "code"}
49
+ class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 aria-pressed:bg-zinc-100 dark:aria-pressed:bg-zinc-800 aria-pressed:text-zinc-900 dark:aria-pressed:text-zinc-100"
50
+ >
51
+ <Code2 class="w-3.5 h-3.5" />
52
+ {t("mermaid.viewCode")}
53
+ </button>
54
+ </div>
55
+ {/snippet}
56
+
57
+ <div class="w-full h-full overflow-auto flex items-center justify-center">
58
+ {#if view === "graph"}
59
+ <!-- eslint-disable-next-line -- trusted mermaid render output -->
60
+ <div class="mermaid-container mermaid-modal-graph">{@html svg}</div>
61
+ {:else}
62
+ <pre
63
+ class="w-full h-full m-0 p-4 text-xs leading-relaxed font-mono text-zinc-800 dark:text-zinc-200 bg-zinc-50 dark:bg-zinc-950 rounded-lg overflow-auto whitespace-pre"
64
+ >{source}</pre>
65
+ {/if}
66
+ </div>
67
+ </Dialog>
@@ -0,0 +1,126 @@
1
+ <script lang="ts">
2
+ import { Copy } from "lucide-svelte";
3
+ import { app } from "../stores/app.svelte";
4
+ import { t } from "../stores/locale.svelte";
5
+ import Button from "./ui/Button.svelte";
6
+ import Dialog from "./ui/Dialog.svelte";
7
+ import Text from "./ui/Text.svelte";
8
+
9
+ interface Props {
10
+ open: boolean;
11
+ onclose: () => void;
12
+ }
13
+
14
+ let { open = $bindable(false), onclose }: Props = $props();
15
+
16
+ type ModalState =
17
+ | { status: "idle" }
18
+ | { status: "loading" }
19
+ | { status: "error"; error: string }
20
+ | { status: "empty"; path: string }
21
+ | { status: "success"; content: string; path: string };
22
+
23
+ let modalState = $state<ModalState>({ status: "idle" });
24
+
25
+ $effect(() => {
26
+ if (!open) {
27
+ modalState = { status: "idle" };
28
+ return;
29
+ }
30
+
31
+ modalState = { status: "loading" };
32
+
33
+ const query = app.activeDocumentPath
34
+ ? `?path=${encodeURIComponent(app.activeDocumentPath)}`
35
+ : "";
36
+
37
+ fetch(`/api/comments/raw${query}`)
38
+ .then((response) => {
39
+ if (!response.ok) {
40
+ throw new Error("Failed to fetch raw comments");
41
+ }
42
+ return response.json();
43
+ })
44
+ .then((result) => {
45
+ if (result.content === null) {
46
+ modalState = { status: "empty", path: result.path };
47
+ } else {
48
+ modalState = {
49
+ status: "success",
50
+ content: result.content,
51
+ path: result.path,
52
+ };
53
+ }
54
+ })
55
+ .catch((err) => {
56
+ modalState = {
57
+ status: "error",
58
+ error: err instanceof Error ? err.message : "Unknown error",
59
+ };
60
+ });
61
+ });
62
+
63
+ async function handleCopy() {
64
+ if (modalState.status !== "success") return;
65
+
66
+ try {
67
+ await navigator.clipboard.writeText(modalState.content);
68
+ } catch {}
69
+ }
70
+ </script>
71
+
72
+ <Dialog bind:open {onclose} contentClass="max-w-2xl max-h-[80vh]">
73
+ {#snippet header()}
74
+ {t("rawModal.title")}
75
+ {/snippet}
76
+
77
+ {#snippet headerActions()}
78
+ {#if modalState.status === "success"}
79
+ <Button
80
+ variant="ghost"
81
+ size="icon"
82
+ class="size-7"
83
+ onclick={handleCopy}
84
+ title={t("rawModal.copyTitle")}
85
+ >
86
+ <Copy class="w-4 h-4" />
87
+ </Button>
88
+ {/if}
89
+ {/snippet}
90
+
91
+ {#if modalState.status === "success" || modalState.status === "empty"}
92
+ <div
93
+ class="px-4 py-2 border-b border-zinc-50 dark:border-zinc-800 text-xs text-zinc-400 dark:text-zinc-500 font-mono truncate -mt-4 -mx-4 mb-4"
94
+ >
95
+ {modalState.path}
96
+ </div>
97
+ {/if}
98
+
99
+ {#if modalState.status === "loading"}
100
+ <Text variant="caption" class="text-center py-8">
101
+ {t("rawModal.loading")}
102
+ </Text>
103
+ {/if}
104
+
105
+ {#if modalState.status === "error"}
106
+ <Text variant="body" class="text-red-500 text-center py-8">
107
+ {modalState.error}
108
+ </Text>
109
+ {/if}
110
+
111
+ {#if modalState.status === "empty"}
112
+ <Text variant="caption" class="text-center py-8">
113
+ {t("rawModal.noComments")}
114
+ </Text>
115
+ {/if}
116
+
117
+ {#if modalState.status === "success"}
118
+ <Text
119
+ variant="body"
120
+ as="pre"
121
+ class="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed"
122
+ >
123
+ {modalState.content}
124
+ </Text>
125
+ {/if}
126
+ </Dialog>