@bendyline/squisq-editor-react 1.6.1 → 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.
- package/dist/index.d.ts +87 -41
- package/dist/index.js +8263 -6705
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +841 -91
- package/package.json +4 -4
- package/src/BlockPropertiesPopover.tsx +23 -7
- package/src/EditorShell.tsx +65 -20
- package/src/MediaBin.tsx +171 -28
- package/src/PreviewControls.tsx +177 -44
- package/src/PreviewPanel.tsx +2 -0
- package/src/TemplateAnnotation.ts +22 -7
- package/src/TemplateContentPreview.tsx +56 -0
- package/src/TemplatePicker.tsx +295 -128
- package/src/ThemeCustomizerPanel.tsx +22 -15
- package/src/Toolbar.tsx +527 -207
- package/src/TransitionPicker.tsx +8 -1
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +45 -3
- package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
- package/src/__tests__/editorShellProps.test.tsx +268 -1
- package/src/__tests__/headingTransition.test.ts +59 -9
- package/src/__tests__/imageEditorShell.test.tsx +23 -0
- package/src/__tests__/mediaReferences.test.ts +82 -0
- package/src/__tests__/previewControls.test.tsx +94 -1
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
- package/src/__tests__/templateContentPreview.test.ts +101 -0
- package/src/__tests__/tiptapBridge.test.ts +47 -0
- package/src/diagram/DiagramWidget.tsx +6 -4
- package/src/headingTransition.ts +96 -21
- package/src/index.ts +2 -1
- package/src/mediaReferences.ts +299 -0
- package/src/scene/Scene.tsx +53 -7
- package/src/scene/SceneBlockWidget.tsx +6 -3
- package/src/scene/SceneSelection.tsx +19 -15
- package/src/scene/SceneSideToolbar.tsx +89 -0
- package/src/scene/layers/DiagramEdges.tsx +4 -3
- package/src/scene/layers/edgeGeometry.ts +23 -4
- package/src/scene/scene.css +142 -2
- package/src/scene/tools/ConnectTool.ts +113 -24
- package/src/scene/tools/DrawingConnectTool.ts +86 -16
- package/src/scene/tools/SceneTool.ts +2 -0
- package/src/styles/editor.css +862 -101
- package/src/templateContentPreviewResolver.ts +353 -0
- package/src/tiptapBridge.ts +60 -33
package/src/TransitionPicker.tsx
CHANGED
|
@@ -32,7 +32,14 @@ export interface TransitionPickerProps {
|
|
|
32
32
|
onChange: (next: TransitionFields) => void;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* DOM id of the portaled transition flyout. Exported so hosts that embed the
|
|
37
|
+
* picker inside their own popovers (e.g. the toolbar overflow menu) can treat
|
|
38
|
+
* clicks inside the flyout as "inside" in their outside-click handling.
|
|
39
|
+
*/
|
|
40
|
+
export const TRANSITION_FLYOUT_PORTAL_ID = 'squisq-transition-flyout-portal';
|
|
41
|
+
|
|
42
|
+
const FLYOUT_ID = TRANSITION_FLYOUT_PORTAL_ID;
|
|
36
43
|
|
|
37
44
|
export function TransitionPicker({ value, onChange }: TransitionPickerProps) {
|
|
38
45
|
const [open, setOpen] = useState(false);
|
package/src/ViewSwitcher.tsx
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ViewSwitcher
|
|
3
3
|
*
|
|
4
|
-
* Tab bar for switching between
|
|
4
|
+
* Tab bar for switching between Write, Source, and Use editor views.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useEditorContext, type EditorView } from './EditorContext';
|
|
8
8
|
|
|
9
9
|
const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
|
|
10
|
-
{ id: '
|
|
11
|
-
{ id: '
|
|
12
|
-
{ id: 'preview', label: '
|
|
10
|
+
{ id: 'wysiwyg', label: 'Write', shortcut: '⌘1' },
|
|
11
|
+
{ id: 'raw', label: 'Source', shortcut: '⌘2' },
|
|
12
|
+
{ id: 'preview', label: 'Use', shortcut: '⌘3' },
|
|
13
13
|
];
|
|
14
14
|
|
|
15
15
|
export interface ViewSwitcherProps {
|
package/src/WysiwygEditor.tsx
CHANGED
|
@@ -22,7 +22,9 @@ import TaskList from '@tiptap/extension-task-list';
|
|
|
22
22
|
import TaskItem from '@tiptap/extension-task-item';
|
|
23
23
|
import Link from '@tiptap/extension-link';
|
|
24
24
|
import Placeholder from '@tiptap/extension-placeholder';
|
|
25
|
-
import { resolveFontFamily, FONT_FALLBACKS } from '@bendyline/squisq/schemas';
|
|
25
|
+
import { resolveFontFamily, FONT_FALLBACKS, VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
|
|
26
|
+
import { DEFAULT_THEME, flattenBlocks, markdownToDoc } from '@bendyline/squisq/doc';
|
|
27
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
26
28
|
import { HeadingWithTemplate } from './TemplateAnnotation';
|
|
27
29
|
import { DiagramExtension } from './diagram/DiagramExtension';
|
|
28
30
|
import { SceneBlockExtension } from './scene/SceneBlockExtension';
|
|
@@ -108,6 +110,8 @@ export function WysiwygEditor({
|
|
|
108
110
|
mentionProvider,
|
|
109
111
|
blockTagsVisible,
|
|
110
112
|
themeInheritance,
|
|
113
|
+
colorScheme,
|
|
114
|
+
bumpMediaRevision,
|
|
111
115
|
} = useEditorContext();
|
|
112
116
|
// Custom templates inlined in the active doc's frontmatter + the
|
|
113
117
|
// persist callback that writes a new list back into the source.
|
|
@@ -257,7 +261,7 @@ export function WysiwygEditor({
|
|
|
257
261
|
const imageFiles = filesFromClipboard(clipboard);
|
|
258
262
|
if (imageFiles.length > 0 && mediaProviderRef.current) {
|
|
259
263
|
event.preventDefault();
|
|
260
|
-
uploadAndInsertImages(view, imageFiles, mediaProviderRef.current);
|
|
264
|
+
uploadAndInsertImages(view, imageFiles, mediaProviderRef.current, bumpMediaRevision);
|
|
261
265
|
return true;
|
|
262
266
|
}
|
|
263
267
|
|
|
@@ -326,7 +330,7 @@ export function WysiwygEditor({
|
|
|
326
330
|
|
|
327
331
|
event.preventDefault();
|
|
328
332
|
moveSelectionToDropPoint(view, event);
|
|
329
|
-
uploadAndInsertImages(view, imageFiles, mediaProviderRef.current);
|
|
333
|
+
uploadAndInsertImages(view, imageFiles, mediaProviderRef.current, bumpMediaRevision);
|
|
330
334
|
return true;
|
|
331
335
|
},
|
|
332
336
|
},
|
|
@@ -467,6 +471,29 @@ export function WysiwygEditor({
|
|
|
467
471
|
// a PreviewSettingsProvider in scope).
|
|
468
472
|
const previewSettings = usePreviewSettingsOptional();
|
|
469
473
|
const activeTheme = previewSettings?.activeTheme;
|
|
474
|
+
const badgePreviewSource = useMemo(() => {
|
|
475
|
+
if (!badgeMenu) return undefined;
|
|
476
|
+
try {
|
|
477
|
+
const previewDoc = markdownToDoc(parseMarkdown(editorSource), { autoTemplates: false });
|
|
478
|
+
const block = flattenBlocks(previewDoc.blocks)[badgeMenu.headingIndex];
|
|
479
|
+
if (!block) return undefined;
|
|
480
|
+
return {
|
|
481
|
+
block,
|
|
482
|
+
theme: previewSettings?.activeTheme ?? DEFAULT_THEME,
|
|
483
|
+
viewport: previewSettings?.activeViewport ?? VIEWPORT_PRESETS.landscape,
|
|
484
|
+
basePath: '/',
|
|
485
|
+
mediaProvider,
|
|
486
|
+
};
|
|
487
|
+
} catch {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
}, [
|
|
491
|
+
badgeMenu,
|
|
492
|
+
editorSource,
|
|
493
|
+
mediaProvider,
|
|
494
|
+
previewSettings?.activeTheme,
|
|
495
|
+
previewSettings?.activeViewport,
|
|
496
|
+
]);
|
|
470
497
|
const themeStyle = useMemo<CSSProperties>(() => {
|
|
471
498
|
if (themeInheritance === 'none' || !activeTheme) return {};
|
|
472
499
|
const out: Record<string, string> = {
|
|
@@ -507,6 +534,7 @@ export function WysiwygEditor({
|
|
|
507
534
|
<TemplateBadgePopover
|
|
508
535
|
anchorRect={badgeMenu.rect}
|
|
509
536
|
value={badgeMenu.template}
|
|
537
|
+
colorScheme={colorScheme}
|
|
510
538
|
recommended={(() => {
|
|
511
539
|
// `headingIndex` is counted within the mounted Tiptap doc, which
|
|
512
540
|
// reflects `editorSource` — the active block's slice in block
|
|
@@ -516,6 +544,7 @@ export function WysiwygEditor({
|
|
|
516
544
|
const profile = profileBlockContents(slice);
|
|
517
545
|
return recommendTemplatesForBlock(profile, TEMPLATE_NAMES).recommended;
|
|
518
546
|
})()}
|
|
547
|
+
previewSource={badgePreviewSource}
|
|
519
548
|
onOpenDesigner={() => {
|
|
520
549
|
setBadgeMenu(null);
|
|
521
550
|
setDesignerState({});
|
|
@@ -546,6 +575,17 @@ export function WysiwygEditor({
|
|
|
546
575
|
});
|
|
547
576
|
editor.view.dispatch(tr);
|
|
548
577
|
}}
|
|
578
|
+
onAnnotationChange={(next) => {
|
|
579
|
+
if (!editor) return;
|
|
580
|
+
const current = editor.state.doc.nodeAt(propsMenu.headingPos);
|
|
581
|
+
if (!current || current.type.name !== 'heading') return;
|
|
582
|
+
const tr = editor.state.tr.setNodeMarkup(propsMenu.headingPos, undefined, {
|
|
583
|
+
...current.attrs,
|
|
584
|
+
dataBlockAttrs: next.blockAttrsInner,
|
|
585
|
+
dataTemplateParams: next.templateParams,
|
|
586
|
+
});
|
|
587
|
+
editor.view.dispatch(tr);
|
|
588
|
+
}}
|
|
549
589
|
onClose={() => setPropsMenu(null)}
|
|
550
590
|
/>
|
|
551
591
|
)}
|
|
@@ -631,6 +671,7 @@ async function uploadAndInsertImages(
|
|
|
631
671
|
view: any,
|
|
632
672
|
files: File[],
|
|
633
673
|
mediaProvider: import('@bendyline/squisq/schemas').MediaProvider,
|
|
674
|
+
onMediaUploaded?: () => void,
|
|
634
675
|
): Promise<void> {
|
|
635
676
|
for (const file of files) {
|
|
636
677
|
try {
|
|
@@ -643,6 +684,7 @@ async function uploadAndInsertImages(
|
|
|
643
684
|
const relativePath = await mediaProvider.addMedia(name, buffer, mimeType);
|
|
644
685
|
const altText = name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
|
|
645
686
|
insertImageNode(view, relativePath, altText);
|
|
687
|
+
onMediaUploaded?.();
|
|
646
688
|
} catch (err) {
|
|
647
689
|
console.error('Failed to upload dropped image:', err);
|
|
648
690
|
}
|
|
@@ -11,8 +11,7 @@ function previewSlides(md: string) {
|
|
|
11
11
|
|
|
12
12
|
describe('buildPreviewDoc transition mapping', () => {
|
|
13
13
|
it('carries an authored transition through to the player slide', () => {
|
|
14
|
-
//
|
|
15
|
-
// into the heading's Pandoc `{…}` attribute block.
|
|
14
|
+
// Legacy Pandoc transition syntax remains supported.
|
|
16
15
|
const md = ['# Intro', '', '# Second {transition=vortex}', '', 'body'].join('\n');
|
|
17
16
|
const slides = previewSlides(md);
|
|
18
17
|
expect(slides[1].transition).toEqual({ type: 'vortex' });
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* the props it receives so we can assert `monacoTheme` reaches it.
|
|
14
14
|
*/
|
|
15
15
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
16
|
-
import { render, screen } from '@testing-library/react';
|
|
16
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
17
17
|
import type { RawEditorProps } from '../RawEditor';
|
|
18
|
+
import type { MediaEntry, MediaProvider } from '@bendyline/squisq/schemas';
|
|
18
19
|
|
|
19
20
|
// Records the props the shell passes to RawEditor on each render.
|
|
20
21
|
const rawEditorProps: RawEditorProps[] = [];
|
|
@@ -33,6 +34,94 @@ vi.mock('../PreviewPanel', () => ({
|
|
|
33
34
|
}));
|
|
34
35
|
|
|
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
|
+
}
|
|
36
125
|
|
|
37
126
|
beforeEach(() => {
|
|
38
127
|
rawEditorProps.length = 0;
|
|
@@ -94,3 +183,181 @@ describe('RawEditor monacoTheme prop', () => {
|
|
|
94
183
|
expect(last?.monacoTheme).toBe('vs');
|
|
95
184
|
});
|
|
96
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
|
+
'',
|
|
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={'\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
|
|
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('
|
|
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
|
|
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('
|
|
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
|
|
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
|
+
'',
|
|
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', '', '', '', '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 .',
|
|
56
|
+
'Drop  and [file](attachments/pasted.png).',
|
|
57
|
+
].join('\n');
|
|
58
|
+
|
|
59
|
+
expect(removeMediaReferencesFromMarkdown(source, 'attachments/pasted.png')).toBe(
|
|
60
|
+
['Keep .', 'Drop and .'].join('\n'),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('supports angle-wrapped destinations and markdown titles', () => {
|
|
65
|
+
const source =
|
|
66
|
+
'Drop  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  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
|
+
});
|