@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,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,218 @@
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
+
13
+ let {
14
+ content,
15
+ comments,
16
+ isActive,
17
+ onTextSelect,
18
+ onHighlightHover,
19
+ onHighlightClick,
20
+ registerHighlighter,
21
+ unregisterHighlighter,
22
+ positions,
23
+ }: {
24
+ content: string;
25
+ comments: Comment[];
26
+ isActive: boolean;
27
+ onTextSelect: (
28
+ text: string,
29
+ startOffset: number,
30
+ endOffset: number,
31
+ selectionTop: number,
32
+ ) => void;
33
+ onHighlightHover?: (commentId: string | undefined) => void;
34
+ onHighlightClick?: (commentId: string) => void;
35
+ registerHighlighter: (
36
+ setFocused: (id: string | undefined) => void,
37
+ scrollToComment: (id: string) => void,
38
+ ) => void;
39
+ unregisterHighlighter?: () => void;
40
+ positions: Positions;
41
+ } = $props();
42
+
43
+ let contentEl: HTMLElement | undefined;
44
+ let containerEl: HTMLDivElement | undefined = $state();
45
+ let adapter: Highlighter | null = null;
46
+ let renderedContent = "";
47
+
48
+ let proseClass = $derived(
49
+ settings.fontFamily === FontFamilies.SANS_SERIF
50
+ ? "prose-sans"
51
+ : "prose-serif",
52
+ );
53
+
54
+ let mermaidCounter = 0;
55
+
56
+ async function hydrateMermaid(root: HTMLElement) {
57
+ const mermaidBlocks = root.querySelectorAll("pre code.language-mermaid");
58
+ if (mermaidBlocks.length === 0) return;
59
+
60
+ requestIdleCallback(async () => {
61
+ try {
62
+ const { default: mermaid } = await import("mermaid");
63
+ const { getMermaidInitConfig } = await import("../lib/mermaid-config");
64
+ mermaid.initialize(getMermaidInitConfig());
65
+
66
+ for (const codeEl of mermaidBlocks) {
67
+ const preEl = codeEl.parentElement;
68
+ if (!preEl) continue;
69
+ const code = codeEl.textContent ?? "";
70
+ try {
71
+ const { svg } = await mermaid.render(
72
+ `mermaid-${mermaidCounter++}`,
73
+ code,
74
+ );
75
+ const wrapper = document.createElement("div");
76
+ wrapper.className = "mermaid-container";
77
+ wrapper.innerHTML = svg;
78
+ preEl.replaceWith(wrapper);
79
+ } catch {}
80
+ }
81
+ if (isActive) requestAnimationFrame(() => positions.cache());
82
+ } catch {}
83
+ });
84
+ }
85
+
86
+ onMount(() => {
87
+ if (!containerEl) return;
88
+
89
+ const existingArticle = document.getElementById(
90
+ "document-content",
91
+ ) as HTMLElement | null;
92
+ if (existingArticle && !existingArticle.dataset.readitAdopted) {
93
+ existingArticle.dataset.readitAdopted = "true";
94
+ existingArticle.removeAttribute("id");
95
+ containerEl.appendChild(existingArticle);
96
+ contentEl = existingArticle;
97
+ existingArticle.className = cn("prose", proseClass);
98
+ } else if (!contentEl) {
99
+ const article = document.createElement("article");
100
+ article.className = cn("prose", proseClass);
101
+ article.innerHTML = content; // eslint-disable-line -- trusted server content
102
+ containerEl.appendChild(article);
103
+ contentEl = article;
104
+ }
105
+
106
+ adapter = createHighlighter({
107
+ root: contentEl!,
108
+ container: containerEl,
109
+ onSelect: onTextSelect,
110
+ });
111
+
112
+ registerHighlighter(adapter.setFocused, adapter.scrollToComment);
113
+
114
+ if (onHighlightHover) {
115
+ adapter.onHighlightHover(onHighlightHover);
116
+ }
117
+
118
+ if (onHighlightClick) {
119
+ adapter.onHighlightClick(onHighlightClick);
120
+ }
121
+
122
+ if (isActive && comments.length > 0) {
123
+ const hc: HighlightComment[] = comments
124
+ .filter((c) => c.anchorConfidence !== AnchorConfidences.UNRESOLVED)
125
+ .map((c) => ({
126
+ id: c.id,
127
+ selectedText: c.selectedText,
128
+ startOffset: c.startOffset,
129
+ endOffset: c.endOffset,
130
+ }));
131
+ adapter.applyHighlights(hc);
132
+ }
133
+
134
+ renderedContent = content;
135
+
136
+ void hydrateMermaid(contentEl!);
137
+
138
+ const handleTestSelect = (e: Event) => {
139
+ const { text, startOffset, endOffset } = (e as CustomEvent).detail;
140
+ onTextSelect(text, startOffset, endOffset, 0);
141
+ };
142
+ window.addEventListener("test:select-text", handleTestSelect);
143
+
144
+ document.documentElement.dataset.readitReady = "true";
145
+
146
+ return () => {
147
+ window.removeEventListener("test:select-text", handleTestSelect);
148
+ };
149
+ });
150
+
151
+ onDestroy(() => {
152
+ positions.detach();
153
+ adapter?.dispose();
154
+ adapter = null;
155
+ unregisterHighlighter?.();
156
+ });
157
+
158
+ // Intentionally capture initial value — skip first $effect when already active.
159
+ // svelte-ignore state_referenced_locally
160
+ let initialHighlightsDone = !isActive;
161
+ $effect(() => {
162
+ if (!isActive || !adapter) return;
163
+
164
+ const _comments = comments;
165
+ void content;
166
+
167
+ if (!initialHighlightsDone) {
168
+ initialHighlightsDone = true;
169
+ return;
170
+ }
171
+
172
+ if (_comments.length === 0) {
173
+ adapter.clearHighlights();
174
+ return;
175
+ }
176
+
177
+ const hc: HighlightComment[] = _comments
178
+ .filter((c) => c.anchorConfidence !== AnchorConfidences.UNRESOLVED)
179
+ .map((c) => ({
180
+ id: c.id,
181
+ selectedText: c.selectedText,
182
+ startOffset: c.startOffset,
183
+ endOffset: c.endOffset,
184
+ }));
185
+ adapter.applyHighlights(hc);
186
+ });
187
+
188
+ $effect(() => {
189
+ if (!contentEl || !containerEl || !adapter) return;
190
+
191
+ if (isActive) {
192
+ positions.attach(contentEl, containerEl, adapter);
193
+ positions.cache();
194
+ return () => positions.detach();
195
+ }
196
+ });
197
+
198
+ $effect(() => {
199
+ const sorted = comments
200
+ .filter((c) => c.anchorConfidence !== AnchorConfidences.UNRESOLVED)
201
+ .sort((a, b) => a.startOffset - b.startOffset);
202
+ positions.setIds(sorted.map((c) => c.id));
203
+ });
204
+
205
+ $effect(() => {
206
+ if (!contentEl) return;
207
+ contentEl.className = cn("prose", proseClass);
208
+
209
+ if (renderedContent !== content) {
210
+ contentEl.innerHTML = content; // eslint-disable-line -- trusted server content
211
+ renderedContent = content;
212
+ void hydrateMermaid(contentEl);
213
+ }
214
+ });
215
+ </script>
216
+
217
+ <div bind:this={containerEl} class="flex-1 min-w-0">
218
+ </div>
@@ -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>
@@ -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>