md-redline 0.1.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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/bin/md-redline +255 -0
  4. package/bin/test-windows.ps1 +70 -0
  5. package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
  6. package/dist/assets/arc-DI2g9LXK.js +1 -0
  7. package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
  8. package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
  9. package/dist/assets/array-DOVTz2Mq.js +1 -0
  10. package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
  11. package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
  12. package/dist/assets/channel-DPCihw7y.js +1 -0
  13. package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
  14. package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
  15. package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
  16. package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
  17. package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
  18. package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
  19. package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
  20. package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
  21. package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
  22. package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
  23. package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
  24. package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
  25. package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
  26. package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
  27. package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
  28. package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
  29. package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
  30. package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
  31. package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
  32. package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
  33. package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
  34. package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
  35. package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
  36. package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
  37. package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
  38. package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
  39. package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
  40. package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
  41. package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
  42. package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
  43. package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
  44. package/dist/assets/clone-CI9aUwHe.js +1 -0
  45. package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
  46. package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
  47. package/dist/assets/dagre-CilMRazv.js +1 -0
  48. package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
  49. package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
  50. package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
  51. package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
  52. package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
  53. package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
  54. package/dist/assets/dist-C_eddq6m.js +1 -0
  55. package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
  56. package/dist/assets/flatten-B8XIuT0x.js +1 -0
  57. package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
  58. package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
  59. package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
  60. package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
  61. package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
  62. package/dist/assets/index-BoggyWS0.css +2 -0
  63. package/dist/assets/index-aLvjHQW4.js +104 -0
  64. package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
  65. package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
  66. package/dist/assets/init-Bft5Ffpj.js +1 -0
  67. package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
  68. package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
  69. package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
  70. package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
  71. package/dist/assets/katex-Uj9wLT16.js +265 -0
  72. package/dist/assets/line-CRxEwpOv.js +1 -0
  73. package/dist/assets/linear-PDPfFByd.js +1 -0
  74. package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
  75. package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
  76. package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
  77. package/dist/assets/ordinal-DIg8h6NI.js +1 -0
  78. package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
  79. package/dist/assets/path-DfRbCp9y.js +1 -0
  80. package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
  81. package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
  82. package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
  83. package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
  84. package/dist/assets/reduce-CV2X8n1a.js +1 -0
  85. package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
  86. package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
  87. package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
  88. package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
  89. package/dist/assets/src-PKe5NtkK.js +1 -0
  90. package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
  91. package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
  92. package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
  93. package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
  94. package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
  95. package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
  96. package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
  97. package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
  98. package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
  99. package/dist/favicon.svg +15 -0
  100. package/dist/index.html +14 -0
  101. package/dist/screenshot.png +0 -0
  102. package/index.html +13 -0
  103. package/package.json +105 -0
  104. package/public/favicon.svg +15 -0
  105. package/public/screenshot.png +0 -0
  106. package/server/index.test.ts +814 -0
  107. package/server/index.ts +736 -0
  108. package/server/preferences.test.ts +126 -0
  109. package/server/preferences.ts +76 -0
  110. package/src/App.tsx +1620 -0
  111. package/src/components/ActionButton.tsx +41 -0
  112. package/src/components/CommandPalette.tsx +191 -0
  113. package/src/components/CommentCard.tsx +556 -0
  114. package/src/components/CommentForm.tsx +285 -0
  115. package/src/components/CommentSidebar.tsx +428 -0
  116. package/src/components/ConfirmDialog.tsx +64 -0
  117. package/src/components/ContextMenu.tsx +220 -0
  118. package/src/components/DragHandles.tsx +48 -0
  119. package/src/components/FileExplorer.tsx +251 -0
  120. package/src/components/FileOpener.tsx +304 -0
  121. package/src/components/IconButton.tsx +32 -0
  122. package/src/components/KeyboardShortcutsPanel.tsx +136 -0
  123. package/src/components/MarkdownViewer.tsx +682 -0
  124. package/src/components/RawView.tsx +798 -0
  125. package/src/components/SearchBar.tsx +129 -0
  126. package/src/components/Separator.tsx +7 -0
  127. package/src/components/SettingsPanel.tsx +813 -0
  128. package/src/components/SplitIconButton.tsx +133 -0
  129. package/src/components/TabBar.tsx +594 -0
  130. package/src/components/TableOfContents.tsx +70 -0
  131. package/src/components/ThemeSelector.tsx +159 -0
  132. package/src/components/Toast.tsx +99 -0
  133. package/src/components/Toolbar.tsx +161 -0
  134. package/src/components/iconButtonVariants.ts +19 -0
  135. package/src/components/rawView.test.ts +291 -0
  136. package/src/contexts/SettingsContext.tsx +120 -0
  137. package/src/hooks/useAuthor.test.ts +58 -0
  138. package/src/hooks/useAuthor.ts +69 -0
  139. package/src/hooks/useAutoResize.ts +20 -0
  140. package/src/hooks/useCommentCardTriggers.ts +20 -0
  141. package/src/hooks/useComments.test.ts +773 -0
  142. package/src/hooks/useComments.ts +332 -0
  143. package/src/hooks/useContextMenu.ts +48 -0
  144. package/src/hooks/useContextMenuItems.ts +392 -0
  145. package/src/hooks/useDiffSnapshot.test.ts +130 -0
  146. package/src/hooks/useDiffSnapshot.ts +67 -0
  147. package/src/hooks/useDragHandles.ts +417 -0
  148. package/src/hooks/useFileWatcher.ts +45 -0
  149. package/src/hooks/useHeadingTracking.ts +84 -0
  150. package/src/hooks/useMermaidRenderer.ts +75 -0
  151. package/src/hooks/useModalState.ts +22 -0
  152. package/src/hooks/usePageVisible.test.ts +69 -0
  153. package/src/hooks/usePageVisible.ts +19 -0
  154. package/src/hooks/usePaneLayout.test.ts +108 -0
  155. package/src/hooks/usePaneLayout.ts +102 -0
  156. package/src/hooks/useRecentFiles.test.ts +103 -0
  157. package/src/hooks/useRecentFiles.ts +99 -0
  158. package/src/hooks/useResizablePanel.test.ts +84 -0
  159. package/src/hooks/useResizablePanel.ts +118 -0
  160. package/src/hooks/useSearch.test.ts +72 -0
  161. package/src/hooks/useSearch.ts +53 -0
  162. package/src/hooks/useSelection.ts +48 -0
  163. package/src/hooks/useSessionPersistence.test.ts +59 -0
  164. package/src/hooks/useSessionPersistence.ts +43 -0
  165. package/src/hooks/useTabs.test.ts +127 -0
  166. package/src/hooks/useTabs.ts +561 -0
  167. package/src/hooks/useThemePersistence.ts +41 -0
  168. package/src/hooks/useToast.ts +27 -0
  169. package/src/index.css +1047 -0
  170. package/src/lib/agent-prompts.test.ts +34 -0
  171. package/src/lib/agent-prompts.ts +68 -0
  172. package/src/lib/comment-editor-state.ts +6 -0
  173. package/src/lib/comment-parser.test.ts +1959 -0
  174. package/src/lib/comment-parser.ts +1021 -0
  175. package/src/lib/diff.test.ts +164 -0
  176. package/src/lib/diff.ts +139 -0
  177. package/src/lib/heading-slugs.test.ts +85 -0
  178. package/src/lib/heading-slugs.ts +44 -0
  179. package/src/lib/http.test.ts +43 -0
  180. package/src/lib/http.ts +29 -0
  181. package/src/lib/mermaid-highlights.test.ts +517 -0
  182. package/src/lib/mermaid-highlights.ts +936 -0
  183. package/src/lib/mermaid-renderer.test.ts +114 -0
  184. package/src/lib/mermaid-renderer.ts +89 -0
  185. package/src/lib/path-utils.test.ts +17 -0
  186. package/src/lib/path-utils.ts +7 -0
  187. package/src/lib/platform.test.ts +58 -0
  188. package/src/lib/platform.ts +14 -0
  189. package/src/lib/preferences-client.test.ts +177 -0
  190. package/src/lib/preferences-client.ts +94 -0
  191. package/src/lib/selection-resolver.test.ts +118 -0
  192. package/src/lib/selection-resolver.ts +37 -0
  193. package/src/lib/settings.test.ts +152 -0
  194. package/src/lib/settings.ts +78 -0
  195. package/src/lib/shortcut-label.tsx +18 -0
  196. package/src/lib/themes.ts +21 -0
  197. package/src/lib/visible-text.test.ts +86 -0
  198. package/src/lib/visible-text.ts +77 -0
  199. package/src/main.tsx +22 -0
  200. package/src/markdown/pipeline.test.ts +82 -0
  201. package/src/markdown/pipeline.ts +33 -0
  202. package/src/types.test.ts +43 -0
  203. package/src/types.ts +46 -0
  204. package/tsconfig.app.json +28 -0
  205. package/tsconfig.json +7 -0
  206. package/tsconfig.node.json +26 -0
  207. package/vite.config.ts +50 -0
@@ -0,0 +1,556 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ memo,
7
+ useCallback,
8
+ type MouseEventHandler,
9
+ } from 'react';
10
+ import type { MdComment, CommentStatus } from '../types';
11
+ import { getEffectiveStatus } from '../types';
12
+ import { getAuthorColor } from '../hooks/useAuthor';
13
+ import { useAutoResize } from '../hooks/useAutoResize';
14
+ import { useSettings } from '../contexts/SettingsContext';
15
+ import type { CommentCardEditorState } from '../lib/comment-editor-state';
16
+ import { ActionButton } from './ActionButton';
17
+
18
+ interface Props {
19
+ comment: MdComment;
20
+ isActive: boolean;
21
+ anchorMissing?: boolean;
22
+ onActivate: (id: string) => void;
23
+ onResolve?: (id: string) => void;
24
+ onUnresolve?: (id: string) => void;
25
+ onDelete: (id: string) => void;
26
+ onEdit: (id: string, newText: string) => void;
27
+ onReply: (id: string, text: string) => void;
28
+ onEditReply: (commentId: string, replyId: string, newText: string) => void;
29
+ onDeleteReply: (commentId: string, replyId: string) => void;
30
+ editor: CommentCardEditorState | null;
31
+ onRequestCommentEdit: (commentId: string) => void;
32
+ onRequestReplyCompose: (commentId: string) => void;
33
+ onRequestReplyEdit: (commentId: string, replyId: string) => void;
34
+ onCloseEditor: () => void;
35
+ onContextMenu?: (id: string, x: number, y: number) => void;
36
+ }
37
+
38
+ const STATUS_CONFIG: Record<CommentStatus, { label: string; className: string }> = {
39
+ open: { label: 'Open', className: 'bg-status-open-bg text-status-open-text' },
40
+ resolved: { label: 'Resolved', className: 'bg-status-resolved-bg text-status-resolved-text' },
41
+ };
42
+
43
+ export const CommentCard = memo(function CommentCard({
44
+ comment,
45
+ isActive,
46
+ anchorMissing,
47
+ onActivate,
48
+ onResolve,
49
+ onUnresolve,
50
+ onDelete,
51
+ onEdit,
52
+ onReply,
53
+ onEditReply,
54
+ onDeleteReply,
55
+ editor,
56
+ onRequestCommentEdit,
57
+ onRequestReplyCompose,
58
+ onRequestReplyEdit,
59
+ onCloseEditor,
60
+ onContextMenu: onCtxMenu,
61
+ }: Props) {
62
+ const { settings } = useSettings();
63
+ const COMMENT_MAX_LENGTH = settings.commentMaxLength;
64
+ const resolveEnabled = settings.enableResolve;
65
+ const status = getEffectiveStatus(comment);
66
+ const isResolved = resolveEnabled && status === 'resolved';
67
+ const timeAgo = getTimeAgo(comment.timestamp);
68
+ const [editText, setEditText] = useState(comment.text);
69
+ const [replyText, setReplyText] = useState('');
70
+ const [editReplyText, setEditReplyText] = useState('');
71
+ const [isTextExpanded, setIsTextExpanded] = useState(false);
72
+ const textRef = useRef<HTMLParagraphElement>(null);
73
+ const [isClamped, setIsClamped] = useState(false);
74
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
75
+ const replyRef = useRef<HTMLTextAreaElement>(null);
76
+ const editReplyRef = useRef<HTMLTextAreaElement>(null);
77
+ const resizeEditTextarea = useAutoResize(textareaRef, editText);
78
+ useAutoResize(replyRef, replyText);
79
+ const resizeEditReplyTextarea = useAutoResize(editReplyRef, editReplyText);
80
+ const isEditing = editor?.mode === 'comment-edit';
81
+ const isReplying = editor?.mode === 'reply-compose';
82
+ const editingReplyId = editor?.mode === 'reply-edit' ? editor.replyId : null;
83
+ const editorToken = editor?.token ?? 0;
84
+
85
+ // Detect if comment text is long enough to need clamping
86
+ const checkClamped = useCallback(() => {
87
+ const el = textRef.current;
88
+ if (el) setIsClamped(el.scrollHeight > el.clientHeight);
89
+ }, []);
90
+
91
+ useLayoutEffect(() => {
92
+ checkClamped();
93
+ }, [comment.text, isTextExpanded, checkClamped]);
94
+
95
+ useEffect(() => {
96
+ if (isEditing && textareaRef.current) {
97
+ resizeEditTextarea();
98
+ textareaRef.current.focus();
99
+ textareaRef.current.selectionStart = textareaRef.current.value.length;
100
+ }
101
+ }, [isEditing, resizeEditTextarea, editorToken]);
102
+
103
+ useEffect(() => {
104
+ if (isReplying && replyRef.current) {
105
+ replyRef.current.focus();
106
+ }
107
+ }, [isReplying, editorToken]);
108
+
109
+ useEffect(() => {
110
+ if (editingReplyId && editReplyRef.current) {
111
+ resizeEditReplyTextarea();
112
+ editReplyRef.current.focus();
113
+ editReplyRef.current.selectionStart = editReplyRef.current.value.length;
114
+ }
115
+ }, [editingReplyId, resizeEditReplyTextarea, editorToken]);
116
+
117
+ useEffect(() => {
118
+ if (editor?.mode === 'comment-edit') {
119
+ setEditText(comment.text);
120
+ }
121
+ }, [editor?.mode, editorToken, comment.text]);
122
+
123
+ useEffect(() => {
124
+ if (editor?.mode === 'reply-compose') {
125
+ setReplyText('');
126
+ }
127
+ }, [editor?.mode, editorToken]);
128
+
129
+ const handleSave = () => {
130
+ const trimmed = editText.trim();
131
+ if (trimmed && trimmed !== comment.text && trimmed.length <= COMMENT_MAX_LENGTH) {
132
+ onEdit(comment.id, trimmed);
133
+ }
134
+ onCloseEditor();
135
+ };
136
+
137
+ const handleCancel = () => {
138
+ setEditText(comment.text);
139
+ onCloseEditor();
140
+ };
141
+
142
+ const handleReplySubmit = () => {
143
+ const trimmed = replyText.trim();
144
+ if (trimmed && trimmed.length <= COMMENT_MAX_LENGTH) {
145
+ onReply(comment.id, trimmed);
146
+ setReplyText('');
147
+ }
148
+ onCloseEditor();
149
+ };
150
+
151
+ const replies = comment.replies || [];
152
+ const isEditingReply = editingReplyId !== null;
153
+ const editingReply = editingReplyId
154
+ ? (replies.find((reply) => reply.id === editingReplyId) ?? null)
155
+ : null;
156
+
157
+ useEffect(() => {
158
+ if (editor?.mode === 'reply-edit') {
159
+ setEditReplyText(editingReply?.text ?? '');
160
+ return;
161
+ }
162
+ if (editingReplyId === null) {
163
+ setEditReplyText('');
164
+ }
165
+ }, [editor?.mode, editorToken, editingReply?.text, editingReplyId]);
166
+
167
+ const handleReplyEditSave = () => {
168
+ const trimmed = editReplyText.trim();
169
+ if (
170
+ editingReplyId &&
171
+ editingReply &&
172
+ trimmed &&
173
+ trimmed !== editingReply.text &&
174
+ trimmed.length <= COMMENT_MAX_LENGTH
175
+ ) {
176
+ onEditReply(comment.id, editingReplyId, trimmed);
177
+ }
178
+ setEditReplyText('');
179
+ onCloseEditor();
180
+ };
181
+
182
+ const handleReplyEditCancel = () => {
183
+ setEditReplyText('');
184
+ onCloseEditor();
185
+ };
186
+
187
+ return (
188
+ <div
189
+ className={`group rounded-lg border transition-all duration-200 cursor-pointer ${
190
+ isActive
191
+ ? 'border-primary-border bg-primary-bg shadow-sm ring-1 ring-primary-ring'
192
+ : isResolved
193
+ ? 'border-border bg-surface-secondary opacity-60'
194
+ : 'border-border-subtle bg-surface hover:border-content-faint hover:shadow-sm'
195
+ }`}
196
+ onClick={() => onActivate(comment.id)}
197
+ onContextMenu={(e) => {
198
+ if (onCtxMenu) {
199
+ e.preventDefault();
200
+ onCtxMenu(comment.id, e.clientX, e.clientY);
201
+ }
202
+ }}
203
+ >
204
+ {/* Header: anchor + optional status badge */}
205
+ <div className="px-3 pt-3 pb-1 flex items-start gap-2">
206
+ <div
207
+ className={`text-xs font-mono px-2 py-1 rounded inline-block max-w-full truncate flex-1 min-w-0 ${
208
+ isResolved
209
+ ? 'bg-surface-inset text-content-muted'
210
+ : 'bg-comment-anchor-bg text-comment-anchor-text border border-comment-anchor-border'
211
+ }`}
212
+ >
213
+ &ldquo;{comment.anchor}&rdquo;
214
+ </div>
215
+ <div className="flex items-center gap-1 shrink-0">
216
+ {anchorMissing && (
217
+ <span
218
+ className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full whitespace-nowrap bg-danger-bg text-danger-text"
219
+ title="Anchor text was modified or removed"
220
+ >
221
+ Changed
222
+ </span>
223
+ )}
224
+ {resolveEnabled && (
225
+ <span
226
+ className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full whitespace-nowrap ${STATUS_CONFIG[status].className}`}
227
+ >
228
+ {STATUS_CONFIG[status].label}
229
+ </span>
230
+ )}
231
+ </div>
232
+ </div>
233
+
234
+ {/* Comment text */}
235
+ <div className="px-3 py-2">
236
+ {isEditing ? (
237
+ <div className="flex flex-col-reverse gap-1.5" onClick={(e) => e.stopPropagation()}>
238
+ <div className="flex justify-end gap-1.5">
239
+ <ActionButton size="sm" onClick={handleCancel}>
240
+ Cancel
241
+ </ActionButton>
242
+ <ActionButton
243
+ intent="submit"
244
+ size="sm"
245
+ onClick={handleSave}
246
+ disabled={!editText.trim() || editText.length > COMMENT_MAX_LENGTH}
247
+ >
248
+ Save
249
+ </ActionButton>
250
+ </div>
251
+ {editText.length > COMMENT_MAX_LENGTH * 0.8 && (
252
+ <p
253
+ className={`text-right text-xs ${
254
+ editText.length >= COMMENT_MAX_LENGTH
255
+ ? 'text-danger font-medium'
256
+ : 'text-content-muted'
257
+ }`}
258
+ >
259
+ {editText.length}/{COMMENT_MAX_LENGTH}
260
+ </p>
261
+ )}
262
+ <textarea
263
+ ref={textareaRef}
264
+ value={editText}
265
+ onChange={(e) => setEditText(e.target.value)}
266
+ onKeyDown={(e) => {
267
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
268
+ e.preventDefault();
269
+ handleSave();
270
+ }
271
+ if (e.key === 'Escape') {
272
+ handleCancel();
273
+ }
274
+ }}
275
+ maxLength={COMMENT_MAX_LENGTH}
276
+ className="w-full text-sm border border-border-subtle rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-none overflow-hidden bg-surface text-content"
277
+ rows={1}
278
+ />
279
+ </div>
280
+ ) : (
281
+ <div>
282
+ <p
283
+ ref={textRef}
284
+ className={`text-sm leading-relaxed whitespace-pre-wrap ${
285
+ isResolved ? 'text-content-muted line-through' : 'text-content'
286
+ } ${!isTextExpanded ? 'line-clamp-4' : ''}`}
287
+ >
288
+ {comment.text}
289
+ </p>
290
+ {(isClamped || isTextExpanded) && (
291
+ <button
292
+ onClick={(e) => {
293
+ e.stopPropagation();
294
+ setIsTextExpanded(!isTextExpanded);
295
+ }}
296
+ className="text-xs text-primary-text hover:underline mt-1"
297
+ >
298
+ {isTextExpanded ? 'Show less' : 'Show more'}
299
+ </button>
300
+ )}
301
+ </div>
302
+ )}
303
+ </div>
304
+
305
+ {/* Footer: author, time, actions */}
306
+ <div className="px-3 pb-2 flex items-center flex-wrap gap-y-1">
307
+ <span className="text-xs text-content-muted flex items-center gap-1.5 mr-auto">
308
+ <span
309
+ className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
310
+ style={{ backgroundColor: getAuthorColor(comment.author).text }}
311
+ title={comment.author}
312
+ />
313
+ {comment.author} &middot; {timeAgo}
314
+ </span>
315
+
316
+ {!isEditing && !isEditingReply && (
317
+ <div
318
+ className={`flex items-center gap-1 transition-opacity ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
319
+ >
320
+ {!isResolved && (
321
+ <>
322
+ <ActionButton
323
+ onClick={(e) => {
324
+ e.stopPropagation();
325
+ onRequestReplyCompose(comment.id);
326
+ }}
327
+ >
328
+ Reply
329
+ </ActionButton>
330
+ <ActionButton
331
+ onClick={(e) => {
332
+ e.stopPropagation();
333
+ onRequestCommentEdit(comment.id);
334
+ }}
335
+ >
336
+ Edit
337
+ </ActionButton>
338
+ </>
339
+ )}
340
+ {isResolved
341
+ ? onUnresolve && (
342
+ <ActionButton
343
+ intent="primary"
344
+ onClick={(e) => {
345
+ e.stopPropagation();
346
+ onUnresolve(comment.id);
347
+ }}
348
+ >
349
+ Reopen
350
+ </ActionButton>
351
+ )
352
+ : onResolve && (
353
+ <ActionButton
354
+ intent="success"
355
+ onClick={(e) => {
356
+ e.stopPropagation();
357
+ onResolve(comment.id);
358
+ }}
359
+ >
360
+ Resolve
361
+ </ActionButton>
362
+ )}
363
+ <DeleteIconButton
364
+ onClick={(e) => {
365
+ e.stopPropagation();
366
+ onDelete(comment.id);
367
+ }}
368
+ />
369
+ </div>
370
+ )}
371
+ </div>
372
+
373
+ {/* Replies thread */}
374
+ {replies.length > 0 && (
375
+ <div className="mx-3 mb-2 border-l-2 border-border-subtle pl-3 space-y-2">
376
+ {replies.map((reply) => (
377
+ <div key={reply.id} className="group/reply text-xs" data-reply-id={reply.id}>
378
+ {editingReplyId === reply.id ? (
379
+ <div className="flex flex-col-reverse gap-1.5" onClick={(e) => e.stopPropagation()}>
380
+ <div className="flex justify-end gap-1.5">
381
+ <ActionButton size="sm" onClick={handleReplyEditCancel}>
382
+ Cancel
383
+ </ActionButton>
384
+ <ActionButton
385
+ intent="submit"
386
+ size="sm"
387
+ onClick={handleReplyEditSave}
388
+ disabled={!editReplyText.trim() || editReplyText.length > COMMENT_MAX_LENGTH}
389
+ >
390
+ Save
391
+ </ActionButton>
392
+ </div>
393
+ {editReplyText.length > COMMENT_MAX_LENGTH * 0.8 && (
394
+ <p
395
+ className={`text-right text-xs ${
396
+ editReplyText.length >= COMMENT_MAX_LENGTH
397
+ ? 'text-danger font-medium'
398
+ : 'text-content-muted'
399
+ }`}
400
+ >
401
+ {editReplyText.length}/{COMMENT_MAX_LENGTH}
402
+ </p>
403
+ )}
404
+ <textarea
405
+ ref={editReplyRef}
406
+ value={editReplyText}
407
+ onChange={(e) => setEditReplyText(e.target.value)}
408
+ onKeyDown={(e) => {
409
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
410
+ e.preventDefault();
411
+ handleReplyEditSave();
412
+ }
413
+ if (e.key === 'Escape') {
414
+ handleReplyEditCancel();
415
+ }
416
+ }}
417
+ maxLength={COMMENT_MAX_LENGTH}
418
+ className="w-full text-sm border border-border-subtle rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-none overflow-hidden bg-surface text-content"
419
+ rows={1}
420
+ />
421
+ </div>
422
+ ) : (
423
+ <>
424
+ <p className="text-content-secondary leading-relaxed whitespace-pre-wrap">
425
+ {reply.text}
426
+ </p>
427
+ <div className="mt-1 flex items-center justify-between gap-2">
428
+ <span className="min-w-0 flex flex-1 items-center gap-1 text-content-muted">
429
+ <span
430
+ className="inline-block w-2 h-2 rounded-full shrink-0"
431
+ style={{ backgroundColor: getAuthorColor(reply.author).text }}
432
+ />
433
+ {reply.author} &middot; {getTimeAgo(reply.timestamp)}
434
+ </span>
435
+ {!isResolved && (
436
+ <div
437
+ className="invisible flex shrink-0 items-center gap-1 pl-2 opacity-0 transition-opacity group-hover/reply:visible group-hover/reply:opacity-100 group-focus-within/reply:visible group-focus-within/reply:opacity-100"
438
+ onClick={(e) => e.stopPropagation()}
439
+ >
440
+ <ActionButton
441
+ onClick={() => {
442
+ onRequestReplyEdit(comment.id, reply.id);
443
+ }}
444
+ >
445
+ Edit
446
+ </ActionButton>
447
+ <DeleteIconButton
448
+ onClick={() => {
449
+ onDeleteReply(comment.id, reply.id);
450
+ }}
451
+ />
452
+ </div>
453
+ )}
454
+ </div>
455
+ </>
456
+ )}
457
+ </div>
458
+ ))}
459
+ </div>
460
+ )}
461
+
462
+ {/* Reply input (shown when replying — trigger is in the action bar above) */}
463
+ {!isResolved && isReplying && (
464
+ <div className="px-3 pb-3" onClick={(e) => e.stopPropagation()}>
465
+ <textarea
466
+ ref={replyRef}
467
+ value={replyText}
468
+ onChange={(e) => setReplyText(e.target.value)}
469
+ onKeyDown={(e) => {
470
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
471
+ e.preventDefault();
472
+ handleReplySubmit();
473
+ }
474
+ if (e.key === 'Escape') {
475
+ setReplyText('');
476
+ onCloseEditor();
477
+ }
478
+ }}
479
+ placeholder="Write a reply..."
480
+ maxLength={COMMENT_MAX_LENGTH}
481
+ className="w-full text-xs border border-border-subtle rounded-md px-2 py-1.5 resize-none overflow-hidden focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent placeholder:text-content-muted bg-surface text-content"
482
+ rows={1}
483
+ />
484
+ {replyText.length > COMMENT_MAX_LENGTH * 0.8 && (
485
+ <p
486
+ className={`text-right text-xs mt-0.5 ${
487
+ replyText.length >= COMMENT_MAX_LENGTH
488
+ ? 'text-danger font-medium'
489
+ : 'text-content-muted'
490
+ }`}
491
+ >
492
+ {replyText.length}/{COMMENT_MAX_LENGTH}
493
+ </p>
494
+ )}
495
+ <div className="flex justify-end gap-1.5 mt-1">
496
+ <ActionButton
497
+ onClick={() => {
498
+ setReplyText('');
499
+ onCloseEditor();
500
+ }}
501
+ >
502
+ Cancel
503
+ </ActionButton>
504
+ <ActionButton
505
+ intent="submit"
506
+ onClick={handleReplySubmit}
507
+ disabled={!replyText.trim() || replyText.length > COMMENT_MAX_LENGTH}
508
+ >
509
+ Reply
510
+ </ActionButton>
511
+ </div>
512
+ </div>
513
+ )}
514
+ </div>
515
+ );
516
+ });
517
+
518
+ function getTimeAgo(timestamp: string): string {
519
+ const now = Date.now();
520
+ const then = new Date(timestamp).getTime();
521
+ const diffMs = now - then;
522
+ const diffMin = Math.floor(diffMs / 60000);
523
+ const diffHr = Math.floor(diffMs / 3600000);
524
+ const diffDay = Math.floor(diffMs / 86400000);
525
+
526
+ if (diffMin < 1) return 'just now';
527
+ if (diffMin < 60) return `${diffMin}m ago`;
528
+ if (diffHr < 24) return `${diffHr}h ago`;
529
+ if (diffDay < 7) return `${diffDay}d ago`;
530
+ return new Date(timestamp).toLocaleDateString();
531
+ }
532
+
533
+ function DeleteIconButton({ onClick }: { onClick: MouseEventHandler<HTMLButtonElement> }) {
534
+ return (
535
+ <button
536
+ onClick={onClick}
537
+ className="p-1 rounded text-content-muted hover:text-danger hover:bg-tint-danger transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
538
+ title="Delete"
539
+ aria-label="Delete"
540
+ >
541
+ <svg
542
+ className="w-3.5 h-3.5"
543
+ fill="none"
544
+ viewBox="0 0 24 24"
545
+ stroke="currentColor"
546
+ strokeWidth={1.5}
547
+ >
548
+ <path
549
+ strokeLinecap="round"
550
+ strokeLinejoin="round"
551
+ d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
552
+ />
553
+ </svg>
554
+ </button>
555
+ );
556
+ }