@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,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
6
|
+
import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
7
|
+
import {
|
|
8
|
+
buildNarrationScript,
|
|
9
|
+
parseNarrationTimingJson,
|
|
10
|
+
type NarrationAlignment,
|
|
11
|
+
} from '@bendyline/squisq/narration';
|
|
12
|
+
import {
|
|
13
|
+
insertNarrationPreamble,
|
|
14
|
+
narrationAnnotationLine,
|
|
15
|
+
} from '../teleprompter/recording/insertPreamble';
|
|
16
|
+
import { buildNarrationSavePlan } from '../teleprompter/recording/narrationSave';
|
|
17
|
+
|
|
18
|
+
describe('insertNarrationPreamble', () => {
|
|
19
|
+
it('inserts at the top of a plain document', () => {
|
|
20
|
+
const next = insertNarrationPreamble('# Title\n\nBody.\n', 'audio/narration-1.webm', null);
|
|
21
|
+
expect(next.startsWith('{[audio src=audio/narration-1.webm anchor=document]}\n\n# Title')).toBe(
|
|
22
|
+
true,
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('inserts after the frontmatter fence', () => {
|
|
27
|
+
const source = '---\ntitle: Doc\n---\n\n# Title\n\nBody.\n';
|
|
28
|
+
const next = insertNarrationPreamble(source, 'audio/narration-1.webm', null);
|
|
29
|
+
const lines = next.split('\n');
|
|
30
|
+
expect(lines[2]).toBe('---');
|
|
31
|
+
expect(next).toContain(
|
|
32
|
+
'---\n\n{[audio src=audio/narration-1.webm anchor=document]}\n\n# Title',
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('replaces a previous take (and its camera line) on retake', () => {
|
|
37
|
+
const withTake = insertNarrationPreamble(
|
|
38
|
+
'# Title\n\nBody.\n',
|
|
39
|
+
'audio/narration-old.webm',
|
|
40
|
+
'video/narration-cam-old.webm',
|
|
41
|
+
);
|
|
42
|
+
expect(withTake).toContain('narration-old');
|
|
43
|
+
expect(withTake).toContain('<video src="video/narration-cam-old.webm"');
|
|
44
|
+
|
|
45
|
+
const retaken = insertNarrationPreamble(withTake, 'audio/narration-new.webm', null);
|
|
46
|
+
expect(retaken).not.toContain('narration-old');
|
|
47
|
+
expect(retaken).not.toContain('narration-cam-old');
|
|
48
|
+
expect(retaken).toContain('{[audio src=audio/narration-new.webm anchor=document]}');
|
|
49
|
+
// No duplicate annotations.
|
|
50
|
+
expect(retaken.match(/\{\[audio /g)?.length).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('adds the camera companion as an inline video line', () => {
|
|
54
|
+
const next = insertNarrationPreamble(
|
|
55
|
+
'# Title\n\nBody.\n',
|
|
56
|
+
'audio/narration-1.webm',
|
|
57
|
+
'video/narration-cam-1.webm',
|
|
58
|
+
);
|
|
59
|
+
expect(next).toContain('{[audio src=audio/narration-1.webm anchor=document]}');
|
|
60
|
+
expect(next).toContain('<video src="video/narration-cam-1.webm" controls width="240"></video>');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('quotes srcs with spaces', () => {
|
|
64
|
+
expect(narrationAnnotationLine('audio/my take.webm')).toBe(
|
|
65
|
+
'{[audio src="audio/my take.webm" anchor=document]}',
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('round-trips through markdownToDoc as documentMedia', () => {
|
|
70
|
+
const next = insertNarrationPreamble(
|
|
71
|
+
'---\ntitle: Doc\n---\n\n# Title\n\nBody.\n',
|
|
72
|
+
'audio/narration-1.webm',
|
|
73
|
+
null,
|
|
74
|
+
);
|
|
75
|
+
const doc = markdownToDoc(parseMarkdown(next));
|
|
76
|
+
expect(doc.documentMedia?.length).toBe(1);
|
|
77
|
+
expect(doc.documentMedia![0].src).toBe('audio/narration-1.webm');
|
|
78
|
+
expect(doc.documentMedia![0].anchor).toBe('document');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('buildNarrationSavePlan', () => {
|
|
83
|
+
const script = buildNarrationScript(
|
|
84
|
+
markdownToDoc(parseMarkdown('# One\n\nAlpha beta.\n\n# Two\n\nGamma delta.\n')),
|
|
85
|
+
);
|
|
86
|
+
const alignment: NarrationAlignment = {
|
|
87
|
+
words: script.tokens.map((_, i) => ({ tokenIndex: i, tSec: i * 0.4, interpolated: false })),
|
|
88
|
+
blocks: script.blocks.map((range, i) => ({
|
|
89
|
+
blockId: range.blockId,
|
|
90
|
+
blockIndex: i,
|
|
91
|
+
charStart: range.charStart,
|
|
92
|
+
charEnd: range.charEnd,
|
|
93
|
+
startSec: i * 5,
|
|
94
|
+
endSec: (i + 1) * 5,
|
|
95
|
+
})),
|
|
96
|
+
detectedSyllables: script.totalSyllables,
|
|
97
|
+
cost: 0,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
it('produces recorder-convention paths and a v3 sidecar the parser accepts', () => {
|
|
101
|
+
const plan = buildNarrationSavePlan({
|
|
102
|
+
script,
|
|
103
|
+
alignment,
|
|
104
|
+
durationSec: 10,
|
|
105
|
+
audioExt: '.webm',
|
|
106
|
+
cameraExt: '.webm',
|
|
107
|
+
baseWpm: 150,
|
|
108
|
+
cameraOffsetSec: 0.02,
|
|
109
|
+
});
|
|
110
|
+
expect(plan.audioRelativeName).toMatch(/^audio\/narration-.*\.webm$/);
|
|
111
|
+
expect(plan.cameraRelativeName).toMatch(/^video\/narration-cam.*\.webm$/);
|
|
112
|
+
expect(plan.sidecarPathFor('audio/renamed.webm')).toBe('audio/renamed.webm.timing.json');
|
|
113
|
+
|
|
114
|
+
const parsed = parseNarrationTimingJson(JSON.stringify(plan.sidecarPayload));
|
|
115
|
+
expect(parsed).not.toBeNull();
|
|
116
|
+
expect(parsed!.blocks.length).toBe(script.blocks.length);
|
|
117
|
+
expect(parsed!.bookmarks.length).toBe(script.tokens.length);
|
|
118
|
+
expect(parsed!.cameraOffsetSec).toBeCloseTo(0.02, 6);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('degrades to an empty-timing sidecar when alignment failed', () => {
|
|
122
|
+
const plan = buildNarrationSavePlan({
|
|
123
|
+
script,
|
|
124
|
+
alignment: null,
|
|
125
|
+
durationSec: 10,
|
|
126
|
+
audioExt: '.webm',
|
|
127
|
+
cameraExt: null,
|
|
128
|
+
baseWpm: 150,
|
|
129
|
+
});
|
|
130
|
+
expect(plan.cameraRelativeName).toBeNull();
|
|
131
|
+
expect(plan.sidecarPayload.blocks).toEqual([]);
|
|
132
|
+
expect(plan.sidecarPayload.bookmarks).toEqual([]);
|
|
133
|
+
expect(plan.sidecarPayload.sourceText).toBe(script.sourceText);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('nextMarkdown composes the preamble from one snapshot', () => {
|
|
137
|
+
const plan = buildNarrationSavePlan({
|
|
138
|
+
script,
|
|
139
|
+
alignment,
|
|
140
|
+
durationSec: 10,
|
|
141
|
+
audioExt: '.webm',
|
|
142
|
+
cameraExt: null,
|
|
143
|
+
baseWpm: 150,
|
|
144
|
+
});
|
|
145
|
+
const next = plan.nextMarkdown('# One\n\nAlpha beta.\n', 'audio/narration-x.webm', null);
|
|
146
|
+
expect(next).toContain('{[audio src=audio/narration-x.webm anchor=document]}');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { describe, expect, it, beforeAll } from 'vitest';
|
|
2
|
-
import { render, screen } from '@testing-library/react';
|
|
3
|
-
import { EditorProvider } from '../EditorContext';
|
|
1
|
+
import { describe, expect, it, beforeAll, vi } from 'vitest';
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { EditorProvider, useEditorContext } from '../EditorContext';
|
|
4
4
|
import { OutlinePanel } from '../OutlinePanel';
|
|
5
5
|
|
|
6
6
|
// jsdom lacks ResizeObserver — the pane wires one up to track the editor's
|
|
@@ -23,6 +23,33 @@ function renderOutline(markdown: string) {
|
|
|
23
23
|
);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function SourceProbe() {
|
|
27
|
+
const { markdownSource } = useEditorContext();
|
|
28
|
+
return <output data-testid="markdown-source">{markdownSource}</output>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderOutlineWithSource(markdown: string, readOnly = false) {
|
|
32
|
+
return render(
|
|
33
|
+
<EditorProvider initialMarkdown={markdown} initialView="wysiwyg" articleId="test">
|
|
34
|
+
<OutlinePanel readOnly={readOnly} />
|
|
35
|
+
<SourceProbe />
|
|
36
|
+
</EditorProvider>,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeDataTransfer(): DataTransfer {
|
|
41
|
+
const values = new Map<string, string>();
|
|
42
|
+
return {
|
|
43
|
+
effectAllowed: 'uninitialized',
|
|
44
|
+
dropEffect: 'none',
|
|
45
|
+
setData: vi.fn((type: string, value: string) => values.set(type, value)),
|
|
46
|
+
getData: vi.fn((type: string) => values.get(type) ?? ''),
|
|
47
|
+
get types() {
|
|
48
|
+
return [...values.keys()];
|
|
49
|
+
},
|
|
50
|
+
} as unknown as DataTransfer;
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
describe('OutlinePanel', () => {
|
|
27
54
|
it('renders the empty placeholder when the doc has no headings', async () => {
|
|
28
55
|
renderOutline('Just a paragraph, no headings.\n');
|
|
@@ -76,4 +103,67 @@ describe('OutlinePanel', () => {
|
|
|
76
103
|
expect(chip).not.toBeNull();
|
|
77
104
|
expect(chip!.textContent).toContain('Title');
|
|
78
105
|
});
|
|
106
|
+
|
|
107
|
+
it('drags a section after a sibling and rewrites the underlying markdown', async () => {
|
|
108
|
+
const source = '# Alpha\n\nalpha\n\n# Bravo\n\nbravo\n';
|
|
109
|
+
const expected = '# Bravo\n\nbravo\n\n# Alpha\n\nalpha\n';
|
|
110
|
+
const { container } = renderOutlineWithSource(source);
|
|
111
|
+
const alpha = await screen.findByRole('button', { name: 'Alpha' });
|
|
112
|
+
const bravo = await screen.findByRole('button', { name: 'Bravo' });
|
|
113
|
+
const bravoWrap = bravo.closest<HTMLElement>('.squisq-outline-row-wrap');
|
|
114
|
+
expect(bravoWrap).not.toBeNull();
|
|
115
|
+
vi.spyOn(bravoWrap!, 'getBoundingClientRect').mockReturnValue({
|
|
116
|
+
x: 0,
|
|
117
|
+
y: 0,
|
|
118
|
+
top: 0,
|
|
119
|
+
right: 200,
|
|
120
|
+
bottom: 20,
|
|
121
|
+
left: 0,
|
|
122
|
+
width: 200,
|
|
123
|
+
height: 20,
|
|
124
|
+
toJSON: () => ({}),
|
|
125
|
+
});
|
|
126
|
+
const transfer = makeDataTransfer();
|
|
127
|
+
|
|
128
|
+
fireEvent.dragStart(alpha, { dataTransfer: transfer });
|
|
129
|
+
expect(transfer.effectAllowed).toBe('move');
|
|
130
|
+
expect(transfer.setData).toHaveBeenCalledWith('text/plain', expect.any(String));
|
|
131
|
+
|
|
132
|
+
fireEvent.dragOver(bravoWrap!, { dataTransfer: transfer, clientY: 18 });
|
|
133
|
+
expect(transfer.dropEffect).toBe('move');
|
|
134
|
+
expect(bravo.closest('.squisq-outline-item')?.classList).toContain(
|
|
135
|
+
'squisq-outline-item--drop-after',
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
fireEvent.drop(bravoWrap!, { dataTransfer: transfer, clientY: 18 });
|
|
139
|
+
expect(container.querySelector('.squisq-outline-item--drop-after')).toBeNull();
|
|
140
|
+
|
|
141
|
+
await waitFor(() => {
|
|
142
|
+
expect(screen.getByTestId('markdown-source').textContent).toBe(expected);
|
|
143
|
+
const labels = Array.from(container.querySelectorAll('.squisq-outline-row-text')).map(
|
|
144
|
+
(row) => row.textContent,
|
|
145
|
+
);
|
|
146
|
+
expect(labels).toEqual(['Bravo', 'Alpha']);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('disables outline mutations in read-only mode', async () => {
|
|
151
|
+
const source = '# Alpha\n\nalpha\n\n# Bravo\n\nbravo\n';
|
|
152
|
+
renderOutlineWithSource(source, true);
|
|
153
|
+
const alpha = await screen.findByRole('button', { name: 'Alpha' });
|
|
154
|
+
const bravo = await screen.findByRole('button', { name: 'Bravo' });
|
|
155
|
+
const bravoWrap = bravo.closest<HTMLElement>('.squisq-outline-row-wrap');
|
|
156
|
+
const transfer = makeDataTransfer();
|
|
157
|
+
|
|
158
|
+
expect(alpha.getAttribute('draggable')).toBe('false');
|
|
159
|
+
expect(
|
|
160
|
+
screen
|
|
161
|
+
.getAllByRole('button', { name: /demote heading \(currently h1\)/i })[0]
|
|
162
|
+
.hasAttribute('disabled'),
|
|
163
|
+
).toBe(true);
|
|
164
|
+
fireEvent.dragStart(alpha, { dataTransfer: transfer });
|
|
165
|
+
fireEvent.drop(bravoWrap!, { dataTransfer: transfer, clientY: 100 });
|
|
166
|
+
|
|
167
|
+
expect(screen.getByTestId('markdown-source').textContent).toBe(source);
|
|
168
|
+
});
|
|
79
169
|
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { moveHeadingSectionInSource } from '../outlineSource';
|
|
3
|
+
|
|
4
|
+
function lineOf(source: string, heading: string): number {
|
|
5
|
+
const index = source.indexOf(heading);
|
|
6
|
+
if (index < 0) throw new Error(`Missing heading: ${heading}`);
|
|
7
|
+
return source.slice(0, index).split(/\r\n|\r|\n/).length;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('moveHeadingSectionInSource', () => {
|
|
11
|
+
it('moves a parent section with its body and nested subsections', () => {
|
|
12
|
+
const source = [
|
|
13
|
+
'# Document',
|
|
14
|
+
'',
|
|
15
|
+
'Intro.',
|
|
16
|
+
'',
|
|
17
|
+
'## First',
|
|
18
|
+
'',
|
|
19
|
+
'First body.',
|
|
20
|
+
'',
|
|
21
|
+
'### First child',
|
|
22
|
+
'',
|
|
23
|
+
'Child body.',
|
|
24
|
+
'',
|
|
25
|
+
'## Second',
|
|
26
|
+
'',
|
|
27
|
+
'Second body.',
|
|
28
|
+
'',
|
|
29
|
+
'### Second child',
|
|
30
|
+
'',
|
|
31
|
+
'Second child body.',
|
|
32
|
+
'',
|
|
33
|
+
'## Third',
|
|
34
|
+
'',
|
|
35
|
+
'Third body.',
|
|
36
|
+
'',
|
|
37
|
+
].join('\n');
|
|
38
|
+
|
|
39
|
+
const next = moveHeadingSectionInSource(
|
|
40
|
+
source,
|
|
41
|
+
lineOf(source, '## First'),
|
|
42
|
+
lineOf(source, '## Second'),
|
|
43
|
+
'after',
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(next).toBe(
|
|
47
|
+
[
|
|
48
|
+
'# Document',
|
|
49
|
+
'',
|
|
50
|
+
'Intro.',
|
|
51
|
+
'',
|
|
52
|
+
'## Second',
|
|
53
|
+
'',
|
|
54
|
+
'Second body.',
|
|
55
|
+
'',
|
|
56
|
+
'### Second child',
|
|
57
|
+
'',
|
|
58
|
+
'Second child body.',
|
|
59
|
+
'',
|
|
60
|
+
'## First',
|
|
61
|
+
'',
|
|
62
|
+
'First body.',
|
|
63
|
+
'',
|
|
64
|
+
'### First child',
|
|
65
|
+
'',
|
|
66
|
+
'Child body.',
|
|
67
|
+
'',
|
|
68
|
+
'## Third',
|
|
69
|
+
'',
|
|
70
|
+
'Third body.',
|
|
71
|
+
'',
|
|
72
|
+
].join('\n'),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('moves a later section before an earlier sibling', () => {
|
|
77
|
+
const source = '# A\n\na\n\n# B\n\nb\n\n# C\n\nc\n';
|
|
78
|
+
expect(
|
|
79
|
+
moveHeadingSectionInSource(source, lineOf(source, '# C'), lineOf(source, '# A'), 'before'),
|
|
80
|
+
).toBe('# C\n\nc\n\n# A\n\na\n\n# B\n\nb\n');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('preserves CRLF and the absence of a final newline', () => {
|
|
84
|
+
const source = '# A\r\n\r\na\r\n\r\n# B\r\n\r\nb';
|
|
85
|
+
expect(
|
|
86
|
+
moveHeadingSectionInSource(source, lineOf(source, '# B'), lineOf(source, '# A'), 'before'),
|
|
87
|
+
).toBe('# B\r\n\r\nb\r\n\r\n# A\r\n\r\na');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('leaves frontmatter and preamble bytes in place', () => {
|
|
91
|
+
const source = [
|
|
92
|
+
'---',
|
|
93
|
+
'title: Example',
|
|
94
|
+
'---',
|
|
95
|
+
'',
|
|
96
|
+
'Preamble stays here.',
|
|
97
|
+
'',
|
|
98
|
+
'# A {[sectionHeader]}',
|
|
99
|
+
'',
|
|
100
|
+
'a',
|
|
101
|
+
'',
|
|
102
|
+
'# B {#custom .wide}',
|
|
103
|
+
'',
|
|
104
|
+
'b',
|
|
105
|
+
'',
|
|
106
|
+
].join('\n');
|
|
107
|
+
const prefix = source.slice(0, source.indexOf('# A'));
|
|
108
|
+
|
|
109
|
+
const next = moveHeadingSectionInSource(
|
|
110
|
+
source,
|
|
111
|
+
lineOf(source, '# B'),
|
|
112
|
+
lineOf(source, '# A'),
|
|
113
|
+
'before',
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(next?.startsWith(prefix)).toBe(true);
|
|
117
|
+
expect(next).toContain('# B {#custom .wide}\n\nb\n\n# A {[sectionHeader]}\n\na\n');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('ignores heading lookalikes inside fenced code', () => {
|
|
121
|
+
const source = ['# A', '', '```md', '# not an outline heading', '```', '', '# B', '', 'b'].join(
|
|
122
|
+
'\n',
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const next = moveHeadingSectionInSource(
|
|
126
|
+
source,
|
|
127
|
+
lineOf(source, '# A'),
|
|
128
|
+
lineOf(source, '# B'),
|
|
129
|
+
'after',
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(next).toBe(
|
|
133
|
+
['# B', '', 'b', '', '# A', '', '```md', '# not an outline heading', '```'].join('\n'),
|
|
134
|
+
);
|
|
135
|
+
expect(
|
|
136
|
+
moveHeadingSectionInSource(
|
|
137
|
+
source,
|
|
138
|
+
lineOf(source, '# not an outline heading'),
|
|
139
|
+
lineOf(source, '# B'),
|
|
140
|
+
'before',
|
|
141
|
+
),
|
|
142
|
+
).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('supports duplicate heading text and Setext headings by source line', () => {
|
|
146
|
+
const source = [
|
|
147
|
+
'Document',
|
|
148
|
+
'========',
|
|
149
|
+
'',
|
|
150
|
+
'First duplicate',
|
|
151
|
+
'---------------',
|
|
152
|
+
'',
|
|
153
|
+
'one',
|
|
154
|
+
'',
|
|
155
|
+
'First duplicate',
|
|
156
|
+
'---------------',
|
|
157
|
+
'',
|
|
158
|
+
'two',
|
|
159
|
+
].join('\n');
|
|
160
|
+
const firstLine = lineOf(source, 'First duplicate');
|
|
161
|
+
const secondLine = source
|
|
162
|
+
.slice(0, source.lastIndexOf('First duplicate'))
|
|
163
|
+
.split(/\r\n|\r|\n/).length;
|
|
164
|
+
|
|
165
|
+
const next = moveHeadingSectionInSource(source, secondLine, firstLine, 'before');
|
|
166
|
+
|
|
167
|
+
expect(next).toBe(
|
|
168
|
+
[
|
|
169
|
+
'Document',
|
|
170
|
+
'========',
|
|
171
|
+
'',
|
|
172
|
+
'First duplicate',
|
|
173
|
+
'---------------',
|
|
174
|
+
'',
|
|
175
|
+
'two',
|
|
176
|
+
'',
|
|
177
|
+
'First duplicate',
|
|
178
|
+
'---------------',
|
|
179
|
+
'',
|
|
180
|
+
'one',
|
|
181
|
+
].join('\n'),
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('rejects hierarchy changes, stale lines, self-drops, and adjacent no-ops', () => {
|
|
186
|
+
const source = [
|
|
187
|
+
'# Parent A',
|
|
188
|
+
'',
|
|
189
|
+
'## Child A',
|
|
190
|
+
'',
|
|
191
|
+
'### Detail',
|
|
192
|
+
'',
|
|
193
|
+
'# Parent B',
|
|
194
|
+
'',
|
|
195
|
+
'## Child B',
|
|
196
|
+
'',
|
|
197
|
+
'### Skipped sibling',
|
|
198
|
+
'',
|
|
199
|
+
'## Last child',
|
|
200
|
+
].join('\n');
|
|
201
|
+
const parentA = lineOf(source, '# Parent A');
|
|
202
|
+
const parentB = lineOf(source, '# Parent B');
|
|
203
|
+
const childA = lineOf(source, '## Child A');
|
|
204
|
+
const childB = lineOf(source, '## Child B');
|
|
205
|
+
const detail = lineOf(source, '### Detail');
|
|
206
|
+
const skipped = lineOf(source, '### Skipped sibling');
|
|
207
|
+
const lastChild = lineOf(source, '## Last child');
|
|
208
|
+
|
|
209
|
+
expect(moveHeadingSectionInSource(source, parentA, parentA, 'before')).toBeNull();
|
|
210
|
+
expect(moveHeadingSectionInSource(source, childA, detail, 'before')).toBeNull();
|
|
211
|
+
expect(moveHeadingSectionInSource(source, childA, childB, 'before')).toBeNull();
|
|
212
|
+
expect(moveHeadingSectionInSource(source, skipped, lastChild, 'before')).toBeNull();
|
|
213
|
+
expect(moveHeadingSectionInSource(source, parentA, parentB, 'before')).toBeNull();
|
|
214
|
+
expect(moveHeadingSectionInSource(source, parentB, parentA, 'after')).toBeNull();
|
|
215
|
+
expect(moveHeadingSectionInSource(source, 999, parentA, 'before')).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
3
|
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
4
4
|
import { resolveTheme } from '@bendyline/squisq/schemas';
|
|
5
5
|
import { PlainHtmlPreview } from '../PlainHtmlPreview';
|
|
@@ -26,6 +26,22 @@ describe('PlainHtmlPreview', () => {
|
|
|
26
26
|
expect(sandbox).not.toContain('allow-scripts');
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
it('globally scrolls the iframe with up and down arrows when enabled', () => {
|
|
30
|
+
render(<PlainHtmlPreview markdown={'# Scroll me'} globalKeyboardShortcuts />);
|
|
31
|
+
const iframe = screen.getByTestId('plain-html-preview') as HTMLIFrameElement;
|
|
32
|
+
const scrollBy = vi.fn();
|
|
33
|
+
Object.defineProperty(iframe.contentWindow, 'scrollBy', {
|
|
34
|
+
configurable: true,
|
|
35
|
+
value: scrollBy,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
fireEvent.keyDown(document, { key: 'ArrowDown' });
|
|
39
|
+
fireEvent.keyDown(document, { key: 'ArrowUp' });
|
|
40
|
+
|
|
41
|
+
expect(scrollBy).toHaveBeenNthCalledWith(1, { top: 64, behavior: 'smooth' });
|
|
42
|
+
expect(scrollBy).toHaveBeenNthCalledWith(2, { top: -64, behavior: 'smooth' });
|
|
43
|
+
});
|
|
44
|
+
|
|
29
45
|
it('applies pre-resolved images via the images prop', async () => {
|
|
30
46
|
render(
|
|
31
47
|
<PlainHtmlPreview
|