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,291 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
buildHighlightedHtml,
|
|
4
|
+
escapeHtml,
|
|
5
|
+
extractRawHeadings,
|
|
6
|
+
splitHighlightedHtml,
|
|
7
|
+
} from './RawView';
|
|
8
|
+
|
|
9
|
+
describe('escapeHtml', () => {
|
|
10
|
+
it('escapes &, <, >, "', () => {
|
|
11
|
+
expect(escapeHtml('a & b < c > d "e"')).toBe('a & b < c > d "e"');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns empty string unchanged', () => {
|
|
15
|
+
expect(escapeHtml('')).toBe('');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('does not double-escape', () => {
|
|
19
|
+
expect(escapeHtml('&')).toBe('&amp;');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('buildHighlightedHtml', () => {
|
|
24
|
+
describe('plain text', () => {
|
|
25
|
+
it('returns escaped plain text when no syntax matches', () => {
|
|
26
|
+
const html = buildHighlightedHtml('Just some plain text.');
|
|
27
|
+
expect(html).toBe('Just some plain text.');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('preserves empty lines', () => {
|
|
31
|
+
const html = buildHighlightedHtml('line1\n\nline3');
|
|
32
|
+
expect(html).toBe('line1\n\nline3');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('headings', () => {
|
|
37
|
+
it('highlights ATX headings', () => {
|
|
38
|
+
const html = buildHighlightedHtml('# Hello World');
|
|
39
|
+
expect(html).toContain('class="raw-heading"');
|
|
40
|
+
expect(html).toContain('# Hello World');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('highlights h2-h6', () => {
|
|
44
|
+
for (const prefix of ['##', '###', '####', '#####', '######']) {
|
|
45
|
+
const html = buildHighlightedHtml(`${prefix} Heading`);
|
|
46
|
+
expect(html).toContain('class="raw-heading"');
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does not highlight # without space', () => {
|
|
51
|
+
const html = buildHighlightedHtml('#nospace');
|
|
52
|
+
expect(html).not.toContain('raw-heading');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('bold and italic', () => {
|
|
57
|
+
it('highlights bold text', () => {
|
|
58
|
+
const html = buildHighlightedHtml('**bold text**');
|
|
59
|
+
expect(html).toContain('class="raw-bold"');
|
|
60
|
+
expect(html).toContain('**bold text**');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('highlights italic text', () => {
|
|
64
|
+
const html = buildHighlightedHtml('*italic text*');
|
|
65
|
+
expect(html).toContain('class="raw-italic"');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not highlight bold as italic', () => {
|
|
69
|
+
const html = buildHighlightedHtml('**bold** *italic*');
|
|
70
|
+
// Bold should be matched, not italic within bold
|
|
71
|
+
expect(html).toContain('raw-bold');
|
|
72
|
+
expect(html).toContain('raw-italic');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('inline code', () => {
|
|
77
|
+
it('highlights inline code', () => {
|
|
78
|
+
const html = buildHighlightedHtml('Use `console.log()` here');
|
|
79
|
+
expect(html).toContain('class="raw-inline-code"');
|
|
80
|
+
expect(html).toContain('`console.log()`');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('links', () => {
|
|
85
|
+
it('highlights markdown links', () => {
|
|
86
|
+
const html = buildHighlightedHtml('[click here](https://example.com)');
|
|
87
|
+
expect(html).toContain('class="raw-link"');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('blockquotes', () => {
|
|
92
|
+
it('highlights blockquote lines', () => {
|
|
93
|
+
const html = buildHighlightedHtml('> This is a quote');
|
|
94
|
+
expect(html).toContain('class="raw-blockquote"');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('horizontal rules', () => {
|
|
99
|
+
it('highlights --- as HR', () => {
|
|
100
|
+
const html = buildHighlightedHtml('---');
|
|
101
|
+
expect(html).toContain('class="raw-hr"');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('highlights ---- (4+ dashes) as HR', () => {
|
|
105
|
+
const html = buildHighlightedHtml('----');
|
|
106
|
+
expect(html).toContain('class="raw-hr"');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('highlights *** as HR', () => {
|
|
110
|
+
const html = buildHighlightedHtml('***');
|
|
111
|
+
expect(html).toContain('class="raw-hr"');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('tables', () => {
|
|
116
|
+
it('highlights table rows', () => {
|
|
117
|
+
const html = buildHighlightedHtml('| A | B |\n|---|---|\n| 1 | 2 |');
|
|
118
|
+
expect(html).toContain('class="raw-table"');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('frontmatter', () => {
|
|
123
|
+
it('highlights YAML frontmatter at start of file', () => {
|
|
124
|
+
const html = buildHighlightedHtml('---\ntitle: Hello\n---\n# Content');
|
|
125
|
+
expect(html).toContain('class="raw-frontmatter"');
|
|
126
|
+
expect(html).toContain('class="raw-heading"');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('does not highlight --- in middle of file as frontmatter', () => {
|
|
130
|
+
const html = buildHighlightedHtml('# Title\n\n---\n\nMore text');
|
|
131
|
+
// The --- should be an HR, not frontmatter
|
|
132
|
+
expect(html).not.toContain('raw-frontmatter');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('comment markers', () => {
|
|
137
|
+
const marker =
|
|
138
|
+
'<!-- @comment{"id":"abc","anchor":"hello","text":"fix this","author":"User","timestamp":"2026-01-01T00:00:00Z","replies":[]} -->';
|
|
139
|
+
|
|
140
|
+
it('highlights comment markers', () => {
|
|
141
|
+
const html = buildHighlightedHtml(`Some text ${marker}hello world`);
|
|
142
|
+
expect(html).toContain('class="raw-comment-marker"');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('adds data-comment-id attribute', () => {
|
|
146
|
+
const html = buildHighlightedHtml(`${marker}hello`);
|
|
147
|
+
expect(html).toContain('data-comment-id="abc"');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('handles multiline comment markers', () => {
|
|
151
|
+
// Use \\n in JSON (escaped newline) so JSON.parse succeeds — this is how the app serializes them
|
|
152
|
+
const multilineMarker =
|
|
153
|
+
'<!-- @comment{"id":"m1","anchor":"test","text":"long\\ncomment","author":"User","timestamp":"2026-01-01T00:00:00Z","replies":[]} -->';
|
|
154
|
+
const html = buildHighlightedHtml(`before ${multilineMarker}after`);
|
|
155
|
+
expect(html).toContain('data-comment-id="m1"');
|
|
156
|
+
expect(html).toContain('raw-comment-marker');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('gives comment markers priority over bold', () => {
|
|
160
|
+
// When bold wraps around a comment marker: **<!-- @comment{...} -->text**
|
|
161
|
+
const raw = `**${marker}hello world**`;
|
|
162
|
+
const html = buildHighlightedHtml(raw);
|
|
163
|
+
// Comment marker should be highlighted, not swallowed by bold
|
|
164
|
+
expect(html).toContain('raw-comment-marker');
|
|
165
|
+
expect(html).toContain('data-comment-id="abc"');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('gives comment markers priority over headings', () => {
|
|
169
|
+
const raw = `## ${marker}Section Title`;
|
|
170
|
+
const html = buildHighlightedHtml(raw);
|
|
171
|
+
expect(html).toContain('raw-comment-marker');
|
|
172
|
+
expect(html).toContain('data-comment-id="abc"');
|
|
173
|
+
// Heading should not overlap with the comment marker
|
|
174
|
+
expect(html).not.toContain('raw-heading');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('handles multiple comment markers', () => {
|
|
178
|
+
const m1 =
|
|
179
|
+
'<!-- @comment{"id":"c1","anchor":"a","text":"x","author":"U","timestamp":"2026-01-01T00:00:00Z","replies":[]} -->';
|
|
180
|
+
const m2 =
|
|
181
|
+
'<!-- @comment{"id":"c2","anchor":"b","text":"y","author":"U","timestamp":"2026-01-01T00:00:00Z","replies":[]} -->';
|
|
182
|
+
const html = buildHighlightedHtml(`${m1}alpha ${m2}beta`);
|
|
183
|
+
expect(html).toContain('data-comment-id="c1"');
|
|
184
|
+
expect(html).toContain('data-comment-id="c2"');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('overlap resolution', () => {
|
|
189
|
+
it('first syntax match wins when two non-comment rules overlap', () => {
|
|
190
|
+
// Inline code appears before bold in rule order
|
|
191
|
+
// But if bold wraps inline code, the one that starts first wins
|
|
192
|
+
const html = buildHighlightedHtml('**bold `code` more**');
|
|
193
|
+
// Bold starts first, should capture everything
|
|
194
|
+
expect(html).toContain('raw-bold');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('does not apply syntax highlighting inside comment markers', () => {
|
|
198
|
+
const marker =
|
|
199
|
+
'<!-- @comment{"id":"x","anchor":"# heading","text":"fix","author":"U","timestamp":"2026-01-01T00:00:00Z","replies":[]} -->';
|
|
200
|
+
const html = buildHighlightedHtml(marker);
|
|
201
|
+
// The "# heading" inside the JSON should not be highlighted as a heading
|
|
202
|
+
expect(html).not.toContain('raw-heading');
|
|
203
|
+
expect(html).toContain('raw-comment-marker');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('HTML escaping in output', () => {
|
|
208
|
+
it('escapes HTML entities in plain text', () => {
|
|
209
|
+
const html = buildHighlightedHtml('a < b & c > d');
|
|
210
|
+
expect(html).toContain('<');
|
|
211
|
+
expect(html).toContain('&');
|
|
212
|
+
expect(html).toContain('>');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('escapes HTML inside highlighted spans', () => {
|
|
216
|
+
const html = buildHighlightedHtml('## Title <script>');
|
|
217
|
+
expect(html).toContain('<script>');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('extractRawHeadings', () => {
|
|
223
|
+
it('extracts headings with stable slug ids and line indexes', () => {
|
|
224
|
+
const headings = extractRawHeadings('# Title\n\n## Section One\n\n## Section One\n');
|
|
225
|
+
expect(headings).toEqual([
|
|
226
|
+
{ id: 'title', text: 'Title', level: 1, lineIndex: 0 },
|
|
227
|
+
{ id: 'section-one', text: 'Section One', level: 2, lineIndex: 2 },
|
|
228
|
+
{ id: 'section-one-1', text: 'Section One', level: 2, lineIndex: 4 },
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('ignores inline comment markers when matching heading lines', () => {
|
|
233
|
+
const headings = extractRawHeadings(
|
|
234
|
+
'# Intro\n\n## <!-- @comment{"id":"c1","anchor":"Heading","text":"Fix","author":"U","timestamp":"2026-01-01T00:00:00Z","replies":[]} -->Heading\n',
|
|
235
|
+
);
|
|
236
|
+
expect(headings).toEqual([
|
|
237
|
+
{ id: 'intro', text: 'Intro', level: 1, lineIndex: 0 },
|
|
238
|
+
{ id: 'heading', text: 'Heading', level: 2, lineIndex: 2 },
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('splitHighlightedHtml', () => {
|
|
244
|
+
it('splits plain text into one segment per source line', () => {
|
|
245
|
+
const raw = 'line1\nline2\nline3';
|
|
246
|
+
const html = buildHighlightedHtml(raw);
|
|
247
|
+
const lines = splitHighlightedHtml(raw, html);
|
|
248
|
+
expect(lines).toHaveLength(3);
|
|
249
|
+
expect(lines[0]).toBe('line1');
|
|
250
|
+
expect(lines[1]).toBe('line2');
|
|
251
|
+
expect(lines[2]).toBe('line3');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('handles a single line with no newlines', () => {
|
|
255
|
+
const raw = 'hello world';
|
|
256
|
+
const html = buildHighlightedHtml(raw);
|
|
257
|
+
const lines = splitHighlightedHtml(raw, html);
|
|
258
|
+
expect(lines).toHaveLength(1);
|
|
259
|
+
expect(lines[0]).toBe('hello world');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('preserves empty lines', () => {
|
|
263
|
+
const raw = 'a\n\nb';
|
|
264
|
+
const html = buildHighlightedHtml(raw);
|
|
265
|
+
const lines = splitHighlightedHtml(raw, html);
|
|
266
|
+
expect(lines).toHaveLength(3);
|
|
267
|
+
expect(lines[0]).toBe('a');
|
|
268
|
+
expect(lines[1]).toBe('');
|
|
269
|
+
expect(lines[2]).toBe('b');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('closes and reopens spans that cross line boundaries', () => {
|
|
273
|
+
const raw = '```mermaid\ngraph TD\n```';
|
|
274
|
+
const html = buildHighlightedHtml(raw);
|
|
275
|
+
const lines = splitHighlightedHtml(raw, html);
|
|
276
|
+
expect(lines).toHaveLength(3);
|
|
277
|
+
// Each line should have balanced tags
|
|
278
|
+
for (const line of lines) {
|
|
279
|
+
const opens = (line.match(/<span/g) ?? []).length;
|
|
280
|
+
const closes = (line.match(/<\/span>/g) ?? []).length;
|
|
281
|
+
expect(opens).toBe(closes);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('matches source line count even with syntax-highlighted content', () => {
|
|
286
|
+
const raw = '# Title\n\n**bold** and *italic*\n\n> quote';
|
|
287
|
+
const html = buildHighlightedHtml(raw);
|
|
288
|
+
const lines = splitHighlightedHtml(raw, html);
|
|
289
|
+
expect(lines).toHaveLength(raw.split('\n').length);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useState,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import {
|
|
11
|
+
type AppSettings,
|
|
12
|
+
type CommentTemplate,
|
|
13
|
+
DEFAULT_SETTINGS,
|
|
14
|
+
DEFAULT_TEMPLATES,
|
|
15
|
+
loadSettings,
|
|
16
|
+
saveSettings,
|
|
17
|
+
} from '../lib/settings';
|
|
18
|
+
import { fetchPreferences } from '../lib/preferences-client';
|
|
19
|
+
|
|
20
|
+
interface SettingsContextValue {
|
|
21
|
+
settings: AppSettings;
|
|
22
|
+
updateTemplates: (templates: CommentTemplate[]) => void;
|
|
23
|
+
updateCommentMaxLength: (maxLength: number) => void;
|
|
24
|
+
updateShowTemplatesByDefault: (show: boolean) => void;
|
|
25
|
+
updateEnableResolve: (enable: boolean) => void;
|
|
26
|
+
updateQuickComment: (quick: boolean) => void;
|
|
27
|
+
resetTemplates: () => void;
|
|
28
|
+
resetAll: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SettingsContext = createContext<SettingsContextValue | null>(null);
|
|
32
|
+
|
|
33
|
+
export function SettingsProvider({ children }: { children: ReactNode }) {
|
|
34
|
+
const [settings, setSettings] = useState<AppSettings>(loadSettings);
|
|
35
|
+
const hasLocalMutationRef = useRef(false);
|
|
36
|
+
|
|
37
|
+
// Hydrate from disk on mount — disk overrides localStorage
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
fetchPreferences().then((prefs) => {
|
|
41
|
+
if (cancelled || hasLocalMutationRef.current) return;
|
|
42
|
+
if (prefs.settings && typeof prefs.settings === 'object') {
|
|
43
|
+
const diskSettings = prefs.settings as Partial<AppSettings>;
|
|
44
|
+
setSettings((prev) => {
|
|
45
|
+
const merged = { ...prev, ...diskSettings };
|
|
46
|
+
saveSettings(merged);
|
|
47
|
+
return merged;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return () => {
|
|
52
|
+
cancelled = true;
|
|
53
|
+
};
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const update = useCallback((patch: Partial<AppSettings>) => {
|
|
57
|
+
hasLocalMutationRef.current = true;
|
|
58
|
+
setSettings((prev) => {
|
|
59
|
+
const next = { ...prev, ...patch };
|
|
60
|
+
saveSettings(next);
|
|
61
|
+
return next;
|
|
62
|
+
});
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const updateTemplates = useCallback(
|
|
66
|
+
(templates: CommentTemplate[]) => update({ templates }),
|
|
67
|
+
[update],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const updateCommentMaxLength = useCallback(
|
|
71
|
+
(commentMaxLength: number) => update({ commentMaxLength }),
|
|
72
|
+
[update],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const updateShowTemplatesByDefault = useCallback(
|
|
76
|
+
(showTemplatesByDefault: boolean) => update({ showTemplatesByDefault }),
|
|
77
|
+
[update],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const updateEnableResolve = useCallback(
|
|
81
|
+
(enableResolve: boolean) => update({ enableResolve }),
|
|
82
|
+
[update],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const updateQuickComment = useCallback(
|
|
86
|
+
(quickComment: boolean) => update({ quickComment }),
|
|
87
|
+
[update],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const resetTemplates = useCallback(() => update({ templates: DEFAULT_TEMPLATES }), [update]);
|
|
91
|
+
|
|
92
|
+
const resetAll = useCallback(() => {
|
|
93
|
+
hasLocalMutationRef.current = true;
|
|
94
|
+
setSettings(DEFAULT_SETTINGS);
|
|
95
|
+
saveSettings(DEFAULT_SETTINGS);
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<SettingsContext.Provider
|
|
100
|
+
value={{
|
|
101
|
+
settings,
|
|
102
|
+
updateTemplates,
|
|
103
|
+
updateCommentMaxLength,
|
|
104
|
+
updateShowTemplatesByDefault,
|
|
105
|
+
updateEnableResolve,
|
|
106
|
+
updateQuickComment,
|
|
107
|
+
resetTemplates,
|
|
108
|
+
resetAll,
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{children}
|
|
112
|
+
</SettingsContext.Provider>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function useSettings(): SettingsContextValue {
|
|
117
|
+
const ctx = useContext(SettingsContext);
|
|
118
|
+
if (!ctx) throw new Error('useSettings must be used within a SettingsProvider');
|
|
119
|
+
return ctx;
|
|
120
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { hashString, getAuthorColor } from './useAuthor';
|
|
3
|
+
|
|
4
|
+
describe('hashString', () => {
|
|
5
|
+
it('returns a non-negative number', () => {
|
|
6
|
+
expect(hashString('test')).toBeGreaterThanOrEqual(0);
|
|
7
|
+
expect(hashString('another string')).toBeGreaterThanOrEqual(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns the same hash for the same input', () => {
|
|
11
|
+
expect(hashString('hello')).toBe(hashString('hello'));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns different hashes for different inputs', () => {
|
|
15
|
+
expect(hashString('alice')).not.toBe(hashString('bob'));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns 0 for empty string', () => {
|
|
19
|
+
expect(hashString('')).toBe(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('handles single character', () => {
|
|
23
|
+
expect(hashString('a')).toBeGreaterThanOrEqual(0);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('getAuthorColor', () => {
|
|
28
|
+
it('returns a color object with bg, text, border', () => {
|
|
29
|
+
const color = getAuthorColor('Alice');
|
|
30
|
+
expect(color).toHaveProperty('bg');
|
|
31
|
+
expect(color).toHaveProperty('text');
|
|
32
|
+
expect(color).toHaveProperty('border');
|
|
33
|
+
expect(color.bg).toMatch(/^#/);
|
|
34
|
+
expect(color.text).toMatch(/^#/);
|
|
35
|
+
expect(color.border).toMatch(/^#/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns the same color for the same author', () => {
|
|
39
|
+
expect(getAuthorColor('Alice')).toEqual(getAuthorColor('Alice'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns different colors for different authors', () => {
|
|
43
|
+
// Not guaranteed for all pairs, but very likely for these
|
|
44
|
+
const colors = new Set([
|
|
45
|
+
getAuthorColor('Alice').bg,
|
|
46
|
+
getAuthorColor('Bob').bg,
|
|
47
|
+
getAuthorColor('Charlie').bg,
|
|
48
|
+
]);
|
|
49
|
+
expect(colors.size).toBeGreaterThan(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns a valid color for empty string', () => {
|
|
53
|
+
const color = getAuthorColor('');
|
|
54
|
+
expect(color).toHaveProperty('bg');
|
|
55
|
+
expect(color).toHaveProperty('text');
|
|
56
|
+
expect(color).toHaveProperty('border');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { fetchPreferences, savePreferencesToDisk } from '../lib/preferences-client';
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = 'md-redline-author';
|
|
5
|
+
const DEFAULT_AUTHOR = 'User';
|
|
6
|
+
|
|
7
|
+
// 8 maximally distinct hues for author color coding
|
|
8
|
+
const AUTHOR_COLORS = [
|
|
9
|
+
{ bg: '#dbeafe', text: '#2563eb', border: '#93c5fd' }, // blue
|
|
10
|
+
{ bg: '#fce7f3', text: '#db2777', border: '#f9a8d4' }, // pink
|
|
11
|
+
{ bg: '#d1fae5', text: '#059669', border: '#6ee7b7' }, // green
|
|
12
|
+
{ bg: '#fef3c7', text: '#d97706', border: '#fcd34d' }, // amber
|
|
13
|
+
{ bg: '#ede9fe', text: '#7c3aed', border: '#c4b5fd' }, // violet
|
|
14
|
+
{ bg: '#ffedd5', text: '#ea580c', border: '#fdba74' }, // orange
|
|
15
|
+
{ bg: '#cffafe', text: '#0891b2', border: '#67e8f9' }, // cyan
|
|
16
|
+
{ bg: '#fce4ec', text: '#e11d48', border: '#f48fb1' }, // rose
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function hashString(str: string): number {
|
|
20
|
+
let hash = 0;
|
|
21
|
+
for (let i = 0; i < str.length; i++) {
|
|
22
|
+
hash = (hash << 5) - hash + str.charCodeAt(i);
|
|
23
|
+
hash |= 0;
|
|
24
|
+
}
|
|
25
|
+
return Math.abs(hash);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getAuthorColor(author: string) {
|
|
29
|
+
return AUTHOR_COLORS[hashString(author) % AUTHOR_COLORS.length];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useAuthor() {
|
|
33
|
+
const [author, setAuthorState] = useState(DEFAULT_AUTHOR);
|
|
34
|
+
const hasLocalMutationRef = useRef(false);
|
|
35
|
+
|
|
36
|
+
// Hydrate from disk on mount
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
fetchPreferences().then((prefs) => {
|
|
40
|
+
if (cancelled || hasLocalMutationRef.current) return;
|
|
41
|
+
if (typeof prefs.author === 'string' && prefs.author.trim() && prefs.author !== author) {
|
|
42
|
+
setAuthorState(prefs.author);
|
|
43
|
+
try {
|
|
44
|
+
localStorage.setItem(STORAGE_KEY, prefs.author);
|
|
45
|
+
} catch {
|
|
46
|
+
/* storage unavailable */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
cancelled = true;
|
|
52
|
+
};
|
|
53
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const setAuthor = useCallback((name: string) => {
|
|
57
|
+
const trimmed = name.trim() || DEFAULT_AUTHOR;
|
|
58
|
+
hasLocalMutationRef.current = true;
|
|
59
|
+
setAuthorState(trimmed);
|
|
60
|
+
try {
|
|
61
|
+
localStorage.setItem(STORAGE_KEY, trimmed);
|
|
62
|
+
} catch {
|
|
63
|
+
// Storage unavailable
|
|
64
|
+
}
|
|
65
|
+
savePreferencesToDisk({ author: trimmed });
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
return { author, setAuthor };
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useCallback, useEffect, type RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Auto-resizes a textarea to fit its content.
|
|
5
|
+
* Call `trigger` after programmatic value changes (e.g. template insert).
|
|
6
|
+
*/
|
|
7
|
+
export function useAutoResize(ref: RefObject<HTMLTextAreaElement | null>, value: string) {
|
|
8
|
+
const resize = useCallback(() => {
|
|
9
|
+
const el = ref.current;
|
|
10
|
+
if (!el) return;
|
|
11
|
+
el.style.height = 'auto';
|
|
12
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
13
|
+
}, [ref]);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
resize();
|
|
17
|
+
}, [value, resize]);
|
|
18
|
+
|
|
19
|
+
return resize;
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import type { SidebarCommentEditorState } from '../lib/comment-editor-state';
|
|
3
|
+
|
|
4
|
+
export function useCommentCardTriggers() {
|
|
5
|
+
const [requestedEditor, setRequestedEditor] = useState<SidebarCommentEditorState>(null);
|
|
6
|
+
|
|
7
|
+
const triggerEdit = useCallback((commentId: string) => {
|
|
8
|
+
setRequestedEditor({ mode: 'comment-edit', commentId, token: Date.now() });
|
|
9
|
+
}, []);
|
|
10
|
+
|
|
11
|
+
const triggerReply = useCallback((commentId: string) => {
|
|
12
|
+
setRequestedEditor({ mode: 'reply-compose', commentId, token: Date.now() });
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
requestedEditor,
|
|
17
|
+
triggerEdit,
|
|
18
|
+
triggerReply,
|
|
19
|
+
};
|
|
20
|
+
}
|