@bendyline/squisq-editor-react 1.6.1 → 2.0.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 (208) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1304 -208
  3. package/dist/index.js +22838 -14494
  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 +2384 -408
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +48 -17
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +403 -270
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +276 -40
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +466 -93
  20. package/src/PreviewPanel.tsx +313 -77
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +34 -10
  24. package/src/TemplateContentPreview.tsx +56 -0
  25. package/src/TemplatePicker.tsx +355 -134
  26. package/src/ThemeCustomizerPanel.tsx +30 -15
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +719 -238
  30. package/src/TransitionPicker.tsx +44 -5
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/ViewSwitcher.tsx +4 -4
  33. package/src/WysiwygEditor.tsx +167 -92
  34. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  35. package/src/__tests__/blockTagActivity.test.ts +183 -0
  36. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  37. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  38. package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
  39. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +29 -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 +390 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +53 -15
  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 +38 -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__/mediaBinDrop.test.tsx +90 -0
  54. package/src/__tests__/mediaReferences.test.ts +82 -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 +312 -6
  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__/resolveBlockVisual.test.ts +43 -0
  66. package/src/__tests__/statusBar.test.tsx +27 -0
  67. package/src/__tests__/teleprompter.test.tsx +190 -0
  68. package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
  69. package/src/__tests__/templateContentPreview.test.ts +101 -0
  70. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  71. package/src/__tests__/tiptapBridge.test.ts +70 -0
  72. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  73. package/src/__tests__/transformStyleId.test.ts +13 -0
  74. package/src/__tests__/useImageEditor.test.tsx +67 -0
  75. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  76. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  77. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  78. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  79. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  80. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  81. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  82. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  83. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  84. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  85. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  86. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  87. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  88. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  89. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  90. package/src/asciiDiagram/asciiPaste.ts +19 -0
  91. package/src/blockTagActivity.ts +233 -0
  92. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  93. package/src/buildPreviewDoc.ts +19 -12
  94. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  95. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  96. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  97. package/src/customTemplates/__tests__/library.test.ts +8 -0
  98. package/src/customTemplates/designer.css +158 -99
  99. package/src/customTemplates/library.ts +16 -3
  100. package/src/customThemes/CustomThemeContext.tsx +6 -0
  101. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  102. package/src/customThemes/ImportThemeSection.tsx +178 -0
  103. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  104. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  105. package/src/customThemes/customThemeLibrary.ts +7 -4
  106. package/src/customThemes/index.ts +7 -1
  107. package/src/customThemes/themeDraft.ts +23 -7
  108. package/src/diagram/DiagramCanvas.tsx +9 -4
  109. package/src/diagram/types.ts +35 -0
  110. package/src/editorScrollSync.ts +73 -0
  111. package/src/emojiData.ts +3 -23
  112. package/src/headingTransition.ts +85 -25
  113. package/src/imageEditor/CanvasSurface.tsx +29 -23
  114. package/src/imageEditor/LayersPanel.tsx +78 -3
  115. package/src/imageEditor/Toolbar.tsx +5 -1
  116. package/src/imageEditor/icons.tsx +4 -0
  117. package/src/imageEditor/image-editor.css +75 -0
  118. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  119. package/src/imageEditor/useImageEditor.ts +71 -14
  120. package/src/index.ts +193 -20
  121. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  122. package/src/jsonEditor/RenderNode.tsx +24 -3
  123. package/src/jsonEditor/editors.tsx +86 -19
  124. package/src/mediaEntries.ts +12 -0
  125. package/src/mediaReferences.ts +299 -0
  126. package/src/monaco.ts +35 -0
  127. package/src/monacoWorkers.ts +89 -0
  128. package/src/outlineSource.ts +171 -0
  129. package/src/presentation/PresentationMode.tsx +596 -0
  130. package/src/rawEditorIsolation.ts +18 -0
  131. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  132. package/src/resolveBlockVisual.ts +10 -4
  133. package/src/scene/Scene.tsx +101 -25
  134. package/src/scene/SceneBlockExtension.ts +17 -10
  135. package/src/scene/SceneBlockWidget.tsx +11 -4
  136. package/src/scene/SceneSelection.tsx +19 -15
  137. package/src/scene/SceneSideToolbar.tsx +89 -0
  138. package/src/scene/SceneViewport.tsx +7 -3
  139. package/src/scene/ShapePalette.tsx +17 -2
  140. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  141. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  142. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  143. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  144. package/src/scene/commands/SceneCommand.ts +4 -1
  145. package/src/scene/index.ts +1 -6
  146. package/src/scene/layers/DiagramEdges.tsx +24 -9
  147. package/src/scene/layers/edgeGeometry.ts +31 -4
  148. package/src/scene/layers/nodeCard.tsx +27 -8
  149. package/src/scene/scene.css +145 -2
  150. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  151. package/src/scene/text/sceneTextChannel.ts +26 -20
  152. package/src/scene/text/sceneTextConfig.ts +4 -0
  153. package/src/scene/tools/ConnectTool.ts +126 -28
  154. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  155. package/src/scene/tools/SceneTool.ts +10 -0
  156. package/src/scene/tools/SelectTool.ts +34 -18
  157. package/src/scene/tools/ShapeTool.ts +2 -3
  158. package/src/styles/ascii-diagram.css +79 -0
  159. package/src/styles/ascii-timeline.css +499 -0
  160. package/src/styles/editor.css +1566 -219
  161. package/src/styles/index.css +3 -0
  162. package/src/styles/tree-view.css +139 -0
  163. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  164. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  165. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  166. package/src/teleprompter/TeleprompterView.tsx +338 -0
  167. package/src/teleprompter/canvasRenderer.ts +161 -0
  168. package/src/teleprompter/floatingWindow.ts +352 -0
  169. package/src/teleprompter/index.ts +61 -0
  170. package/src/teleprompter/pcmWorklet.ts +62 -0
  171. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  172. package/src/teleprompter/recording/narrationSave.ts +134 -0
  173. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  174. package/src/teleprompter/scrollModel.ts +85 -0
  175. package/src/teleprompter/teleprompterTheme.ts +303 -0
  176. package/src/teleprompter/types.ts +38 -0
  177. package/src/teleprompter/useFloatingWindow.ts +74 -0
  178. package/src/teleprompter/useMicAnalysis.ts +211 -0
  179. package/src/teleprompter/useTeleprompter.ts +421 -0
  180. package/src/templateContentPreviewResolver.ts +360 -0
  181. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  182. package/src/timeline/TimelineViewExtension.ts +252 -0
  183. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  184. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  185. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  186. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  187. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  188. package/src/timeline/timelineCommands.ts +367 -0
  189. package/src/timeline/timelineData.ts +54 -0
  190. package/src/timeline/timelineOps.ts +277 -0
  191. package/src/timeline/timelinePaste.ts +8 -0
  192. package/src/tiptapBridge.ts +84 -49
  193. package/src/transformStyleId.ts +17 -0
  194. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  195. package/src/treeview/TreeViewExtension.ts +250 -0
  196. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  197. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  198. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  199. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  200. package/src/treeview/treeOps.ts +172 -0
  201. package/src/treeview/treePaste.ts +13 -0
  202. package/src/treeview/treeViewCommands.ts +94 -0
  203. package/src/treeview/treeViewData.ts +41 -0
  204. package/src/useMonacoLoader.ts +11 -34
  205. package/src/wysiwygImageUpload.ts +113 -0
  206. package/src/diagram/DiagramExtension.ts +0 -209
  207. package/src/diagram/DiagramWidget.tsx +0 -270
  208. package/src/diagram/useDiagramData.ts +0 -126
@@ -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,9 +6,10 @@
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
+ import { DARK_SURFACE } from '@bendyline/squisq/schemas';
12
13
  import { ImageEditor } from '../ImageEditor.js';
13
14
 
14
15
  beforeEach(() => {
@@ -54,4 +55,40 @@ describe('<ImageEditor>', () => {
54
55
  render(<ImageEditor filesContainer={sidecar} />);
55
56
  expect(screen.getByText(/loading image editor/i)).toBeTruthy();
56
57
  });
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
+
73
+ it('applies an explicit dark surface to the editor chrome', async () => {
74
+ const parent = new MemoryContentContainer();
75
+ const sidecar = scopeContainer(parent, 'pic_files');
76
+ await writeImageEditDoc(sidecar, createEmptyImageEditDoc(64, 48));
77
+
78
+ render(<ImageEditor filesContainer={sidecar} surface={DARK_SURFACE} />);
79
+
80
+ await waitFor(() => {
81
+ expect(screen.getByTestId('image-editor')).toBeTruthy();
82
+ });
83
+
84
+ const editor = screen.getByTestId('image-editor');
85
+ expect(editor.style.getPropertyValue('--squisq-image-editor-bg')).toBe(DARK_SURFACE.background);
86
+ expect(editor.style.getPropertyValue('--squisq-image-editor-text')).toBe(DARK_SURFACE.text);
87
+ expect(editor.style.getPropertyValue('--squisq-image-editor-panel-bg')).toBe(
88
+ `color-mix(in srgb, ${DARK_SURFACE.background} 92%, ${DARK_SURFACE.text} 8%)`,
89
+ );
90
+ expect(editor.style.getPropertyValue('--squisq-image-editor-control-bg')).toBe(
91
+ `color-mix(in srgb, ${DARK_SURFACE.background} 86%, ${DARK_SURFACE.text} 14%)`,
92
+ );
93
+ });
57
94
  });
@@ -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
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
6
+ import type { MediaEntry, MediaProvider } from '@bendyline/squisq/schemas';
7
+ import { MediaBin } from '../MediaBin.js';
8
+
9
+ function createProvider(): { provider: MediaProvider; added: string[] } {
10
+ const entries: MediaEntry[] = [];
11
+ const added: string[] = [];
12
+ return {
13
+ added,
14
+ provider: {
15
+ async addMedia(name, data, mimeType) {
16
+ const relativePath = `attachments/${name}`;
17
+ added.push(relativePath);
18
+ entries.push({
19
+ name: relativePath,
20
+ mimeType,
21
+ size: data instanceof Blob ? data.size : data.byteLength,
22
+ });
23
+ return relativePath;
24
+ },
25
+ async resolveUrl(path) {
26
+ return `blob:${path}`;
27
+ },
28
+ async listMedia() {
29
+ return [...entries];
30
+ },
31
+ async removeMedia() {},
32
+ dispose() {},
33
+ },
34
+ };
35
+ }
36
+
37
+ function dataTransfer(files: File[], itemTypes: string[]) {
38
+ return {
39
+ files,
40
+ items: itemTypes.map((type) => ({ kind: 'file', type })),
41
+ types: ['Files'],
42
+ dropEffect: 'none',
43
+ };
44
+ }
45
+
46
+ describe('MediaBin image drop target', () => {
47
+ it('shows a drop affordance and uploads a dropped image through the normal callback', async () => {
48
+ const { provider, added } = createProvider();
49
+ const onMediaUploaded = vi.fn();
50
+ const image = new File([new Uint8Array([1, 2, 3])], 'photo.png', { type: 'image/png' });
51
+ Object.defineProperty(image, 'arrayBuffer', {
52
+ configurable: true,
53
+ value: async () => new Uint8Array([1, 2, 3]).buffer,
54
+ });
55
+
56
+ const { container } = render(
57
+ <MediaBin mediaProvider={provider} isDark onMediaUploaded={onMediaUploaded} />,
58
+ );
59
+ const bin = container.querySelector('.squisq-media-bin') as HTMLElement;
60
+ const transfer = dataTransfer([image], ['image/png']);
61
+ await screen.findByText('No files yet.');
62
+
63
+ fireEvent.dragEnter(bin, { dataTransfer: transfer });
64
+ expect(bin.classList.contains('squisq-media-bin--drop-active')).toBe(true);
65
+ expect(screen.getByText('Drop images here')).toBeTruthy();
66
+
67
+ fireEvent.drop(bin, { dataTransfer: transfer });
68
+
69
+ await waitFor(() => expect(added).toEqual(['attachments/photo.png']));
70
+ expect(onMediaUploaded).toHaveBeenCalledWith('attachments/photo.png', 'photo.png', 'image/png');
71
+ expect(await screen.findByText('photo.png')).toBeTruthy();
72
+ expect(screen.queryByText('Drop images here')).toBeNull();
73
+ });
74
+
75
+ it('does not activate or upload for a non-image file', async () => {
76
+ const { provider, added } = createProvider();
77
+ const text = new File(['notes'], 'notes.txt', { type: 'text/plain' });
78
+ const { container } = render(<MediaBin mediaProvider={provider} isDark={false} />);
79
+ const bin = container.querySelector('.squisq-media-bin') as HTMLElement;
80
+ const transfer = dataTransfer([text], ['text/plain']);
81
+ await screen.findByText('No files yet.');
82
+
83
+ fireEvent.dragEnter(bin, { dataTransfer: transfer });
84
+ fireEvent.drop(bin, { dataTransfer: transfer });
85
+
86
+ expect(bin.classList.contains('squisq-media-bin--drop-active')).toBe(false);
87
+ expect(screen.queryByText('Drop images here')).toBeNull();
88
+ await waitFor(() => expect(added).toEqual([]));
89
+ });
90
+ });
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ collectMediaReferencesFromMarkdown,
4
+ removeMediaReferencesFromMarkdown,
5
+ } from '../mediaReferences';
6
+
7
+ describe('collectMediaReferencesFromMarkdown', () => {
8
+ it('collects markdown image/link and html media references', () => {
9
+ const refs = collectMediaReferencesFromMarkdown(
10
+ [
11
+ '![One](attachments/one.png)',
12
+ '[Two](attachments/two.pdf)',
13
+ '<video src="video/clip.webm" poster="video/poster.png"></video>',
14
+ '<a href="attachments/three.txt">Three</a>',
15
+ ].join('\n'),
16
+ );
17
+
18
+ expect([...refs].sort()).toEqual([
19
+ 'attachments/one.png',
20
+ 'attachments/three.txt',
21
+ 'attachments/two.pdf',
22
+ 'video/clip.webm',
23
+ 'video/poster.png',
24
+ ]);
25
+ });
26
+
27
+ it('collects media references from squiggly annotations', () => {
28
+ const refs = collectMediaReferencesFromMarkdown(
29
+ [
30
+ '## Intro {[audio=audio/take.mp3]}',
31
+ '{[video src="video/clip with spaces.webm"]}',
32
+ '### Image {#hero} {[image src=images/hero.png alt="Hero"]}',
33
+ ].join('\n'),
34
+ );
35
+
36
+ expect([...refs].sort()).toEqual([
37
+ 'audio/take.mp3',
38
+ 'images/hero.png',
39
+ 'video/clip with spaces.webm',
40
+ ]);
41
+ });
42
+ });
43
+
44
+ describe('removeMediaReferencesFromMarkdown', () => {
45
+ it('removes standalone image references to the media path', () => {
46
+ const source = ['Before', '', '![Screenshot](attachments/pasted.png)', '', 'After'].join('\n');
47
+
48
+ expect(removeMediaReferencesFromMarkdown(source, 'attachments/pasted.png')).toBe(
49
+ ['Before', '', '', 'After'].join('\n'),
50
+ );
51
+ });
52
+
53
+ it('removes inline image and link references without touching similar paths', () => {
54
+ const source = [
55
+ 'Keep ![Other](attachments/pasted.png.backup).',
56
+ 'Drop ![Screenshot](attachments/pasted.png) and [file](attachments/pasted.png).',
57
+ ].join('\n');
58
+
59
+ expect(removeMediaReferencesFromMarkdown(source, 'attachments/pasted.png')).toBe(
60
+ ['Keep ![Other](attachments/pasted.png.backup).', 'Drop and .'].join('\n'),
61
+ );
62
+ });
63
+
64
+ it('supports angle-wrapped destinations and markdown titles', () => {
65
+ const source =
66
+ 'Drop ![A](<attachments/my pasted image.png>) and [A](attachments/simple.png "title").';
67
+
68
+ expect(removeMediaReferencesFromMarkdown(source, 'attachments/my pasted image.png')).toBe(
69
+ 'Drop and [A](attachments/simple.png "title").',
70
+ );
71
+ expect(removeMediaReferencesFromMarkdown(source, 'attachments/simple.png')).toBe(
72
+ 'Drop ![A](<attachments/my pasted image.png>) and .',
73
+ );
74
+ });
75
+
76
+ it('removes resized raw HTML image tags and raw HTML anchors', () => {
77
+ const source =
78
+ '<img alt="Screenshot" src="attachments/pasted.png" width="320">\n<a href="attachments/pasted.png">Screenshot</a>';
79
+
80
+ expect(removeMediaReferencesFromMarkdown(source, 'attachments/pasted.png')).toBe('\n');
81
+ });
82
+ });