@bendyline/squisq-editor-react 1.5.3 → 1.6.1
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 +57 -10
- package/dist/index.d.ts +1114 -52
- package/dist/index.js +18446 -8050
- package/dist/index.js.map +1 -1
- package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
- package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
- package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
- package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
- package/dist/styles/index.css +13867 -0
- package/package.json +15 -7
- package/src/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +165 -16
- package/src/EditorShell.tsx +265 -144
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +64 -7
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +491 -122
- package/src/PreviewPanel.tsx +15 -9
- package/src/RawEditor.tsx +179 -17
- package/src/RecorderEntry.tsx +9 -16
- package/src/TemplateAnnotation.ts +44 -0
- package/src/TemplatePicker.tsx +329 -54
- package/src/ThemeCustomizerPanel.tsx +30 -336
- package/src/ThemePicker.tsx +112 -3
- package/src/TimelineBlockPreview.tsx +37 -0
- package/src/TimelineTrack.tsx +671 -0
- package/src/Toolbar.tsx +544 -180
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +4 -16
- package/src/ViewMenuPanel.tsx +17 -14
- package/src/WysiwygEditor.tsx +161 -65
- package/src/__tests__/blockProperties.test.ts +92 -0
- package/src/__tests__/blockRange.test.ts +105 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
- package/src/__tests__/codeContextSectionView.test.tsx +95 -0
- package/src/__tests__/codeContextZoneManager.test.ts +127 -0
- package/src/__tests__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/diffContextSections.test.ts +39 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
- package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
- package/src/__tests__/editorShellProps.test.tsx +96 -0
- package/src/__tests__/embeddedMedia.test.ts +48 -0
- package/src/__tests__/headingTransition.test.ts +138 -0
- package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
- package/src/__tests__/previewControls.test.tsx +70 -0
- package/src/__tests__/recorderMediaInsert.test.ts +86 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
- package/src/__tests__/templatePickerMetadata.test.ts +32 -0
- package/src/__tests__/timelineSource.test.ts +134 -0
- package/src/__tests__/tiptapBridge.test.ts +92 -0
- package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
- package/src/__tests__/tooltip.test.tsx +72 -0
- package/src/__tests__/transitionCatalog.test.ts +64 -0
- package/src/__tests__/useBlockNavigator.test.tsx +67 -0
- package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
- package/src/__tests__/useMediaRecorder.test.ts +41 -0
- package/src/__tests__/useTimelineClock.test.ts +21 -0
- package/src/blockProperties.ts +88 -0
- package/src/blockRange.ts +132 -0
- package/src/buildPreviewDoc.ts +98 -9
- package/src/codeContext/CodeContextSectionView.tsx +124 -0
- package/src/codeContext/CodeContextZoneManager.ts +149 -0
- package/src/codeContext/CodeContextZones.tsx +121 -0
- package/src/codeContext/diffContextSections.ts +38 -0
- package/src/codeContext/types.ts +75 -0
- package/src/customTemplates/AddBin.tsx +126 -0
- package/src/customTemplates/CustomLayoutManager.tsx +233 -0
- package/src/customTemplates/CustomTemplateContext.tsx +182 -0
- package/src/customTemplates/LayerToolbar.tsx +580 -0
- package/src/customTemplates/ShapeGlyph.tsx +47 -0
- package/src/customTemplates/TemplateDesigner.tsx +430 -0
- package/src/customTemplates/__tests__/library.test.ts +88 -0
- package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
- package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
- package/src/customTemplates/designer.css +673 -0
- package/src/customTemplates/index.ts +31 -0
- package/src/customTemplates/library.ts +97 -0
- package/src/customTemplates/normalizePositions.ts +75 -0
- package/src/customTemplates/shapeDefs.ts +131 -0
- package/src/customTemplates/thumbnail.tsx +63 -0
- package/src/customTemplates/tokenDefs.ts +60 -0
- package/src/customTemplates/useDocCustomTemplates.ts +52 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
- package/src/customThemes/CustomThemeContext.tsx +179 -0
- package/src/customThemes/CustomThemeDialog.tsx +286 -0
- package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
- package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
- package/src/customThemes/customThemeLibrary.ts +97 -0
- package/src/customThemes/index.ts +31 -0
- package/src/customThemes/themeControls.tsx +229 -0
- package/src/customThemes/themeDraft.ts +272 -0
- package/src/customThemes/useDocCustomThemes.ts +49 -0
- package/src/diagram/DiagramCanvas.tsx +240 -0
- package/src/diagram/DiagramExtension.ts +209 -0
- package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
- package/src/diagram/DiagramWidget.tsx +270 -0
- package/src/diagram/diagramCommands.ts +604 -0
- package/src/diagram/diagramConstants.ts +17 -0
- package/src/diagram/useDiagramData.ts +126 -0
- package/src/embeddedMedia.ts +78 -0
- package/src/frontmatter.ts +29 -0
- package/src/headingTransition.ts +231 -0
- package/src/imageEditor/CanvasSurface.tsx +383 -88
- package/src/imageEditor/PropertiesPanel.tsx +47 -1
- package/src/imageEditor/Toolbar.tsx +229 -16
- package/src/imageEditor/createShapeLayer.ts +280 -0
- package/src/imageEditor/icons.tsx +34 -114
- package/src/imageEditor/image-editor.css +54 -5
- package/src/imageEditor/state.ts +23 -3
- package/src/index.ts +109 -1
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +17 -11
- package/src/recorder/insertMediaBlock.ts +30 -0
- package/src/resolveBlockVisual.ts +33 -0
- package/src/scene/Scene.tsx +540 -0
- package/src/scene/SceneBlockExtension.ts +198 -0
- package/src/scene/SceneBlockToolbar.tsx +201 -0
- package/src/scene/SceneBlockWidget.tsx +434 -0
- package/src/scene/ScenePropsBar.tsx +85 -0
- package/src/scene/SceneSelection.tsx +107 -0
- package/src/scene/SceneViewport.tsx +102 -0
- package/src/scene/ShapePalette.tsx +181 -0
- package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
- package/src/scene/__tests__/bezierEdit.test.ts +85 -0
- package/src/scene/__tests__/blockLayers.test.ts +57 -0
- package/src/scene/__tests__/shapeLayers.test.ts +106 -0
- package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
- package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
- package/src/scene/adapters/DiagramAdapter.ts +168 -0
- package/src/scene/adapters/DrawingAdapter.ts +415 -0
- package/src/scene/adapters/LayoutAdapter.ts +310 -0
- package/src/scene/adapters/blockLayers.ts +159 -0
- package/src/scene/commands/SceneCommand.ts +70 -0
- package/src/scene/commands/drawingCommands.ts +318 -0
- package/src/scene/commands/layoutCommands.ts +301 -0
- package/src/scene/hooks/useSceneHitTest.ts +105 -0
- package/src/scene/hooks/useScenePanZoom.ts +147 -0
- package/src/scene/hooks/useSceneSelection.ts +62 -0
- package/src/scene/index.ts +95 -0
- package/src/scene/layers/DiagramEdges.tsx +127 -0
- package/src/scene/layers/edgeGeometry.ts +77 -0
- package/src/scene/layers/nodeCard.tsx +145 -0
- package/src/scene/layers/renderLayer.tsx +70 -0
- package/src/scene/layers/shapeLayers.ts +201 -0
- package/src/scene/paths/bezierEdit.ts +208 -0
- package/src/scene/scene.css +649 -0
- package/src/scene/text/SceneTextOverlay.tsx +161 -0
- package/src/scene/text/sceneTextChannel.ts +40 -0
- package/src/scene/text/sceneTextConfig.ts +27 -0
- package/src/scene/text/sceneTiptap.ts +36 -0
- package/src/scene/text/useSceneTextEditing.ts +39 -0
- package/src/scene/tools/ConnectTool.ts +111 -0
- package/src/scene/tools/DrawingConnectTool.ts +161 -0
- package/src/scene/tools/PathTool.ts +158 -0
- package/src/scene/tools/PlaceTool.ts +47 -0
- package/src/scene/tools/SceneTool.ts +75 -0
- package/src/scene/tools/SelectTool.ts +284 -0
- package/src/scene/tools/ShapeTool.ts +144 -0
- package/src/scene/tools/TextTool.ts +72 -0
- package/src/scene/tools/TokenTool.ts +95 -0
- package/src/scene/tools/createDrawShapeTool.ts +82 -0
- package/src/styles/code-context.css +155 -0
- package/src/styles/diagram.css +183 -0
- package/src/styles/editor.css +1708 -150
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +5 -0
- package/src/timelineSource.ts +244 -0
- package/src/tiptapBridge.ts +115 -34
- package/src/tooltipPlacement.ts +13 -0
- package/src/transitionCatalog.ts +159 -0
- package/src/types/monaco-shims.d.ts +10 -0
- package/src/useBlockNavigator.ts +153 -0
- package/src/useMonacoLoader.ts +23 -1
- package/src/useTimelineClock.ts +76 -0
- package/src/utils/dropUtils.ts +1 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
readHeadingLineTransition,
|
|
4
|
+
setHeadingLineTransition,
|
|
5
|
+
readBlockAttrsTransition,
|
|
6
|
+
setBlockAttrsTransition,
|
|
7
|
+
EMPTY_TRANSITION,
|
|
8
|
+
} from '../headingTransition';
|
|
9
|
+
|
|
10
|
+
describe('readHeadingLineTransition', () => {
|
|
11
|
+
it('returns empty for a plain heading', () => {
|
|
12
|
+
expect(readHeadingLineTransition('## Tips')).toEqual(EMPTY_TRANSITION);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns empty for a non-heading line', () => {
|
|
16
|
+
expect(readHeadingLineTransition('Just a paragraph')).toEqual(EMPTY_TRANSITION);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('reads transition from a Pandoc block', () => {
|
|
20
|
+
expect(readHeadingLineTransition('## Tips {transition=fade}')).toEqual({
|
|
21
|
+
type: 'fade',
|
|
22
|
+
direction: '',
|
|
23
|
+
duration: '',
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('reads type, direction and duration together', () => {
|
|
28
|
+
expect(
|
|
29
|
+
readHeadingLineTransition(
|
|
30
|
+
'## Tips {transition=push transitionDirection=left transitionDuration=0.7}',
|
|
31
|
+
),
|
|
32
|
+
).toEqual({ type: 'push', direction: 'left', duration: '0.7' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('reads a hand-typed transition from the {[…]} template params', () => {
|
|
36
|
+
expect(readHeadingLineTransition('## Tips {[title transition=zoom]}')).toEqual({
|
|
37
|
+
type: 'zoom',
|
|
38
|
+
direction: '',
|
|
39
|
+
duration: '',
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('prefers the Pandoc block over the template params when both set it', () => {
|
|
44
|
+
expect(
|
|
45
|
+
readHeadingLineTransition('## Tips {transition=fade} {[title transition=zoom]}'),
|
|
46
|
+
).toEqual({ type: 'fade', direction: '', duration: '' });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('setHeadingLineTransition', () => {
|
|
51
|
+
it('adds a Pandoc block to a plain heading', () => {
|
|
52
|
+
expect(setHeadingLineTransition('## Tips', { type: 'fade', direction: '', duration: '' })).toBe(
|
|
53
|
+
'## Tips {transition=fade}',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('writes the Pandoc block before an existing {[…]} template annotation', () => {
|
|
58
|
+
expect(
|
|
59
|
+
setHeadingLineTransition('## Tips {[title]}', { type: 'fade', direction: '', duration: '' }),
|
|
60
|
+
).toBe('## Tips {transition=fade} {[title]}');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('includes direction and duration when present', () => {
|
|
64
|
+
expect(
|
|
65
|
+
setHeadingLineTransition('## Tips', { type: 'push', direction: 'up', duration: '1.2' }),
|
|
66
|
+
).toBe('## Tips {transition=push transitionDirection=up transitionDuration=1.2}');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('updates an existing transition in place, preserving other params and the id', () => {
|
|
70
|
+
expect(
|
|
71
|
+
setHeadingLineTransition('## Tips {#intro transition=fade x=10}', {
|
|
72
|
+
type: 'zoom',
|
|
73
|
+
direction: '',
|
|
74
|
+
duration: '',
|
|
75
|
+
}),
|
|
76
|
+
).toBe('## Tips {#intro x=10 transition=zoom}');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('removes the transition (and an empty block) when set to none', () => {
|
|
80
|
+
expect(setHeadingLineTransition('## Tips {transition=fade}', EMPTY_TRANSITION)).toBe('## Tips');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('keeps a non-empty Pandoc block when only the transition is cleared', () => {
|
|
84
|
+
expect(setHeadingLineTransition('## Tips {#intro transition=fade}', EMPTY_TRANSITION)).toBe(
|
|
85
|
+
'## Tips {#intro}',
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('drops direction/duration when the type is cleared', () => {
|
|
90
|
+
expect(
|
|
91
|
+
setHeadingLineTransition(
|
|
92
|
+
'## Tips {transition=push transitionDirection=left}',
|
|
93
|
+
EMPTY_TRANSITION,
|
|
94
|
+
),
|
|
95
|
+
).toBe('## Tips');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('round-trips through read', () => {
|
|
99
|
+
const next = { type: 'wipe', direction: 'right', duration: '0.5' };
|
|
100
|
+
const line = setHeadingLineTransition('## Tips {[title]}', next);
|
|
101
|
+
expect(readHeadingLineTransition(line)).toEqual(next);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('leaves non-heading lines untouched', () => {
|
|
105
|
+
expect(
|
|
106
|
+
setHeadingLineTransition('paragraph', { type: 'fade', direction: '', duration: '' }),
|
|
107
|
+
).toBe('paragraph');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('block-attrs (WYSIWYG) helpers', () => {
|
|
112
|
+
it('reads from the dataBlockAttrs inner', () => {
|
|
113
|
+
expect(readBlockAttrsTransition('#intro transition=fade', null)).toEqual({
|
|
114
|
+
type: 'fade',
|
|
115
|
+
direction: '',
|
|
116
|
+
duration: '',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('reads a hand-typed transition from dataTemplateParams', () => {
|
|
121
|
+
expect(readBlockAttrsTransition(null, 'transition=zoom x=1')).toEqual({
|
|
122
|
+
type: 'zoom',
|
|
123
|
+
direction: '',
|
|
124
|
+
duration: '',
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('writes a fresh inner when none exists', () => {
|
|
129
|
+
expect(setBlockAttrsTransition(null, { type: 'fade', direction: '', duration: '' })).toBe(
|
|
130
|
+
'transition=fade',
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('preserves the id and clears to null when emptied', () => {
|
|
135
|
+
expect(setBlockAttrsTransition('transition=fade', EMPTY_TRANSITION)).toBeNull();
|
|
136
|
+
expect(setBlockAttrsTransition('#intro transition=fade', EMPTY_TRANSITION)).toBe('#intro');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout child sub-blocks round-trip through the markdown ↔ Tiptap bridge.
|
|
3
|
+
*
|
|
4
|
+
* Layouts now persist each layer as a readable `### {#id} {[type …]}` child
|
|
5
|
+
* heading (no base64 `layers=` blob); a text layer's content is the child's
|
|
6
|
+
* markdown body. These tests pin that serialization seam — the same
|
|
7
|
+
* `data-template` / `data-template-params` / `data-block-attrs` attributes the
|
|
8
|
+
* LayoutAdapter reads back — without standing up a full editor.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
|
|
13
|
+
|
|
14
|
+
// Parent title "Pricing" (not "Layout") avoids the bridge's template-label
|
|
15
|
+
// de-dup heuristic, which would strip a title equal to the template's label.
|
|
16
|
+
const LAYOUT = [
|
|
17
|
+
'## Pricing {[layout]}',
|
|
18
|
+
'',
|
|
19
|
+
'### {#text-1} {[text x=360 y=380 width=1200 height=320 align=center]}',
|
|
20
|
+
'',
|
|
21
|
+
'Welcome to the **bold** layout',
|
|
22
|
+
'',
|
|
23
|
+
'- first point',
|
|
24
|
+
'- second point',
|
|
25
|
+
'',
|
|
26
|
+
'### {#box-1} {[rectangle x=760 y=390 width=400 height=300 fill="#e0e7ff"]}',
|
|
27
|
+
'',
|
|
28
|
+
'### {#pic-1} {[image x=40 y=40 width=320 height=240 src=cover.png alt="Cover"]}',
|
|
29
|
+
].join('\n');
|
|
30
|
+
|
|
31
|
+
describe('Layout child sub-block round-trip', () => {
|
|
32
|
+
it('extracts a text layer annotation into the template + block-attr attributes', () => {
|
|
33
|
+
const html = markdownToTiptap('### {#text-1} {[text x=360 y=380 width=1200 height=320]}');
|
|
34
|
+
expect(html).toContain('data-template="text"');
|
|
35
|
+
expect(html).toContain('data-template-params="x=360 y=380 width=1200 height=320"');
|
|
36
|
+
expect(html).toContain('data-block-attrs="#text-1"');
|
|
37
|
+
expect(html).not.toContain('{[text'); // raw annotation text stripped
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps a text layer body (marks + list) as sibling blocks, not in the heading', () => {
|
|
41
|
+
const html = markdownToTiptap(
|
|
42
|
+
['### {#text-1} {[text x=0 y=0]}', '', 'Hello **world**', '', '- a', '- b'].join('\n'),
|
|
43
|
+
);
|
|
44
|
+
// Heading is empty; the body is its own paragraph + list.
|
|
45
|
+
expect(html).toContain('<strong>world</strong>');
|
|
46
|
+
expect(html).toContain('<ul>');
|
|
47
|
+
expect(html).toContain('<li>');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('round-trips a full layout (text + box + image) losslessly and idempotently', () => {
|
|
51
|
+
const back = tiptapToMarkdown(markdownToTiptap(LAYOUT));
|
|
52
|
+
expect(back).toContain('## Pricing {[layout]}');
|
|
53
|
+
// Child sub-blocks keep their id + annotation (empty-text headings get an
|
|
54
|
+
// extra space after `###`, which re-parses identically — assert spacing-free).
|
|
55
|
+
expect(back).toContain('{#text-1} {[text x=360 y=380 width=1200 height=320 align=center]}');
|
|
56
|
+
expect(back).toContain(
|
|
57
|
+
'{#box-1} {[rectangle x=760 y=390 width=400 height=300 fill="#e0e7ff"]}',
|
|
58
|
+
);
|
|
59
|
+
expect(back).toContain(
|
|
60
|
+
'{#pic-1} {[image x=40 y=40 width=320 height=240 src=cover.png alt="Cover"]}',
|
|
61
|
+
);
|
|
62
|
+
// The text layer's body markdown survives as readable content.
|
|
63
|
+
expect(back).toContain('Welcome to the **bold** layout');
|
|
64
|
+
expect(back).toContain('- first point');
|
|
65
|
+
expect(back).toContain('- second point');
|
|
66
|
+
// No base64 layers blob anywhere — the whole point of the change.
|
|
67
|
+
expect(back).not.toContain('layers=');
|
|
68
|
+
// Stable: a second pass reproduces the first exactly.
|
|
69
|
+
expect(tiptapToMarkdown(markdownToTiptap(back))).toBe(back);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -10,10 +10,10 @@ function getIframeSrcDoc(): string {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
describe('PlainHtmlPreview', () => {
|
|
13
|
-
it('renders the markdown into the iframe srcDoc', () => {
|
|
13
|
+
it('renders the markdown into the iframe srcDoc', async () => {
|
|
14
14
|
render(<PlainHtmlPreview markdown={'# Hello\n\nWorld'} />);
|
|
15
|
+
await waitFor(() => expect(getIframeSrcDoc()).toContain('<!DOCTYPE html>'));
|
|
15
16
|
const srcdoc = getIframeSrcDoc();
|
|
16
|
-
expect(srcdoc).toContain('<!DOCTYPE html>');
|
|
17
17
|
expect(srcdoc).toContain('<h1>Hello</h1>');
|
|
18
18
|
expect(srcdoc).toContain('<p>World</p>');
|
|
19
19
|
});
|
|
@@ -26,14 +26,14 @@ describe('PlainHtmlPreview', () => {
|
|
|
26
26
|
expect(sandbox).not.toContain('allow-scripts');
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
it('applies pre-resolved images via the images prop', () => {
|
|
29
|
+
it('applies pre-resolved images via the images prop', async () => {
|
|
30
30
|
render(
|
|
31
31
|
<PlainHtmlPreview
|
|
32
32
|
markdown={''}
|
|
33
33
|
images={new Map([['a.jpg', 'data:image/png;base64,AAA']])}
|
|
34
34
|
/>,
|
|
35
35
|
);
|
|
36
|
-
expect(getIframeSrcDoc()).toContain('src="data:image/png;base64,AAA"');
|
|
36
|
+
await waitFor(() => expect(getIframeSrcDoc()).toContain('src="data:image/png;base64,AAA"'));
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
it('resolves image URLs through the supplied mediaProvider', async () => {
|
|
@@ -68,19 +68,21 @@ describe('PlainHtmlPreview', () => {
|
|
|
68
68
|
expect(provider.resolveUrl).not.toHaveBeenCalled();
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
it('applies the supplied theme to the iframe document', () => {
|
|
71
|
+
it('applies the supplied theme to the iframe document', async () => {
|
|
72
72
|
const theme = resolveTheme('warm-earth');
|
|
73
73
|
render(<PlainHtmlPreview markdown={'# Hi'} theme={theme} />);
|
|
74
|
+
await waitFor(() =>
|
|
75
|
+
expect(getIframeSrcDoc()).toContain(`--plain-bg: ${theme.colors.background};`),
|
|
76
|
+
);
|
|
74
77
|
const srcdoc = getIframeSrcDoc();
|
|
75
|
-
expect(srcdoc).toContain(`--plain-bg: ${theme.colors.background};`);
|
|
76
78
|
expect(srcdoc).toContain(`--plain-primary: ${theme.colors.primary};`);
|
|
77
79
|
expect(srcdoc).toContain('--plain-body-font:');
|
|
78
80
|
});
|
|
79
81
|
|
|
80
|
-
it('loads Google Fonts for themes that reference google-hosted faces', () => {
|
|
82
|
+
it('loads Google Fonts for themes that reference google-hosted faces', async () => {
|
|
81
83
|
const theme = resolveTheme('documentary');
|
|
82
84
|
render(<PlainHtmlPreview markdown={'# Hi'} theme={theme} />);
|
|
83
|
-
expect(getIframeSrcDoc()).toContain('https://fonts.googleapis.com/css2?');
|
|
85
|
+
await waitFor(() => expect(getIframeSrcDoc()).toContain('https://fonts.googleapis.com/css2?'));
|
|
84
86
|
});
|
|
85
87
|
|
|
86
88
|
it('resolves raw HTML <img> tags too (resized image case)', async () => {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
6
|
+
import { EditorProvider, useEditorContext } from '../EditorContext';
|
|
7
|
+
import { PreviewModeSwitch, PreviewSettingsProvider, usePreviewSettings } from '../PreviewControls';
|
|
8
|
+
|
|
9
|
+
function ModeProbe() {
|
|
10
|
+
const { activeDisplayMode } = usePreviewSettings();
|
|
11
|
+
return <div data-testid="active-mode">{activeDisplayMode}</div>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function PreviewHarness() {
|
|
15
|
+
const { doc } = useEditorContext();
|
|
16
|
+
return (
|
|
17
|
+
<PreviewSettingsProvider doc={doc}>
|
|
18
|
+
<PreviewModeSwitch />
|
|
19
|
+
<ModeProbe />
|
|
20
|
+
</PreviewSettingsProvider>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderPreviewControls(markdown: string) {
|
|
25
|
+
render(
|
|
26
|
+
<EditorProvider initialMarkdown={markdown}>
|
|
27
|
+
<PreviewHarness />
|
|
28
|
+
</EditorProvider>,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => cleanup());
|
|
33
|
+
|
|
34
|
+
describe('PreviewModeSwitch', () => {
|
|
35
|
+
it('labels the plain document preview as Document and the styled view as Page', () => {
|
|
36
|
+
renderPreviewControls('# Hello');
|
|
37
|
+
|
|
38
|
+
const labels = screen
|
|
39
|
+
.getAllByRole('button')
|
|
40
|
+
.map((button) => button.textContent)
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
|
|
43
|
+
expect(labels).toEqual(['Video', 'Slideshow', 'Page', 'Document']);
|
|
44
|
+
|
|
45
|
+
fireEvent.click(screen.getByRole('button', { name: 'Document' }));
|
|
46
|
+
expect(screen.getByTestId('active-mode').textContent).toBe('page');
|
|
47
|
+
|
|
48
|
+
fireEvent.click(screen.getByRole('button', { name: 'Page' }));
|
|
49
|
+
expect(screen.getByTestId('active-mode').textContent).toBe('linear');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('maps product-facing display-mode frontmatter to the correct renderer values', async () => {
|
|
53
|
+
renderPreviewControls('---\ndisplay-mode: document\n---\n\n# Hello');
|
|
54
|
+
|
|
55
|
+
await waitFor(() => {
|
|
56
|
+
expect(screen.getByTestId('active-mode').textContent).toBe('page');
|
|
57
|
+
});
|
|
58
|
+
expect(screen.getByRole('button', { name: 'Document' }).getAttribute('aria-pressed')).toBe(
|
|
59
|
+
'true',
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
cleanup();
|
|
63
|
+
renderPreviewControls('---\ndisplay-mode: page\n---\n\n# Hello');
|
|
64
|
+
|
|
65
|
+
await waitFor(() => {
|
|
66
|
+
expect(screen.getByTestId('active-mode').textContent).toBe('linear');
|
|
67
|
+
});
|
|
68
|
+
expect(screen.getByRole('button', { name: 'Page' }).getAttribute('aria-pressed')).toBe('true');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Editor } from '@tiptap/core';
|
|
2
|
+
import Document from '@tiptap/extension-document';
|
|
3
|
+
import Paragraph from '@tiptap/extension-paragraph';
|
|
4
|
+
import Text from '@tiptap/extension-text';
|
|
5
|
+
import OrderedList from '@tiptap/extension-ordered-list';
|
|
6
|
+
import ListItem from '@tiptap/extension-list-item';
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
9
|
+
import { markdownToDoc, flattenBlocks } from '@bendyline/squisq/doc';
|
|
10
|
+
import { tiptapToMarkdown } from '../tiptapBridge';
|
|
11
|
+
import { insertMediaBlock } from '../recorder/insertMediaBlock';
|
|
12
|
+
import { collectEmbeddedMedia } from '../embeddedMedia';
|
|
13
|
+
import { TiptapVideo } from '../tiptap/TiptapVideo';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Regression: a recorded clip used to vanish from the markdown source
|
|
17
|
+
* (and therefore the timeline) when the editor caret sat inside a list
|
|
18
|
+
* item. The recorder inserts a block-level `<video>` atom; a plain
|
|
19
|
+
* `insertContent` nests it inside the `<li>`, and `tiptapToMarkdown`'s
|
|
20
|
+
* list serializer emits only the item's inline text — silently dropping
|
|
21
|
+
* the nested media. `insertMediaBlock` lifts the insertion to the
|
|
22
|
+
* top level so the tag round-trips.
|
|
23
|
+
*/
|
|
24
|
+
function makeEditor() {
|
|
25
|
+
return new Editor({
|
|
26
|
+
extensions: [Document, Paragraph, Text, OrderedList, ListItem, TiptapVideo],
|
|
27
|
+
content: '<ol><li><p>First</p></li><li><p>Second</p></li></ol>',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Put the caret at the end of the last list item — where the recorder
|
|
32
|
+
* modal typically leaves it after the user selected a list-bearing block. */
|
|
33
|
+
function caretInLastListItem(editor: Editor) {
|
|
34
|
+
editor.commands.focus('end');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('recorder media insertion (caret inside a list)', () => {
|
|
38
|
+
it('a plain insertContent buries the video as an indented continuation of the <li>', () => {
|
|
39
|
+
const editor = makeEditor();
|
|
40
|
+
caretInLastListItem(editor);
|
|
41
|
+
editor
|
|
42
|
+
.chain()
|
|
43
|
+
.focus()
|
|
44
|
+
.insertContent({ type: 'video', attrs: { src: 'video/clip.webm', controls: true } })
|
|
45
|
+
.run();
|
|
46
|
+
const md = tiptapToMarkdown(editor.getHTML());
|
|
47
|
+
editor.destroy();
|
|
48
|
+
// The hardened serializer no longer drops it, but it lands nested
|
|
49
|
+
// inside the list item (indented continuation) rather than as its
|
|
50
|
+
// own block — which is why the recorder uses `insertMediaBlock`.
|
|
51
|
+
expect(md).toMatch(/\n {2,}<video/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('insertMediaBlock places the video as a top-level block after the list', () => {
|
|
55
|
+
const editor = makeEditor();
|
|
56
|
+
caretInLastListItem(editor);
|
|
57
|
+
insertMediaBlock(editor, {
|
|
58
|
+
type: 'video',
|
|
59
|
+
attrs: { src: 'video/clip.webm', controls: true, width: 480 },
|
|
60
|
+
});
|
|
61
|
+
const md = tiptapToMarkdown(editor.getHTML());
|
|
62
|
+
editor.destroy();
|
|
63
|
+
// The list is preserved and the clip sits at the document level
|
|
64
|
+
// (start of line, not indented under a bullet).
|
|
65
|
+
expect(md).toContain('1. First');
|
|
66
|
+
expect(md).toContain('2. Second');
|
|
67
|
+
expect(md).toMatch(/^<video src="video\/clip\.webm"/m);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('hardened list serializer: nested media round-trips to a detectable clip', () => {
|
|
72
|
+
it('a <video> left inside a list item still parses back as embedded media', () => {
|
|
73
|
+
// Even when media ends up nested in a list item (drag/paste, not the
|
|
74
|
+
// recorder path), the serializer keeps it — and the markdown re-parses
|
|
75
|
+
// to a block whose embedded media the timeline can surface.
|
|
76
|
+
const md = tiptapToMarkdown(
|
|
77
|
+
'<h2>Section</h2><ol><li><p>Step</p><video src="video/clip.webm" controls=""></video></li></ol>',
|
|
78
|
+
);
|
|
79
|
+
expect(md).toContain('<video');
|
|
80
|
+
|
|
81
|
+
const doc = markdownToDoc(parseMarkdown(md));
|
|
82
|
+
const blocks = flattenBlocks(doc.blocks);
|
|
83
|
+
const total = blocks.reduce((s, b) => s + collectEmbeddedMedia(b).length, 0);
|
|
84
|
+
expect(total).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -31,4 +31,22 @@ describe('Template annotation round-trip', () => {
|
|
|
31
31
|
expect(md).not.toContain('squisq-template-badge');
|
|
32
32
|
expect(md).not.toContain('Comparison Bar'); // CSS-rendered label shouldn't bleed
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
it('does not leak the block-properties badge into markdown', () => {
|
|
36
|
+
// The props badge is an empty CSS-painted span rendered alongside the
|
|
37
|
+
// template badge (and on plain headings, the empty template badge). It
|
|
38
|
+
// must serialize to nothing — no stray `*`/`**` from an `<i>`, no span.
|
|
39
|
+
const tiptapRendered =
|
|
40
|
+
'<h2 data-block-attrs="transition=fade">' +
|
|
41
|
+
'<span class="squisq-heading-content">Intro</span>' +
|
|
42
|
+
'<span class="squisq-template-badge squisq-template-badge--empty" contenteditable="false"></span>' +
|
|
43
|
+
'<span class="squisq-props-badge" contenteditable="false" data-props-summary="Fade · 1:30 start"></span>' +
|
|
44
|
+
'</h2>';
|
|
45
|
+
const md = tiptapToMarkdown(tiptapRendered).trim();
|
|
46
|
+
expect(md).toBe('## Intro {transition=fade}');
|
|
47
|
+
expect(md).not.toContain('squisq-props-badge');
|
|
48
|
+
expect(md).not.toContain('*');
|
|
49
|
+
// The CSS-painted summary lives in a data attribute; it must not bleed.
|
|
50
|
+
expect(md).not.toContain('start');
|
|
51
|
+
});
|
|
34
52
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { TEMPLATE_METADATA } from '@bendyline/squisq/doc';
|
|
3
|
+
import { TEMPLATE_ENTRIES } from '../TemplatePicker';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The TemplatePicker gallery must stay 1:1 with core's TEMPLATE_METADATA so a
|
|
7
|
+
* template added to the core registry can never silently fail to appear in the
|
|
8
|
+
* editor. Core owns the canonical id list + labels + descriptions; the picker
|
|
9
|
+
* adds only the per-id preview icon.
|
|
10
|
+
*/
|
|
11
|
+
describe('TemplatePicker ↔ core TEMPLATE_METADATA', () => {
|
|
12
|
+
it('lists the same ids in the same order as TEMPLATE_METADATA', () => {
|
|
13
|
+
const pickerNames = TEMPLATE_ENTRIES.map((e) => e.name);
|
|
14
|
+
const metadataNames = Object.keys(TEMPLATE_METADATA);
|
|
15
|
+
expect(pickerNames).toEqual(metadataNames);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('uses the canonical label and description for every entry', () => {
|
|
19
|
+
for (const entry of TEMPLATE_ENTRIES) {
|
|
20
|
+
const meta = TEMPLATE_METADATA[entry.name];
|
|
21
|
+
expect(meta, `missing core metadata for picker entry "${entry.name}"`).toBeTruthy();
|
|
22
|
+
expect(entry.label, `label drift for "${entry.name}"`).toBe(meta.label);
|
|
23
|
+
expect(entry.description, `description drift for "${entry.name}"`).toBe(meta.description);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('provides a preview icon for every entry', () => {
|
|
28
|
+
for (const entry of TEMPLATE_ENTRIES) {
|
|
29
|
+
expect(entry.icon, `missing icon for "${entry.name}"`).toBeTruthy();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
3
|
+
import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
4
|
+
import {
|
|
5
|
+
formatSeconds,
|
|
6
|
+
setBlockDurationInSource,
|
|
7
|
+
setMediaClipInSource,
|
|
8
|
+
buildClipAnnotation,
|
|
9
|
+
placeClipInBlock,
|
|
10
|
+
} from '../timelineSource';
|
|
11
|
+
|
|
12
|
+
/** Re-parse and read a block's duration to confirm the edit took effect. */
|
|
13
|
+
function durationOf(source: string, blockIndex: number): number {
|
|
14
|
+
const doc = markdownToDoc(parseMarkdown(source), { articleId: 't' });
|
|
15
|
+
return doc.blocks[blockIndex].duration;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('formatSeconds', () => {
|
|
19
|
+
it('keeps integers bare and rounds fractions', () => {
|
|
20
|
+
expect(formatSeconds(8)).toBe('8');
|
|
21
|
+
expect(formatSeconds(8.5)).toBe('8.5');
|
|
22
|
+
expect(formatSeconds(8.499)).toBe('8.5');
|
|
23
|
+
expect(formatSeconds(-3)).toBe('0');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('setBlockDurationInSource', () => {
|
|
28
|
+
it('inserts the duration on a bare heading in the squiggly form', () => {
|
|
29
|
+
const src = '# Intro\n\nBody.\n';
|
|
30
|
+
const next = setBlockDurationInSource(src, 1, 12)!;
|
|
31
|
+
expect(next).toContain('# Intro {[duration=12]}');
|
|
32
|
+
expect(durationOf(next, 0)).toBe(12);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('folds the duration into an existing template annotation', () => {
|
|
36
|
+
const src = '# Title {[sectionHeader]}\n\nBody.\n';
|
|
37
|
+
const next = setBlockDurationInSource(src, 1, 7)!;
|
|
38
|
+
expect(next).toContain('{[sectionHeader duration=7]}');
|
|
39
|
+
expect(durationOf(next, 0)).toBe(7);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('updates an existing squiggly duration in place', () => {
|
|
43
|
+
const src = '# Intro {[duration=5]}\n\nBody.\n';
|
|
44
|
+
const next = setBlockDurationInSource(src, 1, 9)!;
|
|
45
|
+
expect(next).toContain('{[duration=9]}');
|
|
46
|
+
expect(next).not.toContain('duration=5');
|
|
47
|
+
expect(durationOf(next, 0)).toBe(9);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('migrates a legacy Pandoc duration to squiggly, preserving id/classes', () => {
|
|
51
|
+
const src = '# Intro {#intro .hero duration=5}\n\nBody.\n';
|
|
52
|
+
const next = setBlockDurationInSource(src, 1, 9)!;
|
|
53
|
+
expect(next).toContain('#intro');
|
|
54
|
+
expect(next).toContain('.hero');
|
|
55
|
+
expect(next).toContain('{[duration=9]}');
|
|
56
|
+
// The stale Pandoc duration is gone so the two forms can't disagree.
|
|
57
|
+
expect(next).not.toMatch(/\{[^[]*duration=/);
|
|
58
|
+
expect(durationOf(next, 0)).toBe(9);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns null for a non-heading line', () => {
|
|
62
|
+
expect(setBlockDurationInSource('not a heading\n', 1, 5)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('setMediaClipInSource', () => {
|
|
67
|
+
const src = '# B {duration=20}\n\n{[audio src=a.mp3 startAt=2]}\n';
|
|
68
|
+
|
|
69
|
+
it('patches startAt on a media annotation', () => {
|
|
70
|
+
const next = setMediaClipInSource(src, 3, { startAt: 6 })!;
|
|
71
|
+
expect(next).toContain('{[audio src=a.mp3 startAt=6]}');
|
|
72
|
+
const doc = markdownToDoc(parseMarkdown(next), { articleId: 't' });
|
|
73
|
+
expect(doc.blocks[0].media![0].startAt).toBe(6);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('adds clipEnd and toggles spillover, keeping src', () => {
|
|
77
|
+
const next = setMediaClipInSource(src, 3, { clipEnd: 30, spillover: true })!;
|
|
78
|
+
expect(next).toContain('src=a.mp3');
|
|
79
|
+
expect(next).toContain('clipEnd=30');
|
|
80
|
+
expect(next).toContain('spillover=true');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('removes a key when patched with null', () => {
|
|
84
|
+
const withSpill = setMediaClipInSource(src, 3, { spillover: true })!;
|
|
85
|
+
const removed = setMediaClipInSource(withSpill, 3, { spillover: null })!;
|
|
86
|
+
expect(removed).not.toContain('spillover');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns null for a non-media line', () => {
|
|
90
|
+
expect(setMediaClipInSource(src, 1, { startAt: 1 })).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('buildClipAnnotation', () => {
|
|
95
|
+
it('emits a minimal annotation', () => {
|
|
96
|
+
expect(buildClipAnnotation({ kind: 'video', src: 'r.webm' }, 0)).toBe('{[video src=r.webm]}');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('includes timing fields when set', () => {
|
|
100
|
+
expect(
|
|
101
|
+
buildClipAnnotation({ kind: 'audio', src: 'a.mp3', clipEnd: 8, spillover: true }, 5),
|
|
102
|
+
).toBe('{[audio src=a.mp3 startAt=5 clipEnd=8 spillover=true]}');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('placeClipInBlock', () => {
|
|
107
|
+
// Lines: 1 "# One {duration=10}", 2 "", 3 "<video src=r.webm></video>",
|
|
108
|
+
// 4 "", 5 "# Two {duration=10}", 6 "", 7 "body"
|
|
109
|
+
const src =
|
|
110
|
+
'# One {duration=10}\n\n<video src="r.webm"></video>\n\n# Two {duration=10}\n\nbody\n';
|
|
111
|
+
|
|
112
|
+
it('converts an embed to an annotation in place (same block)', () => {
|
|
113
|
+
const next = placeClipInBlock(src, 3, 1, { kind: 'video', src: 'r.webm', clipEnd: 10 }, 0)!;
|
|
114
|
+
expect(next).toContain('{[video src=r.webm clipEnd=10]}');
|
|
115
|
+
expect(next).not.toContain('<video');
|
|
116
|
+
// Still under block One (line 1), before block Two.
|
|
117
|
+
expect(next.indexOf('{[video')).toBeLessThan(next.indexOf('# Two'));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('relocates the clip into a different (earlier/later) block', () => {
|
|
121
|
+
// Move the embed (line 3, in block One) into block Two (heading line 5).
|
|
122
|
+
const next = placeClipInBlock(src, 3, 5, { kind: 'video', src: 'r.webm', clipEnd: 4 }, 2)!;
|
|
123
|
+
expect(next).not.toContain('<video');
|
|
124
|
+
// The annotation now sits after block Two's heading.
|
|
125
|
+
const twoIdx = next.indexOf('# Two');
|
|
126
|
+
const clipIdx = next.indexOf('{[video');
|
|
127
|
+
expect(clipIdx).toBeGreaterThan(twoIdx);
|
|
128
|
+
expect(next).toContain('startAt=2');
|
|
129
|
+
// Re-parsing attaches it to block Two as a media clip.
|
|
130
|
+
const doc = markdownToDoc(parseMarkdown(next), { articleId: 't' });
|
|
131
|
+
expect(doc.blocks[1].media?.[0]).toMatchObject({ src: 'r.webm', startAt: 2, anchor: 'block' });
|
|
132
|
+
expect(doc.blocks[0].media).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
});
|