@bendyline/squisq-editor-react 1.5.3 → 1.6.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.
Files changed (165) hide show
  1. package/dist/index.d.ts +757 -20
  2. package/dist/index.js +16875 -6883
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -4
  5. package/src/BlockCardView.tsx +121 -0
  6. package/src/BlockPreviewPanel.tsx +69 -0
  7. package/src/BlockPropertiesPopover.tsx +191 -0
  8. package/src/EditorContext.tsx +143 -0
  9. package/src/EditorShell.tsx +200 -120
  10. package/src/FolderView.tsx +131 -0
  11. package/src/Icon.tsx +26 -0
  12. package/src/ImageEditor.tsx +69 -22
  13. package/src/OutlinePanel.tsx +38 -3
  14. package/src/PlainHtmlPreview.tsx +30 -3
  15. package/src/PreviewControls.tsx +180 -8
  16. package/src/RawEditor.tsx +169 -13
  17. package/src/RecorderEntry.tsx +9 -16
  18. package/src/TemplateAnnotation.ts +44 -0
  19. package/src/TemplatePicker.tsx +329 -54
  20. package/src/ThemeCustomizerPanel.tsx +30 -336
  21. package/src/ThemePicker.tsx +112 -3
  22. package/src/TimelineBlockPreview.tsx +37 -0
  23. package/src/TimelineTrack.tsx +671 -0
  24. package/src/Toolbar.tsx +528 -174
  25. package/src/Tooltip.tsx +22 -4
  26. package/src/TransitionPicker.tsx +351 -0
  27. package/src/VersionHistoryPanel.tsx +2 -14
  28. package/src/ViewMenuPanel.tsx +17 -14
  29. package/src/WysiwygEditor.tsx +161 -65
  30. package/src/__tests__/blockProperties.test.ts +92 -0
  31. package/src/__tests__/blockRange.test.ts +105 -0
  32. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  33. package/src/__tests__/createShapeLayer.test.ts +46 -0
  34. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  35. package/src/__tests__/embeddedMedia.test.ts +48 -0
  36. package/src/__tests__/headingTransition.test.ts +138 -0
  37. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  38. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  39. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  40. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  41. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  42. package/src/__tests__/timelineSource.test.ts +134 -0
  43. package/src/__tests__/tiptapBridge.test.ts +92 -0
  44. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  45. package/src/__tests__/tooltip.test.tsx +72 -0
  46. package/src/__tests__/transitionCatalog.test.ts +64 -0
  47. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  48. package/src/__tests__/useMediaRecorder.test.ts +24 -0
  49. package/src/__tests__/useTimelineClock.test.ts +21 -0
  50. package/src/blockProperties.ts +88 -0
  51. package/src/blockRange.ts +132 -0
  52. package/src/buildPreviewDoc.ts +98 -9
  53. package/src/customTemplates/AddBin.tsx +126 -0
  54. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  55. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  56. package/src/customTemplates/LayerToolbar.tsx +580 -0
  57. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  58. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  59. package/src/customTemplates/__tests__/library.test.ts +88 -0
  60. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  61. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  62. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  63. package/src/customTemplates/designer.css +673 -0
  64. package/src/customTemplates/index.ts +31 -0
  65. package/src/customTemplates/library.ts +97 -0
  66. package/src/customTemplates/normalizePositions.ts +75 -0
  67. package/src/customTemplates/shapeDefs.ts +131 -0
  68. package/src/customTemplates/thumbnail.tsx +63 -0
  69. package/src/customTemplates/tokenDefs.ts +60 -0
  70. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  71. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  72. package/src/customThemes/CustomThemeContext.tsx +179 -0
  73. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  74. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  75. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  76. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  77. package/src/customThemes/customThemeLibrary.ts +97 -0
  78. package/src/customThemes/index.ts +31 -0
  79. package/src/customThemes/themeControls.tsx +229 -0
  80. package/src/customThemes/themeDraft.ts +272 -0
  81. package/src/customThemes/useDocCustomThemes.ts +49 -0
  82. package/src/diagram/DiagramCanvas.tsx +240 -0
  83. package/src/diagram/DiagramExtension.ts +209 -0
  84. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  85. package/src/diagram/DiagramWidget.tsx +270 -0
  86. package/src/diagram/diagramCommands.ts +604 -0
  87. package/src/diagram/diagramConstants.ts +17 -0
  88. package/src/diagram/useDiagramData.ts +126 -0
  89. package/src/embeddedMedia.ts +78 -0
  90. package/src/frontmatter.ts +29 -0
  91. package/src/headingTransition.ts +231 -0
  92. package/src/imageEditor/CanvasSurface.tsx +383 -88
  93. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  94. package/src/imageEditor/Toolbar.tsx +229 -16
  95. package/src/imageEditor/createShapeLayer.ts +280 -0
  96. package/src/imageEditor/icons.tsx +34 -114
  97. package/src/imageEditor/image-editor.css +54 -5
  98. package/src/imageEditor/state.ts +23 -3
  99. package/src/index.ts +77 -0
  100. package/src/recorder/RecorderModal.tsx +120 -53
  101. package/src/recorder/RecorderPanel.tsx +2 -26
  102. package/src/recorder/hooks/useMediaRecorder.ts +8 -1
  103. package/src/recorder/insertMediaBlock.ts +30 -0
  104. package/src/resolveBlockVisual.ts +33 -0
  105. package/src/scene/Scene.tsx +540 -0
  106. package/src/scene/SceneBlockExtension.ts +198 -0
  107. package/src/scene/SceneBlockToolbar.tsx +201 -0
  108. package/src/scene/SceneBlockWidget.tsx +434 -0
  109. package/src/scene/ScenePropsBar.tsx +85 -0
  110. package/src/scene/SceneSelection.tsx +107 -0
  111. package/src/scene/SceneViewport.tsx +102 -0
  112. package/src/scene/ShapePalette.tsx +181 -0
  113. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  114. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  115. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  116. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  117. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  118. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  119. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  120. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  121. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  122. package/src/scene/adapters/blockLayers.ts +159 -0
  123. package/src/scene/commands/SceneCommand.ts +70 -0
  124. package/src/scene/commands/drawingCommands.ts +318 -0
  125. package/src/scene/commands/layoutCommands.ts +301 -0
  126. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  127. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  128. package/src/scene/hooks/useSceneSelection.ts +62 -0
  129. package/src/scene/index.ts +95 -0
  130. package/src/scene/layers/DiagramEdges.tsx +127 -0
  131. package/src/scene/layers/edgeGeometry.ts +77 -0
  132. package/src/scene/layers/nodeCard.tsx +145 -0
  133. package/src/scene/layers/renderLayer.tsx +70 -0
  134. package/src/scene/layers/shapeLayers.ts +201 -0
  135. package/src/scene/paths/bezierEdit.ts +208 -0
  136. package/src/scene/scene.css +649 -0
  137. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  138. package/src/scene/text/sceneTextChannel.ts +40 -0
  139. package/src/scene/text/sceneTextConfig.ts +27 -0
  140. package/src/scene/text/sceneTiptap.ts +36 -0
  141. package/src/scene/text/useSceneTextEditing.ts +39 -0
  142. package/src/scene/tools/ConnectTool.ts +111 -0
  143. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  144. package/src/scene/tools/PathTool.ts +158 -0
  145. package/src/scene/tools/PlaceTool.ts +47 -0
  146. package/src/scene/tools/SceneTool.ts +75 -0
  147. package/src/scene/tools/SelectTool.ts +284 -0
  148. package/src/scene/tools/ShapeTool.ts +144 -0
  149. package/src/scene/tools/TextTool.ts +72 -0
  150. package/src/scene/tools/TokenTool.ts +95 -0
  151. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  152. package/src/styles/diagram.css +183 -0
  153. package/src/styles/editor.css +1615 -203
  154. package/src/styles/folder-view.css +210 -0
  155. package/src/styles/image-edit-affordance.css +2 -2
  156. package/src/styles/index.css +4 -0
  157. package/src/timelineSource.ts +244 -0
  158. package/src/tiptapBridge.ts +115 -34
  159. package/src/tooltipPlacement.ts +13 -0
  160. package/src/transitionCatalog.ts +159 -0
  161. package/src/types/monaco-shims.d.ts +10 -0
  162. package/src/useBlockNavigator.ts +153 -0
  163. package/src/useMonacoLoader.ts +23 -1
  164. package/src/useTimelineClock.ts +76 -0
  165. package/src/utils/dropUtils.ts +1 -1
@@ -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
- markdownSource,
105
- setMarkdownSource,
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(markdownSource);
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 save
131
- const frontmatterRef = useRef(stripFrontmatter(markdownSource).frontmatter);
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(markdownSource).body),
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
- setMarkdownSource(newSource);
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 badge = target.closest('.squisq-template-badge') as HTMLElement | null;
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
- // Count how many headings precede this one so the markdown-source
361
- // slice helper can locate the matching heading by index.
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
- // Only update if the source changed externally (not from our own onUpdate)
387
- if (markdownSource !== lastSourceRef.current) {
388
- isExternalUpdate.current = true;
389
- const { body, frontmatter } = stripFrontmatter(markdownSource);
390
- frontmatterRef.current = frontmatter;
391
- const content = markdownToTiptap(body);
392
- editor.commands.setContent(content);
393
- lastSourceRef.current = markdownSource;
394
- isExternalUpdate.current = false;
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
- }, [markdownSource, editor]);
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
- <div
436
- className={`squisq-wysiwyg-container${className ? ` ${className}` : ''}`}
437
- style={{ width: '100%', height: '100%', overflow: 'auto', ...themeStyle }}
438
- data-testid="wysiwyg-container"
439
- data-block-tags={blockTagsVisible ? 'visible' : 'hidden'}
440
- data-theme-inheritance={themeInheritance}
441
- ref={containerRef}
442
- >
443
- <EditorContent editor={editor} style={{ height: '100%' }} />
444
- {badgeMenu && (
445
- <TemplateBadgePopover
446
- anchorRect={badgeMenu.rect}
447
- value={badgeMenu.template}
448
- recommended={(() => {
449
- const slice = findBlockSliceByHeadingIndex(markdownSource, badgeMenu.headingIndex);
450
- if (!slice) return undefined;
451
- const profile = profileBlockContents(slice);
452
- return recommendTemplatesForBlock(profile, TEMPLATE_NAMES).recommended;
453
- })()}
454
- onChange={(name) => {
455
- if (!editor) return;
456
- const tr = editor.state.tr.setNodeMarkup(badgeMenu.headingPos, undefined, {
457
- ...editor.state.doc.nodeAt(badgeMenu.headingPos)?.attrs,
458
- dataTemplate: name === '' ? null : name,
459
- });
460
- editor.view.dispatch(tr);
461
- }}
462
- onClose={() => setBadgeMenu(null)}
463
- />
464
- )}
465
- </div>
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
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createShapeLayer } from '../imageEditor/createShapeLayer';
3
+
4
+ describe('createShapeLayer', () => {
5
+ it('maps native primitives to ShapeLayer, centered on the drop point', () => {
6
+ const rect = createShapeLayer('rectangle', 100, 100);
7
+ expect(rect.type).toBe('shape');
8
+ if (rect.type === 'shape') expect(rect.content.shape).toBe('rect');
9
+ // 120×80 box centered at (100,100) → top-left (40, 60).
10
+ expect(rect.position).toMatchObject({ x: 40, y: 60, width: 120, height: 80 });
11
+
12
+ expect(createShapeLayer('circle', 0, 0).type).toBe('shape');
13
+ const line = createShapeLayer('line', 0, 0);
14
+ if (line.type === 'shape') expect(line.content.shape).toBe('line');
15
+ });
16
+
17
+ it('maps named shapes to a PathLayer carrying shapeKind + derived d', () => {
18
+ const diamond = createShapeLayer('diamond', 100, 100);
19
+ expect(diamond.type).toBe('path');
20
+ if (diamond.type === 'path') {
21
+ expect(diamond.content.shapeKind).toBe('diamond');
22
+ expect(diamond.content.d.length).toBeGreaterThan(0);
23
+ expect(diamond.content.fill).toBeTruthy();
24
+ }
25
+ });
26
+
27
+ it('maps the line arrow to a PathLayer with an end marker (no shapeKind)', () => {
28
+ const arrow = createShapeLayer('arrow', 50, 50);
29
+ expect(arrow.type).toBe('path');
30
+ if (arrow.type === 'path') {
31
+ expect(arrow.content.endMarker).toBe('arrow');
32
+ expect(arrow.content.shapeKind).toBeUndefined();
33
+ expect(arrow.content.fill).toBe('none');
34
+ }
35
+ });
36
+
37
+ it('maps the text shape to a TextLayer', () => {
38
+ const text = createShapeLayer('text', 10, 10);
39
+ expect(text.type).toBe('text');
40
+ });
41
+
42
+ it('prettifies multi-word kinds for the layer name', () => {
43
+ expect(createShapeLayer('arrow-right', 0, 0).name).toBe('Arrow Right');
44
+ expect(createShapeLayer('right-triangle', 0, 0).name).toBe('Right Triangle');
45
+ });
46
+ });