@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,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>
@@ -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,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>
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ import { t } from "../stores/locale.svelte";
3
+ import Button from "./ui/Button.svelte";
4
+ import Text from "./ui/Text.svelte";
5
+
6
+ interface Props {
7
+ selectionText: string;
8
+ onconfirm: () => void;
9
+ oncancel: () => void;
10
+ }
11
+
12
+ let { selectionText, onconfirm, oncancel }: Props = $props();
13
+ </script>
14
+
15
+ <div class="border-t border-zinc-200 dark:border-zinc-700 pt-2 pb-3 pl-6">
16
+ <Text variant="body" class="mb-2">
17
+ {t("reanchor.question")}
18
+ </Text>
19
+ <Text variant="caption" class="italic line-clamp-2 mb-2">
20
+ "{selectionText}"
21
+ </Text>
22
+ <div class="flex gap-3 text-sm">
23
+ <Button variant="link" size="sm" onclick={onconfirm}>
24
+ {t("reanchor.confirm")}
25
+ </Button>
26
+ <Button variant="ghost" size="sm" onclick={oncancel}>
27
+ {t("reanchor.cancel")}
28
+ </Button>
29
+ </div>
30
+ </div>