@bendyline/squisq-editor-react 1.6.2 → 2.0.1

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 (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +382 -5
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. package/src/diagram/useDiagramData.ts +0 -126
@@ -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);
@@ -158,6 +160,12 @@ describe('markdownToTiptap', () => {
158
160
  expect(html).toContain('This is a quote');
159
161
  });
160
162
 
163
+ it('groups consecutive quote lines into one visual blockquote', () => {
164
+ const html = markdownToTiptap('> A wise quote\n> -- Ada');
165
+ expect(html.match(/<blockquote>/g)).toHaveLength(1);
166
+ expect(html).toContain('<blockquote><p>A wise quote</p><p>-- Ada</p></blockquote>');
167
+ });
168
+
161
169
  it('converts horizontal rules', () => {
162
170
  const md = 'Before\n\n---\n\nAfter';
163
171
  const html = markdownToTiptap(md);
@@ -283,12 +291,42 @@ describe('tiptapToMarkdown', () => {
283
291
  expect(md).toContain('plain code');
284
292
  });
285
293
 
294
+ it('converts code blocks when <pre> carries attributes (real getHTML shape)', () => {
295
+ // StarterKit is configured with codeBlock HTMLAttributes, so real
296
+ // editor.getHTML() output puts class="squisq-code-block" on <pre>.
297
+ const md = tiptapToMarkdown(
298
+ '<pre class="squisq-code-block"><code class="language-js">const x = 1;</code></pre>',
299
+ );
300
+ expect(md).toContain('```js');
301
+ expect(md).toContain('const x = 1;');
302
+ });
303
+
304
+ it('converts attribute-bearing code blocks without language', () => {
305
+ const md = tiptapToMarkdown('<pre class="squisq-code-block"><code>plain code</code></pre>');
306
+ expect(md).toContain('```\nplain code\n```');
307
+ });
308
+
309
+ it('extracts language regardless of surrounding attributes on <code>', () => {
310
+ const md = tiptapToMarkdown(
311
+ '<pre class="squisq-code-block"><code spellcheck="false" class="language-python">x = 1</code></pre>',
312
+ );
313
+ expect(md).toContain('```python');
314
+ expect(md).toContain('x = 1');
315
+ });
316
+
286
317
  it('converts blockquotes', () => {
287
318
  const md = tiptapToMarkdown('<blockquote><p>A wise quote</p></blockquote>');
288
319
  expect(md).toContain('> ');
289
320
  expect(md).toContain('A wise quote');
290
321
  });
291
322
 
323
+ it('keeps adjacent blockquote nodes on adjacent markdown lines', () => {
324
+ const md = tiptapToMarkdown(
325
+ '<blockquote><p>A wise quote</p></blockquote><blockquote><p>-- Ada</p></blockquote>',
326
+ );
327
+ expect(md).toBe('> A wise quote\n> -- Ada\n');
328
+ });
329
+
292
330
  it('converts horizontal rules', () => {
293
331
  const md = tiptapToMarkdown('<p>Before</p><hr><p>After</p>');
294
332
  expect(md).toContain('---');
@@ -443,6 +481,32 @@ describe('round-trip: markdownToTiptap → tiptapToMarkdown', () => {
443
481
  expect(roundTrip('> Important note')).toContain('> Important note');
444
482
  });
445
483
 
484
+ it('does not add a blank line before a quote attribution', () => {
485
+ const md = [
486
+ '### A Famous Quote {[quote]}',
487
+ '',
488
+ '> "The best way to predict the future is to invent it."',
489
+ '> -- Alan Kay',
490
+ ].join('\n');
491
+
492
+ expect(roundTrip(md)).toBe(md + '\n');
493
+ });
494
+
495
+ it('does not add a blank line after Tiptap normalizes a quote attribution', () => {
496
+ const md = ['> "The best way to predict the future is to invent it."', '> -- Alan Kay'].join(
497
+ '\n',
498
+ );
499
+ const editor = new Editor({
500
+ extensions: [StarterKit],
501
+ content: markdownToTiptap(md),
502
+ });
503
+
504
+ const normalizedHtml = editor.getHTML();
505
+ expect(normalizedHtml.match(/<blockquote>/g)).toHaveLength(1);
506
+ expect(tiptapToMarkdown(normalizedHtml)).toBe(md + '\n');
507
+ editor.destroy();
508
+ });
509
+
446
510
  it('preserves unordered lists', () => {
447
511
  const result = roundTrip('- Alpha\n- Beta');
448
512
  expect(result).toContain('- Alpha');
@@ -0,0 +1,109 @@
1
+ import { Editor } from '@tiptap/core';
2
+ import StarterKit from '@tiptap/starter-kit';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
5
+ import { HeadingWithTemplate } from '../TemplateAnnotation';
6
+
7
+ /**
8
+ * Code-fence round-trip through a REAL tiptap editor.
9
+ *
10
+ * The string-level bridge tests feed hand-written `<pre><code>` HTML, but
11
+ * the production editor configures StarterKit's codeBlock with
12
+ * `HTMLAttributes: { class: 'squisq-code-block' }`, which tiptap renders
13
+ * onto the <pre>. The serializer regex used to require a bare `<pre><code`
14
+ * and silently dropped every fence on save. These tests exercise the real
15
+ * `getHTML()` output so that shape can never regress unnoticed.
16
+ */
17
+
18
+ function roundTripThroughEditor(md: string): string {
19
+ const editor = new Editor({
20
+ extensions: [
21
+ StarterKit.configure({
22
+ heading: false,
23
+ codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
24
+ }),
25
+ HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
26
+ ],
27
+ content: markdownToTiptap(md),
28
+ });
29
+ const html = editor.getHTML();
30
+ editor.destroy();
31
+ return tiptapToMarkdown(html);
32
+ }
33
+
34
+ describe('code fences through a real editor (getHTML → markdown)', () => {
35
+ it('emits class="squisq-code-block" on <pre> (the shape the serializer must accept)', () => {
36
+ const editor = new Editor({
37
+ extensions: [
38
+ StarterKit.configure({
39
+ heading: false,
40
+ codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
41
+ }),
42
+ HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
43
+ ],
44
+ content: markdownToTiptap('```js\nconst x = 1;\n```\n'),
45
+ });
46
+ const html = editor.getHTML();
47
+ editor.destroy();
48
+ expect(html).toContain('<pre class="squisq-code-block">');
49
+ });
50
+
51
+ it('round-trips a fence with a language byte-for-byte', () => {
52
+ const md = '```js\nconst x = 1;\n```\n';
53
+ expect(roundTripThroughEditor(md)).toBe(md);
54
+ });
55
+
56
+ it('round-trips a fence without a language byte-for-byte', () => {
57
+ const md = '```\nplain text content\n```\n';
58
+ expect(roundTripThroughEditor(md)).toBe(md);
59
+ });
60
+
61
+ it('round-trips a fence surrounded by prose', () => {
62
+ const md = 'Before the code.\n\n```python\nprint("hi")\n```\n\nAfter the code.\n';
63
+ expect(roundTripThroughEditor(md)).toBe(md);
64
+ });
65
+
66
+ it('round-trips HTML-significant characters inside a fence', () => {
67
+ const md = '```\nif (a < b && c > d) { echo "&nbsp;"; }\n```\n';
68
+ expect(roundTripThroughEditor(md)).toBe(md);
69
+ });
70
+
71
+ it('round-trips trailing spaces inside fence lines', () => {
72
+ const md = '```\nline with trailing \nspaces\n```\n';
73
+ expect(roundTripThroughEditor(md)).toBe(md);
74
+ });
75
+
76
+ it('round-trips a blank last line inside a fence', () => {
77
+ const md = '```\nfoo\n\n```\n';
78
+ expect(roundTripThroughEditor(md)).toBe(md);
79
+ });
80
+
81
+ it('round-trips Unicode box-drawing art byte-for-byte', () => {
82
+ const md = [
83
+ '```',
84
+ '┌────────┐ ┌────────┐',
85
+ '│ CLI │ │ MCP │',
86
+ '└───┬────┘ └───┬────┘',
87
+ ' │ │',
88
+ ' ▼ ▼',
89
+ '┌──────────────────────┐',
90
+ '│ kernel │',
91
+ '└──────────────────────┘',
92
+ '```',
93
+ '',
94
+ ].join('\n');
95
+ expect(roundTripThroughEditor(md)).toBe(md);
96
+ });
97
+
98
+ it('round-trips +--+ ASCII art byte-for-byte', () => {
99
+ const md = [
100
+ '```text',
101
+ '+--------+ +--------+',
102
+ '| Input | --> | Output |',
103
+ '+--------+ +--------+',
104
+ '```',
105
+ '',
106
+ ].join('\n');
107
+ expect(roundTripThroughEditor(md)).toBe(md);
108
+ });
109
+ });
@@ -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,164 @@
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
+ getValueInRange: () => selectedText,
35
+ getLineContent: () => selectedText.split('\n')[0] ?? '',
36
+ };
37
+ const disposable = { dispose: vi.fn() };
38
+ const executeEdits = vi.fn();
39
+ const editor = {
40
+ getSelection: () => selection,
41
+ getModel: () => model,
42
+ getPosition: () => ({ lineNumber: 1, column: 1 }),
43
+ onDidChangeCursorPosition: () => disposable,
44
+ onDidChangeModelContent: () => disposable,
45
+ executeEdits,
46
+ focus: vi.fn(),
47
+ };
48
+ return { editor, executeEdits, selection };
49
+ }
50
+
51
+ beforeEach(() => {
52
+ currentContext = null;
53
+ if (typeof window.matchMedia !== 'function') {
54
+ Object.defineProperty(window, 'matchMedia', {
55
+ configurable: true,
56
+ value: () => ({
57
+ matches: false,
58
+ addEventListener: vi.fn(),
59
+ removeEventListener: vi.fn(),
60
+ }),
61
+ });
62
+ }
63
+ if (typeof globalThis.ResizeObserver === 'undefined') {
64
+ class ResizeObserverStub {
65
+ observe(): void {}
66
+ unobserve(): void {}
67
+ disconnect(): void {}
68
+ }
69
+ globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
70
+ }
71
+ });
72
+
73
+ describe('<Toolbar> selection conversion menu', () => {
74
+ it('shows Convert above Insert and replaces a delimited Monaco selection', async () => {
75
+ const source = 'Name,Role\nAda,Engineer\n';
76
+ const { editor, executeEdits, selection } = monacoWithSelection(source);
77
+ render(
78
+ <EditorProvider initialMarkdown={source} initialView="raw" allowRecording={false}>
79
+ <Toolbar />
80
+ <ContextProbe />
81
+ </EditorProvider>,
82
+ );
83
+ act(() => context().setMonacoEditor(editor as never));
84
+
85
+ fireEvent.click(screen.getByLabelText('Insert'));
86
+ const menu = await screen.findByRole('menu');
87
+ expect(
88
+ within(menu)
89
+ .getAllByText(/^(Convert|Insert)$/)
90
+ .map((node) => node.textContent),
91
+ ).toEqual(['Convert', 'Insert']);
92
+
93
+ fireEvent.click(within(menu).getByRole('menuitem', { name: 'Convert selection to Table' }));
94
+
95
+ expect(executeEdits).toHaveBeenCalledWith('toolbar-convert-selection', [
96
+ {
97
+ range: selection,
98
+ text: '| Name | Role |\n| --- | --- |\n| Ada | Engineer |\n',
99
+ },
100
+ ]);
101
+ });
102
+
103
+ it('does not show Convert without selected text', async () => {
104
+ const { editor } = monacoWithSelection('');
105
+ render(
106
+ <EditorProvider initialMarkdown="Intro" initialView="raw" allowRecording={false}>
107
+ <Toolbar />
108
+ <ContextProbe />
109
+ </EditorProvider>,
110
+ );
111
+ act(() => context().setMonacoEditor(editor as never));
112
+
113
+ fireEvent.click(screen.getByLabelText('Insert'));
114
+ const menu = await screen.findByRole('menu');
115
+ expect(within(menu).queryByText('Convert')).toBeNull();
116
+ expect(within(menu).queryByRole('menuitem', { name: 'Convert selection to Table' })).toBeNull();
117
+ });
118
+
119
+ it('replaces fully selected Write paragraphs without leaving a blank paragraph', async () => {
120
+ const editor = new Editor({
121
+ extensions: [StarterKit, TaskList, TaskItem],
122
+ content: '<p>First</p><p>Second</p><p>Third</p><h2>After</h2>',
123
+ });
124
+ const paragraphs: Array<{ pos: number; contentSize: number }> = [];
125
+ editor.state.doc.descendants((node, pos) => {
126
+ if (node.type.name === 'paragraph') {
127
+ paragraphs.push({ pos, contentSize: node.content.size });
128
+ }
129
+ });
130
+ const first = paragraphs[0];
131
+ const third = paragraphs[2];
132
+ expect(first).toBeDefined();
133
+ expect(third).toBeDefined();
134
+ editor.commands.setTextSelection({
135
+ from: first.pos + 1,
136
+ to: third.pos + 1 + third.contentSize,
137
+ });
138
+
139
+ render(
140
+ <EditorProvider
141
+ initialMarkdown="First\n\nSecond\n\nThird\n\n## After"
142
+ initialView="wysiwyg"
143
+ allowRecording={false}
144
+ >
145
+ <Toolbar />
146
+ <ContextProbe />
147
+ </EditorProvider>,
148
+ );
149
+ act(() => context().setTiptapEditor(editor));
150
+
151
+ fireEvent.click(screen.getByLabelText('Insert'));
152
+ fireEvent.click(
153
+ await screen.findByRole('menuitem', { name: 'Convert selection to Task List' }),
154
+ );
155
+
156
+ await waitFor(() => {
157
+ expect(editor.getJSON().content?.map((node) => node.type)).toEqual(['taskList', 'heading']);
158
+ });
159
+ expect(tiptapToMarkdown(editor.getHTML())).toBe(
160
+ '- [ ] First\n- [ ] Second\n- [ ] Third\n\n## After\n',
161
+ );
162
+ editor.destroy();
163
+ });
164
+ });
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolvePersistedTransformStyleId } from '../transformStyleId';
3
+
4
+ describe('resolvePersistedTransformStyleId', () => {
5
+ it('accepts canonical ids and canonicalizes the legacy wire value', () => {
6
+ expect(resolvePersistedTransformStyleId('data-driven')).toBe('data-driven');
7
+ expect(resolvePersistedTransformStyleId('dataDriven')).toBe('data-driven');
8
+ });
9
+
10
+ it('does not turn an unknown id into the default transform', () => {
11
+ expect(resolvePersistedTransformStyleId('not-a-style')).toBeNull();
12
+ });
13
+ });
@@ -12,6 +12,14 @@ import {
12
12
  } from '@bendyline/squisq/imageEdit';
13
13
  import { useImageEditor } from '../imageEditor/useImageEditor.js';
14
14
 
15
+ function deferred<T>() {
16
+ let resolve!: (value: T) => void;
17
+ const promise = new Promise<T>((res) => {
18
+ resolve = res;
19
+ });
20
+ return { promise, resolve };
21
+ }
22
+
15
23
  beforeEach(() => {
16
24
  // jsdom doesn't implement createObjectURL or HTMLImageElement loading.
17
25
  // Stub both so the hook can complete its asset-resolution path.
@@ -131,6 +139,65 @@ describe('useImageEditor', () => {
131
139
  expect(persisted?.canvas.width).toBe(25);
132
140
  });
133
141
 
142
+ it('does not mark a newer edit clean when an older write resolves', async () => {
143
+ const container = new MemoryContentContainer();
144
+ await writeImageEditDoc(container, createEmptyImageEditDoc(10, 10));
145
+ const originalWrite = container.writeFile.bind(container);
146
+ const gate = deferred<void>();
147
+ const started = deferred<void>();
148
+ let writes = 0;
149
+ vi.spyOn(container, 'writeFile').mockImplementation(async (...args) => {
150
+ writes += 1;
151
+ if (writes === 1) {
152
+ started.resolve();
153
+ await gate.promise;
154
+ }
155
+ return originalWrite(...args);
156
+ });
157
+ const { result } = renderHook(() => useImageEditor({ container, persistDebounceMs: 5 }));
158
+ await waitFor(() => expect(result.current.ready).toBe(true));
159
+
160
+ act(() => result.current.dispatch({ type: 'set-canvas', canvas: { width: 20, height: 20 } }));
161
+ await started.promise;
162
+ act(() => result.current.dispatch({ type: 'set-canvas', canvas: { width: 30, height: 30 } }));
163
+ gate.resolve();
164
+
165
+ await waitFor(() => expect(result.current.state?.dirty).toBe(false));
166
+ expect((await readImageEditDoc(container))?.canvas.width).toBe(30);
167
+ });
168
+
169
+ it('flushes a dirty debounce when the editor unmounts', async () => {
170
+ const container = new MemoryContentContainer();
171
+ await writeImageEditDoc(container, createEmptyImageEditDoc(10, 10));
172
+ const { result, unmount } = renderHook(() =>
173
+ useImageEditor({ container, persistDebounceMs: 100000 }),
174
+ );
175
+ await waitFor(() => expect(result.current.ready).toBe(true));
176
+ act(() => result.current.dispatch({ type: 'set-canvas', canvas: { width: 44, height: 44 } }));
177
+ unmount();
178
+ await waitFor(async () => expect((await readImageEditDoc(container))?.canvas.width).toBe(44));
179
+ });
180
+
181
+ it('does not populate the URL cache after unmount', async () => {
182
+ const container = new MemoryContentContainer();
183
+ await writeImageEditDoc(container, createEmptyImageEditDoc(10, 10));
184
+ await container.writeFile('assets/late.png', new Uint8Array([1]).buffer, 'image/png');
185
+ const { result, unmount } = renderHook(() => useImageEditor({ container }));
186
+ await waitFor(() => expect(result.current.ready).toBe(true));
187
+ const originalRead = container.readFile.bind(container);
188
+ const gate = deferred<ArrayBuffer | null>();
189
+ vi.spyOn(container, 'readFile').mockImplementation((path) =>
190
+ path === 'assets/late.png' ? gate.promise : originalRead(path),
191
+ );
192
+ const create = vi.mocked(URL.createObjectURL);
193
+ const before = create.mock.calls.length;
194
+ const pending = result.current.resolveAssetUrl('assets/late.png');
195
+ unmount();
196
+ gate.resolve(new Uint8Array([1]).buffer);
197
+ await expect(pending).rejects.toThrow(/cancelled/);
198
+ expect(create).toHaveBeenCalledTimes(before);
199
+ });
200
+
134
201
  it('versioning is null when allowVersioning is false', async () => {
135
202
  const container = new MemoryContentContainer();
136
203
  await writeImageEditDoc(container, createEmptyImageEditDoc(10, 10));
@@ -50,6 +50,7 @@ interface FakeRecorderHandle {
50
50
  }
51
51
 
52
52
  let lastRecorder: FakeRecorderHandle | null = null;
53
+ let lastStream: FakeMediaStream | null = null;
53
54
 
54
55
  class FakeMediaRecorder implements FakeRecorderHandle {
55
56
  state: 'recording' | 'inactive' = 'inactive';
@@ -90,6 +91,7 @@ beforeEach(() => {
90
91
  lastRecorder = null;
91
92
  (globalThis as { MediaRecorder?: unknown }).MediaRecorder = FakeMediaRecorder;
92
93
  const fakeStream = new FakeMediaStream([new FakeMediaStreamTrack('audio')]);
94
+ lastStream = fakeStream;
93
95
  Object.defineProperty(globalThis, 'navigator', {
94
96
  value: {
95
97
  mediaDevices: {
@@ -224,4 +226,77 @@ describe('useMediaRecorder lifecycle', () => {
224
226
  expect(result.current.stream).toBeNull();
225
227
  expect(tracks.every((t) => t.stop.mock.calls.length > 0)).toBe(true);
226
228
  });
229
+
230
+ it('deduplicates concurrent permission requests', async () => {
231
+ let resolve!: (stream: FakeMediaStream) => void;
232
+ const pending = new Promise<FakeMediaStream>((res) => {
233
+ resolve = res;
234
+ });
235
+ const getUserMedia = vi.fn().mockReturnValue(pending);
236
+ Object.defineProperty(globalThis, 'navigator', {
237
+ configurable: true,
238
+ value: { mediaDevices: { getUserMedia } },
239
+ });
240
+ const stream = new FakeMediaStream([new FakeMediaStreamTrack('audio')]);
241
+ const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
242
+ let first!: Promise<void>;
243
+ let second!: Promise<void>;
244
+ act(() => {
245
+ first = result.current.request();
246
+ second = result.current.request();
247
+ });
248
+ expect(getUserMedia).toHaveBeenCalledTimes(1);
249
+ resolve(stream);
250
+ await act(async () => Promise.all([first, second]));
251
+ expect(result.current.state).toBe('ready');
252
+ });
253
+
254
+ it('releases an acquired stream when recorder construction fails', async () => {
255
+ class ThrowingRecorder {
256
+ static isTypeSupported() {
257
+ return true;
258
+ }
259
+ constructor() {
260
+ throw new Error('construction failed');
261
+ }
262
+ }
263
+ (globalThis as { MediaRecorder?: unknown }).MediaRecorder = ThrowingRecorder;
264
+ const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
265
+ await act(async () => {
266
+ await expect(result.current.request()).rejects.toThrow('construction failed');
267
+ });
268
+ expect(lastStream?.getTracks().every((track) => track.stop.mock.calls.length > 0)).toBe(true);
269
+ });
270
+
271
+ it('ignores a late onstop event after cancel', async () => {
272
+ const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
273
+ await act(async () => result.current.request());
274
+ act(() => result.current.start());
275
+ const lateOnStop = lastRecorder?.onstop;
276
+ act(() => result.current.cancel());
277
+ act(() => lateOnStop?.());
278
+ expect(result.current.state).toBe('idle');
279
+ expect(result.current.blob).toBeNull();
280
+ });
281
+
282
+ it('returns the same in-flight stop promise to repeated callers', async () => {
283
+ const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
284
+ await act(async () => result.current.request());
285
+ act(() => result.current.start());
286
+ const recorder = lastRecorder!;
287
+ const complete = recorder.onstop!;
288
+ recorder.stop = vi.fn(() => {
289
+ recorder.state = 'inactive';
290
+ });
291
+ let first!: Promise<Blob | null>;
292
+ let second!: Promise<Blob | null>;
293
+ act(() => {
294
+ first = result.current.stop();
295
+ second = result.current.stop();
296
+ });
297
+ expect(second).toBe(first);
298
+ act(() => complete());
299
+ await first;
300
+ expect(result.current.state).toBe('stopped');
301
+ });
227
302
  });