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,682 @@
1
+ import { memo, useRef, useLayoutEffect, forwardRef, useImperativeHandle, useMemo } from 'react';
2
+ import type { MdComment } from '../types';
3
+ import { getEffectiveStatus } from '../types';
4
+ import { stripInlineFormatting } from '../lib/comment-parser';
5
+ import { assignHeadingIds } from '../lib/heading-slugs';
6
+ import { useMermaidRenderer } from '../hooks/useMermaidRenderer';
7
+ import { collectVisibleTextNodes } from '../lib/visible-text';
8
+ import {
9
+ applyMermaidHighlightStyles,
10
+ getMermaidHighlightTheme,
11
+ scheduleMermaidLayoutStabilization,
12
+ } from '../lib/mermaid-highlights';
13
+
14
+ export interface ViewerContextMenuInfo {
15
+ /** 'selection' when user right-clicks on selected text; 'highlight' when on a comment mark */
16
+ type: 'selection' | 'highlight';
17
+ /** Comment IDs (only for 'highlight' type) */
18
+ commentIds?: string[];
19
+ /** Screen coordinates for the menu */
20
+ x: number;
21
+ y: number;
22
+ }
23
+
24
+ interface Props {
25
+ html: string;
26
+ cleanMarkdown: string;
27
+ comments: MdComment[];
28
+ activeCommentId: string | null;
29
+ selectionText: string | null;
30
+ selectionOffset: number | null;
31
+ onHighlightClick: (commentId: string) => void;
32
+ onContextMenu?: (info: ViewerContextMenuInfo) => void;
33
+ enableResolve?: boolean;
34
+ searchQuery?: string;
35
+ searchActiveIndex?: number;
36
+ onSearchCount?: (count: number) => void;
37
+ theme?: string;
38
+ }
39
+
40
+ export interface TocHeading {
41
+ id: string;
42
+ text: string;
43
+ level: number; // 1-6
44
+ }
45
+
46
+ export interface MarkdownViewerHandle {
47
+ getContainer: () => HTMLElement | null;
48
+ scrollToComment: (commentId: string) => void;
49
+ getActiveMark: () => HTMLElement | null;
50
+ getActiveMarks: () => HTMLElement[];
51
+ getHeadings: () => TocHeading[];
52
+ }
53
+
54
+ // React.memo prevents re-renders from parent state changes that don't affect our props.
55
+ // Combined with ref-based innerHTML (no dangerouslySetInnerHTML), React never touches
56
+ // the container's children — our useLayoutEffect is the sole DOM manager.
57
+ export const MarkdownViewer = memo(
58
+ forwardRef<MarkdownViewerHandle, Props>(function MarkdownViewer(
59
+ {
60
+ html,
61
+ cleanMarkdown,
62
+ comments,
63
+ activeCommentId,
64
+ selectionText,
65
+ selectionOffset,
66
+ onHighlightClick,
67
+ onContextMenu: onCtxMenu,
68
+ enableResolve,
69
+ searchQuery,
70
+ searchActiveIndex,
71
+ onSearchCount,
72
+ theme,
73
+ },
74
+ ref,
75
+ ) {
76
+ const containerRef = useRef<HTMLDivElement>(null);
77
+ const activeMarkRef = useRef<HTMLElement | null>(null);
78
+ const searchCountCb = useRef(onSearchCount);
79
+ searchCountCb.current = onSearchCount;
80
+
81
+ // Mermaid rendering
82
+ const mermaidSvgMap = useMermaidRenderer(cleanMarkdown, theme || 'light');
83
+
84
+ // Build a mapping from clean markdown offsets to rendered/plain text offsets.
85
+ // cleanOffset lives in clean-markdown space (with ** ## etc), but DOM text is
86
+ // in rendered space (formatting stripped). We need to convert before matching.
87
+ const toPlainOffset = useMemo(
88
+ () => stripInlineFormatting(cleanMarkdown).toPlainOffset,
89
+ [cleanMarkdown],
90
+ );
91
+
92
+ useImperativeHandle(ref, () => ({
93
+ getContainer: () => containerRef.current,
94
+ scrollToComment: (commentId: string) => {
95
+ if (!containerRef.current) return;
96
+ const marks = containerRef.current.querySelectorAll(
97
+ '.comment-highlight, .mermaid-comment-highlight',
98
+ );
99
+ const mark = Array.from(marks).find((m) =>
100
+ (m as HTMLElement).dataset.commentIds?.split(',').includes(commentId),
101
+ );
102
+ if (mark) {
103
+ mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
104
+ }
105
+ },
106
+ getActiveMark: () => activeMarkRef.current,
107
+ getActiveMarks: () => {
108
+ if (!containerRef.current) return [];
109
+ return Array.from(
110
+ containerRef.current.querySelectorAll(
111
+ '.comment-highlight-active, .mermaid-comment-highlight-active',
112
+ ),
113
+ ) as HTMLElement[];
114
+ },
115
+ getHeadings: () => {
116
+ if (!containerRef.current) return [];
117
+ const els = containerRef.current.querySelectorAll('h1, h2, h3, h4, h5, h6');
118
+ return Array.from(els).map((el) => ({
119
+ id: el.id,
120
+ text: el.textContent?.trim() || '',
121
+ level: parseInt(el.tagName[1], 10),
122
+ }));
123
+ },
124
+ }));
125
+
126
+ // Set innerHTML and apply highlights after React commits.
127
+ // We manage innerHTML ourselves (no dangerouslySetInnerHTML) so React's
128
+ // reconciliation never interferes with our DOM modifications.
129
+ useLayoutEffect(() => {
130
+ const container = containerRef.current;
131
+ if (!container) return;
132
+
133
+ // Set innerHTML from scratch — guarantees a clean starting state
134
+ container.innerHTML = html;
135
+
136
+ // --- Heading IDs ---
137
+ assignHeadingIds(container);
138
+
139
+ // --- Mermaid blocks ---
140
+ const mermaidPres = container.querySelectorAll('pre');
141
+ for (const pre of mermaidPres) {
142
+ const code = pre.querySelector('code.language-mermaid');
143
+ if (!code) continue;
144
+
145
+ const source = (code.textContent || '').trim();
146
+ if (!source) continue;
147
+
148
+ const result = mermaidSvgMap.get(source);
149
+ if (result?.svg) {
150
+ const wrapper = document.createElement('div');
151
+ wrapper.className = 'mermaid-block';
152
+ const svgDiv = document.createElement('div');
153
+ svgDiv.className = 'mermaid-svg';
154
+ // SVG is already sanitized via DOMPurify in mermaid-renderer.ts
155
+ svgDiv.innerHTML = result.svg;
156
+ wrapper.appendChild(svgDiv);
157
+ pre.replaceWith(wrapper);
158
+ } else if (result?.error) {
159
+ const errDiv = document.createElement('div');
160
+ errDiv.className = 'mermaid-block mermaid-error';
161
+ errDiv.textContent = `Mermaid error: ${result.error}`;
162
+ pre.replaceWith(errDiv);
163
+ }
164
+ // If no result yet (loading), leave the code block as-is until SVGs are ready
165
+ }
166
+
167
+ // --- Comment highlights ---
168
+ // Group comments that share the same anchor AND cleanOffset (exact same highlight).
169
+ // Convert cleanOffset (clean markdown space) → plainOffset (rendered text space)
170
+ // so wrapText can correctly match against DOM text node positions.
171
+ const highlightGroups = new Map<
172
+ string,
173
+ {
174
+ ids: string[];
175
+ anchor: string;
176
+ plainOffset?: number;
177
+ contextBefore?: string;
178
+ contextAfter?: string;
179
+ }
180
+ >();
181
+ for (const comment of comments) {
182
+ if (enableResolve && getEffectiveStatus(comment) === 'resolved') continue;
183
+ const plainOffset =
184
+ comment.cleanOffset != null ? toPlainOffset(comment.cleanOffset) : undefined;
185
+ const key = `${comment.cleanOffset ?? ''}:${comment.anchor}`;
186
+ const group = highlightGroups.get(key) || {
187
+ ids: [],
188
+ anchor: comment.anchor,
189
+ plainOffset,
190
+ contextBefore: comment.contextBefore,
191
+ contextAfter: comment.contextAfter,
192
+ };
193
+ group.ids.push(comment.id);
194
+ highlightGroups.set(key, group);
195
+ }
196
+
197
+ for (const {
198
+ anchor,
199
+ ids,
200
+ plainOffset,
201
+ contextBefore,
202
+ contextAfter,
203
+ } of highlightGroups.values()) {
204
+ wrapText(
205
+ container,
206
+ anchor,
207
+ (mark) => {
208
+ mark.className = 'comment-highlight';
209
+ mark.dataset.commentIds = ids.join(',');
210
+ if (ids.includes(activeCommentId || '')) {
211
+ mark.classList.add('comment-highlight-active');
212
+ }
213
+ },
214
+ plainOffset,
215
+ contextBefore,
216
+ contextAfter,
217
+ );
218
+ }
219
+
220
+ // IMPORTANT: Mermaid highlight quirks (do NOT refactor to class-based styles):
221
+ // 1. Chrome ignores class-based background-color on inline elements inside
222
+ // SVG foreignObject — only inline style="..." works.
223
+ // 2. CSS text-decoration on <mark> prevents text wrapping inside foreignObject.
224
+ // 3. CSS background shorthand (e.g. linear-gradient) also prevents wrapping.
225
+ // 4. Headless Chromium does NOT reproduce these issues — can't verify headlessly.
226
+ // Solution: keep the <mark> but swap class styles for inline styles.
227
+ const mermaidTheme = getMermaidHighlightTheme(getComputedStyle(document.documentElement));
228
+ for (const mark of container.querySelectorAll(
229
+ '.mermaid-block mark.comment-highlight, .mermaid-block mark.comment-highlight-active',
230
+ )) {
231
+ const el = mark as HTMLElement;
232
+ const isActive = el.classList.contains('comment-highlight-active');
233
+ el.classList.remove('comment-highlight', 'comment-highlight-active');
234
+ el.classList.add('mermaid-comment-highlight');
235
+ if (isActive) {
236
+ el.classList.add('mermaid-comment-highlight-active');
237
+ }
238
+ applyMermaidHighlightStyles(el, mermaidTheme, isActive);
239
+ }
240
+ const cleanupMermaidLayout = scheduleMermaidLayoutStabilization(container);
241
+
242
+ // --- Selection highlight ---
243
+ if (selectionText) {
244
+ wrapText(
245
+ container,
246
+ selectionText,
247
+ (mark) => {
248
+ mark.className = 'selection-highlight';
249
+ },
250
+ selectionOffset ?? undefined,
251
+ );
252
+ }
253
+
254
+ // --- Search highlights ---
255
+ if (searchQuery) {
256
+ const count = highlightSearchMatches(container, searchQuery, searchActiveIndex ?? 0);
257
+ searchCountCb.current?.(count);
258
+ } else {
259
+ searchCountCb.current?.(0);
260
+ }
261
+
262
+ // Store reference to the active mark for drag handles
263
+ activeMarkRef.current = container.querySelector(
264
+ '.comment-highlight-active, .mermaid-comment-highlight-active',
265
+ ) as HTMLElement | null;
266
+
267
+ return cleanupMermaidLayout;
268
+ }, [
269
+ html,
270
+ comments,
271
+ activeCommentId,
272
+ selectionText,
273
+ selectionOffset,
274
+ toPlainOffset,
275
+ enableResolve,
276
+ searchQuery,
277
+ searchActiveIndex,
278
+ mermaidSvgMap,
279
+ ]);
280
+
281
+ const handleClick = (e: React.MouseEvent) => {
282
+ const mark = (e.target as HTMLElement).closest(
283
+ '.comment-highlight, .mermaid-comment-highlight',
284
+ ) as HTMLElement | null;
285
+ if (mark?.dataset.commentIds) {
286
+ const ids = mark.dataset.commentIds.split(',');
287
+ onHighlightClick(ids[0]);
288
+ }
289
+ };
290
+
291
+ const handleContextMenu = (e: React.MouseEvent) => {
292
+ if (!onCtxMenu) return;
293
+
294
+ // Check if right-click is on a comment highlight
295
+ const mark = (e.target as HTMLElement).closest(
296
+ '.comment-highlight, .mermaid-comment-highlight',
297
+ ) as HTMLElement | null;
298
+ if (mark?.dataset.commentIds) {
299
+ e.preventDefault();
300
+ const ids = mark.dataset.commentIds.split(',');
301
+ onCtxMenu({ type: 'highlight', commentIds: ids, x: e.clientX, y: e.clientY });
302
+ return;
303
+ }
304
+
305
+ // Check if there is a text selection within the container
306
+ const sel = window.getSelection();
307
+ if (
308
+ sel &&
309
+ sel.toString().trim().length > 0 &&
310
+ containerRef.current?.contains(sel.anchorNode)
311
+ ) {
312
+ e.preventDefault();
313
+ onCtxMenu({ type: 'selection', x: e.clientX, y: e.clientY });
314
+ return;
315
+ }
316
+ };
317
+
318
+ return (
319
+ <div
320
+ ref={containerRef}
321
+ className="prose max-w-none prose-headings:scroll-mt-4
322
+ prose-h1:text-2xl prose-h1:font-bold prose-h1:border-b prose-h1:pb-2
323
+ prose-h2:text-xl prose-h2:font-semibold prose-h2:mt-8
324
+ prose-h3:text-lg prose-h3:font-medium
325
+ prose-p:leading-relaxed
326
+ prose-table:text-sm
327
+ prose-th:font-semibold
328
+ prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-sm prose-code:font-normal prose-code:before:content-none prose-code:after:content-none"
329
+ onClick={handleClick}
330
+ onContextMenu={handleContextMenu}
331
+ />
332
+ );
333
+ }),
334
+ );
335
+
336
+ /** Find an occurrence of `text` in the container's text nodes and wrap it in <mark> elements.
337
+ * When `hintOffset` is provided (in rendered/plain-text space), uses it to disambiguate
338
+ * duplicate anchor text. When `contextBefore`/`contextAfter` are provided, uses them as
339
+ * primary disambiguation (more reliable than offset across coordinate spaces).
340
+ * Handles text that spans multiple DOM elements. */
341
+ function wrapText(
342
+ container: HTMLElement,
343
+ text: string,
344
+ configure: (mark: HTMLElement) => void,
345
+ hintOffset?: number,
346
+ contextBefore?: string,
347
+ contextAfter?: string,
348
+ ) {
349
+ // Collect ALL text nodes — include those inside marks to support overlapping highlights
350
+ const allTextNodes = collectVisibleTextNodes(container);
351
+ if (allTextNodes.length === 0) return;
352
+
353
+ // Build concatenated text with position tracking (all nodes, for offset-based matching)
354
+ const allNodeInfo: { node: Text; globalStart: number; length: number }[] = [];
355
+ let allPos = 0;
356
+ for (const tn of allTextNodes) {
357
+ const len = tn.textContent?.length || 0;
358
+ allNodeInfo.push({ node: tn, globalStart: allPos, length: len });
359
+ allPos += len;
360
+ }
361
+ const fullText = allTextNodes.map((n) => n.textContent || '').join('');
362
+
363
+ // Find the match
364
+ let matchStart: number;
365
+ let matchEnd: number;
366
+
367
+ if (hintOffset != null) {
368
+ // Collect ALL occurrences to support context-based disambiguation
369
+ const allOccs: number[] = [];
370
+ let sf = 0;
371
+ while (sf < fullText.length) {
372
+ const idx = fullText.indexOf(text, sf);
373
+ if (idx === -1) break;
374
+ allOccs.push(idx);
375
+ sf = idx + 1;
376
+ }
377
+
378
+ if (allOccs.length > 0) {
379
+ let best: number;
380
+ if (allOccs.length === 1) {
381
+ best = allOccs[0];
382
+ } else if (contextBefore || contextAfter) {
383
+ // Context-based disambiguation: context strings are from the same
384
+ // DOM textContent space as fullText, so compare directly (no normalization).
385
+ let bestScore = -1;
386
+ let bestDist = Infinity;
387
+ best = allOccs[0];
388
+ for (const occ of allOccs) {
389
+ let score = 0;
390
+ if (contextBefore) {
391
+ const before = fullText.slice(Math.max(0, occ - contextBefore.length), occ);
392
+ for (let j = 1; j <= Math.min(before.length, contextBefore.length); j++) {
393
+ if (before[before.length - j] === contextBefore[contextBefore.length - j]) score++;
394
+ else break;
395
+ }
396
+ }
397
+ if (contextAfter) {
398
+ const after = fullText.slice(
399
+ occ + text.length,
400
+ occ + text.length + contextAfter.length,
401
+ );
402
+ for (let j = 0; j < Math.min(after.length, contextAfter.length); j++) {
403
+ if (after[j] === contextAfter[j]) score++;
404
+ else break;
405
+ }
406
+ }
407
+ const dist = Math.abs(occ - hintOffset);
408
+ if (score > bestScore || (score === bestScore && dist < bestDist)) {
409
+ bestScore = score;
410
+ best = occ;
411
+ bestDist = dist;
412
+ }
413
+ }
414
+ } else {
415
+ // No context — use the existing hintOffset proximity with search window.
416
+ // When the anchor is drag-expanded backwards, it can start well before
417
+ // the hint (the marker stays put but the anchor grows leftward).
418
+ const searchWindow = Math.max(20, text.length);
419
+ const exactIdx = fullText.indexOf(text, Math.max(0, hintOffset - searchWindow));
420
+ if (exactIdx !== -1 && exactIdx <= hintOffset + 20) {
421
+ best = exactIdx;
422
+ } else {
423
+ best = allOccs.reduce((b, idx) =>
424
+ Math.abs(idx - hintOffset) < Math.abs(b - hintOffset) ? idx : b,
425
+ );
426
+ }
427
+ }
428
+ matchStart = best;
429
+ matchEnd = best + text.length;
430
+ } else {
431
+ // No exact match — try flexible whitespace search
432
+ const result = flexibleSearch(fullText, text);
433
+ if (!result) return;
434
+ matchStart = result.start;
435
+ matchEnd = result.end;
436
+ }
437
+ } else {
438
+ // No offset — first occurrence (used for selection highlights)
439
+ const exactIdx = fullText.indexOf(text);
440
+ if (exactIdx !== -1) {
441
+ matchStart = exactIdx;
442
+ matchEnd = exactIdx + text.length;
443
+ } else {
444
+ const result = flexibleSearch(fullText, text);
445
+ if (!result) return;
446
+ matchStart = result.start;
447
+ matchEnd = result.end;
448
+ }
449
+ }
450
+
451
+ // Determine which text nodes the match spans and their local offsets
452
+ const wraps: { node: Text; start: number; end: number }[] = [];
453
+ for (const info of allNodeInfo) {
454
+ const nodeEnd = info.globalStart + info.length;
455
+ if (nodeEnd <= matchStart || info.globalStart >= matchEnd) continue;
456
+ const localStart = Math.max(0, matchStart - info.globalStart);
457
+ const localEnd = Math.min(info.length, matchEnd - info.globalStart);
458
+ if (localStart < localEnd) {
459
+ wraps.push({ node: info.node, start: localStart, end: localEnd });
460
+ }
461
+ }
462
+ if (wraps.length === 0) return;
463
+
464
+ // Filter out whitespace-only portions
465
+ const visibleWraps = wraps.filter(({ node: tn, start, end }) => {
466
+ const slice = tn.textContent?.slice(start, end) || '';
467
+ return slice.trim().length > 0;
468
+ });
469
+ if (visibleWraps.length === 0) return;
470
+
471
+ // Group wraps by block parent so we merge wraps within the same block
472
+ // (e.g. text nodes split by <strong>) into a single <mark>, while creating
473
+ // separate marks for wraps in different blocks (e.g. different <li>s).
474
+ const groups: { node: Text; start: number; end: number }[][] = [];
475
+ let currentGroup: (typeof groups)[0] = [];
476
+ let currentBlock: Element | null = null;
477
+
478
+ for (const w of visibleWraps) {
479
+ const block = getBlockParent(w.node);
480
+ if (block !== currentBlock && currentGroup.length > 0) {
481
+ groups.push(currentGroup);
482
+ currentGroup = [];
483
+ }
484
+ currentBlock = block;
485
+ currentGroup.push(w);
486
+ }
487
+ if (currentGroup.length > 0) groups.push(currentGroup);
488
+
489
+ // Process each group in reverse to avoid invalidating earlier positions
490
+ for (let g = groups.length - 1; g >= 0; g--) {
491
+ const group = groups[g];
492
+ if (group.length > 1) {
493
+ // Multiple wraps in the same block — use Range to wrap them in a single
494
+ // <mark>. extractContents() splits partially-selected inline elements
495
+ // (e.g. <strong>), preserving formatting while producing one mark (no seam).
496
+ const mark = document.createElement('mark');
497
+ configure(mark);
498
+
499
+ const firstWrap = group[0];
500
+ const lastWrap = group[group.length - 1];
501
+ const range = document.createRange();
502
+ range.setStart(firstWrap.node, firstWrap.start);
503
+ range.setEnd(lastWrap.node, lastWrap.end);
504
+
505
+ try {
506
+ const fragment = range.extractContents();
507
+ mark.appendChild(fragment);
508
+ range.insertNode(mark);
509
+ } catch {
510
+ // Skip if extraction fails
511
+ }
512
+ } else {
513
+ // Single wrap in this block — use surroundContents
514
+ const { node: tn, start, end } = group[0];
515
+ const range = document.createRange();
516
+ range.setStart(tn, start);
517
+ range.setEnd(tn, end);
518
+ const mark = document.createElement('mark');
519
+ configure(mark);
520
+ try {
521
+ range.surroundContents(mark);
522
+ } catch {
523
+ try {
524
+ const fragment = range.extractContents();
525
+ mark.appendChild(fragment);
526
+ range.insertNode(mark);
527
+ } catch {
528
+ // Skip if all wrapping fails
529
+ }
530
+ }
531
+ }
532
+ }
533
+ }
534
+
535
+ const BLOCK_TAGS = new Set([
536
+ 'P',
537
+ 'LI',
538
+ 'DIV',
539
+ 'BLOCKQUOTE',
540
+ 'TD',
541
+ 'TH',
542
+ 'DD',
543
+ 'DT',
544
+ 'PRE',
545
+ 'H1',
546
+ 'H2',
547
+ 'H3',
548
+ 'H4',
549
+ 'H5',
550
+ 'H6',
551
+ 'SECTION',
552
+ 'ARTICLE',
553
+ ]);
554
+
555
+ function getBlockParent(node: Node): Element | null {
556
+ let el = node.parentElement;
557
+ while (el) {
558
+ if (BLOCK_TAGS.has(el.tagName)) return el;
559
+ el = el.parentElement;
560
+ }
561
+ return null;
562
+ }
563
+
564
+ /**
565
+ * Search for `needle` in `haystack` with flexible whitespace matching.
566
+ * Whitespace runs in the needle can match zero or more whitespace chars in the haystack,
567
+ * handling cross-element selections where sel.toString() adds newlines that aren't in text nodes.
568
+ */
569
+ function flexibleSearch(
570
+ haystack: string,
571
+ needle: string,
572
+ startFrom: number = 0,
573
+ ): { start: number; end: number } | null {
574
+ const parts = needle.split(/\s+/).filter(Boolean);
575
+ if (parts.length === 0) return null;
576
+ if (parts.length === 1) {
577
+ const idx = haystack.indexOf(parts[0], startFrom);
578
+ return idx === -1 ? null : { start: idx, end: idx + parts[0].length };
579
+ }
580
+
581
+ let searchFrom = startFrom;
582
+ while (searchFrom < haystack.length) {
583
+ const firstIdx = haystack.indexOf(parts[0], searchFrom);
584
+ if (firstIdx === -1) return null;
585
+
586
+ let pos = firstIdx + parts[0].length;
587
+ let matched = true;
588
+ for (let i = 1; i < parts.length; i++) {
589
+ // Skip optional whitespace between segments
590
+ while (pos < haystack.length && /\s/.test(haystack[pos])) pos++;
591
+ if (haystack.startsWith(parts[i], pos)) {
592
+ pos += parts[i].length;
593
+ } else {
594
+ matched = false;
595
+ break;
596
+ }
597
+ }
598
+ if (matched) return { start: firstIdx, end: pos };
599
+ searchFrom = firstIdx + 1;
600
+ }
601
+ return null;
602
+ }
603
+
604
+ /** Find all case-insensitive occurrences of `query` in the container's text and wrap them
605
+ * in <mark class="search-highlight"> elements. The match at `activeIndex` gets an additional
606
+ * `search-highlight-active` class and is scrolled into view. */
607
+ export function highlightSearchMatches(
608
+ container: HTMLElement,
609
+ query: string,
610
+ activeIndex: number,
611
+ ): number {
612
+ const textNodes = collectVisibleTextNodes(container);
613
+ if (textNodes.length === 0) return 0;
614
+
615
+ const nodeInfo: { node: Text; globalStart: number; length: number }[] = [];
616
+ let pos = 0;
617
+ for (const n of textNodes) {
618
+ const len = n.textContent?.length || 0;
619
+ nodeInfo.push({ node: n, globalStart: pos, length: len });
620
+ pos += len;
621
+ }
622
+ const fullText = textNodes.map((n) => n.textContent || '').join('');
623
+ const lowerFull = fullText.toLowerCase();
624
+ const lowerQuery = query.toLowerCase();
625
+
626
+ // Find all non-overlapping match positions
627
+ const matches: { start: number; end: number }[] = [];
628
+ let searchPos = 0;
629
+ while (searchPos < lowerFull.length) {
630
+ const idx = lowerFull.indexOf(lowerQuery, searchPos);
631
+ if (idx === -1) break;
632
+ matches.push({ start: idx, end: idx + query.length });
633
+ searchPos = idx + query.length;
634
+ }
635
+ if (matches.length === 0) return 0;
636
+
637
+ // Process matches in reverse to preserve earlier text node positions
638
+ for (let i = matches.length - 1; i >= 0; i--) {
639
+ const match = matches[i];
640
+ const isActive = i === activeIndex;
641
+
642
+ // Collect text node portions spanning this match
643
+ const wraps: { node: Text; start: number; end: number }[] = [];
644
+ for (const info of nodeInfo) {
645
+ const nodeEnd = info.globalStart + info.length;
646
+ if (nodeEnd <= match.start || info.globalStart >= match.end) continue;
647
+ const localStart = Math.max(0, match.start - info.globalStart);
648
+ const localEnd = Math.min(info.length, match.end - info.globalStart);
649
+ if (localStart < localEnd) wraps.push({ node: info.node, start: localStart, end: localEnd });
650
+ }
651
+
652
+ // Wrap each portion in reverse order within this match
653
+ for (let w = wraps.length - 1; w >= 0; w--) {
654
+ const { node: wn, start, end } = wraps[w];
655
+ const range = document.createRange();
656
+ range.setStart(wn, start);
657
+ range.setEnd(wn, end);
658
+ const mark = document.createElement('mark');
659
+ mark.className = isActive ? 'search-highlight search-highlight-active' : 'search-highlight';
660
+ if (isActive) mark.dataset.searchActive = 'true';
661
+ try {
662
+ range.surroundContents(mark);
663
+ } catch {
664
+ try {
665
+ const fragment = range.extractContents();
666
+ mark.appendChild(fragment);
667
+ range.insertNode(mark);
668
+ } catch {
669
+ /* skip */
670
+ }
671
+ }
672
+ }
673
+ }
674
+
675
+ // Scroll active match into view
676
+ const activeMark = container.querySelector('mark[data-search-active]');
677
+ if (activeMark) {
678
+ activeMark.scrollIntoView({ behavior: 'smooth', block: 'center' });
679
+ }
680
+
681
+ return matches.length;
682
+ }