@peaske7/readit 0.1.8 → 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 (221) 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 -5
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -710
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +130 -0
  12. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  13. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  14. package/e2e/comments.spec.ts +14 -58
  15. package/e2e/document-load.spec.ts +1 -23
  16. package/e2e/export.spec.ts +4 -4
  17. package/e2e/perf/add-comment.spec.ts +116 -0
  18. package/e2e/perf/fixtures/generate.ts +327 -0
  19. package/e2e/perf/initial-load.spec.ts +49 -0
  20. package/e2e/perf/perf.setup.ts +23 -0
  21. package/e2e/perf/perf.teardown.ts +9 -0
  22. package/e2e/perf/screenshot-final.png +0 -0
  23. package/e2e/perf/scroll.spec.ts +39 -0
  24. package/e2e/perf/tab-switch.spec.ts +69 -0
  25. package/e2e/perf/text-selection.spec.ts +119 -0
  26. package/e2e/perf/utils/metrics.ts +350 -0
  27. package/e2e/perf/utils/perf-cli.ts +86 -0
  28. package/e2e/persistence-file.spec.ts +41 -26
  29. package/e2e/utils/selection.ts +17 -73
  30. package/go/cmd/readit/main.go +416 -0
  31. package/go/go.mod +20 -0
  32. package/go/go.sum +41 -0
  33. package/go/internal/server/anchor.go +302 -0
  34. package/go/internal/server/anchor_test.go +111 -0
  35. package/go/internal/server/comments.go +390 -0
  36. package/go/internal/server/documents.go +113 -0
  37. package/go/internal/server/embed.go +17 -0
  38. package/go/internal/server/headings.go +33 -0
  39. package/go/internal/server/headings_test.go +75 -0
  40. package/go/internal/server/htmltext.go +123 -0
  41. package/go/internal/server/markdown.go +157 -0
  42. package/go/internal/server/markdown_bench_test.go +42 -0
  43. package/go/internal/server/markdown_test.go +79 -0
  44. package/go/internal/server/server.go +453 -0
  45. package/go/internal/server/server_bench_test.go +122 -0
  46. package/go/internal/server/settings.go +110 -0
  47. package/go/internal/server/sse.go +140 -0
  48. package/go/internal/server/storage.go +275 -0
  49. package/go/internal/server/storage_test.go +118 -0
  50. package/go/internal/server/template.go +66 -0
  51. package/go/internal/server/types.go +101 -0
  52. package/go/internal/server/watcher.go +74 -0
  53. package/index.html +4 -14
  54. package/nvim-readit/lua/readit/health.lua +64 -0
  55. package/nvim-readit/lua/readit/init.lua +463 -0
  56. package/nvim-readit/plugin/readit.lua +19 -0
  57. package/package.json +24 -41
  58. package/playwright.config.ts +12 -0
  59. package/shell/_readit +158 -0
  60. package/shell/readit.zsh +87 -0
  61. package/src/App.svelte +881 -0
  62. package/src/{cli/index.ts → cli.ts} +216 -70
  63. package/src/components/ActionsMenu.svelte +95 -0
  64. package/src/components/CommentBadge.svelte +67 -0
  65. package/src/components/CommentErrorBanner.svelte +33 -0
  66. package/src/components/CommentInput.svelte +75 -0
  67. package/src/components/CommentListItem.svelte +95 -0
  68. package/src/components/CommentManager.svelte +129 -0
  69. package/src/components/CommentNav.svelte +109 -0
  70. package/src/components/DocumentViewer.svelte +218 -0
  71. package/src/components/FloatingComment.svelte +107 -0
  72. package/src/components/Header.svelte +76 -0
  73. package/src/components/InlineEditor.svelte +72 -0
  74. package/src/components/MarginNote.svelte +167 -0
  75. package/src/components/MarginNotesContainer.svelte +33 -0
  76. package/src/components/RawModal.svelte +126 -0
  77. package/src/components/ReanchorConfirm.svelte +30 -0
  78. package/src/components/SettingsModal.svelte +220 -0
  79. package/src/components/ShortcutCapture.svelte +82 -0
  80. package/src/components/ShortcutList.svelte +145 -0
  81. package/src/components/TabBar.svelte +52 -0
  82. package/src/components/TableOfContents.svelte +125 -0
  83. package/src/components/ui/ActionLink.svelte +40 -0
  84. package/src/components/ui/Button.svelte +53 -0
  85. package/src/components/ui/Dialog.svelte +97 -0
  86. package/src/components/ui/DropdownMenu.svelte +85 -0
  87. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  88. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  89. package/src/components/ui/Text.svelte +42 -0
  90. package/src/env.d.ts +6 -0
  91. package/src/index.css +36 -166
  92. package/src/lib/__fixtures__/bench-data.ts +1 -54
  93. package/src/lib/anchor.bench.ts +47 -68
  94. package/src/lib/anchor.test.ts +5 -9
  95. package/src/lib/anchor.ts +9 -93
  96. package/src/lib/comment-storage.bench.ts +6 -20
  97. package/src/lib/comment-storage.test.ts +45 -37
  98. package/src/lib/comment-storage.ts +23 -64
  99. package/src/lib/export.bench.ts +9 -23
  100. package/src/lib/export.ts +7 -14
  101. package/src/lib/headings.test.ts +103 -0
  102. package/src/lib/headings.ts +44 -0
  103. package/src/lib/highlight/core.test.ts +1 -6
  104. package/src/lib/highlight/dom.ts +53 -280
  105. package/src/lib/highlight/highlight-registry.ts +221 -0
  106. package/src/lib/highlight/highlight.bench.ts +92 -0
  107. package/src/lib/highlight/highlighter.ts +122 -302
  108. package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
  109. package/src/lib/highlight/types.ts +0 -40
  110. package/src/lib/html-text.test.ts +162 -0
  111. package/src/lib/html-text.ts +161 -0
  112. package/src/lib/i18n/en.ts +13 -36
  113. package/src/lib/i18n/ja.ts +14 -37
  114. package/src/lib/i18n/types.ts +13 -36
  115. package/src/lib/margin-layout.bench.ts +48 -15
  116. package/src/lib/margin-layout.ts +2 -31
  117. package/src/lib/markdown-renderer.test.ts +154 -0
  118. package/src/lib/markdown-renderer.ts +177 -0
  119. package/src/lib/mermaid-config.ts +38 -0
  120. package/src/lib/mermaid-renderer.ts +162 -0
  121. package/src/lib/mermaid-worker.ts +60 -0
  122. package/src/lib/positions.ts +157 -0
  123. package/src/lib/shortcut-registry.ts +138 -103
  124. package/src/lib/utils.ts +2 -48
  125. package/src/main.ts +16 -0
  126. package/src/schema.ts +92 -0
  127. package/src/{server/index.ts → server.ts} +427 -163
  128. package/src/stores/app.svelte.ts +231 -0
  129. package/src/stores/locale.svelte.ts +46 -0
  130. package/src/stores/settings.svelte.ts +90 -0
  131. package/src/stores/shortcuts.svelte.ts +104 -0
  132. package/src/stores/ui.svelte.ts +12 -0
  133. package/src/template.ts +104 -0
  134. package/src/test-setup.ts +47 -0
  135. package/svelte.config.js +5 -0
  136. package/tsconfig.json +2 -2
  137. package/vite.config.ts +31 -3
  138. package/vscode-readit/.mcp.json +7 -0
  139. package/vscode-readit/.vscodeignore +7 -0
  140. package/vscode-readit/bun.lock +78 -0
  141. package/vscode-readit/icon.svg +10 -0
  142. package/vscode-readit/package.json +110 -0
  143. package/vscode-readit/src/extension.ts +117 -0
  144. package/vscode-readit/src/server-manager.ts +272 -0
  145. package/vscode-readit/src/webview-provider.ts +204 -0
  146. package/vscode-readit/tsconfig.json +20 -0
  147. package/e2e/fixtures/sample.html +0 -13
  148. package/src/App.tsx +0 -416
  149. package/src/components/ActionsMenu.tsx +0 -112
  150. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  151. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
  152. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  153. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  154. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
  155. package/src/components/DocumentViewer/index.ts +0 -1
  156. package/src/components/FloatingTOC.tsx +0 -61
  157. package/src/components/Header.tsx +0 -65
  158. package/src/components/InlineEditor.tsx +0 -74
  159. package/src/components/MarginNote.tsx +0 -207
  160. package/src/components/MarginNotes.tsx +0 -50
  161. package/src/components/RawModal.tsx +0 -143
  162. package/src/components/ReanchorConfirm.tsx +0 -36
  163. package/src/components/SettingsModal.tsx +0 -310
  164. package/src/components/ShortcutCapture.tsx +0 -48
  165. package/src/components/ShortcutList.tsx +0 -198
  166. package/src/components/TabBar.tsx +0 -60
  167. package/src/components/TableOfContents.tsx +0 -108
  168. package/src/components/comments/CommentBadge.tsx +0 -49
  169. package/src/components/comments/CommentInput.tsx +0 -114
  170. package/src/components/comments/CommentListItem.tsx +0 -92
  171. package/src/components/comments/CommentManager.tsx +0 -113
  172. package/src/components/comments/CommentMinimap.tsx +0 -62
  173. package/src/components/comments/CommentNav.tsx +0 -109
  174. package/src/components/ui/ActionBar.tsx +0 -16
  175. package/src/components/ui/ActionLink.tsx +0 -32
  176. package/src/components/ui/Button.tsx +0 -55
  177. package/src/components/ui/Dialog.tsx +0 -156
  178. package/src/components/ui/DropdownMenu.tsx +0 -114
  179. package/src/components/ui/SeparatorDot.tsx +0 -9
  180. package/src/components/ui/Text.tsx +0 -54
  181. package/src/contexts/CommentContext.tsx +0 -229
  182. package/src/contexts/LayoutContext.tsx +0 -88
  183. package/src/contexts/LocaleContext.tsx +0 -35
  184. package/src/hooks/useClickOutside.ts +0 -35
  185. package/src/hooks/useClipboard.ts +0 -82
  186. package/src/hooks/useCommentNavigation.ts +0 -130
  187. package/src/hooks/useComments.ts +0 -323
  188. package/src/hooks/useDocument.ts +0 -156
  189. package/src/hooks/useEditorScheme.ts +0 -51
  190. package/src/hooks/useFontPreference.ts +0 -59
  191. package/src/hooks/useHeadings.test.ts +0 -159
  192. package/src/hooks/useHeadings.ts +0 -129
  193. package/src/hooks/useKeybindings.ts +0 -108
  194. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  195. package/src/hooks/useLayoutMode.ts +0 -44
  196. package/src/hooks/useLocalePreference.ts +0 -42
  197. package/src/hooks/useReanchorMode.ts +0 -33
  198. package/src/hooks/useScrollMetrics.ts +0 -56
  199. package/src/hooks/useScrollSpy.ts +0 -81
  200. package/src/hooks/useTextSelection.ts +0 -123
  201. package/src/hooks/useThemePreference.ts +0 -66
  202. package/src/lib/context.bench.ts +0 -41
  203. package/src/lib/context.test.ts +0 -224
  204. package/src/lib/context.ts +0 -193
  205. package/src/lib/editor-links.ts +0 -59
  206. package/src/lib/highlight/colors.ts +0 -37
  207. package/src/lib/highlight/index.ts +0 -23
  208. package/src/lib/highlight/script-builder.ts +0 -485
  209. package/src/lib/html-processor.test.tsx +0 -170
  210. package/src/lib/html-processor.tsx +0 -95
  211. package/src/lib/i18n/completeness.test.ts +0 -51
  212. package/src/lib/i18n/translations.test.ts +0 -39
  213. package/src/lib/layout-constants.ts +0 -12
  214. package/src/lib/scroll.test.ts +0 -118
  215. package/src/lib/scroll.ts +0 -47
  216. package/src/lib/shortcut-registry.test.ts +0 -173
  217. package/src/lib/utils.test.ts +0 -110
  218. package/src/main.tsx +0 -13
  219. package/src/store/index.test.ts +0 -242
  220. package/src/store/index.ts +0 -254
  221. package/src/types/index.ts +0 -127
@@ -0,0 +1,75 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import { cn } from "../lib/utils";
4
+ import { FontFamilies } from "../schema";
5
+ import { t } from "../stores/locale.svelte";
6
+ import { settings } from "../stores/settings.svelte";
7
+ import Button from "./ui/Button.svelte";
8
+ import Text from "./ui/Text.svelte";
9
+
10
+ interface Props {
11
+ selectedText: string | null;
12
+ onsubmit: (commentText: string) => void;
13
+ oncancel: () => void;
14
+ }
15
+
16
+ let { selectedText, onsubmit, oncancel }: Props = $props();
17
+
18
+ let commentText = $state("");
19
+ let textareaEl: HTMLTextAreaElement | undefined = $state();
20
+
21
+ let fontClass = $derived(
22
+ settings.fontFamily === FontFamilies.SANS_SERIF ? "font-sans" : "font-serif",
23
+ );
24
+
25
+ onMount(() => {
26
+ if (textareaEl && window.matchMedia("(pointer: fine)").matches) {
27
+ textareaEl.focus();
28
+ }
29
+ });
30
+
31
+ function handleSubmit() {
32
+ onsubmit(commentText.trim());
33
+ commentText = "";
34
+ }
35
+
36
+ function handleKeyDown(e: KeyboardEvent) {
37
+ if (e.key === "Enter" && e.metaKey) {
38
+ e.preventDefault();
39
+ handleSubmit();
40
+ }
41
+ if (e.key === "Escape") {
42
+ oncancel();
43
+ }
44
+ }
45
+ </script>
46
+
47
+ {#if selectedText}
48
+ <div
49
+ data-comment-input
50
+ class="border-t border-zinc-200 dark:border-zinc-700 pt-3 pb-2"
51
+ >
52
+ <Text variant="caption" as="div" class="italic mb-2 line-clamp-2">
53
+ "{selectedText}"
54
+ </Text>
55
+ <textarea
56
+ bind:this={textareaEl}
57
+ bind:value={commentText}
58
+ placeholder={t("comment.placeholder")}
59
+ class={cn(
60
+ fontClass,
61
+ "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",
62
+ )}
63
+ rows={2}
64
+ onkeydown={handleKeyDown}
65
+ ></textarea>
66
+ <div class="flex justify-end items-center gap-3 mt-2 text-sm">
67
+ <Button variant="ghost" size="sm" onclick={oncancel}>
68
+ {t("comment.cancel")}
69
+ </Button>
70
+ <Button variant="link" size="sm" onclick={handleSubmit} title="Cmd+Enter">
71
+ {commentText.trim() ? t("comment.addNote") : t("comment.highlight")}
72
+ </Button>
73
+ </div>
74
+ </div>
75
+ {/if}
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ import { cn } from "../lib/utils";
3
+ import type { Comment } from "../schema";
4
+ import { t } from "../stores/locale.svelte";
5
+ import InlineEditor from "./InlineEditor.svelte";
6
+ import ActionLink from "./ui/ActionLink.svelte";
7
+ import Text from "./ui/Text.svelte";
8
+
9
+ interface Props {
10
+ comment: Comment;
11
+ onaction?: () => void;
12
+ onedit: (id: string, newText: string) => void;
13
+ ondelete: (id: string) => void;
14
+ onnavigate: (id: string) => void;
15
+ onstartreanchor: (id: string) => void;
16
+ }
17
+
18
+ let {
19
+ comment,
20
+ onaction,
21
+ onedit,
22
+ ondelete,
23
+ onnavigate,
24
+ onstartreanchor,
25
+ }: Props = $props();
26
+
27
+ let isEditing = $state(false);
28
+
29
+ let isUnresolved = $derived(comment.anchorConfidence === "unresolved");
30
+ let canGoTo = $derived(!isUnresolved);
31
+
32
+ function handleGoTo() {
33
+ onnavigate(comment.id);
34
+ onaction?.();
35
+ }
36
+
37
+ function handleReanchor() {
38
+ onstartreanchor(comment.id);
39
+ onaction?.();
40
+ }
41
+ </script>
42
+
43
+ <div
44
+ class={cn(
45
+ "group px-3 py-2 border-b border-zinc-100 dark:border-zinc-800 last:border-b-0",
46
+ isUnresolved && "opacity-50",
47
+ )}
48
+ >
49
+ <div class="flex items-center gap-1.5 mb-1">
50
+ <Text variant="caption" as="span" class="italic line-clamp-1">
51
+ "{comment.selectedText}"
52
+ </Text>
53
+ {#if isUnresolved}
54
+ <Text variant="caption" as="span" class="shrink-0">
55
+ · {t("commentList.unresolved")}
56
+ </Text>
57
+ {/if}
58
+ </div>
59
+
60
+ {#if isEditing}
61
+ <InlineEditor
62
+ initialText={comment.comment}
63
+ onsave={(text) => {
64
+ onedit(comment.id, text);
65
+ isEditing = false;
66
+ }}
67
+ oncancel={() => (isEditing = false)}
68
+ />
69
+ {:else}
70
+ <Text variant="body" class="line-clamp-2">
71
+ {comment.comment}
72
+ </Text>
73
+
74
+ <div
75
+ class="flex items-center text-xs text-zinc-400 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-within:opacity-100 [@media(pointer:coarse)]:opacity-100 transition-opacity gap-3 mt-1.5"
76
+ >
77
+ <ActionLink onclick={() => (isEditing = true)}>
78
+ {t("commentList.edit")}
79
+ </ActionLink>
80
+ <ActionLink onclick={() => ondelete(comment.id)}>
81
+ {t("commentList.delete")}
82
+ </ActionLink>
83
+ {#if canGoTo}
84
+ <ActionLink onclick={handleGoTo}>
85
+ {t("commentList.goTo")}
86
+ </ActionLink>
87
+ {/if}
88
+ {#if isUnresolved}
89
+ <ActionLink onclick={handleReanchor}>
90
+ {t("commentList.reanchor")}
91
+ </ActionLink>
92
+ {/if}
93
+ </div>
94
+ {/if}
95
+ </div>
@@ -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>