@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.
- package/README.md +10 -2
- package/dist/index.d.ts +505 -15
- package/dist/index.js +6035 -2283
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +870 -40
- package/package.json +6 -5
- package/src/EditorContext.tsx +27 -0
- package/src/EditorShell.tsx +24 -0
- package/src/PreviewControls.tsx +20 -5
- package/src/PreviewPanel.tsx +5 -1
- package/src/RawEditor.tsx +12 -0
- package/src/RecorderEntry.tsx +3 -0
- package/src/Toolbar.tsx +315 -17
- package/src/WysiwygEditor.tsx +25 -11
- package/src/__tests__/buildPreviewDocContent.test.ts +17 -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__/mediaReferences.test.ts +15 -0
- package/src/__tests__/previewControls.test.tsx +31 -0
- package/src/__tests__/tiptapBridge.test.ts +9 -0
- package/src/__tests__/toolbarSelectionConversion.test.tsx +26 -0
- package/src/__tests__/writeCanvasSettings.test.ts +15 -0
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
- package/src/buildPreviewDoc.ts +9 -7
- 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/diagram/DiagramCanvas.tsx +1 -0
- package/src/find/FindHighlightExtension.ts +81 -0
- package/src/find/FindToolbar.tsx +314 -0
- package/src/find/findModel.ts +77 -0
- package/src/index.ts +89 -0
- 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/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__/useScenePanZoom.test.ts +28 -1
- package/src/scene/fitScale.ts +17 -0
- package/src/scene/hooks/useScenePanZoom.ts +11 -4
- package/src/scene/scene.css +85 -3
- package/src/styles/code-snippet.css +76 -0
- package/src/styles/editor.css +322 -2
- package/src/styles/index.css +2 -0
- package/src/styles/mermaid-diagram.css +487 -0
- 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
|
+
'',
|
|
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();
|
package/src/buildPreviewDoc.ts
CHANGED
|
@@ -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
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
//
|
|
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,
|