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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/md-redline +255 -0
- package/bin/test-windows.ps1 +70 -0
- package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
- package/dist/assets/arc-DI2g9LXK.js +1 -0
- package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
- package/dist/assets/array-DOVTz2Mq.js +1 -0
- package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
- package/dist/assets/channel-DPCihw7y.js +1 -0
- package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
- package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
- package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
- package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
- package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
- package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
- package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
- package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
- package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
- package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
- package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
- package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
- package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
- package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
- package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
- package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
- package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
- package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
- package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
- package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
- package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
- package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
- package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
- package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
- package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
- package/dist/assets/clone-CI9aUwHe.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
- package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
- package/dist/assets/dagre-CilMRazv.js +1 -0
- package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
- package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
- package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
- package/dist/assets/dist-C_eddq6m.js +1 -0
- package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
- package/dist/assets/flatten-B8XIuT0x.js +1 -0
- package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
- package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
- package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
- package/dist/assets/index-BoggyWS0.css +2 -0
- package/dist/assets/index-aLvjHQW4.js +104 -0
- package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
- package/dist/assets/init-Bft5Ffpj.js +1 -0
- package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
- package/dist/assets/katex-Uj9wLT16.js +265 -0
- package/dist/assets/line-CRxEwpOv.js +1 -0
- package/dist/assets/linear-PDPfFByd.js +1 -0
- package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
- package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
- package/dist/assets/ordinal-DIg8h6NI.js +1 -0
- package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
- package/dist/assets/path-DfRbCp9y.js +1 -0
- package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
- package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
- package/dist/assets/reduce-CV2X8n1a.js +1 -0
- package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
- package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
- package/dist/assets/src-PKe5NtkK.js +1 -0
- package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
- package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
- package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
- package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +14 -0
- package/dist/screenshot.png +0 -0
- package/index.html +13 -0
- package/package.json +105 -0
- package/public/favicon.svg +15 -0
- package/public/screenshot.png +0 -0
- package/server/index.test.ts +814 -0
- package/server/index.ts +736 -0
- package/server/preferences.test.ts +126 -0
- package/server/preferences.ts +76 -0
- package/src/App.tsx +1620 -0
- package/src/components/ActionButton.tsx +41 -0
- package/src/components/CommandPalette.tsx +191 -0
- package/src/components/CommentCard.tsx +556 -0
- package/src/components/CommentForm.tsx +285 -0
- package/src/components/CommentSidebar.tsx +428 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/ContextMenu.tsx +220 -0
- package/src/components/DragHandles.tsx +48 -0
- package/src/components/FileExplorer.tsx +251 -0
- package/src/components/FileOpener.tsx +304 -0
- package/src/components/IconButton.tsx +32 -0
- package/src/components/KeyboardShortcutsPanel.tsx +136 -0
- package/src/components/MarkdownViewer.tsx +682 -0
- package/src/components/RawView.tsx +798 -0
- package/src/components/SearchBar.tsx +129 -0
- package/src/components/Separator.tsx +7 -0
- package/src/components/SettingsPanel.tsx +813 -0
- package/src/components/SplitIconButton.tsx +133 -0
- package/src/components/TabBar.tsx +594 -0
- package/src/components/TableOfContents.tsx +70 -0
- package/src/components/ThemeSelector.tsx +159 -0
- package/src/components/Toast.tsx +99 -0
- package/src/components/Toolbar.tsx +161 -0
- package/src/components/iconButtonVariants.ts +19 -0
- package/src/components/rawView.test.ts +291 -0
- package/src/contexts/SettingsContext.tsx +120 -0
- package/src/hooks/useAuthor.test.ts +58 -0
- package/src/hooks/useAuthor.ts +69 -0
- package/src/hooks/useAutoResize.ts +20 -0
- package/src/hooks/useCommentCardTriggers.ts +20 -0
- package/src/hooks/useComments.test.ts +773 -0
- package/src/hooks/useComments.ts +332 -0
- package/src/hooks/useContextMenu.ts +48 -0
- package/src/hooks/useContextMenuItems.ts +392 -0
- package/src/hooks/useDiffSnapshot.test.ts +130 -0
- package/src/hooks/useDiffSnapshot.ts +67 -0
- package/src/hooks/useDragHandles.ts +417 -0
- package/src/hooks/useFileWatcher.ts +45 -0
- package/src/hooks/useHeadingTracking.ts +84 -0
- package/src/hooks/useMermaidRenderer.ts +75 -0
- package/src/hooks/useModalState.ts +22 -0
- package/src/hooks/usePageVisible.test.ts +69 -0
- package/src/hooks/usePageVisible.ts +19 -0
- package/src/hooks/usePaneLayout.test.ts +108 -0
- package/src/hooks/usePaneLayout.ts +102 -0
- package/src/hooks/useRecentFiles.test.ts +103 -0
- package/src/hooks/useRecentFiles.ts +99 -0
- package/src/hooks/useResizablePanel.test.ts +84 -0
- package/src/hooks/useResizablePanel.ts +118 -0
- package/src/hooks/useSearch.test.ts +72 -0
- package/src/hooks/useSearch.ts +53 -0
- package/src/hooks/useSelection.ts +48 -0
- package/src/hooks/useSessionPersistence.test.ts +59 -0
- package/src/hooks/useSessionPersistence.ts +43 -0
- package/src/hooks/useTabs.test.ts +127 -0
- package/src/hooks/useTabs.ts +561 -0
- package/src/hooks/useThemePersistence.ts +41 -0
- package/src/hooks/useToast.ts +27 -0
- package/src/index.css +1047 -0
- package/src/lib/agent-prompts.test.ts +34 -0
- package/src/lib/agent-prompts.ts +68 -0
- package/src/lib/comment-editor-state.ts +6 -0
- package/src/lib/comment-parser.test.ts +1959 -0
- package/src/lib/comment-parser.ts +1021 -0
- package/src/lib/diff.test.ts +164 -0
- package/src/lib/diff.ts +139 -0
- package/src/lib/heading-slugs.test.ts +85 -0
- package/src/lib/heading-slugs.ts +44 -0
- package/src/lib/http.test.ts +43 -0
- package/src/lib/http.ts +29 -0
- package/src/lib/mermaid-highlights.test.ts +517 -0
- package/src/lib/mermaid-highlights.ts +936 -0
- package/src/lib/mermaid-renderer.test.ts +114 -0
- package/src/lib/mermaid-renderer.ts +89 -0
- package/src/lib/path-utils.test.ts +17 -0
- package/src/lib/path-utils.ts +7 -0
- package/src/lib/platform.test.ts +58 -0
- package/src/lib/platform.ts +14 -0
- package/src/lib/preferences-client.test.ts +177 -0
- package/src/lib/preferences-client.ts +94 -0
- package/src/lib/selection-resolver.test.ts +118 -0
- package/src/lib/selection-resolver.ts +37 -0
- package/src/lib/settings.test.ts +152 -0
- package/src/lib/settings.ts +78 -0
- package/src/lib/shortcut-label.tsx +18 -0
- package/src/lib/themes.ts +21 -0
- package/src/lib/visible-text.test.ts +86 -0
- package/src/lib/visible-text.ts +77 -0
- package/src/main.tsx +22 -0
- package/src/markdown/pipeline.test.ts +82 -0
- package/src/markdown/pipeline.ts +33 -0
- package/src/types.test.ts +43 -0
- package/src/types.ts +46 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- 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
|
+
}
|