@bendyline/squisq-editor-react 1.5.3 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +1114 -52
  3. package/dist/index.js +18446 -8050
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +13867 -0
  10. package/package.json +15 -7
  11. package/src/BlockCardView.tsx +121 -0
  12. package/src/BlockPreviewPanel.tsx +69 -0
  13. package/src/BlockPropertiesPopover.tsx +191 -0
  14. package/src/EditorContext.tsx +165 -16
  15. package/src/EditorShell.tsx +265 -144
  16. package/src/FolderView.tsx +131 -0
  17. package/src/Icon.tsx +26 -0
  18. package/src/ImageEditor.tsx +69 -22
  19. package/src/OutlinePanel.tsx +64 -7
  20. package/src/PlainHtmlPreview.tsx +30 -3
  21. package/src/PreviewControls.tsx +491 -122
  22. package/src/PreviewPanel.tsx +15 -9
  23. package/src/RawEditor.tsx +179 -17
  24. package/src/RecorderEntry.tsx +9 -16
  25. package/src/TemplateAnnotation.ts +44 -0
  26. package/src/TemplatePicker.tsx +329 -54
  27. package/src/ThemeCustomizerPanel.tsx +30 -336
  28. package/src/ThemePicker.tsx +112 -3
  29. package/src/TimelineBlockPreview.tsx +37 -0
  30. package/src/TimelineTrack.tsx +671 -0
  31. package/src/Toolbar.tsx +544 -180
  32. package/src/Tooltip.tsx +22 -4
  33. package/src/TransitionPicker.tsx +351 -0
  34. package/src/VersionHistoryPanel.tsx +4 -16
  35. package/src/ViewMenuPanel.tsx +17 -14
  36. package/src/WysiwygEditor.tsx +161 -65
  37. package/src/__tests__/blockProperties.test.ts +92 -0
  38. package/src/__tests__/blockRange.test.ts +105 -0
  39. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  40. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  41. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  42. package/src/__tests__/createShapeLayer.test.ts +46 -0
  43. package/src/__tests__/diffContextSections.test.ts +39 -0
  44. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  45. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  46. package/src/__tests__/editorShellProps.test.tsx +96 -0
  47. package/src/__tests__/embeddedMedia.test.ts +48 -0
  48. package/src/__tests__/headingTransition.test.ts +138 -0
  49. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  50. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  51. package/src/__tests__/previewControls.test.tsx +70 -0
  52. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  53. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  54. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  55. package/src/__tests__/timelineSource.test.ts +134 -0
  56. package/src/__tests__/tiptapBridge.test.ts +92 -0
  57. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  58. package/src/__tests__/tooltip.test.tsx +72 -0
  59. package/src/__tests__/transitionCatalog.test.ts +64 -0
  60. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  61. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  62. package/src/__tests__/useMediaRecorder.test.ts +41 -0
  63. package/src/__tests__/useTimelineClock.test.ts +21 -0
  64. package/src/blockProperties.ts +88 -0
  65. package/src/blockRange.ts +132 -0
  66. package/src/buildPreviewDoc.ts +98 -9
  67. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  68. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  69. package/src/codeContext/CodeContextZones.tsx +121 -0
  70. package/src/codeContext/diffContextSections.ts +38 -0
  71. package/src/codeContext/types.ts +75 -0
  72. package/src/customTemplates/AddBin.tsx +126 -0
  73. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  74. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  75. package/src/customTemplates/LayerToolbar.tsx +580 -0
  76. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  77. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  78. package/src/customTemplates/__tests__/library.test.ts +88 -0
  79. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  80. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  81. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  82. package/src/customTemplates/designer.css +673 -0
  83. package/src/customTemplates/index.ts +31 -0
  84. package/src/customTemplates/library.ts +97 -0
  85. package/src/customTemplates/normalizePositions.ts +75 -0
  86. package/src/customTemplates/shapeDefs.ts +131 -0
  87. package/src/customTemplates/thumbnail.tsx +63 -0
  88. package/src/customTemplates/tokenDefs.ts +60 -0
  89. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  90. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  91. package/src/customThemes/CustomThemeContext.tsx +179 -0
  92. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  93. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  94. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  95. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  96. package/src/customThemes/customThemeLibrary.ts +97 -0
  97. package/src/customThemes/index.ts +31 -0
  98. package/src/customThemes/themeControls.tsx +229 -0
  99. package/src/customThemes/themeDraft.ts +272 -0
  100. package/src/customThemes/useDocCustomThemes.ts +49 -0
  101. package/src/diagram/DiagramCanvas.tsx +240 -0
  102. package/src/diagram/DiagramExtension.ts +209 -0
  103. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  104. package/src/diagram/DiagramWidget.tsx +270 -0
  105. package/src/diagram/diagramCommands.ts +604 -0
  106. package/src/diagram/diagramConstants.ts +17 -0
  107. package/src/diagram/useDiagramData.ts +126 -0
  108. package/src/embeddedMedia.ts +78 -0
  109. package/src/frontmatter.ts +29 -0
  110. package/src/headingTransition.ts +231 -0
  111. package/src/imageEditor/CanvasSurface.tsx +383 -88
  112. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  113. package/src/imageEditor/Toolbar.tsx +229 -16
  114. package/src/imageEditor/createShapeLayer.ts +280 -0
  115. package/src/imageEditor/icons.tsx +34 -114
  116. package/src/imageEditor/image-editor.css +54 -5
  117. package/src/imageEditor/state.ts +23 -3
  118. package/src/index.ts +109 -1
  119. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  120. package/src/recorder/RecorderModal.tsx +120 -53
  121. package/src/recorder/RecorderPanel.tsx +2 -26
  122. package/src/recorder/hooks/useMediaRecorder.ts +17 -11
  123. package/src/recorder/insertMediaBlock.ts +30 -0
  124. package/src/resolveBlockVisual.ts +33 -0
  125. package/src/scene/Scene.tsx +540 -0
  126. package/src/scene/SceneBlockExtension.ts +198 -0
  127. package/src/scene/SceneBlockToolbar.tsx +201 -0
  128. package/src/scene/SceneBlockWidget.tsx +434 -0
  129. package/src/scene/ScenePropsBar.tsx +85 -0
  130. package/src/scene/SceneSelection.tsx +107 -0
  131. package/src/scene/SceneViewport.tsx +102 -0
  132. package/src/scene/ShapePalette.tsx +181 -0
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  134. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  135. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  136. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  137. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  138. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  139. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  140. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  141. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  142. package/src/scene/adapters/blockLayers.ts +159 -0
  143. package/src/scene/commands/SceneCommand.ts +70 -0
  144. package/src/scene/commands/drawingCommands.ts +318 -0
  145. package/src/scene/commands/layoutCommands.ts +301 -0
  146. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  147. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  148. package/src/scene/hooks/useSceneSelection.ts +62 -0
  149. package/src/scene/index.ts +95 -0
  150. package/src/scene/layers/DiagramEdges.tsx +127 -0
  151. package/src/scene/layers/edgeGeometry.ts +77 -0
  152. package/src/scene/layers/nodeCard.tsx +145 -0
  153. package/src/scene/layers/renderLayer.tsx +70 -0
  154. package/src/scene/layers/shapeLayers.ts +201 -0
  155. package/src/scene/paths/bezierEdit.ts +208 -0
  156. package/src/scene/scene.css +649 -0
  157. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  158. package/src/scene/text/sceneTextChannel.ts +40 -0
  159. package/src/scene/text/sceneTextConfig.ts +27 -0
  160. package/src/scene/text/sceneTiptap.ts +36 -0
  161. package/src/scene/text/useSceneTextEditing.ts +39 -0
  162. package/src/scene/tools/ConnectTool.ts +111 -0
  163. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  164. package/src/scene/tools/PathTool.ts +158 -0
  165. package/src/scene/tools/PlaceTool.ts +47 -0
  166. package/src/scene/tools/SceneTool.ts +75 -0
  167. package/src/scene/tools/SelectTool.ts +284 -0
  168. package/src/scene/tools/ShapeTool.ts +144 -0
  169. package/src/scene/tools/TextTool.ts +72 -0
  170. package/src/scene/tools/TokenTool.ts +95 -0
  171. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  172. package/src/styles/code-context.css +155 -0
  173. package/src/styles/diagram.css +183 -0
  174. package/src/styles/editor.css +1708 -150
  175. package/src/styles/folder-view.css +210 -0
  176. package/src/styles/image-edit-affordance.css +2 -2
  177. package/src/styles/index.css +5 -0
  178. package/src/timelineSource.ts +244 -0
  179. package/src/tiptapBridge.ts +115 -34
  180. package/src/tooltipPlacement.ts +13 -0
  181. package/src/transitionCatalog.ts +159 -0
  182. package/src/types/monaco-shims.d.ts +10 -0
  183. package/src/useBlockNavigator.ts +153 -0
  184. package/src/useMonacoLoader.ts +23 -1
  185. package/src/useTimelineClock.ts +76 -0
  186. package/src/utils/dropUtils.ts +1 -1
@@ -48,6 +48,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
48
48
  activeTheme,
49
49
  activeTransformStyle,
50
50
  activeCaptionStyle,
51
+ activeCaptionsEnabled,
51
52
  } = usePreviewSettings();
52
53
 
53
54
  // Build the player-ready Doc whenever the parsed doc changes.
@@ -108,10 +109,15 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
108
109
  );
109
110
  }
110
111
 
111
- // Page mode renders directly from markdown it doesn't depend on the
112
- // parsed Doc tree or the player preview build, so let it fall through
113
- // even when those aren't ready yet.
114
- if (!previewDoc && activeDisplayMode !== 'page') {
112
+ // The public DisplayMode values predate the current labels: raw `page`
113
+ // is the plain Document preview, while raw `linear` is the styled Page view.
114
+ const isDocumentMode = activeDisplayMode === 'page';
115
+ const isPageMode = activeDisplayMode === 'linear';
116
+
117
+ // Document mode renders directly from markdown — it doesn't depend on the
118
+ // parsed Doc tree or the player preview build, so let it fall through even
119
+ // when those aren't ready yet.
120
+ if (!previewDoc && !isDocumentMode) {
115
121
  return (
116
122
  <div className={`squisq-preview-status ${className || ''}`} data-testid="preview-panel">
117
123
  <p>No content to preview. Start typing in the editor.</p>
@@ -119,8 +125,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
119
125
  );
120
126
  }
121
127
 
122
- const fillsContainer =
123
- activeDisplayMode === 'linear' || activeDisplayMode === 'page' ? 'stretch' : 'center';
128
+ const fillsContainer = isDocumentMode || isPageMode ? 'stretch' : 'center';
124
129
 
125
130
  return (
126
131
  <div
@@ -147,7 +152,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
147
152
  minHeight: 0,
148
153
  }}
149
154
  >
150
- {activeDisplayMode === 'page' ? (
155
+ {isDocumentMode ? (
151
156
  <PlainHtmlPreview
152
157
  markdown={markdownSource}
153
158
  title={(doc?.frontmatter?.title as string | undefined) ?? undefined}
@@ -155,7 +160,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
155
160
  mediaRevision={mediaRevision}
156
161
  theme={activeTheme}
157
162
  />
158
- ) : activeDisplayMode === 'linear' ? (
163
+ ) : isPageMode ? (
159
164
  <LinearDocView
160
165
  doc={doc!}
161
166
  basePath={basePath}
@@ -164,7 +169,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
164
169
  />
165
170
  ) : (
166
171
  <DocPlayer
167
- script={previewDoc!}
172
+ doc={previewDoc!}
168
173
  basePath={basePath}
169
174
  showControls
170
175
  muted
@@ -172,6 +177,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
172
177
  displayMode={activeDisplayMode}
173
178
  theme={activeTheme}
174
179
  captionStyle={activeCaptionStyle}
180
+ captionsEnabled={activeCaptionsEnabled}
175
181
  />
176
182
  )}
177
183
  </div>
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
@@ -50,8 +56,14 @@ const SQUISQ_THEMES: Record<string, string> = {
50
56
  };
51
57
 
52
58
  export interface RawEditorProps {
53
- /** Monaco editor theme (default: 'vs-dark') */
54
- theme?: string;
59
+ /**
60
+ * Monaco editor theme name (default: `'vs'`). Accepts Monaco's built-in
61
+ * theme ids (`'vs'`, `'vs-dark'`, `'hc-black'`) — which are transparently
62
+ * mapped to the Squisq-tinted variants — or any custom theme registered
63
+ * via `monaco.editor.defineTheme`. This is the *code editor* color
64
+ * theme, distinct from the shell's light/dark `colorScheme`.
65
+ */
66
+ monacoTheme?: string;
55
67
  /** Show minimap (default: false) */
56
68
  minimap?: boolean;
57
69
  /** Font size in pixels (default: 14) */
@@ -74,7 +86,7 @@ export interface RawEditorProps {
74
86
  * Binds to the shared EditorContext for source synchronization.
75
87
  */
76
88
  export function RawEditor({
77
- theme = 'vs',
89
+ monacoTheme = 'vs',
78
90
  minimap = false,
79
91
  fontSize = 14,
80
92
  wordWrap = 'on',
@@ -82,7 +94,7 @@ export function RawEditor({
82
94
  submitOnEnter,
83
95
  readOnly = false,
84
96
  }: RawEditorProps) {
85
- const { markdownSource, setMarkdownSource, setMonacoEditor, language, mentionProvider } =
97
+ const { editorSource, setEditorSource, setMonacoEditor, language, mentionProvider, doc } =
86
98
  useEditorContext();
87
99
  const { monaco: monacoNs, ready: monacoReady } = useMonacoLoader();
88
100
  const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
@@ -90,6 +102,7 @@ export function RawEditor({
90
102
  const completionDisposable = useRef<monaco.IDisposable | null>(null);
91
103
  const mentionCompletionDisposable = useRef<monaco.IDisposable | null>(null);
92
104
  const iconCompletionDisposable = useRef<monaco.IDisposable | null>(null);
105
+ const attrCompletionDisposable = useRef<monaco.IDisposable | null>(null);
93
106
  const iconGlyphDecorations = useRef<monaco.editor.IEditorDecorationsCollection | null>(null);
94
107
  const dropCleanupRef = useRef<(() => void) | null>(null);
95
108
  const keyDisposable = useRef<monaco.IDisposable | null>(null);
@@ -105,6 +118,35 @@ export function RawEditor({
105
118
  mentionProviderRef.current = mentionProvider;
106
119
  }, [mentionProvider]);
107
120
 
121
+ // Template completion list: built-in registry names plus any custom
122
+ // templates inlined in the active doc's frontmatter (`doc.customTemplates`
123
+ // — the same set that actually renders via `{[name]}`). Held in a ref
124
+ // (like mentionProvider above) so the once-registered provider always
125
+ // reads the latest set — custom templates created after mount must show
126
+ // up without re-mounting Monaco.
127
+ const docCustomTemplates = doc?.customTemplates;
128
+ const templateEntriesRef = useRef<{ name: string; detail: string }[] | null>(null);
129
+ if (templateEntriesRef.current === null) {
130
+ templateEntriesRef.current = getAvailableTemplates().map((name) => ({
131
+ name,
132
+ detail: 'Block template',
133
+ }));
134
+ }
135
+ useEffect(() => {
136
+ const builtIns = getAvailableTemplates().map((name) => ({
137
+ name,
138
+ detail: 'Block template',
139
+ }));
140
+ const seen = new Set(builtIns.map((b) => b.name));
141
+ const custom = (docCustomTemplates ?? [])
142
+ .filter((t) => !seen.has(t.name))
143
+ .map((t) => ({
144
+ name: t.name,
145
+ detail: t.label ? `Custom — ${t.label}` : 'Custom template',
146
+ }));
147
+ templateEntriesRef.current = [...builtIns, ...custom];
148
+ }, [docCustomTemplates]);
149
+
108
150
  const handleBeforeMount: BeforeMount = useCallback((monaco) => {
109
151
  monaco.editor.defineTheme('squisq-light', {
110
152
  base: 'vs',
@@ -143,11 +185,12 @@ export function RawEditor({
143
185
  mentionCompletionDisposable.current = null;
144
186
  iconCompletionDisposable.current?.dispose();
145
187
  iconCompletionDisposable.current = null;
188
+ attrCompletionDisposable.current?.dispose();
189
+ attrCompletionDisposable.current = null;
146
190
 
147
191
  // Register the `{[template]}` completion provider only for markdown
148
192
  // files — it's meaningless for TypeScript, JSON, Python, etc.
149
193
  if (language === 'markdown') {
150
- const templates = getAvailableTemplates();
151
194
  completionDisposable.current = monaco.languages.registerCompletionItemProvider('markdown', {
152
195
  triggerCharacters: ['['],
153
196
  provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) {
@@ -161,6 +204,12 @@ export function RawEditor({
161
204
  const bracketIdx = textBeforeCursor.lastIndexOf('{[');
162
205
  if (bracketIdx === -1) return { suggestions: [] };
163
206
 
207
+ // Template names are the FIRST token after `{[`. Once a space
208
+ // follows the name we're in the `key=value` attribute region —
209
+ // the attribute provider handles that, so bail here to avoid
210
+ // offering template names mid-attribute.
211
+ if (/\s/.test(textBeforeCursor.slice(bracketIdx + 2))) return { suggestions: [] };
212
+
164
213
  // When Monaco's bracket auto-pair has already produced the
165
214
  // closing `]}` we just leave it in place and skip the
166
215
  // suffix — otherwise accepting `sectionHeader` on
@@ -176,13 +225,13 @@ export function RawEditor({
176
225
  position.column,
177
226
  );
178
227
 
179
- const suggestions = templates.map((name) => ({
228
+ const suggestions = (templateEntriesRef.current ?? []).map(({ name, detail }) => ({
180
229
  label: name,
181
230
  filterText: name,
182
231
  kind: monaco.languages.CompletionItemKind.Value,
183
232
  insertText: name + suffix,
184
233
  range,
185
- detail: 'Block template',
234
+ detail,
186
235
  sortText: name,
187
236
  }));
188
237
 
@@ -324,6 +373,110 @@ export function RawEditor({
324
373
  },
325
374
  },
326
375
  );
376
+
377
+ // `{[name key=value]}` block-meta attribute completion. Fires on
378
+ // heading lines once the cursor is past the template name (the
379
+ // first token), where attributes like `transition=` apply to the
380
+ // block. Two modes:
381
+ // - bare token → suggest attribute KEYS (insert `key=`)
382
+ // - after `key=` → suggest that key's VALUES, when it has a
383
+ // closed set (e.g. transition / transitionDirection)
384
+ // The descriptor list comes from core so keys and value enums stay
385
+ // in lockstep with what `coerceAnnotationValues` actually accepts.
386
+ attrCompletionDisposable.current = monaco.languages.registerCompletionItemProvider(
387
+ 'markdown',
388
+ {
389
+ triggerCharacters: ['=', ' '],
390
+ provideCompletionItems(model, position) {
391
+ const lineContent = model.getLineContent(position.lineNumber);
392
+ // Block-meta attributes only mean something on a heading's
393
+ // template annotation — same gate as the template provider.
394
+ if (!/^#{1,6}\s/.test(lineContent)) return { suggestions: [] };
395
+
396
+ const textBeforeCursor = lineContent.substring(0, position.column - 1);
397
+ const bracketIdx = textBeforeCursor.lastIndexOf('{[');
398
+ if (bracketIdx === -1) return { suggestions: [] };
399
+
400
+ // Bail if a `]` already closed the annotation before the
401
+ // cursor — we'd be past it, not inside the attribute list.
402
+ const innerBefore = textBeforeCursor.slice(bracketIdx + 2);
403
+ if (innerBefore.includes(']')) return { suggestions: [] };
404
+ // The first token is the template name; attributes only begin
405
+ // after a whitespace separator. No space yet → still the name.
406
+ if (!/\s/.test(innerBefore)) return { suggestions: [] };
407
+
408
+ // Current token = run of non-whitespace ending at the cursor.
409
+ const lastWsMatch = innerBefore.match(/\s(\S*)$/);
410
+ const currentToken = lastWsMatch ? lastWsMatch[1] : '';
411
+ const tokenStartCol = position.column - currentToken.length;
412
+
413
+ const eqIdx = currentToken.indexOf('=');
414
+ if (eqIdx >= 0) {
415
+ // ── Value mode: `key=<partial>` ──
416
+ const key = currentToken.slice(0, eqIdx);
417
+ const descriptor = BLOCK_META_KEY_DESCRIPTORS.find((d) => d.key === key);
418
+ if (!descriptor?.values) return { suggestions: [] };
419
+ const range = new monaco.Range(
420
+ position.lineNumber,
421
+ tokenStartCol + eqIdx + 1, // after `=`
422
+ position.lineNumber,
423
+ position.column,
424
+ );
425
+ return {
426
+ suggestions: descriptor.values.map((value, i) => ({
427
+ label: value,
428
+ filterText: value,
429
+ kind: monaco.languages.CompletionItemKind.EnumMember,
430
+ insertText: value,
431
+ range,
432
+ detail: descriptor.description,
433
+ sortText: String(i).padStart(4, '0'),
434
+ })),
435
+ };
436
+ }
437
+
438
+ // ── Key mode: suggest attribute names not already present ──
439
+ // Collect keys already set anywhere in the annotation so we
440
+ // don't re-offer them; keep the key under the cursor eligible
441
+ // so re-editing an existing `key=` still suggests it.
442
+ const afterBracket = lineContent.slice(bracketIdx + 2);
443
+ const closeIdx = afterBracket.indexOf(']}');
444
+ const fullInner = closeIdx === -1 ? afterBracket : afterBracket.slice(0, closeIdx);
445
+ const present = new Set(
446
+ tokenizeAttrTokens(fullInner)
447
+ .map((t) => {
448
+ const i = t.indexOf('=');
449
+ return i > 0 ? t.slice(0, i) : null;
450
+ })
451
+ .filter((k): k is string => k != null),
452
+ );
453
+ present.delete(currentToken);
454
+
455
+ const range = new monaco.Range(
456
+ position.lineNumber,
457
+ tokenStartCol,
458
+ position.lineNumber,
459
+ position.column,
460
+ );
461
+ const suggestions = BLOCK_META_KEY_DESCRIPTORS.filter((d) => !present.has(d.key)).map(
462
+ (d, i) => ({
463
+ label: d.key,
464
+ filterText: d.key,
465
+ kind: monaco.languages.CompletionItemKind.Property,
466
+ insertText: `${d.key}=`,
467
+ range,
468
+ detail: d.values ? d.description : `${d.description} — ${d.valueHint}`,
469
+ sortText: String(i).padStart(4, '0'),
470
+ // Closed-set keys chain straight into their value list.
471
+ ...(d.values
472
+ ? { command: { id: 'editor.action.triggerSuggest', title: '' } }
473
+ : {}),
474
+ }),
475
+ );
476
+ return { suggestions };
477
+ },
478
+ },
479
+ );
327
480
  }
328
481
 
329
482
  // Chat-composer mode: intercept Enter before Monaco inserts a newline.
@@ -401,6 +554,8 @@ export function RawEditor({
401
554
  mentionCompletionDisposable.current = null;
402
555
  iconCompletionDisposable.current?.dispose();
403
556
  iconCompletionDisposable.current = null;
557
+ attrCompletionDisposable.current?.dispose();
558
+ attrCompletionDisposable.current = null;
404
559
  iconGlyphDecorations.current?.clear();
405
560
  iconGlyphDecorations.current = null;
406
561
  dropCleanupRef.current?.();
@@ -414,24 +569,31 @@ export function RawEditor({
414
569
  (value) => {
415
570
  if (isExternalUpdate.current) return;
416
571
  if (value !== undefined) {
417
- setMarkdownSource(value);
572
+ setEditorSource(value);
418
573
  }
419
574
  },
420
- [setMarkdownSource],
575
+ [setEditorSource],
421
576
  );
422
577
 
423
- // When external changes happen (e.g. from WYSIWYG), update Monaco
578
+ // When external changes happen (e.g. from WYSIWYG, or navigating to another
579
+ // block in block-at-a-time mode), update Monaco. In block mode `editorSource`
580
+ // is the active block's slice, so this swaps Monaco's content on navigation.
424
581
  useEffect(() => {
425
582
  const editor = editorRef.current;
426
583
  if (editor) {
427
584
  const currentValue = editor.getValue();
428
- if (currentValue !== markdownSource) {
585
+ // Ignore trailing-whitespace-only differences from block-mode splice
586
+ // normalization — calling setValue for those resets the Monaco cursor.
587
+ if (
588
+ currentValue !== editorSource &&
589
+ currentValue.replace(/\s+$/, '') !== editorSource.replace(/\s+$/, '')
590
+ ) {
429
591
  isExternalUpdate.current = true;
430
- editor.setValue(markdownSource);
592
+ editor.setValue(editorSource);
431
593
  isExternalUpdate.current = false;
432
594
  }
433
595
  }
434
- }, [markdownSource]);
596
+ }, [editorSource]);
435
597
 
436
598
  // ── Inline FontAwesome glyph decorations ────────────
437
599
  // Walk the markdown source on every change, find each resolvable
@@ -482,9 +644,9 @@ export function RawEditor({
482
644
  } else {
483
645
  iconGlyphDecorations.current.set(decorations);
484
646
  }
485
- }, [markdownSource, language, monacoNs]);
647
+ }, [editorSource, language, monacoNs]);
486
648
 
487
- const effectiveTheme = SQUISQ_THEMES[theme] ?? theme;
649
+ const effectiveTheme = SQUISQ_THEMES[monacoTheme] ?? monacoTheme;
488
650
 
489
651
  // Wait for the lazy monaco namespace + `loader.config()` to settle
490
652
  // before mounting `<Editor>`. Without this gate, the @monaco-editor/
@@ -516,7 +678,7 @@ export function RawEditor({
516
678
  <div className={className} style={{ width: '100%', height: '100%' }} data-testid="raw-editor">
517
679
  <Editor
518
680
  defaultLanguage={language}
519
- value={markdownSource}
681
+ value={editorSource}
520
682
  theme={effectiveTheme}
521
683
  beforeMount={handleBeforeMount}
522
684
  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
+ }