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,114 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import DOMPurify from 'dompurify';
|
|
5
|
+
import { getMermaidTheme, hasMermaidBlocks } from './mermaid-renderer';
|
|
6
|
+
import { ALL_THEMES } from './themes';
|
|
7
|
+
|
|
8
|
+
describe('getMermaidTheme', () => {
|
|
9
|
+
it('maps "light" to "default"', () => {
|
|
10
|
+
expect(getMermaidTheme('light')).toBe('default');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('maps "dark" to "dark"', () => {
|
|
14
|
+
expect(getMermaidTheme('dark')).toBe('dark');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('maps "sepia" to "neutral"', () => {
|
|
18
|
+
expect(getMermaidTheme('sepia')).toBe('neutral');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('maps "nord" to "dark"', () => {
|
|
22
|
+
expect(getMermaidTheme('nord')).toBe('dark');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('maps "rose-pine" to "dark"', () => {
|
|
26
|
+
expect(getMermaidTheme('rose-pine')).toBe('dark');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('maps "solarized" to "default"', () => {
|
|
30
|
+
expect(getMermaidTheme('solarized')).toBe('default');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('maps "github" to "default"', () => {
|
|
34
|
+
expect(getMermaidTheme('github')).toBe('default');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('maps "catppuccin" to "dark"', () => {
|
|
38
|
+
expect(getMermaidTheme('catppuccin')).toBe('dark');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('falls back to "default" for unknown themes', () => {
|
|
42
|
+
expect(getMermaidTheme('unknown-theme')).toBe('default');
|
|
43
|
+
expect(getMermaidTheme('')).toBe('default');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns a valid mermaid theme for every app theme in themes.ts', () => {
|
|
47
|
+
const validMermaidThemes = ['default', 'dark', 'forest', 'neutral', 'base'];
|
|
48
|
+
for (const theme of ALL_THEMES) {
|
|
49
|
+
const result = getMermaidTheme(theme.key);
|
|
50
|
+
expect(
|
|
51
|
+
validMermaidThemes.includes(result),
|
|
52
|
+
`getMermaidTheme("${theme.key}") returned "${result}" — add it to THEME_MAP in mermaid-renderer.ts`,
|
|
53
|
+
).toBe(true);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('hasMermaidBlocks', () => {
|
|
59
|
+
it('returns true when markdown contains a mermaid code block', () => {
|
|
60
|
+
expect(hasMermaidBlocks('# Title\n\n```mermaid\ngraph TD\nA-->B\n```\n')).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns true when mermaid block is at the start', () => {
|
|
64
|
+
expect(hasMermaidBlocks('```mermaid\nflowchart LR\n```')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns false when no mermaid blocks exist', () => {
|
|
68
|
+
expect(hasMermaidBlocks('# Title\n\n```js\nconst x = 1;\n```\n')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns false for empty string', () => {
|
|
72
|
+
expect(hasMermaidBlocks('')).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns false for inline mermaid mention', () => {
|
|
76
|
+
expect(hasMermaidBlocks('Use mermaid for diagrams')).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns false when mermaid is not at line start', () => {
|
|
80
|
+
expect(hasMermaidBlocks(' ```mermaid\ngraph TD\n```')).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('SVG sanitization (DOMPurify config)', () => {
|
|
85
|
+
// These tests verify the exact DOMPurify config used by renderMermaidBlock.
|
|
86
|
+
// The config must NOT include ADD_TAGS: ['foreignObject'] because foreignObject
|
|
87
|
+
// can contain arbitrary HTML that bypasses rehype-sanitize.
|
|
88
|
+
const sanitize = (svg: string) =>
|
|
89
|
+
DOMPurify.sanitize(svg, {
|
|
90
|
+
USE_PROFILES: { html: true, svg: true, svgFilters: true },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('strips foreignObject elements from SVG', () => {
|
|
94
|
+
const malicious = '<svg><foreignObject><div onclick="alert(1)">XSS</div></foreignObject></svg>';
|
|
95
|
+
const clean = sanitize(malicious);
|
|
96
|
+
expect(clean).not.toContain('foreignObject');
|
|
97
|
+
expect(clean).not.toContain('onclick');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('strips script elements from SVG', () => {
|
|
101
|
+
const malicious = '<svg><script>alert(1)</script><rect width="10" height="10"/></svg>';
|
|
102
|
+
const clean = sanitize(malicious);
|
|
103
|
+
expect(clean).not.toContain('<script');
|
|
104
|
+
expect(clean).toContain('<rect');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('preserves safe SVG elements', () => {
|
|
108
|
+
const safe = '<svg viewBox="0 0 100 100"><rect x="0" y="0" width="100" height="100" fill="blue"/><text x="10" y="50">Hello</text></svg>';
|
|
109
|
+
const clean = sanitize(safe);
|
|
110
|
+
expect(clean).toContain('<rect');
|
|
111
|
+
expect(clean).toContain('<text');
|
|
112
|
+
expect(clean).toContain('Hello');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify';
|
|
2
|
+
|
|
3
|
+
let mermaidModule: typeof import('mermaid') | null = null;
|
|
4
|
+
let initTheme: string | null = null;
|
|
5
|
+
let themeChangePromise: Promise<void> | null = null;
|
|
6
|
+
|
|
7
|
+
const FLOWCHART_CONFIG = {
|
|
8
|
+
useMaxWidth: true,
|
|
9
|
+
wrappingWidth: 200,
|
|
10
|
+
rankSpacing: 70,
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
async function getMermaid() {
|
|
14
|
+
if (!mermaidModule) {
|
|
15
|
+
mermaidModule = await import('mermaid');
|
|
16
|
+
mermaidModule.default.initialize({
|
|
17
|
+
startOnLoad: false,
|
|
18
|
+
securityLevel: 'strict',
|
|
19
|
+
theme: 'default',
|
|
20
|
+
htmlLabels: true,
|
|
21
|
+
flowchart: FLOWCHART_CONFIG,
|
|
22
|
+
});
|
|
23
|
+
initTheme = 'default';
|
|
24
|
+
}
|
|
25
|
+
return mermaidModule.default;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Maps app theme keys (from src/lib/themes.ts) to mermaid theme names.
|
|
29
|
+
// Keep in sync when adding new themes.
|
|
30
|
+
const THEME_MAP: Record<string, string> = {
|
|
31
|
+
light: 'default',
|
|
32
|
+
dark: 'dark',
|
|
33
|
+
sepia: 'neutral',
|
|
34
|
+
nord: 'dark',
|
|
35
|
+
'rose-pine': 'dark',
|
|
36
|
+
solarized: 'default',
|
|
37
|
+
github: 'default',
|
|
38
|
+
catppuccin: 'dark',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function getMermaidTheme(appTheme: string): string {
|
|
42
|
+
return THEME_MAP[appTheme] || 'default';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let renderCounter = 0;
|
|
46
|
+
|
|
47
|
+
export async function renderMermaidBlock(
|
|
48
|
+
source: string,
|
|
49
|
+
appTheme: string,
|
|
50
|
+
): Promise<{ svg: string } | { error: string }> {
|
|
51
|
+
try {
|
|
52
|
+
const mermaid = await getMermaid();
|
|
53
|
+
const mermaidTheme = getMermaidTheme(appTheme);
|
|
54
|
+
|
|
55
|
+
// Serialize theme changes to avoid concurrent re-initialization races.
|
|
56
|
+
// After awaiting a pending promise, re-check in case a newer theme was
|
|
57
|
+
// requested while we were waiting (rapid toggling).
|
|
58
|
+
while (mermaidTheme !== initTheme) {
|
|
59
|
+
if (!themeChangePromise) {
|
|
60
|
+
themeChangePromise = (async () => {
|
|
61
|
+
mermaid.initialize({
|
|
62
|
+
startOnLoad: false,
|
|
63
|
+
securityLevel: 'strict',
|
|
64
|
+
theme: mermaidTheme as Parameters<typeof mermaid.initialize>[0]['theme'],
|
|
65
|
+
htmlLabels: true,
|
|
66
|
+
flowchart: FLOWCHART_CONFIG,
|
|
67
|
+
});
|
|
68
|
+
initTheme = mermaidTheme;
|
|
69
|
+
themeChangePromise = null;
|
|
70
|
+
})();
|
|
71
|
+
}
|
|
72
|
+
await themeChangePromise;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const id = `mermaid-svg-${++renderCounter}`;
|
|
76
|
+
const { svg } = await mermaid.render(id, source.trim());
|
|
77
|
+
const cleanSvg = DOMPurify.sanitize(svg, {
|
|
78
|
+
USE_PROFILES: { html: true, svg: true, svgFilters: true },
|
|
79
|
+
});
|
|
80
|
+
return { svg: cleanSvg };
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Quick check if clean markdown contains any mermaid fenced code blocks */
|
|
87
|
+
export function hasMermaidBlocks(markdown: string): boolean {
|
|
88
|
+
return /^```mermaid\s*$/m.test(markdown);
|
|
89
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getPathBasename } from './path-utils';
|
|
3
|
+
|
|
4
|
+
describe('getPathBasename', () => {
|
|
5
|
+
it('returns the basename for POSIX paths', () => {
|
|
6
|
+
expect(getPathBasename('/tmp/docs/spec.md')).toBe('spec.md');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('returns the basename for Windows paths', () => {
|
|
10
|
+
expect(getPathBasename('C:\\docs\\spec.md')).toBe('spec.md');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('trims trailing separators before reading the basename', () => {
|
|
14
|
+
expect(getPathBasename('/tmp/docs/')).toBe('docs');
|
|
15
|
+
expect(getPathBasename('C:\\docs\\')).toBe('docs');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { isApplePlatform, getPrimaryModifierLabel } from './platform';
|
|
3
|
+
|
|
4
|
+
const originalPlatform = Object.getOwnPropertyDescriptor(navigator, 'platform');
|
|
5
|
+
const originalUserAgent = Object.getOwnPropertyDescriptor(navigator, 'userAgent');
|
|
6
|
+
|
|
7
|
+
function mockNavigator(platform?: string, userAgent?: string) {
|
|
8
|
+
if (platform !== undefined) {
|
|
9
|
+
Object.defineProperty(navigator, 'platform', { value: platform, configurable: true });
|
|
10
|
+
}
|
|
11
|
+
if (userAgent !== undefined) {
|
|
12
|
+
Object.defineProperty(navigator, 'userAgent', { value: userAgent, configurable: true });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (originalPlatform) Object.defineProperty(navigator, 'platform', originalPlatform);
|
|
18
|
+
if (originalUserAgent) Object.defineProperty(navigator, 'userAgent', originalUserAgent);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('isApplePlatform', () => {
|
|
22
|
+
it('returns true for MacIntel platform', () => {
|
|
23
|
+
mockNavigator('MacIntel', '');
|
|
24
|
+
expect(isApplePlatform()).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns true for iPhone userAgent', () => {
|
|
28
|
+
mockNavigator('', 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)');
|
|
29
|
+
expect(isApplePlatform()).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns true for iPad userAgent', () => {
|
|
33
|
+
mockNavigator('', 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X)');
|
|
34
|
+
expect(isApplePlatform()).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns false for Windows platform', () => {
|
|
38
|
+
mockNavigator('Win32', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
|
|
39
|
+
expect(isApplePlatform()).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns false for Linux platform', () => {
|
|
43
|
+
mockNavigator('Linux x86_64', 'Mozilla/5.0 (X11; Linux x86_64)');
|
|
44
|
+
expect(isApplePlatform()).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('getPrimaryModifierLabel', () => {
|
|
49
|
+
it('returns "Cmd" on Apple platforms', () => {
|
|
50
|
+
mockNavigator('MacIntel', '');
|
|
51
|
+
expect(getPrimaryModifierLabel()).toBe('Cmd');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns "Ctrl" on non-Apple platforms', () => {
|
|
55
|
+
mockNavigator('Win32', 'Mozilla/5.0 (Windows NT 10.0)');
|
|
56
|
+
expect(getPrimaryModifierLabel()).toBe('Ctrl');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
function getNavigatorValue(key: 'platform' | 'userAgent'): string {
|
|
2
|
+
if (typeof navigator === 'undefined') return '';
|
|
3
|
+
return navigator[key] ?? '';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isApplePlatform(): boolean {
|
|
7
|
+
const platform = getNavigatorValue('platform');
|
|
8
|
+
const userAgent = getNavigatorValue('userAgent');
|
|
9
|
+
return /Mac|iPhone|iPad|iPod/.test(platform || userAgent);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getPrimaryModifierLabel(): 'Cmd' | 'Ctrl' {
|
|
13
|
+
return isApplePlatform() ? 'Cmd' : 'Ctrl';
|
|
14
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
fetchPreferences,
|
|
4
|
+
savePreferencesToDisk,
|
|
5
|
+
migrateLocalStorageToDisk,
|
|
6
|
+
} from './preferences-client';
|
|
7
|
+
|
|
8
|
+
// Mock localStorage
|
|
9
|
+
const store: Record<string, string> = {};
|
|
10
|
+
const localStorageMock = {
|
|
11
|
+
getItem: vi.fn((key: string) => store[key] ?? null),
|
|
12
|
+
setItem: vi.fn((key: string, value: string) => {
|
|
13
|
+
store[key] = value;
|
|
14
|
+
}),
|
|
15
|
+
removeItem: vi.fn((key: string) => {
|
|
16
|
+
delete store[key];
|
|
17
|
+
}),
|
|
18
|
+
clear: vi.fn(() => {
|
|
19
|
+
for (const key in store) delete store[key];
|
|
20
|
+
}),
|
|
21
|
+
get length() {
|
|
22
|
+
return Object.keys(store).length;
|
|
23
|
+
},
|
|
24
|
+
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
|
|
25
|
+
};
|
|
26
|
+
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
|
|
27
|
+
|
|
28
|
+
const mockFetch = vi.fn();
|
|
29
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
localStorageMock.clear();
|
|
33
|
+
mockFetch.mockReset();
|
|
34
|
+
vi.clearAllMocks();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function jsonResponse(body: unknown, init?: ResponseInit) {
|
|
38
|
+
return new Response(JSON.stringify(body), {
|
|
39
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40
|
+
...init,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('fetchPreferences', () => {
|
|
45
|
+
it('returns parsed JSON on successful response', async () => {
|
|
46
|
+
mockFetch.mockResolvedValue(jsonResponse({ author: 'Alice', theme: 'dark' }));
|
|
47
|
+
const result = await fetchPreferences();
|
|
48
|
+
expect(result).toEqual({ author: 'Alice', theme: 'dark' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns empty object when response is not ok', async () => {
|
|
52
|
+
mockFetch.mockResolvedValue(jsonResponse({ error: 'boom' }, { status: 500 }));
|
|
53
|
+
const result = await fetchPreferences();
|
|
54
|
+
expect(result).toEqual({});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns empty object on invalid JSON response', async () => {
|
|
58
|
+
mockFetch.mockResolvedValue(new Response('', { status: 502 }));
|
|
59
|
+
const result = await fetchPreferences();
|
|
60
|
+
expect(result).toEqual({});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns empty object on network error', async () => {
|
|
64
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
65
|
+
const result = await fetchPreferences();
|
|
66
|
+
expect(result).toEqual({});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('savePreferencesToDisk', () => {
|
|
71
|
+
it('sends PUT request with correct body', async () => {
|
|
72
|
+
mockFetch.mockResolvedValue({ ok: true });
|
|
73
|
+
await expect(savePreferencesToDisk({ author: 'Bob' })).resolves.toBe(true);
|
|
74
|
+
expect(mockFetch).toHaveBeenCalledWith('/api/preferences', {
|
|
75
|
+
method: 'PUT',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ author: 'Bob' }),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns false on network error', async () => {
|
|
82
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
83
|
+
await expect(savePreferencesToDisk({ author: 'Bob' })).resolves.toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns false on non-ok response', async () => {
|
|
87
|
+
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
|
88
|
+
await expect(savePreferencesToDisk({ author: 'Bob' })).resolves.toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('migrateLocalStorageToDisk', () => {
|
|
93
|
+
it('skips migration when already migrated', async () => {
|
|
94
|
+
store['md-redline-migrated-to-disk'] = '1';
|
|
95
|
+
await migrateLocalStorageToDisk();
|
|
96
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('skips migration when disk has existing data', async () => {
|
|
100
|
+
mockFetch.mockResolvedValue(jsonResponse({ author: 'Existing' }));
|
|
101
|
+
await migrateLocalStorageToDisk();
|
|
102
|
+
expect(store['md-redline-migrated-to-disk']).toBe('1');
|
|
103
|
+
// Should not have called PUT
|
|
104
|
+
expect(mockFetch).toHaveBeenCalledTimes(1); // Only the GET
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('migrates settings, theme, and recentFiles from localStorage', async () => {
|
|
108
|
+
// Mock disk as empty
|
|
109
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
|
110
|
+
// Mock save
|
|
111
|
+
mockFetch.mockResolvedValueOnce({ ok: true });
|
|
112
|
+
|
|
113
|
+
store['md-redline-settings'] = JSON.stringify({ enableResolve: true });
|
|
114
|
+
store['theme'] = 'dark';
|
|
115
|
+
store['md-redline-recent-files'] = JSON.stringify([
|
|
116
|
+
{ path: '/test.md', name: 'test.md', openedAt: '2026-01-01' },
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
await migrateLocalStorageToDisk();
|
|
120
|
+
|
|
121
|
+
// Verify PUT was called with migrated data
|
|
122
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
123
|
+
const putCall = mockFetch.mock.calls[1];
|
|
124
|
+
expect(putCall[0]).toBe('/api/preferences');
|
|
125
|
+
const body = JSON.parse(putCall[1].body);
|
|
126
|
+
expect(body.settings).toEqual({ enableResolve: true });
|
|
127
|
+
expect(body.theme).toBe('dark');
|
|
128
|
+
expect(body.recentFiles).toHaveLength(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not migrate legacy author from localStorage', async () => {
|
|
132
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
|
133
|
+
mockFetch.mockResolvedValueOnce({ ok: true });
|
|
134
|
+
|
|
135
|
+
store['md-redline-author'] = 'PersistentUser';
|
|
136
|
+
|
|
137
|
+
await migrateLocalStorageToDisk();
|
|
138
|
+
|
|
139
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
140
|
+
expect(store['md-redline-migrated-to-disk']).toBe('1');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('removes migrated keys from localStorage except theme', async () => {
|
|
144
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
|
145
|
+
mockFetch.mockResolvedValueOnce({ ok: true });
|
|
146
|
+
|
|
147
|
+
store['md-redline-author'] = 'Alice';
|
|
148
|
+
store['theme'] = 'dark';
|
|
149
|
+
|
|
150
|
+
await migrateLocalStorageToDisk();
|
|
151
|
+
|
|
152
|
+
expect(localStorageMock.removeItem).toHaveBeenCalledWith('md-redline-author');
|
|
153
|
+
// Theme should NOT be removed (next-themes reads it synchronously)
|
|
154
|
+
expect(localStorageMock.removeItem).not.toHaveBeenCalledWith('theme');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('sets migrated flag after successful migration', async () => {
|
|
158
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
|
159
|
+
await migrateLocalStorageToDisk();
|
|
160
|
+
expect(store['md-redline-migrated-to-disk']).toBe('1');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('keeps local data and retries later when saving to disk fails', async () => {
|
|
164
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
|
165
|
+
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
|
|
166
|
+
|
|
167
|
+
store['md-redline-author'] = 'Alice';
|
|
168
|
+
store['md-redline-settings'] = JSON.stringify({ enableResolve: true });
|
|
169
|
+
store['theme'] = 'dark';
|
|
170
|
+
|
|
171
|
+
await migrateLocalStorageToDisk();
|
|
172
|
+
|
|
173
|
+
expect(store['md-redline-settings']).toBe(JSON.stringify({ enableResolve: true }));
|
|
174
|
+
expect(store['theme']).toBe('dark');
|
|
175
|
+
expect(store['md-redline-migrated-to-disk']).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readJsonResponse } from './http';
|
|
2
|
+
|
|
3
|
+
export interface RecentFileEntry {
|
|
4
|
+
path: string;
|
|
5
|
+
name: string;
|
|
6
|
+
openedAt: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DiskPreferences {
|
|
10
|
+
author?: string;
|
|
11
|
+
settings?: Record<string, unknown>;
|
|
12
|
+
theme?: string;
|
|
13
|
+
recentFiles?: RecentFileEntry[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function fetchPreferences(): Promise<DiskPreferences> {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch('/api/preferences');
|
|
19
|
+
const data = await readJsonResponse<DiskPreferences>(res);
|
|
20
|
+
if (!res.ok || !data) return {};
|
|
21
|
+
return data;
|
|
22
|
+
} catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function savePreferencesToDisk(patch: Partial<DiskPreferences>): Promise<boolean> {
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch('/api/preferences', {
|
|
30
|
+
method: 'PUT',
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
body: JSON.stringify(patch),
|
|
33
|
+
});
|
|
34
|
+
return res.ok;
|
|
35
|
+
} catch {
|
|
36
|
+
// Server unavailable — silently fail, localStorage is the fallback
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MIGRATED_KEY = 'md-redline-migrated-to-disk';
|
|
42
|
+
|
|
43
|
+
export async function migrateLocalStorageToDisk(): Promise<void> {
|
|
44
|
+
// Skip if already migrated
|
|
45
|
+
if (localStorage.getItem(MIGRATED_KEY)) return;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const existing = await fetchPreferences();
|
|
49
|
+
// If dotfile already has data, mark as migrated and skip
|
|
50
|
+
if (Object.keys(existing).length > 0) {
|
|
51
|
+
localStorage.setItem(MIGRATED_KEY, '1');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Collect from localStorage
|
|
56
|
+
const patch: DiskPreferences = {};
|
|
57
|
+
|
|
58
|
+
const settingsRaw = localStorage.getItem('md-redline-settings');
|
|
59
|
+
if (settingsRaw) {
|
|
60
|
+
try {
|
|
61
|
+
patch.settings = JSON.parse(settingsRaw);
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const theme = localStorage.getItem('theme');
|
|
68
|
+
if (theme) patch.theme = theme;
|
|
69
|
+
|
|
70
|
+
const recentRaw = localStorage.getItem('md-redline-recent-files');
|
|
71
|
+
if (recentRaw) {
|
|
72
|
+
try {
|
|
73
|
+
patch.recentFiles = JSON.parse(recentRaw);
|
|
74
|
+
} catch {
|
|
75
|
+
/* ignore */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (Object.keys(patch).length > 0) {
|
|
80
|
+
const saved = await savePreferencesToDisk(patch);
|
|
81
|
+
if (!saved) return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Remove migrated keys from localStorage (keep theme for next-themes flash-free init)
|
|
85
|
+
localStorage.removeItem('md-redline-author');
|
|
86
|
+
localStorage.removeItem('md-redline-settings');
|
|
87
|
+
localStorage.removeItem('md-redline-recent-files');
|
|
88
|
+
// Note: do NOT remove 'theme' — next-themes reads it synchronously on startup
|
|
89
|
+
|
|
90
|
+
localStorage.setItem(MIGRATED_KEY, '1');
|
|
91
|
+
} catch {
|
|
92
|
+
// Migration failed — will retry next load
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
import { resolveSelection } from './selection-resolver';
|
|
5
|
+
|
|
6
|
+
// jsdom's Range doesn't implement getBoundingClientRect — stub it.
|
|
7
|
+
if (!Range.prototype.getBoundingClientRect) {
|
|
8
|
+
Range.prototype.getBoundingClientRect = () =>
|
|
9
|
+
({
|
|
10
|
+
x: 0,
|
|
11
|
+
y: 0,
|
|
12
|
+
width: 0,
|
|
13
|
+
height: 0,
|
|
14
|
+
top: 0,
|
|
15
|
+
right: 0,
|
|
16
|
+
bottom: 0,
|
|
17
|
+
left: 0,
|
|
18
|
+
toJSON: () => {},
|
|
19
|
+
}) as DOMRect;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
document.body.innerHTML = '';
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function mockSelection(
|
|
27
|
+
opts: {
|
|
28
|
+
collapsed?: boolean;
|
|
29
|
+
text?: string;
|
|
30
|
+
range?: Range;
|
|
31
|
+
} | null,
|
|
32
|
+
) {
|
|
33
|
+
const sel = opts
|
|
34
|
+
? {
|
|
35
|
+
isCollapsed: opts.collapsed ?? false,
|
|
36
|
+
toString: () => opts.text ?? '',
|
|
37
|
+
getRangeAt: () => opts.range!,
|
|
38
|
+
rangeCount: opts.range ? 1 : 0,
|
|
39
|
+
}
|
|
40
|
+
: null;
|
|
41
|
+
vi.spyOn(window, 'getSelection').mockReturnValue(sel as unknown as Selection);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('resolveSelection', () => {
|
|
45
|
+
it('returns null when no selection', () => {
|
|
46
|
+
mockSelection(null);
|
|
47
|
+
const container = document.createElement('div');
|
|
48
|
+
expect(resolveSelection(container)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns null when selection is collapsed', () => {
|
|
52
|
+
mockSelection({ collapsed: true, text: '' });
|
|
53
|
+
const container = document.createElement('div');
|
|
54
|
+
expect(resolveSelection(container)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns null when text is less than 2 chars', () => {
|
|
58
|
+
document.body.innerHTML = '<div id="root">A</div>';
|
|
59
|
+
const container = document.getElementById('root')!;
|
|
60
|
+
const textNode = container.firstChild!;
|
|
61
|
+
|
|
62
|
+
const range = document.createRange();
|
|
63
|
+
range.setStart(textNode, 0);
|
|
64
|
+
range.setEnd(textNode, 1);
|
|
65
|
+
|
|
66
|
+
mockSelection({ text: 'A', range });
|
|
67
|
+
expect(resolveSelection(container)).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns correct contextBefore and contextAfter for a mid-document selection', () => {
|
|
71
|
+
const content = 'The quick brown fox jumps over the lazy dog and then some more text follows';
|
|
72
|
+
document.body.innerHTML = `<div id="root">${content}</div>`;
|
|
73
|
+
const container = document.getElementById('root')!;
|
|
74
|
+
const textNode = container.firstChild!;
|
|
75
|
+
|
|
76
|
+
// Select "fox jumps"
|
|
77
|
+
const selStart = content.indexOf('fox jumps');
|
|
78
|
+
const selEnd = selStart + 'fox jumps'.length;
|
|
79
|
+
|
|
80
|
+
const range = document.createRange();
|
|
81
|
+
range.setStart(textNode, selStart);
|
|
82
|
+
range.setEnd(textNode, selEnd);
|
|
83
|
+
|
|
84
|
+
mockSelection({ text: 'fox jumps', range });
|
|
85
|
+
|
|
86
|
+
const result = resolveSelection(container);
|
|
87
|
+
expect(result).not.toBeNull();
|
|
88
|
+
expect(result!.text).toBe('fox jumps');
|
|
89
|
+
expect(result!.contextBefore).toBe(content.slice(Math.max(0, selStart - 40), selStart));
|
|
90
|
+
expect(result!.contextAfter).toBe(content.slice(selEnd, selEnd + 40));
|
|
91
|
+
expect(result!.offset).toBe(selStart);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('handles leading whitespace in selection correctly', () => {
|
|
95
|
+
// Simpler: content "Hello World Goodbye"
|
|
96
|
+
document.body.innerHTML = '<div id="root">Hello World Goodbye</div>';
|
|
97
|
+
const container2 = document.getElementById('root')!;
|
|
98
|
+
const textNode2 = container2.firstChild!;
|
|
99
|
+
|
|
100
|
+
// Select " World" (positions 5-12) — raw has leading spaces, trim gives "World"
|
|
101
|
+
const range = document.createRange();
|
|
102
|
+
range.setStart(textNode2, 5);
|
|
103
|
+
range.setEnd(textNode2, 12);
|
|
104
|
+
|
|
105
|
+
mockSelection({ text: ' World', range });
|
|
106
|
+
|
|
107
|
+
const result = resolveSelection(container2);
|
|
108
|
+
expect(result).not.toBeNull();
|
|
109
|
+
// Trimmed text should be "World"
|
|
110
|
+
expect(result!.text).toBe('World');
|
|
111
|
+
// Leading whitespace is 2 chars, so adjustedStart = 5 + 2 = 7
|
|
112
|
+
// contextBefore should be content[max(0, 7-40) .. 7] = "Hello "
|
|
113
|
+
expect(result!.contextBefore).toBe('Hello ');
|
|
114
|
+
// selEnd = 7 + 5 = 12, contextAfter = content[12..52] = " Goodbye"
|
|
115
|
+
expect(result!.contextAfter).toBe(' Goodbye');
|
|
116
|
+
expect(result!.offset).toBe(7);
|
|
117
|
+
});
|
|
118
|
+
});
|