@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
package/src/WysiwygEditor.tsx
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* and code blocks.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
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
16
|
import StarterKit from '@tiptap/starter-kit';
|
|
@@ -24,13 +24,25 @@ import Link from '@tiptap/extension-link';
|
|
|
24
24
|
import Placeholder from '@tiptap/extension-placeholder';
|
|
25
25
|
import { resolveFontFamily, FONT_FALLBACKS } from '@bendyline/squisq/schemas';
|
|
26
26
|
import { HeadingWithTemplate } from './TemplateAnnotation';
|
|
27
|
+
import { DiagramExtension } from './diagram/DiagramExtension';
|
|
28
|
+
import { SceneBlockExtension } from './scene/SceneBlockExtension';
|
|
27
29
|
import { InlineIcon } from './InlineIcon';
|
|
28
30
|
import { ImageWithMediaProvider } from './ImageNodeView';
|
|
29
31
|
import { TiptapVideo } from './tiptap/TiptapVideo';
|
|
30
32
|
import { TiptapAudio } from './tiptap/TiptapAudio';
|
|
31
33
|
import { TemplateBadgePopover, TEMPLATE_NAMES } from './TemplatePicker';
|
|
34
|
+
import { BlockPropertiesPopover } from './BlockPropertiesPopover';
|
|
35
|
+
import {
|
|
36
|
+
CustomTemplateProvider,
|
|
37
|
+
TemplateDesigner,
|
|
38
|
+
type DesignerSaveTarget,
|
|
39
|
+
} from './customTemplates';
|
|
40
|
+
import { saveLibraryTemplate } from './customTemplates/library';
|
|
41
|
+
import { useDocCustomTemplates } from './customTemplates/useDocCustomTemplates';
|
|
42
|
+
import type { CustomTemplateDefinition } from '@bendyline/squisq/schemas';
|
|
32
43
|
import { profileBlockContents, recommendTemplatesForBlock } from '@bendyline/squisq/recommend';
|
|
33
44
|
import { findBlockSliceByHeadingIndex } from './blockSlice';
|
|
45
|
+
import { stripFrontmatter } from './frontmatter';
|
|
34
46
|
import { useEditorContext } from './EditorContext';
|
|
35
47
|
import { buildMentionExtension } from './MentionExtension';
|
|
36
48
|
import { markdownToTiptap, tiptapToMarkdown } from './tiptapBridge';
|
|
@@ -38,18 +50,6 @@ import { looksLikeMarkdown } from './detectMarkdown';
|
|
|
38
50
|
import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
|
|
39
51
|
import { usePreviewSettingsOptional } from './PreviewControls';
|
|
40
52
|
|
|
41
|
-
// ── Frontmatter helpers ────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
/** Regex matching a YAML frontmatter block at the start of the document. */
|
|
44
|
-
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
45
|
-
|
|
46
|
-
/** Strip YAML frontmatter from markdown, returning both parts. */
|
|
47
|
-
function stripFrontmatter(md: string): { body: string; frontmatter: string } {
|
|
48
|
-
const m = md.match(FRONTMATTER_RE);
|
|
49
|
-
if (!m) return { body: md, frontmatter: '' };
|
|
50
|
-
return { body: md.slice(m[0].length), frontmatter: m[0] };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
53
|
/**
|
|
54
54
|
* Rotating placeholder prompts shown when the editor is empty. One is
|
|
55
55
|
* picked at random per editor mount. Hosts can override by passing the
|
|
@@ -101,14 +101,38 @@ export function WysiwygEditor({
|
|
|
101
101
|
readOnly = false,
|
|
102
102
|
}: WysiwygEditorProps) {
|
|
103
103
|
const {
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
editorSource,
|
|
105
|
+
setEditorSource,
|
|
106
106
|
setTiptapEditor,
|
|
107
107
|
mediaProvider,
|
|
108
108
|
mentionProvider,
|
|
109
109
|
blockTagsVisible,
|
|
110
110
|
themeInheritance,
|
|
111
111
|
} = useEditorContext();
|
|
112
|
+
// Custom templates inlined in the active doc's frontmatter + the
|
|
113
|
+
// persist callback that writes a new list back into the source.
|
|
114
|
+
const { docTemplates, onDocTemplatesChange } = useDocCustomTemplates();
|
|
115
|
+
// Designer modal visibility. `null` when closed; `{ initial }` when
|
|
116
|
+
// open. `initial` is undefined for a "+ New" flow or set to an
|
|
117
|
+
// existing template to edit it.
|
|
118
|
+
const [designerState, setDesignerState] = useState<{ initial?: CustomTemplateDefinition } | null>(
|
|
119
|
+
null,
|
|
120
|
+
);
|
|
121
|
+
const handleDesignerSave = useCallback(
|
|
122
|
+
(def: CustomTemplateDefinition, target: DesignerSaveTarget) => {
|
|
123
|
+
if (target === 'doc') {
|
|
124
|
+
const existingIdx = docTemplates.findIndex((t) => t.name === def.name);
|
|
125
|
+
const next =
|
|
126
|
+
existingIdx >= 0
|
|
127
|
+
? docTemplates.map((t, i) => (i === existingIdx ? def : t))
|
|
128
|
+
: [...docTemplates, def];
|
|
129
|
+
onDocTemplatesChange(next);
|
|
130
|
+
} else {
|
|
131
|
+
saveLibraryTemplate(def);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[docTemplates, onDocTemplatesChange],
|
|
135
|
+
);
|
|
112
136
|
// Keep a ref so the mention extension — created once at editor mount —
|
|
113
137
|
// always sees the latest provider. Swapping projects changes
|
|
114
138
|
// the provider without remounting the editor.
|
|
@@ -120,15 +144,18 @@ export function WysiwygEditor({
|
|
|
120
144
|
// from EMPTY_PROMPTS. Re-renders don't reshuffle.
|
|
121
145
|
const resolvedPlaceholder = useMemo(() => placeholder ?? pickEmptyPrompt(), [placeholder]);
|
|
122
146
|
const isExternalUpdate = useRef(false);
|
|
123
|
-
const lastSourceRef = useRef(
|
|
147
|
+
const lastSourceRef = useRef(editorSource);
|
|
124
148
|
// Keep a ref so the editor's drop/paste handlers (created once) always
|
|
125
149
|
// see the current MediaProvider without needing to recreate the editor.
|
|
126
150
|
const mediaProviderRef = useRef(mediaProvider);
|
|
127
151
|
useEffect(() => {
|
|
128
152
|
mediaProviderRef.current = mediaProvider;
|
|
129
153
|
}, [mediaProvider]);
|
|
130
|
-
// Preserve frontmatter across edits — hidden from WYSIWYG but prepended on
|
|
131
|
-
|
|
154
|
+
// Preserve frontmatter across edits — hidden from WYSIWYG but prepended on
|
|
155
|
+
// save. In block mode the bound slice carries no frontmatter, so this is an
|
|
156
|
+
// empty string and the splice in `setEditorSource` keeps the doc's real
|
|
157
|
+
// frontmatter intact.
|
|
158
|
+
const frontmatterRef = useRef(stripFrontmatter(editorSource).frontmatter);
|
|
132
159
|
// Stash the latest submit callback so the editor's handleKeyDown (bound
|
|
133
160
|
// once at creation) always sees the current value.
|
|
134
161
|
const submitOnEnterRef = useRef(submitOnEnter);
|
|
@@ -147,6 +174,8 @@ export function WysiwygEditor({
|
|
|
147
174
|
},
|
|
148
175
|
}),
|
|
149
176
|
HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
177
|
+
DiagramExtension,
|
|
178
|
+
SceneBlockExtension,
|
|
150
179
|
Table.configure({ resizable: true }),
|
|
151
180
|
TableRow,
|
|
152
181
|
TableCell,
|
|
@@ -165,14 +194,14 @@ export function WysiwygEditor({
|
|
|
165
194
|
buildMentionExtension(() => mentionProviderRef.current),
|
|
166
195
|
InlineIcon,
|
|
167
196
|
],
|
|
168
|
-
content: markdownToTiptap(stripFrontmatter(
|
|
197
|
+
content: markdownToTiptap(stripFrontmatter(editorSource).body),
|
|
169
198
|
onUpdate: ({ editor: ed }) => {
|
|
170
199
|
if (isExternalUpdate.current) return;
|
|
171
200
|
const html = ed.getHTML();
|
|
172
201
|
const bodyMd = tiptapToMarkdown(html);
|
|
173
202
|
const newSource = frontmatterRef.current + bodyMd;
|
|
174
203
|
lastSourceRef.current = newSource;
|
|
175
|
-
|
|
204
|
+
setEditorSource(newSource);
|
|
176
205
|
},
|
|
177
206
|
editorProps: {
|
|
178
207
|
attributes: {
|
|
@@ -330,6 +359,12 @@ export function WysiwygEditor({
|
|
|
330
359
|
headingPos: number;
|
|
331
360
|
headingIndex: number;
|
|
332
361
|
} | null>(null);
|
|
362
|
+
const [propsMenu, setPropsMenu] = useState<{
|
|
363
|
+
rect: DOMRect;
|
|
364
|
+
headingPos: number;
|
|
365
|
+
blockAttrs: string | null;
|
|
366
|
+
templateParams: string | null;
|
|
367
|
+
} | null>(null);
|
|
333
368
|
|
|
334
369
|
useEffect(() => {
|
|
335
370
|
if (!editor) return;
|
|
@@ -338,7 +373,9 @@ export function WysiwygEditor({
|
|
|
338
373
|
const onClick = (e: MouseEvent) => {
|
|
339
374
|
const target = e.target as HTMLElement | null;
|
|
340
375
|
if (!target) return;
|
|
341
|
-
const
|
|
376
|
+
const propsBadge = target.closest('.squisq-props-badge') as HTMLElement | null;
|
|
377
|
+
const templateBadge = target.closest('.squisq-template-badge') as HTMLElement | null;
|
|
378
|
+
const badge = propsBadge ?? templateBadge;
|
|
342
379
|
if (!badge || !root.contains(badge)) return;
|
|
343
380
|
e.preventDefault();
|
|
344
381
|
e.stopPropagation();
|
|
@@ -357,8 +394,22 @@ export function WysiwygEditor({
|
|
|
357
394
|
const headingPos = Math.max(0, pos - 1);
|
|
358
395
|
const node = editor.state.doc.nodeAt(headingPos);
|
|
359
396
|
if (!node || node.type.name !== 'heading') return;
|
|
360
|
-
|
|
361
|
-
//
|
|
397
|
+
|
|
398
|
+
// Block-properties badge → open the properties palette.
|
|
399
|
+
if (propsBadge) {
|
|
400
|
+
setBadgeMenu(null);
|
|
401
|
+
setPropsMenu({
|
|
402
|
+
rect: badge.getBoundingClientRect(),
|
|
403
|
+
headingPos,
|
|
404
|
+
blockAttrs: (node.attrs.dataBlockAttrs as string | null) ?? null,
|
|
405
|
+
templateParams: (node.attrs.dataTemplateParams as string | null) ?? null,
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Template badge → open the template gallery. Count how many headings
|
|
411
|
+
// precede this one so the markdown-source slice helper can locate the
|
|
412
|
+
// matching heading by index.
|
|
362
413
|
let headingIndex = 0;
|
|
363
414
|
let count = 0;
|
|
364
415
|
editor.state.doc.descendants((n, p) => {
|
|
@@ -369,6 +420,7 @@ export function WysiwygEditor({
|
|
|
369
420
|
}
|
|
370
421
|
count++;
|
|
371
422
|
});
|
|
423
|
+
setPropsMenu(null);
|
|
372
424
|
setBadgeMenu({
|
|
373
425
|
rect: badge.getBoundingClientRect(),
|
|
374
426
|
template: (node.attrs.dataTemplate as string | null) ?? '',
|
|
@@ -380,20 +432,29 @@ export function WysiwygEditor({
|
|
|
380
432
|
return () => root.removeEventListener('mousedown', onClick);
|
|
381
433
|
}, [editor]);
|
|
382
434
|
|
|
383
|
-
// Sync external changes into Tiptap
|
|
435
|
+
// Sync external changes into Tiptap. `editorSource` also changes when the
|
|
436
|
+
// user navigates to a different block in block-at-a-time mode, so this same
|
|
437
|
+
// path reloads the card with the newly selected block's slice.
|
|
384
438
|
useEffect(() => {
|
|
385
439
|
if (!editor) return;
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
lastSourceRef.current =
|
|
394
|
-
|
|
440
|
+
if (editorSource === lastSourceRef.current) return;
|
|
441
|
+
// In block/timeline mode `setEditorSource` normalizes the slice's trailing
|
|
442
|
+
// whitespace (a `\n\n` before the next block) before splicing, so the
|
|
443
|
+
// re-extracted `editorSource` differs from what we just emitted by trailing
|
|
444
|
+
// whitespace only. Re-setting content for that would reset the caret to the
|
|
445
|
+
// end on every keystroke — so only reload when the body actually changed.
|
|
446
|
+
if (editorSource.replace(/\s+$/, '') === lastSourceRef.current.replace(/\s+$/, '')) {
|
|
447
|
+
lastSourceRef.current = editorSource;
|
|
448
|
+
return;
|
|
395
449
|
}
|
|
396
|
-
|
|
450
|
+
isExternalUpdate.current = true;
|
|
451
|
+
const { body, frontmatter } = stripFrontmatter(editorSource);
|
|
452
|
+
frontmatterRef.current = frontmatter;
|
|
453
|
+
const content = markdownToTiptap(body);
|
|
454
|
+
editor.commands.setContent(content);
|
|
455
|
+
lastSourceRef.current = editorSource;
|
|
456
|
+
isExternalUpdate.current = false;
|
|
457
|
+
}, [editorSource, editor]);
|
|
397
458
|
|
|
398
459
|
// Match the WYSIWYG editor's appearance to the active Squisq theme
|
|
399
460
|
// when one is set in frontmatter or picked in the preview dropdown.
|
|
@@ -432,37 +493,72 @@ export function WysiwygEditor({
|
|
|
432
493
|
}, [activeTheme, themeInheritance]);
|
|
433
494
|
|
|
434
495
|
return (
|
|
435
|
-
<
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
496
|
+
<CustomTemplateProvider docTemplates={docTemplates} onDocTemplatesChange={onDocTemplatesChange}>
|
|
497
|
+
<div
|
|
498
|
+
className={`squisq-wysiwyg-container${className ? ` ${className}` : ''}`}
|
|
499
|
+
style={{ width: '100%', height: '100%', overflow: 'auto', ...themeStyle }}
|
|
500
|
+
data-testid="wysiwyg-container"
|
|
501
|
+
data-block-tags={blockTagsVisible ? 'visible' : 'hidden'}
|
|
502
|
+
data-theme-inheritance={themeInheritance}
|
|
503
|
+
ref={containerRef}
|
|
504
|
+
>
|
|
505
|
+
<EditorContent editor={editor} style={{ height: '100%' }} />
|
|
506
|
+
{badgeMenu && (
|
|
507
|
+
<TemplateBadgePopover
|
|
508
|
+
anchorRect={badgeMenu.rect}
|
|
509
|
+
value={badgeMenu.template}
|
|
510
|
+
recommended={(() => {
|
|
511
|
+
// `headingIndex` is counted within the mounted Tiptap doc, which
|
|
512
|
+
// reflects `editorSource` — the active block's slice in block
|
|
513
|
+
// mode, the full document otherwise.
|
|
514
|
+
const slice = findBlockSliceByHeadingIndex(editorSource, badgeMenu.headingIndex);
|
|
515
|
+
if (!slice) return undefined;
|
|
516
|
+
const profile = profileBlockContents(slice);
|
|
517
|
+
return recommendTemplatesForBlock(profile, TEMPLATE_NAMES).recommended;
|
|
518
|
+
})()}
|
|
519
|
+
onOpenDesigner={() => {
|
|
520
|
+
setBadgeMenu(null);
|
|
521
|
+
setDesignerState({});
|
|
522
|
+
}}
|
|
523
|
+
onChange={(name) => {
|
|
524
|
+
if (!editor) return;
|
|
525
|
+
const tr = editor.state.tr.setNodeMarkup(badgeMenu.headingPos, undefined, {
|
|
526
|
+
...editor.state.doc.nodeAt(badgeMenu.headingPos)?.attrs,
|
|
527
|
+
dataTemplate: name === '' ? null : name,
|
|
528
|
+
});
|
|
529
|
+
editor.view.dispatch(tr);
|
|
530
|
+
}}
|
|
531
|
+
onClose={() => setBadgeMenu(null)}
|
|
532
|
+
/>
|
|
533
|
+
)}
|
|
534
|
+
{propsMenu && (
|
|
535
|
+
<BlockPropertiesPopover
|
|
536
|
+
anchorRect={propsMenu.rect}
|
|
537
|
+
blockAttrs={propsMenu.blockAttrs}
|
|
538
|
+
templateParams={propsMenu.templateParams}
|
|
539
|
+
onChange={(nextInner) => {
|
|
540
|
+
if (!editor) return;
|
|
541
|
+
const current = editor.state.doc.nodeAt(propsMenu.headingPos);
|
|
542
|
+
if (!current || current.type.name !== 'heading') return;
|
|
543
|
+
const tr = editor.state.tr.setNodeMarkup(propsMenu.headingPos, undefined, {
|
|
544
|
+
...current.attrs,
|
|
545
|
+
dataBlockAttrs: nextInner,
|
|
546
|
+
});
|
|
547
|
+
editor.view.dispatch(tr);
|
|
548
|
+
}}
|
|
549
|
+
onClose={() => setPropsMenu(null)}
|
|
550
|
+
/>
|
|
551
|
+
)}
|
|
552
|
+
{designerState && (
|
|
553
|
+
<TemplateDesigner
|
|
554
|
+
initial={designerState.initial}
|
|
555
|
+
onSave={handleDesignerSave}
|
|
556
|
+
onClose={() => setDesignerState(null)}
|
|
557
|
+
mediaProvider={mediaProvider}
|
|
558
|
+
/>
|
|
559
|
+
)}
|
|
560
|
+
</div>
|
|
561
|
+
</CustomTemplateProvider>
|
|
466
562
|
);
|
|
467
563
|
}
|
|
468
564
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
readBlockAttrsParams,
|
|
4
|
+
readBlockAttrsValue,
|
|
5
|
+
setBlockAttrsValue,
|
|
6
|
+
summarizeBlockProps,
|
|
7
|
+
} from '../blockProperties';
|
|
8
|
+
|
|
9
|
+
describe('readBlockAttrsParams', () => {
|
|
10
|
+
it('returns empty for null/empty inner', () => {
|
|
11
|
+
expect(readBlockAttrsParams(null)).toEqual({});
|
|
12
|
+
expect(readBlockAttrsParams('')).toEqual({});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('parses key=value params (ignoring #id / .class)', () => {
|
|
16
|
+
expect(readBlockAttrsParams('#intro .lead duration=45 startTime=5')).toEqual({
|
|
17
|
+
duration: '45',
|
|
18
|
+
startTime: '5',
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('readBlockAttrsValue', () => {
|
|
24
|
+
it('reads a single param or empty string', () => {
|
|
25
|
+
expect(readBlockAttrsValue('duration=45', 'duration')).toBe('45');
|
|
26
|
+
expect(readBlockAttrsValue('duration=45', 'startTime')).toBe('');
|
|
27
|
+
expect(readBlockAttrsValue(null, 'duration')).toBe('');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('setBlockAttrsValue', () => {
|
|
32
|
+
it('adds a param to a fresh block', () => {
|
|
33
|
+
expect(setBlockAttrsValue(null, 'duration', '45')).toBe('duration=45');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('updates an existing param in place, preserving order and other keys', () => {
|
|
37
|
+
expect(setBlockAttrsValue('#intro duration=10 startTime=2', 'duration', '20')).toBe(
|
|
38
|
+
'#intro duration=20 startTime=2',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('removes a param when set to empty, dropping the block when nothing remains', () => {
|
|
43
|
+
expect(setBlockAttrsValue('duration=45', 'duration', '')).toBeNull();
|
|
44
|
+
expect(setBlockAttrsValue('#intro duration=45', 'duration', ' ')).toBe('#intro');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('does not disturb a transition already present', () => {
|
|
48
|
+
expect(setBlockAttrsValue('transition=fade', 'duration', '3')).toBe(
|
|
49
|
+
'transition=fade duration=3',
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('round-trips through read', () => {
|
|
54
|
+
const inner = setBlockAttrsValue(setBlockAttrsValue(null, 'duration', '12'), 'startTime', '4');
|
|
55
|
+
expect(readBlockAttrsValue(inner, 'duration')).toBe('12');
|
|
56
|
+
expect(readBlockAttrsValue(inner, 'startTime')).toBe('4');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('summarizeBlockProps', () => {
|
|
61
|
+
it('returns empty when nothing is set', () => {
|
|
62
|
+
expect(summarizeBlockProps(null, null)).toBe('');
|
|
63
|
+
expect(summarizeBlockProps('#intro', null)).toBe('');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('names the transition using its friendly label', () => {
|
|
67
|
+
expect(summarizeBlockProps('transition=doors', null)).toBe('Doors');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('formats start time and duration as m:ss', () => {
|
|
71
|
+
expect(summarizeBlockProps('startTime=90', null)).toBe('1:30 start');
|
|
72
|
+
expect(summarizeBlockProps('duration=200', null)).toBe('3:20 long');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('accepts already-formatted m:ss time values', () => {
|
|
76
|
+
expect(summarizeBlockProps('startTime=1:30', null)).toBe('1:30 start');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('joins all set properties in order with a middot', () => {
|
|
80
|
+
expect(summarizeBlockProps('transition=vortex startTime=90 duration=200', null)).toBe(
|
|
81
|
+
'Vortex · 1:30 start · 3:20 long',
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('reads a hand-typed transition from the {[…]} params', () => {
|
|
86
|
+
expect(summarizeBlockProps(null, 'title transition=zoom')).toBe('Zoom');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('normalizes a transition alias spelling for the label', () => {
|
|
90
|
+
expect(summarizeBlockProps('transition=Doors', null)).toBe('Doors');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getBlockSlices,
|
|
4
|
+
spliceBlock,
|
|
5
|
+
lineToOffset,
|
|
6
|
+
offsetToLine,
|
|
7
|
+
sliceIndexAtOffset,
|
|
8
|
+
} from '../blockRange';
|
|
9
|
+
|
|
10
|
+
describe('getBlockSlices', () => {
|
|
11
|
+
it('returns one slice per heading in document order', () => {
|
|
12
|
+
const md = '# One\n\nalpha\n\n# Two\n\nbeta\n';
|
|
13
|
+
const slices = getBlockSlices(md);
|
|
14
|
+
expect(slices.map((s) => s.text)).toEqual(['# One\n\nalpha\n\n', '# Two\n\nbeta\n']);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('starts a new slice at every heading depth (children are not folded in)', () => {
|
|
18
|
+
const md = '# Parent\n\nintro\n\n## Child\n\nbody\n';
|
|
19
|
+
const slices = getBlockSlices(md);
|
|
20
|
+
expect(slices).toHaveLength(2);
|
|
21
|
+
expect(slices[0].text).toBe('# Parent\n\nintro\n\n');
|
|
22
|
+
expect(slices[1].text).toBe('## Child\n\nbody\n');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('includes leading content before the first heading as a preamble slice', () => {
|
|
26
|
+
const md = 'preamble text\n\n# First\n\nbody\n';
|
|
27
|
+
const slices = getBlockSlices(md);
|
|
28
|
+
expect(slices).toHaveLength(2);
|
|
29
|
+
expect(slices[0].text).toBe('preamble text\n\n');
|
|
30
|
+
expect(slices[1].text).toBe('# First\n\nbody\n');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('skips a whitespace-only preamble', () => {
|
|
34
|
+
const md = '\n\n# Only\n\nbody\n';
|
|
35
|
+
const slices = getBlockSlices(md);
|
|
36
|
+
expect(slices).toHaveLength(1);
|
|
37
|
+
expect(slices[0].text).toBe('# Only\n\nbody\n');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('treats a heading-less document as a single slice', () => {
|
|
41
|
+
const md = 'just a paragraph\n\nand another\n';
|
|
42
|
+
const slices = getBlockSlices(md);
|
|
43
|
+
expect(slices).toHaveLength(1);
|
|
44
|
+
expect(slices[0].text).toBe(md);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('treats an empty document as a single (empty) slice', () => {
|
|
48
|
+
const slices = getBlockSlices('');
|
|
49
|
+
expect(slices).toHaveLength(1);
|
|
50
|
+
expect(slices[0].text).toBe('');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('never folds frontmatter into a slice', () => {
|
|
54
|
+
const md = '---\ntitle: Hi\n---\n# Heading\n\nbody\n';
|
|
55
|
+
const slices = getBlockSlices(md);
|
|
56
|
+
expect(slices).toHaveLength(1);
|
|
57
|
+
expect(slices[0].text).toBe('# Heading\n\nbody\n');
|
|
58
|
+
// The frontmatter stays in the untouched prefix.
|
|
59
|
+
expect(md.slice(0, slices[0].range.startOffset)).toBe('---\ntitle: Hi\n---\n');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('spliceBlock round-trips', () => {
|
|
64
|
+
const cases = [
|
|
65
|
+
'# One\n\nalpha\n\n# Two\n\nbeta\n',
|
|
66
|
+
'# Parent\n\nintro\n\n## Child\n\nbody\n',
|
|
67
|
+
'preamble text\n\n# First\n\nbody\n',
|
|
68
|
+
'---\ntitle: Hi\n---\n# Heading\n\nbody\n',
|
|
69
|
+
'no headings at all\n',
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
it('re-splicing a slice with its own text reproduces the source exactly', () => {
|
|
73
|
+
for (const md of cases) {
|
|
74
|
+
const slices = getBlockSlices(md);
|
|
75
|
+
for (const slice of slices) {
|
|
76
|
+
expect(spliceBlock(md, slice.range, slice.text)).toBe(md);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('splices an edited block back into the full document', () => {
|
|
82
|
+
const md = '# One\n\nalpha\n\n# Two\n\nbeta\n';
|
|
83
|
+
const slices = getBlockSlices(md);
|
|
84
|
+
const edited = spliceBlock(md, slices[0].range, '# One\n\nalpha edited\n\n');
|
|
85
|
+
expect(edited).toBe('# One\n\nalpha edited\n\n# Two\n\nbeta\n');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('line/offset helpers', () => {
|
|
90
|
+
const md = '# One\n\nalpha\n\n# Two\n';
|
|
91
|
+
|
|
92
|
+
it('maps lines to offsets and back', () => {
|
|
93
|
+
// Line 5 is the "# Two" heading.
|
|
94
|
+
const offset = lineToOffset(md, 5);
|
|
95
|
+
expect(md.slice(offset, offset + 5)).toBe('# Two');
|
|
96
|
+
expect(offsetToLine(md, offset)).toBe(5);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('finds the slice index containing an offset', () => {
|
|
100
|
+
const slices = getBlockSlices(md);
|
|
101
|
+
const secondHeadingOffset = lineToOffset(md, 5);
|
|
102
|
+
expect(sliceIndexAtOffset(slices, secondHeadingOffset)).toBe(1);
|
|
103
|
+
expect(sliceIndexAtOffset(slices, 0)).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
3
|
+
import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
4
|
+
import type { Transition } from '@bendyline/squisq/schemas';
|
|
5
|
+
import { buildPreviewDoc } from '../buildPreviewDoc';
|
|
6
|
+
|
|
7
|
+
function previewSlides(md: string) {
|
|
8
|
+
const doc = markdownToDoc(parseMarkdown(md), { articleId: 't' });
|
|
9
|
+
return buildPreviewDoc(doc).blocks as unknown as Array<{ transition?: Transition }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('buildPreviewDoc transition mapping', () => {
|
|
13
|
+
it('carries an authored transition through to the player slide', () => {
|
|
14
|
+
// Transition written the way the toolbar / properties palette writes it:
|
|
15
|
+
// into the heading's Pandoc `{…}` attribute block.
|
|
16
|
+
const md = ['# Intro', '', '# Second {transition=vortex}', '', 'body'].join('\n');
|
|
17
|
+
const slides = previewSlides(md);
|
|
18
|
+
expect(slides[1].transition).toEqual({ type: 'vortex' });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('preserves direction and duration on the transition', () => {
|
|
22
|
+
const md = [
|
|
23
|
+
'# Intro',
|
|
24
|
+
'',
|
|
25
|
+
'# Second {transition=push transitionDirection=up transitionDuration=1.2}',
|
|
26
|
+
].join('\n');
|
|
27
|
+
const slides = previewSlides(md);
|
|
28
|
+
expect(slides[1].transition).toEqual({ type: 'push', direction: 'up', duration: 1.2 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('falls back to the default fade for a non-first block with no transition', () => {
|
|
32
|
+
const md = ['# Intro', '', '# Second'].join('\n');
|
|
33
|
+
const slides = previewSlides(md);
|
|
34
|
+
expect(slides[1].transition).toEqual({ type: 'fade', duration: 0.5 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('leaves the first block without a transition when none is authored', () => {
|
|
38
|
+
const slides = previewSlides(['# Intro', '', '# Second'].join('\n'));
|
|
39
|
+
expect(slides[0].transition).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('honors a transition authored on the first block', () => {
|
|
43
|
+
const slides = previewSlides(['# Intro {transition=zoom}', '', '# Second'].join('\n'));
|
|
44
|
+
expect(slides[0].transition).toEqual({ type: 'zoom' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// The `{[name key=value]}` template-annotation form is the one the editor's
|
|
48
|
+
// attribute autocomplete advertises for `transition=`. The coerced typed
|
|
49
|
+
// transition object also rides along as a raw string in the block's
|
|
50
|
+
// templateData; if that string is spread over the typed field it silently
|
|
51
|
+
// downgrades `{ type: 'vortex' }` to the string `'vortex'`, which the player
|
|
52
|
+
// can't animate. These guard that the typed object wins.
|
|
53
|
+
describe('transition written inside a {[…]} template annotation', () => {
|
|
54
|
+
it('keeps a bare transition as a typed object, not a raw string', () => {
|
|
55
|
+
const md = ['# Intro', '', '## Second {[quote transition=vortex]}', '', '> hi'].join('\n');
|
|
56
|
+
const slides = previewSlides(md);
|
|
57
|
+
expect(slides[1].transition).toEqual({ type: 'vortex' });
|
|
58
|
+
expect(typeof slides[1].transition).toBe('object');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('preserves direction and duration from the annotation', () => {
|
|
62
|
+
const md = [
|
|
63
|
+
'# Intro',
|
|
64
|
+
'',
|
|
65
|
+
'## Second {[factCard transition=push transitionDirection=left transitionDuration=0.8]}',
|
|
66
|
+
'',
|
|
67
|
+
'body',
|
|
68
|
+
].join('\n');
|
|
69
|
+
const slides = previewSlides(md);
|
|
70
|
+
expect(slides[1].transition).toEqual({ type: 'push', direction: 'left', duration: 0.8 });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|