@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
package/src/cli.ts CHANGED
@@ -80,9 +80,7 @@ async function clearStaleServerLock(): Promise<void> {
80
80
  try {
81
81
  const lock = JSON.parse(content) as { pid?: number };
82
82
  pid = lock.pid;
83
- } catch {
84
- // Ignore malformed lock files and fall back to age-based cleanup.
85
- }
83
+ } catch {}
86
84
  }
87
85
 
88
86
  if (age > SERVER_LOCK_MAX_AGE_MS || (pid !== undefined && !isAlive(pid))) {
@@ -138,12 +136,10 @@ async function discoverServer(): Promise<ServerInfo | null> {
138
136
  const content = readFileSync(SERVER_INFO_PATH, "utf-8");
139
137
  const info: ServerInfo = JSON.parse(content);
140
138
 
141
- // Verify the process is alive
142
139
  if (!isAlive(info.pid)) {
143
140
  return null;
144
141
  }
145
142
 
146
- // Verify health endpoint responds
147
143
  try {
148
144
  const res = await fetch(`http://127.0.0.1:${info.port}/api/health`);
149
145
  if (!res.ok) return null;
@@ -252,7 +248,6 @@ function findReviewableFiles(dir: string): FileEntry[] {
252
248
  try {
253
249
  const entries = readdirSync(dir);
254
250
  for (const entry of entries) {
255
- // Skip hidden directories and node_modules
256
251
  if (entry.startsWith(".") || entry === "node_modules") continue;
257
252
 
258
253
  const fullPath = join(dir, entry);
@@ -338,9 +333,7 @@ async function markOnboarded(): Promise<void> {
338
333
  try {
339
334
  const content = readFileSync(SETTINGS_PATH, "utf-8");
340
335
  settings = JSON.parse(content);
341
- } catch {
342
- // No existing settings
343
- }
336
+ } catch {}
344
337
  settings.onboarded = true;
345
338
  const dir = join(os.homedir(), ".readit");
346
339
  await fs.mkdir(dir, { recursive: true });
@@ -428,7 +421,7 @@ const WELCOME_PATH = join(os.homedir(), ".readit", "welcome.md");
428
421
  program
429
422
  .name("readit")
430
423
  .description("Review Markdown documents with inline comments")
431
- .version("0.1.3");
424
+ .version("0.2.0");
432
425
 
433
426
  program
434
427
  .command("list")
@@ -462,9 +455,7 @@ program
462
455
  ` ${commentCount} comment${commentCount !== 1 ? "s" : ""}`,
463
456
  );
464
457
  console.log();
465
- } catch {
466
- // Skip unreadable files
467
- }
458
+ } catch {}
468
459
  }
469
460
  });
470
461
 
@@ -499,7 +490,6 @@ program
499
490
  `Selected: "${comment.selectedText.slice(0, 80)}${comment.selectedText.length > 80 ? "..." : ""}"`,
500
491
  );
501
492
  console.log(`Comment: ${comment.comment}`);
502
- console.log(`Created: ${comment.createdAt}`);
503
493
  console.log();
504
494
  }
505
495
  } catch (err) {
@@ -560,16 +550,13 @@ program
560
550
  process.exit(1);
561
551
  }
562
552
 
563
- // Snapshot previous session before startServer() overwrites server.json
564
553
  let previousPort: number | undefined;
565
554
  try {
566
555
  const info = JSON.parse(readFileSync(SERVER_INFO_PATH, "utf-8"));
567
556
  if (!isAlive(info.pid)) {
568
557
  previousPort = info.port;
569
558
  }
570
- } catch {
571
- // No previous session — will open browser normally
572
- }
559
+ } catch {}
573
560
 
574
561
  try {
575
562
  const { url, server } = await startServer({
@@ -613,12 +600,10 @@ ${fileList.join("\n")}
613
600
  open(url);
614
601
  }
615
602
 
616
- // Mark onboarding complete on first server start
617
603
  if (fileArgs.length === 0) {
618
604
  await markOnboarded();
619
605
  }
620
606
 
621
- // Graceful shutdown on Ctrl+C
622
607
  process.on("SIGINT", async () => {
623
608
  console.log("\n\nShutting down...");
624
609
  server.stop();
@@ -643,7 +628,6 @@ program
643
628
  .option("--host <address>", "Host for new server (if starting)", "127.0.0.1")
644
629
  .action(
645
630
  async (fileArgs: string[], options: { port: string; host: string }) => {
646
- // Resolve and validate files
647
631
  const resolvedFiles: { path: string }[] = [];
648
632
  for (const arg of fileArgs) {
649
633
  const inputPath = resolve(process.cwd(), arg);
@@ -716,4 +700,182 @@ ${fileList.join("\n")}
716
700
  },
717
701
  );
718
702
 
703
+ program
704
+ .command("completion")
705
+ .argument("[shell]", "Shell type (zsh, bash, fish)", "zsh")
706
+ .description("Output shell completion and integration script")
707
+ .action((shell: string) => {
708
+ const shellDir = join(import.meta.dir, "..", "shell");
709
+
710
+ switch (shell) {
711
+ case "zsh": {
712
+ // Output the full zsh integration:
713
+ // 1. _readit compdef (loaded into fpath via autoload) - handles @ prefix
714
+ // 2. readit.zsh widget (accept-line bracket stripping + syntax highlighting)
715
+ const widgetPath = join(shellDir, "readit.zsh");
716
+ const compPath = join(shellDir, "_readit");
717
+
718
+ if (!existsSync(widgetPath) || !existsSync(compPath)) {
719
+ console.log(generateInlineZshCompletion());
720
+ return;
721
+ }
722
+
723
+ // Wrap the compdef in an autoload function so eval works cleanly
724
+ const compdefContent = readFileSync(compPath, "utf-8");
725
+ const widgetContent = readFileSync(widgetPath, "utf-8");
726
+
727
+ const lines: string[] = [];
728
+ lines.push("# readit shell integration for zsh");
729
+ lines.push('# Add to your .zshrc: eval "$(readit completion zsh)"');
730
+ lines.push("");
731
+ lines.push("# ── _readit compdef (autoloaded) ──");
732
+ lines.push(
733
+ "# This handles: subcommand/option completion + @ file autocomplete",
734
+ );
735
+ lines.push(
736
+ "# Renders [file.md] in a native multi-column grid via compadd",
737
+ );
738
+ lines.push("");
739
+ // Replace #compdef with autoload -Uz _readit; _readit() { ... }
740
+ lines.push(
741
+ compdefContent
742
+ .replace(
743
+ /^#compdef readit\n/,
744
+ "autoload -Uz _readit\n_readit() {\n",
745
+ )
746
+ .replace(/\n_readit "\$@"\n?$/, "\n}\n"),
747
+ );
748
+ lines.push("");
749
+ lines.push("# ── readit.zsh (sourced) ──");
750
+ lines.push(
751
+ "# This handles: @[...] bracket stripping on Enter + syntax highlighting",
752
+ );
753
+ lines.push("");
754
+ // Strip the shebang and guard from the widget since it's being eval'd
755
+ lines.push(
756
+ widgetContent
757
+ .replace(/^#!/, "#")
758
+ .replace(/\n\(\( \$\+ functions\[_readit_plugin_loaded\] \)\)/, ""),
759
+ );
760
+ console.log(lines.join("\n"));
761
+ break;
762
+ }
763
+ case "bash":
764
+ console.log(generateBashCompletion());
765
+ break;
766
+ case "fish":
767
+ console.log(generateFishCompletion());
768
+ break;
769
+ default:
770
+ console.error(`error: unsupported shell: ${shell}`);
771
+ console.error("Supported shells: zsh, bash, fish");
772
+ process.exit(1);
773
+ }
774
+ });
775
+
719
776
  program.parse();
777
+
778
+ function generateInlineZshCompletion(): string {
779
+ return `
780
+ #compdef readit
781
+
782
+ _readit_markdown_files() {
783
+ local -a files
784
+ files=( \${(f)"$(find . -type f \\( -name '*.md' -o -name '*.markdown' \\) -not -path '*/\\.*' -not -path '*/node_modules/*' 2>/dev/null | sed 's|^\\./||')"} )
785
+ _describe -t files 'markdown files' files
786
+ }
787
+
788
+ _readit() {
789
+ local context state state_descr line
790
+ typeset -A opt_args
791
+ _arguments -C '1:command:->cmd_or_files' '*::arg:->args'
792
+ case "$state" in
793
+ cmd_or_files)
794
+ local -a commands=(
795
+ 'open:Add files to running server'
796
+ 'list:List files with comments'
797
+ 'show:Show comments for a file'
798
+ 'completion:Output shell completion script'
799
+ )
800
+ _alternative 'commands:command:compadd -a commands' 'files:markdown file:_readit_markdown_files'
801
+ ;;
802
+ args)
803
+ case "\${line[1]}" in
804
+ open) _arguments '*:file:_readit_markdown_files' ;;
805
+ show) _arguments '1:file:_files -g "*.md *.markdown"' ;;
806
+ *) _arguments '*:file:_readit_markdown_files' ;;
807
+ esac
808
+ ;;
809
+ esac
810
+ }
811
+ _readit "$@"
812
+ `.trim();
813
+ }
814
+
815
+ function generateBashCompletion(): string {
816
+ return `
817
+ # readit bash completion
818
+ # Add to .bashrc: eval "$(readit completion bash)"
819
+
820
+ _readit_completions() {
821
+ local cur prev commands
822
+ COMPREPLY=()
823
+ cur="\${COMP_WORDS[COMP_CWORD]}"
824
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
825
+ commands="open list show completion"
826
+
827
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
828
+ COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
829
+ # Also complete markdown files
830
+ local files=$(find . -type f \\( -name '*.md' -o -name '*.markdown' \\) \\
831
+ -not -path '*/.*' -not -path '*/node_modules/*' 2>/dev/null | sed 's|^\\./||')
832
+ COMPREPLY+=( $(compgen -W "\${files}" -- "\${cur}") )
833
+ return 0
834
+ fi
835
+
836
+ case "\${COMP_WORDS[1]}" in
837
+ open|show)
838
+ local files=$(find . -type f \\( -name '*.md' -o -name '*.markdown' \\) \\
839
+ -not -path '*/.*' -not -path '*/node_modules/*' 2>/dev/null | sed 's|^\\./||')
840
+ COMPREPLY=( $(compgen -W "\${files}" -- "\${cur}") )
841
+ ;;
842
+ completion)
843
+ COMPREPLY=( $(compgen -W "zsh bash fish" -- "\${cur}") )
844
+ ;;
845
+ esac
846
+ return 0
847
+ }
848
+
849
+ complete -F _readit_completions readit
850
+ `.trim();
851
+ }
852
+
853
+ function generateFishCompletion(): string {
854
+ return `
855
+ # readit fish completion
856
+ # Add to config.fish: readit completion fish | source
857
+
858
+ # Disable file completions by default
859
+ complete -c readit -f
860
+
861
+ # Subcommands
862
+ complete -c readit -n '__fish_use_subcommand' -a 'open' -d 'Add files to running server'
863
+ complete -c readit -n '__fish_use_subcommand' -a 'list' -d 'List files with comments'
864
+ complete -c readit -n '__fish_use_subcommand' -a 'show' -d 'Show comments for a file'
865
+ complete -c readit -n '__fish_use_subcommand' -a 'completion' -d 'Output shell completion script'
866
+
867
+ # Options
868
+ complete -c readit -s p -l port -d 'Port to run server on'
869
+ complete -c readit -l host -d 'Host address to bind to'
870
+ complete -c readit -l no-open -d "Don't automatically open browser"
871
+ complete -c readit -l clean -d 'Clear existing comments'
872
+
873
+ # File arguments for default command and open
874
+ complete -c readit -n '__fish_use_subcommand' -F -a '(find . -type f \\( -name "*.md" -o -name "*.markdown" \\) -not -path "*/.*" -not -path "*/node_modules/*" 2>/dev/null | sed "s|^\\./||")'
875
+ complete -c readit -n '__fish_seen_subcommand_from open' -F -a '(find . -type f \\( -name "*.md" -o -name "*.markdown" \\) -not -path "*/.*" -not -path "*/node_modules/*" 2>/dev/null | sed "s|^\\./||")'
876
+ complete -c readit -n '__fish_seen_subcommand_from show' -F -a '(find . -type f \\( -name "*.md" -o -name "*.markdown" \\) -not -path "*/.*" -not -path "*/node_modules/*" 2>/dev/null | sed "s|^\\./||")'
877
+
878
+ # Shell completions for completion subcommand
879
+ complete -c readit -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish'
880
+ `.trim();
881
+ }
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ import {
3
+ ClipboardCopy,
4
+ FileDown,
5
+ FileText,
6
+ MoreHorizontal,
7
+ RefreshCw,
8
+ Settings,
9
+ } from "lucide-svelte";
10
+ import { t } from "../stores/locale.svelte";
11
+ import RawModal from "./RawModal.svelte";
12
+ import SettingsModal from "./SettingsModal.svelte";
13
+ import Button from "./ui/Button.svelte";
14
+ import DropdownMenu from "./ui/DropdownMenu.svelte";
15
+ import DropdownMenuItem from "./ui/DropdownMenuItem.svelte";
16
+ import DropdownMenuSeparator from "./ui/DropdownMenuSeparator.svelte";
17
+
18
+ interface Props {
19
+ commentCount: number;
20
+ oncopyall: () => void;
21
+ onexportjson: () => void;
22
+ onreload: () => void;
23
+ }
24
+
25
+ let { commentCount, oncopyall, onexportjson, onreload }: Props = $props();
26
+
27
+ let menuOpen = $state(false);
28
+ let rawModalOpen = $state(false);
29
+ let settingsOpen = $state(false);
30
+ </script>
31
+
32
+ <DropdownMenu bind:open={menuOpen} align="end" contentClass="min-w-[160px]">
33
+ {#snippet trigger()}
34
+ <Button
35
+ variant="ghost"
36
+ size="icon"
37
+ class="size-7"
38
+ title={t("actions.ariaLabel")}
39
+ >
40
+ <MoreHorizontal class="w-4 h-4" />
41
+ </Button>
42
+ {/snippet}
43
+
44
+ <DropdownMenuItem
45
+ onselect={() => {
46
+ settingsOpen = true;
47
+ menuOpen = false;
48
+ }}
49
+ >
50
+ <Settings />
51
+ {t("actions.settings")}
52
+ </DropdownMenuItem>
53
+ <DropdownMenuSeparator />
54
+ <DropdownMenuItem
55
+ onselect={() => {
56
+ onreload();
57
+ menuOpen = false;
58
+ }}
59
+ >
60
+ <RefreshCw />
61
+ {t("actions.reload")}
62
+ </DropdownMenuItem>
63
+ {#if commentCount > 0}
64
+ <DropdownMenuItem
65
+ onselect={() => {
66
+ oncopyall();
67
+ menuOpen = false;
68
+ }}
69
+ >
70
+ <ClipboardCopy />
71
+ {t("actions.copyAll")}
72
+ </DropdownMenuItem>
73
+ <DropdownMenuItem
74
+ onselect={() => {
75
+ onexportjson();
76
+ menuOpen = false;
77
+ }}
78
+ >
79
+ <FileDown />
80
+ {t("actions.exportJson")}
81
+ </DropdownMenuItem>
82
+ <DropdownMenuItem
83
+ onselect={() => {
84
+ rawModalOpen = true;
85
+ menuOpen = false;
86
+ }}
87
+ >
88
+ <FileText />
89
+ {t("actions.viewRaw")}
90
+ </DropdownMenuItem>
91
+ {/if}
92
+ </DropdownMenu>
93
+
94
+ <RawModal bind:open={rawModalOpen} onclose={() => (rawModalOpen = false)} />
95
+ <SettingsModal bind:open={settingsOpen} onclose={() => (settingsOpen = false)} />
@@ -0,0 +1,67 @@
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 CommentManager from "./CommentManager.svelte";
6
+ import DropdownMenu from "./ui/DropdownMenu.svelte";
7
+
8
+ interface Props {
9
+ comments: Comment[];
10
+ fileName: string;
11
+ onedit: (id: string, newText: string) => void;
12
+ ondelete: (id: string) => void;
13
+ ondeleteall: () => void;
14
+ onnavigate: (id: string) => void;
15
+ onstartreanchor: (id: string) => void;
16
+ }
17
+
18
+ let {
19
+ comments,
20
+ fileName,
21
+ onedit,
22
+ ondelete,
23
+ ondeleteall,
24
+ onnavigate,
25
+ onstartreanchor,
26
+ }: Props = $props();
27
+
28
+ let commentsOpen = $state(false);
29
+ let commentCount = $derived(comments.length);
30
+ </script>
31
+
32
+ {#if commentCount > 0}
33
+ <DropdownMenu
34
+ bind:open={commentsOpen}
35
+ align="end"
36
+ contentClass="w-80 max-h-96 overflow-hidden p-0"
37
+ >
38
+ {#snippet trigger()}
39
+ <button
40
+ type="button"
41
+ class={cn(
42
+ "inline-flex items-center gap-1 text-xs tabular-nums select-none transition-colors",
43
+ commentsOpen
44
+ ? "text-zinc-600"
45
+ : "text-zinc-400 hover:text-zinc-600",
46
+ )}
47
+ title={commentCount === 1
48
+ ? t("commentBadge.title", { count: commentCount })
49
+ : t("commentBadge.titlePlural", { count: commentCount })}
50
+ >
51
+ <span class="text-zinc-300">·</span>
52
+ {commentCount}
53
+ </button>
54
+ {/snippet}
55
+
56
+ <CommentManager
57
+ {comments}
58
+ {fileName}
59
+ onclose={() => (commentsOpen = false)}
60
+ {onedit}
61
+ {ondelete}
62
+ {ondeleteall}
63
+ {onnavigate}
64
+ {onstartreanchor}
65
+ />
66
+ </DropdownMenu>
67
+ {/if}
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import { AlertCircle, X } from "lucide-svelte";
3
+
4
+ interface Props {
5
+ error: string | null;
6
+ ondismiss: () => void;
7
+ }
8
+
9
+ let { error, ondismiss }: Props = $props();
10
+ </script>
11
+
12
+ {#if error}
13
+ <div
14
+ role="alert"
15
+ aria-live="polite"
16
+ class="fixed top-24 left-1/2 -translate-x-1/2 z-40 w-full max-w-3xl px-4 pointer-events-none"
17
+ >
18
+ <div
19
+ class="flex items-start gap-3 px-4 py-2 text-sm bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-900 text-red-900 dark:text-red-200 rounded-lg shadow-lg pointer-events-auto"
20
+ >
21
+ <AlertCircle class="size-4 shrink-0 mt-0.5" />
22
+ <p class="flex-1 select-text break-words">{error}</p>
23
+ <button
24
+ type="button"
25
+ onclick={ondismiss}
26
+ aria-label="Dismiss"
27
+ class="shrink-0 rounded p-1 -mr-1 hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors"
28
+ >
29
+ <X class="size-4" />
30
+ </button>
31
+ </div>
32
+ </div>
33
+ {/if}
@@ -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>