@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
package/src/Toolbar.tsx CHANGED
@@ -10,17 +10,30 @@
10
10
  import type { ReactNode } from 'react';
11
11
  import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
12
12
  import type { Editor as TiptapEditor } from '@tiptap/core';
13
+ import { TextSelection } from '@tiptap/pm/state';
14
+ import { Fragment } from '@tiptap/pm/model';
13
15
  import type { IRange } from 'monaco-editor';
14
16
  import { useEditorContext, type EditorView } from './EditorContext';
15
17
  import { VersionHistoryPanel } from './VersionHistoryPanel';
16
18
  import { RecorderEntry } from './RecorderEntry';
17
19
  import { ViewMenuPanel } from './ViewMenuPanel';
18
20
  import { TemplatePicker, TEMPLATE_NAMES } from './TemplatePicker';
21
+ import { TransitionPicker } from './TransitionPicker';
22
+ import {
23
+ readHeadingLineTransition,
24
+ setHeadingLineTransition,
25
+ readBlockAttrsTransition,
26
+ setBlockAttrsTransition,
27
+ EMPTY_TRANSITION,
28
+ type TransitionFields,
29
+ } from './headingTransition';
19
30
  import { profileBlockContents, recommendTemplatesForBlock } from '@bendyline/squisq/recommend';
20
31
  import { findBlockSliceAtLine, findBlockSliceByHeadingIndex } from './blockSlice';
21
32
  import { LinkDialog } from './LinkDialog';
22
33
  import { DocumentSettingsDialog } from './DocumentSettingsDialog';
23
34
  import { EmojiPicker, EMOJI_PICKER_WIDTH, EMOJI_PICKER_MAX_HEIGHT } from './EmojiPicker';
35
+ import { CustomLayoutManager } from './customTemplates/CustomLayoutManager';
36
+ import { Icon } from './Icon';
24
37
  import type { PickerEntry } from './emojiData';
25
38
  import { createPortal } from 'react-dom';
26
39
 
@@ -39,6 +52,10 @@ export interface ToolbarProps {
39
52
  onToggleFiles?: () => void;
40
53
  /** Content rendered at the left edge of the toolbar, before the view tabs. */
41
54
  slotLeft?: ReactNode;
55
+ /** Content rendered immediately after the view tabs, on the left side of the
56
+ * toolbar (before the formatting controls). Used for the preview mode
57
+ * switch in Play view. */
58
+ slotAfterTabs?: ReactNode;
42
59
  /** Content rendered after the formatting controls (in the middle area). */
43
60
  slotAfterActions?: ReactNode;
44
61
  /** Content rendered at the rightmost end of the toolbar, after all other elements. */
@@ -54,11 +71,12 @@ export interface ToolbarProps {
54
71
  interface ToolbarButton {
55
72
  id: string;
56
73
  label: string;
74
+ /** Text glyph shown when the button has no Font Awesome icon (headings). */
57
75
  icon: string;
58
76
  title: string;
59
77
  group: 'format' | 'lists' | 'structure' | 'insert' | 'media';
60
- /** CSS font style for the icon (e.g. italic for the I button) */
61
- iconStyle?: React.CSSProperties;
78
+ /** Font Awesome class string (e.g. `"fa-solid fa-bold"`); omitted for text buttons. */
79
+ faIcon?: string;
62
80
  }
63
81
 
64
82
  const BUTTONS: ToolbarButton[] = [
@@ -69,7 +87,7 @@ const BUTTONS: ToolbarButton[] = [
69
87
  icon: 'B',
70
88
  title: 'Bold (Ctrl+B)',
71
89
  group: 'format',
72
- iconStyle: { fontWeight: 700 },
90
+ faIcon: 'fa-solid fa-bold',
73
91
  },
74
92
  {
75
93
  id: 'italic',
@@ -77,7 +95,7 @@ const BUTTONS: ToolbarButton[] = [
77
95
  icon: 'I',
78
96
  title: 'Italic (Ctrl+I)',
79
97
  group: 'format',
80
- iconStyle: { fontStyle: 'italic' },
98
+ faIcon: 'fa-solid fa-italic',
81
99
  },
82
100
  {
83
101
  id: 'strikethrough',
@@ -85,15 +103,30 @@ const BUTTONS: ToolbarButton[] = [
85
103
  icon: 'S',
86
104
  title: 'Strikethrough',
87
105
  group: 'format',
88
- iconStyle: { textDecoration: 'line-through' },
106
+ faIcon: 'fa-solid fa-strikethrough',
89
107
  },
90
108
 
91
109
  // Lists group — sits between format and structure so bullets/numbers
92
110
  // are adjacent to the inline formatters people reach for together.
93
- { id: 'ul', label: '•', icon: '•', title: 'Bullet list', group: 'lists' },
94
- { id: 'ol', label: '1.', icon: '1.', title: 'Numbered list', group: 'lists' },
111
+ {
112
+ id: 'ul',
113
+ label: '•',
114
+ icon: '•',
115
+ title: 'Bullet list',
116
+ group: 'lists',
117
+ faIcon: 'fa-solid fa-list-ul',
118
+ },
119
+ {
120
+ id: 'ol',
121
+ label: '1.',
122
+ icon: '1.',
123
+ title: 'Numbered list',
124
+ group: 'lists',
125
+ faIcon: 'fa-solid fa-list-ol',
126
+ },
95
127
 
96
- // Structure group
128
+ // Structure group — headings keep their text labels; Font Awesome Free
129
+ // has no numbered (H1–H6) heading glyphs, and the numerals are clearer.
97
130
  { id: 'h1', label: 'H1', icon: 'H1', title: 'Heading 1', group: 'structure' },
98
131
  { id: 'h2', label: 'H2', icon: 'H2', title: 'Heading 2', group: 'structure' },
99
132
  { id: 'h3', label: 'H3', icon: 'H3', title: 'Heading 3', group: 'structure' },
@@ -102,119 +135,105 @@ const BUTTONS: ToolbarButton[] = [
102
135
  { id: 'h6', label: 'H6', icon: 'H6', title: 'Heading 6', group: 'structure' },
103
136
 
104
137
  // Insert group — block-level inserts (quote, code blocks, rules)
105
- { id: 'quote', label: '❝', icon: '❝', title: 'Blockquote', group: 'insert' },
106
- { id: 'codeblock', label: '{ }', icon: '{ }', title: 'Code block', group: 'insert' },
107
- { id: 'code', label: '</>', icon: '</>', title: 'Inline code', group: 'insert' },
108
- { id: 'hr', label: '—', icon: '', title: 'Horizontal rule', group: 'insert' },
138
+ {
139
+ id: 'quote',
140
+ label: '',
141
+ icon: '',
142
+ title: 'Blockquote',
143
+ group: 'insert',
144
+ faIcon: 'fa-solid fa-quote-left',
145
+ },
146
+ {
147
+ id: 'codeblock',
148
+ label: '{ }',
149
+ icon: '{ }',
150
+ title: 'Code block',
151
+ group: 'insert',
152
+ faIcon: 'fa-solid fa-file-code',
153
+ },
154
+ {
155
+ id: 'code',
156
+ label: '</>',
157
+ icon: '</>',
158
+ title: 'Inline code',
159
+ group: 'insert',
160
+ faIcon: 'fa-solid fa-code',
161
+ },
162
+ {
163
+ id: 'hr',
164
+ label: '—',
165
+ icon: '—',
166
+ title: 'Horizontal rule',
167
+ group: 'insert',
168
+ faIcon: 'fa-solid fa-minus',
169
+ },
109
170
 
110
171
  // Media group — links, tables, images, emoji
111
- { id: 'link', label: '🔗', icon: '🔗', title: 'Insert link', group: 'media' },
112
- { id: 'table', label: 'table', icon: '', title: 'Insert table', group: 'media' },
113
- { id: 'image', label: '🖼', icon: '🖼', title: 'Insert image', group: 'media' },
114
- { id: 'emoji', label: '😊', icon: '😊', title: 'Insert emoji', group: 'media' },
172
+ {
173
+ id: 'link',
174
+ label: '🔗',
175
+ icon: '🔗',
176
+ title: 'Insert link',
177
+ group: 'media',
178
+ faIcon: 'fa-solid fa-link',
179
+ },
180
+ {
181
+ id: 'table',
182
+ label: 'table',
183
+ icon: '',
184
+ title: 'Insert table',
185
+ group: 'media',
186
+ faIcon: 'fa-solid fa-table',
187
+ },
188
+ {
189
+ id: 'diagram',
190
+ label: 'diagram',
191
+ icon: '',
192
+ title: 'Insert diagram',
193
+ group: 'media',
194
+ faIcon: 'fa-solid fa-diagram-project',
195
+ },
196
+ {
197
+ id: 'drawing',
198
+ label: 'drawing',
199
+ icon: '',
200
+ title: 'Insert drawing',
201
+ group: 'media',
202
+ faIcon: 'fa-solid fa-pen-nib',
203
+ },
204
+ {
205
+ id: 'layout',
206
+ label: 'layout',
207
+ icon: '',
208
+ title: 'Insert layout',
209
+ group: 'media',
210
+ faIcon: 'fa-solid fa-object-group',
211
+ },
212
+ {
213
+ id: 'image',
214
+ label: '🖼',
215
+ icon: '🖼',
216
+ title: 'Insert image',
217
+ group: 'media',
218
+ faIcon: 'fa-solid fa-image',
219
+ },
220
+ {
221
+ id: 'emoji',
222
+ label: '😊',
223
+ icon: '😊',
224
+ title: 'Insert emoji',
225
+ group: 'media',
226
+ faIcon: 'fa-solid fa-face-smile',
227
+ },
115
228
  ];
116
229
 
117
- // ─── Inline SVG icons (line-art, currentColor) ──────────
118
-
119
- const TABLE_ICON = (
120
- <svg
121
- width="14"
122
- height="14"
123
- viewBox="0 0 14 14"
124
- fill="none"
125
- stroke="currentColor"
126
- strokeWidth="1.4"
127
- strokeLinecap="round"
128
- >
129
- <rect x="1" y="1" width="12" height="12" rx="1" />
130
- <line x1="1" y1="5" x2="13" y2="5" />
131
- <line x1="1" y1="9" x2="13" y2="9" />
132
- <line x1="5" y1="1" x2="5" y2="13" />
133
- <line x1="9" y1="1" x2="9" y2="13" />
134
- </svg>
135
- );
136
-
137
- const LINK_ICON = (
138
- <svg
139
- width="14"
140
- height="14"
141
- viewBox="0 0 14 14"
142
- fill="none"
143
- stroke="currentColor"
144
- strokeWidth="1.4"
145
- strokeLinecap="round"
146
- strokeLinejoin="round"
147
- >
148
- <path d="M5.75 8.25 L8.25 5.75" />
149
- <path d="M6.5 3.75 L8 2.25 a2.5 2.5 0 0 1 3.54 3.54 L10 7.25" />
150
- <path d="M7.5 10.25 L6 11.75 a2.5 2.5 0 0 1 -3.54 -3.54 L4 6.75" />
151
- </svg>
152
- );
153
-
154
- const IMAGE_ICON = (
155
- <svg
156
- width="14"
157
- height="14"
158
- viewBox="0 0 14 14"
159
- fill="none"
160
- stroke="currentColor"
161
- strokeWidth="1.4"
162
- strokeLinecap="round"
163
- strokeLinejoin="round"
164
- >
165
- <rect x="1.5" y="2.5" width="11" height="9" rx="1" />
166
- <circle cx="5" cy="5.5" r="0.9" />
167
- <path d="M2 10 L5.5 7 L8 9 L10 7.5 L12.5 10" />
168
- </svg>
169
- );
170
-
171
- const PAPERCLIP_ICON = (
172
- <svg
173
- width="14"
174
- height="14"
175
- viewBox="0 0 14 14"
176
- fill="none"
177
- stroke="currentColor"
178
- strokeWidth="1.4"
179
- strokeLinecap="round"
180
- strokeLinejoin="round"
181
- >
182
- <path d="M11 4 L5.5 9.5 a1.75 1.75 0 0 0 2.5 2.5 L12.5 7.5 a3 3 0 0 0 -4.25 -4.25 L3 8.5 a4.25 4.25 0 0 0 6 6 L13 10.5" />
183
- </svg>
184
- );
185
-
186
- const EMOJI_ICON = (
187
- <svg
188
- width="14"
189
- height="14"
190
- viewBox="0 0 14 14"
191
- fill="none"
192
- stroke="currentColor"
193
- strokeWidth="1.4"
194
- strokeLinecap="round"
195
- strokeLinejoin="round"
196
- >
197
- <circle cx="7" cy="7" r="5.25" />
198
- <circle cx="5.25" cy="5.75" r="0.6" fill="currentColor" stroke="none" />
199
- <circle cx="8.75" cy="5.75" r="0.6" fill="currentColor" stroke="none" />
200
- <path d="M4.75 8.5 a2.5 2.5 0 0 0 4.5 0" />
201
- </svg>
202
- );
203
-
204
- /** Returns an SVG element when the button id maps to one, otherwise null. */
205
- function buttonIconSvg(id: string): React.ReactNode | null {
206
- switch (id) {
207
- case 'table':
208
- return TABLE_ICON;
209
- case 'link':
210
- return LINK_ICON;
211
- case 'image':
212
- return IMAGE_ICON;
213
- case 'emoji':
214
- return EMOJI_ICON;
215
- default:
216
- return null;
217
- }
230
+ const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
231
+ const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
232
+ const INSERT_MENU_WIDTH = 200;
233
+
234
+ /** Renders a button's icon: a Font Awesome glyph when set, else the text label. */
235
+ function buttonIcon(btn: ToolbarButton): ReactNode {
236
+ return btn.faIcon ? <Icon icon={btn.faIcon} /> : btn.icon;
218
237
  }
219
238
 
220
239
  // ─── Tiptap active-state map ────────────────────────────
@@ -256,6 +275,96 @@ function isTiptapActive(editor: TiptapEditor, id: string): boolean {
256
275
  }
257
276
  }
258
277
 
278
+ // ─── Scene-block inserts (diagram / drawing / layout) ───
279
+
280
+ /**
281
+ * A freshly-inserted layout starts with one centered text layer so the
282
+ * canvas isn't a blank surface. Each layer is a readable child sub-block
283
+ * (`### {#id} {[type …]}`); a text layer's content is its markdown body.
284
+ * Coordinates are absolute within the 1920×1080 scene viewport
285
+ * (`SceneBlockWidget`). See `scene/commands/layoutCommands.ts`.
286
+ */
287
+ const LAYOUT_STARTER_TEXT_PARAMS =
288
+ 'x=360 y=380 width=1200 height=320 fontSize=64 fontWeight=bold align=center valign=middle color="#1e293b"';
289
+ /** Multi-line markdown for a new layout (raw / code views). */
290
+ const LAYOUT_STARTER_MARKDOWN = `\n## Layout {[layout]}\n\n### {#text-1} {[text ${LAYOUT_STARTER_TEXT_PARAMS}]}\n\nLayout\n`;
291
+
292
+ /**
293
+ * Insert a block-level heading carrying a Scene template (`diagram` /
294
+ * `drawing` / `layout`) at the top level, after the block the caret sits
295
+ * in. Going through a command (rather than `insertContent` at the caret)
296
+ * keeps the heading from being coerced into inline text when the caret is
297
+ * nested inside a list item or other block. The matching extension
298
+ * (DiagramExtension / SceneBlockExtension) then mounts the editable canvas
299
+ * right below it.
300
+ */
301
+ function insertTemplateHeading(
302
+ editor: TiptapEditor,
303
+ opts: { template: string; text: string; level?: number; blockAttrs?: string | null },
304
+ ): void {
305
+ editor
306
+ .chain()
307
+ .focus()
308
+ .command(({ tr, state, dispatch }) => {
309
+ const headingType = state.schema.nodes.heading;
310
+ if (!headingType) return false;
311
+ const heading = headingType.create(
312
+ {
313
+ level: opts.level ?? 2,
314
+ dataTemplate: opts.template,
315
+ dataBlockAttrs: opts.blockAttrs ?? null,
316
+ },
317
+ state.schema.text(opts.text),
318
+ );
319
+ const { $from } = state.selection;
320
+ const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
321
+ if (dispatch) {
322
+ tr.insert(insertPos, heading);
323
+ // Drop the caret into the new heading's text so it can be renamed.
324
+ tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
325
+ }
326
+ return true;
327
+ })
328
+ .run();
329
+ }
330
+
331
+ /**
332
+ * Insert a `{[layout]}` block seeded with one text layer. The layer is a
333
+ * child sub-block heading (`### {#text-1} {[text …]}`) whose markdown body
334
+ * ("Layout") is the text content — the readable counterpart to the old
335
+ * base64 `layers=` blob. The parent + child + body go in as one fragment so
336
+ * the layout widget mounts with the seed already present.
337
+ */
338
+ function insertLayoutBlock(editor: TiptapEditor): void {
339
+ editor
340
+ .chain()
341
+ .focus()
342
+ .command(({ tr, state, dispatch }) => {
343
+ const headingType = state.schema.nodes.heading;
344
+ const paragraphType = state.schema.nodes.paragraph;
345
+ if (!headingType || !paragraphType) return false;
346
+ const parent = headingType.create(
347
+ { level: 2, dataTemplate: 'layout' },
348
+ state.schema.text('Layout'),
349
+ );
350
+ const child = headingType.create({
351
+ level: 3,
352
+ dataTemplate: 'text',
353
+ dataTemplateParams: LAYOUT_STARTER_TEXT_PARAMS,
354
+ dataBlockAttrs: '#text-1',
355
+ });
356
+ const body = paragraphType.create(null, state.schema.text('Layout'));
357
+ const { $from } = state.selection;
358
+ const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
359
+ if (dispatch) {
360
+ tr.insert(insertPos, Fragment.fromArray([parent, child, body]));
361
+ tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
362
+ }
363
+ return true;
364
+ })
365
+ .run();
366
+ }
367
+
259
368
  /**
260
369
  * Formatting toolbar.
261
370
  * - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
@@ -266,6 +375,7 @@ export function Toolbar({
266
375
  showFiles,
267
376
  onToggleFiles,
268
377
  slotLeft,
378
+ slotAfterTabs,
269
379
  slotAfterActions,
270
380
  slotRight,
271
381
  showPlayTab = true,
@@ -277,13 +387,19 @@ export function Toolbar({
277
387
  setMarkdownSource,
278
388
  tiptapEditor,
279
389
  monacoEditor,
390
+ activeSceneText,
280
391
  mediaProvider,
281
392
  editorMode,
282
393
  versioning,
283
394
  allowRecording,
284
395
  documentLinkProvider,
285
- theme,
396
+ colorScheme,
286
397
  } = useEditorContext();
398
+ // When a canvas textbox is being edited, its Tiptap instance takes over
399
+ // the formatting buttons; otherwise they drive the document editor. The
400
+ // `level` gates which buttons apply (inline labels vs. rich textboxes).
401
+ const formattingEditor = activeSceneText?.editor ?? tiptapEditor;
402
+ const sceneTextLevel = activeSceneText?.level ?? null;
287
403
  const isCodeMode = editorMode === 'code';
288
404
  // In code mode only the raw view is meaningful; the WYSIWYG and Preview
289
405
  // surfaces aren't mounted, so hide their tabs.
@@ -347,17 +463,30 @@ export function Toolbar({
347
463
 
348
464
  const closeEmojiPicker = useCallback(() => setEmojiPickerAnchor(null), []);
349
465
 
350
- // ── Narrow-screen detection ──────────────────────────
351
- const [isNarrow, setIsNarrow] = useState(
352
- () => typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches,
466
+ // Insert menu — toolbar-anchored dropdown that replaces the individual media-group
467
+ // buttons with a single "+" button. Portaled out to avoid overflow:hidden clipping.
468
+ const insertMenuButtonRef = useRef<HTMLButtonElement | null>(null);
469
+ const insertMenuRef = useRef<HTMLDivElement | null>(null);
470
+ const [insertMenuAnchor, setInsertMenuAnchor] = useState<{ top: number; left: number } | null>(
471
+ null,
353
472
  );
354
- useEffect(() => {
355
- const mq = window.matchMedia('(max-width: 768px)');
356
- const handler = (e: MediaQueryListEvent) => setIsNarrow(e.matches);
357
- mq.addEventListener('change', handler);
358
- return () => mq.removeEventListener('change', handler);
473
+
474
+ const openInsertMenu = useCallback((trigger?: HTMLElement | null) => {
475
+ const btn = trigger ?? insertMenuButtonRef.current;
476
+ if (!btn) return;
477
+ const rect = btn.getBoundingClientRect();
478
+ const gap = 4;
479
+ const margin = 8;
480
+ const vw = window.innerWidth;
481
+ let left = rect.left;
482
+ if (left + INSERT_MENU_WIDTH + margin > vw) {
483
+ left = Math.max(margin, rect.right - INSERT_MENU_WIDTH);
484
+ }
485
+ setInsertMenuAnchor({ top: rect.bottom + gap, left });
359
486
  }, []);
360
487
 
488
+ const closeInsertMenu = useCallback(() => setInsertMenuAnchor(null), []);
489
+
361
490
  // ── Overflow detection ────────────────────────────────
362
491
  const actionsRef = useRef<HTMLDivElement>(null);
363
492
  const [measuredOverflowIndex, setMeasuredOverflowIndex] = useState<number | null>(null);
@@ -367,11 +496,12 @@ export function Toolbar({
367
496
  // Document settings (frontmatter) dialog
368
497
  const [showDocSettings, setShowDocSettings] = useState(false);
369
498
 
370
- // On narrow screens, force all buttons into the overflow menu
371
- const overflowIndex = isNarrow ? 0 : measuredOverflowIndex;
499
+ // Custom layout manager dialog (list of doc/library layouts + designer)
500
+ const [showLayoutManager, setShowLayoutManager] = useState(false);
501
+
502
+ const overflowIndex = measuredOverflowIndex;
372
503
 
373
504
  useEffect(() => {
374
- if (isNarrow) return; // Skip measurement on narrow — everything overflows
375
505
  const container = actionsRef.current;
376
506
  if (!container) return;
377
507
 
@@ -381,13 +511,18 @@ export function Toolbar({
381
511
  ':scope > .squisq-toolbar-group > .squisq-toolbar-button',
382
512
  );
383
513
  let firstHidden: number | null = null;
384
- children.forEach((child, i) => {
514
+ let btnIndex = 0;
515
+ children.forEach((child) => {
385
516
  if (firstHidden !== null) return;
517
+ // data-btn-count lets a single DOM button represent multiple BUTTONS entries
518
+ // (e.g. the Insert dropdown button represents the entire media group).
519
+ const btnCount = Number(child.dataset.btnCount ?? 1);
386
520
  const rect = child.getBoundingClientRect();
387
521
  // A button is hidden if its right edge extends past the container
388
522
  if (rect.right > containerRight + 2) {
389
- firstHidden = i;
523
+ firstHidden = btnIndex;
390
524
  }
525
+ btnIndex += btnCount;
391
526
  });
392
527
  setMeasuredOverflowIndex(firstHidden);
393
528
  };
@@ -396,7 +531,7 @@ export function Toolbar({
396
531
  ro.observe(container);
397
532
  measure();
398
533
  return () => ro.disconnect();
399
- }, [activeView, isNarrow]);
534
+ }, [activeView]);
400
535
 
401
536
  // Close overflow menu on outside click
402
537
  useEffect(() => {
@@ -410,6 +545,18 @@ export function Toolbar({
410
545
  return () => document.removeEventListener('mousedown', handleClick);
411
546
  }, [showOverflow]);
412
547
 
548
+ // Close insert menu on outside click
549
+ useEffect(() => {
550
+ if (!insertMenuAnchor) return;
551
+ const handleClick = (e: MouseEvent) => {
552
+ if (insertMenuButtonRef.current?.contains(e.target as Node)) return;
553
+ if (insertMenuRef.current?.contains(e.target as Node)) return;
554
+ closeInsertMenu();
555
+ };
556
+ document.addEventListener('mousedown', handleClick);
557
+ return () => document.removeEventListener('mousedown', handleClick);
558
+ }, [insertMenuAnchor, closeInsertMenu]);
559
+
413
560
  // Open-up vs open-down: the overflow menu is anchored to its trigger with
414
561
  // `top: 100%` by default. When the toolbar lives near the bottom of a
415
562
  // small container (e.g. a chat composer), a downward menu gets clipped.
@@ -438,16 +585,17 @@ export function Toolbar({
438
585
  // Force re-render when Tiptap selection or formatting state changes
439
586
  const [, forceUpdate] = useReducer((c: number) => c + 1, 0);
440
587
  useEffect(() => {
441
- if (!tiptapEditor) return;
442
- tiptapEditor.on('transaction', forceUpdate);
588
+ if (!formattingEditor) return;
589
+ formattingEditor.on('transaction', forceUpdate);
443
590
  return () => {
444
- tiptapEditor.off('transaction', forceUpdate);
591
+ formattingEditor.off('transaction', forceUpdate);
445
592
  };
446
- }, [tiptapEditor]);
593
+ }, [formattingEditor]);
447
594
 
448
595
  // ── Tiptap handler ─────────────────────────────────────
449
596
  const handleTiptap = useCallback(
450
597
  (id: string) => {
598
+ const tiptapEditor = formattingEditor;
451
599
  if (!tiptapEditor) return;
452
600
  const chain = tiptapEditor.chain().focus();
453
601
  switch (id) {
@@ -525,9 +673,24 @@ export function Toolbar({
525
673
  case 'table':
526
674
  tiptapEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
527
675
  break;
676
+ case 'diagram':
677
+ // Empty diagram is usable via its "+ Add first node" affordance;
678
+ // sub-headings under this block become nodes.
679
+ insertTemplateHeading(tiptapEditor, { template: 'diagram', text: 'Diagram' });
680
+ break;
681
+ case 'drawing':
682
+ // Empty drawing is usable via the canvas's "Shapes ▾" palette;
683
+ // each shape persists as a child heading.
684
+ insertTemplateHeading(tiptapEditor, { template: 'drawing', text: 'Drawing' });
685
+ break;
686
+ case 'layout':
687
+ // Seed with a starter text layer (a child sub-block) so the canvas
688
+ // isn't blank; further layers add via the toolbar Text/Box buttons.
689
+ insertLayoutBlock(tiptapEditor);
690
+ break;
528
691
  }
529
692
  },
530
- [tiptapEditor],
693
+ [formattingEditor],
531
694
  );
532
695
 
533
696
  // ── Raw markdown handler ───────────────────────────────
@@ -664,19 +827,37 @@ export function Toolbar({
664
827
  newCursorOffset = 3; // after \n|
665
828
  break;
666
829
  }
830
+ case 'diagram': {
831
+ // A diagram is just a heading with the `{[diagram]}` template
832
+ // annotation; the WYSIWYG view renders its editable canvas.
833
+ replacement = '\n## Diagram {[diagram]}\n';
834
+ newCursorOffset = replacement.length;
835
+ break;
836
+ }
837
+ case 'drawing': {
838
+ replacement = '\n## Drawing {[drawing]}\n';
839
+ newCursorOffset = replacement.length;
840
+ break;
841
+ }
842
+ case 'layout': {
843
+ // Seed a starter text layer (a child sub-block) so it isn't blank.
844
+ replacement = LAYOUT_STARTER_MARKDOWN;
845
+ newCursorOffset = replacement.length;
846
+ break;
847
+ }
667
848
  }
668
849
 
669
850
  // Apply the edit via Monaco's executeEdits for proper undo support
670
851
  const range = selection;
671
852
  monacoEditor.executeEdits('toolbar', [{ range, text: replacement }]);
672
853
 
673
- // If no selection, select the placeholder text so user can type over it
854
+ // If no selection, move the cursor to the command's preferred edit point.
674
855
  if (!hasSelection && newCursorOffset > 0) {
675
856
  const startPos = model.getPositionAt(
676
857
  model.getOffsetAt(range.getStartPosition()) + newCursorOffset,
677
858
  );
678
- // Just place cursor after the prefix
679
859
  monacoEditor.setPosition(startPos);
860
+ monacoEditor.revealPositionInCenterIfOutsideViewport(startPos);
680
861
  }
681
862
 
682
863
  monacoEditor.focus();
@@ -727,6 +908,15 @@ export function Toolbar({
727
908
  insertion =
728
909
  '\n| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |\n';
729
910
  break;
911
+ case 'diagram':
912
+ insertion = '\n## Diagram {[diagram]}\n';
913
+ break;
914
+ case 'drawing':
915
+ insertion = '\n## Drawing {[drawing]}\n';
916
+ break;
917
+ case 'layout':
918
+ insertion = LAYOUT_STARTER_MARKDOWN;
919
+ break;
730
920
  }
731
921
  if (insertion) {
732
922
  setMarkdownSource(markdownSource + insertion);
@@ -773,7 +963,9 @@ export function Toolbar({
773
963
  else openEmojiPicker();
774
964
  return;
775
965
  }
776
- if (activeView === 'wysiwyg' && tiptapEditor) {
966
+ // A focused canvas textbox takes the formatting (handleTiptap targets
967
+ // `formattingEditor`); otherwise route to the document editor.
968
+ if (activeSceneText || (activeView === 'wysiwyg' && tiptapEditor)) {
777
969
  handleTiptap(id);
778
970
  } else {
779
971
  handleRaw(id);
@@ -782,6 +974,7 @@ export function Toolbar({
782
974
  [
783
975
  activeView,
784
976
  tiptapEditor,
977
+ activeSceneText,
785
978
  handleTiptap,
786
979
  handleRaw,
787
980
  emojiPickerAnchor,
@@ -868,17 +1061,20 @@ export function Toolbar({
868
1061
  const trimmedUrl = url.trim();
869
1062
  const trimmedText = text.trim();
870
1063
 
871
- if (linkDialog.target === 'wysiwyg' && tiptapEditor) {
1064
+ // Target the focused canvas textbox editor when one is active, else
1065
+ // the document editor (the dialog was opened from the same target).
1066
+ const wysiwygTarget = formattingEditor;
1067
+ if (linkDialog.target === 'wysiwyg' && wysiwygTarget) {
872
1068
  if (!trimmedUrl) {
873
1069
  // Empty URL on update = unlink. On insert with no URL, do nothing.
874
1070
  if (linkDialog.mode === 'update') {
875
- tiptapEditor.chain().focus().unsetLink().run();
1071
+ wysiwygTarget.chain().focus().unsetLink().run();
876
1072
  }
877
1073
  setLinkDialog(null);
878
1074
  return;
879
1075
  }
880
1076
  const visibleText = trimmedText || trimmedUrl;
881
- const chain = tiptapEditor.chain().focus();
1077
+ const chain = wysiwygTarget.chain().focus();
882
1078
  // Insert (or replace selection) with text carrying a link mark. When
883
1079
  // updating an existing link, the selection was extended to the full
884
1080
  // mark range earlier, so this replaces the entire `[text](url)`.
@@ -927,13 +1123,37 @@ export function Toolbar({
927
1123
 
928
1124
  setLinkDialog(null);
929
1125
  },
930
- [linkDialog, tiptapEditor, monacoEditor],
1126
+ [linkDialog, formattingEditor, monacoEditor],
931
1127
  );
932
1128
 
933
- const groups = ['format', 'lists', 'structure', 'insert', 'media'] as const;
1129
+ const groups = ['format', 'lists', 'structure', 'insert'] as const;
934
1130
  const isWysiwyg = activeView === 'wysiwyg' && tiptapEditor;
935
1131
  const isPreview = activeView === 'preview';
936
1132
 
1133
+ // Formatting buttons reflect/drive the focused canvas textbox when one is
1134
+ // active, else the document's WYSIWYG editor.
1135
+ const formatActive = !!activeSceneText || isWysiwyg;
1136
+ // Inline-level textboxes (diagram/drawing labels) only support inline
1137
+ // marks — grey out block formatters so the toolbar reflects what applies.
1138
+ const INLINE_SCENE_BUTTONS = new Set([
1139
+ 'bold',
1140
+ 'italic',
1141
+ 'strikethrough',
1142
+ 'code',
1143
+ 'link',
1144
+ 'emoji',
1145
+ ]);
1146
+ // `block`-level textboxes (layout) add lists/blockquote/code block on top of
1147
+ // the inline marks — but NOT headings (their content is a sub-block body, so
1148
+ // a heading there would re-parse as a sibling sub-block).
1149
+ const BLOCK_SCENE_BUTTONS = new Set([...INLINE_SCENE_BUTTONS, 'ul', 'ol', 'quote', 'codeblock']);
1150
+ const buttonAllowed = (id: string): boolean => {
1151
+ if (!sceneTextLevel) return true;
1152
+ if (sceneTextLevel === 'rich') return true;
1153
+ if (sceneTextLevel === 'block') return BLOCK_SCENE_BUTTONS.has(id);
1154
+ return INLINE_SCENE_BUTTONS.has(id);
1155
+ };
1156
+
937
1157
  // ── Progressive heading disclosure ───────────────────
938
1158
  // H1\u2013H3 are always visible. H4 appears once the document already
939
1159
  // contains an H3, H5 once it contains an H4, and H6 once it contains
@@ -962,6 +1182,9 @@ export function Toolbar({
962
1182
  if (!m) return true;
963
1183
  return Number(m[1]) <= visibleHeadingMax;
964
1184
  };
1185
+ const hasVisibleMediaButtons = MEDIA_BUTTONS.some((b) => isButtonVisible(b.id));
1186
+ const showInsertInOverflow =
1187
+ overflowIndex !== null && overflowIndex <= FIRST_MEDIA_INDEX && hasVisibleMediaButtons;
965
1188
 
966
1189
  // Detect whether cursor is inside a table (WYSIWYG mode only)
967
1190
  const isInTable = isWysiwyg ? tiptapEditor.isActive('table') : false;
@@ -980,10 +1203,12 @@ export function Toolbar({
980
1203
  const isRawView = activeView === 'raw';
981
1204
  const [rawTemplate, setRawTemplate] = useState<string | null>(null);
982
1205
  const [rawHeadingLine, setRawHeadingLine] = useState<number | null>(null);
1206
+ const [rawTransition, setRawTransition] = useState<TransitionFields>(EMPTY_TRANSITION);
983
1207
  useEffect(() => {
984
1208
  if (!isRawView || !monacoEditor) {
985
1209
  setRawTemplate(null);
986
1210
  setRawHeadingLine(null);
1211
+ setRawTransition(EMPTY_TRANSITION);
987
1212
  return;
988
1213
  }
989
1214
  const recompute = () => {
@@ -992,6 +1217,7 @@ export function Toolbar({
992
1217
  if (!model || !pos) {
993
1218
  setRawTemplate(null);
994
1219
  setRawHeadingLine(null);
1220
+ setRawTransition(EMPTY_TRANSITION);
995
1221
  return;
996
1222
  }
997
1223
  const line = model.getLineContent(pos.lineNumber);
@@ -999,9 +1225,11 @@ export function Toolbar({
999
1225
  if (!headingMatch) {
1000
1226
  setRawTemplate(null);
1001
1227
  setRawHeadingLine(null);
1228
+ setRawTransition(EMPTY_TRANSITION);
1002
1229
  return;
1003
1230
  }
1004
1231
  setRawHeadingLine(pos.lineNumber);
1232
+ setRawTransition(readHeadingLineTransition(line));
1005
1233
  const annotMatch = headingMatch[1].match(/\s*\{\[([^\]]+)\]\}[\s\]}]*$/);
1006
1234
  if (annotMatch) {
1007
1235
  // First whitespace-delimited token is the template name; the rest are params.
@@ -1115,6 +1343,52 @@ export function Toolbar({
1115
1343
  }
1116
1344
  };
1117
1345
 
1346
+ // ── Current block transition ──────────────────────────
1347
+ // Non-null exactly when a block (heading) is active — same condition as
1348
+ // the template picker. Read from the Pandoc `{…}` block (canonical), with
1349
+ // the `{[…]}` params as a fallback for hand-typed transitions.
1350
+ let currentTransition: TransitionFields | null = null;
1351
+ if (isWysiwyg && wysiwygTemplate !== null) {
1352
+ const attrs = tiptapEditor.getAttributes('heading') as {
1353
+ dataBlockAttrs?: string | null;
1354
+ dataTemplateParams?: string | null;
1355
+ };
1356
+ currentTransition = readBlockAttrsTransition(attrs.dataBlockAttrs, attrs.dataTemplateParams);
1357
+ } else if (isRawView && rawTemplate !== null) {
1358
+ currentTransition = rawTransition;
1359
+ }
1360
+
1361
+ const handleTransitionPick = (next: TransitionFields) => {
1362
+ // Raw (Monaco) — rewrite the heading line, writing into the `{…}` block.
1363
+ if (isRawView && monacoEditor) {
1364
+ const model = monacoEditor.getModel();
1365
+ const pos = monacoEditor.getPosition();
1366
+ if (!model || !pos) return;
1367
+ const lineNumber = pos.lineNumber;
1368
+ const lineText = model.getLineContent(lineNumber);
1369
+ const newLine = setHeadingLineTransition(lineText, next);
1370
+ if (newLine === lineText) return;
1371
+ monacoEditor.executeEdits('toolbar-transition-pick', [
1372
+ {
1373
+ range: {
1374
+ startLineNumber: lineNumber,
1375
+ startColumn: 1,
1376
+ endLineNumber: lineNumber,
1377
+ endColumn: lineText.length + 1,
1378
+ },
1379
+ text: newLine,
1380
+ },
1381
+ ]);
1382
+ monacoEditor.focus();
1383
+ return;
1384
+ }
1385
+ // WYSIWYG — rewrite the heading node's `dataBlockAttrs` (Pandoc inner).
1386
+ if (!tiptapEditor) return;
1387
+ const attrs = tiptapEditor.getAttributes('heading') as { dataBlockAttrs?: string | null };
1388
+ const inner = setBlockAttrsTransition(attrs.dataBlockAttrs, next);
1389
+ tiptapEditor.chain().focus().updateAttributes('heading', { dataBlockAttrs: inner }).run();
1390
+ };
1391
+
1118
1392
  return (
1119
1393
  <div
1120
1394
  className={`squisq-toolbar ${className || ''}`}
@@ -1167,8 +1441,10 @@ export function Toolbar({
1167
1441
  ))}
1168
1442
  </div>
1169
1443
  )}
1170
- {/* Formatting buttonshidden in preview mode, narrow screens, and code mode */}
1171
- {!isPreview && !isNarrow && !isCodeMode && (
1444
+ {/* After-tabs slotleft side, before formatting controls (preview mode switch) */}
1445
+ {slotAfterTabs}
1446
+ {/* Formatting buttons — hidden in preview mode and code mode */}
1447
+ {!isPreview && !isCodeMode && (
1172
1448
  <div className="squisq-toolbar-actions" ref={actionsRef}>
1173
1449
  {groups.map((group, gi) => (
1174
1450
  <div key={group} className="squisq-toolbar-group">
@@ -1177,10 +1453,10 @@ export function Toolbar({
1177
1453
  const active =
1178
1454
  btn.id === 'emoji'
1179
1455
  ? emojiPickerAnchor !== null
1180
- : isWysiwyg
1181
- ? isTiptapActive(tiptapEditor, btn.id)
1456
+ : formatActive && formattingEditor
1457
+ ? isTiptapActive(formattingEditor, btn.id)
1182
1458
  : false;
1183
- const disabled = btn.id === 'image' && !mediaProvider;
1459
+ const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
1184
1460
  return (
1185
1461
  <button
1186
1462
  key={btn.id}
@@ -1191,15 +1467,31 @@ export function Toolbar({
1191
1467
  aria-label={btn.title}
1192
1468
  aria-pressed={active}
1193
1469
  disabled={disabled}
1194
- style={btn.iconStyle}
1195
1470
  >
1196
- {buttonIconSvg(btn.id) ?? btn.icon}
1471
+ {buttonIcon(btn)}
1197
1472
  </button>
1198
1473
  );
1199
1474
  })}
1200
1475
  </div>
1201
1476
  ))}
1202
1477
 
1478
+ {/* Insert menu button — collapses all media-group actions into a single dropdown */}
1479
+ <div className="squisq-toolbar-group">
1480
+ <div className="squisq-toolbar-separator" />
1481
+ <button
1482
+ ref={insertMenuButtonRef}
1483
+ className={`squisq-toolbar-button${insertMenuAnchor ? ' squisq-toolbar-button--active' : ''}`}
1484
+ data-btn-count={String(MEDIA_BUTTONS.length)}
1485
+ data-tooltip="Insert..."
1486
+ onClick={() => (insertMenuAnchor ? closeInsertMenu() : openInsertMenu())}
1487
+ aria-label="Insert"
1488
+ aria-expanded={insertMenuAnchor !== null}
1489
+ aria-haspopup="menu"
1490
+ >
1491
+ <Icon icon="fa-solid fa-plus" />
1492
+ </button>
1493
+ </div>
1494
+
1203
1495
  {/* Template picker — visible when the cursor is in a heading.
1204
1496
  In WYSIWYG, reads from the heading node's `dataTemplate`; in
1205
1497
  Markdown view, parses the `{[...]}` suffix on the cursor's line. */}
@@ -1216,6 +1508,18 @@ export function Toolbar({
1216
1508
  </>
1217
1509
  )}
1218
1510
 
1511
+ {/* Transition picker — visible alongside the template picker when a
1512
+ block (heading) is active. Writes `transition=` into the block's
1513
+ Pandoc `{…}` attribute block (the canonical home for transitions). */}
1514
+ {currentTransition !== null && (
1515
+ <>
1516
+ <div className="squisq-toolbar-separator" />
1517
+ <div className="squisq-toolbar-group squisq-transition-picker-group">
1518
+ <TransitionPicker value={currentTransition} onChange={handleTransitionPick} />
1519
+ </div>
1520
+ </>
1521
+ )}
1522
+
1219
1523
  {/* Table controls — visible when cursor is in a table (WYSIWYG) */}
1220
1524
  {isInTable && (
1221
1525
  <>
@@ -1392,14 +1696,17 @@ export function Toolbar({
1392
1696
  >
1393
1697
  {BUTTONS.slice(overflowIndex)
1394
1698
  .filter((b) => isButtonVisible(b.id))
1699
+ // Media buttons are represented by the synthetic Insert dropdown
1700
+ // in both the visible toolbar and the overflow menu.
1701
+ .filter((b) => b.group !== 'media')
1395
1702
  .map((btn) => {
1396
1703
  const active =
1397
1704
  btn.id === 'emoji'
1398
1705
  ? emojiPickerAnchor !== null
1399
- : isWysiwyg
1400
- ? isTiptapActive(tiptapEditor, btn.id)
1706
+ : formatActive && formattingEditor
1707
+ ? isTiptapActive(formattingEditor, btn.id)
1401
1708
  : false;
1402
- const disabled = btn.id === 'image' && !mediaProvider;
1709
+ const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
1403
1710
  return (
1404
1711
  <button
1405
1712
  key={btn.id}
@@ -1414,16 +1721,33 @@ export function Toolbar({
1414
1721
  }}
1415
1722
  disabled={disabled}
1416
1723
  >
1417
- {buttonIconSvg(btn.id) ?? (
1418
- <span className="squisq-toolbar-overflow-icon" style={btn.iconStyle}>
1419
- {btn.icon}
1420
- </span>
1421
- )}
1724
+ <span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
1422
1725
  <span>{btn.title}</span>
1423
1726
  </button>
1424
1727
  );
1425
1728
  })}
1426
1729
 
1730
+ {showInsertInOverflow && (
1731
+ <button
1732
+ className={`squisq-toolbar-overflow-item${insertMenuAnchor ? ' squisq-toolbar-overflow-item--active' : ''}`}
1733
+ onClick={(event) => {
1734
+ if (insertMenuAnchor) {
1735
+ closeInsertMenu();
1736
+ } else {
1737
+ openInsertMenu(event.currentTarget);
1738
+ }
1739
+ setShowOverflow(false);
1740
+ }}
1741
+ aria-haspopup="menu"
1742
+ aria-expanded={insertMenuAnchor !== null}
1743
+ >
1744
+ <span className="squisq-toolbar-overflow-icon">
1745
+ <Icon icon="fa-solid fa-plus" />
1746
+ </span>
1747
+ <span>Insert...</span>
1748
+ </button>
1749
+ )}
1750
+
1427
1751
  {/* Contextual: template picker in overflow */}
1428
1752
  {currentTemplate !== null && (
1429
1753
  <div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
@@ -1496,9 +1820,12 @@ export function Toolbar({
1496
1820
 
1497
1821
  {/* After-actions slot — after formatting controls */}
1498
1822
  {slotAfterActions}
1499
- {/* Spacer — only needed when the actions container (which has flex:1
1500
- and already pushes right-side items to the end) isn't rendered. */}
1501
- {(isPreview || isNarrow || isCodeMode) && <div style={{ flex: 1 }} />}
1823
+ {/* Spacer — pushes right-side items to the end when the flex:1 actions
1824
+ container isn't rendered. In preview mode PreviewToolbarControls
1825
+ supplies its own flex:1 filler (and measures that leftover width to
1826
+ decide whether to collapse), so a second spacer here would split the
1827
+ slack and make the controls collapse too early. */}
1828
+ {isCodeMode && <div style={{ flex: 1 }} />}
1502
1829
  {/* Version history — renders only when the host enabled versioning
1503
1830
  and a container is wired up. The component owns its own button
1504
1831
  and popover; we just give it a slot in the toolbar. */}
@@ -1516,21 +1843,18 @@ export function Toolbar({
1516
1843
  data-tooltip="Document settings"
1517
1844
  aria-label="Document settings"
1518
1845
  >
1519
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
1520
- <path
1521
- d="M3 2.5h7l3 3v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-10a1 1 0 0 1 1-1Z"
1522
- stroke="currentColor"
1523
- strokeWidth="1.3"
1524
- strokeLinejoin="round"
1525
- />
1526
- <path d="M10 2.5v3h3" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
1527
- <path
1528
- d="M5 8.5h6M5 11h4"
1529
- stroke="currentColor"
1530
- strokeWidth="1.3"
1531
- strokeLinecap="round"
1532
- />
1533
- </svg>
1846
+ <Icon icon="fa-solid fa-file-lines" />
1847
+ </button>
1848
+ )}
1849
+ {!isCodeMode && (
1850
+ <button
1851
+ type="button"
1852
+ className={`squisq-toolbar-button${showLayoutManager ? ' squisq-toolbar-button--active' : ''}`}
1853
+ onClick={() => setShowLayoutManager(true)}
1854
+ data-tooltip="Custom layouts"
1855
+ aria-label="Custom layouts"
1856
+ >
1857
+ <Icon icon="fa-solid fa-shapes" />
1534
1858
  </button>
1535
1859
  )}
1536
1860
  {!isCodeMode && <ViewMenuPanel />}
@@ -1543,7 +1867,7 @@ export function Toolbar({
1543
1867
  aria-pressed={showFiles}
1544
1868
  aria-label="Toggle Files panel"
1545
1869
  >
1546
- {PAPERCLIP_ICON}
1870
+ <Icon icon="fa-solid fa-paperclip" />
1547
1871
  </button>
1548
1872
  )}
1549
1873
  {/* Right slot — rightmost end of toolbar */}
@@ -1561,6 +1885,9 @@ export function Toolbar({
1561
1885
  />
1562
1886
  )}
1563
1887
 
1888
+ {/* Custom layout manager — full-window list + designer dialog. */}
1889
+ {showLayoutManager && <CustomLayoutManager onClose={() => setShowLayoutManager(false)} />}
1890
+
1564
1891
  {/* Link insert/edit dialog — shared by WYSIWYG and Raw views. */}
1565
1892
  {linkDialog && (
1566
1893
  <LinkDialog
@@ -1573,6 +1900,43 @@ export function Toolbar({
1573
1900
  />
1574
1901
  )}
1575
1902
 
1903
+ {/* Insert menu — portaled to the document body so the overflow:hidden
1904
+ actions row doesn't clip it. Position computed from trigger rect. */}
1905
+ {insertMenuAnchor &&
1906
+ createPortal(
1907
+ <div
1908
+ ref={insertMenuRef}
1909
+ className="squisq-insert-menu"
1910
+ data-theme={colorScheme}
1911
+ style={{ position: 'fixed', top: insertMenuAnchor.top, left: insertMenuAnchor.left }}
1912
+ role="menu"
1913
+ >
1914
+ <div className="squisq-insert-menu-header">Insert</div>
1915
+ {MEDIA_BUTTONS.filter((b) => isButtonVisible(b.id)).map((btn) => {
1916
+ const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
1917
+ const stripped = btn.title.replace(/^Insert\s+/i, '');
1918
+ const label = stripped.charAt(0).toUpperCase() + stripped.slice(1);
1919
+ return (
1920
+ <button
1921
+ key={btn.id}
1922
+ ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
1923
+ className="squisq-toolbar-overflow-item"
1924
+ disabled={disabled}
1925
+ onClick={() => {
1926
+ handleAction(btn.id);
1927
+ if (btn.id !== 'emoji') closeInsertMenu();
1928
+ }}
1929
+ role="menuitem"
1930
+ >
1931
+ <span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
1932
+ <span>{label}</span>
1933
+ </button>
1934
+ );
1935
+ })}
1936
+ </div>,
1937
+ document.body,
1938
+ )}
1939
+
1576
1940
  {/* Emoji picker — portaled to the document body so the toolbar's
1577
1941
  overflow:hidden actions row doesn't clip the popover. Position
1578
1942
  is computed from the trigger button's screen rect at open. */}
@@ -1583,7 +1947,7 @@ export function Toolbar({
1583
1947
  onSelect={handleEmojiSelect}
1584
1948
  onClose={closeEmojiPicker}
1585
1949
  anchorRef={emojiButtonRef as React.RefObject<HTMLElement>}
1586
- theme={theme === 'dark' ? 'dark' : 'light'}
1950
+ theme={colorScheme === 'dark' ? 'dark' : 'light'}
1587
1951
  style={{
1588
1952
  position: 'fixed',
1589
1953
  top: emojiPickerAnchor.top,