@peaske7/readit 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +0 -3
  2. package/biome.json +1 -1
  3. package/bun.lock +43 -185
  4. package/docs/perf-baseline.md +75 -0
  5. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  6. package/e2e/perf/add-comment.spec.ts +118 -0
  7. package/e2e/perf/fixtures/generate.ts +331 -0
  8. package/e2e/perf/initial-load.spec.ts +49 -0
  9. package/e2e/perf/perf.setup.ts +23 -0
  10. package/e2e/perf/perf.teardown.ts +9 -0
  11. package/e2e/perf/scroll.spec.ts +39 -0
  12. package/e2e/perf/tab-switch.spec.ts +69 -0
  13. package/e2e/perf/text-selection.spec.ts +119 -0
  14. package/e2e/perf/utils/metrics.ts +286 -0
  15. package/e2e/perf/utils/perf-cli.ts +86 -0
  16. package/package.json +9 -18
  17. package/playwright.config.ts +12 -0
  18. package/src/App.tsx +124 -172
  19. package/src/{cli/index.ts → cli.ts} +37 -53
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +77 -106
  22. package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
  23. package/src/components/Header.tsx +9 -20
  24. package/src/components/InlineEditor.tsx +5 -5
  25. package/src/components/MarginNote.tsx +71 -93
  26. package/src/components/MarginNotes.tsx +7 -34
  27. package/src/components/RawModal.tsx +9 -8
  28. package/src/components/ReanchorConfirm.tsx +2 -2
  29. package/src/components/SettingsModal.tsx +11 -89
  30. package/src/components/TabBar.tsx +4 -4
  31. package/src/components/TableOfContents.tsx +5 -5
  32. package/src/components/comments/CommentInput.tsx +7 -35
  33. package/src/components/comments/CommentListItem.tsx +9 -11
  34. package/src/components/comments/CommentManager.tsx +53 -37
  35. package/src/components/comments/CommentNav.tsx +14 -14
  36. package/src/components/ui/ActionLink.tsx +14 -18
  37. package/src/components/ui/Button.tsx +42 -43
  38. package/src/components/ui/Dialog.tsx +73 -113
  39. package/src/components/ui/DropdownMenu.tsx +113 -69
  40. package/src/components/ui/Text.tsx +30 -37
  41. package/src/contexts/CommentContext.tsx +75 -106
  42. package/src/contexts/LocaleContext.tsx +45 -4
  43. package/src/contexts/PositionsContext.tsx +16 -0
  44. package/src/contexts/SettingsContext.tsx +133 -0
  45. package/src/hooks/useClickOutside.ts +0 -4
  46. package/src/hooks/useCommentNavigation.ts +6 -29
  47. package/src/hooks/useComments.ts +6 -18
  48. package/src/hooks/useDocument.ts +35 -34
  49. package/src/hooks/useHeadings.test.ts +8 -50
  50. package/src/hooks/useHeadings.ts +5 -88
  51. package/src/hooks/useScrollSpy.ts +10 -14
  52. package/src/hooks/useTextSelection.ts +1 -38
  53. package/src/lib/__fixtures__/bench-data.ts +1 -41
  54. package/src/lib/anchor.bench.ts +57 -67
  55. package/src/lib/anchor.test.ts +5 -1
  56. package/src/lib/anchor.ts +13 -93
  57. package/src/lib/comment-storage.test.ts +4 -4
  58. package/src/lib/comment-storage.ts +2 -46
  59. package/src/lib/export.ts +7 -13
  60. package/src/lib/highlight/core.test.ts +1 -1
  61. package/src/lib/highlight/dom.ts +5 -68
  62. package/src/lib/highlight/highlighter.ts +102 -262
  63. package/src/lib/highlight/resolver.ts +112 -0
  64. package/src/lib/highlight/types.ts +0 -35
  65. package/src/lib/highlight/worker.ts +45 -0
  66. package/src/lib/i18n/en.ts +1 -50
  67. package/src/lib/i18n/ja.ts +1 -50
  68. package/src/lib/i18n/types.ts +1 -49
  69. package/src/lib/margin-layout.ts +5 -27
  70. package/src/lib/positions.ts +150 -0
  71. package/src/lib/utils.ts +2 -19
  72. package/src/schema.ts +81 -0
  73. package/src/{server/index.ts → server.ts} +74 -74
  74. package/src/{store/index.ts → store.ts} +14 -46
  75. package/vite.config.ts +8 -0
  76. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  77. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  78. package/src/components/DocumentViewer/index.ts +0 -1
  79. package/src/components/FloatingTOC.tsx +0 -61
  80. package/src/components/ShortcutCapture.tsx +0 -48
  81. package/src/components/ShortcutList.tsx +0 -198
  82. package/src/components/comments/CommentMinimap.tsx +0 -62
  83. package/src/components/ui/ActionBar.tsx +0 -16
  84. package/src/components/ui/SeparatorDot.tsx +0 -9
  85. package/src/contexts/LayoutContext.tsx +0 -88
  86. package/src/hooks/useClipboard.ts +0 -82
  87. package/src/hooks/useEditorScheme.ts +0 -51
  88. package/src/hooks/useFontPreference.ts +0 -59
  89. package/src/hooks/useKeybindings.ts +0 -108
  90. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  91. package/src/hooks/useLayoutMode.ts +0 -44
  92. package/src/hooks/useLocalePreference.ts +0 -42
  93. package/src/hooks/useReanchorMode.ts +0 -33
  94. package/src/hooks/useScrollMetrics.ts +0 -56
  95. package/src/hooks/useThemePreference.ts +0 -66
  96. package/src/lib/comment-storage.bench.ts +0 -63
  97. package/src/lib/context.bench.ts +0 -41
  98. package/src/lib/context.test.ts +0 -224
  99. package/src/lib/context.ts +0 -193
  100. package/src/lib/editor-links.ts +0 -59
  101. package/src/lib/export.bench.ts +0 -35
  102. package/src/lib/highlight/colors.ts +0 -37
  103. package/src/lib/highlight/core.ts +0 -54
  104. package/src/lib/highlight/index.ts +0 -23
  105. package/src/lib/highlight/script-builder.ts +0 -485
  106. package/src/lib/html-processor.test.tsx +0 -170
  107. package/src/lib/html-processor.tsx +0 -95
  108. package/src/lib/i18n/completeness.test.ts +0 -51
  109. package/src/lib/i18n/translations.test.ts +0 -39
  110. package/src/lib/layout-constants.ts +0 -12
  111. package/src/lib/margin-layout.bench.ts +0 -28
  112. package/src/lib/scroll.test.ts +0 -118
  113. package/src/lib/scroll.ts +0 -47
  114. package/src/lib/shortcut-registry.test.ts +0 -173
  115. package/src/lib/shortcut-registry.ts +0 -209
  116. package/src/lib/utils.test.ts +0 -110
  117. package/src/store/index.test.ts +0 -242
  118. package/src/types/index.ts +0 -127
@@ -1,114 +1,158 @@
1
- import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
1
+ import { createContext, use, useCallback, useRef, useState } from "react";
2
+ import { useClickOutside } from "../../hooks/useClickOutside";
2
3
  import { cn } from "../../lib/utils";
3
- import { textVariants } from "./Text";
4
+
5
+ interface DropdownState {
6
+ open: boolean;
7
+ setOpen: (open: boolean) => void;
8
+ }
9
+
10
+ const DropdownContext = createContext<DropdownState>({
11
+ open: false,
12
+ setOpen: () => {},
13
+ });
4
14
 
5
15
  function DropdownMenu({
6
- ...props
7
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
8
- return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
16
+ open: controlledOpen,
17
+ onOpenChange,
18
+ children,
19
+ }: {
20
+ open?: boolean;
21
+ onOpenChange?: (open: boolean) => void;
22
+ children: React.ReactNode;
23
+ }) {
24
+ const [internalOpen, setInternalOpen] = useState(false);
25
+ const isControlled = controlledOpen !== undefined;
26
+ const open = isControlled ? controlledOpen : internalOpen;
27
+ const setOpen = useCallback(
28
+ (v: boolean) => {
29
+ if (!isControlled) setInternalOpen(v);
30
+ onOpenChange?.(v);
31
+ },
32
+ [isControlled, onOpenChange],
33
+ );
34
+
35
+ const ref = useRef<HTMLDivElement>(null);
36
+ useClickOutside(ref, () => setOpen(false), open);
37
+
38
+ return (
39
+ <DropdownContext value={{ open, setOpen }}>
40
+ <div ref={ref} className="relative inline-block">
41
+ {children}
42
+ </div>
43
+ </DropdownContext>
44
+ );
9
45
  }
10
46
 
11
47
  function DropdownMenuTrigger({
48
+ asChild,
49
+ children,
12
50
  ...props
13
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
51
+ }: {
52
+ asChild?: boolean;
53
+ children: React.ReactNode;
54
+ } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
55
+ const { open, setOpen } = use(DropdownContext);
56
+
57
+ if (
58
+ asChild &&
59
+ children &&
60
+ typeof children === "object" &&
61
+ "props" in children
62
+ ) {
63
+ const child = children as React.ReactElement<Record<string, unknown>>;
64
+ return (
65
+ <child.type
66
+ {...child.props}
67
+ onClick={(e: React.MouseEvent) => {
68
+ setOpen(!open);
69
+ if (typeof child.props.onClick === "function") child.props.onClick(e);
70
+ }}
71
+ />
72
+ );
73
+ }
74
+
14
75
  return (
15
- <DropdownMenuPrimitive.Trigger
16
- data-slot="dropdown-menu-trigger"
17
- {...props}
18
- />
76
+ <button type="button" onClick={() => setOpen(!open)} {...props}>
77
+ {children}
78
+ </button>
19
79
  );
20
80
  }
21
81
 
22
82
  function DropdownMenuContent({
23
83
  className,
24
- sideOffset = 4,
25
- ...props
26
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
27
- return (
28
- <DropdownMenuPrimitive.Portal>
29
- <DropdownMenuPrimitive.Content
30
- data-slot="dropdown-menu-content"
31
- sideOffset={sideOffset}
32
- className={cn(
33
- "z-50 min-w-[8rem] overflow-hidden rounded-xl py-1",
34
- "bg-white/95 dark:bg-zinc-900/95 backdrop-blur-sm shadow-lg border border-zinc-200/40 dark:border-zinc-700/40",
35
- "animate-in",
36
- className,
37
- )}
38
- {...props}
39
- />
40
- </DropdownMenuPrimitive.Portal>
41
- );
42
- }
84
+ align = "start",
85
+ children,
86
+ }: {
87
+ className?: string;
88
+ align?: "start" | "end";
89
+ children: React.ReactNode;
90
+ }) {
91
+ const { open } = use(DropdownContext);
92
+ if (!open) return null;
43
93
 
44
- function DropdownMenuGroup({
45
- ...props
46
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
47
94
  return (
48
- <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
95
+ <div
96
+ className={cn(
97
+ "absolute top-full mt-1 z-50 min-w-[8rem] overflow-hidden rounded-xl py-1",
98
+ "bg-white/95 dark:bg-zinc-900/95 backdrop-blur-sm shadow-lg border border-zinc-200/40 dark:border-zinc-700/40",
99
+ align === "end" ? "right-0" : "left-0",
100
+ className,
101
+ )}
102
+ >
103
+ {children}
104
+ </div>
49
105
  );
50
106
  }
51
107
 
52
108
  function DropdownMenuItem({
53
109
  className,
54
110
  variant = "default",
111
+ onSelect,
112
+ children,
55
113
  ...props
56
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
114
+ }: {
115
+ className?: string;
57
116
  variant?: "default" | "destructive";
117
+ onSelect?: () => void;
118
+ children: React.ReactNode;
119
+ title?: string;
58
120
  }) {
121
+ const { setOpen } = use(DropdownContext);
122
+
59
123
  return (
60
- <DropdownMenuPrimitive.Item
61
- data-slot="dropdown-menu-item"
62
- data-variant={variant}
124
+ <button
125
+ type="button"
63
126
  className={cn(
64
127
  "w-full px-3 py-1.5 text-left text-sm outline-none select-none transition-colors duration-150 flex items-center gap-2 cursor-default",
65
- "data-[variant=default]:text-zinc-600 dark:data-[variant=default]:text-zinc-400 data-[variant=default]:focus:bg-zinc-50 dark:data-[variant=default]:focus:bg-zinc-800 data-[variant=default]:focus:text-zinc-900 dark:data-[variant=default]:focus:text-zinc-100",
66
- "data-[variant=destructive]:text-red-600 dark:data-[variant=destructive]:text-red-400 data-[variant=destructive]:focus:bg-red-50 dark:data-[variant=destructive]:focus:bg-red-950 data-[variant=destructive]:focus:text-red-700 dark:data-[variant=destructive]:focus:text-red-300",
67
- "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
128
+ variant === "default" &&
129
+ "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100",
130
+ variant === "destructive" &&
131
+ "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 hover:text-red-700 dark:hover:text-red-300",
68
132
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
69
133
  className,
70
134
  )}
135
+ onClick={() => {
136
+ onSelect?.();
137
+ setOpen(false);
138
+ }}
71
139
  {...props}
72
- />
140
+ >
141
+ {children}
142
+ </button>
73
143
  );
74
144
  }
75
145
 
76
- function DropdownMenuSeparator({
77
- className,
78
- ...props
79
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
146
+ function DropdownMenuSeparator({ className }: { className?: string }) {
80
147
  return (
81
- <DropdownMenuPrimitive.Separator
82
- data-slot="dropdown-menu-separator"
83
- className={cn("my-1 h-px bg-zinc-100 dark:bg-zinc-800", className)}
84
- {...props}
85
- />
86
- );
87
- }
88
-
89
- function DropdownMenuLabel({
90
- className,
91
- ...props
92
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Label>) {
93
- return (
94
- <DropdownMenuPrimitive.Label
95
- data-slot="dropdown-menu-label"
96
- className={cn(
97
- "px-3 py-1.5 font-medium",
98
- textVariants({ variant: "caption" }),
99
- className,
100
- )}
101
- {...props}
102
- />
148
+ <div className={cn("my-1 h-px bg-zinc-100 dark:bg-zinc-800", className)} />
103
149
  );
104
150
  }
105
151
 
106
152
  export {
107
153
  DropdownMenu,
108
154
  DropdownMenuContent,
109
- DropdownMenuGroup,
110
155
  DropdownMenuItem,
111
- DropdownMenuLabel,
112
156
  DropdownMenuSeparator,
113
157
  DropdownMenuTrigger,
114
158
  };
@@ -1,54 +1,47 @@
1
- import { Slot } from "@radix-ui/react-slot";
2
- import { cva, type VariantProps } from "class-variance-authority";
3
1
  import { use } from "react";
4
- import { LayoutContext } from "../../contexts/LayoutContext";
2
+ import { SettingsContext } from "../../contexts/SettingsContext";
5
3
  import { cn } from "../../lib/utils";
6
- import { FontFamilies } from "../../types";
4
+ import { FontFamilies } from "../../schema";
7
5
 
8
- const textVariants = cva("", {
9
- variants: {
10
- variant: {
11
- title:
12
- "text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-100",
13
- section: "text-sm font-medium text-zinc-900 dark:text-zinc-100",
14
- subsection: "text-xs font-medium text-zinc-700 dark:text-zinc-300",
15
- overline:
16
- "text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider",
17
- body: "text-sm text-zinc-600 dark:text-zinc-400",
18
- caption: "text-xs text-zinc-500 dark:text-zinc-400",
19
- micro: "text-[10px] text-zinc-400 dark:text-zinc-500",
20
- },
21
- },
22
- defaultVariants: {
23
- variant: "body",
24
- },
25
- });
6
+ const variantStyles = {
7
+ title:
8
+ "text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-100",
9
+ section: "text-sm font-medium text-zinc-900 dark:text-zinc-100",
10
+ subsection: "text-xs font-medium text-zinc-700 dark:text-zinc-300",
11
+ overline:
12
+ "text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider",
13
+ body: "text-sm text-zinc-600 dark:text-zinc-400",
14
+ caption: "text-xs text-zinc-500 dark:text-zinc-400",
15
+ micro: "text-[10px] text-zinc-400 dark:text-zinc-500",
16
+ } as const;
17
+
18
+ type TextVariant = keyof typeof variantStyles;
19
+
20
+ interface TextProps extends React.HTMLAttributes<HTMLElement> {
21
+ variant?: TextVariant;
22
+ as?: "p" | "span" | "div" | "h1" | "h2" | "h3" | "label" | "pre";
23
+ }
26
24
 
27
25
  function Text({
28
26
  className,
29
- variant,
30
- asChild = false,
27
+ variant = "body",
28
+ as: Tag = "p",
31
29
  ...props
32
- }: React.ComponentProps<"p"> &
33
- VariantProps<typeof textVariants> & {
34
- asChild?: boolean;
35
- }) {
36
- const layout = use(LayoutContext);
37
- const fontClass = layout
38
- ? layout.fontFamily === FontFamilies.SANS_SERIF
30
+ }: TextProps) {
31
+ const settings = use(SettingsContext);
32
+ const fontClass = settings
33
+ ? settings.fontFamily === FontFamilies.SANS_SERIF
39
34
  ? "font-sans"
40
35
  : "font-serif"
41
36
  : undefined;
42
37
 
43
- const Comp = asChild ? Slot : "p";
44
-
45
38
  return (
46
- <Comp
47
- data-slot="text"
48
- className={cn(fontClass, textVariants({ variant }), className)}
39
+ <Tag
40
+ className={cn(fontClass, variantStyles[variant], className)}
49
41
  {...props}
50
42
  />
51
43
  );
52
44
  }
53
45
 
54
- export { Text, textVariants };
46
+ export type { TextVariant };
47
+ export { Text, variantStyles };
@@ -9,18 +9,14 @@ import {
9
9
  import { toast } from "sonner";
10
10
  import { useCommentNavigation } from "../hooks/useCommentNavigation";
11
11
  import { useComments } from "../hooks/useComments";
12
- import { useReanchorMode } from "../hooks/useReanchorMode";
13
- import { extractContext, formatForLLM } from "../lib/context";
14
- import { generatePrompt } from "../lib/export";
12
+ import { formatComment } from "../lib/export";
15
13
  import { truncate } from "../lib/utils";
16
- import { useAppStore } from "../store";
17
- import type { Comment, DocumentType } from "../types";
14
+ import type { Comment } from "../schema";
15
+ import { appStore, useAppStore } from "../store";
18
16
  import { useLocale } from "./LocaleContext";
19
17
 
20
- interface CommentContextValue {
21
- // From useComments
22
- comments: Comment[];
23
- commentCount: number;
18
+ // Stable callbacks — never causes re-renders
19
+ interface CommentActionsValue {
24
20
  addComment: (
25
21
  selectedText: string,
26
22
  comment: string,
@@ -36,52 +32,62 @@ interface CommentContextValue {
36
32
  startOffset: number,
37
33
  endOffset: number,
38
34
  ) => void;
39
- // Derived
40
- sortedComments: Comment[];
41
- // From useCommentNavigation
42
- currentIndex: number;
43
- hoveredCommentId: string | undefined;
44
35
  setHoveredCommentId: (id: string | undefined) => void;
45
36
  navigateToComment: (commentId: string) => void;
46
37
  navigatePrevious: () => void;
47
38
  navigateNext: () => void;
48
- // From useReanchorMode
49
- reanchorTarget: { commentId: string } | null;
50
39
  startReanchor: (commentId: string) => void;
51
40
  cancelReanchor: () => void;
52
- // Copy operations
53
- copyCommentRaw: (comment: Comment) => void;
54
- copyCommentForLLM: (comment: Comment) => void;
55
- copyAllForLLM: () => void;
56
- // Scroll to highlight
41
+ copyComment: (comment: Comment) => void;
57
42
  scrollToHighlight: (commentId: string) => void;
58
43
  }
59
44
 
60
- export const CommentContext = createContext<CommentContextValue | null>(null);
45
+ const CommentActionsContext = createContext<CommentActionsValue | null>(null);
61
46
 
62
- export function useCommentContext(): CommentContextValue {
63
- const value = use(CommentContext);
47
+ export function useCommentActions(): CommentActionsValue {
48
+ const value = use(CommentActionsContext);
64
49
  if (!value) {
65
- throw new Error("useCommentContext must be used within a CommentProvider");
50
+ throw new Error("useCommentActions must be used within a CommentProvider");
66
51
  }
67
52
  return value;
68
53
  }
69
54
 
55
+ // Volatile — re-renders consumers on change
56
+ interface CommentDataValue {
57
+ comments: Comment[];
58
+ commentCount: number;
59
+ sortedComments: Comment[];
60
+ currentIndex: number;
61
+ reanchorTarget: { commentId: string } | null;
62
+ }
63
+
64
+ const CommentDataContext = createContext<CommentDataValue | null>(null);
65
+
66
+ export function useCommentData(): CommentDataValue {
67
+ const value = use(CommentDataContext);
68
+ if (!value) {
69
+ throw new Error("useCommentData must be used within a CommentProvider");
70
+ }
71
+ return value;
72
+ }
73
+
74
+ export type CommentContextValue = CommentActionsValue & CommentDataValue;
75
+
76
+ export function useCommentContext(): CommentContextValue {
77
+ return { ...useCommentActions(), ...useCommentData() };
78
+ }
79
+
80
+ export const CommentContext = CommentDataContext;
81
+
70
82
  interface CommentProviderProps {
71
83
  filePath: string;
72
84
  clean: boolean;
73
- documentContent: string;
74
- fileName: string;
75
- documentType: DocumentType;
76
85
  children: ReactNode;
77
86
  }
78
87
 
79
88
  export function CommentProvider({
80
89
  filePath,
81
90
  clean,
82
- documentContent,
83
- fileName,
84
- documentType,
85
91
  children,
86
92
  }: CommentProviderProps) {
87
93
  const {
@@ -94,136 +100,99 @@ export function CommentProvider({
94
100
  reanchorComment,
95
101
  } = useComments(filePath, { clean });
96
102
 
97
- // sortedComments from store (already sorted by setComments)
98
103
  const sortedComments = useAppStore(
99
104
  (s) => s.documents.get(filePath)?.sortedComments ?? [],
100
105
  );
101
106
 
102
107
  const {
103
108
  currentIndex,
104
- hoveredCommentId,
105
109
  setHoveredCommentId,
106
110
  navigateToComment,
107
111
  navigatePrevious,
108
112
  navigateNext,
109
113
  } = useCommentNavigation(sortedComments);
110
114
 
111
- const { reanchorTarget, startReanchor, cancelReanchor } = useReanchorMode();
115
+ const reanchorTarget = useAppStore(
116
+ (s) => s.getActiveDocumentState()?.reanchorTarget ?? null,
117
+ );
118
+ const startReanchor = useCallback((commentId: string) => {
119
+ appStore.getState().setReanchorTarget({ commentId });
120
+ }, []);
121
+ const cancelReanchor = useCallback(() => {
122
+ appStore.getState().setReanchorTarget(null);
123
+ }, []);
112
124
  const { t } = useLocale();
113
125
 
114
- // Show comments errors as toast
115
126
  useEffect(() => {
116
127
  if (commentsError) {
117
128
  toast.error(commentsError);
118
129
  }
119
130
  }, [commentsError]);
120
131
 
121
- const copyCommentRaw = useCallback(
132
+ const copyComment = useCallback(
122
133
  (comment: Comment) => {
123
- const raw = `${comment.selectedText}\n\n${comment.comment}`;
124
- navigator.clipboard.writeText(raw);
134
+ navigator.clipboard.writeText(formatComment(comment));
125
135
  toast.success(t("toast.copied", { text: truncate(comment.comment) }));
126
136
  },
127
137
  [t],
128
138
  );
129
139
 
130
- const copyCommentForLLM = useCallback(
131
- (comment: Comment) => {
132
- const context = extractContext({
133
- content: documentContent,
134
- startOffset: comment.startOffset,
135
- endOffset: comment.endOffset,
136
- });
137
- const formatted = formatForLLM({
138
- context,
139
- fileName,
140
- comment: comment.comment,
141
- });
142
-
143
- navigator.clipboard.writeText(formatted);
144
- toast.success(
145
- t("toast.copiedForLLM", { text: truncate(comment.comment) }),
146
- );
147
- },
148
- [documentContent, fileName, t],
149
- );
150
-
151
- const copyAllForLLM = useCallback(() => {
152
- const prompt = generatePrompt(comments, fileName);
153
- navigator.clipboard.writeText(prompt);
154
- toast.success(t("toast.copiedAllComments"));
155
- }, [comments, fileName, t]);
156
-
157
- const scrollToHighlight = useCallback(
158
- (commentId: string) => {
159
- if (documentType === "html") {
160
- const iframe = window.document.querySelector("iframe");
161
- iframe?.contentWindow?.postMessage(
162
- { type: "scrollToHighlight", commentId },
163
- "*",
164
- );
165
- } else {
166
- const mark = window.document.querySelector(
167
- `mark[data-comment-id="${commentId}"]`,
168
- );
169
- if (mark) {
170
- mark.scrollIntoView({ behavior: "smooth", block: "center" });
171
- }
172
- }
173
- },
174
- [documentType],
175
- );
176
-
177
- const commentCount = comments.length;
140
+ const scrollToHighlight = useCallback((commentId: string) => {
141
+ const mark = window.document.querySelector(
142
+ `mark[data-comment-id="${commentId}"]`,
143
+ );
144
+ if (mark) {
145
+ mark.scrollIntoView({ behavior: "smooth", block: "center" });
146
+ }
147
+ }, []);
178
148
 
179
- const value = useMemo<CommentContextValue>(
149
+ const actions = useMemo<CommentActionsValue>(
180
150
  () => ({
181
- comments,
182
- commentCount,
183
151
  addComment,
184
152
  editComment,
185
153
  deleteComment,
186
154
  deleteAll,
187
155
  reanchorComment,
188
- sortedComments,
189
- currentIndex,
190
- hoveredCommentId,
191
156
  setHoveredCommentId,
192
157
  navigateToComment,
193
158
  navigatePrevious,
194
159
  navigateNext,
195
- reanchorTarget,
196
160
  startReanchor,
197
161
  cancelReanchor,
198
- copyCommentRaw,
199
- copyCommentForLLM,
200
- copyAllForLLM,
162
+ copyComment,
201
163
  scrollToHighlight,
202
164
  }),
203
165
  [
204
- comments,
205
- commentCount,
206
166
  addComment,
207
167
  editComment,
208
168
  deleteComment,
209
169
  deleteAll,
210
170
  reanchorComment,
211
- sortedComments,
212
- currentIndex,
213
- hoveredCommentId,
214
171
  setHoveredCommentId,
215
172
  navigateToComment,
216
173
  navigatePrevious,
217
174
  navigateNext,
218
- reanchorTarget,
219
175
  startReanchor,
220
176
  cancelReanchor,
221
- copyCommentRaw,
222
- copyCommentForLLM,
223
- copyAllForLLM,
177
+ copyComment,
224
178
  scrollToHighlight,
225
179
  ],
226
180
  );
227
181
 
228
- return <CommentContext value={value}>{children}</CommentContext>;
182
+ const data = useMemo<CommentDataValue>(
183
+ () => ({
184
+ comments,
185
+ commentCount: comments.length,
186
+ sortedComments,
187
+ currentIndex,
188
+ reanchorTarget,
189
+ }),
190
+ [comments, sortedComments, currentIndex, reanchorTarget],
191
+ );
192
+
193
+ return (
194
+ <CommentActionsContext value={actions}>
195
+ <CommentDataContext value={data}>{children}</CommentDataContext>
196
+ </CommentActionsContext>
197
+ );
229
198
  }
@@ -1,6 +1,37 @@
1
- import { createContext, type ReactNode, use, useMemo } from "react";
2
- import { useLocalePreference } from "../hooks/useLocalePreference";
3
- import { createT, type Locale, type TranslationKey } from "../lib/i18n";
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ use,
5
+ useCallback,
6
+ useMemo,
7
+ useState,
8
+ } from "react";
9
+ import {
10
+ createT,
11
+ type Locale,
12
+ Locales,
13
+ type TranslationKey,
14
+ } from "../lib/i18n";
15
+
16
+ const STORAGE_KEY = "readit:locale";
17
+
18
+ function detectLocale(): Locale {
19
+ const browserLang = navigator.language.slice(0, 2).toLowerCase();
20
+ if (browserLang === "ja") return Locales.JA;
21
+ return Locales.EN;
22
+ }
23
+
24
+ function getStoredLocale(): Locale {
25
+ try {
26
+ const stored = localStorage.getItem(STORAGE_KEY);
27
+ if (stored === Locales.JA || stored === Locales.EN) {
28
+ return stored;
29
+ }
30
+ } catch {
31
+ // localStorage may be unavailable
32
+ }
33
+ return detectLocale();
34
+ }
4
35
 
5
36
  interface LocaleContextValue {
6
37
  locale: Locale;
@@ -23,7 +54,17 @@ interface LocaleProviderProps {
23
54
  }
24
55
 
25
56
  export function LocaleProvider({ children }: LocaleProviderProps) {
26
- const { locale, setLocale } = useLocalePreference();
57
+ const [locale, setLocaleState] = useState<Locale>(getStoredLocale);
58
+
59
+ const setLocale = useCallback((newLocale: Locale) => {
60
+ setLocaleState(newLocale);
61
+ try {
62
+ localStorage.setItem(STORAGE_KEY, newLocale);
63
+ } catch {
64
+ // localStorage may be unavailable
65
+ }
66
+ }, []);
67
+
27
68
  const t = useMemo(() => createT(locale), [locale]);
28
69
 
29
70
  const value = useMemo<LocaleContextValue>(
@@ -0,0 +1,16 @@
1
+ import { createContext, type ReactNode, use, useRef } from "react";
2
+ import { Positions } from "../lib/positions";
3
+
4
+ const Ctx = createContext<Positions | null>(null);
5
+
6
+ export function usePositions(): Positions {
7
+ const value = use(Ctx);
8
+ if (!value) throw new Error("usePositions requires PositionsProvider");
9
+ return value;
10
+ }
11
+
12
+ export function PositionsProvider({ children }: { children: ReactNode }) {
13
+ const ref = useRef<Positions | null>(null);
14
+ if (!ref.current) ref.current = new Positions();
15
+ return <Ctx value={ref.current}>{children}</Ctx>;
16
+ }