@bendyline/squisq-editor-react 1.6.2 → 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 (200) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1229 -179
  3. package/dist/index.js +18017 -11231
  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 +1471 -245
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +348 -260
  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 +309 -69
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +12 -3
  24. package/src/TemplatePicker.tsx +75 -21
  25. package/src/ThemeCustomizerPanel.tsx +8 -0
  26. package/src/ThemePicker.tsx +10 -5
  27. package/src/TimelineBlockPreview.tsx +1 -1
  28. package/src/Toolbar.tsx +202 -41
  29. package/src/TransitionPicker.tsx +36 -4
  30. package/src/ViewMenuPanel.tsx +12 -14
  31. package/src/WysiwygEditor.tsx +124 -91
  32. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  33. package/src/__tests__/blockTagActivity.test.ts +183 -0
  34. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  35. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  36. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  37. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  38. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  39. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  40. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  41. package/src/__tests__/editorScrollSync.test.ts +65 -0
  42. package/src/__tests__/editorShellProps.test.tsx +123 -1
  43. package/src/__tests__/emojiPicker.test.tsx +2 -42
  44. package/src/__tests__/headingTransition.test.ts +0 -12
  45. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  46. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  47. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  48. package/src/__tests__/jsonEditor.test.tsx +56 -1
  49. package/src/__tests__/layersPanel.test.tsx +75 -5
  50. package/src/__tests__/majorApiTypes.test.ts +26 -0
  51. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  52. package/src/__tests__/narrationSave.test.ts +148 -0
  53. package/src/__tests__/outlinePanel.test.tsx +93 -3
  54. package/src/__tests__/outlineSource.test.ts +217 -0
  55. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  56. package/src/__tests__/presentationMode.test.tsx +290 -0
  57. package/src/__tests__/previewControls.test.tsx +218 -5
  58. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  59. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  60. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  61. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  62. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  63. package/src/__tests__/statusBar.test.tsx +27 -0
  64. package/src/__tests__/teleprompter.test.tsx +190 -0
  65. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  66. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  67. package/src/__tests__/tiptapBridge.test.ts +23 -0
  68. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  69. package/src/__tests__/transformStyleId.test.ts +13 -0
  70. package/src/__tests__/useImageEditor.test.tsx +67 -0
  71. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  72. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  73. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  74. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  75. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  76. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  77. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  78. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  79. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  80. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  81. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  82. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  83. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  84. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  85. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  86. package/src/asciiDiagram/asciiPaste.ts +19 -0
  87. package/src/blockTagActivity.ts +233 -0
  88. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  89. package/src/buildPreviewDoc.ts +19 -12
  90. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  91. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  92. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  93. package/src/customTemplates/__tests__/library.test.ts +8 -0
  94. package/src/customTemplates/designer.css +158 -99
  95. package/src/customTemplates/library.ts +16 -3
  96. package/src/customThemes/CustomThemeContext.tsx +6 -0
  97. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  98. package/src/customThemes/ImportThemeSection.tsx +178 -0
  99. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  100. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  101. package/src/customThemes/customThemeLibrary.ts +7 -4
  102. package/src/customThemes/index.ts +7 -1
  103. package/src/customThemes/themeDraft.ts +23 -7
  104. package/src/diagram/DiagramCanvas.tsx +9 -4
  105. package/src/diagram/types.ts +35 -0
  106. package/src/editorScrollSync.ts +73 -0
  107. package/src/emojiData.ts +3 -23
  108. package/src/headingTransition.ts +0 -15
  109. package/src/imageEditor/CanvasSurface.tsx +29 -23
  110. package/src/imageEditor/LayersPanel.tsx +78 -3
  111. package/src/imageEditor/Toolbar.tsx +5 -1
  112. package/src/imageEditor/icons.tsx +4 -0
  113. package/src/imageEditor/image-editor.css +75 -0
  114. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  115. package/src/imageEditor/useImageEditor.ts +71 -14
  116. package/src/index.ts +191 -19
  117. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  118. package/src/jsonEditor/RenderNode.tsx +24 -3
  119. package/src/jsonEditor/editors.tsx +86 -19
  120. package/src/mediaEntries.ts +12 -0
  121. package/src/monaco.ts +35 -0
  122. package/src/monacoWorkers.ts +89 -0
  123. package/src/outlineSource.ts +171 -0
  124. package/src/presentation/PresentationMode.tsx +596 -0
  125. package/src/rawEditorIsolation.ts +18 -0
  126. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  127. package/src/resolveBlockVisual.ts +10 -4
  128. package/src/scene/Scene.tsx +48 -18
  129. package/src/scene/SceneBlockExtension.ts +17 -10
  130. package/src/scene/SceneBlockWidget.tsx +5 -1
  131. package/src/scene/SceneViewport.tsx +7 -3
  132. package/src/scene/ShapePalette.tsx +17 -2
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  134. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  135. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  136. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  137. package/src/scene/commands/SceneCommand.ts +4 -1
  138. package/src/scene/index.ts +1 -6
  139. package/src/scene/layers/DiagramEdges.tsx +21 -7
  140. package/src/scene/layers/edgeGeometry.ts +9 -1
  141. package/src/scene/layers/nodeCard.tsx +27 -8
  142. package/src/scene/scene.css +5 -2
  143. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  144. package/src/scene/text/sceneTextChannel.ts +26 -20
  145. package/src/scene/text/sceneTextConfig.ts +4 -0
  146. package/src/scene/tools/ConnectTool.ts +13 -4
  147. package/src/scene/tools/SceneTool.ts +8 -0
  148. package/src/scene/tools/SelectTool.ts +34 -18
  149. package/src/scene/tools/ShapeTool.ts +2 -3
  150. package/src/styles/ascii-diagram.css +79 -0
  151. package/src/styles/ascii-timeline.css +499 -0
  152. package/src/styles/editor.css +706 -120
  153. package/src/styles/index.css +3 -0
  154. package/src/styles/tree-view.css +139 -0
  155. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  156. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  157. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  158. package/src/teleprompter/TeleprompterView.tsx +338 -0
  159. package/src/teleprompter/canvasRenderer.ts +161 -0
  160. package/src/teleprompter/floatingWindow.ts +352 -0
  161. package/src/teleprompter/index.ts +61 -0
  162. package/src/teleprompter/pcmWorklet.ts +62 -0
  163. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  164. package/src/teleprompter/recording/narrationSave.ts +134 -0
  165. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  166. package/src/teleprompter/scrollModel.ts +85 -0
  167. package/src/teleprompter/teleprompterTheme.ts +303 -0
  168. package/src/teleprompter/types.ts +38 -0
  169. package/src/teleprompter/useFloatingWindow.ts +74 -0
  170. package/src/teleprompter/useMicAnalysis.ts +211 -0
  171. package/src/teleprompter/useTeleprompter.ts +421 -0
  172. package/src/templateContentPreviewResolver.ts +12 -5
  173. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  174. package/src/timeline/TimelineViewExtension.ts +252 -0
  175. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  176. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  177. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  178. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  179. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  180. package/src/timeline/timelineCommands.ts +367 -0
  181. package/src/timeline/timelineData.ts +54 -0
  182. package/src/timeline/timelineOps.ts +277 -0
  183. package/src/timeline/timelinePaste.ts +8 -0
  184. package/src/tiptapBridge.ts +24 -16
  185. package/src/transformStyleId.ts +17 -0
  186. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  187. package/src/treeview/TreeViewExtension.ts +250 -0
  188. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  189. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  190. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  191. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  192. package/src/treeview/treeOps.ts +172 -0
  193. package/src/treeview/treePaste.ts +13 -0
  194. package/src/treeview/treeViewCommands.ts +94 -0
  195. package/src/treeview/treeViewData.ts +41 -0
  196. package/src/useMonacoLoader.ts +11 -34
  197. package/src/wysiwygImageUpload.ts +113 -0
  198. package/src/diagram/DiagramExtension.ts +0 -209
  199. package/src/diagram/DiagramWidget.tsx +0 -272
  200. 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,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
+ });
@@ -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,148 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, expect, it } from 'vitest';
5
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
6
+ import { markdownToDoc } from '@bendyline/squisq/doc';
7
+ import {
8
+ buildNarrationScript,
9
+ parseNarrationTimingJson,
10
+ type NarrationAlignment,
11
+ } from '@bendyline/squisq/narration';
12
+ import {
13
+ insertNarrationPreamble,
14
+ narrationAnnotationLine,
15
+ } from '../teleprompter/recording/insertPreamble';
16
+ import { buildNarrationSavePlan } from '../teleprompter/recording/narrationSave';
17
+
18
+ describe('insertNarrationPreamble', () => {
19
+ it('inserts at the top of a plain document', () => {
20
+ const next = insertNarrationPreamble('# Title\n\nBody.\n', 'audio/narration-1.webm', null);
21
+ expect(next.startsWith('{[audio src=audio/narration-1.webm anchor=document]}\n\n# Title')).toBe(
22
+ true,
23
+ );
24
+ });
25
+
26
+ it('inserts after the frontmatter fence', () => {
27
+ const source = '---\ntitle: Doc\n---\n\n# Title\n\nBody.\n';
28
+ const next = insertNarrationPreamble(source, 'audio/narration-1.webm', null);
29
+ const lines = next.split('\n');
30
+ expect(lines[2]).toBe('---');
31
+ expect(next).toContain(
32
+ '---\n\n{[audio src=audio/narration-1.webm anchor=document]}\n\n# Title',
33
+ );
34
+ });
35
+
36
+ it('replaces a previous take (and its camera line) on retake', () => {
37
+ const withTake = insertNarrationPreamble(
38
+ '# Title\n\nBody.\n',
39
+ 'audio/narration-old.webm',
40
+ 'video/narration-cam-old.webm',
41
+ );
42
+ expect(withTake).toContain('narration-old');
43
+ expect(withTake).toContain('<video src="video/narration-cam-old.webm"');
44
+
45
+ const retaken = insertNarrationPreamble(withTake, 'audio/narration-new.webm', null);
46
+ expect(retaken).not.toContain('narration-old');
47
+ expect(retaken).not.toContain('narration-cam-old');
48
+ expect(retaken).toContain('{[audio src=audio/narration-new.webm anchor=document]}');
49
+ // No duplicate annotations.
50
+ expect(retaken.match(/\{\[audio /g)?.length).toBe(1);
51
+ });
52
+
53
+ it('adds the camera companion as an inline video line', () => {
54
+ const next = insertNarrationPreamble(
55
+ '# Title\n\nBody.\n',
56
+ 'audio/narration-1.webm',
57
+ 'video/narration-cam-1.webm',
58
+ );
59
+ expect(next).toContain('{[audio src=audio/narration-1.webm anchor=document]}');
60
+ expect(next).toContain('<video src="video/narration-cam-1.webm" controls width="240"></video>');
61
+ });
62
+
63
+ it('quotes srcs with spaces', () => {
64
+ expect(narrationAnnotationLine('audio/my take.webm')).toBe(
65
+ '{[audio src="audio/my take.webm" anchor=document]}',
66
+ );
67
+ });
68
+
69
+ it('round-trips through markdownToDoc as documentMedia', () => {
70
+ const next = insertNarrationPreamble(
71
+ '---\ntitle: Doc\n---\n\n# Title\n\nBody.\n',
72
+ 'audio/narration-1.webm',
73
+ null,
74
+ );
75
+ const doc = markdownToDoc(parseMarkdown(next));
76
+ expect(doc.documentMedia?.length).toBe(1);
77
+ expect(doc.documentMedia![0].src).toBe('audio/narration-1.webm');
78
+ expect(doc.documentMedia![0].anchor).toBe('document');
79
+ });
80
+ });
81
+
82
+ describe('buildNarrationSavePlan', () => {
83
+ const script = buildNarrationScript(
84
+ markdownToDoc(parseMarkdown('# One\n\nAlpha beta.\n\n# Two\n\nGamma delta.\n')),
85
+ );
86
+ const alignment: NarrationAlignment = {
87
+ words: script.tokens.map((_, i) => ({ tokenIndex: i, tSec: i * 0.4, interpolated: false })),
88
+ blocks: script.blocks.map((range, i) => ({
89
+ blockId: range.blockId,
90
+ blockIndex: i,
91
+ charStart: range.charStart,
92
+ charEnd: range.charEnd,
93
+ startSec: i * 5,
94
+ endSec: (i + 1) * 5,
95
+ })),
96
+ detectedSyllables: script.totalSyllables,
97
+ cost: 0,
98
+ };
99
+
100
+ it('produces recorder-convention paths and a v3 sidecar the parser accepts', () => {
101
+ const plan = buildNarrationSavePlan({
102
+ script,
103
+ alignment,
104
+ durationSec: 10,
105
+ audioExt: '.webm',
106
+ cameraExt: '.webm',
107
+ baseWpm: 150,
108
+ cameraOffsetSec: 0.02,
109
+ });
110
+ expect(plan.audioRelativeName).toMatch(/^audio\/narration-.*\.webm$/);
111
+ expect(plan.cameraRelativeName).toMatch(/^video\/narration-cam.*\.webm$/);
112
+ expect(plan.sidecarPathFor('audio/renamed.webm')).toBe('audio/renamed.webm.timing.json');
113
+
114
+ const parsed = parseNarrationTimingJson(JSON.stringify(plan.sidecarPayload));
115
+ expect(parsed).not.toBeNull();
116
+ expect(parsed!.blocks.length).toBe(script.blocks.length);
117
+ expect(parsed!.bookmarks.length).toBe(script.tokens.length);
118
+ expect(parsed!.cameraOffsetSec).toBeCloseTo(0.02, 6);
119
+ });
120
+
121
+ it('degrades to an empty-timing sidecar when alignment failed', () => {
122
+ const plan = buildNarrationSavePlan({
123
+ script,
124
+ alignment: null,
125
+ durationSec: 10,
126
+ audioExt: '.webm',
127
+ cameraExt: null,
128
+ baseWpm: 150,
129
+ });
130
+ expect(plan.cameraRelativeName).toBeNull();
131
+ expect(plan.sidecarPayload.blocks).toEqual([]);
132
+ expect(plan.sidecarPayload.bookmarks).toEqual([]);
133
+ expect(plan.sidecarPayload.sourceText).toBe(script.sourceText);
134
+ });
135
+
136
+ it('nextMarkdown composes the preamble from one snapshot', () => {
137
+ const plan = buildNarrationSavePlan({
138
+ script,
139
+ alignment,
140
+ durationSec: 10,
141
+ audioExt: '.webm',
142
+ cameraExt: null,
143
+ baseWpm: 150,
144
+ });
145
+ const next = plan.nextMarkdown('# One\n\nAlpha beta.\n', 'audio/narration-x.webm', null);
146
+ expect(next).toContain('{[audio src=audio/narration-x.webm anchor=document]}');
147
+ });
148
+ });