@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
@@ -13,14 +13,9 @@ export const en: Translations = {
13
13
 
14
14
  // Actions menu
15
15
  "actions.ariaLabel": "Actions menu",
16
- "actions.centered": "Centered",
17
- "actions.fullscreen": "Fullscreen",
18
16
  "actions.settings": "Settings",
19
17
  "actions.reload": "Reload",
20
- "actions.copyAllAI": "Copy All (AI)",
21
- "actions.copyAllAITitle": "Copy in prompt format for AI assistants",
22
- "actions.copyAllRaw": "Copy All (Raw)",
23
- "actions.copyAllRawTitle": "Copy as plain text",
18
+ "actions.copyAll": "Copy All",
24
19
  "actions.exportJson": "Export JSON",
25
20
  "actions.viewRaw": "View Raw",
26
21
 
@@ -29,37 +24,23 @@ export const en: Translations = {
29
24
  "settings.theme": "Theme",
30
25
  "settings.font": "Font",
31
26
  "settings.language": "Language",
32
- "settings.keyboardShortcuts": "Keyboard Shortcuts",
33
- "settings.clickToRebind": "Click a key to rebind",
34
27
  "settings.theme.system": "System",
35
28
  "settings.theme.light": "Light",
36
29
  "settings.theme.dark": "Dark",
37
30
  "settings.font.serif": "Serif",
38
31
  "settings.font.sansSerif": "Sans-serif",
39
- "settings.editor": "Editor",
40
- "settings.editor.none": "None",
41
- "settings.editor.vscode": "VS Code",
42
- "settings.editor.vscodeInsiders": "VS Code Insiders",
43
- "settings.editor.cursor": "Cursor",
44
32
 
45
33
  // Comment input
46
34
  "comment.placeholder": "Add your comment...",
47
35
  "comment.cancel": "Cancel",
48
36
  "comment.addNote": "Add Note",
49
37
  "comment.highlight": "Highlight",
50
- "comment.copyRawTitle": "Copy raw text (⌘C)",
51
- "comment.copyRawLabel": "Copy raw text",
52
- "comment.copyLLMTitle": "Copy with context for LLM (⌘⇧C)",
53
- "comment.copyLLMLabel": "Copy for LLM",
54
38
 
55
39
  // Margin note
56
40
  "marginNote.addNote": "Add note",
57
41
  "marginNote.delete": "Delete",
58
42
  "marginNote.edit": "Edit",
59
43
  "marginNote.copy": "Copy",
60
- "marginNote.copyTitle": "Copy raw text (⌘C)",
61
- "marginNote.llm": "LLM",
62
- "marginNote.llmTitle": "Copy with context for LLM (⌘⇧C)",
63
44
 
64
45
  // Comment manager
65
46
  "commentManager.unresolved": "unresolved",
@@ -99,39 +80,9 @@ export const en: Translations = {
99
80
  "rawModal.copiedToClipboard": "Copied to clipboard",
100
81
  "rawModal.failedToCopy": "Failed to copy",
101
82
 
102
- // Shortcut groups
103
- "shortcutGroup.copy": "Copy",
104
- "shortcutGroup.navigate": "Navigate",
105
- "shortcutGroup.other": "Other",
106
- "shortcuts.resetToDefaults": "Reset to defaults",
107
- "shortcuts.enableDisable": "Enable/disable shortcut",
108
- "shortcutCapture.pressKeys": "Press keys...",
109
-
110
- // Shortcut labels
111
- "shortcut.copyAll.label": "Copy All (AI)",
112
- "shortcut.copyAll.description": "Copy all comments in AI prompt format",
113
- "shortcut.copyAllRaw.label": "Copy All (Raw)",
114
- "shortcut.copyAllRaw.description": "Copy all comments as raw text",
115
- "shortcut.navigateNext.label": "Next Comment",
116
- "shortcut.navigateNext.description": "Navigate to next comment",
117
- "shortcut.navigatePrevious.label": "Previous Comment",
118
- "shortcut.navigatePrevious.description": "Navigate to previous comment",
119
- "shortcut.copySelectionRaw.label": "Copy Selection",
120
- "shortcut.copySelectionRaw.description": "Copy selected text",
121
- "shortcut.copySelectionLLM.label": "Copy Selection (LLM)",
122
- "shortcut.copySelectionLLM.description":
123
- "Copy selected text with context for LLM",
124
- "shortcut.clearSelection.label": "Clear Selection",
125
- "shortcut.clearSelection.description": "Clear text selection",
126
-
127
83
  // Toast messages
128
84
  "toast.copied": 'Copied: "{{text}}"',
129
- "toast.copiedForLLM": 'Copied for LLM: "{{text}}"',
130
85
  "toast.copiedAllComments": "Copied all comments",
131
- "toast.copiedAllRaw": "Copied all comments as raw text",
132
-
133
- // Floating TOC
134
- "floatingTOC.label": "Table of Contents",
135
86
 
136
87
  // Comment badge
137
88
  "commentBadge.title": "{{count}} comment",
@@ -13,14 +13,9 @@ export const ja: Translations = {
13
13
 
14
14
  // Actions menu
15
15
  "actions.ariaLabel": "操作メニュー",
16
- "actions.centered": "中央揃え",
17
- "actions.fullscreen": "全画面",
18
16
  "actions.settings": "設定",
19
17
  "actions.reload": "再読み込み",
20
- "actions.copyAllAI": "全てコピー (AI)",
21
- "actions.copyAllAITitle": "AIアシスタント用プロンプト形式でコピー",
22
- "actions.copyAllRaw": "全てコピー (テキスト)",
23
- "actions.copyAllRawTitle": "プレーンテキストとしてコピー",
18
+ "actions.copyAll": "全てコピー",
24
19
  "actions.exportJson": "JSONエクスポート",
25
20
  "actions.viewRaw": "生データを表示",
26
21
 
@@ -29,37 +24,23 @@ export const ja: Translations = {
29
24
  "settings.theme": "テーマ",
30
25
  "settings.font": "フォント",
31
26
  "settings.language": "言語",
32
- "settings.keyboardShortcuts": "キーボードショートカット",
33
- "settings.clickToRebind": "キーをクリックして変更",
34
27
  "settings.theme.system": "システム",
35
28
  "settings.theme.light": "ライト",
36
29
  "settings.theme.dark": "ダーク",
37
30
  "settings.font.serif": "明朝体",
38
31
  "settings.font.sansSerif": "ゴシック体",
39
- "settings.editor": "エディター",
40
- "settings.editor.none": "なし",
41
- "settings.editor.vscode": "VS Code",
42
- "settings.editor.vscodeInsiders": "VS Code Insiders",
43
- "settings.editor.cursor": "Cursor",
44
32
 
45
33
  // Comment input
46
34
  "comment.placeholder": "コメントを入力...",
47
35
  "comment.cancel": "キャンセル",
48
36
  "comment.addNote": "メモを追加",
49
37
  "comment.highlight": "ハイライト",
50
- "comment.copyRawTitle": "テキストをコピー (⌘C)",
51
- "comment.copyRawLabel": "テキストをコピー",
52
- "comment.copyLLMTitle": "LLM用にコンテキスト付きでコピー (⌘⇧C)",
53
- "comment.copyLLMLabel": "LLM用にコピー",
54
38
 
55
39
  // Margin note
56
40
  "marginNote.addNote": "メモを追加",
57
41
  "marginNote.delete": "削除",
58
42
  "marginNote.edit": "編集",
59
43
  "marginNote.copy": "コピー",
60
- "marginNote.copyTitle": "テキストをコピー (⌘C)",
61
- "marginNote.llm": "LLM",
62
- "marginNote.llmTitle": "LLM用にコンテキスト付きでコピー (⌘⇧C)",
63
44
 
64
45
  // Comment manager
65
46
  "commentManager.unresolved": "未解決",
@@ -101,39 +82,9 @@ export const ja: Translations = {
101
82
  "rawModal.copiedToClipboard": "クリップボードにコピーしました",
102
83
  "rawModal.failedToCopy": "コピーに失敗しました",
103
84
 
104
- // Shortcut groups
105
- "shortcutGroup.copy": "コピー",
106
- "shortcutGroup.navigate": "ナビゲーション",
107
- "shortcutGroup.other": "その他",
108
- "shortcuts.resetToDefaults": "初期設定に戻す",
109
- "shortcuts.enableDisable": "ショートカットの有効/無効",
110
- "shortcutCapture.pressKeys": "キーを入力...",
111
-
112
- // Shortcut labels
113
- "shortcut.copyAll.label": "全てコピー (AI)",
114
- "shortcut.copyAll.description": "全コメントをAIプロンプト形式でコピー",
115
- "shortcut.copyAllRaw.label": "全てコピー (テキスト)",
116
- "shortcut.copyAllRaw.description": "全コメントをテキストとしてコピー",
117
- "shortcut.navigateNext.label": "次のコメント",
118
- "shortcut.navigateNext.description": "次のコメントに移動",
119
- "shortcut.navigatePrevious.label": "前のコメント",
120
- "shortcut.navigatePrevious.description": "前のコメントに移動",
121
- "shortcut.copySelectionRaw.label": "選択をコピー",
122
- "shortcut.copySelectionRaw.description": "選択テキストをコピー",
123
- "shortcut.copySelectionLLM.label": "選択をコピー (LLM)",
124
- "shortcut.copySelectionLLM.description":
125
- "選択テキストをLLM用コンテキスト付きでコピー",
126
- "shortcut.clearSelection.label": "選択を解除",
127
- "shortcut.clearSelection.description": "テキスト選択を解除",
128
-
129
85
  // Toast messages
130
86
  "toast.copied": 'コピーしました: "{{text}}"',
131
- "toast.copiedForLLM": 'LLM用にコピーしました: "{{text}}"',
132
87
  "toast.copiedAllComments": "全てのコメントをコピーしました",
133
- "toast.copiedAllRaw": "全てのコメントをテキストとしてコピーしました",
134
-
135
- // Floating TOC
136
- "floatingTOC.label": "目次",
137
88
 
138
89
  // Comment badge
139
90
  "commentBadge.title": "{{count}}件のコメント",
@@ -18,14 +18,9 @@ export interface Translations {
18
18
 
19
19
  // Actions menu
20
20
  "actions.ariaLabel": string;
21
- "actions.centered": string;
22
- "actions.fullscreen": string;
23
21
  "actions.settings": string;
24
22
  "actions.reload": string;
25
- "actions.copyAllAI": string;
26
- "actions.copyAllAITitle": string;
27
- "actions.copyAllRaw": string;
28
- "actions.copyAllRawTitle": string;
23
+ "actions.copyAll": string;
29
24
  "actions.exportJson": string;
30
25
  "actions.viewRaw": string;
31
26
 
@@ -34,37 +29,23 @@ export interface Translations {
34
29
  "settings.theme": string;
35
30
  "settings.font": string;
36
31
  "settings.language": string;
37
- "settings.keyboardShortcuts": string;
38
- "settings.clickToRebind": string;
39
32
  "settings.theme.system": string;
40
33
  "settings.theme.light": string;
41
34
  "settings.theme.dark": string;
42
35
  "settings.font.serif": string;
43
36
  "settings.font.sansSerif": string;
44
- "settings.editor": string;
45
- "settings.editor.none": string;
46
- "settings.editor.vscode": string;
47
- "settings.editor.vscodeInsiders": string;
48
- "settings.editor.cursor": string;
49
37
 
50
38
  // Comment input
51
39
  "comment.placeholder": string;
52
40
  "comment.cancel": string;
53
41
  "comment.addNote": string;
54
42
  "comment.highlight": string;
55
- "comment.copyRawTitle": string;
56
- "comment.copyRawLabel": string;
57
- "comment.copyLLMTitle": string;
58
- "comment.copyLLMLabel": string;
59
43
 
60
44
  // Margin note
61
45
  "marginNote.addNote": string;
62
46
  "marginNote.delete": string;
63
47
  "marginNote.edit": string;
64
48
  "marginNote.copy": string;
65
- "marginNote.copyTitle": string;
66
- "marginNote.llm": string;
67
- "marginNote.llmTitle": string;
68
49
 
69
50
  // Comment manager
70
51
  "commentManager.unresolved": string;
@@ -104,38 +85,9 @@ export interface Translations {
104
85
  "rawModal.copiedToClipboard": string;
105
86
  "rawModal.failedToCopy": string;
106
87
 
107
- // Shortcut groups
108
- "shortcutGroup.copy": string;
109
- "shortcutGroup.navigate": string;
110
- "shortcutGroup.other": string;
111
- "shortcuts.resetToDefaults": string;
112
- "shortcuts.enableDisable": string;
113
- "shortcutCapture.pressKeys": string;
114
-
115
- // Shortcut labels (rendered in ShortcutList)
116
- "shortcut.copyAll.label": string;
117
- "shortcut.copyAll.description": string;
118
- "shortcut.copyAllRaw.label": string;
119
- "shortcut.copyAllRaw.description": string;
120
- "shortcut.navigateNext.label": string;
121
- "shortcut.navigateNext.description": string;
122
- "shortcut.navigatePrevious.label": string;
123
- "shortcut.navigatePrevious.description": string;
124
- "shortcut.copySelectionRaw.label": string;
125
- "shortcut.copySelectionRaw.description": string;
126
- "shortcut.copySelectionLLM.label": string;
127
- "shortcut.copySelectionLLM.description": string;
128
- "shortcut.clearSelection.label": string;
129
- "shortcut.clearSelection.description": string;
130
-
131
88
  // Toast messages
132
89
  "toast.copied": string;
133
- "toast.copiedForLLM": string;
134
90
  "toast.copiedAllComments": string;
135
- "toast.copiedAllRaw": string;
136
-
137
- // Floating TOC
138
- "floatingTOC.label": string;
139
91
 
140
92
  // Comment badge
141
93
  "commentBadge.title": string;
@@ -1,7 +1,5 @@
1
- import {
2
- COMMENT_INPUT_HEIGHT_PX,
3
- MARGIN_NOTE_MIN_GAP_PX,
4
- } from "./layout-constants";
1
+ const MARGIN_NOTE_MIN_GAP_PX = 150;
2
+ const COMMENT_INPUT_HEIGHT_PX = 160;
5
3
 
6
4
  interface NotePosition {
7
5
  commentId: string;
@@ -10,18 +8,7 @@ interface NotePosition {
10
8
 
11
9
  /**
12
10
  * Resolves margin note positions to avoid overlaps.
13
- *
14
- * Algorithm:
15
- * 1. Build initial positions from highlight positions
16
- * 2. Handle input zone collision (push notes up or down to avoid input)
17
- * 3. Resolve note-to-note overlaps:
18
- * - Pass 1: Notes above input zone → push UP
19
- * - Pass 2: Notes at/below input zone → push DOWN
20
- *
21
- * @param commentIds - Comment IDs in document order (sorted by startOffset)
22
- * @param highlightPositions - Map of comment ID to top position
23
- * @param pendingSelectionTop - Top position of input zone (if any)
24
- * @returns Map of comment ID to resolved top position
11
+ * Pass 1: push notes above input zone UP. Pass 2: push notes at/below DOWN.
25
12
  */
26
13
  export function resolveMarginNotePositions(
27
14
  commentIds: string[],
@@ -36,10 +23,8 @@ export function resolveMarginNotePositions(
36
23
  top: highlightPositions[id],
37
24
  }));
38
25
 
39
- // Sort by top position
40
26
  positions.sort((a, b) => a.top - b.top);
41
27
 
42
- // Handle input zone collision - check visual overlap, not just top position
43
28
  if (pendingSelectionTop !== null && pendingSelectionTop !== undefined) {
44
29
  const inputStart = pendingSelectionTop;
45
30
  const inputEnd = pendingSelectionTop + COMMENT_INPUT_HEIGHT_PX;
@@ -47,48 +32,41 @@ export function resolveMarginNotePositions(
47
32
  for (const pos of positions) {
48
33
  const noteBottom = pos.top + MARGIN_NOTE_MIN_GAP_PX;
49
34
 
50
- // Check if note visually overlaps with input zone
51
35
  const overlaps = noteBottom > inputStart && pos.top < inputEnd;
52
36
 
53
37
  if (overlaps) {
54
38
  if (pos.top < inputStart) {
55
- // Note is above input but overlaps - push UP
56
39
  pos.top = Math.max(0, inputStart - MARGIN_NOTE_MIN_GAP_PX);
57
40
  } else {
58
- // Note is within/below input zone - push DOWN
59
41
  pos.top = inputEnd;
60
42
  }
61
43
  }
62
44
  }
63
- // Re-sort after potential position changes
64
45
  positions.sort((a, b) => a.top - b.top);
65
46
  }
66
47
 
67
- // Resolve note-to-note overlaps
68
48
  const inputStartForOverlap = pendingSelectionTop ?? Infinity;
69
49
  const inputEndForOverlap =
70
50
  pendingSelectionTop != null
71
51
  ? pendingSelectionTop + COMMENT_INPUT_HEIGHT_PX
72
52
  : Infinity;
73
53
 
74
- // Pass 1: Notes ABOVE input - resolve by pushing UP (bottom to top)
54
+ // Pass 1: push notes above input UP (bottom to top)
75
55
  for (let i = positions.length - 2; i >= 0; i--) {
76
56
  const curr = positions[i];
77
57
  const next = positions[i + 1];
78
- // Only process if next note is above the input zone
79
58
  if (next.top >= inputStartForOverlap) continue;
80
59
  if (next.top - curr.top < MARGIN_NOTE_MIN_GAP_PX) {
81
60
  curr.top = Math.max(0, next.top - MARGIN_NOTE_MIN_GAP_PX);
82
61
  }
83
62
  }
84
63
 
85
- // Pass 2: Notes at/below input - resolve by pushing DOWN (top to bottom)
64
+ // Pass 2: push notes at/below input DOWN (top to bottom)
86
65
  for (let i = 1; i < positions.length; i++) {
87
66
  const prev = positions[i - 1];
88
67
  const curr = positions[i];
89
68
  if (curr.top - prev.top < MARGIN_NOTE_MIN_GAP_PX) {
90
69
  let newTop = prev.top + MARGIN_NOTE_MIN_GAP_PX;
91
- // If new position lands in input zone, skip to below input
92
70
  if (newTop >= inputStartForOverlap && newTop < inputEndForOverlap) {
93
71
  newTop = inputEndForOverlap;
94
72
  }
@@ -0,0 +1,150 @@
1
+ import { resolveMarginNotePositions } from "./margin-layout";
2
+
3
+ type Listener = () => void;
4
+
5
+ /**
6
+ * Positions managed outside React. Scroll-invariant — only recalculates
7
+ * on highlight mutation (MutationObserver) and resize.
8
+ */
9
+ export class Positions {
10
+ private relative = new Map<string, number>();
11
+ private absolute = new Map<string, number>();
12
+ private snapshot: Record<string, number> = {};
13
+ private notes = new Map<string, HTMLElement>();
14
+ private ids: string[] = [];
15
+ private pendingTop: number | undefined;
16
+ private listeners = new Set<Listener>();
17
+ private root: HTMLElement | null = null;
18
+ private container: HTMLElement | null = null;
19
+ private resizeRaf: number | null = null;
20
+ private mutationRaf: number | null = null;
21
+ private observer: MutationObserver | null = null;
22
+
23
+ attach(root: HTMLElement, container: HTMLElement) {
24
+ this.root = root;
25
+ this.container = container;
26
+ window.addEventListener("resize", this.onResize);
27
+
28
+ this.observer = new MutationObserver(() => {
29
+ if (this.mutationRaf !== null) return;
30
+ this.mutationRaf = requestAnimationFrame(() => {
31
+ this.mutationRaf = null;
32
+ this.cache();
33
+ });
34
+ });
35
+ this.observer.observe(root, { childList: true, subtree: true });
36
+ }
37
+
38
+ detach() {
39
+ window.removeEventListener("resize", this.onResize);
40
+ if (this.resizeRaf !== null) cancelAnimationFrame(this.resizeRaf);
41
+ if (this.mutationRaf !== null) cancelAnimationFrame(this.mutationRaf);
42
+ this.resizeRaf = null;
43
+ this.mutationRaf = null;
44
+ this.observer?.disconnect();
45
+ this.observer = null;
46
+ this.root = null;
47
+ this.container = null;
48
+ }
49
+
50
+ cache() {
51
+ if (!this.root || !this.container) return;
52
+
53
+ const ref = this.container.getBoundingClientRect();
54
+ const scrollY = window.scrollY;
55
+
56
+ this.relative.clear();
57
+ this.absolute.clear();
58
+
59
+ for (const mark of this.root.querySelectorAll("mark[data-comment-id]")) {
60
+ const id = mark.getAttribute("data-comment-id");
61
+ if (!id || this.relative.has(id)) continue;
62
+
63
+ const rect = mark.getBoundingClientRect();
64
+ this.relative.set(id, rect.top - ref.top);
65
+ this.absolute.set(id, rect.top + scrollY);
66
+ }
67
+
68
+ const snap: Record<string, number> = {};
69
+ for (const [id, top] of this.absolute) snap[id] = top;
70
+ this.snapshot = snap;
71
+
72
+ this.apply();
73
+ this.notify();
74
+ }
75
+
76
+ setIds(ids: string[]) {
77
+ this.ids = ids;
78
+ }
79
+
80
+ setPending(top: number | undefined) {
81
+ if (this.pendingTop === top) return;
82
+ this.pendingTop = top;
83
+ this.apply();
84
+ }
85
+
86
+ register(id: string, el: HTMLElement) {
87
+ this.notes.set(id, el);
88
+ const top = this.resolve().get(id);
89
+ if (top !== undefined) {
90
+ el.style.top = `${top}px`;
91
+ el.style.visibility = "visible";
92
+ } else {
93
+ el.style.visibility = "hidden";
94
+ }
95
+ }
96
+
97
+ unregister(id: string) {
98
+ this.notes.delete(id);
99
+ }
100
+
101
+ getAbsolute(): Record<string, number> {
102
+ return this.snapshot;
103
+ }
104
+
105
+ subscribe(fn: Listener): () => void {
106
+ this.listeners.add(fn);
107
+ return () => this.listeners.delete(fn);
108
+ }
109
+
110
+ dispose() {
111
+ this.detach();
112
+ this.relative.clear();
113
+ this.absolute.clear();
114
+ this.snapshot = {};
115
+ this.notes.clear();
116
+ this.listeners.clear();
117
+ this.ids = [];
118
+ }
119
+
120
+ private resolve(): Map<string, number> {
121
+ const pos: Record<string, number> = {};
122
+ for (const [id, top] of this.relative) pos[id] = top;
123
+ return resolveMarginNotePositions(this.ids, pos, this.pendingTop);
124
+ }
125
+
126
+ private apply() {
127
+ const resolved = this.resolve();
128
+ for (const [id, el] of this.notes) {
129
+ const top = resolved.get(id);
130
+ if (top !== undefined) {
131
+ el.style.top = `${top}px`;
132
+ el.style.visibility = "visible";
133
+ } else {
134
+ el.style.visibility = "hidden";
135
+ }
136
+ }
137
+ }
138
+
139
+ private onResize = () => {
140
+ if (this.resizeRaf !== null) return;
141
+ this.resizeRaf = requestAnimationFrame(() => {
142
+ this.resizeRaf = null;
143
+ this.cache();
144
+ });
145
+ };
146
+
147
+ private notify() {
148
+ for (const fn of this.listeners) fn();
149
+ }
150
+ }
package/src/lib/utils.ts CHANGED
@@ -1,34 +1,20 @@
1
1
  import { type ClassValue, clsx } from "clsx";
2
2
  import type { ReactNode } from "react";
3
3
  import { twMerge } from "tailwind-merge";
4
- import type { DocumentType } from "../types";
5
4
 
6
- export function getFileType(filePath: string): DocumentType | null {
7
- if (filePath.endsWith(".md") || filePath.endsWith(".markdown")) {
8
- return "markdown";
9
- }
10
- if (filePath.endsWith(".html") || filePath.endsWith(".htm")) {
11
- return "html";
12
- }
13
- return null;
5
+ export function isMarkdownFile(filePath: string): boolean {
6
+ return filePath.endsWith(".md") || filePath.endsWith(".markdown");
14
7
  }
15
8
 
16
9
  export function cn(...inputs: ReadonlyArray<ClassValue>) {
17
10
  return twMerge(clsx(inputs));
18
11
  }
19
12
 
20
- /**
21
- * Truncate text with ellipsis for toast notifications.
22
- */
23
13
  export function truncate(text: string, maxLength = 30): string {
24
14
  if (text.length <= maxLength) return text;
25
15
  return `${text.slice(0, maxLength)}…`;
26
16
  }
27
17
 
28
- /**
29
- * Recursively extract text content from React children.
30
- * Handles strings, numbers, arrays, and React elements.
31
- */
32
18
  export function getTextContent(children: ReactNode): string {
33
19
  if (typeof children === "string" || typeof children === "number") {
34
20
  return String(children);
@@ -48,9 +34,6 @@ export function getTextContent(children: ReactNode): string {
48
34
  return "";
49
35
  }
50
36
 
51
- /**
52
- * Slugify text to create URL-friendly IDs
53
- */
54
37
  export function slugify(text: string): string {
55
38
  return text
56
39
  .toLowerCase()
package/src/schema.ts ADDED
@@ -0,0 +1,81 @@
1
+ export const AnchorConfidences = {
2
+ EXACT: "exact",
3
+ NORMALIZED: "normalized",
4
+ FUZZY: "fuzzy",
5
+ UNRESOLVED: "unresolved",
6
+ } as const;
7
+
8
+ export type AnchorConfidence =
9
+ (typeof AnchorConfidences)[keyof typeof AnchorConfidences];
10
+
11
+ export type ResolvedAnchorConfidence = Exclude<
12
+ AnchorConfidence,
13
+ typeof AnchorConfidences.UNRESOLVED
14
+ >;
15
+
16
+ export interface Comment {
17
+ id: string;
18
+ selectedText: string;
19
+ comment: string;
20
+ createdAt: string;
21
+ startOffset: number;
22
+ endOffset: number;
23
+ /** e.g. "L42" or "L42-L55" */
24
+ lineHint?: string;
25
+ anchorConfidence?: AnchorConfidence;
26
+ /** First N chars of original text for anchor matching when selectedText is truncated */
27
+ anchorPrefix?: string;
28
+ }
29
+
30
+ export interface CommentFile {
31
+ source: string;
32
+ /** SHA-256 prefix (16 chars) */
33
+ hash: string;
34
+ version: number;
35
+ comments: Comment[];
36
+ }
37
+
38
+ export interface Anchor {
39
+ start: number;
40
+ end: number;
41
+ line: number;
42
+ confidence: ResolvedAnchorConfidence;
43
+ distance?: number;
44
+ }
45
+
46
+ export interface SelectionRange {
47
+ startOffset: number;
48
+ endOffset: number;
49
+ }
50
+
51
+ export interface Selection extends SelectionRange {
52
+ text: string;
53
+ }
54
+
55
+ export interface Document {
56
+ content: string;
57
+ filePath: string;
58
+ fileName: string;
59
+ clean: boolean;
60
+ }
61
+
62
+ export const FontFamilies = {
63
+ SERIF: "serif",
64
+ SANS_SERIF: "sans-serif",
65
+ } as const;
66
+
67
+ export type FontFamily = (typeof FontFamilies)[keyof typeof FontFamilies];
68
+
69
+ export const ThemeModes = {
70
+ LIGHT: "light",
71
+ DARK: "dark",
72
+ SYSTEM: "system",
73
+ } as const;
74
+
75
+ export type ThemeMode = (typeof ThemeModes)[keyof typeof ThemeModes];
76
+
77
+ export interface DocumentSettings {
78
+ version: number;
79
+ fontFamily: FontFamily;
80
+ onboarded?: boolean;
81
+ }