@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,129 @@
1
+ <script lang="ts">
2
+ import { Copy, Trash2 } from "lucide-svelte";
3
+ import { generatePrompt } from "../lib/export";
4
+ import type { Comment } from "../schema";
5
+ import { t } from "../stores/locale.svelte";
6
+ import CommentListItem from "./CommentListItem.svelte";
7
+ import Button from "./ui/Button.svelte";
8
+ import Text from "./ui/Text.svelte";
9
+
10
+ interface Props {
11
+ comments: Comment[];
12
+ fileName: string;
13
+ onclose: () => void;
14
+ onedit: (id: string, newText: string) => void;
15
+ ondelete: (id: string) => void;
16
+ ondeleteall: () => void;
17
+ onnavigate: (id: string) => void;
18
+ onstartreanchor: (id: string) => void;
19
+ }
20
+
21
+ let {
22
+ comments,
23
+ fileName,
24
+ onclose,
25
+ onedit,
26
+ ondelete,
27
+ ondeleteall,
28
+ onnavigate,
29
+ onstartreanchor,
30
+ }: Props = $props();
31
+
32
+ let confirmingDelete = $state(false);
33
+
34
+ let unresolvedCount = $derived(
35
+ comments.filter((c) => c.anchorConfidence === "unresolved").length,
36
+ );
37
+ let resolvedCount = $derived(comments.length - unresolvedCount);
38
+
39
+ let sortedComments = $derived(
40
+ [...comments].sort((a, b) => {
41
+ const aUnresolved = a.anchorConfidence === "unresolved";
42
+ const bUnresolved = b.anchorConfidence === "unresolved";
43
+ if (aUnresolved === bUnresolved) return 0;
44
+ return aUnresolved ? 1 : -1;
45
+ }),
46
+ );
47
+
48
+ function copyAll() {
49
+ const text = generatePrompt(comments, fileName);
50
+ navigator.clipboard.writeText(text);
51
+ }
52
+ </script>
53
+
54
+ {#if confirmingDelete}
55
+ <div class="px-3 py-2 border-b border-zinc-100">
56
+ <Text variant="caption" class="mb-1.5">
57
+ {t("commentManager.deleteAllConfirm", { count: comments.length })}
58
+ </Text>
59
+ <div class="flex gap-3">
60
+ <Button
61
+ variant="link"
62
+ size="sm"
63
+ class="text-red-600 hover:text-red-700 h-auto p-0 text-xs"
64
+ onclick={() => {
65
+ ondeleteall();
66
+ onclose();
67
+ }}
68
+ >
69
+ {t("commentManager.delete")}
70
+ </Button>
71
+ <Button
72
+ variant="ghost"
73
+ size="sm"
74
+ class="h-auto p-0 text-xs"
75
+ onclick={() => (confirmingDelete = false)}
76
+ >
77
+ {t("commentManager.cancel")}
78
+ </Button>
79
+ </div>
80
+ </div>
81
+ {:else}
82
+ <Text variant="caption" as="div" class="flex items-center justify-between px-3 py-2 border-b border-zinc-100">
83
+ <span>
84
+ {resolvedCount}
85
+ {#if unresolvedCount > 0}
86
+ <span>
87
+ {" "}· {unresolvedCount} {t("commentManager.unresolved")}
88
+ </span>
89
+ {/if}
90
+ </span>
91
+ <span class="flex items-center gap-1">
92
+ <button
93
+ type="button"
94
+ class="p-1 rounded hover:bg-zinc-100 text-zinc-400 hover:text-zinc-600 transition-colors"
95
+ onclick={copyAll}
96
+ title={t("commentManager.copyAllTitle")}
97
+ >
98
+ <Copy size={13} />
99
+ </button>
100
+ <button
101
+ type="button"
102
+ class="p-1 rounded hover:bg-zinc-100 text-zinc-400 hover:text-red-500 transition-colors"
103
+ onclick={() => (confirmingDelete = true)}
104
+ title={t("commentManager.deleteAllTitle")}
105
+ >
106
+ <Trash2 size={13} />
107
+ </button>
108
+ </span>
109
+ </Text>
110
+ {/if}
111
+
112
+ <div class="overflow-y-auto max-h-80">
113
+ {#if sortedComments.length === 0}
114
+ <Text variant="caption" as="div" class="px-3 py-4 text-center">
115
+ {t("commentManager.noComments")}
116
+ </Text>
117
+ {:else}
118
+ {#each sortedComments as comment (comment.id)}
119
+ <CommentListItem
120
+ {comment}
121
+ onaction={onclose}
122
+ {onedit}
123
+ {ondelete}
124
+ {onnavigate}
125
+ {onstartreanchor}
126
+ />
127
+ {/each}
128
+ {/if}
129
+ </div>
@@ -0,0 +1,109 @@
1
+ <script lang="ts">
2
+ import { ChevronLeft, ChevronRight } from "lucide-svelte";
3
+ import { onDestroy } from "svelte";
4
+ import { cn } from "../lib/utils";
5
+ import type { Comment } from "../schema";
6
+ import { t } from "../stores/locale.svelte";
7
+ import Button from "./ui/Button.svelte";
8
+ import Text from "./ui/Text.svelte";
9
+
10
+ const ANIMATION_DURATION_MS = 200;
11
+
12
+ interface Props {
13
+ sortedComments: Comment[];
14
+ currentIndex: number;
15
+ onprevious: () => void;
16
+ onnext: () => void;
17
+ }
18
+
19
+ let { sortedComments, currentIndex, onprevious, onnext }: Props = $props();
20
+
21
+ let isHovered = $state(false);
22
+ let animating = $state<"prev" | "next" | null>(null);
23
+ let animationTimeout: ReturnType<typeof setTimeout> | undefined;
24
+
25
+ onDestroy(() => {
26
+ clearTimeout(animationTimeout);
27
+ });
28
+
29
+ function handlePrevious() {
30
+ animating = "prev";
31
+ onprevious();
32
+ clearTimeout(animationTimeout);
33
+ animationTimeout = setTimeout(() => {
34
+ animating = null;
35
+ }, ANIMATION_DURATION_MS);
36
+ }
37
+
38
+ function handleNext() {
39
+ animating = "next";
40
+ onnext();
41
+ clearTimeout(animationTimeout);
42
+ animationTimeout = setTimeout(() => {
43
+ animating = null;
44
+ }, ANIMATION_DURATION_MS);
45
+ }
46
+
47
+ let totalComments = $derived(sortedComments.length);
48
+ </script>
49
+
50
+ {#if totalComments > 1}
51
+ <fieldset
52
+ class="fixed bottom-6 left-1/2 -translate-x-1/2 z-40"
53
+ onmouseenter={() => (isHovered = true)}
54
+ onmouseleave={() => (isHovered = false)}
55
+ >
56
+ <div
57
+ class={cn(
58
+ "inline-flex items-center gap-1 h-9 px-3 rounded-full",
59
+ "bg-white/90 dark:bg-zinc-900/90 backdrop-blur-md shadow-lg border border-zinc-200/60 dark:border-zinc-700/60",
60
+ "transition-opacity duration-150 ease-out",
61
+ isHovered ? "opacity-100" : "opacity-0",
62
+ )}
63
+ >
64
+ <Button
65
+ variant="ghost"
66
+ size="icon"
67
+ class={cn(
68
+ "size-7 rounded-full text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300",
69
+ animating === "prev" &&
70
+ "scale-90 bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300",
71
+ )}
72
+ onclick={handlePrevious}
73
+ title={t("commentNav.previous")}
74
+ >
75
+ <ChevronLeft class="w-4 h-4" />
76
+ </Button>
77
+
78
+ <Text
79
+ variant="body"
80
+ as="span"
81
+ class={cn(
82
+ "px-3 tabular-nums select-none min-w-[4rem] text-center",
83
+ "transition-transform duration-200 ease-out",
84
+ animating === "prev" && "-translate-x-0.5",
85
+ animating === "next" && "translate-x-0.5",
86
+ )}
87
+ >
88
+ {t("commentNav.of", {
89
+ current: currentIndex + 1,
90
+ total: totalComments,
91
+ })}
92
+ </Text>
93
+
94
+ <Button
95
+ variant="ghost"
96
+ size="icon"
97
+ class={cn(
98
+ "size-7 rounded-full text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300",
99
+ animating === "next" &&
100
+ "scale-90 bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300",
101
+ )}
102
+ onclick={handleNext}
103
+ title={t("commentNav.next")}
104
+ >
105
+ <ChevronRight class="w-4 h-4" />
106
+ </Button>
107
+ </div>
108
+ </fieldset>
109
+ {/if}
@@ -0,0 +1,233 @@
1
+ <script lang="ts">
2
+ import { onDestroy, onMount } from "svelte";
3
+ import {
4
+ createHighlighter,
5
+ type Highlighter,
6
+ } from "../lib/highlight/highlighter";
7
+ import type { HighlightComment } from "../lib/highlight/types";
8
+ import type { Positions } from "../lib/positions";
9
+ import { cn } from "../lib/utils";
10
+ import { AnchorConfidences, type Comment, FontFamilies } from "../schema";
11
+ import { settings } from "../stores/settings.svelte";
12
+ import MermaidEnhancer from "./MermaidEnhancer.svelte";
13
+
14
+ let {
15
+ content,
16
+ comments,
17
+ isActive,
18
+ onTextSelect,
19
+ onHighlightHover,
20
+ onHighlightClick,
21
+ registerHighlighter,
22
+ unregisterHighlighter,
23
+ positions,
24
+ }: {
25
+ content: string;
26
+ comments: Comment[];
27
+ isActive: boolean;
28
+ onTextSelect: (
29
+ text: string,
30
+ startOffset: number,
31
+ endOffset: number,
32
+ selectionTop: number,
33
+ ) => void;
34
+ onHighlightHover?: (commentId: string | undefined) => void;
35
+ onHighlightClick?: (commentId: string) => void;
36
+ registerHighlighter: (
37
+ setFocused: (id: string | undefined) => void,
38
+ scrollToComment: (id: string) => void,
39
+ ) => void;
40
+ unregisterHighlighter?: () => void;
41
+ positions: Positions;
42
+ } = $props();
43
+
44
+ let contentEl: HTMLElement | undefined = $state();
45
+ let containerEl: HTMLDivElement | undefined = $state();
46
+ let adapter: Highlighter | null = null;
47
+ let renderedContent = "";
48
+ let contentVersion = $state(0);
49
+
50
+ let proseClass = $derived(
51
+ settings.fontFamily === FontFamilies.SANS_SERIF
52
+ ? "prose-sans"
53
+ : "prose-serif",
54
+ );
55
+
56
+ let mermaidCounter = 0;
57
+
58
+ async function hydrateMermaid(root: HTMLElement) {
59
+ const mermaidBlocks = root.querySelectorAll("pre code.language-mermaid");
60
+ if (mermaidBlocks.length === 0) return;
61
+
62
+ requestIdleCallback(async () => {
63
+ try {
64
+ const { default: mermaid } = await import("mermaid");
65
+ const { getMermaidInitConfig } = await import("../lib/mermaid-config");
66
+ mermaid.initialize(getMermaidInitConfig());
67
+
68
+ for (const codeEl of mermaidBlocks) {
69
+ const preEl = codeEl.parentElement;
70
+ if (!preEl) continue;
71
+ const code = codeEl.textContent ?? "";
72
+ try {
73
+ const { svg } = await mermaid.render(
74
+ `mermaid-${mermaidCounter++}`,
75
+ code,
76
+ );
77
+ const wrapper = document.createElement("div");
78
+ wrapper.className = "mermaid-container";
79
+ wrapper.dataset.mermaidSource = encodeURIComponent(code);
80
+ // eslint-disable-next-line -- trusted mermaid render output
81
+ wrapper.innerHTML = svg;
82
+ preEl.replaceWith(wrapper);
83
+ } catch {}
84
+ }
85
+ contentVersion++;
86
+ if (isActive) requestAnimationFrame(() => positions.cache());
87
+ } catch {}
88
+ });
89
+ }
90
+
91
+ onMount(() => {
92
+ if (!containerEl) return;
93
+
94
+ const existingArticle = document.getElementById(
95
+ "document-content",
96
+ ) as HTMLElement | null;
97
+ if (existingArticle && !existingArticle.dataset.readitAdopted) {
98
+ existingArticle.dataset.readitAdopted = "true";
99
+ existingArticle.removeAttribute("id");
100
+ containerEl.appendChild(existingArticle);
101
+ contentEl = existingArticle;
102
+ existingArticle.className = cn("prose", proseClass);
103
+ } else if (!contentEl) {
104
+ const article = document.createElement("article");
105
+ article.className = cn("prose", proseClass);
106
+ article.innerHTML = content; // eslint-disable-line -- trusted server content
107
+ containerEl.appendChild(article);
108
+ contentEl = article;
109
+ }
110
+
111
+ adapter = createHighlighter({
112
+ root: contentEl!,
113
+ container: containerEl,
114
+ onSelect: onTextSelect,
115
+ });
116
+
117
+ registerHighlighter(adapter.setFocused, adapter.scrollToComment);
118
+
119
+ if (onHighlightHover) {
120
+ adapter.onHighlightHover(onHighlightHover);
121
+ }
122
+
123
+ if (onHighlightClick) {
124
+ adapter.onHighlightClick(onHighlightClick);
125
+ }
126
+
127
+ if (isActive && comments.length > 0) {
128
+ const hc: HighlightComment[] = comments
129
+ .filter((c) => c.anchorConfidence !== AnchorConfidences.UNRESOLVED)
130
+ .map((c) => ({
131
+ id: c.id,
132
+ selectedText: c.selectedText,
133
+ startOffset: c.startOffset,
134
+ endOffset: c.endOffset,
135
+ }));
136
+ adapter.applyHighlights(hc);
137
+ }
138
+
139
+ renderedContent = content;
140
+ contentVersion++;
141
+
142
+ void hydrateMermaid(contentEl!);
143
+
144
+ const handleTestSelect = (e: Event) => {
145
+ const { text, startOffset, endOffset } = (e as CustomEvent).detail;
146
+ onTextSelect(text, startOffset, endOffset, 0);
147
+ };
148
+ window.addEventListener("test:select-text", handleTestSelect);
149
+
150
+ document.documentElement.dataset.readitReady = "true";
151
+
152
+ return () => {
153
+ window.removeEventListener("test:select-text", handleTestSelect);
154
+ };
155
+ });
156
+
157
+ onDestroy(() => {
158
+ positions.detach();
159
+ adapter?.dispose();
160
+ adapter = null;
161
+ unregisterHighlighter?.();
162
+ });
163
+
164
+ // Intentionally capture initial value — skip first $effect when already active.
165
+ // svelte-ignore state_referenced_locally
166
+ let initialHighlightsDone = !isActive;
167
+ $effect(() => {
168
+ if (!isActive || !adapter) return;
169
+
170
+ const _comments = comments;
171
+ void content;
172
+
173
+ if (!initialHighlightsDone) {
174
+ initialHighlightsDone = true;
175
+ return;
176
+ }
177
+
178
+ if (_comments.length === 0) {
179
+ adapter.clearHighlights();
180
+ return;
181
+ }
182
+
183
+ const hc: HighlightComment[] = _comments
184
+ .filter((c) => c.anchorConfidence !== AnchorConfidences.UNRESOLVED)
185
+ .map((c) => ({
186
+ id: c.id,
187
+ selectedText: c.selectedText,
188
+ startOffset: c.startOffset,
189
+ endOffset: c.endOffset,
190
+ }));
191
+ adapter.applyHighlights(hc);
192
+ });
193
+
194
+ $effect(() => {
195
+ if (!contentEl || !containerEl || !adapter) return;
196
+
197
+ if (isActive) {
198
+ positions.attach(contentEl, containerEl, adapter);
199
+ positions.cache();
200
+ return () => positions.detach();
201
+ }
202
+ });
203
+
204
+ $effect(() => {
205
+ const sorted = comments
206
+ .filter((c) => c.anchorConfidence !== AnchorConfidences.UNRESOLVED)
207
+ .sort((a, b) => a.startOffset - b.startOffset);
208
+ positions.setIds(sorted.map((c) => c.id));
209
+ });
210
+
211
+ $effect(() => {
212
+ if (!contentEl) return;
213
+ contentEl.className = cn("prose", proseClass);
214
+
215
+ if (renderedContent !== content) {
216
+ contentEl.innerHTML = content; // eslint-disable-line -- trusted server content
217
+ renderedContent = content;
218
+ contentVersion++;
219
+ void hydrateMermaid(contentEl);
220
+ }
221
+ });
222
+ </script>
223
+
224
+ <div bind:this={containerEl} class="flex-1 min-w-0">
225
+ </div>
226
+
227
+ <MermaidEnhancer
228
+ root={contentEl}
229
+ {contentVersion}
230
+ notifyContentChanged={() => {
231
+ if (isActive) requestAnimationFrame(() => positions.cache());
232
+ }}
233
+ />
@@ -0,0 +1,107 @@
1
+ <script lang="ts">
2
+ import { cn } from "../lib/utils";
3
+ import { type Comment, FontFamilies } from "../schema";
4
+ import { t } from "../stores/locale.svelte";
5
+ import { settings } from "../stores/settings.svelte";
6
+ import { setActiveCommentId, ui } from "../stores/ui.svelte";
7
+ import InlineEditor from "./InlineEditor.svelte";
8
+ import ActionLink from "./ui/ActionLink.svelte";
9
+ import Text from "./ui/Text.svelte";
10
+
11
+ interface Props {
12
+ comment: Comment;
13
+ onedit: (id: string, text: string) => void;
14
+ ondelete: (id: string) => void;
15
+ oncopy: (comment: Comment) => void;
16
+ onnavigate: (commentId: string) => void;
17
+ }
18
+
19
+ let { comment, onedit, ondelete, oncopy, onnavigate }: Props = $props();
20
+
21
+ let isEditing = $state(false);
22
+ let fontClass = $derived(
23
+ settings.fontFamily === FontFamilies.SANS_SERIF ? "font-sans" : "font-serif",
24
+ );
25
+ let hasNote = $derived(comment.comment.trim().length > 0);
26
+
27
+ function dismiss() {
28
+ setActiveCommentId(undefined);
29
+ }
30
+
31
+ function handleWindowKeydown(e: KeyboardEvent) {
32
+ if (ui.activeCommentId && e.key === "Escape") {
33
+ dismiss();
34
+ }
35
+ }
36
+ </script>
37
+
38
+ <svelte:window onkeydown={handleWindowKeydown} />
39
+
40
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
41
+ <!-- Backdrop -->
42
+ <div
43
+ class="fixed inset-0 z-40 lg:hidden"
44
+ onclick={dismiss}
45
+ ></div>
46
+
47
+ <!-- Floating panel -->
48
+ <div
49
+ class="fixed bottom-16 left-4 right-4 z-50 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg p-4 lg:hidden"
50
+ >
51
+ <!-- Selected text -->
52
+ <div class={cn(fontClass, "text-sm italic text-zinc-500 dark:text-zinc-400 mb-2 line-clamp-2")}>
53
+ <button
54
+ type="button"
55
+ onclick={() => {
56
+ onnavigate(comment.id);
57
+ dismiss();
58
+ }}
59
+ class="cursor-pointer hover:underline text-left"
60
+ >
61
+ "{comment.selectedText}"
62
+ </button>
63
+ </div>
64
+
65
+ {#if isEditing}
66
+ <InlineEditor
67
+ initialText={comment.comment}
68
+ onsave={(text) => {
69
+ onedit(comment.id, text);
70
+ isEditing = false;
71
+ }}
72
+ oncancel={() => (isEditing = false)}
73
+ />
74
+ {:else}
75
+ <!-- Comment text -->
76
+ {#if hasNote}
77
+ <p class={cn(fontClass, "text-sm text-zinc-800 dark:text-zinc-200 whitespace-pre-wrap mb-3")}>
78
+ {comment.comment}
79
+ </p>
80
+ {:else}
81
+ <Text variant="caption" class="mb-3 italic">
82
+ {t("marginNote.addNote")}
83
+ </Text>
84
+ {/if}
85
+
86
+ <!-- Actions -->
87
+ <div class="flex items-center text-xs text-zinc-400 gap-3 pt-2 border-t border-zinc-100 dark:border-zinc-800">
88
+ <ActionLink onclick={() => (isEditing = true)}>
89
+ {hasNote ? t("marginNote.edit") : t("marginNote.addNote")}
90
+ </ActionLink>
91
+ <span aria-hidden="true">&middot;</span>
92
+ <ActionLink variant="destructive" onclick={() => { ondelete(comment.id); dismiss(); }}>
93
+ {t("marginNote.delete")}
94
+ </ActionLink>
95
+ {#if hasNote}
96
+ <span aria-hidden="true">&middot;</span>
97
+ <ActionLink onclick={() => oncopy(comment)}>
98
+ {t("marginNote.copy")}
99
+ </ActionLink>
100
+ {/if}
101
+ <span class="flex-1"></span>
102
+ <ActionLink onclick={dismiss}>
103
+ {t("comment.cancel")}
104
+ </ActionLink>
105
+ </div>
106
+ {/if}
107
+ </div>
@@ -0,0 +1,76 @@
1
+ <script lang="ts">
2
+ import type { Comment } from "../schema";
3
+ import { t } from "../stores/locale.svelte";
4
+ import ActionsMenu from "./ActionsMenu.svelte";
5
+ import CommentBadge from "./CommentBadge.svelte";
6
+ import Text from "./ui/Text.svelte";
7
+
8
+ interface Props {
9
+ fileName: string;
10
+ comments: Comment[];
11
+ hasReanchorTarget: boolean;
12
+ oncopyall: () => void;
13
+ onexportjson: () => void;
14
+ onreload: () => void;
15
+ onedit: (id: string, newText: string) => void;
16
+ ondelete: (id: string) => void;
17
+ ondeleteall: () => void;
18
+ onnavigate: (id: string) => void;
19
+ onstartreanchor: (id: string) => void;
20
+ }
21
+
22
+ let {
23
+ fileName,
24
+ comments,
25
+ hasReanchorTarget,
26
+ oncopyall,
27
+ onexportjson,
28
+ onreload,
29
+ onedit,
30
+ ondelete,
31
+ ondeleteall,
32
+ onnavigate,
33
+ onstartreanchor,
34
+ }: Props = $props();
35
+
36
+ let commentCount = $derived(comments.length);
37
+ </script>
38
+
39
+ <header class="sticky top-0 z-50 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-sm border-b border-zinc-100 dark:border-zinc-800">
40
+ <div class="px-6 py-3 flex items-center justify-between max-w-7xl mx-auto">
41
+ <div class="flex items-center gap-3">
42
+ <Text variant="title" as="h1">
43
+ readit
44
+ </Text>
45
+ <span class="text-zinc-200 dark:text-zinc-700 font-light">&mdash;</span>
46
+ <Text variant="caption" as="span" class="truncate max-w-[200px]">
47
+ {fileName}
48
+ </Text>
49
+ </div>
50
+
51
+ <div class="flex items-center gap-3">
52
+ {#if hasReanchorTarget}
53
+ <Text variant="caption" as="span" class="italic">
54
+ {t("header.selectTextToReanchor")}
55
+ </Text>
56
+ {/if}
57
+
58
+ <CommentBadge
59
+ {comments}
60
+ {fileName}
61
+ {onedit}
62
+ {ondelete}
63
+ {ondeleteall}
64
+ {onnavigate}
65
+ {onstartreanchor}
66
+ />
67
+
68
+ <ActionsMenu
69
+ {commentCount}
70
+ {oncopyall}
71
+ {onexportjson}
72
+ {onreload}
73
+ />
74
+ </div>
75
+ </div>
76
+ </header>