@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
|
@@ -88,6 +88,75 @@ function LibraryThemeHarness() {
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
function CoverSlideProbe() {
|
|
92
|
+
const { activeCoverSlide, setCoverSlideEnabled } = usePreviewSettings();
|
|
93
|
+
const { markdownSource } = useEditorContext();
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
<button type="button" onClick={() => setCoverSlideEnabled(false)}>
|
|
97
|
+
Hide cover slide
|
|
98
|
+
</button>
|
|
99
|
+
<button type="button" onClick={() => setCoverSlideEnabled(true)}>
|
|
100
|
+
Use default cover slide
|
|
101
|
+
</button>
|
|
102
|
+
<div data-testid="active-cover-slide">{String(activeCoverSlide)}</div>
|
|
103
|
+
<pre data-testid="markdown-source">{markdownSource}</pre>
|
|
104
|
+
</>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function CoverSlideHarness() {
|
|
109
|
+
const { doc } = useEditorContext();
|
|
110
|
+
return (
|
|
111
|
+
<PreviewSettingsProvider doc={doc}>
|
|
112
|
+
<CoverSlideProbe />
|
|
113
|
+
</PreviewSettingsProvider>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ManagedDefaultsProbe() {
|
|
118
|
+
const {
|
|
119
|
+
activeThemeId,
|
|
120
|
+
activeTransformStyle,
|
|
121
|
+
activeCaptionStyle,
|
|
122
|
+
activeCaptionsEnabled,
|
|
123
|
+
setSelectedThemeId,
|
|
124
|
+
setSelectedTransformStyle,
|
|
125
|
+
setCaptionMode,
|
|
126
|
+
} = usePreviewSettings();
|
|
127
|
+
const { markdownSource } = useEditorContext();
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
<button type="button" onClick={() => setSelectedThemeId('standard')}>
|
|
131
|
+
Use default theme
|
|
132
|
+
</button>
|
|
133
|
+
<button type="button" onClick={() => setSelectedTransformStyle('')}>
|
|
134
|
+
Use default transform
|
|
135
|
+
</button>
|
|
136
|
+
<button type="button" onClick={() => setCaptionMode('standard')}>
|
|
137
|
+
Use default captions
|
|
138
|
+
</button>
|
|
139
|
+
<div
|
|
140
|
+
data-testid="managed-defaults"
|
|
141
|
+
data-theme={activeThemeId}
|
|
142
|
+
data-transform={activeTransformStyle}
|
|
143
|
+
data-caption-style={activeCaptionStyle}
|
|
144
|
+
data-captions-enabled={String(activeCaptionsEnabled)}
|
|
145
|
+
/>
|
|
146
|
+
<pre data-testid="markdown-source">{markdownSource}</pre>
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ManagedDefaultsHarness() {
|
|
152
|
+
const { doc } = useEditorContext();
|
|
153
|
+
return (
|
|
154
|
+
<PreviewSettingsProvider doc={doc}>
|
|
155
|
+
<ManagedDefaultsProbe />
|
|
156
|
+
</PreviewSettingsProvider>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
91
160
|
function renderPreviewControls(markdown: string) {
|
|
92
161
|
render(
|
|
93
162
|
<EditorProvider initialMarkdown={markdown}>
|
|
@@ -191,6 +260,101 @@ describe('PreviewModeSwitch', () => {
|
|
|
191
260
|
});
|
|
192
261
|
});
|
|
193
262
|
|
|
263
|
+
describe('cover-slide frontmatter', () => {
|
|
264
|
+
it('writes the non-default as a boolean and removes the default value', async () => {
|
|
265
|
+
render(
|
|
266
|
+
<EditorProvider initialMarkdown="# Hello">
|
|
267
|
+
<CoverSlideHarness />
|
|
268
|
+
</EditorProvider>,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(screen.getByTestId('active-cover-slide').textContent).toBe('true');
|
|
272
|
+
fireEvent.click(screen.getByRole('button', { name: 'Hide cover slide' }));
|
|
273
|
+
|
|
274
|
+
await waitFor(() => {
|
|
275
|
+
const source = screen.getByTestId('markdown-source').textContent ?? '';
|
|
276
|
+
expect(source).toContain('squisq-cover-slide: false');
|
|
277
|
+
expect(source).not.toContain('squisq-cover-slide: "false"');
|
|
278
|
+
});
|
|
279
|
+
expect(screen.getByTestId('active-cover-slide').textContent).toBe('false');
|
|
280
|
+
|
|
281
|
+
fireEvent.click(screen.getByRole('button', { name: 'Use default cover slide' }));
|
|
282
|
+
|
|
283
|
+
await waitFor(() => {
|
|
284
|
+
const source = screen.getByTestId('markdown-source').textContent ?? '';
|
|
285
|
+
expect(source).not.toContain('squisq-cover-slide');
|
|
286
|
+
expect(source).not.toContain('---');
|
|
287
|
+
});
|
|
288
|
+
expect(screen.getByTestId('active-cover-slide').textContent).toBe('true');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('removes a legacy cover-slide override when restoring the default', async () => {
|
|
292
|
+
render(
|
|
293
|
+
<EditorProvider initialMarkdown={'---\ncover-slide: false\ntitle: Hello\n---\n\n# Hello'}>
|
|
294
|
+
<CoverSlideHarness />
|
|
295
|
+
</EditorProvider>,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
await waitFor(() => {
|
|
299
|
+
expect(screen.getByTestId('active-cover-slide').textContent).toBe('false');
|
|
300
|
+
});
|
|
301
|
+
fireEvent.click(screen.getByRole('button', { name: 'Use default cover slide' }));
|
|
302
|
+
|
|
303
|
+
await waitFor(() => {
|
|
304
|
+
const source = screen.getByTestId('markdown-source').textContent ?? '';
|
|
305
|
+
expect(source).not.toContain('cover-slide');
|
|
306
|
+
expect(source).toContain('title: Hello');
|
|
307
|
+
});
|
|
308
|
+
expect(screen.getByTestId('active-cover-slide').textContent).toBe('true');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('managed preview-setting defaults', () => {
|
|
313
|
+
it('removes default theme, transform, and caption values plus their legacy aliases', async () => {
|
|
314
|
+
const markdown = `---
|
|
315
|
+
squisq-theme: documentary
|
|
316
|
+
themeId: bold
|
|
317
|
+
theme: cinematic
|
|
318
|
+
squisq-transform: documentary
|
|
319
|
+
transform-style: magazine
|
|
320
|
+
squisq-captions: social
|
|
321
|
+
caption-style: off
|
|
322
|
+
title: Hello
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
# Hello`;
|
|
326
|
+
render(
|
|
327
|
+
<EditorProvider initialMarkdown={markdown}>
|
|
328
|
+
<ManagedDefaultsHarness />
|
|
329
|
+
</EditorProvider>,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
fireEvent.click(screen.getByRole('button', { name: 'Use default theme' }));
|
|
333
|
+
await waitFor(() => {
|
|
334
|
+
const source = screen.getByTestId('markdown-source').textContent ?? '';
|
|
335
|
+
expect(source).not.toMatch(/^(?:squisq-theme|themeId|theme):/m);
|
|
336
|
+
});
|
|
337
|
+
expect(screen.getByTestId('managed-defaults').getAttribute('data-theme')).toBe('standard');
|
|
338
|
+
|
|
339
|
+
fireEvent.click(screen.getByRole('button', { name: 'Use default transform' }));
|
|
340
|
+
await waitFor(() => {
|
|
341
|
+
const source = screen.getByTestId('markdown-source').textContent ?? '';
|
|
342
|
+
expect(source).not.toMatch(/^(?:squisq-transform|transform-style):/m);
|
|
343
|
+
});
|
|
344
|
+
expect(screen.getByTestId('managed-defaults').getAttribute('data-transform')).toBe('');
|
|
345
|
+
|
|
346
|
+
fireEvent.click(screen.getByRole('button', { name: 'Use default captions' }));
|
|
347
|
+
await waitFor(() => {
|
|
348
|
+
const source = screen.getByTestId('markdown-source').textContent ?? '';
|
|
349
|
+
expect(source).not.toMatch(/^(?:squisq-captions|caption-style):/m);
|
|
350
|
+
expect(source).toContain('title: Hello');
|
|
351
|
+
});
|
|
352
|
+
const defaults = screen.getByTestId('managed-defaults');
|
|
353
|
+
expect(defaults.getAttribute('data-caption-style')).toBe('standard');
|
|
354
|
+
expect(defaults.getAttribute('data-captions-enabled')).toBe('true');
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
194
358
|
describe('PreviewToolbarControls', () => {
|
|
195
359
|
it('presents transforms as summarization without implying the source is changed', () => {
|
|
196
360
|
const originalResizeObserver = globalThis.ResizeObserver;
|
|
@@ -223,6 +387,37 @@ describe('PreviewToolbarControls', () => {
|
|
|
223
387
|
}
|
|
224
388
|
});
|
|
225
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
|
+
|
|
226
421
|
it('keeps the overflow popover inside the left viewport edge', async () => {
|
|
227
422
|
const originalResizeObserver = globalThis.ResizeObserver;
|
|
228
423
|
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
7
|
+
import { RecorderModal } from '../recorder/RecorderModal';
|
|
8
|
+
import { RecorderPanel } from '../recorder/RecorderPanel';
|
|
9
|
+
|
|
10
|
+
const mediaProvider: MediaProvider = {
|
|
11
|
+
resolveUrl: vi.fn(async (path: string) => path),
|
|
12
|
+
listMedia: vi.fn(async () => []),
|
|
13
|
+
addMedia: vi.fn(async (name: string) => name),
|
|
14
|
+
removeMedia: vi.fn(async () => undefined),
|
|
15
|
+
dispose: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('recorder theme propagation', () => {
|
|
19
|
+
it('creates a dark theme scope for the recorder dialog', () => {
|
|
20
|
+
render(<RecorderModal mediaProvider={mediaProvider} colorScheme="dark" onClose={vi.fn()} />);
|
|
21
|
+
|
|
22
|
+
const dialog = screen.getByRole('dialog', { name: 'Record media' });
|
|
23
|
+
expect(dialog.getAttribute('data-theme')).toBe('dark');
|
|
24
|
+
expect(dialog.classList.contains('squisq-editor-shell')).toBe(true);
|
|
25
|
+
expect(dialog.style.colorScheme).toBe('dark');
|
|
26
|
+
expect(dialog.style.getPropertyValue('--squisq-recorder-surface')).toBe(
|
|
27
|
+
'var(--squisq-bg, #1f2937)',
|
|
28
|
+
);
|
|
29
|
+
expect(dialog.style.getPropertyValue('--squisq-recorder-text')).toBe(
|
|
30
|
+
'var(--squisq-text, #e5e7eb)',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('passes the requested scheme through the portaled panel wrapper', () => {
|
|
35
|
+
render(<RecorderPanel mediaProvider={mediaProvider} colorScheme="dark" />);
|
|
36
|
+
fireEvent.click(screen.getByRole('button', { name: 'Record media' }));
|
|
37
|
+
|
|
38
|
+
expect(screen.getByRole('dialog', { name: 'Record media' }).getAttribute('data-theme')).toBe(
|
|
39
|
+
'dark',
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
selectionToTable,
|
|
4
|
+
selectionToTableMarkdown,
|
|
5
|
+
selectionToTaskItems,
|
|
6
|
+
selectionToTaskListMarkdown,
|
|
7
|
+
} from '../selectionConversions';
|
|
8
|
+
|
|
9
|
+
describe('selectionToTable', () => {
|
|
10
|
+
it('detects consistently pipe-delimited rows, including outer pipes', () => {
|
|
11
|
+
expect(selectionToTable('| Name | Role |\n| Ada | Engineer |')).toEqual({
|
|
12
|
+
delimiter: 'pipe',
|
|
13
|
+
rows: [
|
|
14
|
+
['Name', 'Role'],
|
|
15
|
+
['Ada', 'Engineer'],
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('detects CSV rows without splitting quoted commas', () => {
|
|
21
|
+
expect(selectionToTable('Name,Notes\nAda,"Math, logic"')).toEqual({
|
|
22
|
+
delimiter: 'comma',
|
|
23
|
+
rows: [
|
|
24
|
+
['Name', 'Notes'],
|
|
25
|
+
['Ada', 'Math, logic'],
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('retains literal quotes in comma-delimited cells', () => {
|
|
31
|
+
expect(selectionToTable('Item,Size\nDisplay,5" screen').rows[1]).toEqual([
|
|
32
|
+
'Display',
|
|
33
|
+
'5" screen',
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it.each([
|
|
38
|
+
['tab', 'Name\tRole\nAda\tEngineer'],
|
|
39
|
+
['multispace', 'Name Role\nAda Engineer'],
|
|
40
|
+
] as const)('detects %s-delimited rows', (delimiter, text) => {
|
|
41
|
+
expect(selectionToTable(text)).toEqual({
|
|
42
|
+
delimiter,
|
|
43
|
+
rows: [
|
|
44
|
+
['Name', 'Role'],
|
|
45
|
+
['Ada', 'Engineer'],
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('falls back to one column when delimiter counts are inconsistent', () => {
|
|
51
|
+
expect(selectionToTable('One,Two\nThree\nFour,Five,Six')).toEqual({
|
|
52
|
+
delimiter: null,
|
|
53
|
+
rows: [['One,Two'], ['Three'], ['Four,Five,Six']],
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('uses the first row as the Markdown header and escapes cell pipes', () => {
|
|
58
|
+
expect(selectionToTableMarkdown('Name,Notes\nAda,A | B')).toBe(
|
|
59
|
+
'| Name | Notes |\n| --- | --- |\n| Ada | A \\| B |',
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('selectionToTaskListMarkdown', () => {
|
|
65
|
+
it('turns non-empty selected lines into unchecked tasks', () => {
|
|
66
|
+
expect(selectionToTaskListMarkdown('Buy milk\n\nCall Sam')).toBe(
|
|
67
|
+
'- [ ] Buy milk\n- [ ] Call Sam',
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('normalizes list markers and retains existing checked state', () => {
|
|
72
|
+
const text = '- first\n2. second\n- [x] already done';
|
|
73
|
+
expect(selectionToTaskItems(text)).toEqual([
|
|
74
|
+
{ checked: false, text: 'first' },
|
|
75
|
+
{ checked: false, text: 'second' },
|
|
76
|
+
{ checked: true, text: 'already done' },
|
|
77
|
+
]);
|
|
78
|
+
expect(selectionToTaskListMarkdown(text)).toBe('- [ ] first\n- [ ] second\n- [x] already done');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
2
4
|
import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
|
|
3
5
|
|
|
4
6
|
// ---------------------------------------------------------------------------
|
|
@@ -66,25 +68,25 @@ describe('markdownToTiptap', () => {
|
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
it('converts mentions to chip spans', () => {
|
|
69
|
-
const html = markdownToTiptap('Hey @[Leo](
|
|
71
|
+
const html = markdownToTiptap('Hey @[Leo](person:leo), take a look.');
|
|
70
72
|
expect(html).toContain('data-mention="true"');
|
|
71
|
-
expect(html).toContain('data-kind="
|
|
73
|
+
expect(html).toContain('data-kind="person"');
|
|
72
74
|
expect(html).toContain('data-id="leo"');
|
|
73
75
|
expect(html).toContain('data-label="Leo"');
|
|
74
76
|
// "@Leo" appears inside the chip — NOT as a broken link
|
|
75
|
-
expect(html).not.toContain('href="
|
|
77
|
+
expect(html).not.toContain('href="person:leo"');
|
|
76
78
|
});
|
|
77
79
|
|
|
78
80
|
it('tolerates the backslash-escaped colon remark emits', () => {
|
|
79
|
-
// remark-stringify sometimes emits `
|
|
81
|
+
// remark-stringify sometimes emits `person\:leo` to disambiguate
|
|
80
82
|
// from autolink syntax. The bridge should still recognize it.
|
|
81
|
-
const html = markdownToTiptap('Hey @[Leo](
|
|
82
|
-
expect(html).toContain('data-kind="
|
|
83
|
+
const html = markdownToTiptap('Hey @[Leo](person\\:leo).');
|
|
84
|
+
expect(html).toContain('data-kind="person"');
|
|
83
85
|
expect(html).toContain('data-id="leo"');
|
|
84
86
|
});
|
|
85
87
|
|
|
86
88
|
it('round-trips mentions back to markdown', () => {
|
|
87
|
-
const md = 'Hey @[Leo](
|
|
89
|
+
const md = 'Hey @[Leo](person:leo), ping @[Tess](person:tess) too.';
|
|
88
90
|
const html = markdownToTiptap(md);
|
|
89
91
|
const back = tiptapToMarkdown(html);
|
|
90
92
|
expect(back.trim()).toBe(md);
|
|
@@ -120,6 +122,15 @@ describe('markdownToTiptap', () => {
|
|
|
120
122
|
expect(html).toContain('const x = 1;');
|
|
121
123
|
});
|
|
122
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
|
+
|
|
123
134
|
it('converts unordered lists', () => {
|
|
124
135
|
const md = '- Item one\n- Item two\n- Item three';
|
|
125
136
|
const html = markdownToTiptap(md);
|
|
@@ -158,6 +169,12 @@ describe('markdownToTiptap', () => {
|
|
|
158
169
|
expect(html).toContain('This is a quote');
|
|
159
170
|
});
|
|
160
171
|
|
|
172
|
+
it('groups consecutive quote lines into one visual blockquote', () => {
|
|
173
|
+
const html = markdownToTiptap('> A wise quote\n> -- Ada');
|
|
174
|
+
expect(html.match(/<blockquote>/g)).toHaveLength(1);
|
|
175
|
+
expect(html).toContain('<blockquote><p>A wise quote</p><p>-- Ada</p></blockquote>');
|
|
176
|
+
});
|
|
177
|
+
|
|
161
178
|
it('converts horizontal rules', () => {
|
|
162
179
|
const md = 'Before\n\n---\n\nAfter';
|
|
163
180
|
const html = markdownToTiptap(md);
|
|
@@ -312,6 +329,13 @@ describe('tiptapToMarkdown', () => {
|
|
|
312
329
|
expect(md).toContain('A wise quote');
|
|
313
330
|
});
|
|
314
331
|
|
|
332
|
+
it('keeps adjacent blockquote nodes on adjacent markdown lines', () => {
|
|
333
|
+
const md = tiptapToMarkdown(
|
|
334
|
+
'<blockquote><p>A wise quote</p></blockquote><blockquote><p>-- Ada</p></blockquote>',
|
|
335
|
+
);
|
|
336
|
+
expect(md).toBe('> A wise quote\n> -- Ada\n');
|
|
337
|
+
});
|
|
338
|
+
|
|
315
339
|
it('converts horizontal rules', () => {
|
|
316
340
|
const md = tiptapToMarkdown('<p>Before</p><hr><p>After</p>');
|
|
317
341
|
expect(md).toContain('---');
|
|
@@ -466,6 +490,32 @@ describe('round-trip: markdownToTiptap → tiptapToMarkdown', () => {
|
|
|
466
490
|
expect(roundTrip('> Important note')).toContain('> Important note');
|
|
467
491
|
});
|
|
468
492
|
|
|
493
|
+
it('does not add a blank line before a quote attribution', () => {
|
|
494
|
+
const md = [
|
|
495
|
+
'### A Famous Quote {[quote]}',
|
|
496
|
+
'',
|
|
497
|
+
'> "The best way to predict the future is to invent it."',
|
|
498
|
+
'> -- Alan Kay',
|
|
499
|
+
].join('\n');
|
|
500
|
+
|
|
501
|
+
expect(roundTrip(md)).toBe(md + '\n');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('does not add a blank line after Tiptap normalizes a quote attribution', () => {
|
|
505
|
+
const md = ['> "The best way to predict the future is to invent it."', '> -- Alan Kay'].join(
|
|
506
|
+
'\n',
|
|
507
|
+
);
|
|
508
|
+
const editor = new Editor({
|
|
509
|
+
extensions: [StarterKit],
|
|
510
|
+
content: markdownToTiptap(md),
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const normalizedHtml = editor.getHTML();
|
|
514
|
+
expect(normalizedHtml.match(/<blockquote>/g)).toHaveLength(1);
|
|
515
|
+
expect(tiptapToMarkdown(normalizedHtml)).toBe(md + '\n');
|
|
516
|
+
editor.destroy();
|
|
517
|
+
});
|
|
518
|
+
|
|
469
519
|
it('preserves unordered lists', () => {
|
|
470
520
|
const result = roundTrip('- Alpha\n- Beta');
|
|
471
521
|
expect(result).toContain('- Alpha');
|
|
@@ -12,7 +12,7 @@ import { tiptapToMarkdown } from '../tiptapBridge';
|
|
|
12
12
|
* when a pasted / uploaded image is inserted into the tiptap
|
|
13
13
|
* editor via `setImage({src, alt})`, does the markdown we serialize
|
|
14
14
|
* out (via `getHTML()` + `tiptapToMarkdown`) contain
|
|
15
|
-
* `` — the shape the
|
|
15
|
+
* `` — the shape the downstream service's image-attachment
|
|
16
16
|
* extractor expects?
|
|
17
17
|
*
|
|
18
18
|
* Unit tests on the regex alone have been green the whole time, and
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { Editor } from '@tiptap/core';
|
|
7
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
8
|
+
import TaskList from '@tiptap/extension-task-list';
|
|
9
|
+
import TaskItem from '@tiptap/extension-task-item';
|
|
10
|
+
import { EditorProvider, useEditorContext, type EditorContextValue } from '../EditorContext';
|
|
11
|
+
import { Toolbar } from '../Toolbar';
|
|
12
|
+
import { tiptapToMarkdown } from '../tiptapBridge';
|
|
13
|
+
|
|
14
|
+
let currentContext: EditorContextValue | null = null;
|
|
15
|
+
|
|
16
|
+
function ContextProbe() {
|
|
17
|
+
currentContext = useEditorContext();
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function context(): EditorContextValue {
|
|
22
|
+
if (!currentContext) throw new Error('EditorContext has not mounted');
|
|
23
|
+
return currentContext;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function monacoWithSelection(selectedText: string) {
|
|
27
|
+
const selection = {
|
|
28
|
+
startLineNumber: 1,
|
|
29
|
+
startColumn: 1,
|
|
30
|
+
endLineNumber: selectedText.split('\n').length,
|
|
31
|
+
endColumn: 1,
|
|
32
|
+
};
|
|
33
|
+
const model = {
|
|
34
|
+
getValue: () => selectedText,
|
|
35
|
+
getValueInRange: () => selectedText,
|
|
36
|
+
getLineContent: () => selectedText.split('\n')[0] ?? '',
|
|
37
|
+
};
|
|
38
|
+
const disposable = { dispose: vi.fn() };
|
|
39
|
+
const executeEdits = vi.fn();
|
|
40
|
+
const editor = {
|
|
41
|
+
getSelection: () => selection,
|
|
42
|
+
getModel: () => model,
|
|
43
|
+
getPosition: () => ({ lineNumber: 1, column: 1 }),
|
|
44
|
+
onDidChangeCursorPosition: () => disposable,
|
|
45
|
+
onDidChangeModelContent: () => disposable,
|
|
46
|
+
executeEdits,
|
|
47
|
+
focus: vi.fn(),
|
|
48
|
+
};
|
|
49
|
+
return { editor, executeEdits, selection };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
currentContext = null;
|
|
54
|
+
if (typeof window.matchMedia !== 'function') {
|
|
55
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
56
|
+
configurable: true,
|
|
57
|
+
value: () => ({
|
|
58
|
+
matches: false,
|
|
59
|
+
addEventListener: vi.fn(),
|
|
60
|
+
removeEventListener: vi.fn(),
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
65
|
+
class ResizeObserverStub {
|
|
66
|
+
observe(): void {}
|
|
67
|
+
unobserve(): void {}
|
|
68
|
+
disconnect(): void {}
|
|
69
|
+
}
|
|
70
|
+
globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('<Toolbar> selection conversion menu', () => {
|
|
75
|
+
it('shows Convert above Insert and replaces a delimited Monaco selection', async () => {
|
|
76
|
+
const source = 'Name,Role\nAda,Engineer\n';
|
|
77
|
+
const { editor, executeEdits, selection } = monacoWithSelection(source);
|
|
78
|
+
render(
|
|
79
|
+
<EditorProvider initialMarkdown={source} initialView="raw" allowRecording={false}>
|
|
80
|
+
<Toolbar />
|
|
81
|
+
<ContextProbe />
|
|
82
|
+
</EditorProvider>,
|
|
83
|
+
);
|
|
84
|
+
act(() => context().setMonacoEditor(editor as never));
|
|
85
|
+
|
|
86
|
+
fireEvent.click(screen.getByLabelText('Insert'));
|
|
87
|
+
const menu = await screen.findByRole('menu');
|
|
88
|
+
expect(
|
|
89
|
+
within(menu)
|
|
90
|
+
.getAllByText(/^(Convert|Insert)$/)
|
|
91
|
+
.map((node) => node.textContent),
|
|
92
|
+
).toEqual(['Convert', 'Insert']);
|
|
93
|
+
|
|
94
|
+
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Convert selection to Table' }));
|
|
95
|
+
|
|
96
|
+
expect(executeEdits).toHaveBeenCalledWith('toolbar-convert-selection', [
|
|
97
|
+
{
|
|
98
|
+
range: selection,
|
|
99
|
+
text: '| Name | Role |\n| --- | --- |\n| Ada | Engineer |\n',
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('does not show Convert without selected text', async () => {
|
|
105
|
+
const { editor } = monacoWithSelection('');
|
|
106
|
+
render(
|
|
107
|
+
<EditorProvider initialMarkdown="Intro" initialView="raw" allowRecording={false}>
|
|
108
|
+
<Toolbar />
|
|
109
|
+
<ContextProbe />
|
|
110
|
+
</EditorProvider>,
|
|
111
|
+
);
|
|
112
|
+
act(() => context().setMonacoEditor(editor as never));
|
|
113
|
+
|
|
114
|
+
fireEvent.click(screen.getByLabelText('Insert'));
|
|
115
|
+
const menu = await screen.findByRole('menu');
|
|
116
|
+
expect(within(menu).queryByText('Convert')).toBeNull();
|
|
117
|
+
expect(within(menu).queryByRole('menuitem', { name: 'Convert selection to Table' })).toBeNull();
|
|
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
|
+
|
|
145
|
+
it('replaces fully selected Write paragraphs without leaving a blank paragraph', async () => {
|
|
146
|
+
const editor = new Editor({
|
|
147
|
+
extensions: [StarterKit, TaskList, TaskItem],
|
|
148
|
+
content: '<p>First</p><p>Second</p><p>Third</p><h2>After</h2>',
|
|
149
|
+
});
|
|
150
|
+
const paragraphs: Array<{ pos: number; contentSize: number }> = [];
|
|
151
|
+
editor.state.doc.descendants((node, pos) => {
|
|
152
|
+
if (node.type.name === 'paragraph') {
|
|
153
|
+
paragraphs.push({ pos, contentSize: node.content.size });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
const first = paragraphs[0];
|
|
157
|
+
const third = paragraphs[2];
|
|
158
|
+
expect(first).toBeDefined();
|
|
159
|
+
expect(third).toBeDefined();
|
|
160
|
+
editor.commands.setTextSelection({
|
|
161
|
+
from: first.pos + 1,
|
|
162
|
+
to: third.pos + 1 + third.contentSize,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
render(
|
|
166
|
+
<EditorProvider
|
|
167
|
+
initialMarkdown="First\n\nSecond\n\nThird\n\n## After"
|
|
168
|
+
initialView="wysiwyg"
|
|
169
|
+
allowRecording={false}
|
|
170
|
+
>
|
|
171
|
+
<Toolbar />
|
|
172
|
+
<ContextProbe />
|
|
173
|
+
</EditorProvider>,
|
|
174
|
+
);
|
|
175
|
+
act(() => context().setTiptapEditor(editor));
|
|
176
|
+
|
|
177
|
+
fireEvent.click(screen.getByLabelText('Insert'));
|
|
178
|
+
fireEvent.click(
|
|
179
|
+
await screen.findByRole('menuitem', { name: 'Convert selection to Task List' }),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
await waitFor(() => {
|
|
183
|
+
expect(editor.getJSON().content?.map((node) => node.type)).toEqual(['taskList', 'heading']);
|
|
184
|
+
});
|
|
185
|
+
expect(tiptapToMarkdown(editor.getHTML())).toBe(
|
|
186
|
+
'- [ ] First\n- [ ] Second\n- [ ] Third\n\n## After\n',
|
|
187
|
+
);
|
|
188
|
+
editor.destroy();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -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
|
+
});
|