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,417 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import type { MarkdownViewerHandle } from '../components/MarkdownViewer';
3
+ import type { MdComment } from '../types';
4
+ import { applyMermaidHighlightStyles, getMermaidHighlightTheme } from '../lib/mermaid-highlights';
5
+ import { collectVisibleTextNodes, getVisibleTextContent } from '../lib/visible-text';
6
+
7
+ interface Position {
8
+ top: number;
9
+ left: number;
10
+ height: number;
11
+ }
12
+
13
+ interface HandlePositions {
14
+ start: Position;
15
+ end: Position;
16
+ }
17
+
18
+ interface UseDragHandlesOptions {
19
+ viewerRef: React.RefObject<MarkdownViewerHandle | null>;
20
+ scrollContainerRef: React.RefObject<HTMLElement | null>;
21
+ activeCommentId: string | null;
22
+ comments: MdComment[];
23
+ onAnchorChange: (commentIds: string[], newAnchor: string) => void;
24
+ }
25
+
26
+ interface UseDragHandlesReturn {
27
+ handlePositions: HandlePositions | null;
28
+ isDragging: boolean;
29
+ onHandleMouseDown: (handle: 'start' | 'end') => void;
30
+ }
31
+
32
+ function caretFromPoint(x: number, y: number): { node: Node; offset: number } | null {
33
+ if ('caretPositionFromPoint' in document) {
34
+ const pos = (
35
+ document as unknown as {
36
+ caretPositionFromPoint(x: number, y: number): { offsetNode: Node; offset: number } | null;
37
+ }
38
+ ).caretPositionFromPoint(x, y);
39
+ if (pos) return { node: pos.offsetNode, offset: pos.offset };
40
+ }
41
+ if ('caretRangeFromPoint' in document) {
42
+ const range = document.caretRangeFromPoint(x, y);
43
+ if (range) return { node: range.startContainer, offset: range.startOffset };
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function computePositions(
49
+ markEls: HTMLElement[],
50
+ scrollContainer: HTMLElement,
51
+ ): HandlePositions | null {
52
+ const allRects: DOMRect[] = [];
53
+ for (const markEl of markEls) {
54
+ const rects = markEl.getClientRects();
55
+ for (let i = 0; i < rects.length; i++) {
56
+ allRects.push(rects[i]);
57
+ }
58
+ }
59
+ if (allRects.length === 0) return null;
60
+
61
+ const containerRect = scrollContainer.getBoundingClientRect();
62
+ const firstRect = allRects[0];
63
+ const lastRect = allRects[allRects.length - 1];
64
+
65
+ return {
66
+ start: {
67
+ top: firstRect.top - containerRect.top + scrollContainer.scrollTop,
68
+ left: firstRect.left - containerRect.left + scrollContainer.scrollLeft,
69
+ height: firstRect.height,
70
+ },
71
+ end: {
72
+ top: lastRect.top - containerRect.top + scrollContainer.scrollTop,
73
+ left: lastRect.right - containerRect.left + scrollContainer.scrollLeft,
74
+ height: lastRect.height,
75
+ },
76
+ };
77
+ }
78
+
79
+ /** Get container-relative text offset for a node+offset pair.
80
+ * Uses Range API to handle both text nodes and element nodes
81
+ * (caretFromPoint can return element nodes at block boundaries). */
82
+ function getContainerTextOffset(container: HTMLElement, targetNode: Node, offset: number): number {
83
+ const range = document.createRange();
84
+ range.selectNodeContents(container);
85
+ try {
86
+ range.setEnd(targetNode, offset);
87
+ } catch {
88
+ return 0;
89
+ }
90
+ return getVisibleTextContent(range.cloneContents()).length;
91
+ }
92
+
93
+ export function useDragHandles({
94
+ viewerRef,
95
+ scrollContainerRef,
96
+ activeCommentId,
97
+ comments,
98
+ onAnchorChange,
99
+ }: UseDragHandlesOptions): UseDragHandlesReturn {
100
+ const [handlePositions, setHandlePositions] = useState<HandlePositions | null>(null);
101
+ const [isDragging, setIsDragging] = useState(false);
102
+
103
+ // Refs for drag state (avoid stale closures in event handlers)
104
+ const dragRef = useRef<{
105
+ handle: 'start' | 'end';
106
+ commentIds: string[];
107
+ originalAnchor: string;
108
+ initialHtml: string;
109
+ // Container-relative text offsets for the fixed boundary
110
+ fixedStartOffset: number;
111
+ fixedEndOffset: number;
112
+ // Current text offsets (updated during drag)
113
+ currentStartOffset: number;
114
+ currentEndOffset: number;
115
+ isMermaid: boolean;
116
+ markEls: HTMLElement[];
117
+ } | null>(null);
118
+
119
+ // Store drag listener cleanup so we can call it on unmount
120
+ const dragCleanupRef = useRef<(() => void) | null>(null);
121
+
122
+ // Compute handle positions when active comment changes
123
+ const updatePositions = useCallback(() => {
124
+ const markEls = viewerRef.current?.getActiveMarks() || [];
125
+ const scrollContainer = scrollContainerRef.current;
126
+ if (markEls.length === 0 || !scrollContainer) {
127
+ setHandlePositions(null);
128
+ return;
129
+ }
130
+ setHandlePositions(computePositions(markEls, scrollContainer));
131
+ }, [viewerRef, scrollContainerRef]);
132
+
133
+ // Recalculate positions when activeCommentId or comments change
134
+ useEffect(() => {
135
+ // Small delay to let useLayoutEffect in MarkdownViewer run first
136
+ const raf = requestAnimationFrame(updatePositions);
137
+ return () => cancelAnimationFrame(raf);
138
+ }, [activeCommentId, comments, updatePositions]);
139
+
140
+ // Recalculate on scroll and resize
141
+ useEffect(() => {
142
+ if (!activeCommentId) return;
143
+ const scrollContainer = scrollContainerRef.current;
144
+ if (!scrollContainer) return;
145
+
146
+ const handler = () => {
147
+ if (!dragRef.current) updatePositions();
148
+ };
149
+ scrollContainer.addEventListener('scroll', handler, { passive: true });
150
+ window.addEventListener('resize', handler);
151
+ return () => {
152
+ scrollContainer.removeEventListener('scroll', handler);
153
+ window.removeEventListener('resize', handler);
154
+ };
155
+ }, [activeCommentId, scrollContainerRef, updatePositions]);
156
+
157
+ const onHandleMouseDown = useCallback(
158
+ (handle: 'start' | 'end') => {
159
+ const markEls = viewerRef.current?.getActiveMarks() || [];
160
+ const container = viewerRef.current?.getContainer();
161
+ if (markEls.length === 0 || !container) return;
162
+
163
+ const commentIds = markEls[0].dataset.commentIds?.split(',') || [];
164
+
165
+ // Find the mark's text boundaries across ALL active marks
166
+ const firstMark = markEls[0];
167
+ const lastMark = markEls[markEls.length - 1];
168
+
169
+ const firstWalker = document.createTreeWalker(firstMark, NodeFilter.SHOW_TEXT);
170
+ const firstTextNode = firstWalker.nextNode() as Text | null;
171
+
172
+ const lastWalker = document.createTreeWalker(lastMark, NodeFilter.SHOW_TEXT);
173
+ let lastTextNode: Text | null = null;
174
+ let tn: Text | null;
175
+ while ((tn = lastWalker.nextNode() as Text | null)) {
176
+ lastTextNode = tn;
177
+ }
178
+ if (!lastTextNode) lastTextNode = firstTextNode;
179
+
180
+ if (!firstTextNode || !lastTextNode) return;
181
+
182
+ const startOffset = getContainerTextOffset(container, firstTextNode, 0);
183
+ const endOffset = getContainerTextOffset(
184
+ container,
185
+ lastTextNode,
186
+ lastTextNode.textContent?.length || 0,
187
+ );
188
+ const originalAnchor = getVisibleTextContent(container).slice(startOffset, endOffset);
189
+
190
+ dragRef.current = {
191
+ handle,
192
+ commentIds,
193
+ originalAnchor,
194
+ initialHtml: container.innerHTML,
195
+ fixedStartOffset: startOffset,
196
+ fixedEndOffset: endOffset,
197
+ currentStartOffset: startOffset,
198
+ currentEndOffset: endOffset,
199
+ isMermaid: markEls.some((mark) => mark.classList.contains('mermaid-comment-highlight')),
200
+ markEls,
201
+ };
202
+
203
+ setIsDragging(true);
204
+ document.body.classList.add('anchor-dragging');
205
+
206
+ const handleMouseMove = (e: MouseEvent) => {
207
+ const drag = dragRef.current;
208
+ if (!drag) return;
209
+
210
+ const caret = caretFromPoint(e.clientX, e.clientY);
211
+ if (!caret || !container.contains(caret.node)) return;
212
+
213
+ // Don't allow dragging into another comment's mark
214
+ const parentMark = (caret.node.parentElement as Element)?.closest?.('mark');
215
+ if (
216
+ parentMark &&
217
+ !drag.markEls.some((m) => m.contains(caret.node)) &&
218
+ !drag.markEls.includes(parentMark as HTMLElement)
219
+ ) {
220
+ return;
221
+ }
222
+
223
+ const caretOffset = getContainerTextOffset(container, caret.node, caret.offset);
224
+
225
+ let newStartOffset: number;
226
+ let newEndOffset: number;
227
+
228
+ if (drag.handle === 'start') {
229
+ newStartOffset = caretOffset;
230
+ newEndOffset = drag.fixedEndOffset;
231
+ } else {
232
+ newStartOffset = drag.fixedStartOffset;
233
+ newEndOffset = caretOffset;
234
+ }
235
+
236
+ // Ensure valid range
237
+ if (newStartOffset >= newEndOffset) return;
238
+
239
+ // Pre-validate text length before modifying DOM
240
+ const fullText = getVisibleTextContent(container);
241
+ const newText = fullText.slice(newStartOffset, newEndOffset);
242
+ if (newText.length < 2) return;
243
+
244
+ // Snapshot DOM before mutation so we can roll back if re-wrapping fails
245
+ const snapshot = container.innerHTML;
246
+
247
+ // Unwrap all active marks, preserving their children
248
+ const oldMarks = container.querySelectorAll(
249
+ 'mark.comment-highlight-active, mark.mermaid-comment-highlight-active',
250
+ );
251
+ oldMarks.forEach((oldMark) => {
252
+ const parent = oldMark.parentNode;
253
+ if (parent) {
254
+ while (oldMark.firstChild) parent.insertBefore(oldMark.firstChild, oldMark);
255
+ parent.removeChild(oldMark);
256
+ }
257
+ });
258
+ if (oldMarks.length > 0) container.normalize();
259
+
260
+ // Collect text node positions after normalize
261
+ const nodeInfos: { node: Text; globalStart: number; length: number }[] = [];
262
+ let pos = 0;
263
+ for (const textNode of collectVisibleTextNodes(container)) {
264
+ const len = textNode.textContent?.length || 0;
265
+ nodeInfos.push({ node: textNode, globalStart: pos, length: len });
266
+ pos += len;
267
+ }
268
+
269
+ // Find text nodes that overlap the new range
270
+ const wraps: { node: Text; start: number; end: number }[] = [];
271
+ for (const info of nodeInfos) {
272
+ const nodeEnd = info.globalStart + info.length;
273
+ if (nodeEnd <= newStartOffset || info.globalStart >= newEndOffset) continue;
274
+ const localStart = Math.max(0, newStartOffset - info.globalStart);
275
+ const localEnd = Math.min(info.length, newEndOffset - info.globalStart);
276
+ if (localStart < localEnd) {
277
+ const slice = info.node.textContent?.slice(localStart, localEnd) || '';
278
+ if (slice.trim()) {
279
+ wraps.push({ node: info.node, start: localStart, end: localEnd });
280
+ }
281
+ }
282
+ }
283
+
284
+ if (wraps.length === 0) {
285
+ // Nothing to wrap — roll back so the old highlight stays visible
286
+ container.innerHTML = snapshot;
287
+ drag.markEls = Array.from(
288
+ container.querySelectorAll(
289
+ 'mark.comment-highlight-active, mark.mermaid-comment-highlight-active',
290
+ ),
291
+ ) as HTMLElement[];
292
+ return;
293
+ }
294
+
295
+ // Wrap each portion in reverse order to avoid invalidating earlier nodes
296
+ const newMarks: HTMLElement[] = [];
297
+ for (let i = wraps.length - 1; i >= 0; i--) {
298
+ const { node, start, end } = wraps[i];
299
+ const range = document.createRange();
300
+ range.setStart(node, start);
301
+ range.setEnd(node, end);
302
+ const mark = document.createElement('mark');
303
+ if (drag.isMermaid) {
304
+ mark.className = 'mermaid-comment-highlight mermaid-comment-highlight-active';
305
+ // Mermaid labels need inline styles; class-only marks break wrapping in foreignObject.
306
+ applyMermaidHighlightStyles(
307
+ mark,
308
+ getMermaidHighlightTheme(getComputedStyle(document.documentElement)),
309
+ true,
310
+ );
311
+ } else {
312
+ mark.className = 'comment-highlight comment-highlight-active';
313
+ }
314
+ mark.dataset.commentIds = drag.commentIds.join(',');
315
+ try {
316
+ range.surroundContents(mark);
317
+ newMarks.unshift(mark);
318
+ } catch {
319
+ // Skip if wrapping fails
320
+ }
321
+ }
322
+
323
+ if (newMarks.length > 0) {
324
+ drag.markEls = newMarks;
325
+ drag.currentStartOffset = newStartOffset;
326
+ drag.currentEndOffset = newEndOffset;
327
+ } else {
328
+ // All wrapping failed — roll back
329
+ container.innerHTML = snapshot;
330
+ drag.markEls = Array.from(
331
+ container.querySelectorAll(
332
+ 'mark.comment-highlight-active, mark.mermaid-comment-highlight-active',
333
+ ),
334
+ ) as HTMLElement[];
335
+ }
336
+
337
+ // Update handle positions
338
+ const scrollContainer = scrollContainerRef.current;
339
+ if (scrollContainer && drag.markEls.length > 0) {
340
+ const positions = computePositions(drag.markEls, scrollContainer);
341
+ if (positions) setHandlePositions(positions);
342
+ }
343
+ };
344
+
345
+ const handleMouseUp = () => {
346
+ const drag = dragRef.current;
347
+ if (drag) {
348
+ // Use container text offsets to get the full anchor including whitespace
349
+ // between styled elements that weren't wrapped in marks
350
+ const newAnchor = getVisibleTextContent(container).slice(
351
+ drag.currentStartOffset,
352
+ drag.currentEndOffset,
353
+ );
354
+ if (newAnchor.length >= 2 && newAnchor !== drag.originalAnchor) {
355
+ onAnchorChange(drag.commentIds, newAnchor);
356
+ }
357
+ }
358
+
359
+ dragRef.current = null;
360
+ setIsDragging(false);
361
+ document.body.classList.remove('anchor-dragging');
362
+ document.removeEventListener('mousemove', handleMouseMove);
363
+ document.removeEventListener('mouseup', handleMouseUp);
364
+ document.removeEventListener('keydown', handleKeyDown);
365
+ dragCleanupRef.current = null;
366
+ };
367
+
368
+ const handleKeyDown = (e: KeyboardEvent) => {
369
+ if (e.key === 'Escape') {
370
+ const drag = dragRef.current;
371
+ if (drag) {
372
+ container.innerHTML = drag.initialHtml;
373
+ drag.markEls = Array.from(
374
+ container.querySelectorAll(
375
+ 'mark.comment-highlight-active, mark.mermaid-comment-highlight-active',
376
+ ),
377
+ ) as HTMLElement[];
378
+ }
379
+ dragRef.current = null;
380
+ setIsDragging(false);
381
+ document.body.classList.remove('anchor-dragging');
382
+ document.removeEventListener('mousemove', handleMouseMove);
383
+ document.removeEventListener('mouseup', handleMouseUp);
384
+ document.removeEventListener('keydown', handleKeyDown);
385
+ dragCleanupRef.current = null;
386
+ updatePositions();
387
+ }
388
+ };
389
+
390
+ document.addEventListener('mousemove', handleMouseMove);
391
+ document.addEventListener('mouseup', handleMouseUp);
392
+ document.addEventListener('keydown', handleKeyDown);
393
+
394
+ dragCleanupRef.current = () => {
395
+ document.removeEventListener('mousemove', handleMouseMove);
396
+ document.removeEventListener('mouseup', handleMouseUp);
397
+ document.removeEventListener('keydown', handleKeyDown);
398
+ document.body.classList.remove('anchor-dragging');
399
+ dragRef.current = null;
400
+ };
401
+ },
402
+ [viewerRef, scrollContainerRef, onAnchorChange, updatePositions],
403
+ );
404
+
405
+ // Clean up drag listeners on unmount
406
+ useEffect(() => {
407
+ return () => {
408
+ dragCleanupRef.current?.();
409
+ };
410
+ }, []);
411
+
412
+ return {
413
+ handlePositions: activeCommentId ? handlePositions : null,
414
+ isDragging,
415
+ onHandleMouseDown,
416
+ };
417
+ }
@@ -0,0 +1,45 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { usePageVisible } from './usePageVisible';
3
+
4
+ interface Options {
5
+ filePath: string | null;
6
+ onExternalChange: (content: string, mtime?: number) => void;
7
+ }
8
+
9
+ /**
10
+ * Connects to the server's SSE /api/watch endpoint for the active file.
11
+ * Calls onExternalChange when the file is modified externally (not by our own saves).
12
+ *
13
+ * Closes the connection when the browser tab is hidden to avoid exhausting
14
+ * the per-origin connection limit across multiple browser tabs.
15
+ */
16
+ export function useFileWatcher({ filePath, onExternalChange }: Options) {
17
+ const callbackRef = useRef(onExternalChange);
18
+ callbackRef.current = onExternalChange;
19
+
20
+ const visible = usePageVisible();
21
+
22
+ useEffect(() => {
23
+ if (!filePath || !visible) return;
24
+
25
+ const url = `/api/watch?path=${encodeURIComponent(filePath)}`;
26
+ const es = new EventSource(url);
27
+
28
+ es.addEventListener('change', (e) => {
29
+ try {
30
+ const { content, mtime } = JSON.parse(e.data);
31
+ callbackRef.current(content, mtime);
32
+ } catch {
33
+ // Ignore malformed events
34
+ }
35
+ });
36
+
37
+ es.onerror = () => {
38
+ // EventSource auto-reconnects; nothing to do
39
+ };
40
+
41
+ return () => {
42
+ es.close();
43
+ };
44
+ }, [filePath, visible]);
45
+ }
@@ -0,0 +1,84 @@
1
+ import { useState, useEffect, useRef, type RefObject, type MutableRefObject } from 'react';
2
+ import type { MarkdownViewerHandle, TocHeading } from '../components/MarkdownViewer';
3
+
4
+ export function useHeadingTracking(
5
+ containerRef: RefObject<HTMLDivElement | null>,
6
+ viewerRef: RefObject<MarkdownViewerHandle | null>,
7
+ html: string,
8
+ ): {
9
+ tocHeadings: TocHeading[];
10
+ activeHeadingId: string | null;
11
+ setActiveHeadingId: (id: string | null) => void;
12
+ spyDisabledRef: MutableRefObject<boolean>;
13
+ scrollSpyRafRef: MutableRefObject<number>;
14
+ } {
15
+ const [tocHeadings, setTocHeadings] = useState<TocHeading[]>([]);
16
+ const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
17
+ const spyDisabledRef = useRef(false);
18
+ const scrollSpyRafRef = useRef(0);
19
+
20
+ // Extract headings from rendered HTML
21
+ useEffect(() => {
22
+ const headings = viewerRef.current?.getHeadings() ?? [];
23
+ setTocHeadings(headings);
24
+ }, [html, viewerRef]);
25
+
26
+ // Track active heading based on scroll position
27
+ useEffect(() => {
28
+ const scrollEl = containerRef.current;
29
+ if (!scrollEl || tocHeadings.length === 0) return;
30
+ const ids = tocHeadings.map((h) => h.id);
31
+
32
+ const runSpy = () => {
33
+ cancelAnimationFrame(scrollSpyRafRef.current);
34
+ scrollSpyRafRef.current = requestAnimationFrame(() => {
35
+ const containerTop = scrollEl.getBoundingClientRect().top;
36
+ const firstVisibleThreshold = scrollEl.clientHeight * 0.6;
37
+
38
+ let lastAboveFoldId: string | null = null;
39
+ let firstVisibleId: string | null = null;
40
+ let firstVisibleTop = Infinity;
41
+ for (const id of ids) {
42
+ const el = scrollEl.querySelector(`#${CSS.escape(id)}`) as HTMLElement | null;
43
+ if (!el) continue;
44
+ const elTop = el.getBoundingClientRect().top - containerTop;
45
+ if (elTop <= 0) {
46
+ lastAboveFoldId = id;
47
+ } else if (elTop < firstVisibleTop) {
48
+ firstVisibleTop = elTop;
49
+ firstVisibleId = id;
50
+ }
51
+ }
52
+
53
+ const activeId =
54
+ firstVisibleId !== null && firstVisibleTop < firstVisibleThreshold
55
+ ? firstVisibleId
56
+ : (lastAboveFoldId ?? firstVisibleId);
57
+ setActiveHeadingId(activeId);
58
+ });
59
+ };
60
+
61
+ const onScroll = () => {
62
+ cancelAnimationFrame(scrollSpyRafRef.current);
63
+ if (spyDisabledRef.current) return;
64
+ runSpy();
65
+ };
66
+
67
+ const onManualScroll = () => {
68
+ spyDisabledRef.current = false;
69
+ };
70
+
71
+ scrollEl.addEventListener('scroll', onScroll, { passive: true });
72
+ scrollEl.addEventListener('wheel', onManualScroll, { passive: true });
73
+ scrollEl.addEventListener('touchstart', onManualScroll, { passive: true });
74
+ runSpy();
75
+ return () => {
76
+ scrollEl.removeEventListener('scroll', onScroll);
77
+ scrollEl.removeEventListener('wheel', onManualScroll);
78
+ scrollEl.removeEventListener('touchstart', onManualScroll);
79
+ cancelAnimationFrame(scrollSpyRafRef.current);
80
+ };
81
+ }, [tocHeadings, containerRef]);
82
+
83
+ return { tocHeadings, activeHeadingId, setActiveHeadingId, spyDisabledRef, scrollSpyRafRef };
84
+ }
@@ -0,0 +1,75 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { hasMermaidBlocks, renderMermaidBlock } from '../lib/mermaid-renderer';
3
+
4
+ export interface MermaidResult {
5
+ svg?: string;
6
+ error?: string;
7
+ }
8
+
9
+ /**
10
+ * Pre-renders mermaid code blocks found in the clean markdown.
11
+ * Returns a Map from trimmed source text → rendered SVG (or error).
12
+ * Results are cached and only re-rendered when source or theme changes.
13
+ */
14
+ export function useMermaidRenderer(
15
+ cleanMarkdown: string,
16
+ theme: string,
17
+ ): Map<string, MermaidResult> {
18
+ const [svgMap, setSvgMap] = useState<Map<string, MermaidResult>>(new Map());
19
+ const cacheRef = useRef<Map<string, { theme: string; result: MermaidResult }>>(new Map());
20
+
21
+ useEffect(() => {
22
+ if (!hasMermaidBlocks(cleanMarkdown)) {
23
+ setSvgMap((prev) => (prev.size > 0 ? new Map() : prev));
24
+ return;
25
+ }
26
+
27
+ // Extract mermaid blocks
28
+ const blocks: string[] = [];
29
+ const regex = /^```mermaid\s*\n([\s\S]*?)^```\s*$/gm;
30
+ let match: RegExpExecArray | null;
31
+ while ((match = regex.exec(cleanMarkdown)) !== null) {
32
+ blocks.push(match[1].trim());
33
+ }
34
+ if (blocks.length === 0) {
35
+ setSvgMap((prev) => (prev.size > 0 ? new Map() : prev));
36
+ return;
37
+ }
38
+
39
+ let cancelled = false;
40
+
41
+ async function renderAll() {
42
+ const newMap = new Map<string, MermaidResult>();
43
+ const cache = cacheRef.current;
44
+
45
+ for (const source of blocks) {
46
+ // Use cache if same theme
47
+ const cached = cache.get(source);
48
+ if (cached && cached.theme === theme) {
49
+ newMap.set(source, cached.result);
50
+ continue;
51
+ }
52
+
53
+ const result = await renderMermaidBlock(source, theme);
54
+ if (cancelled) return;
55
+
56
+ const mermaidResult: MermaidResult =
57
+ 'svg' in result ? { svg: result.svg } : { error: result.error };
58
+ newMap.set(source, mermaidResult);
59
+ cache.set(source, { theme, result: mermaidResult });
60
+ }
61
+
62
+ if (!cancelled) {
63
+ setSvgMap(newMap);
64
+ }
65
+ }
66
+
67
+ renderAll();
68
+
69
+ return () => {
70
+ cancelled = true;
71
+ };
72
+ }, [cleanMarkdown, theme]);
73
+
74
+ return svgMap;
75
+ }
@@ -0,0 +1,22 @@
1
+ import { useState, useCallback, type Dispatch, type SetStateAction } from 'react';
2
+
3
+ export type ModalId = 'commandPalette' | 'fileOpener' | 'settings' | 'shortcuts' | 'search' | null;
4
+
5
+ export function useModalState(): {
6
+ activeModal: ModalId;
7
+ setActiveModal: Dispatch<SetStateAction<ModalId>>;
8
+ toggleModal: (id: ModalId) => void;
9
+ openFilePicker: () => void;
10
+ } {
11
+ const [activeModal, setActiveModal] = useState<ModalId>(null);
12
+
13
+ const toggleModal = useCallback((id: ModalId) => {
14
+ setActiveModal((prev) => (prev === id ? null : id));
15
+ }, []);
16
+
17
+ const openFilePicker = useCallback(() => {
18
+ setActiveModal('fileOpener');
19
+ }, []);
20
+
21
+ return { activeModal, setActiveModal, toggleModal, openFilePicker };
22
+ }