@bendyline/squisq-editor-react 2.0.0 → 2.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/README.md +10 -2
- package/dist/index.d.ts +579 -47
- package/dist/index.js +7477 -2943
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +1008 -38
- package/package.json +6 -5
- package/src/DocumentSettingsDialog.tsx +32 -18
- package/src/EditorContext.tsx +27 -0
- package/src/EditorShell.tsx +25 -1
- package/src/PreviewControls.tsx +77 -29
- package/src/PreviewPanel.tsx +5 -1
- package/src/RawEditor.tsx +12 -0
- package/src/RecorderEntry.tsx +5 -0
- package/src/Toolbar.tsx +479 -36
- package/src/WysiwygEditor.tsx +25 -11
- package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
- package/src/__tests__/editorShellProps.test.tsx +65 -0
- package/src/__tests__/findMode.test.tsx +104 -0
- package/src/__tests__/findModel.test.ts +55 -0
- package/src/__tests__/markdownCodeFence.test.ts +45 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/mediaReferences.test.ts +15 -0
- package/src/__tests__/previewControls.test.tsx +195 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/tiptapBridge.test.ts +57 -7
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
- package/src/__tests__/writeCanvasSettings.test.ts +15 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
- package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
- package/src/asciiDiagram/asciiDiagramData.ts +33 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
- package/src/buildPreviewDoc.ts +9 -7
- package/src/codeContext/types.ts +1 -1
- package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
- package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
- package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
- package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
- package/src/codeSnippet/codeSnippetCommands.ts +21 -0
- package/src/codeSnippet/codeSnippetData.ts +43 -0
- package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/diagram/DiagramCanvas.tsx +17 -2
- package/src/find/FindHighlightExtension.ts +81 -0
- package/src/find/FindToolbar.tsx +314 -0
- package/src/find/findModel.ts +77 -0
- package/src/frontmatterSettings.ts +23 -0
- package/src/index.ts +96 -1
- package/src/markdownCodeFence.ts +72 -0
- package/src/mediaReferences.ts +5 -3
- package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
- package/src/mermaid/MermaidDiagramExtension.ts +243 -0
- package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
- package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
- package/src/mermaid/MermaidShapePalette.tsx +245 -0
- package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
- package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
- package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
- package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
- package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
- package/src/mermaid/mermaidCommands.ts +34 -0
- package/src/mermaid/mermaidData.ts +31 -0
- package/src/mermaid/mermaidDiagramTypes.ts +325 -0
- package/src/mermaid/mermaidModel.ts +31 -0
- package/src/mermaid/mermaidRenderer.ts +181 -0
- package/src/mermaid/mermaidShapes.ts +113 -0
- package/src/mermaid/mermaidSourceOps.ts +454 -0
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/scene/Scene.tsx +46 -15
- package/src/scene/SceneBlockToolbar.tsx +29 -14
- package/src/scene/SceneViewControls.tsx +43 -0
- package/src/scene/__tests__/fitScale.test.ts +19 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
- package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
- package/src/scene/adapters/DrawingAdapter.ts +6 -1
- package/src/scene/adapters/LayoutAdapter.ts +3 -0
- package/src/scene/commands/SceneCommand.ts +13 -2
- package/src/scene/fitScale.ts +17 -0
- package/src/scene/hooks/useScenePanZoom.ts +11 -4
- package/src/scene/scene.css +85 -3
- package/src/scene/tools/SelectTool.ts +5 -5
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-timeline.css +101 -4
- package/src/styles/code-snippet.css +76 -0
- package/src/styles/editor.css +352 -2
- package/src/styles/index.css +2 -0
- package/src/styles/mermaid-diagram.css +487 -0
- package/src/styles/tree-view.css +51 -1
- package/src/timeline/TimelineEditorWidget.tsx +200 -41
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
- package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
- package/src/timeline/__tests__/timelineOps.test.ts +55 -0
- package/src/timeline/timelineCommands.ts +54 -0
- package/src/timeline/timelineOps.ts +107 -3
- package/src/tiptapBridge.ts +23 -5
- package/src/treeview/TreeOutlineWidget.tsx +153 -3
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
- package/src/treeview/__tests__/treeOps.test.ts +52 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
- package/src/treeview/treeOps.ts +59 -0
- package/src/treeview/treeViewCommands.ts +5 -0
- package/src/writeCanvasSettings.ts +30 -0
package/src/WysiwygEditor.tsx
CHANGED
|
@@ -32,6 +32,8 @@ import { AsciiDiagramExtension } from './asciiDiagram/AsciiDiagramExtension';
|
|
|
32
32
|
import { RepairableDiagramExtension } from './asciiDiagram/RepairableDiagramExtension';
|
|
33
33
|
import { applyRepairCommand } from './asciiDiagram/asciiDiagramCommands';
|
|
34
34
|
import { shouldPasteAsAsciiFence } from './asciiDiagram/asciiPaste';
|
|
35
|
+
import { MermaidDiagramExtension } from './mermaid/MermaidDiagramExtension';
|
|
36
|
+
import { CodeSnippetExtension } from './codeSnippet/CodeSnippetExtension';
|
|
35
37
|
import { TreeViewExtension } from './treeview/TreeViewExtension';
|
|
36
38
|
import { shouldPasteAsTreeFence } from './treeview/treePaste';
|
|
37
39
|
import { TimelineViewExtension } from './timeline/TimelineViewExtension';
|
|
@@ -62,6 +64,8 @@ import { looksLikeMarkdown } from './detectMarkdown';
|
|
|
62
64
|
import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
|
|
63
65
|
import { usePreviewSettingsOptional } from './PreviewControls';
|
|
64
66
|
import { uploadAndInsertImages } from './wysiwygImageUpload';
|
|
67
|
+
import { writeCanvasSettingsStyle, type WriteCanvasSettings } from './writeCanvasSettings';
|
|
68
|
+
import { FindHighlightExtension } from './find/FindHighlightExtension';
|
|
65
69
|
|
|
66
70
|
type ImageMutationView = Pick<ProseMirrorView, 'state' | 'dispatch'>;
|
|
67
71
|
|
|
@@ -71,16 +75,14 @@ type ImageMutationView = Pick<ProseMirrorView, 'state' | 'dispatch'>;
|
|
|
71
75
|
* `placeholder` prop with a fixed string.
|
|
72
76
|
*/
|
|
73
77
|
const EMPTY_PROMPTS = [
|
|
74
|
-
'Start typing your content, or drop images on top of me
|
|
75
|
-
'Write anything
|
|
76
|
-
'Type away. Markdown syntax works too
|
|
77
|
-
'Chapter 1 begins here
|
|
78
|
-
'Once upon a time
|
|
79
|
-
|
|
80
|
-
'The first word is always the hardest
|
|
81
|
-
'Plot twist: this is where it all starts
|
|
82
|
-
'Write something the future you will thank you for…',
|
|
83
|
-
'Begin at the beginning…',
|
|
78
|
+
'Start typing your content, or drop images on top of me...',
|
|
79
|
+
'Write anything -- paste markdown, drag in images, or just start typing...',
|
|
80
|
+
'Type away. Markdown syntax works too...',
|
|
81
|
+
'Chapter 1 begins here...',
|
|
82
|
+
'Once upon a time...',
|
|
83
|
+
"A blank page. Exciting, isn't it?",
|
|
84
|
+
'The first word is always the hardest...',
|
|
85
|
+
'Plot twist: this is where it all starts...',
|
|
84
86
|
];
|
|
85
87
|
|
|
86
88
|
const BLOCK_TAG_DATA_VALUES = {
|
|
@@ -109,6 +111,8 @@ export interface WysiwygEditorProps {
|
|
|
109
111
|
submitOnEnter?: () => void;
|
|
110
112
|
/** Disable Tiptap editing — renders content but blocks input. */
|
|
111
113
|
readOnly?: boolean;
|
|
114
|
+
/** Host-controlled base text size and line spacing for the Write canvas. */
|
|
115
|
+
writeCanvasSettings?: WriteCanvasSettings;
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
/**
|
|
@@ -120,6 +124,7 @@ export function WysiwygEditor({
|
|
|
120
124
|
className,
|
|
121
125
|
submitOnEnter,
|
|
122
126
|
readOnly = false,
|
|
127
|
+
writeCanvasSettings,
|
|
123
128
|
}: WysiwygEditorProps) {
|
|
124
129
|
const {
|
|
125
130
|
editorSource,
|
|
@@ -199,6 +204,8 @@ export function WysiwygEditor({
|
|
|
199
204
|
HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
200
205
|
BlockTagActivityExtension,
|
|
201
206
|
AsciiDiagramExtension.configure({ textChannel: sceneTextChannel }),
|
|
207
|
+
MermaidDiagramExtension,
|
|
208
|
+
CodeSnippetExtension,
|
|
202
209
|
RepairableDiagramExtension.configure({ onRepair: applyRepairCommand }),
|
|
203
210
|
TimelineViewExtension,
|
|
204
211
|
TreeViewExtension,
|
|
@@ -220,6 +227,7 @@ export function WysiwygEditor({
|
|
|
220
227
|
Placeholder.configure({ placeholder: resolvedPlaceholder }),
|
|
221
228
|
buildMentionExtension(() => mentionProviderRef.current),
|
|
222
229
|
InlineIcon,
|
|
230
|
+
FindHighlightExtension,
|
|
223
231
|
],
|
|
224
232
|
content: markdownToTiptap(stripFrontmatter(editorSource).body),
|
|
225
233
|
onUpdate: ({ editor: ed }) => {
|
|
@@ -625,7 +633,13 @@ export function WysiwygEditor({
|
|
|
625
633
|
<CustomTemplateProvider docTemplates={docTemplates} onDocTemplatesChange={onDocTemplatesChange}>
|
|
626
634
|
<div
|
|
627
635
|
className={`squisq-wysiwyg-container${className ? ` ${className}` : ''}`}
|
|
628
|
-
style={{
|
|
636
|
+
style={{
|
|
637
|
+
width: '100%',
|
|
638
|
+
height: '100%',
|
|
639
|
+
overflow: 'auto',
|
|
640
|
+
...themeStyle,
|
|
641
|
+
...writeCanvasSettingsStyle(writeCanvasSettings),
|
|
642
|
+
}}
|
|
629
643
|
data-testid="wysiwyg-container"
|
|
630
644
|
data-block-tags={BLOCK_TAG_DATA_VALUES[blockTagVisibility]}
|
|
631
645
|
data-theme-inheritance={themeInheritance}
|
|
@@ -45,4 +45,21 @@ describe('buildPreviewDoc content mapping', () => {
|
|
|
45
45
|
description: 'Pinned description',
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
it('keeps Mermaid source available to the slide materializer for every template', () => {
|
|
50
|
+
const slide = firstPreviewSlide(
|
|
51
|
+
'# Architecture\n\n```mermaid\nflowchart LR\n client --> server\n```',
|
|
52
|
+
);
|
|
53
|
+
expect(slide.contents).toEqual([expect.objectContaining({ type: 'code', lang: 'mermaid' })]);
|
|
54
|
+
|
|
55
|
+
const { layers } = materializeBlockLayers(slide as unknown as DocBlock, {
|
|
56
|
+
persistentLayers: false,
|
|
57
|
+
});
|
|
58
|
+
expect(layers.some((layer) => layer.type === 'mermaid')).toBe(true);
|
|
59
|
+
const narrativeText = layers
|
|
60
|
+
.filter((layer): layer is TextLayer => layer.type === 'text')
|
|
61
|
+
.map((layer) => layer.content.text)
|
|
62
|
+
.join('\n');
|
|
63
|
+
expect(narrativeText).not.toContain('flowchart LR');
|
|
64
|
+
});
|
|
48
65
|
});
|
|
@@ -10,7 +10,7 @@ const noop = () => {};
|
|
|
10
10
|
const baseSection = {
|
|
11
11
|
id: 'foo@10',
|
|
12
12
|
summaryMarkdown: '**foo** — does things · ↓2 imported-by',
|
|
13
|
-
markdown: 'Body text with [`a.ts`](
|
|
13
|
+
markdown: 'Body text with [`a.ts`](workspace-nav:src%2Fa.ts) and [line 4](#L4).',
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
function renderView(over: Partial<Parameters<typeof CodeContextSectionView>[0]> = {}) {
|
|
@@ -18,7 +18,7 @@ function renderView(over: Partial<Parameters<typeof CodeContextSectionView>[0]>
|
|
|
18
18
|
section: baseSection,
|
|
19
19
|
expanded: false,
|
|
20
20
|
onToggle: vi.fn(),
|
|
21
|
-
linkSchemes: ['
|
|
21
|
+
linkSchemes: ['workspace-nav'] as const,
|
|
22
22
|
onLinkClick: vi.fn(),
|
|
23
23
|
onRevealLine: vi.fn(),
|
|
24
24
|
onMeasure: noop,
|
|
@@ -47,7 +47,7 @@ describe('<CodeContextSectionView>', () => {
|
|
|
47
47
|
const body = container.querySelector('.squisq-ccx-body')!;
|
|
48
48
|
expect(body.textContent).toContain('Body text');
|
|
49
49
|
const anchors = [...body.querySelectorAll('a')].map((a) => a.getAttribute('href'));
|
|
50
|
-
expect(anchors).toContain('
|
|
50
|
+
expect(anchors).toContain('workspace-nav:src%2Fa.ts');
|
|
51
51
|
expect(anchors).toContain('#L4');
|
|
52
52
|
});
|
|
53
53
|
|
|
@@ -63,10 +63,12 @@ describe('<CodeContextSectionView>', () => {
|
|
|
63
63
|
const onLinkClick = vi.fn(() => undefined);
|
|
64
64
|
const { container } = renderView({ expanded: true, onLinkClick });
|
|
65
65
|
const nav = [...container.querySelectorAll('a')].find(
|
|
66
|
-
(a) => a.getAttribute('href') === '
|
|
66
|
+
(a) => a.getAttribute('href') === 'workspace-nav:src%2Fa.ts',
|
|
67
67
|
)!;
|
|
68
68
|
const first = fireEvent.click(nav);
|
|
69
|
-
expect(onLinkClick).toHaveBeenCalledWith('
|
|
69
|
+
expect(onLinkClick).toHaveBeenCalledWith('workspace-nav:src%2Fa.ts', {
|
|
70
|
+
sectionId: 'foo@10',
|
|
71
|
+
});
|
|
70
72
|
expect(first).toBe(false); // preventDefault was called
|
|
71
73
|
|
|
72
74
|
onLinkClick.mockReturnValue(false as unknown as undefined);
|
|
@@ -89,7 +91,7 @@ describe('<CodeContextSectionView>', () => {
|
|
|
89
91
|
it('without linkSchemes, custom-scheme links render blocked (no anchor)', () => {
|
|
90
92
|
const { container } = renderView({ expanded: true, linkSchemes: undefined });
|
|
91
93
|
const anchors = [...container.querySelectorAll('a')].map((a) => a.getAttribute('href'));
|
|
92
|
-
expect(anchors).not.toContain('
|
|
94
|
+
expect(anchors).not.toContain('workspace-nav:src%2Fa.ts');
|
|
93
95
|
expect(container.querySelector('.squisq-md-link--blocked')).toBeTruthy();
|
|
94
96
|
});
|
|
95
97
|
});
|
|
@@ -123,6 +123,28 @@ describe('DocumentSettingsDialog', () => {
|
|
|
123
123
|
expect(next).not.toContain('squisq-theme');
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
it('removes explicit managed defaults and their legacy aliases on save', () => {
|
|
127
|
+
const onSave = vi.fn();
|
|
128
|
+
const src = `---
|
|
129
|
+
squisq-theme: standard
|
|
130
|
+
themeId: standard
|
|
131
|
+
theme: standard
|
|
132
|
+
squisq-captions: standard
|
|
133
|
+
caption-style: standard
|
|
134
|
+
author: Keep
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
# Doc
|
|
138
|
+
`;
|
|
139
|
+
open(src, onSave);
|
|
140
|
+
clickSave();
|
|
141
|
+
|
|
142
|
+
const next = onSave.mock.calls[0][0] as string;
|
|
143
|
+
expect(next).not.toMatch(/^(?:squisq-theme|themeId|theme):/m);
|
|
144
|
+
expect(next).not.toMatch(/^(?:squisq-captions|caption-style):/m);
|
|
145
|
+
expect(next).toContain('author: Keep');
|
|
146
|
+
});
|
|
147
|
+
|
|
126
148
|
it('writes squisq-transform when a transform is picked', () => {
|
|
127
149
|
const onSave = vi.fn();
|
|
128
150
|
open('# Doc\n', onSave);
|
|
@@ -176,6 +176,29 @@ describe('<EditorShell> colorScheme prop', () => {
|
|
|
176
176
|
});
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
describe('<EditorShell> Write canvas settings', () => {
|
|
180
|
+
it('exposes host settings as live CSS variables on the shell', () => {
|
|
181
|
+
const { container, rerender } = render(
|
|
182
|
+
<EditorShell
|
|
183
|
+
initialMarkdown="Paragraph"
|
|
184
|
+
writeCanvasSettings={{ textSize: 18, lineSpacing: 1.9 }}
|
|
185
|
+
/>,
|
|
186
|
+
);
|
|
187
|
+
const shell = container.querySelector<HTMLElement>('.squisq-editor-shell')!;
|
|
188
|
+
expect(shell.style.getPropertyValue('--squisq-write-text-size')).toBe('18px');
|
|
189
|
+
expect(shell.style.getPropertyValue('--squisq-write-line-spacing')).toBe('1.9');
|
|
190
|
+
|
|
191
|
+
rerender(
|
|
192
|
+
<EditorShell
|
|
193
|
+
initialMarkdown="Paragraph"
|
|
194
|
+
writeCanvasSettings={{ textSize: 20, lineSpacing: 2 }}
|
|
195
|
+
/>,
|
|
196
|
+
);
|
|
197
|
+
expect(shell.style.getPropertyValue('--squisq-write-text-size')).toBe('20px');
|
|
198
|
+
expect(shell.style.getPropertyValue('--squisq-write-line-spacing')).toBe('2');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
179
202
|
describe('RawEditor monacoTheme prop', () => {
|
|
180
203
|
it('maps colorScheme="dark" to monacoTheme="vs-dark"', () => {
|
|
181
204
|
render(<EditorShell initialMarkdown="# hi" initialView="raw" colorScheme="dark" />);
|
|
@@ -482,4 +505,46 @@ describe('<Toolbar> Insert menu', () => {
|
|
|
482
505
|
);
|
|
483
506
|
});
|
|
484
507
|
});
|
|
508
|
+
|
|
509
|
+
it('opens the Mermaid type gallery and inserts the selected diagram grammar', async () => {
|
|
510
|
+
render(
|
|
511
|
+
<EditorProvider initialMarkdown="Intro" initialView="raw" allowRecording={false}>
|
|
512
|
+
<Toolbar />
|
|
513
|
+
<MarkdownSourceProbe />
|
|
514
|
+
</EditorProvider>,
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
fireEvent.click(screen.getByLabelText('Insert'));
|
|
518
|
+
fireEvent.click(await screen.findByRole('menuitem', { name: 'Complex Diagram (Mermaid)' }));
|
|
519
|
+
expect(await screen.findByRole('menu', { name: 'Mermaid diagram type' })).toBeTruthy();
|
|
520
|
+
expect(screen.getByText(/Flow direction remains a separate edit/)).toBeTruthy();
|
|
521
|
+
fireEvent.click(await screen.findByRole('menuitem', { name: 'Insert Gantt Mermaid diagram' }));
|
|
522
|
+
|
|
523
|
+
await waitFor(() => {
|
|
524
|
+
expect(screen.getByTestId('markdown-source').textContent).toContain(
|
|
525
|
+
'```mermaid\ngantt\n title Project plan\n dateFormat YYYY-MM-DD',
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('adds a typed code fence from the Code Snippet submenu', async () => {
|
|
531
|
+
render(
|
|
532
|
+
<EditorProvider initialMarkdown="Intro" initialView="raw" allowRecording={false}>
|
|
533
|
+
<Toolbar />
|
|
534
|
+
<MarkdownSourceProbe />
|
|
535
|
+
</EditorProvider>,
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
fireEvent.click(screen.getByLabelText('Insert'));
|
|
539
|
+
fireEvent.click(await screen.findByRole('menuitem', { name: 'Insert Code Snippet' }));
|
|
540
|
+
fireEvent.click(
|
|
541
|
+
await screen.findByRole('menuitem', { name: 'Insert TypeScript code snippet' }),
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
await waitFor(() => {
|
|
545
|
+
expect(screen.getByTestId('markdown-source').textContent).toContain(
|
|
546
|
+
"```typescript\nconst message: string = 'Hello, world!';\n```",
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
});
|
|
485
550
|
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('../RawEditor', () => ({
|
|
7
|
+
RawEditor: () => <div data-testid="raw-editor-stub">Alpha beta alpha</div>,
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('../WysiwygEditor', () => ({
|
|
10
|
+
WysiwygEditor: () => <div data-testid="wysiwyg-editor-stub" />,
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('../PreviewPanel', () => ({
|
|
13
|
+
PreviewPanel: () => <div data-testid="preview-panel">Alpha beta alpha</div>,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { EditorShell } from '../EditorShell';
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
if (typeof window.matchMedia !== 'function') {
|
|
20
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
21
|
+
configurable: true,
|
|
22
|
+
value: (query: string) => ({
|
|
23
|
+
matches: false,
|
|
24
|
+
media: query,
|
|
25
|
+
onchange: null,
|
|
26
|
+
addListener: vi.fn(),
|
|
27
|
+
removeListener: vi.fn(),
|
|
28
|
+
addEventListener: vi.fn(),
|
|
29
|
+
removeEventListener: vi.fn(),
|
|
30
|
+
dispatchEvent: vi.fn(() => false),
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
35
|
+
class ResizeObserverStub {
|
|
36
|
+
observe(): void {}
|
|
37
|
+
unobserve(): void {}
|
|
38
|
+
disconnect(): void {}
|
|
39
|
+
}
|
|
40
|
+
(globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver =
|
|
41
|
+
ResizeObserverStub;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('<EditorShell> Find mode', () => {
|
|
46
|
+
it('does not render a Find trigger or textbox by default', () => {
|
|
47
|
+
render(<EditorShell initialMarkdown="Alpha" initialView="raw" />);
|
|
48
|
+
expect(screen.queryByRole('search', { name: 'Find in document' })).toBeNull();
|
|
49
|
+
expect(screen.queryByRole('button', { name: 'Close find' })).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('shows search beside the view tabs, clears middle/left actions, and preserves right items', () => {
|
|
53
|
+
const onFindModeChange = vi.fn();
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<EditorShell
|
|
56
|
+
initialMarkdown="Alpha beta alpha"
|
|
57
|
+
initialView="raw"
|
|
58
|
+
findMode
|
|
59
|
+
onFindModeChange={onFindModeChange}
|
|
60
|
+
toolbarSlotLeft={<span data-testid="left-slot">Left</span>}
|
|
61
|
+
toolbarSlotAfterActions={<span data-testid="middle-slot">Middle</span>}
|
|
62
|
+
toolbarSlotRight={<span data-testid="right-slot">Right</span>}
|
|
63
|
+
/>,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const tabs = container.querySelector('.squisq-toolbar-view-tabs');
|
|
67
|
+
const search = screen.getByRole('search', { name: 'Find in document' });
|
|
68
|
+
expect(tabs?.nextElementSibling).toBe(search);
|
|
69
|
+
expect(screen.queryByTestId('left-slot')).toBeNull();
|
|
70
|
+
expect(screen.queryByTestId('middle-slot')).toBeNull();
|
|
71
|
+
expect(screen.getByTestId('right-slot')).toBeTruthy();
|
|
72
|
+
expect(screen.queryByRole('button', { name: 'Bold' })).toBeNull();
|
|
73
|
+
expect(screen.getByRole('button', { name: 'Document settings' })).toBeTruthy();
|
|
74
|
+
|
|
75
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close find' }));
|
|
76
|
+
expect(onFindModeChange).toHaveBeenCalledWith(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('responds to host-controlled mode changes and supports Escape to close', () => {
|
|
80
|
+
const onFindModeChange = vi.fn();
|
|
81
|
+
const { rerender } = render(
|
|
82
|
+
<EditorShell
|
|
83
|
+
initialMarkdown="Alpha"
|
|
84
|
+
initialView="raw"
|
|
85
|
+
findMode={false}
|
|
86
|
+
onFindModeChange={onFindModeChange}
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
expect(screen.queryByRole('search', { name: 'Find in document' })).toBeNull();
|
|
90
|
+
|
|
91
|
+
rerender(
|
|
92
|
+
<EditorShell
|
|
93
|
+
initialMarkdown="Alpha"
|
|
94
|
+
initialView="raw"
|
|
95
|
+
findMode
|
|
96
|
+
onFindModeChange={onFindModeChange}
|
|
97
|
+
/>,
|
|
98
|
+
);
|
|
99
|
+
const input = screen.getByRole('searchbox', { name: 'Find in document' });
|
|
100
|
+
expect(document.activeElement).toBe(input);
|
|
101
|
+
fireEvent.keyDown(input, { key: 'Escape' });
|
|
102
|
+
expect(onFindModeChange).toHaveBeenCalledWith(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { Editor } from '@tiptap/core';
|
|
5
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
6
|
+
import { FindHighlightExtension, updateTiptapFindHighlights } from '../find/FindHighlightExtension';
|
|
7
|
+
import { findProseMirrorMatches, findTextMatches, normalizeFindIndex } from '../find/findModel';
|
|
8
|
+
|
|
9
|
+
const editors: Editor[] = [];
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
editors.splice(0).forEach((editor) => editor.destroy());
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('Find model', () => {
|
|
16
|
+
it('finds case-insensitive literal matches and escapes punctuation', () => {
|
|
17
|
+
expect(findTextMatches('A+b a+B A-b', 'a+b')).toEqual([
|
|
18
|
+
{ from: 0, to: 3 },
|
|
19
|
+
{ from: 4, to: 7 },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('wraps next and previous indexes', () => {
|
|
24
|
+
expect(normalizeFindIndex(3, 3)).toBe(0);
|
|
25
|
+
expect(normalizeFindIndex(-1, 3)).toBe(2);
|
|
26
|
+
expect(normalizeFindIndex(10, 0)).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('matches across adjacent formatted spans but not across paragraphs', () => {
|
|
30
|
+
const editor = new Editor({
|
|
31
|
+
extensions: [StarterKit],
|
|
32
|
+
content: '<p>Hello <strong>wide</strong> world</p><p>Hello world</p>',
|
|
33
|
+
});
|
|
34
|
+
editors.push(editor);
|
|
35
|
+
|
|
36
|
+
expect(findProseMirrorMatches(editor.state.doc, 'hello wide world')).toHaveLength(1);
|
|
37
|
+
expect(findProseMirrorMatches(editor.state.doc, 'worldhello')).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('decorates every WYSIWYG match and marks the selected one separately', () => {
|
|
41
|
+
const element = document.createElement('div');
|
|
42
|
+
document.body.append(element);
|
|
43
|
+
const editor = new Editor({
|
|
44
|
+
element,
|
|
45
|
+
extensions: [StarterKit, FindHighlightExtension],
|
|
46
|
+
content: '<p>Alpha alpha ALPHA</p>',
|
|
47
|
+
});
|
|
48
|
+
editors.push(editor);
|
|
49
|
+
|
|
50
|
+
expect(updateTiptapFindHighlights(editor, 'alpha', 1)).toBe(3);
|
|
51
|
+
expect(element.querySelectorAll('.squisq-find-match')).toHaveLength(3);
|
|
52
|
+
expect(element.querySelectorAll('.squisq-find-match--selected')).toHaveLength(1);
|
|
53
|
+
expect(element.querySelector('.squisq-find-match--selected')?.textContent).toBe('alpha');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
isMarkdownFencedCodeLine,
|
|
4
|
+
markdownFencedCodeLineMask,
|
|
5
|
+
maskMarkdownFencedCode,
|
|
6
|
+
} from '../markdownCodeFence';
|
|
7
|
+
|
|
8
|
+
describe('markdown fenced-code detection', () => {
|
|
9
|
+
it('marks opening, body, and closing lines without masking surrounding prose', () => {
|
|
10
|
+
const source = [
|
|
11
|
+
'Before {[github]}',
|
|
12
|
+
'```md',
|
|
13
|
+
'## Example {[sectionHeader]}',
|
|
14
|
+
'```',
|
|
15
|
+
'After',
|
|
16
|
+
].join('\n');
|
|
17
|
+
|
|
18
|
+
expect(markdownFencedCodeLineMask(source)).toEqual([false, true, true, true, false]);
|
|
19
|
+
expect(isMarkdownFencedCodeLine(source, 3)).toBe(true);
|
|
20
|
+
expect(isMarkdownFencedCodeLine(source, 5)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('supports tilde fences, longer closers, indentation, and unclosed fences', () => {
|
|
24
|
+
const source = [
|
|
25
|
+
' ~~~~ts',
|
|
26
|
+
'{[audio src=inside.mp3]}',
|
|
27
|
+
' ~~~~~',
|
|
28
|
+
'text',
|
|
29
|
+
'```',
|
|
30
|
+
'{[quote]}',
|
|
31
|
+
].join('\r\n');
|
|
32
|
+
|
|
33
|
+
expect(markdownFencedCodeLineMask(source)).toEqual([true, true, true, false, true, true]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('masks fenced contents while preserving offsets and line endings', () => {
|
|
37
|
+
const source = 'Outside\r\n```md\r\n{[image src=inside.png]}\r\n```\r\nAfter';
|
|
38
|
+
const masked = maskMarkdownFencedCode(source);
|
|
39
|
+
|
|
40
|
+
expect(masked).toHaveLength(source.length);
|
|
41
|
+
expect(masked).toContain('Outside\r\n');
|
|
42
|
+
expect(masked).not.toContain('inside.png');
|
|
43
|
+
expect(masked.endsWith('\r\nAfter')).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -7,7 +7,7 @@ import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
|
|
|
7
7
|
* uploaded files into the bin without inserting a markdown ref into
|
|
8
8
|
* the editor body. A user would upload an image, hit Send in the
|
|
9
9
|
* downstream chat composer, and the outgoing markdown would have no
|
|
10
|
-
* image reference — the
|
|
10
|
+
* image reference — the downstream consumer would reply "nothing came through."
|
|
11
11
|
*
|
|
12
12
|
* The fix: after `mediaProvider.addMedia(...)` succeeds, MediaBin
|
|
13
13
|
* fires `onMediaUploaded(relativePath, name, mimeType)`. The
|
|
@@ -18,7 +18,7 @@ import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
|
|
|
18
18
|
* These tests exercise the contract directly: the markdown snippet
|
|
19
19
|
* produced by the upload callback, once round-tripped through the
|
|
20
20
|
* editor's markdown↔HTML bridge, must round-trip back to a form
|
|
21
|
-
* the
|
|
21
|
+
* the downstream service's image-extraction regex can see.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
function fakeMediaProvider(records: string[]): MediaProvider {
|
|
@@ -39,6 +39,21 @@ describe('collectMediaReferencesFromMarkdown', () => {
|
|
|
39
39
|
'video/clip with spaces.webm',
|
|
40
40
|
]);
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
it('ignores annotations and other reference-shaped text inside code fences', () => {
|
|
44
|
+
const refs = collectMediaReferencesFromMarkdown(
|
|
45
|
+
[
|
|
46
|
+
'## Real {[image src=images/outside.png]}',
|
|
47
|
+
'```markdown',
|
|
48
|
+
'{[audio src=audio/inside.mp3]}',
|
|
49
|
+
'',
|
|
50
|
+
'<video src="video/inside.webm"></video>',
|
|
51
|
+
'```',
|
|
52
|
+
].join('\n'),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect([...refs]).toEqual(['images/outside.png']);
|
|
56
|
+
});
|
|
42
57
|
});
|
|
43
58
|
|
|
44
59
|
describe('removeMediaReferencesFromMarkdown', () => {
|