@bendyline/squisq-editor-react 1.6.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/dist/index.d.ts +1304 -208
- package/dist/index.js +22838 -14494
- package/dist/index.js.map +1 -1
- package/dist/monaco.d.ts +1 -0
- package/dist/monaco.js +4 -0
- package/dist/monaco.js.map +1 -0
- package/dist/styles/index.css +2384 -408
- package/package.json +9 -5
- package/src/BlockPropertiesPopover.tsx +48 -17
- package/src/DocumentSettingsDialog.tsx +13 -21
- package/src/EditorContext.tsx +199 -42
- package/src/EditorShell.tsx +403 -270
- package/src/ImageEditor.tsx +22 -1
- package/src/InlinePreviewGutter.tsx +16 -8
- package/src/MediaBin.tsx +276 -40
- package/src/OutlinePanel.tsx +202 -7
- package/src/PlainHtmlPreview.tsx +40 -1
- package/src/PreviewControls.tsx +466 -93
- package/src/PreviewPanel.tsx +313 -77
- package/src/RawEditor.tsx +13 -2
- package/src/StatusBar.tsx +5 -2
- package/src/TemplateAnnotation.ts +34 -10
- package/src/TemplateContentPreview.tsx +56 -0
- package/src/TemplatePicker.tsx +355 -134
- package/src/ThemeCustomizerPanel.tsx +30 -15
- package/src/ThemePicker.tsx +10 -5
- package/src/TimelineBlockPreview.tsx +1 -1
- package/src/Toolbar.tsx +719 -238
- package/src/TransitionPicker.tsx +44 -5
- package/src/ViewMenuPanel.tsx +12 -14
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +167 -92
- package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
- package/src/__tests__/blockTagActivity.test.ts +183 -0
- package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
- package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
- package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
- package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
- package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
- package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
- package/src/__tests__/editorScrollSync.test.ts +65 -0
- package/src/__tests__/editorShellProps.test.tsx +390 -1
- package/src/__tests__/emojiPicker.test.tsx +2 -42
- package/src/__tests__/headingTransition.test.ts +53 -15
- package/src/__tests__/imageEditAffordance.test.tsx +5 -1
- package/src/__tests__/imageEditModalContract.test.tsx +119 -0
- package/src/__tests__/imageEditorShell.test.tsx +38 -1
- package/src/__tests__/jsonEditor.test.tsx +56 -1
- package/src/__tests__/layersPanel.test.tsx +75 -5
- package/src/__tests__/majorApiTypes.test.ts +26 -0
- package/src/__tests__/mediaBinDrop.test.tsx +90 -0
- package/src/__tests__/mediaReferences.test.ts +82 -0
- package/src/__tests__/narrationSave.test.ts +148 -0
- package/src/__tests__/outlinePanel.test.tsx +93 -3
- package/src/__tests__/outlineSource.test.ts +217 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
- package/src/__tests__/presentationMode.test.tsx +290 -0
- package/src/__tests__/previewControls.test.tsx +312 -6
- package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
- package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
- package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
- package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
- package/src/__tests__/resolveBlockVisual.test.ts +43 -0
- package/src/__tests__/statusBar.test.tsx +27 -0
- package/src/__tests__/teleprompter.test.tsx +190 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
- package/src/__tests__/templateContentPreview.test.ts +101 -0
- package/src/__tests__/templatePickerPortal.test.tsx +62 -0
- package/src/__tests__/tiptapBridge.test.ts +70 -0
- package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
- package/src/__tests__/transformStyleId.test.ts +13 -0
- package/src/__tests__/useImageEditor.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +75 -0
- package/src/__tests__/viewMenuPanel.test.tsx +130 -0
- package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
- package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
- package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
- package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
- package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
- package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
- package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
- package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
- package/src/asciiDiagram/asciiDiagramData.ts +116 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
- package/src/asciiDiagram/asciiPaste.ts +19 -0
- package/src/blockTagActivity.ts +233 -0
- package/src/buildDocumentPreviewMarkdown.ts +168 -0
- package/src/buildPreviewDoc.ts +19 -12
- package/src/customTemplates/CustomLayoutManager.tsx +3 -2
- package/src/customTemplates/CustomTemplateContext.tsx +6 -0
- package/src/customTemplates/TemplateDesigner.tsx +7 -4
- package/src/customTemplates/__tests__/library.test.ts +8 -0
- package/src/customTemplates/designer.css +158 -99
- package/src/customTemplates/library.ts +16 -3
- package/src/customThemes/CustomThemeContext.tsx +6 -0
- package/src/customThemes/CustomThemeDialog.tsx +35 -5
- package/src/customThemes/ImportThemeSection.tsx +178 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
- package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
- package/src/customThemes/customThemeLibrary.ts +7 -4
- package/src/customThemes/index.ts +7 -1
- package/src/customThemes/themeDraft.ts +23 -7
- package/src/diagram/DiagramCanvas.tsx +9 -4
- package/src/diagram/types.ts +35 -0
- package/src/editorScrollSync.ts +73 -0
- package/src/emojiData.ts +3 -23
- package/src/headingTransition.ts +85 -25
- package/src/imageEditor/CanvasSurface.tsx +29 -23
- package/src/imageEditor/LayersPanel.tsx +78 -3
- package/src/imageEditor/Toolbar.tsx +5 -1
- package/src/imageEditor/icons.tsx +4 -0
- package/src/imageEditor/image-editor.css +75 -0
- package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
- package/src/imageEditor/useImageEditor.ts +71 -14
- package/src/index.ts +193 -20
- package/src/jsonEditor/JsonEditorContext.tsx +12 -6
- package/src/jsonEditor/RenderNode.tsx +24 -3
- package/src/jsonEditor/editors.tsx +86 -19
- package/src/mediaEntries.ts +12 -0
- package/src/mediaReferences.ts +299 -0
- package/src/monaco.ts +35 -0
- package/src/monacoWorkers.ts +89 -0
- package/src/outlineSource.ts +171 -0
- package/src/presentation/PresentationMode.tsx +596 -0
- package/src/rawEditorIsolation.ts +18 -0
- package/src/recorder/hooks/useMediaRecorder.ts +97 -53
- package/src/resolveBlockVisual.ts +10 -4
- package/src/scene/Scene.tsx +101 -25
- package/src/scene/SceneBlockExtension.ts +17 -10
- package/src/scene/SceneBlockWidget.tsx +11 -4
- package/src/scene/SceneSelection.tsx +19 -15
- package/src/scene/SceneSideToolbar.tsx +89 -0
- package/src/scene/SceneViewport.tsx +7 -3
- package/src/scene/ShapePalette.tsx +17 -2
- package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
- package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
- package/src/scene/adapters/DiagramAdapter.ts +12 -101
- package/src/scene/commands/SceneCommand.ts +4 -1
- package/src/scene/index.ts +1 -6
- package/src/scene/layers/DiagramEdges.tsx +24 -9
- package/src/scene/layers/edgeGeometry.ts +31 -4
- package/src/scene/layers/nodeCard.tsx +27 -8
- package/src/scene/scene.css +145 -2
- package/src/scene/text/SceneTextOverlay.tsx +5 -5
- package/src/scene/text/sceneTextChannel.ts +26 -20
- package/src/scene/text/sceneTextConfig.ts +4 -0
- package/src/scene/tools/ConnectTool.ts +126 -28
- package/src/scene/tools/DrawingConnectTool.ts +86 -16
- package/src/scene/tools/SceneTool.ts +10 -0
- package/src/scene/tools/SelectTool.ts +34 -18
- package/src/scene/tools/ShapeTool.ts +2 -3
- package/src/styles/ascii-diagram.css +79 -0
- package/src/styles/ascii-timeline.css +499 -0
- package/src/styles/editor.css +1566 -219
- package/src/styles/index.css +3 -0
- package/src/styles/tree-view.css +139 -0
- package/src/teleprompter/TeleprompterControls.tsx +218 -0
- package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
- package/src/teleprompter/TeleprompterSurface.tsx +267 -0
- package/src/teleprompter/TeleprompterView.tsx +338 -0
- package/src/teleprompter/canvasRenderer.ts +161 -0
- package/src/teleprompter/floatingWindow.ts +352 -0
- package/src/teleprompter/index.ts +61 -0
- package/src/teleprompter/pcmWorklet.ts +62 -0
- package/src/teleprompter/recording/insertPreamble.ts +80 -0
- package/src/teleprompter/recording/narrationSave.ts +134 -0
- package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
- package/src/teleprompter/scrollModel.ts +85 -0
- package/src/teleprompter/teleprompterTheme.ts +303 -0
- package/src/teleprompter/types.ts +38 -0
- package/src/teleprompter/useFloatingWindow.ts +74 -0
- package/src/teleprompter/useMicAnalysis.ts +211 -0
- package/src/teleprompter/useTeleprompter.ts +421 -0
- package/src/templateContentPreviewResolver.ts +360 -0
- package/src/timeline/TimelineEditorWidget.tsx +702 -0
- package/src/timeline/TimelineViewExtension.ts +252 -0
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
- package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
- package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
- package/src/timeline/__tests__/timelineOps.test.ts +222 -0
- package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
- package/src/timeline/timelineCommands.ts +367 -0
- package/src/timeline/timelineData.ts +54 -0
- package/src/timeline/timelineOps.ts +277 -0
- package/src/timeline/timelinePaste.ts +8 -0
- package/src/tiptapBridge.ts +84 -49
- package/src/transformStyleId.ts +17 -0
- package/src/treeview/TreeOutlineWidget.tsx +240 -0
- package/src/treeview/TreeViewExtension.ts +250 -0
- package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
- package/src/treeview/__tests__/treeOps.test.ts +125 -0
- package/src/treeview/__tests__/treePaste.test.ts +40 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
- package/src/treeview/treeOps.ts +172 -0
- package/src/treeview/treePaste.ts +13 -0
- package/src/treeview/treeViewCommands.ts +94 -0
- package/src/treeview/treeViewData.ts +41 -0
- package/src/useMonacoLoader.ts +11 -34
- package/src/wysiwygImageUpload.ts +113 -0
- package/src/diagram/DiagramExtension.ts +0 -209
- package/src/diagram/DiagramWidget.tsx +0 -270
- package/src/diagram/useDiagramData.ts +0 -126
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DEFAULT_THEME, markdownToDoc } from '@bendyline/squisq/doc';
|
|
3
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
4
|
+
import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
|
|
5
|
+
import {
|
|
6
|
+
resolveTemplateContentPreview,
|
|
7
|
+
resolveTemplateContentPreviewResult,
|
|
8
|
+
type TemplatePreviewSource,
|
|
9
|
+
} from '../templateContentPreviewResolver';
|
|
10
|
+
|
|
11
|
+
function previewSource(markdown: string): TemplatePreviewSource {
|
|
12
|
+
const doc = markdownToDoc(parseMarkdown(markdown), { autoTemplates: false });
|
|
13
|
+
const block = doc.blocks[0];
|
|
14
|
+
if (!block) throw new Error('expected markdown to produce a block');
|
|
15
|
+
return {
|
|
16
|
+
block,
|
|
17
|
+
theme: DEFAULT_THEME,
|
|
18
|
+
viewport: VIEWPORT_PRESETS.landscape,
|
|
19
|
+
basePath: '/',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('template content previews', () => {
|
|
24
|
+
it('renders a candidate preview from the active block content', () => {
|
|
25
|
+
const visual = resolveTemplateContentPreview(
|
|
26
|
+
'list',
|
|
27
|
+
previewSource(`## Launch Steps
|
|
28
|
+
|
|
29
|
+
- Draft the outline
|
|
30
|
+
- Review the visuals
|
|
31
|
+
- Publish the page
|
|
32
|
+
`),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(visual).toBeTruthy();
|
|
36
|
+
expect(JSON.stringify(visual?.layers)).toContain('Draft the outline');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('falls back for content-specific templates when the block is too sparse', () => {
|
|
40
|
+
const visual = resolveTemplateContentPreview('list', previewSource('## About Squisq'));
|
|
41
|
+
|
|
42
|
+
expect(visual).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('reports why stat and date previews cannot be derived', () => {
|
|
46
|
+
expect(
|
|
47
|
+
resolveTemplateContentPreviewResult('statHighlight', previewSource('## About Squisq')),
|
|
48
|
+
).toMatchObject({
|
|
49
|
+
visual: null,
|
|
50
|
+
warning: 'No stat found in this block',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(
|
|
54
|
+
resolveTemplateContentPreviewResult('dateEvent', previewSource('## About Squisq')),
|
|
55
|
+
).toMatchObject({
|
|
56
|
+
visual: null,
|
|
57
|
+
warning: 'No date found in this block',
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('reports why image previews cannot be derived', () => {
|
|
62
|
+
expect(
|
|
63
|
+
resolveTemplateContentPreviewResult('imageWithCaption', previewSource('## About Squisq')),
|
|
64
|
+
).toMatchObject({
|
|
65
|
+
visual: null,
|
|
66
|
+
warning: 'No image found in this block',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reports why video previews cannot be derived without media', () => {
|
|
71
|
+
expect(
|
|
72
|
+
resolveTemplateContentPreviewResult('videoWithCaption', previewSource('## About Squisq')),
|
|
73
|
+
).toMatchObject({
|
|
74
|
+
visual: null,
|
|
75
|
+
warning: 'No audio/video found in this block',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(
|
|
79
|
+
resolveTemplateContentPreviewResult('videoPullQuote', previewSource('## About Squisq')),
|
|
80
|
+
).toMatchObject({
|
|
81
|
+
visual: null,
|
|
82
|
+
warning: 'No audio/video found in this block',
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('does not warn about missing media when an audio or video tag is present', () => {
|
|
87
|
+
expect(
|
|
88
|
+
resolveTemplateContentPreviewResult(
|
|
89
|
+
'videoWithCaption',
|
|
90
|
+
previewSource('## Demo\n\n<video src="media/demo.mp4" controls></video>'),
|
|
91
|
+
).warning,
|
|
92
|
+
).toBeUndefined();
|
|
93
|
+
|
|
94
|
+
expect(
|
|
95
|
+
resolveTemplateContentPreviewResult(
|
|
96
|
+
'videoPullQuote',
|
|
97
|
+
previewSource('## Narration\n\n<audio src="audio/narration.webm" controls></audio>'),
|
|
98
|
+
).warning,
|
|
99
|
+
).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { cleanup, render } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { TEMPLATE_GALLERY_PORTAL_SELECTOR, TemplateBadgePopover } from '../TemplatePicker';
|
|
4
|
+
|
|
5
|
+
const anchorRect = new DOMRect(20, 20, 80, 24);
|
|
6
|
+
const originalElementsFromPoint = document.elementsFromPoint;
|
|
7
|
+
|
|
8
|
+
function badge(key: string) {
|
|
9
|
+
return (
|
|
10
|
+
<TemplateBadgePopover
|
|
11
|
+
key={key}
|
|
12
|
+
anchorRect={anchorRect}
|
|
13
|
+
value=""
|
|
14
|
+
onChange={vi.fn()}
|
|
15
|
+
onClose={vi.fn()}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
Object.defineProperty(document, 'elementsFromPoint', {
|
|
22
|
+
configurable: true,
|
|
23
|
+
value: () => [],
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
cleanup();
|
|
29
|
+
Object.defineProperty(document, 'elementsFromPoint', {
|
|
30
|
+
configurable: true,
|
|
31
|
+
value: originalElementsFromPoint,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('template gallery portal ids', () => {
|
|
36
|
+
it('uses an instance-scoped id for a single open heading-badge gallery', () => {
|
|
37
|
+
render(badge('only'));
|
|
38
|
+
|
|
39
|
+
const gallery = document.querySelector(TEMPLATE_GALLERY_PORTAL_SELECTOR);
|
|
40
|
+
expect(gallery).not.toBeNull();
|
|
41
|
+
expect(gallery?.id).toMatch(/^squisq-template-gallery-portal-\S+$/);
|
|
42
|
+
expect(gallery?.id).not.toBe('squisq-template-gallery-portal');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('keeps concurrent editor galleries unique and preserves the survivor id', () => {
|
|
46
|
+
const view = render(
|
|
47
|
+
<>
|
|
48
|
+
{badge('first')}
|
|
49
|
+
{badge('second')}
|
|
50
|
+
</>,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const galleries = [...document.querySelectorAll(TEMPLATE_GALLERY_PORTAL_SELECTOR)];
|
|
54
|
+
expect(galleries).toHaveLength(2);
|
|
55
|
+
expect(new Set(galleries.map((gallery) => gallery.id)).size).toBe(2);
|
|
56
|
+
const survivorId = galleries[1].id;
|
|
57
|
+
|
|
58
|
+
view.rerender(<>{badge('second')}</>);
|
|
59
|
+
const survivor = document.querySelector(TEMPLATE_GALLERY_PORTAL_SELECTOR);
|
|
60
|
+
expect(survivor?.id).toBe(survivorId);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -17,6 +17,22 @@ describe('markdownToTiptap', () => {
|
|
|
17
17
|
expect(html).toContain('Hello world');
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
it('preserves extra blank lines as empty paragraphs', () => {
|
|
21
|
+
expect(markdownToTiptap('Alpha\n\nBeta')).toBe('<p>Alpha</p><p>Beta</p>');
|
|
22
|
+
expect(markdownToTiptap('Alpha\n\n\nBeta')).toBe('<p>Alpha</p><p></p><p>Beta</p>');
|
|
23
|
+
expect(markdownToTiptap('Alpha\n\n\n\nBeta')).toBe('<p>Alpha</p><p></p><p></p><p>Beta</p>');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('preserves trailing extra blank lines as empty paragraphs', () => {
|
|
27
|
+
expect(markdownToTiptap('Alpha\n')).toBe('<p>Alpha</p>');
|
|
28
|
+
expect(markdownToTiptap('Alpha\n\n')).toBe('<p>Alpha</p><p></p>');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('preserves leading paragraph spaces as visible HTML whitespace', () => {
|
|
32
|
+
expect(markdownToTiptap(' indented')).toBe('<p> indented</p>');
|
|
33
|
+
expect(markdownToTiptap('Alpha\n\n Beta')).toBe('<p>Alpha</p><p> Beta</p>');
|
|
34
|
+
});
|
|
35
|
+
|
|
20
36
|
it('converts headings h1-h3', () => {
|
|
21
37
|
expect(markdownToTiptap('# Title')).toContain('<h1');
|
|
22
38
|
expect(markdownToTiptap('## Subtitle')).toContain('<h2');
|
|
@@ -193,6 +209,22 @@ describe('tiptapToMarkdown', () => {
|
|
|
193
209
|
expect(md).toContain('Hello world');
|
|
194
210
|
});
|
|
195
211
|
|
|
212
|
+
it('preserves empty paragraphs as extra blank lines', () => {
|
|
213
|
+
expect(tiptapToMarkdown('<p>Alpha</p><p>Beta</p>')).toBe('Alpha\n\nBeta\n');
|
|
214
|
+
expect(tiptapToMarkdown('<p>Alpha</p><p></p><p>Beta</p>')).toBe('Alpha\n\n\nBeta\n');
|
|
215
|
+
expect(tiptapToMarkdown('<p>Alpha</p><p></p><p></p><p>Beta</p>')).toBe('Alpha\n\n\n\nBeta\n');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('preserves trailing empty paragraphs', () => {
|
|
219
|
+
expect(tiptapToMarkdown('<p>Alpha</p><p></p>')).toBe('Alpha\n\n');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('restores leading paragraph spaces from visible HTML whitespace', () => {
|
|
223
|
+
expect(tiptapToMarkdown('<p> indented</p>')).toBe(' indented\n');
|
|
224
|
+
expect(tiptapToMarkdown('<p>\u00a0\u00a0indented</p>')).toBe(' indented\n');
|
|
225
|
+
expect(tiptapToMarkdown('<p>  indented</p>')).toBe(' indented\n');
|
|
226
|
+
});
|
|
227
|
+
|
|
196
228
|
it('converts headings', () => {
|
|
197
229
|
expect(tiptapToMarkdown('<h1>Title</h1>')).toContain('# Title');
|
|
198
230
|
expect(tiptapToMarkdown('<h2>Sub</h2>')).toContain('## Sub');
|
|
@@ -251,6 +283,29 @@ describe('tiptapToMarkdown', () => {
|
|
|
251
283
|
expect(md).toContain('plain code');
|
|
252
284
|
});
|
|
253
285
|
|
|
286
|
+
it('converts code blocks when <pre> carries attributes (real getHTML shape)', () => {
|
|
287
|
+
// StarterKit is configured with codeBlock HTMLAttributes, so real
|
|
288
|
+
// editor.getHTML() output puts class="squisq-code-block" on <pre>.
|
|
289
|
+
const md = tiptapToMarkdown(
|
|
290
|
+
'<pre class="squisq-code-block"><code class="language-js">const x = 1;</code></pre>',
|
|
291
|
+
);
|
|
292
|
+
expect(md).toContain('```js');
|
|
293
|
+
expect(md).toContain('const x = 1;');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('converts attribute-bearing code blocks without language', () => {
|
|
297
|
+
const md = tiptapToMarkdown('<pre class="squisq-code-block"><code>plain code</code></pre>');
|
|
298
|
+
expect(md).toContain('```\nplain code\n```');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('extracts language regardless of surrounding attributes on <code>', () => {
|
|
302
|
+
const md = tiptapToMarkdown(
|
|
303
|
+
'<pre class="squisq-code-block"><code spellcheck="false" class="language-python">x = 1</code></pre>',
|
|
304
|
+
);
|
|
305
|
+
expect(md).toContain('```python');
|
|
306
|
+
expect(md).toContain('x = 1');
|
|
307
|
+
});
|
|
308
|
+
|
|
254
309
|
it('converts blockquotes', () => {
|
|
255
310
|
const md = tiptapToMarkdown('<blockquote><p>A wise quote</p></blockquote>');
|
|
256
311
|
expect(md).toContain('> ');
|
|
@@ -430,6 +485,21 @@ describe('round-trip: markdownToTiptap → tiptapToMarkdown', () => {
|
|
|
430
485
|
expect(result).not.toContain('- [x] buy milk');
|
|
431
486
|
});
|
|
432
487
|
|
|
488
|
+
it('preserves extra blank lines between paragraphs exactly', () => {
|
|
489
|
+
expect(roundTrip('Alpha\n\nBeta\n')).toBe('Alpha\n\nBeta\n');
|
|
490
|
+
expect(roundTrip('Alpha\n\n\nBeta\n')).toBe('Alpha\n\n\nBeta\n');
|
|
491
|
+
expect(roundTrip('Alpha\n\n\n\nBeta\n')).toBe('Alpha\n\n\n\nBeta\n');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('preserves trailing empty paragraphs exactly', () => {
|
|
495
|
+
expect(roundTrip('Alpha\n\n')).toBe('Alpha\n\n');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('preserves leading paragraph spaces exactly', () => {
|
|
499
|
+
expect(roundTrip(' indented\n')).toBe(' indented\n');
|
|
500
|
+
expect(roundTrip('Alpha\n\n Beta\n')).toBe('Alpha\n\n Beta\n');
|
|
501
|
+
});
|
|
502
|
+
|
|
433
503
|
it('preserves quoted template params with spaces', () => {
|
|
434
504
|
const result = roundTrip(
|
|
435
505
|
'## Gallery {[imageWithCaption src=photo.jpg caption="Beach at sunset"]}',
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Editor } from '@tiptap/core';
|
|
2
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
|
|
5
|
+
import { HeadingWithTemplate } from '../TemplateAnnotation';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Code-fence round-trip through a REAL tiptap editor.
|
|
9
|
+
*
|
|
10
|
+
* The string-level bridge tests feed hand-written `<pre><code>` HTML, but
|
|
11
|
+
* the production editor configures StarterKit's codeBlock with
|
|
12
|
+
* `HTMLAttributes: { class: 'squisq-code-block' }`, which tiptap renders
|
|
13
|
+
* onto the <pre>. The serializer regex used to require a bare `<pre><code`
|
|
14
|
+
* and silently dropped every fence on save. These tests exercise the real
|
|
15
|
+
* `getHTML()` output so that shape can never regress unnoticed.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function roundTripThroughEditor(md: string): string {
|
|
19
|
+
const editor = new Editor({
|
|
20
|
+
extensions: [
|
|
21
|
+
StarterKit.configure({
|
|
22
|
+
heading: false,
|
|
23
|
+
codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
|
|
24
|
+
}),
|
|
25
|
+
HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
26
|
+
],
|
|
27
|
+
content: markdownToTiptap(md),
|
|
28
|
+
});
|
|
29
|
+
const html = editor.getHTML();
|
|
30
|
+
editor.destroy();
|
|
31
|
+
return tiptapToMarkdown(html);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('code fences through a real editor (getHTML → markdown)', () => {
|
|
35
|
+
it('emits class="squisq-code-block" on <pre> (the shape the serializer must accept)', () => {
|
|
36
|
+
const editor = new Editor({
|
|
37
|
+
extensions: [
|
|
38
|
+
StarterKit.configure({
|
|
39
|
+
heading: false,
|
|
40
|
+
codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
|
|
41
|
+
}),
|
|
42
|
+
HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
43
|
+
],
|
|
44
|
+
content: markdownToTiptap('```js\nconst x = 1;\n```\n'),
|
|
45
|
+
});
|
|
46
|
+
const html = editor.getHTML();
|
|
47
|
+
editor.destroy();
|
|
48
|
+
expect(html).toContain('<pre class="squisq-code-block">');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('round-trips a fence with a language byte-for-byte', () => {
|
|
52
|
+
const md = '```js\nconst x = 1;\n```\n';
|
|
53
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('round-trips a fence without a language byte-for-byte', () => {
|
|
57
|
+
const md = '```\nplain text content\n```\n';
|
|
58
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('round-trips a fence surrounded by prose', () => {
|
|
62
|
+
const md = 'Before the code.\n\n```python\nprint("hi")\n```\n\nAfter the code.\n';
|
|
63
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('round-trips HTML-significant characters inside a fence', () => {
|
|
67
|
+
const md = '```\nif (a < b && c > d) { echo " "; }\n```\n';
|
|
68
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('round-trips trailing spaces inside fence lines', () => {
|
|
72
|
+
const md = '```\nline with trailing \nspaces\n```\n';
|
|
73
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('round-trips a blank last line inside a fence', () => {
|
|
77
|
+
const md = '```\nfoo\n\n```\n';
|
|
78
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('round-trips Unicode box-drawing art byte-for-byte', () => {
|
|
82
|
+
const md = [
|
|
83
|
+
'```',
|
|
84
|
+
'┌────────┐ ┌────────┐',
|
|
85
|
+
'│ CLI │ │ MCP │',
|
|
86
|
+
'└───┬────┘ └───┬────┘',
|
|
87
|
+
' │ │',
|
|
88
|
+
' ▼ ▼',
|
|
89
|
+
'┌──────────────────────┐',
|
|
90
|
+
'│ kernel │',
|
|
91
|
+
'└──────────────────────┘',
|
|
92
|
+
'```',
|
|
93
|
+
'',
|
|
94
|
+
].join('\n');
|
|
95
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('round-trips +--+ ASCII art byte-for-byte', () => {
|
|
99
|
+
const md = [
|
|
100
|
+
'```text',
|
|
101
|
+
'+--------+ +--------+',
|
|
102
|
+
'| Input | --> | Output |',
|
|
103
|
+
'+--------+ +--------+',
|
|
104
|
+
'```',
|
|
105
|
+
'',
|
|
106
|
+
].join('\n');
|
|
107
|
+
expect(roundTripThroughEditor(md)).toBe(md);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolvePersistedTransformStyleId } from '../transformStyleId';
|
|
3
|
+
|
|
4
|
+
describe('resolvePersistedTransformStyleId', () => {
|
|
5
|
+
it('accepts canonical ids and canonicalizes the legacy wire value', () => {
|
|
6
|
+
expect(resolvePersistedTransformStyleId('data-driven')).toBe('data-driven');
|
|
7
|
+
expect(resolvePersistedTransformStyleId('dataDriven')).toBe('data-driven');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('does not turn an unknown id into the default transform', () => {
|
|
11
|
+
expect(resolvePersistedTransformStyleId('not-a-style')).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -12,6 +12,14 @@ import {
|
|
|
12
12
|
} from '@bendyline/squisq/imageEdit';
|
|
13
13
|
import { useImageEditor } from '../imageEditor/useImageEditor.js';
|
|
14
14
|
|
|
15
|
+
function deferred<T>() {
|
|
16
|
+
let resolve!: (value: T) => void;
|
|
17
|
+
const promise = new Promise<T>((res) => {
|
|
18
|
+
resolve = res;
|
|
19
|
+
});
|
|
20
|
+
return { promise, resolve };
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
beforeEach(() => {
|
|
16
24
|
// jsdom doesn't implement createObjectURL or HTMLImageElement loading.
|
|
17
25
|
// Stub both so the hook can complete its asset-resolution path.
|
|
@@ -131,6 +139,65 @@ describe('useImageEditor', () => {
|
|
|
131
139
|
expect(persisted?.canvas.width).toBe(25);
|
|
132
140
|
});
|
|
133
141
|
|
|
142
|
+
it('does not mark a newer edit clean when an older write resolves', async () => {
|
|
143
|
+
const container = new MemoryContentContainer();
|
|
144
|
+
await writeImageEditDoc(container, createEmptyImageEditDoc(10, 10));
|
|
145
|
+
const originalWrite = container.writeFile.bind(container);
|
|
146
|
+
const gate = deferred<void>();
|
|
147
|
+
const started = deferred<void>();
|
|
148
|
+
let writes = 0;
|
|
149
|
+
vi.spyOn(container, 'writeFile').mockImplementation(async (...args) => {
|
|
150
|
+
writes += 1;
|
|
151
|
+
if (writes === 1) {
|
|
152
|
+
started.resolve();
|
|
153
|
+
await gate.promise;
|
|
154
|
+
}
|
|
155
|
+
return originalWrite(...args);
|
|
156
|
+
});
|
|
157
|
+
const { result } = renderHook(() => useImageEditor({ container, persistDebounceMs: 5 }));
|
|
158
|
+
await waitFor(() => expect(result.current.ready).toBe(true));
|
|
159
|
+
|
|
160
|
+
act(() => result.current.dispatch({ type: 'set-canvas', canvas: { width: 20, height: 20 } }));
|
|
161
|
+
await started.promise;
|
|
162
|
+
act(() => result.current.dispatch({ type: 'set-canvas', canvas: { width: 30, height: 30 } }));
|
|
163
|
+
gate.resolve();
|
|
164
|
+
|
|
165
|
+
await waitFor(() => expect(result.current.state?.dirty).toBe(false));
|
|
166
|
+
expect((await readImageEditDoc(container))?.canvas.width).toBe(30);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('flushes a dirty debounce when the editor unmounts', async () => {
|
|
170
|
+
const container = new MemoryContentContainer();
|
|
171
|
+
await writeImageEditDoc(container, createEmptyImageEditDoc(10, 10));
|
|
172
|
+
const { result, unmount } = renderHook(() =>
|
|
173
|
+
useImageEditor({ container, persistDebounceMs: 100000 }),
|
|
174
|
+
);
|
|
175
|
+
await waitFor(() => expect(result.current.ready).toBe(true));
|
|
176
|
+
act(() => result.current.dispatch({ type: 'set-canvas', canvas: { width: 44, height: 44 } }));
|
|
177
|
+
unmount();
|
|
178
|
+
await waitFor(async () => expect((await readImageEditDoc(container))?.canvas.width).toBe(44));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('does not populate the URL cache after unmount', async () => {
|
|
182
|
+
const container = new MemoryContentContainer();
|
|
183
|
+
await writeImageEditDoc(container, createEmptyImageEditDoc(10, 10));
|
|
184
|
+
await container.writeFile('assets/late.png', new Uint8Array([1]).buffer, 'image/png');
|
|
185
|
+
const { result, unmount } = renderHook(() => useImageEditor({ container }));
|
|
186
|
+
await waitFor(() => expect(result.current.ready).toBe(true));
|
|
187
|
+
const originalRead = container.readFile.bind(container);
|
|
188
|
+
const gate = deferred<ArrayBuffer | null>();
|
|
189
|
+
vi.spyOn(container, 'readFile').mockImplementation((path) =>
|
|
190
|
+
path === 'assets/late.png' ? gate.promise : originalRead(path),
|
|
191
|
+
);
|
|
192
|
+
const create = vi.mocked(URL.createObjectURL);
|
|
193
|
+
const before = create.mock.calls.length;
|
|
194
|
+
const pending = result.current.resolveAssetUrl('assets/late.png');
|
|
195
|
+
unmount();
|
|
196
|
+
gate.resolve(new Uint8Array([1]).buffer);
|
|
197
|
+
await expect(pending).rejects.toThrow(/cancelled/);
|
|
198
|
+
expect(create).toHaveBeenCalledTimes(before);
|
|
199
|
+
});
|
|
200
|
+
|
|
134
201
|
it('versioning is null when allowVersioning is false', async () => {
|
|
135
202
|
const container = new MemoryContentContainer();
|
|
136
203
|
await writeImageEditDoc(container, createEmptyImageEditDoc(10, 10));
|
|
@@ -50,6 +50,7 @@ interface FakeRecorderHandle {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
let lastRecorder: FakeRecorderHandle | null = null;
|
|
53
|
+
let lastStream: FakeMediaStream | null = null;
|
|
53
54
|
|
|
54
55
|
class FakeMediaRecorder implements FakeRecorderHandle {
|
|
55
56
|
state: 'recording' | 'inactive' = 'inactive';
|
|
@@ -90,6 +91,7 @@ beforeEach(() => {
|
|
|
90
91
|
lastRecorder = null;
|
|
91
92
|
(globalThis as { MediaRecorder?: unknown }).MediaRecorder = FakeMediaRecorder;
|
|
92
93
|
const fakeStream = new FakeMediaStream([new FakeMediaStreamTrack('audio')]);
|
|
94
|
+
lastStream = fakeStream;
|
|
93
95
|
Object.defineProperty(globalThis, 'navigator', {
|
|
94
96
|
value: {
|
|
95
97
|
mediaDevices: {
|
|
@@ -224,4 +226,77 @@ describe('useMediaRecorder lifecycle', () => {
|
|
|
224
226
|
expect(result.current.stream).toBeNull();
|
|
225
227
|
expect(tracks.every((t) => t.stop.mock.calls.length > 0)).toBe(true);
|
|
226
228
|
});
|
|
229
|
+
|
|
230
|
+
it('deduplicates concurrent permission requests', async () => {
|
|
231
|
+
let resolve!: (stream: FakeMediaStream) => void;
|
|
232
|
+
const pending = new Promise<FakeMediaStream>((res) => {
|
|
233
|
+
resolve = res;
|
|
234
|
+
});
|
|
235
|
+
const getUserMedia = vi.fn().mockReturnValue(pending);
|
|
236
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
237
|
+
configurable: true,
|
|
238
|
+
value: { mediaDevices: { getUserMedia } },
|
|
239
|
+
});
|
|
240
|
+
const stream = new FakeMediaStream([new FakeMediaStreamTrack('audio')]);
|
|
241
|
+
const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
|
|
242
|
+
let first!: Promise<void>;
|
|
243
|
+
let second!: Promise<void>;
|
|
244
|
+
act(() => {
|
|
245
|
+
first = result.current.request();
|
|
246
|
+
second = result.current.request();
|
|
247
|
+
});
|
|
248
|
+
expect(getUserMedia).toHaveBeenCalledTimes(1);
|
|
249
|
+
resolve(stream);
|
|
250
|
+
await act(async () => Promise.all([first, second]));
|
|
251
|
+
expect(result.current.state).toBe('ready');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('releases an acquired stream when recorder construction fails', async () => {
|
|
255
|
+
class ThrowingRecorder {
|
|
256
|
+
static isTypeSupported() {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
constructor() {
|
|
260
|
+
throw new Error('construction failed');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
(globalThis as { MediaRecorder?: unknown }).MediaRecorder = ThrowingRecorder;
|
|
264
|
+
const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
|
|
265
|
+
await act(async () => {
|
|
266
|
+
await expect(result.current.request()).rejects.toThrow('construction failed');
|
|
267
|
+
});
|
|
268
|
+
expect(lastStream?.getTracks().every((track) => track.stop.mock.calls.length > 0)).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('ignores a late onstop event after cancel', async () => {
|
|
272
|
+
const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
|
|
273
|
+
await act(async () => result.current.request());
|
|
274
|
+
act(() => result.current.start());
|
|
275
|
+
const lateOnStop = lastRecorder?.onstop;
|
|
276
|
+
act(() => result.current.cancel());
|
|
277
|
+
act(() => lateOnStop?.());
|
|
278
|
+
expect(result.current.state).toBe('idle');
|
|
279
|
+
expect(result.current.blob).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('returns the same in-flight stop promise to repeated callers', async () => {
|
|
283
|
+
const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
|
|
284
|
+
await act(async () => result.current.request());
|
|
285
|
+
act(() => result.current.start());
|
|
286
|
+
const recorder = lastRecorder!;
|
|
287
|
+
const complete = recorder.onstop!;
|
|
288
|
+
recorder.stop = vi.fn(() => {
|
|
289
|
+
recorder.state = 'inactive';
|
|
290
|
+
});
|
|
291
|
+
let first!: Promise<Blob | null>;
|
|
292
|
+
let second!: Promise<Blob | null>;
|
|
293
|
+
act(() => {
|
|
294
|
+
first = result.current.stop();
|
|
295
|
+
second = result.current.stop();
|
|
296
|
+
});
|
|
297
|
+
expect(second).toBe(first);
|
|
298
|
+
act(() => complete());
|
|
299
|
+
await first;
|
|
300
|
+
expect(result.current.state).toBe('stopped');
|
|
301
|
+
});
|
|
227
302
|
});
|