@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.
Files changed (110) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +579 -47
  3. package/dist/index.js +7477 -2943
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +1008 -38
  6. package/package.json +6 -5
  7. package/src/DocumentSettingsDialog.tsx +32 -18
  8. package/src/EditorContext.tsx +27 -0
  9. package/src/EditorShell.tsx +25 -1
  10. package/src/PreviewControls.tsx +77 -29
  11. package/src/PreviewPanel.tsx +5 -1
  12. package/src/RawEditor.tsx +12 -0
  13. package/src/RecorderEntry.tsx +5 -0
  14. package/src/Toolbar.tsx +479 -36
  15. package/src/WysiwygEditor.tsx +25 -11
  16. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  17. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  18. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  19. package/src/__tests__/editorShellProps.test.tsx +65 -0
  20. package/src/__tests__/findMode.test.tsx +104 -0
  21. package/src/__tests__/findModel.test.ts +55 -0
  22. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  23. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  24. package/src/__tests__/mediaReferences.test.ts +15 -0
  25. package/src/__tests__/previewControls.test.tsx +195 -0
  26. package/src/__tests__/recorderTheme.test.tsx +42 -0
  27. package/src/__tests__/selectionConversions.test.ts +80 -0
  28. package/src/__tests__/tiptapBridge.test.ts +57 -7
  29. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  30. package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
  31. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  32. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  33. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  34. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  35. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  36. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  37. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  38. package/src/buildPreviewDoc.ts +9 -7
  39. package/src/codeContext/types.ts +1 -1
  40. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  41. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  42. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  43. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  44. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  45. package/src/codeSnippet/codeSnippetData.ts +43 -0
  46. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  47. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  48. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  49. package/src/diagram/DiagramCanvas.tsx +17 -2
  50. package/src/find/FindHighlightExtension.ts +81 -0
  51. package/src/find/FindToolbar.tsx +314 -0
  52. package/src/find/findModel.ts +77 -0
  53. package/src/frontmatterSettings.ts +23 -0
  54. package/src/index.ts +96 -1
  55. package/src/markdownCodeFence.ts +72 -0
  56. package/src/mediaReferences.ts +5 -3
  57. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  58. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  59. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  60. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  61. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  62. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  63. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  64. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  65. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  66. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  67. package/src/mermaid/mermaidCommands.ts +34 -0
  68. package/src/mermaid/mermaidData.ts +31 -0
  69. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  70. package/src/mermaid/mermaidModel.ts +31 -0
  71. package/src/mermaid/mermaidRenderer.ts +181 -0
  72. package/src/mermaid/mermaidShapes.ts +113 -0
  73. package/src/mermaid/mermaidSourceOps.ts +454 -0
  74. package/src/recorder/RecorderButton.tsx +9 -1
  75. package/src/recorder/RecorderModal.tsx +84 -41
  76. package/src/recorder/RecorderPanel.tsx +9 -1
  77. package/src/scene/Scene.tsx +46 -15
  78. package/src/scene/SceneBlockToolbar.tsx +29 -14
  79. package/src/scene/SceneViewControls.tsx +43 -0
  80. package/src/scene/__tests__/fitScale.test.ts +19 -0
  81. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  82. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  83. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  84. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  85. package/src/scene/commands/SceneCommand.ts +13 -2
  86. package/src/scene/fitScale.ts +17 -0
  87. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  88. package/src/scene/scene.css +85 -3
  89. package/src/scene/tools/SelectTool.ts +5 -5
  90. package/src/selectionConversions.ts +155 -0
  91. package/src/styles/ascii-timeline.css +101 -4
  92. package/src/styles/code-snippet.css +76 -0
  93. package/src/styles/editor.css +352 -2
  94. package/src/styles/index.css +2 -0
  95. package/src/styles/mermaid-diagram.css +487 -0
  96. package/src/styles/tree-view.css +51 -1
  97. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  98. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  99. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  100. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  101. package/src/timeline/timelineCommands.ts +54 -0
  102. package/src/timeline/timelineOps.ts +107 -3
  103. package/src/tiptapBridge.ts +23 -5
  104. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  105. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  106. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  107. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  108. package/src/treeview/treeOps.ts +59 -0
  109. package/src/treeview/treeViewCommands.ts +5 -0
  110. 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](gezel:leo), take a look.');
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="gezel"');
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="gezel:leo"');
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 `gezel\:leo` to disambiguate
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](gezel\\:leo).');
82
- expect(html).toContain('data-kind="gezel"');
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](gezel:leo), ping @[Tess](gezel:tess) too.';
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
- * `![alt](src)` — the shape the gezel service's image-attachment
15
+ * `![alt](src)` — 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
+ });