@peaske7/readit 0.1.7 → 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 +133 -178
  19. package/src/{cli/index.ts → cli.ts} +211 -107
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +78 -105
  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} +111 -81
  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,6 +1,7 @@
1
1
  import {
2
2
  type ComponentPropsWithoutRef,
3
3
  type MutableRefObject,
4
+ memo,
4
5
  useEffect,
5
6
  useMemo,
6
7
  useRef,
@@ -8,24 +9,39 @@ import {
8
9
  import Markdown from "react-markdown";
9
10
  import rehypeRaw from "rehype-raw";
10
11
  import remarkGfm from "remark-gfm";
11
- import { useLayoutContext } from "../../contexts/LayoutContext";
12
+ import { usePositions } from "../../contexts/PositionsContext";
13
+ import { useSettings } from "../../contexts/SettingsContext";
12
14
  import type { Heading } from "../../hooks/useHeadings";
13
15
  import {
14
16
  createHighlighter,
15
- type HighlightComment,
16
17
  type Highlighter,
17
- } from "../../lib/highlight";
18
+ } from "../../lib/highlight/highlighter";
19
+ import type { HighlightComment } from "../../lib/highlight/types";
18
20
  import { cn, getTextContent } from "../../lib/utils";
19
- import { useAppStore } from "../../store";
20
- import {
21
- AnchorConfidences,
22
- type Comment,
23
- type DocumentType,
24
- FontFamilies,
25
- type SelectionRange,
26
- } from "../../types";
27
- import { IframeContainer } from "./IframeContainer";
28
- import { createCodeComponent } from "./InlineCode";
21
+ import { AnchorConfidences, type Comment, FontFamilies } from "../../schema";
22
+ import { CodeBlock } from "./CodeBlock";
23
+
24
+ const REMARK_PLUGINS = [remarkGfm];
25
+ const REHYPE_PLUGINS = [rehypeRaw];
26
+
27
+ /** Memoized Markdown renderer — skips reconciliation when only comments change. */
28
+ const MemoizedMarkdown = memo(function MemoizedMarkdown({
29
+ content,
30
+ components,
31
+ }: {
32
+ content: string;
33
+ components: ComponentPropsWithoutRef<typeof Markdown>["components"];
34
+ }) {
35
+ return (
36
+ <Markdown
37
+ components={components}
38
+ remarkPlugins={REMARK_PLUGINS}
39
+ rehypePlugins={REHYPE_PLUGINS}
40
+ >
41
+ {content}
42
+ </Markdown>
43
+ );
44
+ });
29
45
 
30
46
  function createHeadingComponent(
31
47
  level: 1 | 2 | 3 | 4 | 5 | 6,
@@ -52,7 +68,6 @@ function createHeadingComponent(
52
68
  }
53
69
  }
54
70
 
55
- // Fallback: if not found (shouldn't happen), search from beginning
56
71
  if (!id) {
57
72
  for (const heading of headings) {
58
73
  if (heading.level === level && heading.text === text) {
@@ -72,49 +87,48 @@ function createHeadingComponent(
72
87
 
73
88
  interface DocumentViewerProps {
74
89
  content: string;
75
- type: DocumentType;
76
90
  comments: Comment[];
77
91
  headings: Heading[];
78
- pendingSelection?: SelectionRange;
92
+ isActive: boolean;
79
93
  onTextSelect: (
80
94
  text: string,
81
95
  startOffset: number,
82
96
  endOffset: number,
83
97
  selectionTop: number,
84
98
  ) => void;
85
- onHighlightPositionsChange?: (
86
- positions: Record<string, number>,
87
- documentPositions: Record<string, number>,
88
- pendingTop?: number,
89
- ) => void;
90
99
  onHighlightHover?: (commentId: string | undefined) => void;
91
100
  onHighlightClick?: (commentId: string) => void;
92
101
  }
93
102
 
94
103
  export function DocumentViewer({
95
104
  content,
96
- type,
97
105
  comments,
98
106
  headings,
99
- pendingSelection,
107
+ isActive,
100
108
  onTextSelect,
101
- onHighlightPositionsChange,
102
109
  onHighlightHover,
103
110
  onHighlightClick,
104
111
  }: DocumentViewerProps) {
105
- const { isFullscreen, fontFamily, editorScheme } = useLayoutContext();
106
- const workingDirectory = useAppStore((s) => s.workingDirectory);
112
+ const { fontFamily } = useSettings();
113
+ const pos = usePositions();
107
114
  const contentRef = useRef<HTMLDivElement>(null);
108
115
  const containerRef = useRef<HTMLDivElement>(null);
109
116
  const adapterRef = useRef<Highlighter | null>(null);
110
117
  const headingIndexRef = useRef(0);
111
118
 
119
+ // Attach/detach pos to DOM elements — only when tab is visible
120
+ // (getBoundingClientRect returns zero rects on display:none elements)
121
+ useEffect(() => {
122
+ if (!isActive || !contentRef.current || !containerRef.current) return;
123
+ pos.attach(contentRef.current, containerRef.current);
124
+ pos.cache();
125
+ return () => pos.detach();
126
+ }, [pos, isActive]);
127
+
112
128
  useEffect(() => {
113
- if (type !== "markdown") return;
114
129
  if (!contentRef.current || !containerRef.current) return;
115
130
 
116
131
  const adapter = createHighlighter({
117
- type: "markdown",
118
132
  root: contentRef.current,
119
133
  container: containerRef.current,
120
134
  onSelect: onTextSelect,
@@ -122,16 +136,6 @@ export function DocumentViewer({
122
136
 
123
137
  adapterRef.current = adapter;
124
138
 
125
- const unsubPositions = onHighlightPositionsChange
126
- ? adapter.onPositionsChange((pos) => {
127
- onHighlightPositionsChange(
128
- pos.positions,
129
- pos.documentPositions,
130
- pos.pendingTop,
131
- );
132
- })
133
- : () => {};
134
-
135
139
  const unsubHover = onHighlightHover
136
140
  ? adapter.onHighlightHover(onHighlightHover)
137
141
  : () => {};
@@ -141,56 +145,40 @@ export function DocumentViewer({
141
145
  : () => {};
142
146
 
143
147
  return () => {
144
- unsubPositions();
145
148
  unsubHover();
146
149
  unsubClick();
147
150
  adapter.dispose();
148
151
  adapterRef.current = null;
149
152
  };
150
- }, [
151
- type,
152
- onTextSelect,
153
- onHighlightPositionsChange,
154
- onHighlightHover,
155
- onHighlightClick,
156
- ]);
153
+ }, [onTextSelect, onHighlightHover, onHighlightClick]);
157
154
 
158
- // Double RAF: ensures React commit phase completes before DOM queries.
159
- // See: https://github.com/facebook/react/issues/20863
160
- // biome-ignore lint/correctness/useExhaustiveDependencies: must reapply highlights when content changes
155
+ // Apply highlights after React commit completes (single rAF).
156
+ // Skip when comments is empty to avoid wasted DOM walk.
157
+ // biome-ignore lint/correctness/useExhaustiveDependencies: must reapply highlights when content or components change
161
158
  useEffect(() => {
162
- if (type !== "markdown") return;
163
-
164
- let outerFrameId: number;
165
- let innerFrameId: number;
166
-
167
- outerFrameId = requestAnimationFrame(() => {
168
- innerFrameId = requestAnimationFrame(() => {
169
- const adapter = adapterRef.current;
170
- if (!adapter) return;
171
-
172
- const highlightComments: HighlightComment[] = comments
173
- .filter((c) => c.anchorConfidence !== AnchorConfidences.UNRESOLVED)
174
- .map((c) => ({
175
- id: c.id,
176
- selectedText: c.selectedText,
177
- startOffset: c.startOffset,
178
- endOffset: c.endOffset,
179
- }));
180
-
181
- adapter.applyHighlights(highlightComments);
182
- });
159
+ if (!isActive) return;
160
+ if (comments.length === 0) return;
161
+
162
+ const rafId = requestAnimationFrame(() => {
163
+ const adapter = adapterRef.current;
164
+ if (!adapter) return;
165
+
166
+ const highlightComments: HighlightComment[] = comments
167
+ .filter((c) => c.anchorConfidence !== AnchorConfidences.UNRESOLVED)
168
+ .map((c) => ({
169
+ id: c.id,
170
+ selectedText: c.selectedText,
171
+ startOffset: c.startOffset,
172
+ endOffset: c.endOffset,
173
+ }));
174
+
175
+ adapter.applyHighlights(highlightComments);
183
176
  });
184
177
 
185
- return () => {
186
- cancelAnimationFrame(outerFrameId);
187
- cancelAnimationFrame(innerFrameId);
188
- };
189
- }, [comments, content, type]);
178
+ return () => cancelAnimationFrame(rafId);
179
+ }, [comments, content, isActive, pos]);
190
180
 
191
181
  useEffect(() => {
192
- if (type !== "markdown") return;
193
-
194
182
  const handleTestSelect = (e: Event) => {
195
183
  const { text, startOffset, endOffset } = (e as CustomEvent).detail;
196
184
  onTextSelect(text, startOffset, endOffset, 0);
@@ -199,7 +187,7 @@ export function DocumentViewer({
199
187
  window.addEventListener("test:select-text", handleTestSelect);
200
188
  return () =>
201
189
  window.removeEventListener("test:select-text", handleTestSelect);
202
- }, [type, onTextSelect]);
190
+ }, [onTextSelect]);
203
191
 
204
192
  // Memoized to prevent DOM node replacement (breaks highlight persistence)
205
193
  const markdownComponents = useMemo(
@@ -210,28 +198,20 @@ export function DocumentViewer({
210
198
  h4: createHeadingComponent(4, headings, headingIndexRef),
211
199
  h5: createHeadingComponent(5, headings, headingIndexRef),
212
200
  h6: createHeadingComponent(6, headings, headingIndexRef),
213
- code: createCodeComponent(editorScheme, workingDirectory),
201
+ code: ({
202
+ children,
203
+ className,
204
+ ...props
205
+ }: ComponentPropsWithoutRef<"code">) => {
206
+ if (className || String(children).includes("\n")) {
207
+ return <CodeBlock className={className}>{children}</CodeBlock>;
208
+ }
209
+ return <code {...props}>{children}</code>;
210
+ },
214
211
  }),
215
- [headings, editorScheme, workingDirectory],
212
+ [headings],
216
213
  );
217
214
 
218
- if (type === "html") {
219
- return (
220
- <main className="flex-1 min-w-0 flex flex-col">
221
- <IframeContainer
222
- html={content}
223
- comments={comments}
224
- pendingSelection={pendingSelection}
225
- onTextSelect={onTextSelect}
226
- onHighlightPositionsChange={onHighlightPositionsChange}
227
- onHighlightHover={onHighlightHover}
228
- onHighlightClick={onHighlightClick}
229
- fontFamily={fontFamily}
230
- />
231
- </main>
232
- );
233
- }
234
-
235
215
  headingIndexRef.current = 0;
236
216
 
237
217
  return (
@@ -240,17 +220,10 @@ export function DocumentViewer({
240
220
  ref={contentRef}
241
221
  className={cn(
242
222
  "prose",
243
- isFullscreen && "prose-fullscreen",
244
223
  fontFamily === FontFamilies.SANS_SERIF ? "prose-sans" : "prose-serif",
245
224
  )}
246
225
  >
247
- <Markdown
248
- components={markdownComponents}
249
- remarkPlugins={[remarkGfm]}
250
- rehypePlugins={[rehypeRaw]}
251
- >
252
- {content}
253
- </Markdown>
226
+ <MemoizedMarkdown content={content} components={markdownComponents} />
254
227
  </article>
255
228
  </div>
256
229
  );
@@ -1,4 +1,3 @@
1
- import DOMPurify from "dompurify";
2
1
  import { useEffect, useId, useState } from "react";
3
2
 
4
3
  interface MermaidDiagramProps {
@@ -83,14 +82,14 @@ export function MermaidDiagram({ code }: MermaidDiagramProps) {
83
82
  },
84
83
  });
85
84
 
86
- const { svg: rawSvg } = await mermaid.render(`mermaid-${id}`, code);
87
- // Sanitize SVG output with DOMPurify before storing in state
88
- const sanitizedSvg = DOMPurify.sanitize(rawSvg, {
89
- USE_PROFILES: { svg: true },
90
- });
85
+ // securityLevel: "strict" prevents script injection in mermaid output
86
+ const { svg: renderedSvg } = await mermaid.render(
87
+ `mermaid-${id}`,
88
+ code,
89
+ );
91
90
 
92
91
  if (!cancelled) {
93
- setSvg(sanitizedSvg);
92
+ setSvg(renderedSvg);
94
93
  setError(null);
95
94
  }
96
95
  } catch (err) {
@@ -1,7 +1,5 @@
1
- import { useCommentContext } from "../contexts/CommentContext";
2
- import { useLayoutContext } from "../contexts/LayoutContext";
1
+ import { useCommentData } from "../contexts/CommentContext";
3
2
  import { useLocale } from "../contexts/LocaleContext";
4
- import { cn } from "../lib/utils";
5
3
  import { ActionsMenu } from "./ActionsMenu";
6
4
  import { CommentBadge } from "./comments/CommentBadge";
7
5
  import { Text } from "./ui/Text";
@@ -9,7 +7,6 @@ import { Text } from "./ui/Text";
9
7
  interface HeaderProps {
10
8
  fileName: string;
11
9
  onCopyAll: () => void;
12
- onCopyAllRaw: () => void;
13
10
  onExportJson: () => void;
14
11
  onReload: () => void;
15
12
  }
@@ -17,36 +14,29 @@ interface HeaderProps {
17
14
  export function Header({
18
15
  fileName,
19
16
  onCopyAll,
20
- onCopyAllRaw,
21
17
  onExportJson,
22
18
  onReload,
23
19
  }: HeaderProps) {
24
- const { reanchorTarget } = useCommentContext();
25
- const { isFullscreen } = useLayoutContext();
20
+ const { reanchorTarget } = useCommentData();
26
21
  const { t } = useLocale();
27
22
 
28
23
  return (
29
24
  <header className="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">
30
- <div
31
- className={cn(
32
- "px-6 py-3 flex items-center justify-between",
33
- !isFullscreen && "max-w-7xl mx-auto",
34
- )}
35
- >
25
+ <div className="px-6 py-3 flex items-center justify-between max-w-7xl mx-auto">
36
26
  <div className="flex items-center gap-3">
37
- <Text variant="title" asChild>
38
- <h1>readit</h1>
27
+ <Text variant="title" as="h1">
28
+ readit
39
29
  </Text>
40
30
  <span className="text-zinc-200 dark:text-zinc-700 font-light">—</span>
41
- <Text variant="caption" asChild>
42
- <span className="truncate max-w-[200px]">{fileName}</span>
31
+ <Text variant="caption" as="span" className="truncate max-w-[200px]">
32
+ {fileName}
43
33
  </Text>
44
34
  </div>
45
35
 
46
36
  <div className="flex items-center gap-3">
47
37
  {reanchorTarget && (
48
- <Text variant="caption" asChild>
49
- <span className="italic">{t("header.selectTextToReanchor")}</span>
38
+ <Text variant="caption" as="span" className="italic">
39
+ {t("header.selectTextToReanchor")}
50
40
  </Text>
51
41
  )}
52
42
 
@@ -54,7 +44,6 @@ export function Header({
54
44
 
55
45
  <ActionsMenu
56
46
  onCopyAll={onCopyAll}
57
- onCopyAllRaw={onCopyAllRaw}
58
47
  onExportJson={onExportJson}
59
48
  onReload={onReload}
60
49
  />
@@ -1,8 +1,8 @@
1
1
  import { use, useEffect, useRef, useState } from "react";
2
- import { LayoutContext } from "../contexts/LayoutContext";
3
2
  import { useLocale } from "../contexts/LocaleContext";
3
+ import { SettingsContext } from "../contexts/SettingsContext";
4
4
  import { cn } from "../lib/utils";
5
- import { FontFamilies } from "../types";
5
+ import { FontFamilies } from "../schema";
6
6
  import { Button } from "./ui/Button";
7
7
 
8
8
  interface InlineEditorProps {
@@ -20,10 +20,10 @@ export function InlineEditor({
20
20
  rows = 2,
21
21
  className,
22
22
  }: InlineEditorProps) {
23
- const layout = use(LayoutContext);
23
+ const settings = use(SettingsContext);
24
24
  const { t } = useLocale();
25
- const fontClass = layout
26
- ? layout.fontFamily === FontFamilies.SANS_SERIF
25
+ const fontClass = settings
26
+ ? settings.fontFamily === FontFamilies.SANS_SERIF
27
27
  ? "font-sans"
28
28
  : "font-serif"
29
29
  : undefined;
@@ -1,87 +1,77 @@
1
- import { cva } from "class-variance-authority";
2
- import { useState } from "react";
3
- import { useCommentContext } from "../contexts/CommentContext";
4
- import { useLayoutContext } from "../contexts/LayoutContext";
1
+ import { memo, useCallback, useState } from "react";
2
+ import { useCommentActions } from "../contexts/CommentContext";
5
3
  import { useLocale } from "../contexts/LocaleContext";
4
+ import { usePositions } from "../contexts/PositionsContext";
5
+ import { useSettings } from "../contexts/SettingsContext";
6
6
  import { cn } from "../lib/utils";
7
- import { type Comment, FontFamilies } from "../types";
7
+ import { type Comment, FontFamilies } from "../schema";
8
+ import { useUI } from "../store";
8
9
  import { InlineEditor } from "./InlineEditor";
9
- import { ActionBar } from "./ui/ActionBar";
10
10
  import { ActionLink } from "./ui/ActionLink";
11
- import { SeparatorDot } from "./ui/SeparatorDot";
12
11
 
13
12
  interface MarginNoteProps {
14
13
  comment: Comment;
15
- top: number;
16
14
  commentIndex?: number;
17
15
  }
18
16
 
19
- const selectedTextVariants = cva(
20
- "text-sm italic mb-1 line-clamp-1 flex items-center gap-1 transition-colors duration-150",
21
- {
22
- variants: {
23
- hovered: {
24
- true: "text-zinc-600 dark:text-zinc-400",
25
- false: "text-zinc-400 dark:text-zinc-500",
26
- },
27
- },
28
- defaultVariants: { hovered: false },
29
- },
30
- );
17
+ function selectedTextClass(hovered: boolean) {
18
+ return cn(
19
+ "text-sm italic mb-1 line-clamp-1 flex items-center gap-1 transition-colors duration-150",
20
+ hovered
21
+ ? "text-zinc-600 dark:text-zinc-400"
22
+ : "text-zinc-400 dark:text-zinc-500",
23
+ );
24
+ }
31
25
 
32
- const commentTextVariants = cva(
33
- "text-sm whitespace-pre-wrap transition-colors duration-150",
34
- {
35
- variants: {
36
- hovered: {
37
- true: "text-zinc-800 dark:text-zinc-200",
38
- false: "text-zinc-500 dark:text-zinc-400",
39
- },
40
- },
41
- defaultVariants: { hovered: false },
42
- },
43
- );
26
+ function commentTextClass(hovered: boolean) {
27
+ return cn(
28
+ "text-sm whitespace-pre-wrap transition-colors duration-150",
29
+ hovered
30
+ ? "text-zinc-800 dark:text-zinc-200"
31
+ : "text-zinc-500 dark:text-zinc-400",
32
+ );
33
+ }
44
34
 
45
- const badgeVariants = cva(
46
- "absolute -left-4 top-2 text-xs tabular-nums transition-colors duration-150",
47
- {
48
- variants: {
49
- hovered: {
50
- true: "text-zinc-600 dark:text-zinc-400",
51
- false: "text-zinc-400 dark:text-zinc-500",
52
- },
53
- },
54
- defaultVariants: { hovered: false },
55
- },
56
- );
35
+ function badgeClass(hovered: boolean) {
36
+ return cn(
37
+ "absolute -left-4 top-2 text-xs tabular-nums transition-colors duration-150",
38
+ hovered
39
+ ? "text-zinc-600 dark:text-zinc-400"
40
+ : "text-zinc-400 dark:text-zinc-500",
41
+ );
42
+ }
57
43
 
58
- export function MarginNote({
44
+ export const MarginNote = memo(function MarginNote({
59
45
  comment,
60
- top,
61
46
  commentIndex = 0,
62
47
  }: MarginNoteProps) {
63
- const { fontFamily } = useLayoutContext();
48
+ const { fontFamily } = useSettings();
64
49
  const { t } = useLocale();
65
50
  const {
66
51
  editComment,
67
52
  deleteComment,
68
- copyCommentRaw,
69
- copyCommentForLLM,
70
- hoveredCommentId,
53
+ copyComment,
71
54
  setHoveredCommentId,
72
55
  scrollToHighlight,
73
- } = useCommentContext();
56
+ } = useCommentActions();
74
57
 
75
- const isHovered = hoveredCommentId === comment.id;
58
+ const pos = usePositions();
59
+ const refCallback = useCallback(
60
+ (el: HTMLElement | null) => {
61
+ if (el) pos.register(comment.id, el);
62
+ else pos.unregister(comment.id);
63
+ },
64
+ [pos, comment.id],
65
+ );
66
+
67
+ const isHovered = useUI((s) => s.hoveredCommentId === comment.id);
76
68
  const fontClass =
77
69
  fontFamily === FontFamilies.SANS_SERIF ? "font-sans" : "font-serif";
78
70
  const [isEditing, setIsEditing] = useState(false);
79
71
 
80
72
  const hasNote = comment.comment.trim().length > 0;
81
73
 
82
- const handleCopy = () => {
83
- copyCommentRaw(comment);
84
- };
74
+ const handleCopy = () => copyComment(comment);
85
75
 
86
76
  const createdAtFormatted = new Date(comment.createdAt).toLocaleString();
87
77
 
@@ -89,30 +79,39 @@ export function MarginNote({
89
79
  if (!hasNote && !isEditing) {
90
80
  return (
91
81
  <article
82
+ ref={refCallback}
92
83
  className="absolute left-0 right-0 group"
93
- style={{ top }}
84
+ style={{
85
+ visibility: "hidden",
86
+ contentVisibility: "auto",
87
+ containIntrinsicSize: "auto 80px",
88
+ }}
94
89
  title={`Added: ${createdAtFormatted}`}
95
90
  data-comment-id={comment.id}
96
91
  onMouseEnter={() => setHoveredCommentId(comment.id)}
97
92
  onMouseLeave={() => setHoveredCommentId(undefined)}
98
93
  >
99
- <span className={badgeVariants({ hovered: isHovered })}>—</span>
94
+ <span className={badgeClass(isHovered)}>—</span>
100
95
 
101
96
  <div className="pt-2 pb-2 pl-3">
102
- <ActionBar
103
- className={cn("gap-1.5 duration-150", isHovered && "opacity-100")}
97
+ <div
98
+ className={cn(
99
+ "flex items-center text-xs text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity",
100
+ "gap-1.5 duration-150",
101
+ isHovered && "opacity-100",
102
+ )}
104
103
  >
105
104
  <ActionLink onClick={() => setIsEditing(true)}>
106
105
  {t("marginNote.addNote")}
107
106
  </ActionLink>
108
- <SeparatorDot />
107
+ <span aria-hidden="true">·</span>
109
108
  <ActionLink
110
109
  variant="destructive"
111
110
  onClick={() => deleteComment(comment.id)}
112
111
  >
113
112
  {t("marginNote.delete")}
114
113
  </ActionLink>
115
- </ActionBar>
114
+ </div>
116
115
  </div>
117
116
  </article>
118
117
  );
@@ -120,16 +119,15 @@ export function MarginNote({
120
119
 
121
120
  return (
122
121
  <article
122
+ ref={refCallback}
123
123
  className="absolute left-0 right-0 group"
124
- style={{ top }}
124
+ style={{ visibility: "hidden" }}
125
125
  title={`Added: ${createdAtFormatted}`}
126
126
  data-comment-id={comment.id}
127
127
  onMouseEnter={() => setHoveredCommentId(comment.id)}
128
128
  onMouseLeave={() => setHoveredCommentId(undefined)}
129
129
  >
130
- <span className={badgeVariants({ hovered: isHovered })}>
131
- {commentIndex + 1}
132
- </span>
130
+ <span className={badgeClass(isHovered)}>{commentIndex + 1}</span>
133
131
 
134
132
  <div
135
133
  className={cn(
@@ -138,12 +136,7 @@ export function MarginNote({
138
136
  )}
139
137
  >
140
138
  {!isEditing && (
141
- <div
142
- className={cn(
143
- fontClass,
144
- selectedTextVariants({ hovered: isHovered }),
145
- )}
146
- >
139
+ <div className={cn(fontClass, selectedTextClass(isHovered))}>
147
140
  <button
148
141
  type="button"
149
142
  onClick={() => scrollToHighlight(comment.id)}
@@ -165,43 +158,28 @@ export function MarginNote({
165
158
  />
166
159
  ) : (
167
160
  <>
168
- <p
169
- className={cn(
170
- fontClass,
171
- commentTextVariants({ hovered: isHovered }),
172
- )}
173
- >
161
+ <p className={cn(fontClass, commentTextClass(isHovered))}>
174
162
  {comment.comment}
175
163
  </p>
176
- <ActionBar className="gap-1.5 mt-2">
164
+ <div className="flex items-center text-xs text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity gap-1.5 mt-2">
177
165
  <ActionLink onClick={() => setIsEditing(true)}>
178
166
  {t("marginNote.edit")}
179
167
  </ActionLink>
180
- <SeparatorDot />
168
+ <span aria-hidden="true">·</span>
181
169
  <ActionLink
182
170
  variant="destructive"
183
171
  onClick={() => deleteComment(comment.id)}
184
172
  >
185
173
  {t("marginNote.delete")}
186
174
  </ActionLink>
187
- <SeparatorDot />
188
- <ActionLink
189
- onClick={handleCopy}
190
- title={t("marginNote.copyTitle")}
191
- >
175
+ <span aria-hidden="true">·</span>
176
+ <ActionLink onClick={handleCopy}>
192
177
  {t("marginNote.copy")}
193
178
  </ActionLink>
194
- <SeparatorDot />
195
- <ActionLink
196
- onClick={() => copyCommentForLLM(comment)}
197
- title={t("marginNote.llmTitle")}
198
- >
199
- {t("marginNote.llm")}
200
- </ActionLink>
201
- </ActionBar>
179
+ </div>
202
180
  </>
203
181
  )}
204
182
  </div>
205
183
  </article>
206
184
  );
207
- }
185
+ });