@bendyline/squisq-editor-react 2.0.1 → 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.
Files changed (70) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +505 -15
  3. package/dist/index.js +6035 -2283
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +870 -40
  6. package/package.json +6 -5
  7. package/src/EditorContext.tsx +27 -0
  8. package/src/EditorShell.tsx +24 -0
  9. package/src/PreviewControls.tsx +20 -5
  10. package/src/PreviewPanel.tsx +5 -1
  11. package/src/RawEditor.tsx +12 -0
  12. package/src/RecorderEntry.tsx +3 -0
  13. package/src/Toolbar.tsx +315 -17
  14. package/src/WysiwygEditor.tsx +25 -11
  15. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  16. package/src/__tests__/editorShellProps.test.tsx +65 -0
  17. package/src/__tests__/findMode.test.tsx +104 -0
  18. package/src/__tests__/findModel.test.ts +55 -0
  19. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  20. package/src/__tests__/mediaReferences.test.ts +15 -0
  21. package/src/__tests__/previewControls.test.tsx +31 -0
  22. package/src/__tests__/tiptapBridge.test.ts +9 -0
  23. package/src/__tests__/toolbarSelectionConversion.test.tsx +26 -0
  24. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  25. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  26. package/src/buildPreviewDoc.ts +9 -7
  27. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  28. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  29. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  30. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  31. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  32. package/src/codeSnippet/codeSnippetData.ts +43 -0
  33. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  34. package/src/diagram/DiagramCanvas.tsx +1 -0
  35. package/src/find/FindHighlightExtension.ts +81 -0
  36. package/src/find/FindToolbar.tsx +314 -0
  37. package/src/find/findModel.ts +77 -0
  38. package/src/index.ts +89 -0
  39. package/src/markdownCodeFence.ts +72 -0
  40. package/src/mediaReferences.ts +5 -3
  41. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  42. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  43. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  44. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  45. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  46. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  47. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  48. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  49. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  50. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  51. package/src/mermaid/mermaidCommands.ts +34 -0
  52. package/src/mermaid/mermaidData.ts +31 -0
  53. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  54. package/src/mermaid/mermaidModel.ts +31 -0
  55. package/src/mermaid/mermaidRenderer.ts +181 -0
  56. package/src/mermaid/mermaidShapes.ts +113 -0
  57. package/src/mermaid/mermaidSourceOps.ts +454 -0
  58. package/src/scene/Scene.tsx +46 -15
  59. package/src/scene/SceneBlockToolbar.tsx +29 -14
  60. package/src/scene/SceneViewControls.tsx +43 -0
  61. package/src/scene/__tests__/fitScale.test.ts +19 -0
  62. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  63. package/src/scene/fitScale.ts +17 -0
  64. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  65. package/src/scene/scene.css +85 -3
  66. package/src/styles/code-snippet.css +76 -0
  67. package/src/styles/editor.css +322 -2
  68. package/src/styles/index.css +2 -0
  69. package/src/styles/mermaid-diagram.css +487 -0
  70. package/src/writeCanvasSettings.ts +30 -0
@@ -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
+ });
@@ -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
+ '![Example](images/inside.png)',
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', () => {
@@ -387,6 +387,37 @@ describe('PreviewToolbarControls', () => {
387
387
  }
388
388
  });
389
389
 
390
+ it('hides the aspect-ratio and captions controls in Page (linear) mode', () => {
391
+ const originalResizeObserver = globalThis.ResizeObserver;
392
+ class ResizeObserverStub implements ResizeObserver {
393
+ observe() {}
394
+ unobserve() {}
395
+ disconnect() {}
396
+ }
397
+
398
+ globalThis.ResizeObserver = ResizeObserverStub;
399
+ try {
400
+ // Frontmatter `display-mode: page` resolves to the internal 'linear'
401
+ // display mode (the styled Page view).
402
+ renderPreviewToolbar('---\ndisplay-mode: page\n---\n\n# Hello');
403
+
404
+ // Page is a variable-height HTML rendition: aspect ratio and captions
405
+ // do not apply there.
406
+ expect(document.querySelector('[role="group"][aria-label="Aspect ratio"]')).toBeNull();
407
+ expect(screen.queryByText('Captions:')).toBeNull();
408
+ // Theme, Summarize, and Cover stay live.
409
+ expect(screen.getAllByText('Theme:').length).toBeGreaterThan(0);
410
+ expect(screen.getAllByText('Summarize:').length).toBeGreaterThan(0);
411
+ expect(screen.getAllByText('Cover slide').length).toBeGreaterThan(0);
412
+ } finally {
413
+ if (originalResizeObserver) {
414
+ globalThis.ResizeObserver = originalResizeObserver;
415
+ } else {
416
+ Reflect.deleteProperty(globalThis, 'ResizeObserver');
417
+ }
418
+ }
419
+ });
420
+
390
421
  it('keeps the overflow popover inside the left viewport edge', async () => {
391
422
  const originalResizeObserver = globalThis.ResizeObserver;
392
423
  const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
@@ -122,6 +122,15 @@ describe('markdownToTiptap', () => {
122
122
  expect(html).toContain('const x = 1;');
123
123
  });
124
124
 
125
+ it('keeps squisq annotation syntax literal inside fenced code', () => {
126
+ const md = '```markdown\n## Example {[sectionHeader]}\n{[github]}\n{[ ]}\n```';
127
+ const html = markdownToTiptap(md);
128
+
129
+ expect(html).not.toContain('data-template=');
130
+ expect(html).not.toContain('data-icon=');
131
+ expect(tiptapToMarkdown(html).trim()).toBe(md);
132
+ });
133
+
125
134
  it('converts unordered lists', () => {
126
135
  const md = '- Item one\n- Item two\n- Item three';
127
136
  const html = markdownToTiptap(md);
@@ -31,6 +31,7 @@ function monacoWithSelection(selectedText: string) {
31
31
  endColumn: 1,
32
32
  };
33
33
  const model = {
34
+ getValue: () => selectedText,
34
35
  getValueInRange: () => selectedText,
35
36
  getLineContent: () => selectedText.split('\n')[0] ?? '',
36
37
  };
@@ -116,6 +117,31 @@ describe('<Toolbar> selection conversion menu', () => {
116
117
  expect(within(menu).queryByRole('menuitem', { name: 'Convert selection to Table' })).toBeNull();
117
118
  });
118
119
 
120
+ it('wraps the selected Monaco text in the chosen code-snippet language', async () => {
121
+ const source = 'const answer = 42;';
122
+ const { editor, executeEdits, selection } = monacoWithSelection(source);
123
+ render(
124
+ <EditorProvider initialMarkdown={source} initialView="raw" allowRecording={false}>
125
+ <Toolbar />
126
+ <ContextProbe />
127
+ </EditorProvider>,
128
+ );
129
+ act(() => context().setMonacoEditor(editor as never));
130
+
131
+ fireEvent.click(screen.getByLabelText('Insert'));
132
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Insert Code Snippet' }));
133
+ fireEvent.click(
134
+ await screen.findByRole('menuitem', { name: 'Insert JavaScript code snippet' }),
135
+ );
136
+
137
+ expect(executeEdits).toHaveBeenCalledWith('toolbar-code-snippet', [
138
+ {
139
+ range: selection,
140
+ text: '\n```javascript\nconst answer = 42;\n```\n',
141
+ },
142
+ ]);
143
+ });
144
+
119
145
  it('replaces fully selected Write paragraphs without leaving a blank paragraph', async () => {
120
146
  const editor = new Editor({
121
147
  extensions: [StarterKit, TaskList, TaskItem],
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { writeCanvasSettingsStyle } from '../writeCanvasSettings';
3
+
4
+ describe('writeCanvasSettingsStyle', () => {
5
+ it('maps serializable settings to Write canvas CSS variables', () => {
6
+ expect(writeCanvasSettingsStyle({ textSize: 18, lineSpacing: 1.9 })).toEqual({
7
+ '--squisq-write-text-size': '18px',
8
+ '--squisq-write-line-spacing': '1.9',
9
+ });
10
+ });
11
+
12
+ it('ignores invalid host values instead of emitting broken CSS', () => {
13
+ expect(writeCanvasSettingsStyle({ textSize: 0, lineSpacing: Number.NaN })).toEqual({});
14
+ });
15
+ });
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { Editor } from '@tiptap/core';
8
8
  import StarterKit from '@tiptap/starter-kit';
9
- import { afterEach, beforeAll, describe, expect, it } from 'vitest';
9
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
10
10
  import { markdownToTiptap } from '../../tiptapBridge';
11
11
  import { HeadingWithTemplate } from '../../TemplateAnnotation';
12
12
  import {
@@ -138,6 +138,25 @@ describe('AsciiDiagramExtension registry', () => {
138
138
  expect(JSON.stringify(editor.state.doc.toJSON())).toBe(json);
139
139
  });
140
140
 
141
+ it('shows zoom, fit, and fullscreen affordances with fit active by default', async () => {
142
+ const editor = makeEditor('```diagram\n' + ART_A + '\n```\n');
143
+ const root = editor.view.dom;
144
+ await vi.waitFor(() => {
145
+ expect(root.querySelector('[aria-label="Diagram view"]')).not.toBeNull();
146
+ });
147
+
148
+ expect(root.querySelector('button[aria-label="Zoom out"]')).not.toBeNull();
149
+ expect(root.querySelector('button[aria-label="Zoom in"]')).not.toBeNull();
150
+ expect(root.querySelector('button[title^="Maximize"]')).not.toBeNull();
151
+
152
+ const fitButton = root.querySelector<HTMLButtonElement>('button[aria-label="Fit diagram"]')!;
153
+ expect(fitButton.getAttribute('aria-pressed')).toBe('true');
154
+ root.querySelector<HTMLButtonElement>('button[aria-label="Zoom in"]')?.click();
155
+ await vi.waitFor(() => expect(fitButton.getAttribute('aria-pressed')).toBe('false'));
156
+ fitButton.click();
157
+ await vi.waitFor(() => expect(fitButton.getAttribute('aria-pressed')).toBe('true'));
158
+ });
159
+
141
160
  it('enabled: false disables the plugin entirely', () => {
142
161
  const editor = makeEditor('```\n' + ART_A + '\n```\n', { enabled: false });
143
162
  expect(ASCII_DIAGRAM_KEY.getState(editor.state)).toBeUndefined();
@@ -44,6 +44,9 @@ function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
44
44
  if (!contents || contents.length === 0) return '';
45
45
  const parts: string[] = [];
46
46
  for (const node of contents) {
47
+ // Mermaid fences are visual source, not narrative copy. They stay on the
48
+ // slide as `contents` and materialize into Mermaid layers downstream.
49
+ if (node.type === 'code' && node.lang?.trim().toLowerCase() === 'mermaid') continue;
47
50
  parts.push(extractRichText(node));
48
51
  }
49
52
  return parts.join('\n').trim();
@@ -247,13 +250,12 @@ function blockToSlide(
247
250
  // block has no previous slide to transition in from.
248
251
  transition: block.transition ?? (index > 0 ? { type: 'fade', duration: 0.5 } : undefined),
249
252
  title: headingText,
250
- // Custom templates need access to the source block's body content
251
- // + children so their token resolver (`{content}`, `{children}`,
252
- // `{image:N}`) substitutes against the user's prose, not just the
253
- // heading. Built-in templates don't read these fields and risk
254
- // surprising overlap with their typed inputs, so we only attach
255
- // them when the slide actually maps to a custom template.
256
- ...(isCustomTemplate && block.contents ? { contents: block.contents } : {}),
253
+ // Preserve body nodes on every slide. Built-in templates ignore this
254
+ // structural field, while the canonical materializer uses it to retain
255
+ // authored rich elements (Mermaid fences today; other media can follow)
256
+ // independently of the selected visual template.
257
+ ...(block.contents ? { contents: block.contents } : {}),
258
+ // Custom templates additionally consume child blocks through tokens.
257
259
  ...(isCustomTemplate && block.children ? { children: block.children } : {}),
258
260
  ...defaults,
259
261
  ...extraFields,