@bendyline/squisq-editor-react 1.6.0 → 1.6.2

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 (68) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +476 -105
  3. package/dist/index.js +8703 -6741
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +14617 -0
  10. package/package.json +15 -7
  11. package/src/BlockPropertiesPopover.tsx +23 -7
  12. package/src/EditorContext.tsx +22 -16
  13. package/src/EditorShell.tsx +121 -35
  14. package/src/MediaBin.tsx +171 -28
  15. package/src/OutlinePanel.tsx +26 -4
  16. package/src/PreviewControls.tsx +475 -145
  17. package/src/PreviewPanel.tsx +17 -9
  18. package/src/RawEditor.tsx +10 -4
  19. package/src/TemplateAnnotation.ts +22 -7
  20. package/src/TemplateContentPreview.tsx +56 -0
  21. package/src/TemplatePicker.tsx +295 -128
  22. package/src/ThemeCustomizerPanel.tsx +22 -15
  23. package/src/Toolbar.tsx +547 -217
  24. package/src/TransitionPicker.tsx +8 -1
  25. package/src/VersionHistoryPanel.tsx +2 -2
  26. package/src/ViewSwitcher.tsx +4 -4
  27. package/src/WysiwygEditor.tsx +45 -3
  28. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  29. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  30. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  31. package/src/__tests__/diffContextSections.test.ts +39 -0
  32. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  33. package/src/__tests__/editorShellProps.test.tsx +363 -0
  34. package/src/__tests__/headingTransition.test.ts +59 -9
  35. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  36. package/src/__tests__/mediaReferences.test.ts +82 -0
  37. package/src/__tests__/previewControls.test.tsx +163 -0
  38. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  39. package/src/__tests__/templateContentPreview.test.ts +101 -0
  40. package/src/__tests__/tiptapBridge.test.ts +47 -0
  41. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  42. package/src/__tests__/useMediaRecorder.test.ts +17 -0
  43. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  44. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  45. package/src/codeContext/CodeContextZones.tsx +121 -0
  46. package/src/codeContext/diffContextSections.ts +38 -0
  47. package/src/codeContext/types.ts +75 -0
  48. package/src/diagram/DiagramWidget.tsx +6 -4
  49. package/src/headingTransition.ts +96 -21
  50. package/src/index.ts +34 -2
  51. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  52. package/src/mediaReferences.ts +299 -0
  53. package/src/recorder/hooks/useMediaRecorder.ts +9 -10
  54. package/src/scene/Scene.tsx +53 -7
  55. package/src/scene/SceneBlockWidget.tsx +6 -3
  56. package/src/scene/SceneSelection.tsx +19 -15
  57. package/src/scene/SceneSideToolbar.tsx +89 -0
  58. package/src/scene/layers/DiagramEdges.tsx +4 -3
  59. package/src/scene/layers/edgeGeometry.ts +23 -4
  60. package/src/scene/scene.css +142 -2
  61. package/src/scene/tools/ConnectTool.ts +113 -24
  62. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  63. package/src/scene/tools/SceneTool.ts +2 -0
  64. package/src/styles/code-context.css +155 -0
  65. package/src/styles/editor.css +991 -84
  66. package/src/styles/index.css +1 -0
  67. package/src/templateContentPreviewResolver.ts +353 -0
  68. package/src/tiptapBridge.ts +60 -33
@@ -0,0 +1,363 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * Prop-contract tests for the v1.5 naming renames:
5
+ * - `<EditorShell>`'s light/dark chrome prop is `colorScheme` (was
6
+ * `theme`), and it drives the `data-theme` attribute on the shell root.
7
+ * - `<RawEditor>`'s Monaco theme-string prop is `monacoTheme` (was
8
+ * `theme`); the shell maps `colorScheme` → `monacoTheme` (`'dark'` →
9
+ * `'vs-dark'`, `'light'` → `'vs'`).
10
+ *
11
+ * The heavy editing surfaces are stubbed so the shell mounts under jsdom
12
+ * without dragging in monaco-editor or Tiptap. The RawEditor stub records
13
+ * the props it receives so we can assert `monacoTheme` reaches it.
14
+ */
15
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
16
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
17
+ import type { RawEditorProps } from '../RawEditor';
18
+ import type { MediaEntry, MediaProvider } from '@bendyline/squisq/schemas';
19
+
20
+ // Records the props the shell passes to RawEditor on each render.
21
+ const rawEditorProps: RawEditorProps[] = [];
22
+
23
+ vi.mock('../RawEditor', () => ({
24
+ RawEditor: (props: RawEditorProps) => {
25
+ rawEditorProps.push(props);
26
+ return <div data-testid="raw-editor-stub" />;
27
+ },
28
+ }));
29
+ vi.mock('../WysiwygEditor', () => ({
30
+ WysiwygEditor: () => <div data-testid="wysiwyg-editor-stub" />,
31
+ }));
32
+ vi.mock('../PreviewPanel', () => ({
33
+ PreviewPanel: () => <div data-testid="preview-stub" />,
34
+ }));
35
+
36
+ import { EditorShell } from '../EditorShell';
37
+ import { EditorProvider, useEditorContext } from '../EditorContext';
38
+ import { Toolbar } from '../Toolbar';
39
+
40
+ function MarkdownSourceProbe() {
41
+ const { markdownSource } = useEditorContext();
42
+ return <pre data-testid="markdown-source">{markdownSource}</pre>;
43
+ }
44
+
45
+ function mediaProviderWith(count: number): MediaProvider {
46
+ const entries: MediaEntry[] = Array.from({ length: count }, (_, i) => ({
47
+ name: `file-${i + 1}.png`,
48
+ mimeType: 'image/png',
49
+ size: i + 1,
50
+ }));
51
+ return {
52
+ async addMedia(name: string) {
53
+ return name;
54
+ },
55
+ async resolveUrl(relPath: string) {
56
+ return relPath;
57
+ },
58
+ async listMedia() {
59
+ return entries;
60
+ },
61
+ async removeMedia() {
62
+ /* no-op */
63
+ },
64
+ dispose() {
65
+ /* no-op */
66
+ },
67
+ };
68
+ }
69
+
70
+ function mediaProviderWithCounts(counts: number[]): MediaProvider {
71
+ let callCount = 0;
72
+ return {
73
+ async addMedia(name: string) {
74
+ return name;
75
+ },
76
+ async resolveUrl(relPath: string) {
77
+ return relPath;
78
+ },
79
+ async listMedia() {
80
+ const count = counts[Math.min(callCount, counts.length - 1)] ?? 0;
81
+ callCount += 1;
82
+ return Array.from({ length: count }, (_, i) => ({
83
+ name: `file-${i + 1}.png`,
84
+ mimeType: 'image/png',
85
+ size: i + 1,
86
+ }));
87
+ },
88
+ async removeMedia() {
89
+ /* no-op */
90
+ },
91
+ dispose() {
92
+ /* no-op */
93
+ },
94
+ };
95
+ }
96
+
97
+ function mutableMediaProviderWith(entries: MediaEntry[]): {
98
+ provider: MediaProvider;
99
+ removed: string[];
100
+ } {
101
+ let current = [...entries];
102
+ const removed: string[] = [];
103
+ return {
104
+ removed,
105
+ provider: {
106
+ async addMedia(name: string) {
107
+ return name;
108
+ },
109
+ async resolveUrl(relPath: string) {
110
+ return relPath;
111
+ },
112
+ async listMedia() {
113
+ return current;
114
+ },
115
+ async removeMedia(relPath: string) {
116
+ removed.push(relPath);
117
+ current = current.filter((entry) => entry.name !== relPath);
118
+ },
119
+ dispose() {
120
+ /* no-op */
121
+ },
122
+ },
123
+ };
124
+ }
125
+
126
+ beforeEach(() => {
127
+ rawEditorProps.length = 0;
128
+ // jsdom lacks matchMedia / ResizeObserver, which the Toolbar uses.
129
+ if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
130
+ Object.defineProperty(window, 'matchMedia', {
131
+ configurable: true,
132
+ value: (query: string) => ({
133
+ matches: false,
134
+ media: query,
135
+ onchange: null,
136
+ addListener: vi.fn(),
137
+ removeListener: vi.fn(),
138
+ addEventListener: vi.fn(),
139
+ removeEventListener: vi.fn(),
140
+ dispatchEvent: vi.fn(() => false),
141
+ }),
142
+ });
143
+ }
144
+ if (typeof globalThis.ResizeObserver === 'undefined') {
145
+ class ResizeObserverStub {
146
+ observe(): void {}
147
+ unobserve(): void {}
148
+ disconnect(): void {}
149
+ }
150
+ (globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver =
151
+ ResizeObserverStub;
152
+ }
153
+ });
154
+
155
+ describe('<EditorShell> colorScheme prop', () => {
156
+ it('applies dark chrome via data-theme when colorScheme="dark"', () => {
157
+ const { container } = render(
158
+ <EditorShell initialMarkdown="# hi" initialView="raw" colorScheme="dark" />,
159
+ );
160
+ const shell = container.querySelector('.squisq-editor-shell');
161
+ expect(shell?.getAttribute('data-theme')).toBe('dark');
162
+ });
163
+
164
+ it('defaults to light chrome when colorScheme is omitted', () => {
165
+ const { container } = render(<EditorShell initialMarkdown="# hi" initialView="raw" />);
166
+ const shell = container.querySelector('.squisq-editor-shell');
167
+ expect(shell?.getAttribute('data-theme')).toBe('light');
168
+ });
169
+ });
170
+
171
+ describe('RawEditor monacoTheme prop', () => {
172
+ it('maps colorScheme="dark" to monacoTheme="vs-dark"', () => {
173
+ render(<EditorShell initialMarkdown="# hi" initialView="raw" colorScheme="dark" />);
174
+ expect(screen.getByTestId('raw-editor-stub')).toBeTruthy();
175
+ const last = rawEditorProps[rawEditorProps.length - 1];
176
+ expect(last?.monacoTheme).toBe('vs-dark');
177
+ });
178
+
179
+ it('maps colorScheme="light" to monacoTheme="vs"', () => {
180
+ render(<EditorShell initialMarkdown="# hi" initialView="raw" colorScheme="light" />);
181
+ expect(screen.getByTestId('raw-editor-stub')).toBeTruthy();
182
+ const last = rawEditorProps[rawEditorProps.length - 1];
183
+ expect(last?.monacoTheme).toBe('vs');
184
+ });
185
+ });
186
+
187
+ describe('<EditorShell> Files badge', () => {
188
+ it('shows the mediaProvider file count on the paperclip button', async () => {
189
+ const { container } = render(
190
+ <EditorShell initialMarkdown="# hi" initialView="raw" mediaProvider={mediaProviderWith(3)} />,
191
+ );
192
+
193
+ await waitFor(() => {
194
+ expect(screen.getByLabelText('Toggle Files panel, 3 files')).toBeTruthy();
195
+ });
196
+ expect(container.querySelector('.squisq-toolbar-files-badge')?.textContent).toBe('3');
197
+ });
198
+
199
+ it('compacts large visible counts while keeping the full count in the label', async () => {
200
+ const { container } = render(
201
+ <EditorShell
202
+ initialMarkdown="# hi"
203
+ initialView="raw"
204
+ mediaProvider={mediaProviderWith(120)}
205
+ />,
206
+ );
207
+
208
+ await waitFor(() => {
209
+ expect(screen.getByLabelText('Toggle Files panel, 120 files')).toBeTruthy();
210
+ });
211
+ expect(container.querySelector('.squisq-toolbar-files-badge')?.textContent).toBe('99+');
212
+ });
213
+
214
+ it('uses the MediaBin scan count when the panel discovers files', async () => {
215
+ const { container } = render(
216
+ <EditorShell
217
+ initialMarkdown="# hi"
218
+ initialView="raw"
219
+ mediaProvider={mediaProviderWithCounts([0, 1])}
220
+ />,
221
+ );
222
+
223
+ await waitFor(() => {
224
+ expect(screen.getByLabelText('Toggle Files panel')).toBeTruthy();
225
+ });
226
+ fireEvent.click(screen.getByLabelText('Toggle Files panel'));
227
+
228
+ await waitFor(() => {
229
+ expect(screen.getByLabelText('Toggle Files panel, 1 file')).toBeTruthy();
230
+ });
231
+ expect(container.querySelector('.squisq-toolbar-files-badge')?.textContent).toBe('1');
232
+ });
233
+
234
+ it('removes a media file and matching markdown refs from the files context menu', async () => {
235
+ const { provider, removed } = mutableMediaProviderWith([
236
+ {
237
+ name: 'attachments/pasted.png',
238
+ mimeType: 'image/png',
239
+ size: 1024,
240
+ },
241
+ ]);
242
+ const changes: string[] = [];
243
+ const initialMarkdown = [
244
+ 'Before',
245
+ '',
246
+ '![Screenshot](attachments/pasted.png)',
247
+ '',
248
+ 'After',
249
+ ].join('\n');
250
+
251
+ render(
252
+ <EditorShell
253
+ initialMarkdown={initialMarkdown}
254
+ initialView="raw"
255
+ mediaProvider={provider}
256
+ onChange={(source) => changes.push(source)}
257
+ />,
258
+ );
259
+
260
+ await waitFor(() => {
261
+ expect(screen.getByLabelText('Toggle Files panel, 1 file')).toBeTruthy();
262
+ });
263
+
264
+ fireEvent.click(screen.getByLabelText('Toggle Files panel, 1 file'));
265
+ const item = (await screen.findByText('pasted.png')).closest('.squisq-media-bin-item');
266
+ expect(item).toBeTruthy();
267
+
268
+ fireEvent.contextMenu(item as HTMLElement, { clientX: 24, clientY: 32 });
269
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Remove image' }));
270
+
271
+ await waitFor(() => {
272
+ expect(removed).toEqual(['attachments/pasted.png']);
273
+ });
274
+ await waitFor(() => {
275
+ expect(screen.queryByText('pasted.png')).toBeNull();
276
+ });
277
+ await waitFor(() => {
278
+ expect(changes[changes.length - 1]).toBe(['Before', '', '', 'After'].join('\n'));
279
+ });
280
+ });
281
+
282
+ it('marks files as unused when the document does not reference them', async () => {
283
+ const { container } = render(
284
+ <EditorShell
285
+ initialMarkdown={'![Used](attachments/used.png)\n\n# Notes'}
286
+ initialView="raw"
287
+ mediaProvider={
288
+ mutableMediaProviderWith([
289
+ {
290
+ name: 'attachments/used.png',
291
+ mimeType: 'image/png',
292
+ size: 100,
293
+ },
294
+ {
295
+ name: 'attachments/unused.png',
296
+ mimeType: 'image/png',
297
+ size: 200,
298
+ },
299
+ ]).provider
300
+ }
301
+ />,
302
+ );
303
+
304
+ await waitFor(() => {
305
+ expect(screen.getByLabelText('Toggle Files panel, 2 files')).toBeTruthy();
306
+ });
307
+ fireEvent.click(screen.getByLabelText('Toggle Files panel, 2 files'));
308
+
309
+ await screen.findByText('used.png');
310
+ await screen.findByText('unused.png');
311
+
312
+ const items = Array.from(container.querySelectorAll('.squisq-media-bin-item'));
313
+ const itemNamed = (name: string) =>
314
+ items.find((item) => item.querySelector('.squisq-media-bin-name')?.textContent === name);
315
+ const usedItem = itemNamed('used.png');
316
+ const unusedItem = itemNamed('unused.png');
317
+
318
+ expect(usedItem?.querySelector('.squisq-media-bin-unused-badge')).toBeNull();
319
+ expect(unusedItem?.querySelector('.squisq-media-bin-unused-badge')?.textContent).toBe('Unused');
320
+ });
321
+ });
322
+
323
+ describe('<Toolbar> Files badge', () => {
324
+ it('self-scans mediaProvider when fileCount is not controlled by a parent', async () => {
325
+ const { container } = render(
326
+ <EditorProvider
327
+ initialMarkdown="# hi"
328
+ initialView="raw"
329
+ mediaProvider={mediaProviderWith(2)}
330
+ allowRecording={false}
331
+ >
332
+ <Toolbar onToggleFiles={() => {}} />
333
+ </EditorProvider>,
334
+ );
335
+
336
+ await waitFor(() => {
337
+ expect(screen.getByLabelText('Toggle Files panel, 2 files')).toBeTruthy();
338
+ });
339
+ expect(container.querySelector('.squisq-toolbar-files-badge')?.textContent).toBe('2');
340
+ });
341
+ });
342
+
343
+ describe('<Toolbar> Insert menu', () => {
344
+ it('adds a default task list from the Insert menu in raw fallback mode', async () => {
345
+ render(
346
+ <EditorProvider initialMarkdown="Intro" initialView="raw" allowRecording={false}>
347
+ <Toolbar />
348
+ <MarkdownSourceProbe />
349
+ </EditorProvider>,
350
+ );
351
+
352
+ fireEvent.click(screen.getByLabelText('Insert'));
353
+ const item = await screen.findByRole('menuitem', { name: /Task List/i });
354
+
355
+ fireEvent.click(item);
356
+
357
+ await waitFor(() => {
358
+ expect(screen.getByTestId('markdown-source').textContent).toBe(
359
+ ['Intro', '- [ ] Task 1', '- [ ] Task 2', '- [ ] Task 3', ''].join('\n'),
360
+ );
361
+ });
362
+ });
363
+ });
@@ -3,6 +3,7 @@ import {
3
3
  readHeadingLineTransition,
4
4
  setHeadingLineTransition,
5
5
  readBlockAttrsTransition,
6
+ setHeadingAttrsTransition,
6
7
  setBlockAttrsTransition,
7
8
  EMPTY_TRANSITION,
8
9
  } from '../headingTransition';
@@ -40,6 +41,14 @@ describe('readHeadingLineTransition', () => {
40
41
  });
41
42
  });
42
43
 
44
+ it('reads a transition from a param-only {[…]} annotation', () => {
45
+ expect(readHeadingLineTransition('## Tips {[transition=zoom]}')).toEqual({
46
+ type: 'zoom',
47
+ direction: '',
48
+ duration: '',
49
+ });
50
+ });
51
+
43
52
  it('prefers the Pandoc block over the template params when both set it', () => {
44
53
  expect(
45
54
  readHeadingLineTransition('## Tips {transition=fade} {[title transition=zoom]}'),
@@ -48,32 +57,42 @@ describe('readHeadingLineTransition', () => {
48
57
  });
49
58
 
50
59
  describe('setHeadingLineTransition', () => {
51
- it('adds a Pandoc block to a plain heading', () => {
60
+ it('adds a squiggly annotation to a plain heading', () => {
52
61
  expect(setHeadingLineTransition('## Tips', { type: 'fade', direction: '', duration: '' })).toBe(
53
- '## Tips {transition=fade}',
62
+ '## Tips {[transition=fade]}',
54
63
  );
55
64
  });
56
65
 
57
- it('writes the Pandoc block before an existing {[…]} template annotation', () => {
66
+ it('folds into an existing {[…]} template annotation', () => {
58
67
  expect(
59
68
  setHeadingLineTransition('## Tips {[title]}', { type: 'fade', direction: '', duration: '' }),
60
- ).toBe('## Tips {transition=fade} {[title]}');
69
+ ).toBe('## Tips {[title transition=fade]}');
61
70
  });
62
71
 
63
72
  it('includes direction and duration when present', () => {
64
73
  expect(
65
74
  setHeadingLineTransition('## Tips', { type: 'push', direction: 'up', duration: '1.2' }),
66
- ).toBe('## Tips {transition=push transitionDirection=up transitionDuration=1.2}');
75
+ ).toBe('## Tips {[transition=push transitionDirection=up transitionDuration=1.2]}');
67
76
  });
68
77
 
69
- it('updates an existing transition in place, preserving other params and the id', () => {
78
+ it('migrates an existing Pandoc transition, preserving other params and the id', () => {
70
79
  expect(
71
80
  setHeadingLineTransition('## Tips {#intro transition=fade x=10}', {
72
81
  type: 'zoom',
73
82
  direction: '',
74
83
  duration: '',
75
84
  }),
76
- ).toBe('## Tips {#intro x=10 transition=zoom}');
85
+ ).toBe('## Tips {#intro x=10} {[transition=zoom]}');
86
+ });
87
+
88
+ it('migrates into an existing template annotation without duplicating channels', () => {
89
+ expect(
90
+ setHeadingLineTransition('## Tips {#intro transition=fade} {[title color=blue]}', {
91
+ type: 'zoom',
92
+ direction: '',
93
+ duration: '',
94
+ }),
95
+ ).toBe('## Tips {#intro} {[title color=blue transition=zoom]}');
77
96
  });
78
97
 
79
98
  it('removes the transition (and an empty block) when set to none', () => {
@@ -125,13 +144,44 @@ describe('block-attrs (WYSIWYG) helpers', () => {
125
144
  });
126
145
  });
127
146
 
128
- it('writes a fresh inner when none exists', () => {
147
+ it('writes transition edits to dataTemplateParams by default', () => {
148
+ expect(
149
+ setHeadingAttrsTransition(null, null, { type: 'fade', direction: '', duration: '' }),
150
+ ).toEqual({
151
+ blockAttrsInner: null,
152
+ templateParams: 'transition=fade',
153
+ });
154
+ });
155
+
156
+ it('migrates legacy dataBlockAttrs transitions to dataTemplateParams', () => {
157
+ expect(
158
+ setHeadingAttrsTransition('#intro transition=fade', 'color=blue', {
159
+ type: 'zoom',
160
+ direction: '',
161
+ duration: '',
162
+ }),
163
+ ).toEqual({
164
+ blockAttrsInner: '#intro',
165
+ templateParams: 'color=blue transition=zoom',
166
+ });
167
+ });
168
+
169
+ it('clears transitions from both WYSIWYG channels', () => {
170
+ expect(
171
+ setHeadingAttrsTransition('#intro transition=fade', 'transition=zoom', EMPTY_TRANSITION),
172
+ ).toEqual({
173
+ blockAttrsInner: '#intro',
174
+ templateParams: null,
175
+ });
176
+ });
177
+
178
+ it('legacy single-channel writer still writes a fresh inner when none exists', () => {
129
179
  expect(setBlockAttrsTransition(null, { type: 'fade', direction: '', duration: '' })).toBe(
130
180
  'transition=fade',
131
181
  );
132
182
  });
133
183
 
134
- it('preserves the id and clears to null when emptied', () => {
184
+ it('legacy single-channel writer preserves the id and clears to null when emptied', () => {
135
185
  expect(setBlockAttrsTransition('transition=fade', EMPTY_TRANSITION)).toBeNull();
136
186
  expect(setBlockAttrsTransition('#intro transition=fade', EMPTY_TRANSITION)).toBe('#intro');
137
187
  });
@@ -9,6 +9,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
9
9
  import { 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,26 @@ describe('<ImageEditor>', () => {
54
55
  render(<ImageEditor filesContainer={sidecar} />);
55
56
  expect(screen.getByText(/loading image editor/i)).toBeTruthy();
56
57
  });
58
+
59
+ it('applies an explicit dark surface to the editor chrome', async () => {
60
+ const parent = new MemoryContentContainer();
61
+ const sidecar = scopeContainer(parent, 'pic_files');
62
+ await writeImageEditDoc(sidecar, createEmptyImageEditDoc(64, 48));
63
+
64
+ render(<ImageEditor filesContainer={sidecar} surface={DARK_SURFACE} />);
65
+
66
+ await waitFor(() => {
67
+ expect(screen.getByTestId('image-editor')).toBeTruthy();
68
+ });
69
+
70
+ const editor = screen.getByTestId('image-editor');
71
+ expect(editor.style.getPropertyValue('--squisq-image-editor-bg')).toBe(DARK_SURFACE.background);
72
+ expect(editor.style.getPropertyValue('--squisq-image-editor-text')).toBe(DARK_SURFACE.text);
73
+ expect(editor.style.getPropertyValue('--squisq-image-editor-panel-bg')).toBe(
74
+ `color-mix(in srgb, ${DARK_SURFACE.background} 92%, ${DARK_SURFACE.text} 8%)`,
75
+ );
76
+ expect(editor.style.getPropertyValue('--squisq-image-editor-control-bg')).toBe(
77
+ `color-mix(in srgb, ${DARK_SURFACE.background} 86%, ${DARK_SURFACE.text} 14%)`,
78
+ );
79
+ });
57
80
  });
@@ -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
+ });