@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
package/src/RawEditor.tsx CHANGED
@@ -12,6 +12,7 @@ import type * as monaco from 'monaco-editor';
12
12
  import { useEditorContext } from './EditorContext';
13
13
  import { getAvailableTemplates } from '@bendyline/squisq/doc';
14
14
  import { suggestIcons, resolveIcon, iconGlyph } from '@bendyline/squisq/icons';
15
+ import { BLOCK_META_KEY_DESCRIPTORS, tokenizeAttrTokens } from '@bendyline/squisq/markdown';
15
16
  import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
16
17
  import { useMonacoLoader } from './useMonacoLoader';
17
18
 
@@ -30,7 +31,12 @@ import { useMonacoLoader } from './useMonacoLoader';
30
31
  // resolve: { alias: [{ find: /^monaco-editor$/, replacement: './monaco-slim.ts' }] }
31
32
  //
32
33
  // Where monaco-slim.ts re-exports 'monaco-editor/esm/vs/editor/editor.api'
33
- // plus only the language contributions needed (e.g. markdown, javascript).
34
+ // plus only the language contributions actually needed
35
+ // (`basic-languages/monaco.contribution` for the broad TM grammars,
36
+ // and any of `language/{css,html,json,typescript}/monaco.contribution`
37
+ // for the rich language services). Skipping the language contributions
38
+ // entirely means `defaultLanguage` becomes inert — no tokenizer
39
+ // registered, so every file renders as plain foreground text.
34
40
 
35
41
  // Squisq Monaco themes: same syntax highlighting as vs / vs-dark, but with
36
42
  // Monaco's internal gutter (line numbers + folding margin) and overview
@@ -82,7 +88,7 @@ export function RawEditor({
82
88
  submitOnEnter,
83
89
  readOnly = false,
84
90
  }: RawEditorProps) {
85
- const { markdownSource, setMarkdownSource, setMonacoEditor, language, mentionProvider } =
91
+ const { editorSource, setEditorSource, setMonacoEditor, language, mentionProvider, doc } =
86
92
  useEditorContext();
87
93
  const { monaco: monacoNs, ready: monacoReady } = useMonacoLoader();
88
94
  const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
@@ -90,6 +96,7 @@ export function RawEditor({
90
96
  const completionDisposable = useRef<monaco.IDisposable | null>(null);
91
97
  const mentionCompletionDisposable = useRef<monaco.IDisposable | null>(null);
92
98
  const iconCompletionDisposable = useRef<monaco.IDisposable | null>(null);
99
+ const attrCompletionDisposable = useRef<monaco.IDisposable | null>(null);
93
100
  const iconGlyphDecorations = useRef<monaco.editor.IEditorDecorationsCollection | null>(null);
94
101
  const dropCleanupRef = useRef<(() => void) | null>(null);
95
102
  const keyDisposable = useRef<monaco.IDisposable | null>(null);
@@ -105,6 +112,35 @@ export function RawEditor({
105
112
  mentionProviderRef.current = mentionProvider;
106
113
  }, [mentionProvider]);
107
114
 
115
+ // Template completion list: built-in registry names plus any custom
116
+ // templates inlined in the active doc's frontmatter (`doc.customTemplates`
117
+ // — the same set that actually renders via `{[name]}`). Held in a ref
118
+ // (like mentionProvider above) so the once-registered provider always
119
+ // reads the latest set — custom templates created after mount must show
120
+ // up without re-mounting Monaco.
121
+ const docCustomTemplates = doc?.customTemplates;
122
+ const templateEntriesRef = useRef<{ name: string; detail: string }[] | null>(null);
123
+ if (templateEntriesRef.current === null) {
124
+ templateEntriesRef.current = getAvailableTemplates().map((name) => ({
125
+ name,
126
+ detail: 'Block template',
127
+ }));
128
+ }
129
+ useEffect(() => {
130
+ const builtIns = getAvailableTemplates().map((name) => ({
131
+ name,
132
+ detail: 'Block template',
133
+ }));
134
+ const seen = new Set(builtIns.map((b) => b.name));
135
+ const custom = (docCustomTemplates ?? [])
136
+ .filter((t) => !seen.has(t.name))
137
+ .map((t) => ({
138
+ name: t.name,
139
+ detail: t.label ? `Custom — ${t.label}` : 'Custom template',
140
+ }));
141
+ templateEntriesRef.current = [...builtIns, ...custom];
142
+ }, [docCustomTemplates]);
143
+
108
144
  const handleBeforeMount: BeforeMount = useCallback((monaco) => {
109
145
  monaco.editor.defineTheme('squisq-light', {
110
146
  base: 'vs',
@@ -143,11 +179,12 @@ export function RawEditor({
143
179
  mentionCompletionDisposable.current = null;
144
180
  iconCompletionDisposable.current?.dispose();
145
181
  iconCompletionDisposable.current = null;
182
+ attrCompletionDisposable.current?.dispose();
183
+ attrCompletionDisposable.current = null;
146
184
 
147
185
  // Register the `{[template]}` completion provider only for markdown
148
186
  // files — it's meaningless for TypeScript, JSON, Python, etc.
149
187
  if (language === 'markdown') {
150
- const templates = getAvailableTemplates();
151
188
  completionDisposable.current = monaco.languages.registerCompletionItemProvider('markdown', {
152
189
  triggerCharacters: ['['],
153
190
  provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) {
@@ -161,6 +198,12 @@ export function RawEditor({
161
198
  const bracketIdx = textBeforeCursor.lastIndexOf('{[');
162
199
  if (bracketIdx === -1) return { suggestions: [] };
163
200
 
201
+ // Template names are the FIRST token after `{[`. Once a space
202
+ // follows the name we're in the `key=value` attribute region —
203
+ // the attribute provider handles that, so bail here to avoid
204
+ // offering template names mid-attribute.
205
+ if (/\s/.test(textBeforeCursor.slice(bracketIdx + 2))) return { suggestions: [] };
206
+
164
207
  // When Monaco's bracket auto-pair has already produced the
165
208
  // closing `]}` we just leave it in place and skip the
166
209
  // suffix — otherwise accepting `sectionHeader` on
@@ -176,13 +219,13 @@ export function RawEditor({
176
219
  position.column,
177
220
  );
178
221
 
179
- const suggestions = templates.map((name) => ({
222
+ const suggestions = (templateEntriesRef.current ?? []).map(({ name, detail }) => ({
180
223
  label: name,
181
224
  filterText: name,
182
225
  kind: monaco.languages.CompletionItemKind.Value,
183
226
  insertText: name + suffix,
184
227
  range,
185
- detail: 'Block template',
228
+ detail,
186
229
  sortText: name,
187
230
  }));
188
231
 
@@ -324,6 +367,110 @@ export function RawEditor({
324
367
  },
325
368
  },
326
369
  );
370
+
371
+ // `{[name key=value]}` block-meta attribute completion. Fires on
372
+ // heading lines once the cursor is past the template name (the
373
+ // first token), where attributes like `transition=` apply to the
374
+ // block. Two modes:
375
+ // - bare token → suggest attribute KEYS (insert `key=`)
376
+ // - after `key=` → suggest that key's VALUES, when it has a
377
+ // closed set (e.g. transition / transitionDirection)
378
+ // The descriptor list comes from core so keys and value enums stay
379
+ // in lockstep with what `coerceAnnotationValues` actually accepts.
380
+ attrCompletionDisposable.current = monaco.languages.registerCompletionItemProvider(
381
+ 'markdown',
382
+ {
383
+ triggerCharacters: ['=', ' '],
384
+ provideCompletionItems(model, position) {
385
+ const lineContent = model.getLineContent(position.lineNumber);
386
+ // Block-meta attributes only mean something on a heading's
387
+ // template annotation — same gate as the template provider.
388
+ if (!/^#{1,6}\s/.test(lineContent)) return { suggestions: [] };
389
+
390
+ const textBeforeCursor = lineContent.substring(0, position.column - 1);
391
+ const bracketIdx = textBeforeCursor.lastIndexOf('{[');
392
+ if (bracketIdx === -1) return { suggestions: [] };
393
+
394
+ // Bail if a `]` already closed the annotation before the
395
+ // cursor — we'd be past it, not inside the attribute list.
396
+ const innerBefore = textBeforeCursor.slice(bracketIdx + 2);
397
+ if (innerBefore.includes(']')) return { suggestions: [] };
398
+ // The first token is the template name; attributes only begin
399
+ // after a whitespace separator. No space yet → still the name.
400
+ if (!/\s/.test(innerBefore)) return { suggestions: [] };
401
+
402
+ // Current token = run of non-whitespace ending at the cursor.
403
+ const lastWsMatch = innerBefore.match(/\s(\S*)$/);
404
+ const currentToken = lastWsMatch ? lastWsMatch[1] : '';
405
+ const tokenStartCol = position.column - currentToken.length;
406
+
407
+ const eqIdx = currentToken.indexOf('=');
408
+ if (eqIdx >= 0) {
409
+ // ── Value mode: `key=<partial>` ──
410
+ const key = currentToken.slice(0, eqIdx);
411
+ const descriptor = BLOCK_META_KEY_DESCRIPTORS.find((d) => d.key === key);
412
+ if (!descriptor?.values) return { suggestions: [] };
413
+ const range = new monaco.Range(
414
+ position.lineNumber,
415
+ tokenStartCol + eqIdx + 1, // after `=`
416
+ position.lineNumber,
417
+ position.column,
418
+ );
419
+ return {
420
+ suggestions: descriptor.values.map((value, i) => ({
421
+ label: value,
422
+ filterText: value,
423
+ kind: monaco.languages.CompletionItemKind.EnumMember,
424
+ insertText: value,
425
+ range,
426
+ detail: descriptor.description,
427
+ sortText: String(i).padStart(4, '0'),
428
+ })),
429
+ };
430
+ }
431
+
432
+ // ── Key mode: suggest attribute names not already present ──
433
+ // Collect keys already set anywhere in the annotation so we
434
+ // don't re-offer them; keep the key under the cursor eligible
435
+ // so re-editing an existing `key=` still suggests it.
436
+ const afterBracket = lineContent.slice(bracketIdx + 2);
437
+ const closeIdx = afterBracket.indexOf(']}');
438
+ const fullInner = closeIdx === -1 ? afterBracket : afterBracket.slice(0, closeIdx);
439
+ const present = new Set(
440
+ tokenizeAttrTokens(fullInner)
441
+ .map((t) => {
442
+ const i = t.indexOf('=');
443
+ return i > 0 ? t.slice(0, i) : null;
444
+ })
445
+ .filter((k): k is string => k != null),
446
+ );
447
+ present.delete(currentToken);
448
+
449
+ const range = new monaco.Range(
450
+ position.lineNumber,
451
+ tokenStartCol,
452
+ position.lineNumber,
453
+ position.column,
454
+ );
455
+ const suggestions = BLOCK_META_KEY_DESCRIPTORS.filter((d) => !present.has(d.key)).map(
456
+ (d, i) => ({
457
+ label: d.key,
458
+ filterText: d.key,
459
+ kind: monaco.languages.CompletionItemKind.Property,
460
+ insertText: `${d.key}=`,
461
+ range,
462
+ detail: d.values ? d.description : `${d.description} — ${d.valueHint}`,
463
+ sortText: String(i).padStart(4, '0'),
464
+ // Closed-set keys chain straight into their value list.
465
+ ...(d.values
466
+ ? { command: { id: 'editor.action.triggerSuggest', title: '' } }
467
+ : {}),
468
+ }),
469
+ );
470
+ return { suggestions };
471
+ },
472
+ },
473
+ );
327
474
  }
328
475
 
329
476
  // Chat-composer mode: intercept Enter before Monaco inserts a newline.
@@ -401,6 +548,8 @@ export function RawEditor({
401
548
  mentionCompletionDisposable.current = null;
402
549
  iconCompletionDisposable.current?.dispose();
403
550
  iconCompletionDisposable.current = null;
551
+ attrCompletionDisposable.current?.dispose();
552
+ attrCompletionDisposable.current = null;
404
553
  iconGlyphDecorations.current?.clear();
405
554
  iconGlyphDecorations.current = null;
406
555
  dropCleanupRef.current?.();
@@ -414,24 +563,31 @@ export function RawEditor({
414
563
  (value) => {
415
564
  if (isExternalUpdate.current) return;
416
565
  if (value !== undefined) {
417
- setMarkdownSource(value);
566
+ setEditorSource(value);
418
567
  }
419
568
  },
420
- [setMarkdownSource],
569
+ [setEditorSource],
421
570
  );
422
571
 
423
- // When external changes happen (e.g. from WYSIWYG), update Monaco
572
+ // When external changes happen (e.g. from WYSIWYG, or navigating to another
573
+ // block in block-at-a-time mode), update Monaco. In block mode `editorSource`
574
+ // is the active block's slice, so this swaps Monaco's content on navigation.
424
575
  useEffect(() => {
425
576
  const editor = editorRef.current;
426
577
  if (editor) {
427
578
  const currentValue = editor.getValue();
428
- if (currentValue !== markdownSource) {
579
+ // Ignore trailing-whitespace-only differences from block-mode splice
580
+ // normalization — calling setValue for those resets the Monaco cursor.
581
+ if (
582
+ currentValue !== editorSource &&
583
+ currentValue.replace(/\s+$/, '') !== editorSource.replace(/\s+$/, '')
584
+ ) {
429
585
  isExternalUpdate.current = true;
430
- editor.setValue(markdownSource);
586
+ editor.setValue(editorSource);
431
587
  isExternalUpdate.current = false;
432
588
  }
433
589
  }
434
- }, [markdownSource]);
590
+ }, [editorSource]);
435
591
 
436
592
  // ── Inline FontAwesome glyph decorations ────────────
437
593
  // Walk the markdown source on every change, find each resolvable
@@ -482,7 +638,7 @@ export function RawEditor({
482
638
  } else {
483
639
  iconGlyphDecorations.current.set(decorations);
484
640
  }
485
- }, [markdownSource, language, monacoNs]);
641
+ }, [editorSource, language, monacoNs]);
486
642
 
487
643
  const effectiveTheme = SQUISQ_THEMES[theme] ?? theme;
488
644
 
@@ -516,7 +672,7 @@ export function RawEditor({
516
672
  <div className={className} style={{ width: '100%', height: '100%' }} data-testid="raw-editor">
517
673
  <Editor
518
674
  defaultLanguage={language}
519
- value={markdownSource}
675
+ value={editorSource}
520
676
  theme={effectiveTheme}
521
677
  beforeMount={handleBeforeMount}
522
678
  onMount={handleMount}
@@ -24,6 +24,7 @@
24
24
  import { useCallback } from 'react';
25
25
  import { RecorderPanel } from './recorder/RecorderPanel.js';
26
26
  import type { RecorderSaveResult } from './recorder/RecorderModal.js';
27
+ import { insertMediaBlock } from './recorder/insertMediaBlock.js';
27
28
  import { useEditorContext } from './EditorContext';
28
29
 
29
30
  /**
@@ -98,14 +99,10 @@ export function RecorderEntry() {
98
99
  }
99
100
  const audioTag = `<audio src="${result.relativePath}" controls></audio>`;
100
101
  if (activeView === 'wysiwyg' && tiptapEditor) {
101
- tiptapEditor
102
- .chain()
103
- .focus()
104
- .insertContent({
105
- type: 'audio',
106
- attrs: { src: result.relativePath, controls: true },
107
- })
108
- .run();
102
+ insertMediaBlock(tiptapEditor, {
103
+ type: 'audio',
104
+ attrs: { src: result.relativePath, controls: true },
105
+ });
109
106
  return;
110
107
  }
111
108
  if (activeView === 'raw' && monacoEditor) {
@@ -124,14 +121,10 @@ export function RecorderEntry() {
124
121
  // aspect ratio is preserved regardless of source dimensions.
125
122
  const videoTag = `<video src="${result.relativePath}" controls width="480"></video>`;
126
123
  if (activeView === 'wysiwyg' && tiptapEditor) {
127
- tiptapEditor
128
- .chain()
129
- .focus()
130
- .insertContent({
131
- type: 'video',
132
- attrs: { src: result.relativePath, controls: true, width: 480 },
133
- })
134
- .run();
124
+ insertMediaBlock(tiptapEditor, {
125
+ type: 'video',
126
+ attrs: { src: result.relativePath, controls: true, width: 480 },
127
+ });
135
128
  return;
136
129
  }
137
130
  if (activeView === 'raw' && monacoEditor) {
@@ -15,6 +15,7 @@
15
15
 
16
16
  import Heading from '@tiptap/extension-heading';
17
17
  import { templateLabel } from './TemplatePicker';
18
+ import { summarizeBlockProps } from './blockProperties';
18
19
 
19
20
  /**
20
21
  * HeadingWithTemplate — drop-in replacement for Tiptap's Heading that
@@ -40,6 +41,14 @@ export const HeadingWithTemplate = Heading.extend({
40
41
  return { 'data-template-params': attributes.dataTemplateParams };
41
42
  },
42
43
  },
44
+ dataBlockAttrs: {
45
+ default: null,
46
+ parseHTML: (element: HTMLElement) => element.getAttribute('data-block-attrs') || null,
47
+ renderHTML: (attributes: Record<string, unknown>) => {
48
+ if (!attributes.dataBlockAttrs) return {};
49
+ return { 'data-block-attrs': attributes.dataBlockAttrs };
50
+ },
51
+ },
43
52
  };
44
53
  },
45
54
 
@@ -47,6 +56,12 @@ export const HeadingWithTemplate = Heading.extend({
47
56
  const level = node.attrs.level;
48
57
  const tag = `h${level}`;
49
58
  const templateName = HTMLAttributes['data-template'];
59
+ // Concise summary of authored block properties (transition / timing),
60
+ // painted on the props badge so the canvas shows what's set at a glance.
61
+ const propsSummary = summarizeBlockProps(
62
+ (HTMLAttributes['data-block-attrs'] as string | undefined) ?? null,
63
+ (HTMLAttributes['data-template-params'] as string | undefined) ?? null,
64
+ );
50
65
 
51
66
  // Render heading with a trailing badge span. The badge has no text
52
67
  // content — its label is painted via CSS `content: attr(data-template-label)`
@@ -76,6 +91,7 @@ export const HeadingWithTemplate = Heading.extend({
76
91
  'data-template-label': templateLabel(templateName),
77
92
  },
78
93
  ],
94
+ propsBadgeSpec(propsSummary),
79
95
  ];
80
96
  }
81
97
 
@@ -94,6 +110,34 @@ export const HeadingWithTemplate = Heading.extend({
94
110
  title: 'Choose block template',
95
111
  },
96
112
  ],
113
+ propsBadgeSpec(propsSummary),
97
114
  ];
98
115
  },
99
116
  });
117
+
118
+ /**
119
+ * The block-properties chip rendered beside the template badge — the on-canvas
120
+ * entry point to the {@link BlockPropertiesPopover} (transition, timing, …).
121
+ *
122
+ * Like the template badge, this is an EMPTY inert span: the sliders glyph and
123
+ * the optional `summary` text are painted via CSS (`::before` / `::after` with
124
+ * `content: attr(data-props-summary)`, using the Font Awesome font the editor
125
+ * already loads). Keeping it childless is deliberate — `tiptapToMarkdown`
126
+ * serializes the rendered heading HTML, so any real child node here would risk
127
+ * leaking into the markdown; a `data-*` attribute is stripped with the span.
128
+ * Clicks are delegated in `WysiwygEditor`.
129
+ */
130
+ function propsBadgeSpec(summary: string): [string, Record<string, string>] {
131
+ const attrs: Record<string, string> = {
132
+ class: 'squisq-props-badge',
133
+ contenteditable: 'false',
134
+ role: 'button',
135
+ tabindex: '0',
136
+ 'aria-haspopup': 'dialog',
137
+ title: summary ? `Block properties — ${summary}` : 'Block properties',
138
+ };
139
+ // Only set the attribute when there's something to show, so the CSS
140
+ // `[data-props-summary]` selector cleanly distinguishes "has properties".
141
+ if (summary) attrs['data-props-summary'] = summary;
142
+ return ['span', attrs];
143
+ }