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,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
|
+
}
|