@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
@@ -13,9 +13,10 @@
13
13
  * the props it receives so we can assert `monacoTheme` reaches it.
14
14
  */
15
15
  import { describe, it, expect, vi, beforeEach } from 'vitest';
16
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
16
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
17
17
  import type { RawEditorProps } from '../RawEditor';
18
18
  import type { MediaEntry, MediaProvider } from '@bendyline/squisq/schemas';
19
+ import { useMediaProvider } from '@bendyline/squisq-react';
19
20
 
20
21
  // Records the props the shell passes to RawEditor on each render.
21
22
  const rawEditorProps: RawEditorProps[] = [];
@@ -42,6 +43,13 @@ function MarkdownSourceProbe() {
42
43
  return <pre data-testid="markdown-source">{markdownSource}</pre>;
43
44
  }
44
45
 
46
+ function MediaProviderProbe({ expected }: { expected: MediaProvider }) {
47
+ const provider = useMediaProvider();
48
+ return (
49
+ <span data-testid="media-context-probe">{provider === expected ? 'same' : 'missing'}</span>
50
+ );
51
+ }
52
+
45
53
  function mediaProviderWith(count: number): MediaProvider {
46
54
  const entries: MediaEntry[] = Array.from({ length: count }, (_, i) => ({
47
55
  name: `file-${i + 1}.png`,
@@ -184,6 +192,61 @@ describe('RawEditor monacoTheme prop', () => {
184
192
  });
185
193
  });
186
194
 
195
+ describe('<EditorShell> instance boundaries', () => {
196
+ it('provides its mediaProvider to React preview/layer consumers', async () => {
197
+ const provider = mediaProviderWith(0);
198
+ await act(async () => {
199
+ render(
200
+ <EditorShell
201
+ initialMarkdown="# hi"
202
+ initialView="raw"
203
+ mediaProvider={provider}
204
+ toolbarSlotLeft={<MediaProviderProbe expected={provider} />}
205
+ />,
206
+ );
207
+ });
208
+ expect(screen.getByTestId('media-context-probe').textContent).toBe('same');
209
+ });
210
+
211
+ it('keeps view shortcuts inside the editor that received the key', async () => {
212
+ const { container } = render(
213
+ <>
214
+ <EditorShell initialMarkdown="# first" initialView="raw" />
215
+ <EditorShell initialMarkdown="# second" initialView="raw" />
216
+ </>,
217
+ );
218
+ const shells = container.querySelectorAll<HTMLElement>('.squisq-editor-shell');
219
+ fireEvent.keyDown(within(shells[1]).getByTestId('raw-editor-stub'), {
220
+ key: '1',
221
+ ctrlKey: true,
222
+ });
223
+ await waitFor(() => expect(within(shells[1]).getByTestId('wysiwyg-editor-stub')).toBeTruthy());
224
+ expect(within(shells[0]).getByTestId('raw-editor-stub')).toBeTruthy();
225
+ });
226
+
227
+ it('ignores file drops in readOnly mode', async () => {
228
+ const { container } = render(
229
+ <EditorShell
230
+ initialMarkdown="# original"
231
+ initialView="raw"
232
+ readOnly
233
+ toolbarSlotRight={<MarkdownSourceProbe />}
234
+ />,
235
+ );
236
+ const shell = container.querySelector<HTMLElement>('.squisq-editor-shell')!;
237
+ const file = new File(['# replacement'], 'replacement.md', { type: 'text/markdown' });
238
+ fireEvent.drop(shell, {
239
+ dataTransfer: {
240
+ types: ['Files'],
241
+ files: [file],
242
+ items: [{ kind: 'file', type: 'text/markdown', getAsFile: () => file }],
243
+ },
244
+ });
245
+ await act(async () => Promise.resolve());
246
+ expect(screen.getByTestId('markdown-source').textContent).toBe('# original');
247
+ });
248
+ });
249
+
187
250
  describe('<EditorShell> Files badge', () => {
188
251
  it('shows the mediaProvider file count on the paperclip button', async () => {
189
252
  const { container } = render(
@@ -231,6 +294,25 @@ describe('<EditorShell> Files badge', () => {
231
294
  expect(container.querySelector('.squisq-toolbar-files-badge')?.textContent).toBe('1');
232
295
  });
233
296
 
297
+ it('hides .gitignore entries from the Files panel and its count', async () => {
298
+ const { provider } = mutableMediaProviderWith([
299
+ { name: '.gitignore', mimeType: 'text/plain', size: 11 },
300
+ { name: 'notes_files/.gitignore', mimeType: 'text/plain', size: 11 },
301
+ { name: 'notes_files/hero.png', mimeType: 'image/png', size: 1024 },
302
+ ]);
303
+
304
+ render(<EditorShell initialMarkdown="# hi" initialView="raw" mediaProvider={provider} />);
305
+
306
+ await waitFor(() => {
307
+ expect(screen.getByLabelText('Toggle Files panel, 1 file')).toBeTruthy();
308
+ });
309
+ fireEvent.click(screen.getByLabelText('Toggle Files panel, 1 file'));
310
+
311
+ expect(await screen.findByText('hero.png')).toBeTruthy();
312
+ expect(screen.queryByText('.gitignore')).toBeNull();
313
+ expect(screen.getByText('Files (1)')).toBeTruthy();
314
+ });
315
+
234
316
  it('removes a media file and matching markdown refs from the files context menu', async () => {
235
317
  const { provider, removed } = mutableMediaProviderWith([
236
318
  {
@@ -338,6 +420,28 @@ describe('<Toolbar> Files badge', () => {
338
420
  });
339
421
  expect(container.querySelector('.squisq-toolbar-files-badge')?.textContent).toBe('2');
340
422
  });
423
+
424
+ it('excludes .gitignore from a self-scanned count', async () => {
425
+ const { provider } = mutableMediaProviderWith([
426
+ { name: '.gitignore', mimeType: 'text/plain', size: 11 },
427
+ { name: 'hero.png', mimeType: 'image/png', size: 1024 },
428
+ ]);
429
+ const { container } = render(
430
+ <EditorProvider
431
+ initialMarkdown="# hi"
432
+ initialView="raw"
433
+ mediaProvider={provider}
434
+ allowRecording={false}
435
+ >
436
+ <Toolbar onToggleFiles={() => {}} />
437
+ </EditorProvider>,
438
+ );
439
+
440
+ await waitFor(() => {
441
+ expect(screen.getByLabelText('Toggle Files panel, 1 file')).toBeTruthy();
442
+ });
443
+ expect(container.querySelector('.squisq-toolbar-files-badge')?.textContent).toBe('1');
444
+ });
341
445
  });
342
446
 
343
447
  describe('<Toolbar> Insert menu', () => {
@@ -360,4 +464,22 @@ describe('<Toolbar> Insert menu', () => {
360
464
  );
361
465
  });
362
466
  });
467
+
468
+ it('adds an explicit authored timeline fence from the Insert menu', async () => {
469
+ render(
470
+ <EditorProvider initialMarkdown="Intro" initialView="raw" allowRecording={false}>
471
+ <Toolbar />
472
+ <MarkdownSourceProbe />
473
+ </EditorProvider>,
474
+ );
475
+
476
+ fireEvent.click(screen.getByLabelText('Insert'));
477
+ fireEvent.click(await screen.findByRole('menuitem', { name: /Timeline/i }));
478
+
479
+ await waitFor(() => {
480
+ expect(screen.getByTestId('markdown-source').textContent).toContain(
481
+ '```timeline\nMilestones: ● Start',
482
+ );
483
+ });
484
+ });
363
485
  });
@@ -1,43 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { fireEvent, render, screen } from '@testing-library/react';
3
- import { EmojiPicker } from '../EmojiPicker';
4
- import {
5
- EMOJI_CATEGORIES,
6
- ALL_EMOJIS,
7
- searchEmojis,
8
- PICKER_CATEGORIES,
9
- ALL_PICKER_ENTRIES,
10
- searchPickerEntries,
11
- } from '../emojiData';
12
-
13
- describe('emojiData — legacy emoji exports', () => {
14
- it('exposes the standard CLDR category buckets', () => {
15
- const ids = EMOJI_CATEGORIES.map((c) => c.id);
16
- expect(ids).toEqual([
17
- 'smileys',
18
- 'people',
19
- 'nature',
20
- 'food',
21
- 'travel',
22
- 'activities',
23
- 'objects',
24
- 'symbols',
25
- 'flags',
26
- ]);
27
- });
28
-
29
- it('each entry has a non-empty char and name', () => {
30
- for (const entry of ALL_EMOJIS) {
31
- expect(entry.char.length).toBeGreaterThan(0);
32
- expect(entry.name.length).toBeGreaterThan(0);
33
- }
34
- });
35
-
36
- it('searchEmojis matches by name (case-insensitive)', () => {
37
- const results = searchEmojis('HEART');
38
- expect(results.some((e) => e.char === '❤️')).toBe(true);
39
- });
40
- });
3
+ import { EmojiPicker, PICKER_CATEGORIES, ALL_PICKER_ENTRIES, searchPickerEntries } from '../index';
41
4
 
42
5
  describe('emojiData — unified picker entries', () => {
43
6
  it('appends FA Brands, Solid, and Regular categories after the emoji buckets', () => {
@@ -78,12 +41,9 @@ describe('EmojiPicker', () => {
78
41
 
79
42
  it('renders all emoji + FA tabs when open', () => {
80
43
  render(<EmojiPicker open onSelect={() => {}} onClose={() => {}} />);
81
- for (const cat of EMOJI_CATEGORIES) {
44
+ for (const cat of PICKER_CATEGORIES) {
82
45
  expect(screen.getByRole('tab', { name: cat.label })).toBeTruthy();
83
46
  }
84
- expect(screen.getByRole('tab', { name: 'Brands' })).toBeTruthy();
85
- expect(screen.getByRole('tab', { name: 'Solid' })).toBeTruthy();
86
- expect(screen.getByRole('tab', { name: 'Regular' })).toBeTruthy();
87
47
  });
88
48
 
89
49
  it('fires onSelect with an emoji PickerEntry when an emoji tile is clicked', () => {
@@ -4,7 +4,6 @@ import {
4
4
  setHeadingLineTransition,
5
5
  readBlockAttrsTransition,
6
6
  setHeadingAttrsTransition,
7
- setBlockAttrsTransition,
8
7
  EMPTY_TRANSITION,
9
8
  } from '../headingTransition';
10
9
 
@@ -174,15 +173,4 @@ describe('block-attrs (WYSIWYG) helpers', () => {
174
173
  templateParams: null,
175
174
  });
176
175
  });
177
-
178
- it('legacy single-channel writer still writes a fresh inner when none exists', () => {
179
- expect(setBlockAttrsTransition(null, { type: 'fade', direction: '', duration: '' })).toBe(
180
- 'transition=fade',
181
- );
182
- });
183
-
184
- it('legacy single-channel writer preserves the id and clears to null when emptied', () => {
185
- expect(setBlockAttrsTransition('transition=fade', EMPTY_TRANSITION)).toBeNull();
186
- expect(setBlockAttrsTransition('#intro transition=fade', EMPTY_TRANSITION)).toBe('#intro');
187
- });
188
176
  });
@@ -182,8 +182,10 @@ describe('<EditorShell> image-edit modal wiring', () => {
182
182
 
183
183
  expect(screen.queryByTestId('image-edit-modal')).toBeNull();
184
184
 
185
+ const trigger = screen.getByTestId('trigger');
186
+ trigger.focus();
185
187
  await act(async () => {
186
- fireEvent.click(screen.getByTestId('trigger'));
188
+ fireEvent.click(trigger);
187
189
  });
188
190
 
189
191
  await waitFor(() => {
@@ -191,6 +193,7 @@ describe('<EditorShell> image-edit modal wiring', () => {
191
193
  });
192
194
  // Shows the relative path in the header.
193
195
  expect(screen.getByText('hero.png')).toBeTruthy();
196
+ expect(document.activeElement).toBe(screen.getByTestId('image-edit-modal-close'));
194
197
 
195
198
  await act(async () => {
196
199
  fireEvent.click(screen.getByTestId('image-edit-modal-close'));
@@ -198,6 +201,7 @@ describe('<EditorShell> image-edit modal wiring', () => {
198
201
  await waitFor(() => {
199
202
  expect(screen.queryByTestId('image-edit-modal')).toBeNull();
200
203
  });
204
+ expect(document.activeElement).toBe(screen.getByTestId('trigger'));
201
205
  });
202
206
 
203
207
  it('opens the modal with a transient sidecar when only a mediaProvider is wired (no host container)', async () => {
@@ -0,0 +1,119 @@
1
+ /** @vitest-environment jsdom */
2
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import type { ContentContainer } from '@bendyline/squisq/storage';
5
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
6
+ import type { MediaProvider } from '@bendyline/squisq/schemas';
7
+ import { useEditorContext } from '../EditorContext';
8
+
9
+ const imageEditorProps: Array<{
10
+ filesContainer: ContentContainer;
11
+ saveFormat: 'png' | 'jpeg' | 'webp';
12
+ onExport: (blob: Blob, format: 'png' | 'jpeg' | 'webp') => void;
13
+ }> = [];
14
+
15
+ vi.mock('../ImageEditor', () => ({
16
+ ImageEditor: (props: (typeof imageEditorProps)[number]) => {
17
+ imageEditorProps.push(props);
18
+ return <div data-testid="image-editor-contract-stub" />;
19
+ },
20
+ }));
21
+ vi.mock('../RawEditor', () => ({ RawEditor: () => <div /> }));
22
+ vi.mock('../WysiwygEditor', () => ({ WysiwygEditor: () => <div /> }));
23
+ vi.mock('../PreviewPanel', () => ({ PreviewPanel: () => <div /> }));
24
+
25
+ import { EditorShell } from '../EditorShell';
26
+
27
+ function Trigger({ path }: { path: string }) {
28
+ const { openImageEdit } = useEditorContext();
29
+ return <button onClick={() => openImageEdit(path)}>edit</button>;
30
+ }
31
+
32
+ function provider(): MediaProvider {
33
+ return {
34
+ addMedia: vi.fn(async (name: string) => name),
35
+ resolveUrl: vi.fn(async (name: string) => `blob:${name}`),
36
+ listMedia: vi.fn(async () => []),
37
+ removeMedia: vi.fn(async () => {}),
38
+ dispose: vi.fn(),
39
+ };
40
+ }
41
+
42
+ beforeEach(() => {
43
+ imageEditorProps.length = 0;
44
+ if (typeof window.matchMedia !== 'function') {
45
+ Object.defineProperty(window, 'matchMedia', {
46
+ configurable: true,
47
+ value: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
48
+ });
49
+ }
50
+ });
51
+
52
+ describe('ImageEditModal storage contract', () => {
53
+ it('writes JPEG bytes/MIME back to a .jpg path', async () => {
54
+ const mediaProvider = provider();
55
+ render(
56
+ <EditorShell
57
+ initialMarkdown="# image"
58
+ mediaProvider={mediaProvider}
59
+ toolbarSlotRight={<Trigger path="photo.jpg" />}
60
+ />,
61
+ );
62
+ fireEvent.click(screen.getByText('edit'));
63
+ await waitFor(() => expect(screen.getByTestId('image-editor-contract-stub')).toBeTruthy());
64
+ const props = imageEditorProps[imageEditorProps.length - 1]!;
65
+ expect(props.saveFormat).toBe('jpeg');
66
+ await act(async () => props.onExport(new Blob(['jpeg']), 'jpeg'));
67
+ expect(mediaProvider.addMedia).toHaveBeenCalledWith(
68
+ 'photo.jpg',
69
+ expect.any(Blob),
70
+ 'image/jpeg',
71
+ );
72
+ });
73
+
74
+ it('hashes sidecar paths so sanitized-name collisions remain separate', async () => {
75
+ const parent = new MemoryContentContainer();
76
+ const mediaProvider = provider();
77
+ const first = render(
78
+ <EditorShell
79
+ initialMarkdown="# image"
80
+ workspaceContainer={parent}
81
+ mediaProvider={mediaProvider}
82
+ toolbarSlotRight={<Trigger path="a/b.png" />}
83
+ />,
84
+ );
85
+ fireEvent.click(screen.getByText('edit'));
86
+ await waitFor(() => expect(imageEditorProps).toHaveLength(1));
87
+ const firstContainer = imageEditorProps[0].filesContainer;
88
+ first.unmount();
89
+
90
+ render(
91
+ <EditorShell
92
+ initialMarkdown="# image"
93
+ workspaceContainer={parent}
94
+ mediaProvider={mediaProvider}
95
+ toolbarSlotRight={<Trigger path="a_b.png" />}
96
+ />,
97
+ );
98
+ fireEvent.click(screen.getByText('edit'));
99
+ await waitFor(() => expect(imageEditorProps).toHaveLength(2));
100
+ const secondContainer = imageEditorProps[1].filesContainer;
101
+ await firstContainer.writeFile('sentinel', new Uint8Array([1]).buffer);
102
+ await secondContainer.writeFile('sentinel', new Uint8Array([2]).buffer);
103
+ const paths = (await parent.listFiles('.imageEdits')).map((entry) => entry.path);
104
+ expect(paths.filter((path) => path.endsWith('/sentinel'))).toHaveLength(2);
105
+ });
106
+
107
+ it('refuses unsupported source formats instead of writing mismatched bytes', async () => {
108
+ render(
109
+ <EditorShell
110
+ initialMarkdown="# image"
111
+ mediaProvider={provider()}
112
+ toolbarSlotRight={<Trigger path="animated.gif" />}
113
+ />,
114
+ );
115
+ fireEvent.click(screen.getByText('edit'));
116
+ await waitFor(() => expect(screen.getByText(/supports PNG, JPEG, and WebP/i)).toBeTruthy());
117
+ expect(screen.queryByTestId('image-editor-contract-stub')).toBeNull();
118
+ });
119
+ });
@@ -6,7 +6,7 @@
6
6
  * renders the toolbar / canvas / panels.
7
7
  */
8
8
  import { describe, it, expect, beforeEach, vi } from 'vitest';
9
- import { render, screen, waitFor } from '@testing-library/react';
9
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
10
10
  import { MemoryContentContainer, scopeContainer } from '@bendyline/squisq/storage';
11
11
  import { writeImageEditDoc, createEmptyImageEditDoc } from '@bendyline/squisq/imageEdit';
12
12
  import { DARK_SURFACE } from '@bendyline/squisq/schemas';
@@ -56,6 +56,20 @@ describe('<ImageEditor>', () => {
56
56
  expect(screen.getByText(/loading image editor/i)).toBeTruthy();
57
57
  });
58
58
 
59
+ it('adds a centered shape layer from the Layers panel menu', async () => {
60
+ const parent = new MemoryContentContainer();
61
+ const sidecar = scopeContainer(parent, 'pic_files');
62
+ await writeImageEditDoc(sidecar, createEmptyImageEditDoc(640, 480));
63
+
64
+ render(<ImageEditor filesContainer={sidecar} />);
65
+ await waitFor(() => expect(screen.getByTestId('image-editor')).toBeTruthy());
66
+
67
+ fireEvent.click(screen.getByRole('button', { name: 'Add layer' }));
68
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Shape layer' }));
69
+
70
+ await waitFor(() => expect(screen.getByTitle('Rectangle')).toBeTruthy());
71
+ });
72
+
59
73
  it('applies an explicit dark surface to the editor chrome', async () => {
60
74
  const parent = new MemoryContentContainer();
61
75
  const sidecar = scopeContainer(parent, 'pic_files');
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import { useState } from 'react';
3
- import { fireEvent, render, screen } from '@testing-library/react';
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4
4
  import type { SquisqAnnotatedSchema } from '@bendyline/squisq/jsonForm';
5
5
  import { JsonEditor } from '../jsonEditor';
6
6
 
@@ -165,4 +165,59 @@ describe('JsonEditor', () => {
165
165
  fireEvent.change(input, { target: { value: 'changed' } });
166
166
  expect(onChange).not.toHaveBeenCalled();
167
167
  });
168
+
169
+ it('escapes object property names as RFC 6901 pointer segments', () => {
170
+ const schema: SquisqAnnotatedSchema = {
171
+ type: 'object',
172
+ properties: { 'a/b~c': { type: 'string', title: 'Odd key' } },
173
+ };
174
+ const onChange = vi.fn();
175
+ render(<Controlled schema={schema} initial={{ 'a/b~c': 'before' }} onValueChange={onChange} />);
176
+ fireEvent.change(screen.getByLabelText('Odd key'), { target: { value: 'after' } });
177
+ expect(onChange).toHaveBeenLastCalledWith({ 'a/b~c': 'after' });
178
+ });
179
+
180
+ it('associates labels/help/errors and reports validation after render', async () => {
181
+ const schema: SquisqAnnotatedSchema = {
182
+ type: 'object',
183
+ properties: {
184
+ title: { type: 'string', title: 'Title', description: 'A useful title' },
185
+ },
186
+ };
187
+ const onValidate = vi.fn();
188
+ render(
189
+ <JsonEditor
190
+ schema={schema}
191
+ value={{ title: '' }}
192
+ onChange={() => {}}
193
+ validate={() => [{ path: '/title', message: 'Required' }]}
194
+ onValidate={onValidate}
195
+ />,
196
+ );
197
+ const input = screen.getByLabelText('Title');
198
+ expect(input.getAttribute('aria-invalid')).toBe('true');
199
+ const described = input.getAttribute('aria-describedby') ?? '';
200
+ expect(described).toContain('-help');
201
+ expect(described).toContain('-error');
202
+ expect(screen.getByRole('alert').textContent).toBe('Required');
203
+ await waitFor(() =>
204
+ expect(onValidate).toHaveBeenCalledWith([{ path: '/title', message: 'Required' }]),
205
+ );
206
+ });
207
+
208
+ it('adds ARIA state and keyboard navigation to segmented/tabs controls', () => {
209
+ const schema: SquisqAnnotatedSchema = {
210
+ oneOf: [
211
+ { type: 'string', title: 'Text option' },
212
+ { type: 'number', title: 'Number option' },
213
+ ],
214
+ squisq: { control: 'tabs', label: 'Value type' },
215
+ };
216
+ render(<JsonEditor schema={schema} value="hello" onChange={() => {}} />);
217
+ const tabs = screen.getAllByRole('tab');
218
+ expect(tabs[0].getAttribute('aria-selected')).toBe('true');
219
+ fireEvent.keyDown(tabs[0], { key: 'ArrowRight' });
220
+ expect(tabs[1].getAttribute('aria-selected')).toBe('true');
221
+ expect(screen.getByRole('tabpanel')).toBeTruthy();
222
+ });
168
223
  });
@@ -7,6 +7,8 @@ import type { ImageEditDoc } from '@bendyline/squisq/schemas';
7
7
  import { LayersPanel } from '../imageEditor/LayersPanel.js';
8
8
  import type { ImageEditorAction } from '../imageEditor/state.js';
9
9
 
10
+ const ignoreAddLayer = () => {};
11
+
10
12
  function buildDoc(): ImageEditDoc {
11
13
  return {
12
14
  version: 1,
@@ -41,7 +43,14 @@ function buildDoc(): ImageEditDoc {
41
43
  describe('LayersPanel', () => {
42
44
  it('renders layers with the top of the SVG stack first', () => {
43
45
  const dispatch = vi.fn<(a: ImageEditorAction) => void>();
44
- render(<LayersPanel doc={buildDoc()} selectedLayerId={null} dispatch={dispatch} />);
46
+ render(
47
+ <LayersPanel
48
+ doc={buildDoc()}
49
+ selectedLayerId={null}
50
+ dispatch={dispatch}
51
+ onAddLayer={ignoreAddLayer}
52
+ />,
53
+ );
45
54
  const items = screen.getAllByText(/Bottom|Middle|Top/);
46
55
  expect(items[0]?.textContent).toContain('Top');
47
56
  expect(items[items.length - 1]?.textContent).toContain('Bottom');
@@ -49,14 +58,60 @@ describe('LayersPanel', () => {
49
58
 
50
59
  it('clicking the layer name dispatches a select action', () => {
51
60
  const dispatch = vi.fn<(a: ImageEditorAction) => void>();
52
- render(<LayersPanel doc={buildDoc()} selectedLayerId={null} dispatch={dispatch} />);
61
+ render(
62
+ <LayersPanel
63
+ doc={buildDoc()}
64
+ selectedLayerId={null}
65
+ dispatch={dispatch}
66
+ onAddLayer={ignoreAddLayer}
67
+ />,
68
+ );
53
69
  fireEvent.click(screen.getByText('Middle'));
54
70
  expect(dispatch).toHaveBeenCalledWith({ type: 'select', layerId: 'b' });
55
71
  });
56
72
 
73
+ it('exposes the full layer name as a tooltip when the row is truncated', () => {
74
+ const dispatch = vi.fn<(a: ImageEditorAction) => void>();
75
+ render(
76
+ <LayersPanel
77
+ doc={buildDoc()}
78
+ selectedLayerId={null}
79
+ dispatch={dispatch}
80
+ onAddLayer={ignoreAddLayer}
81
+ />,
82
+ );
83
+ expect(screen.getByTitle('Middle').textContent).toContain('Middle');
84
+ });
85
+
86
+ it('opens the add-layer menu and reports the selected layer kind', () => {
87
+ const dispatch = vi.fn<(a: ImageEditorAction) => void>();
88
+ const onAddLayer = vi.fn();
89
+ render(
90
+ <LayersPanel
91
+ doc={buildDoc()}
92
+ selectedLayerId={null}
93
+ dispatch={dispatch}
94
+ onAddLayer={onAddLayer}
95
+ />,
96
+ );
97
+
98
+ fireEvent.click(screen.getByRole('button', { name: 'Add layer' }));
99
+ expect(screen.getByRole('menu')).toBeTruthy();
100
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Text layer' }));
101
+ expect(onAddLayer).toHaveBeenCalledWith('text');
102
+ expect(screen.queryByRole('menu')).toBeNull();
103
+ });
104
+
57
105
  it('toggling visibility dispatches an update-layer action', () => {
58
106
  const dispatch = vi.fn<(a: ImageEditorAction) => void>();
59
- render(<LayersPanel doc={buildDoc()} selectedLayerId={null} dispatch={dispatch} />);
107
+ render(
108
+ <LayersPanel
109
+ doc={buildDoc()}
110
+ selectedLayerId={null}
111
+ dispatch={dispatch}
112
+ onAddLayer={ignoreAddLayer}
113
+ />,
114
+ );
60
115
  // The "Hide layer" buttons exist for visible layers.
61
116
  const hideButtons = screen.getAllByRole('button', { name: 'Hide layer' });
62
117
  fireEvent.click(hideButtons[0]!); // top-most visible layer is 'b'
@@ -69,7 +124,14 @@ describe('LayersPanel', () => {
69
124
 
70
125
  it('move-up button is disabled at the top of the stack', () => {
71
126
  const dispatch = vi.fn<(a: ImageEditorAction) => void>();
72
- render(<LayersPanel doc={buildDoc()} selectedLayerId={null} dispatch={dispatch} />);
127
+ render(
128
+ <LayersPanel
129
+ doc={buildDoc()}
130
+ selectedLayerId={null}
131
+ dispatch={dispatch}
132
+ onAddLayer={ignoreAddLayer}
133
+ />,
134
+ );
73
135
  const upButtons = screen.getAllByRole('button', { name: 'Move layer up' });
74
136
  // Visual order is c, b, a; layer 'c' is at the top of the stack and cannot move up.
75
137
  expect((upButtons[0] as HTMLButtonElement).disabled).toBe(true);
@@ -77,7 +139,14 @@ describe('LayersPanel', () => {
77
139
 
78
140
  it('delete button dispatches a remove-layer action', () => {
79
141
  const dispatch = vi.fn<(a: ImageEditorAction) => void>();
80
- render(<LayersPanel doc={buildDoc()} selectedLayerId={null} dispatch={dispatch} />);
142
+ render(
143
+ <LayersPanel
144
+ doc={buildDoc()}
145
+ selectedLayerId={null}
146
+ dispatch={dispatch}
147
+ onAddLayer={ignoreAddLayer}
148
+ />,
149
+ );
81
150
  const delButtons = screen.getAllByRole('button', { name: 'Delete layer' });
82
151
  fireEvent.click(delButtons[0]!); // top of stack ('c')
83
152
  expect(dispatch).toHaveBeenCalledWith({ type: 'remove-layer', layerId: 'c' });
@@ -90,6 +159,7 @@ describe('LayersPanel', () => {
90
159
  doc={{ version: 1, canvas: { width: 1, height: 1 }, layers: [] }}
91
160
  selectedLayerId={null}
92
161
  dispatch={dispatch}
162
+ onAddLayer={ignoreAddLayer}
93
163
  />,
94
164
  );
95
165
  expect(screen.getByText(/no layers yet/i)).toBeTruthy();
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { DiagramEdge, DiagramNode, EditorShellProps } from '../index';
3
+
4
+ // @ts-expect-error React Flow-prefixed types were removed in the major API cleanup.
5
+ import type { DiagramRFNode } from '../index';
6
+
7
+ describe('major-version editor API types', () => {
8
+ it('uses implementation-neutral diagram names', () => {
9
+ const node: DiagramNode = { id: 'a', position: { x: 0, y: 0 }, data: { label: 'A' } };
10
+ const edge: DiagramEdge = { id: 'a-b', source: 'a', target: 'b' };
11
+ expect([node.id, edge.id]).toEqual(['a', 'a-b']);
12
+ });
13
+
14
+ it('does not accept the removed EditorShell container prop', () => {
15
+ const props: EditorShellProps = {
16
+ // @ts-expect-error Use workspaceContainer; container was removed.
17
+ container: null,
18
+ };
19
+ expect(props).toHaveProperty('container');
20
+ });
21
+
22
+ it('keeps the old type import absent', () => {
23
+ const legacy: DiagramRFNode | null = null;
24
+ expect(legacy).toBeNull();
25
+ });
26
+ });
@@ -7,7 +7,7 @@ import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
7
7
  * uploaded files into the bin without inserting a markdown ref into
8
8
  * the editor body. A user would upload an image, hit Send in the
9
9
  * downstream chat composer, and the outgoing markdown would have no
10
- * image reference — the gezel would reply "nothing came through."
10
+ * image reference — the downstream consumer would reply "nothing came through."
11
11
  *
12
12
  * The fix: after `mediaProvider.addMedia(...)` succeeds, MediaBin
13
13
  * fires `onMediaUploaded(relativePath, name, mimeType)`. The
@@ -18,7 +18,7 @@ import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
18
18
  * These tests exercise the contract directly: the markdown snippet
19
19
  * produced by the upload callback, once round-tripped through the
20
20
  * editor's markdown↔HTML bridge, must round-trip back to a form
21
- * the gezel service's image-extraction regex can see.
21
+ * the downstream service's image-extraction regex can see.
22
22
  */
23
23
 
24
24
  function fakeMediaProvider(records: string[]): MediaProvider {