@bendyline/squisq-editor-react 1.5.2 → 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 +16910 -6844
  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 +216 -29
  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 +61 -31
  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 +17 -2
  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 +105 -0
  164. package/src/useTimelineClock.ts +76 -0
  165. package/src/utils/dropUtils.ts +1 -1
package/src/RawEditor.tsx CHANGED
@@ -7,29 +7,36 @@
7
7
  */
8
8
 
9
9
  import { useRef, useCallback, useEffect } from 'react';
10
- import Editor, {
11
- loader,
12
- type OnMount,
13
- type OnChange,
14
- type BeforeMount,
15
- } from '@monaco-editor/react';
16
- import * as monaco from 'monaco-editor';
10
+ import Editor, { type OnMount, type OnChange, type BeforeMount } from '@monaco-editor/react';
11
+ import type * as monaco from 'monaco-editor';
17
12
  import { useEditorContext } from './EditorContext';
18
13
  import { getAvailableTemplates } from '@bendyline/squisq/doc';
19
14
  import { suggestIcons, resolveIcon, iconGlyph } from '@bendyline/squisq/icons';
15
+ import { BLOCK_META_KEY_DESCRIPTORS, tokenizeAttrTokens } from '@bendyline/squisq/markdown';
20
16
  import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
21
-
22
- // Use locally installed monaco-editor instead of CDN.
17
+ import { useMonacoLoader } from './useMonacoLoader';
18
+
19
+ // Monaco is loaded lazily through `useMonacoLoader` (see the hook for the
20
+ // rationale). The type-only `import type * as monaco from 'monaco-editor'`
21
+ // above gives us `monaco.editor.IStandaloneCodeEditor`, `monaco.Range`,
22
+ // etc. for typing without pulling the package into the static module
23
+ // graph — which is the whole point: a consumer importing `JsonEditor` or
24
+ // a type from the package barrel no longer drags ~9MB of language
25
+ // services into the resolver.
23
26
  //
24
- // NOTE: By default this imports the full monaco-editor with all 80+ languages
25
- // and workers (~9MB). Consumers can dramatically reduce bundle size by aliasing
26
- // 'monaco-editor' to a slim entry in their bundler config. For example with Vite:
27
+ // Consumers that *do* want the raw editor can still slim the bundle by
28
+ // aliasing `monaco-editor` to a custom entry in their bundler config.
29
+ // For example with Vite:
27
30
  //
28
31
  // resolve: { alias: [{ find: /^monaco-editor$/, replacement: './monaco-slim.ts' }] }
29
32
  //
30
- // Where monaco-slim.ts re-exports 'monaco-editor/esm/vs/editor/editor.api' plus
31
- // only the language contributions needed (e.g. markdown, javascript, etc.).
32
- loader.config({ monaco });
33
+ // Where monaco-slim.ts re-exports 'monaco-editor/esm/vs/editor/editor.api'
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.
33
40
 
34
41
  // Squisq Monaco themes: same syntax highlighting as vs / vs-dark, but with
35
42
  // Monaco's internal gutter (line numbers + folding margin) and overview
@@ -81,13 +88,15 @@ export function RawEditor({
81
88
  submitOnEnter,
82
89
  readOnly = false,
83
90
  }: RawEditorProps) {
84
- const { markdownSource, setMarkdownSource, setMonacoEditor, language, mentionProvider } =
91
+ const { editorSource, setEditorSource, setMonacoEditor, language, mentionProvider, doc } =
85
92
  useEditorContext();
93
+ const { monaco: monacoNs, ready: monacoReady } = useMonacoLoader();
86
94
  const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
87
95
  const isExternalUpdate = useRef(false);
88
96
  const completionDisposable = useRef<monaco.IDisposable | null>(null);
89
97
  const mentionCompletionDisposable = useRef<monaco.IDisposable | null>(null);
90
98
  const iconCompletionDisposable = useRef<monaco.IDisposable | null>(null);
99
+ const attrCompletionDisposable = useRef<monaco.IDisposable | null>(null);
91
100
  const iconGlyphDecorations = useRef<monaco.editor.IEditorDecorationsCollection | null>(null);
92
101
  const dropCleanupRef = useRef<(() => void) | null>(null);
93
102
  const keyDisposable = useRef<monaco.IDisposable | null>(null);
@@ -103,6 +112,35 @@ export function RawEditor({
103
112
  mentionProviderRef.current = mentionProvider;
104
113
  }, [mentionProvider]);
105
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
+
106
144
  const handleBeforeMount: BeforeMount = useCallback((monaco) => {
107
145
  monaco.editor.defineTheme('squisq-light', {
108
146
  base: 'vs',
@@ -141,11 +179,12 @@ export function RawEditor({
141
179
  mentionCompletionDisposable.current = null;
142
180
  iconCompletionDisposable.current?.dispose();
143
181
  iconCompletionDisposable.current = null;
182
+ attrCompletionDisposable.current?.dispose();
183
+ attrCompletionDisposable.current = null;
144
184
 
145
185
  // Register the `{[template]}` completion provider only for markdown
146
186
  // files — it's meaningless for TypeScript, JSON, Python, etc.
147
187
  if (language === 'markdown') {
148
- const templates = getAvailableTemplates();
149
188
  completionDisposable.current = monaco.languages.registerCompletionItemProvider('markdown', {
150
189
  triggerCharacters: ['['],
151
190
  provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) {
@@ -159,6 +198,12 @@ export function RawEditor({
159
198
  const bracketIdx = textBeforeCursor.lastIndexOf('{[');
160
199
  if (bracketIdx === -1) return { suggestions: [] };
161
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
+
162
207
  // When Monaco's bracket auto-pair has already produced the
163
208
  // closing `]}` we just leave it in place and skip the
164
209
  // suffix — otherwise accepting `sectionHeader` on
@@ -174,13 +219,13 @@ export function RawEditor({
174
219
  position.column,
175
220
  );
176
221
 
177
- const suggestions = templates.map((name) => ({
222
+ const suggestions = (templateEntriesRef.current ?? []).map(({ name, detail }) => ({
178
223
  label: name,
179
224
  filterText: name,
180
225
  kind: monaco.languages.CompletionItemKind.Value,
181
226
  insertText: name + suffix,
182
227
  range,
183
- detail: 'Block template',
228
+ detail,
184
229
  sortText: name,
185
230
  }));
186
231
 
@@ -322,6 +367,110 @@ export function RawEditor({
322
367
  },
323
368
  },
324
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
+ );
325
474
  }
326
475
 
327
476
  // Chat-composer mode: intercept Enter before Monaco inserts a newline.
@@ -399,6 +548,8 @@ export function RawEditor({
399
548
  mentionCompletionDisposable.current = null;
400
549
  iconCompletionDisposable.current?.dispose();
401
550
  iconCompletionDisposable.current = null;
551
+ attrCompletionDisposable.current?.dispose();
552
+ attrCompletionDisposable.current = null;
402
553
  iconGlyphDecorations.current?.clear();
403
554
  iconGlyphDecorations.current = null;
404
555
  dropCleanupRef.current?.();
@@ -412,24 +563,31 @@ export function RawEditor({
412
563
  (value) => {
413
564
  if (isExternalUpdate.current) return;
414
565
  if (value !== undefined) {
415
- setMarkdownSource(value);
566
+ setEditorSource(value);
416
567
  }
417
568
  },
418
- [setMarkdownSource],
569
+ [setEditorSource],
419
570
  );
420
571
 
421
- // 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.
422
575
  useEffect(() => {
423
576
  const editor = editorRef.current;
424
577
  if (editor) {
425
578
  const currentValue = editor.getValue();
426
- 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
+ ) {
427
585
  isExternalUpdate.current = true;
428
- editor.setValue(markdownSource);
586
+ editor.setValue(editorSource);
429
587
  isExternalUpdate.current = false;
430
588
  }
431
589
  }
432
- }, [markdownSource]);
590
+ }, [editorSource]);
433
591
 
434
592
  // ── Inline FontAwesome glyph decorations ────────────
435
593
  // Walk the markdown source on every change, find each resolvable
@@ -439,7 +597,10 @@ export function RawEditor({
439
597
  // and weight; the codepoint character is the decoration's content.
440
598
  useEffect(() => {
441
599
  const editor = editorRef.current;
442
- if (!editor) return;
600
+ // `monacoNs` is read from the lazy loader's state rather than a
601
+ // top-level import. Re-run when it transitions from null → loaded
602
+ // so decorations show up the moment monaco is in hand.
603
+ if (!editor || !monacoNs) return;
443
604
  if (language !== 'markdown') return;
444
605
  const model = editor.getModel();
445
606
  if (!model) return;
@@ -461,7 +622,7 @@ export function RawEditor({
461
622
  // glyph as content prepended visually to that position.
462
623
  const col = match.index + 1; // Monaco columns are 1-based
463
624
  decorations.push({
464
- range: new monaco.Range(line, col, line, col),
625
+ range: new monacoNs.Range(line, col, line, col),
465
626
  options: {
466
627
  before: {
467
628
  content: glyph,
@@ -477,15 +638,41 @@ export function RawEditor({
477
638
  } else {
478
639
  iconGlyphDecorations.current.set(decorations);
479
640
  }
480
- }, [markdownSource, language]);
641
+ }, [editorSource, language, monacoNs]);
481
642
 
482
643
  const effectiveTheme = SQUISQ_THEMES[theme] ?? theme;
483
644
 
645
+ // Wait for the lazy monaco namespace + `loader.config()` to settle
646
+ // before mounting `<Editor>`. Without this gate, the @monaco-editor/
647
+ // react singleton loader would fall back to its built-in CDN fetch
648
+ // for any consumer that hasn't aliased monaco-editor — which is the
649
+ // exact regression the lazy-loading move is meant to avoid.
650
+ if (!monacoReady) {
651
+ return (
652
+ <div
653
+ className={className}
654
+ style={{
655
+ width: '100%',
656
+ height: '100%',
657
+ display: 'flex',
658
+ alignItems: 'center',
659
+ justifyContent: 'center',
660
+ color: 'var(--squisq-editor-muted-foreground, #6a6258)',
661
+ fontSize: 13,
662
+ }}
663
+ data-testid="raw-editor"
664
+ data-monaco-loading
665
+ >
666
+ Loading editor…
667
+ </div>
668
+ );
669
+ }
670
+
484
671
  return (
485
672
  <div className={className} style={{ width: '100%', height: '100%' }} data-testid="raw-editor">
486
673
  <Editor
487
674
  defaultLanguage={language}
488
- value={markdownSource}
675
+ value={editorSource}
489
676
  theme={effectiveTheme}
490
677
  beforeMount={handleBeforeMount}
491
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
+ }