@bendyline/squisq-editor-react 1.6.2 → 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 +1229 -179
- package/dist/index.js +18017 -11231
- 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 +1471 -245
- package/package.json +9 -5
- package/src/BlockPropertiesPopover.tsx +30 -15
- package/src/DocumentSettingsDialog.tsx +13 -21
- package/src/EditorContext.tsx +199 -42
- package/src/EditorShell.tsx +348 -260
- package/src/ImageEditor.tsx +22 -1
- package/src/InlinePreviewGutter.tsx +16 -8
- package/src/MediaBin.tsx +107 -14
- package/src/OutlinePanel.tsx +202 -7
- package/src/PlainHtmlPreview.tsx +40 -1
- package/src/PreviewControls.tsx +309 -69
- package/src/PreviewPanel.tsx +312 -78
- package/src/RawEditor.tsx +13 -2
- package/src/StatusBar.tsx +5 -2
- package/src/TemplateAnnotation.ts +12 -3
- package/src/TemplatePicker.tsx +75 -21
- package/src/ThemeCustomizerPanel.tsx +8 -0
- package/src/ThemePicker.tsx +10 -5
- package/src/TimelineBlockPreview.tsx +1 -1
- package/src/Toolbar.tsx +202 -41
- package/src/TransitionPicker.tsx +36 -4
- package/src/ViewMenuPanel.tsx +12 -14
- package/src/WysiwygEditor.tsx +124 -91
- 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 +21 -1
- 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 +123 -1
- package/src/__tests__/emojiPicker.test.tsx +2 -42
- package/src/__tests__/headingTransition.test.ts +0 -12
- package/src/__tests__/imageEditAffordance.test.tsx +5 -1
- package/src/__tests__/imageEditModalContract.test.tsx +119 -0
- package/src/__tests__/imageEditorShell.test.tsx +15 -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__/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 +218 -5
- 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 +26 -0
- package/src/__tests__/templatePickerPortal.test.tsx +62 -0
- package/src/__tests__/tiptapBridge.test.ts +23 -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 +0 -15
- 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 +191 -19
- 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/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 +48 -18
- package/src/scene/SceneBlockExtension.ts +17 -10
- package/src/scene/SceneBlockWidget.tsx +5 -1
- 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 +21 -7
- package/src/scene/layers/edgeGeometry.ts +9 -1
- package/src/scene/layers/nodeCard.tsx +27 -8
- package/src/scene/scene.css +5 -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 +13 -4
- package/src/scene/tools/SceneTool.ts +8 -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 +706 -120
- 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 +12 -5
- 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 +24 -16
- 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 -272
- package/src/diagram/useDiagramData.ts +0 -126
package/src/WysiwygEditor.tsx
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
14
14
|
import type { CSSProperties } from 'react';
|
|
15
15
|
import { useEditor, EditorContent } from '@tiptap/react';
|
|
16
|
+
import { Selection } from '@tiptap/pm/state';
|
|
17
|
+
import type { EditorView as ProseMirrorView } from '@tiptap/pm/view';
|
|
16
18
|
import StarterKit from '@tiptap/starter-kit';
|
|
17
19
|
import Table from '@tiptap/extension-table';
|
|
18
20
|
import TableRow from '@tiptap/extension-table-row';
|
|
@@ -26,7 +28,15 @@ import { resolveFontFamily, FONT_FALLBACKS, VIEWPORT_PRESETS } from '@bendyline/
|
|
|
26
28
|
import { DEFAULT_THEME, flattenBlocks, markdownToDoc } from '@bendyline/squisq/doc';
|
|
27
29
|
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
28
30
|
import { HeadingWithTemplate } from './TemplateAnnotation';
|
|
29
|
-
import {
|
|
31
|
+
import { AsciiDiagramExtension } from './asciiDiagram/AsciiDiagramExtension';
|
|
32
|
+
import { RepairableDiagramExtension } from './asciiDiagram/RepairableDiagramExtension';
|
|
33
|
+
import { applyRepairCommand } from './asciiDiagram/asciiDiagramCommands';
|
|
34
|
+
import { shouldPasteAsAsciiFence } from './asciiDiagram/asciiPaste';
|
|
35
|
+
import { TreeViewExtension } from './treeview/TreeViewExtension';
|
|
36
|
+
import { shouldPasteAsTreeFence } from './treeview/treePaste';
|
|
37
|
+
import { TimelineViewExtension } from './timeline/TimelineViewExtension';
|
|
38
|
+
import { shouldPasteAsTimelineFence } from './timeline/timelinePaste';
|
|
39
|
+
import { BlockTagActivityExtension } from './blockTagActivity';
|
|
30
40
|
import { SceneBlockExtension } from './scene/SceneBlockExtension';
|
|
31
41
|
import { InlineIcon } from './InlineIcon';
|
|
32
42
|
import { ImageWithMediaProvider } from './ImageNodeView';
|
|
@@ -51,6 +61,9 @@ import { markdownToTiptap, tiptapToMarkdown } from './tiptapBridge';
|
|
|
51
61
|
import { looksLikeMarkdown } from './detectMarkdown';
|
|
52
62
|
import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
|
|
53
63
|
import { usePreviewSettingsOptional } from './PreviewControls';
|
|
64
|
+
import { uploadAndInsertImages } from './wysiwygImageUpload';
|
|
65
|
+
|
|
66
|
+
type ImageMutationView = Pick<ProseMirrorView, 'state' | 'dispatch'>;
|
|
54
67
|
|
|
55
68
|
/**
|
|
56
69
|
* Rotating placeholder prompts shown when the editor is empty. One is
|
|
@@ -70,6 +83,12 @@ const EMPTY_PROMPTS = [
|
|
|
70
83
|
'Begin at the beginning…',
|
|
71
84
|
];
|
|
72
85
|
|
|
86
|
+
const BLOCK_TAG_DATA_VALUES = {
|
|
87
|
+
none: 'hidden',
|
|
88
|
+
active: 'active',
|
|
89
|
+
always: 'visible',
|
|
90
|
+
} as const;
|
|
91
|
+
|
|
73
92
|
function pickEmptyPrompt(): string {
|
|
74
93
|
return EMPTY_PROMPTS[Math.floor(Math.random() * EMPTY_PROMPTS.length)];
|
|
75
94
|
}
|
|
@@ -108,10 +127,11 @@ export function WysiwygEditor({
|
|
|
108
127
|
setTiptapEditor,
|
|
109
128
|
mediaProvider,
|
|
110
129
|
mentionProvider,
|
|
111
|
-
|
|
130
|
+
blockTagVisibility,
|
|
112
131
|
themeInheritance,
|
|
113
132
|
colorScheme,
|
|
114
133
|
bumpMediaRevision,
|
|
134
|
+
sceneTextChannel,
|
|
115
135
|
} = useEditorContext();
|
|
116
136
|
// Custom templates inlined in the active doc's frontmatter + the
|
|
117
137
|
// persist callback that writes a new list back into the source.
|
|
@@ -166,7 +186,6 @@ export function WysiwygEditor({
|
|
|
166
186
|
useEffect(() => {
|
|
167
187
|
submitOnEnterRef.current = submitOnEnter;
|
|
168
188
|
}, [submitOnEnter]);
|
|
169
|
-
|
|
170
189
|
const editor = useEditor({
|
|
171
190
|
editable: !readOnly,
|
|
172
191
|
extensions: [
|
|
@@ -178,8 +197,12 @@ export function WysiwygEditor({
|
|
|
178
197
|
},
|
|
179
198
|
}),
|
|
180
199
|
HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
181
|
-
|
|
182
|
-
|
|
200
|
+
BlockTagActivityExtension,
|
|
201
|
+
AsciiDiagramExtension.configure({ textChannel: sceneTextChannel }),
|
|
202
|
+
RepairableDiagramExtension.configure({ onRepair: applyRepairCommand }),
|
|
203
|
+
TimelineViewExtension,
|
|
204
|
+
TreeViewExtension,
|
|
205
|
+
SceneBlockExtension.configure({ textChannel: sceneTextChannel }),
|
|
183
206
|
Table.configure({ resizable: true }),
|
|
184
207
|
TableRow,
|
|
185
208
|
TableCell,
|
|
@@ -201,8 +224,13 @@ export function WysiwygEditor({
|
|
|
201
224
|
content: markdownToTiptap(stripFrontmatter(editorSource).body),
|
|
202
225
|
onUpdate: ({ editor: ed }) => {
|
|
203
226
|
if (isExternalUpdate.current) return;
|
|
204
|
-
|
|
205
|
-
|
|
227
|
+
// Keep the source channel synchronous with Tiptap. Structural actions
|
|
228
|
+
// such as block navigation/addition may run in the very next browser
|
|
229
|
+
// event; deferring this write lets a stale callback serialize the newly
|
|
230
|
+
// selected block into the previous block's range. The expensive full-doc
|
|
231
|
+
// parse remains debounced in EditorContext, so correctness here does not
|
|
232
|
+
// reintroduce parse-on-every-keystroke work.
|
|
233
|
+
const bodyMd = tiptapToMarkdown(ed.getHTML());
|
|
206
234
|
const newSource = frontmatterRef.current + bodyMd;
|
|
207
235
|
lastSourceRef.current = newSource;
|
|
208
236
|
setEditorSource(newSource);
|
|
@@ -225,7 +253,9 @@ export function WysiwygEditor({
|
|
|
225
253
|
// its container with `display: block` while items are showing
|
|
226
254
|
// and `display: none` when empty — only short-circuit when
|
|
227
255
|
// there's actually a candidate to pick.
|
|
228
|
-
const popover =
|
|
256
|
+
const popover = view.dom
|
|
257
|
+
.closest('.squisq-editor-shell')
|
|
258
|
+
?.querySelector<HTMLElement>('.squisq-mention-popover');
|
|
229
259
|
if (popover && popover.style.display !== 'none') {
|
|
230
260
|
return false;
|
|
231
261
|
}
|
|
@@ -266,7 +296,62 @@ export function WysiwygEditor({
|
|
|
266
296
|
}
|
|
267
297
|
|
|
268
298
|
const text = clipboard.getData('text/plain');
|
|
269
|
-
if (!text
|
|
299
|
+
if (!text) return false;
|
|
300
|
+
|
|
301
|
+
// Bare (unfenced) ASCII diagram art → verbatim into a fresh code
|
|
302
|
+
// block, which AsciiDiagramExtension turns into an interactive
|
|
303
|
+
// canvas. Must run BEFORE the markdown branch: `| x |` art rows
|
|
304
|
+
// false-positive as GFM table rows and would be mangled.
|
|
305
|
+
if (shouldPasteAsAsciiFence(text)) {
|
|
306
|
+
const codeBlockType = view.state.schema.nodes.codeBlock;
|
|
307
|
+
if (codeBlockType) {
|
|
308
|
+
event.preventDefault();
|
|
309
|
+
// Tag the fence `diagram` so its identity survives a later
|
|
310
|
+
// flatten → markdown → re-import round-trip (the language class
|
|
311
|
+
// round-trips; fence meta does not).
|
|
312
|
+
view.dispatch(
|
|
313
|
+
view.state.tr.replaceSelectionWith(
|
|
314
|
+
codeBlockType.create({ language: 'diagram' }, view.state.schema.text(text)),
|
|
315
|
+
),
|
|
316
|
+
);
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Bare (unfenced) marker rails → an explicit timeline code block.
|
|
322
|
+
// Run before the tree gate because timeline callout elbows can look
|
|
323
|
+
// like nested tree connectors to a deliberately lenient parser.
|
|
324
|
+
if (shouldPasteAsTimelineFence(text)) {
|
|
325
|
+
const codeBlockType = view.state.schema.nodes.codeBlock;
|
|
326
|
+
if (codeBlockType) {
|
|
327
|
+
event.preventDefault();
|
|
328
|
+
view.dispatch(
|
|
329
|
+
view.state.tr.replaceSelectionWith(
|
|
330
|
+
codeBlockType.create({ language: 'timeline' }, view.state.schema.text(text)),
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Bare (unfenced) ASCII tree art → a fresh code block that the
|
|
338
|
+
// TreeViewExtension turns into an outline. After the diagram gate,
|
|
339
|
+
// before markdown (the `├──` lines must not be mangled as prose).
|
|
340
|
+
if (shouldPasteAsTreeFence(text)) {
|
|
341
|
+
const codeBlockType = view.state.schema.nodes.codeBlock;
|
|
342
|
+
if (codeBlockType) {
|
|
343
|
+
event.preventDefault();
|
|
344
|
+
// Tag the fence `tree` so its identity survives round-trips.
|
|
345
|
+
view.dispatch(
|
|
346
|
+
view.state.tr.replaceSelectionWith(
|
|
347
|
+
codeBlockType.create({ language: 'tree' }, view.state.schema.text(text)),
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!looksLikeMarkdown(text)) return false;
|
|
270
355
|
const html = markdownToTiptap(text);
|
|
271
356
|
if (!html) return false;
|
|
272
357
|
event.preventDefault();
|
|
@@ -370,6 +455,16 @@ export function WysiwygEditor({
|
|
|
370
455
|
templateParams: string | null;
|
|
371
456
|
} | null>(null);
|
|
372
457
|
|
|
458
|
+
const closeBadgeMenu = useCallback(() => {
|
|
459
|
+
setBadgeMenu(null);
|
|
460
|
+
// The portaled gallery moves focus into its search field. Restore the
|
|
461
|
+
// document's preserved ProseMirror selection after React has unmounted the
|
|
462
|
+
// dialog, so the caret resumes blinking where the author left it.
|
|
463
|
+
requestAnimationFrame(() => {
|
|
464
|
+
if (editor && !editor.isDestroyed) editor.commands.focus();
|
|
465
|
+
});
|
|
466
|
+
}, [editor]);
|
|
467
|
+
|
|
373
468
|
useEffect(() => {
|
|
374
469
|
if (!editor) return;
|
|
375
470
|
const root = containerRef.current;
|
|
@@ -460,9 +555,10 @@ export function WysiwygEditor({
|
|
|
460
555
|
isExternalUpdate.current = false;
|
|
461
556
|
}, [editorSource, editor]);
|
|
462
557
|
|
|
463
|
-
// Match the WYSIWYG editor's appearance to the active Squisq theme
|
|
464
|
-
//
|
|
465
|
-
//
|
|
558
|
+
// Match the WYSIWYG editor's appearance to the active Squisq theme when one
|
|
559
|
+
// is set in frontmatter or picked in the preview dropdown. The block-props
|
|
560
|
+
// affordance always carries the theme's primary as UI accent (like the
|
|
561
|
+
// outline); document-content inheritance is driven by the View menu setting:
|
|
466
562
|
// - 'none' → don't inherit anything
|
|
467
563
|
// - 'fonts' → body + heading fonts only (historical default)
|
|
468
564
|
// - 'fonts-colors' → fonts plus the theme's canvas / text colors
|
|
@@ -483,20 +579,26 @@ export function WysiwygEditor({
|
|
|
483
579
|
viewport: previewSettings?.activeViewport ?? VIEWPORT_PRESETS.landscape,
|
|
484
580
|
basePath: '/',
|
|
485
581
|
mediaProvider,
|
|
582
|
+
customTemplates: docTemplates,
|
|
486
583
|
};
|
|
487
584
|
} catch {
|
|
488
585
|
return undefined;
|
|
489
586
|
}
|
|
490
587
|
}, [
|
|
491
588
|
badgeMenu,
|
|
589
|
+
docTemplates,
|
|
492
590
|
editorSource,
|
|
493
591
|
mediaProvider,
|
|
494
592
|
previewSettings?.activeTheme,
|
|
495
593
|
previewSettings?.activeViewport,
|
|
496
594
|
]);
|
|
497
595
|
const themeStyle = useMemo<CSSProperties>(() => {
|
|
498
|
-
if (
|
|
596
|
+
if (!activeTheme) return {};
|
|
499
597
|
const out: Record<string, string> = {
|
|
598
|
+
'--squisq-block-props-accent': activeTheme.colors.primary,
|
|
599
|
+
};
|
|
600
|
+
if (themeInheritance === 'none') return out as CSSProperties;
|
|
601
|
+
Object.assign(out, {
|
|
500
602
|
'--squisq-theme-body-font': resolveFontFamily(
|
|
501
603
|
activeTheme.typography.bodyFont,
|
|
502
604
|
FONT_FALLBACKS.sans,
|
|
@@ -505,7 +607,7 @@ export function WysiwygEditor({
|
|
|
505
607
|
activeTheme.typography.titleFont,
|
|
506
608
|
FONT_FALLBACKS.sans,
|
|
507
609
|
),
|
|
508
|
-
};
|
|
610
|
+
});
|
|
509
611
|
if (themeInheritance === 'fonts-colors') {
|
|
510
612
|
const colors = activeTheme.colors;
|
|
511
613
|
out['--squisq-theme-bg'] = colors.background;
|
|
@@ -525,7 +627,7 @@ export function WysiwygEditor({
|
|
|
525
627
|
className={`squisq-wysiwyg-container${className ? ` ${className}` : ''}`}
|
|
526
628
|
style={{ width: '100%', height: '100%', overflow: 'auto', ...themeStyle }}
|
|
527
629
|
data-testid="wysiwyg-container"
|
|
528
|
-
data-block-tags={
|
|
630
|
+
data-block-tags={BLOCK_TAG_DATA_VALUES[blockTagVisibility]}
|
|
529
631
|
data-theme-inheritance={themeInheritance}
|
|
530
632
|
ref={containerRef}
|
|
531
633
|
>
|
|
@@ -557,7 +659,7 @@ export function WysiwygEditor({
|
|
|
557
659
|
});
|
|
558
660
|
editor.view.dispatch(tr);
|
|
559
661
|
}}
|
|
560
|
-
onClose={
|
|
662
|
+
onClose={closeBadgeMenu}
|
|
561
663
|
/>
|
|
562
664
|
)}
|
|
563
665
|
{propsMenu && (
|
|
@@ -565,6 +667,8 @@ export function WysiwygEditor({
|
|
|
565
667
|
anchorRect={propsMenu.rect}
|
|
566
668
|
blockAttrs={propsMenu.blockAttrs}
|
|
567
669
|
templateParams={propsMenu.templateParams}
|
|
670
|
+
colorScheme={colorScheme}
|
|
671
|
+
accentColor={activeTheme?.colors.primary}
|
|
568
672
|
onChange={(nextInner) => {
|
|
569
673
|
if (!editor) return;
|
|
570
674
|
const current = editor.state.doc.nodeAt(propsMenu.headingPos);
|
|
@@ -595,6 +699,7 @@ export function WysiwygEditor({
|
|
|
595
699
|
onSave={handleDesignerSave}
|
|
596
700
|
onClose={() => setDesignerState(null)}
|
|
597
701
|
mediaProvider={mediaProvider}
|
|
702
|
+
colorScheme={colorScheme}
|
|
598
703
|
/>
|
|
599
704
|
)}
|
|
600
705
|
</div>
|
|
@@ -661,43 +766,8 @@ function filesFromClipboard(clipboard: DataTransfer): File[] {
|
|
|
661
766
|
return files;
|
|
662
767
|
}
|
|
663
768
|
|
|
664
|
-
/**
|
|
665
|
-
* Upload image files to the MediaProvider and insert <img> nodes at the
|
|
666
|
-
* current selection. Inserts a placeholder name when files lack one
|
|
667
|
-
* (e.g., screenshots from the system clipboard).
|
|
668
|
-
*/
|
|
669
|
-
async function uploadAndInsertImages(
|
|
670
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
671
|
-
view: any,
|
|
672
|
-
files: File[],
|
|
673
|
-
mediaProvider: import('@bendyline/squisq/schemas').MediaProvider,
|
|
674
|
-
onMediaUploaded?: () => void,
|
|
675
|
-
): Promise<void> {
|
|
676
|
-
for (const file of files) {
|
|
677
|
-
try {
|
|
678
|
-
const buffer = await file.arrayBuffer();
|
|
679
|
-
const mimeType = file.type || 'image/png';
|
|
680
|
-
const name =
|
|
681
|
-
file.name && file.name !== 'image.png'
|
|
682
|
-
? file.name
|
|
683
|
-
: `pasted-${uniquePasteToken()}.${extFromMime(mimeType)}`;
|
|
684
|
-
const relativePath = await mediaProvider.addMedia(name, buffer, mimeType);
|
|
685
|
-
const altText = name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
|
|
686
|
-
insertImageNode(view, relativePath, altText);
|
|
687
|
-
onMediaUploaded?.();
|
|
688
|
-
} catch (err) {
|
|
689
|
-
console.error('Failed to upload dropped image:', err);
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
|
|
694
769
|
/** Insert an image node at the current selection using the schema image type. */
|
|
695
|
-
function insertImageNode(
|
|
696
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
697
|
-
view: any,
|
|
698
|
-
src: string,
|
|
699
|
-
alt: string,
|
|
700
|
-
): void {
|
|
770
|
+
function insertImageNode(view: ImageMutationView, src: string, alt: string): void {
|
|
701
771
|
const { schema } = view.state;
|
|
702
772
|
const imageType = schema.nodes.image;
|
|
703
773
|
if (!imageType) return;
|
|
@@ -707,50 +777,13 @@ function insertImageNode(
|
|
|
707
777
|
}
|
|
708
778
|
|
|
709
779
|
/** Move the selection to the document position under the drop event's coordinates. */
|
|
710
|
-
function moveSelectionToDropPoint(
|
|
711
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
712
|
-
view: any,
|
|
713
|
-
event: DragEvent,
|
|
714
|
-
): void {
|
|
780
|
+
function moveSelectionToDropPoint(view: ProseMirrorView, event: DragEvent): void {
|
|
715
781
|
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
|
716
782
|
if (!coords) return;
|
|
717
|
-
const tr = view.state.tr.setSelection(
|
|
718
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
719
|
-
(view.state.selection.constructor as any).near(view.state.doc.resolve(coords.pos)),
|
|
720
|
-
);
|
|
783
|
+
const tr = view.state.tr.setSelection(Selection.near(view.state.doc.resolve(coords.pos)));
|
|
721
784
|
view.dispatch(tr);
|
|
722
785
|
}
|
|
723
786
|
|
|
724
|
-
/**
|
|
725
|
-
* Produce a unique token for a pasted-file name. `Date.now()` alone can
|
|
726
|
-
* collide when a user pastes several clipboard images in the same tick
|
|
727
|
-
* (multi-image paste from a screenshot grid, for example), which would make
|
|
728
|
-
* `MediaProvider.addMedia` overwrite or reject later entries. Prefer
|
|
729
|
-
* `crypto.randomUUID()` when available and fall back to a counter so the
|
|
730
|
-
* helper stays pure-JS-everywhere.
|
|
731
|
-
*/
|
|
732
|
-
let pasteCounter = 0;
|
|
733
|
-
function uniquePasteToken(): string {
|
|
734
|
-
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
735
|
-
return crypto.randomUUID();
|
|
736
|
-
}
|
|
737
|
-
pasteCounter = (pasteCounter + 1) % 1_000_000;
|
|
738
|
-
return `${Date.now()}-${pasteCounter.toString(36)}`;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
function extFromMime(mime: string): string {
|
|
742
|
-
const map: Record<string, string> = {
|
|
743
|
-
'image/png': 'png',
|
|
744
|
-
'image/jpeg': 'jpg',
|
|
745
|
-
'image/jpg': 'jpg',
|
|
746
|
-
'image/gif': 'gif',
|
|
747
|
-
'image/webp': 'webp',
|
|
748
|
-
'image/svg+xml': 'svg',
|
|
749
|
-
'image/avif': 'avif',
|
|
750
|
-
};
|
|
751
|
-
return map[mime.toLowerCase()] ?? 'png';
|
|
752
|
-
}
|
|
753
|
-
|
|
754
787
|
/**
|
|
755
788
|
* Hook to access the Tiptap editor instance for toolbar commands.
|
|
756
789
|
* The WysiwygEditor must be mounted as a sibling or descendant.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { BlockPropertiesPopover } from '../BlockPropertiesPopover';
|
|
4
|
+
import { TRANSITION_FLYOUT_PORTAL_ID } from '../TransitionPicker';
|
|
5
|
+
|
|
6
|
+
describe('BlockPropertiesPopover theme propagation', () => {
|
|
7
|
+
it('copies dark mode and the document accent onto both portal roots', async () => {
|
|
8
|
+
render(
|
|
9
|
+
<BlockPropertiesPopover
|
|
10
|
+
anchorRect={new DOMRect(20, 20, 20, 20)}
|
|
11
|
+
blockAttrs={null}
|
|
12
|
+
templateParams={null}
|
|
13
|
+
colorScheme="dark"
|
|
14
|
+
accentColor="#c2410c"
|
|
15
|
+
onChange={vi.fn()}
|
|
16
|
+
onAnnotationChange={vi.fn()}
|
|
17
|
+
onClose={vi.fn()}
|
|
18
|
+
/>,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const panel = screen.getByRole('dialog', { name: 'Block properties' });
|
|
22
|
+
expect(panel.getAttribute('data-theme')).toBe('dark');
|
|
23
|
+
expect(panel.style.getPropertyValue('--squisq-block-props-accent')).toBe('#c2410c');
|
|
24
|
+
|
|
25
|
+
fireEvent.click(screen.getByRole('button', { name: /Transition:/ }));
|
|
26
|
+
|
|
27
|
+
await waitFor(() => {
|
|
28
|
+
const flyout = document.getElementById(TRANSITION_FLYOUT_PORTAL_ID);
|
|
29
|
+
expect(flyout?.getAttribute('data-theme')).toBe('dark');
|
|
30
|
+
expect(flyout?.style.getPropertyValue('--squisq-block-props-accent')).toBe('#c2410c');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Editor } from '@tiptap/core';
|
|
2
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
BlockTagActivityExtension,
|
|
6
|
+
BLOCK_TAG_HOVERED_CLASS,
|
|
7
|
+
BLOCK_TAG_SELECTED_CLASS,
|
|
8
|
+
} from '../blockTagActivity';
|
|
9
|
+
import { HeadingWithTemplate } from '../TemplateAnnotation';
|
|
10
|
+
import { markdownToTiptap } from '../tiptapBridge';
|
|
11
|
+
|
|
12
|
+
const MARKDOWN = `# First block {[title]}
|
|
13
|
+
|
|
14
|
+
First body.
|
|
15
|
+
|
|
16
|
+
- Outer item with **nested inline target**
|
|
17
|
+
|
|
18
|
+
# Second block {[quote]}
|
|
19
|
+
|
|
20
|
+
Second body.
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
const editors: Editor[] = [];
|
|
24
|
+
const editorHosts: HTMLElement[] = [];
|
|
25
|
+
|
|
26
|
+
function makeEditor(): Editor {
|
|
27
|
+
const element = document.createElement('div');
|
|
28
|
+
document.body.appendChild(element);
|
|
29
|
+
editorHosts.push(element);
|
|
30
|
+
|
|
31
|
+
const editor = new Editor({
|
|
32
|
+
element,
|
|
33
|
+
extensions: [
|
|
34
|
+
StarterKit.configure({ heading: false }),
|
|
35
|
+
HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
36
|
+
BlockTagActivityExtension,
|
|
37
|
+
],
|
|
38
|
+
content: markdownToTiptap(MARKDOWN),
|
|
39
|
+
});
|
|
40
|
+
editors.push(editor);
|
|
41
|
+
return editor;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function positionInsideTopLevelNode(editor: Editor, text: string): number {
|
|
45
|
+
let position: number | null = null;
|
|
46
|
+
editor.state.doc.forEach((node, offset) => {
|
|
47
|
+
if (position === null && node.textContent === text) position = offset + 1;
|
|
48
|
+
});
|
|
49
|
+
if (position === null) throw new Error(`Top-level node not found: ${text}`);
|
|
50
|
+
return position;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function headings(editor: Editor): HTMLElement[] {
|
|
54
|
+
return Array.from(editor.view.dom.children).filter((element): element is HTMLElement =>
|
|
55
|
+
/^H[1-6]$/.test(element.tagName),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function elementWithExactText(editor: Editor, selector: string, text: string): HTMLElement {
|
|
60
|
+
const element = Array.from(editor.view.dom.querySelectorAll<HTMLElement>(selector)).find(
|
|
61
|
+
(candidate) => candidate.textContent === text,
|
|
62
|
+
);
|
|
63
|
+
if (!element) throw new Error(`Element not found: ${selector} containing ${text}`);
|
|
64
|
+
return element;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
for (const editor of editors.splice(0)) editor.destroy();
|
|
69
|
+
for (const host of editorHosts.splice(0)) host.remove();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function waitForDomReconciliation(): Promise<void> {
|
|
73
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('block-tag activity', () => {
|
|
77
|
+
it('decorates the initial selection before the editor view settles', async () => {
|
|
78
|
+
const editor = makeEditor();
|
|
79
|
+
await waitForDomReconciliation();
|
|
80
|
+
|
|
81
|
+
const [firstHeading] = headings(editor);
|
|
82
|
+
expect(firstHeading.isConnected).toBe(true);
|
|
83
|
+
expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('keeps cursor and hover decorations through DOM reconciliation', async () => {
|
|
87
|
+
const editor = makeEditor();
|
|
88
|
+
editor.commands.setTextSelection(positionInsideTopLevelNode(editor, 'First body.'));
|
|
89
|
+
await waitForDomReconciliation();
|
|
90
|
+
|
|
91
|
+
const [firstHeading, secondHeading] = headings(editor);
|
|
92
|
+
expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
|
|
93
|
+
expect(secondHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(false);
|
|
94
|
+
|
|
95
|
+
const secondBody = elementWithExactText(editor, 'p', 'Second body.');
|
|
96
|
+
secondBody.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
|
97
|
+
await waitForDomReconciliation();
|
|
98
|
+
|
|
99
|
+
expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
|
|
100
|
+
expect(firstHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
|
|
101
|
+
expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(true);
|
|
102
|
+
expect(secondHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(false);
|
|
103
|
+
|
|
104
|
+
editor.view.dom.dispatchEvent(new MouseEvent('mouseleave'));
|
|
105
|
+
await waitForDomReconciliation();
|
|
106
|
+
expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
|
|
107
|
+
expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
|
|
108
|
+
|
|
109
|
+
editor.commands.setTextSelection(positionInsideTopLevelNode(editor, 'Second body.'));
|
|
110
|
+
await waitForDomReconciliation();
|
|
111
|
+
expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(false);
|
|
112
|
+
expect(secondHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('maps a nested list target to the heading that owns the list', async () => {
|
|
116
|
+
const editor = makeEditor();
|
|
117
|
+
editor.commands.setTextSelection(positionInsideTopLevelNode(editor, 'Second body.'));
|
|
118
|
+
await waitForDomReconciliation();
|
|
119
|
+
|
|
120
|
+
const [firstHeading, secondHeading] = headings(editor);
|
|
121
|
+
const nestedTarget = elementWithExactText(editor, 'strong', 'nested inline target');
|
|
122
|
+
nestedTarget.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
|
123
|
+
await waitForDomReconciliation();
|
|
124
|
+
|
|
125
|
+
expect(firstHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(true);
|
|
126
|
+
expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(false);
|
|
127
|
+
expect(secondHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
|
|
128
|
+
expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('keeps the owning block hovered across blank space between its nodes', async () => {
|
|
132
|
+
const editor = makeEditor();
|
|
133
|
+
await waitForDomReconciliation();
|
|
134
|
+
|
|
135
|
+
const [firstHeading, secondHeading] = headings(editor);
|
|
136
|
+
const topLevelChildren = Array.from(editor.view.dom.children);
|
|
137
|
+
const firstHeadingIndex = topLevelChildren.indexOf(firstHeading);
|
|
138
|
+
const secondHeadingIndex = topLevelChildren.indexOf(secondHeading);
|
|
139
|
+
|
|
140
|
+
topLevelChildren.forEach((element, index) => {
|
|
141
|
+
const top =
|
|
142
|
+
index < secondHeadingIndex
|
|
143
|
+
? 10 + (index - firstHeadingIndex) * 25
|
|
144
|
+
: 120 + (index - secondHeadingIndex) * 25;
|
|
145
|
+
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
|
|
146
|
+
top,
|
|
147
|
+
bottom: top + 18,
|
|
148
|
+
} as DOMRect);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
editor.view.dom.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 105 }));
|
|
152
|
+
await waitForDomReconciliation();
|
|
153
|
+
|
|
154
|
+
expect(firstHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(true);
|
|
155
|
+
expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
|
|
156
|
+
|
|
157
|
+
editor.view.dom.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 145 }));
|
|
158
|
+
await waitForDomReconciliation();
|
|
159
|
+
|
|
160
|
+
expect(firstHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
|
|
161
|
+
expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('clears block hover below the final content node', async () => {
|
|
165
|
+
const editor = makeEditor();
|
|
166
|
+
await waitForDomReconciliation();
|
|
167
|
+
|
|
168
|
+
const [, secondHeading] = headings(editor);
|
|
169
|
+
const topLevelChildren = Array.from(editor.view.dom.children);
|
|
170
|
+
topLevelChildren.forEach((element, index) => {
|
|
171
|
+
const top = index * 25;
|
|
172
|
+
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
|
|
173
|
+
top,
|
|
174
|
+
bottom: top + 18,
|
|
175
|
+
} as DOMRect);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
editor.view.dom.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 1000 }));
|
|
179
|
+
await waitForDomReconciliation();
|
|
180
|
+
|
|
181
|
+
expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { Block, Doc } from '@bendyline/squisq/schemas';
|
|
3
|
+
import { buildDocumentPreviewMarkdown } from '../buildDocumentPreviewMarkdown';
|
|
4
|
+
|
|
5
|
+
function docWith(blocks: Block[]): Doc {
|
|
6
|
+
return {
|
|
7
|
+
articleId: 'summary-doc',
|
|
8
|
+
duration: 10,
|
|
9
|
+
blocks,
|
|
10
|
+
audio: { segments: [] },
|
|
11
|
+
frontmatter: { title: 'Quarterly Report' },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('buildDocumentPreviewMarkdown', () => {
|
|
16
|
+
it('serializes programmatic transform blocks as readable document content', () => {
|
|
17
|
+
const markdown = buildDocumentPreviewMarkdown(
|
|
18
|
+
docWith([
|
|
19
|
+
{
|
|
20
|
+
id: 'transform-stat',
|
|
21
|
+
template: 'statHighlight',
|
|
22
|
+
startTime: 0,
|
|
23
|
+
duration: 5,
|
|
24
|
+
audioSegment: 0,
|
|
25
|
+
stat: '42%',
|
|
26
|
+
description: 'Year-over-year growth',
|
|
27
|
+
} as Block,
|
|
28
|
+
{
|
|
29
|
+
id: 'transform-list',
|
|
30
|
+
template: 'list',
|
|
31
|
+
startTime: 5,
|
|
32
|
+
duration: 5,
|
|
33
|
+
audioSegment: 0,
|
|
34
|
+
title: 'Priorities',
|
|
35
|
+
items: ['Reliability', 'Speed'],
|
|
36
|
+
} as Block,
|
|
37
|
+
]),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(markdown).toContain('# Quarterly Report');
|
|
41
|
+
expect(markdown).toContain('## 42%');
|
|
42
|
+
expect(markdown).toContain('Year-over-year growth');
|
|
43
|
+
expect(markdown).toContain('## Priorities');
|
|
44
|
+
expect(markdown).toContain('Reliability');
|
|
45
|
+
expect(markdown).toContain('Speed');
|
|
46
|
+
expect(markdown).not.toContain('{[statHighlight');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('preserves authored blocks that a conservative transform kept', () => {
|
|
50
|
+
const markdown = buildDocumentPreviewMarkdown(
|
|
51
|
+
docWith([
|
|
52
|
+
{
|
|
53
|
+
id: 'authored',
|
|
54
|
+
startTime: 0,
|
|
55
|
+
duration: 5,
|
|
56
|
+
audioSegment: 0,
|
|
57
|
+
sourceHeading: {
|
|
58
|
+
type: 'heading',
|
|
59
|
+
depth: 2,
|
|
60
|
+
children: [{ type: 'text', value: 'Context' }],
|
|
61
|
+
},
|
|
62
|
+
contents: [
|
|
63
|
+
{
|
|
64
|
+
type: 'paragraph',
|
|
65
|
+
children: [{ type: 'text', value: 'Original supporting detail.' }],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
]),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(markdown).toContain('## Context');
|
|
73
|
+
expect(markdown).toContain('Original supporting detail.');
|
|
74
|
+
});
|
|
75
|
+
});
|