@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/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
 
@@ -54,11 +67,12 @@ export interface ToolbarProps {
54
67
  interface ToolbarButton {
55
68
  id: string;
56
69
  label: string;
70
+ /** Text glyph shown when the button has no Font Awesome icon (headings). */
57
71
  icon: string;
58
72
  title: string;
59
73
  group: 'format' | 'lists' | 'structure' | 'insert' | 'media';
60
- /** CSS font style for the icon (e.g. italic for the I button) */
61
- iconStyle?: React.CSSProperties;
74
+ /** Font Awesome class string (e.g. `"fa-solid fa-bold"`); omitted for text buttons. */
75
+ faIcon?: string;
62
76
  }
63
77
 
64
78
  const BUTTONS: ToolbarButton[] = [
@@ -69,7 +83,7 @@ const BUTTONS: ToolbarButton[] = [
69
83
  icon: 'B',
70
84
  title: 'Bold (Ctrl+B)',
71
85
  group: 'format',
72
- iconStyle: { fontWeight: 700 },
86
+ faIcon: 'fa-solid fa-bold',
73
87
  },
74
88
  {
75
89
  id: 'italic',
@@ -77,7 +91,7 @@ const BUTTONS: ToolbarButton[] = [
77
91
  icon: 'I',
78
92
  title: 'Italic (Ctrl+I)',
79
93
  group: 'format',
80
- iconStyle: { fontStyle: 'italic' },
94
+ faIcon: 'fa-solid fa-italic',
81
95
  },
82
96
  {
83
97
  id: 'strikethrough',
@@ -85,15 +99,30 @@ const BUTTONS: ToolbarButton[] = [
85
99
  icon: 'S',
86
100
  title: 'Strikethrough',
87
101
  group: 'format',
88
- iconStyle: { textDecoration: 'line-through' },
102
+ faIcon: 'fa-solid fa-strikethrough',
89
103
  },
90
104
 
91
105
  // Lists group — sits between format and structure so bullets/numbers
92
106
  // 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' },
107
+ {
108
+ id: 'ul',
109
+ label: '•',
110
+ icon: '•',
111
+ title: 'Bullet list',
112
+ group: 'lists',
113
+ faIcon: 'fa-solid fa-list-ul',
114
+ },
115
+ {
116
+ id: 'ol',
117
+ label: '1.',
118
+ icon: '1.',
119
+ title: 'Numbered list',
120
+ group: 'lists',
121
+ faIcon: 'fa-solid fa-list-ol',
122
+ },
95
123
 
96
- // Structure group
124
+ // Structure group — headings keep their text labels; Font Awesome Free
125
+ // has no numbered (H1–H6) heading glyphs, and the numerals are clearer.
97
126
  { id: 'h1', label: 'H1', icon: 'H1', title: 'Heading 1', group: 'structure' },
98
127
  { id: 'h2', label: 'H2', icon: 'H2', title: 'Heading 2', group: 'structure' },
99
128
  { id: 'h3', label: 'H3', icon: 'H3', title: 'Heading 3', group: 'structure' },
@@ -102,119 +131,105 @@ const BUTTONS: ToolbarButton[] = [
102
131
  { id: 'h6', label: 'H6', icon: 'H6', title: 'Heading 6', group: 'structure' },
103
132
 
104
133
  // 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' },
134
+ {
135
+ id: 'quote',
136
+ label: '',
137
+ icon: '',
138
+ title: 'Blockquote',
139
+ group: 'insert',
140
+ faIcon: 'fa-solid fa-quote-left',
141
+ },
142
+ {
143
+ id: 'codeblock',
144
+ label: '{ }',
145
+ icon: '{ }',
146
+ title: 'Code block',
147
+ group: 'insert',
148
+ faIcon: 'fa-solid fa-file-code',
149
+ },
150
+ {
151
+ id: 'code',
152
+ label: '</>',
153
+ icon: '</>',
154
+ title: 'Inline code',
155
+ group: 'insert',
156
+ faIcon: 'fa-solid fa-code',
157
+ },
158
+ {
159
+ id: 'hr',
160
+ label: '—',
161
+ icon: '—',
162
+ title: 'Horizontal rule',
163
+ group: 'insert',
164
+ faIcon: 'fa-solid fa-minus',
165
+ },
109
166
 
110
167
  // 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' },
168
+ {
169
+ id: 'link',
170
+ label: '🔗',
171
+ icon: '🔗',
172
+ title: 'Insert link',
173
+ group: 'media',
174
+ faIcon: 'fa-solid fa-link',
175
+ },
176
+ {
177
+ id: 'table',
178
+ label: 'table',
179
+ icon: '',
180
+ title: 'Insert table',
181
+ group: 'media',
182
+ faIcon: 'fa-solid fa-table',
183
+ },
184
+ {
185
+ id: 'diagram',
186
+ label: 'diagram',
187
+ icon: '',
188
+ title: 'Insert diagram',
189
+ group: 'media',
190
+ faIcon: 'fa-solid fa-diagram-project',
191
+ },
192
+ {
193
+ id: 'drawing',
194
+ label: 'drawing',
195
+ icon: '',
196
+ title: 'Insert drawing',
197
+ group: 'media',
198
+ faIcon: 'fa-solid fa-pen-nib',
199
+ },
200
+ {
201
+ id: 'layout',
202
+ label: 'layout',
203
+ icon: '',
204
+ title: 'Insert layout',
205
+ group: 'media',
206
+ faIcon: 'fa-solid fa-object-group',
207
+ },
208
+ {
209
+ id: 'image',
210
+ label: '🖼',
211
+ icon: '🖼',
212
+ title: 'Insert image',
213
+ group: 'media',
214
+ faIcon: 'fa-solid fa-image',
215
+ },
216
+ {
217
+ id: 'emoji',
218
+ label: '😊',
219
+ icon: '😊',
220
+ title: 'Insert emoji',
221
+ group: 'media',
222
+ faIcon: 'fa-solid fa-face-smile',
223
+ },
115
224
  ];
116
225
 
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
- }
226
+ const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
227
+ const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
228
+ const INSERT_MENU_WIDTH = 200;
229
+
230
+ /** Renders a button's icon: a Font Awesome glyph when set, else the text label. */
231
+ function buttonIcon(btn: ToolbarButton): ReactNode {
232
+ return btn.faIcon ? <Icon icon={btn.faIcon} /> : btn.icon;
218
233
  }
219
234
 
220
235
  // ─── Tiptap active-state map ────────────────────────────
@@ -256,6 +271,96 @@ function isTiptapActive(editor: TiptapEditor, id: string): boolean {
256
271
  }
257
272
  }
258
273
 
274
+ // ─── Scene-block inserts (diagram / drawing / layout) ───
275
+
276
+ /**
277
+ * A freshly-inserted layout starts with one centered text layer so the
278
+ * canvas isn't a blank surface. Each layer is a readable child sub-block
279
+ * (`### {#id} {[type …]}`); a text layer's content is its markdown body.
280
+ * Coordinates are absolute within the 1920×1080 scene viewport
281
+ * (`SceneBlockWidget`). See `scene/commands/layoutCommands.ts`.
282
+ */
283
+ const LAYOUT_STARTER_TEXT_PARAMS =
284
+ 'x=360 y=380 width=1200 height=320 fontSize=64 fontWeight=bold align=center valign=middle color="#1e293b"';
285
+ /** Multi-line markdown for a new layout (raw / code views). */
286
+ const LAYOUT_STARTER_MARKDOWN = `\n## Layout {[layout]}\n\n### {#text-1} {[text ${LAYOUT_STARTER_TEXT_PARAMS}]}\n\nLayout\n`;
287
+
288
+ /**
289
+ * Insert a block-level heading carrying a Scene template (`diagram` /
290
+ * `drawing` / `layout`) at the top level, after the block the caret sits
291
+ * in. Going through a command (rather than `insertContent` at the caret)
292
+ * keeps the heading from being coerced into inline text when the caret is
293
+ * nested inside a list item or other block. The matching extension
294
+ * (DiagramExtension / SceneBlockExtension) then mounts the editable canvas
295
+ * right below it.
296
+ */
297
+ function insertTemplateHeading(
298
+ editor: TiptapEditor,
299
+ opts: { template: string; text: string; level?: number; blockAttrs?: string | null },
300
+ ): void {
301
+ editor
302
+ .chain()
303
+ .focus()
304
+ .command(({ tr, state, dispatch }) => {
305
+ const headingType = state.schema.nodes.heading;
306
+ if (!headingType) return false;
307
+ const heading = headingType.create(
308
+ {
309
+ level: opts.level ?? 2,
310
+ dataTemplate: opts.template,
311
+ dataBlockAttrs: opts.blockAttrs ?? null,
312
+ },
313
+ state.schema.text(opts.text),
314
+ );
315
+ const { $from } = state.selection;
316
+ const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
317
+ if (dispatch) {
318
+ tr.insert(insertPos, heading);
319
+ // Drop the caret into the new heading's text so it can be renamed.
320
+ tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
321
+ }
322
+ return true;
323
+ })
324
+ .run();
325
+ }
326
+
327
+ /**
328
+ * Insert a `{[layout]}` block seeded with one text layer. The layer is a
329
+ * child sub-block heading (`### {#text-1} {[text …]}`) whose markdown body
330
+ * ("Layout") is the text content — the readable counterpart to the old
331
+ * base64 `layers=` blob. The parent + child + body go in as one fragment so
332
+ * the layout widget mounts with the seed already present.
333
+ */
334
+ function insertLayoutBlock(editor: TiptapEditor): void {
335
+ editor
336
+ .chain()
337
+ .focus()
338
+ .command(({ tr, state, dispatch }) => {
339
+ const headingType = state.schema.nodes.heading;
340
+ const paragraphType = state.schema.nodes.paragraph;
341
+ if (!headingType || !paragraphType) return false;
342
+ const parent = headingType.create(
343
+ { level: 2, dataTemplate: 'layout' },
344
+ state.schema.text('Layout'),
345
+ );
346
+ const child = headingType.create({
347
+ level: 3,
348
+ dataTemplate: 'text',
349
+ dataTemplateParams: LAYOUT_STARTER_TEXT_PARAMS,
350
+ dataBlockAttrs: '#text-1',
351
+ });
352
+ const body = paragraphType.create(null, state.schema.text('Layout'));
353
+ const { $from } = state.selection;
354
+ const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
355
+ if (dispatch) {
356
+ tr.insert(insertPos, Fragment.fromArray([parent, child, body]));
357
+ tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
358
+ }
359
+ return true;
360
+ })
361
+ .run();
362
+ }
363
+
259
364
  /**
260
365
  * Formatting toolbar.
261
366
  * - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
@@ -277,6 +382,7 @@ export function Toolbar({
277
382
  setMarkdownSource,
278
383
  tiptapEditor,
279
384
  monacoEditor,
385
+ activeSceneText,
280
386
  mediaProvider,
281
387
  editorMode,
282
388
  versioning,
@@ -284,6 +390,11 @@ export function Toolbar({
284
390
  documentLinkProvider,
285
391
  theme,
286
392
  } = useEditorContext();
393
+ // When a canvas textbox is being edited, its Tiptap instance takes over
394
+ // the formatting buttons; otherwise they drive the document editor. The
395
+ // `level` gates which buttons apply (inline labels vs. rich textboxes).
396
+ const formattingEditor = activeSceneText?.editor ?? tiptapEditor;
397
+ const sceneTextLevel = activeSceneText?.level ?? null;
287
398
  const isCodeMode = editorMode === 'code';
288
399
  // In code mode only the raw view is meaningful; the WYSIWYG and Preview
289
400
  // surfaces aren't mounted, so hide their tabs.
@@ -347,17 +458,30 @@ export function Toolbar({
347
458
 
348
459
  const closeEmojiPicker = useCallback(() => setEmojiPickerAnchor(null), []);
349
460
 
350
- // ── Narrow-screen detection ──────────────────────────
351
- const [isNarrow, setIsNarrow] = useState(
352
- () => typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches,
461
+ // Insert menu — toolbar-anchored dropdown that replaces the individual media-group
462
+ // buttons with a single "+" button. Portaled out to avoid overflow:hidden clipping.
463
+ const insertMenuButtonRef = useRef<HTMLButtonElement | null>(null);
464
+ const insertMenuRef = useRef<HTMLDivElement | null>(null);
465
+ const [insertMenuAnchor, setInsertMenuAnchor] = useState<{ top: number; left: number } | null>(
466
+ null,
353
467
  );
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);
468
+
469
+ const openInsertMenu = useCallback((trigger?: HTMLElement | null) => {
470
+ const btn = trigger ?? insertMenuButtonRef.current;
471
+ if (!btn) return;
472
+ const rect = btn.getBoundingClientRect();
473
+ const gap = 4;
474
+ const margin = 8;
475
+ const vw = window.innerWidth;
476
+ let left = rect.left;
477
+ if (left + INSERT_MENU_WIDTH + margin > vw) {
478
+ left = Math.max(margin, rect.right - INSERT_MENU_WIDTH);
479
+ }
480
+ setInsertMenuAnchor({ top: rect.bottom + gap, left });
359
481
  }, []);
360
482
 
483
+ const closeInsertMenu = useCallback(() => setInsertMenuAnchor(null), []);
484
+
361
485
  // ── Overflow detection ────────────────────────────────
362
486
  const actionsRef = useRef<HTMLDivElement>(null);
363
487
  const [measuredOverflowIndex, setMeasuredOverflowIndex] = useState<number | null>(null);
@@ -367,11 +491,12 @@ export function Toolbar({
367
491
  // Document settings (frontmatter) dialog
368
492
  const [showDocSettings, setShowDocSettings] = useState(false);
369
493
 
370
- // On narrow screens, force all buttons into the overflow menu
371
- const overflowIndex = isNarrow ? 0 : measuredOverflowIndex;
494
+ // Custom layout manager dialog (list of doc/library layouts + designer)
495
+ const [showLayoutManager, setShowLayoutManager] = useState(false);
496
+
497
+ const overflowIndex = measuredOverflowIndex;
372
498
 
373
499
  useEffect(() => {
374
- if (isNarrow) return; // Skip measurement on narrow — everything overflows
375
500
  const container = actionsRef.current;
376
501
  if (!container) return;
377
502
 
@@ -381,13 +506,18 @@ export function Toolbar({
381
506
  ':scope > .squisq-toolbar-group > .squisq-toolbar-button',
382
507
  );
383
508
  let firstHidden: number | null = null;
384
- children.forEach((child, i) => {
509
+ let btnIndex = 0;
510
+ children.forEach((child) => {
385
511
  if (firstHidden !== null) return;
512
+ // data-btn-count lets a single DOM button represent multiple BUTTONS entries
513
+ // (e.g. the Insert dropdown button represents the entire media group).
514
+ const btnCount = Number(child.dataset.btnCount ?? 1);
386
515
  const rect = child.getBoundingClientRect();
387
516
  // A button is hidden if its right edge extends past the container
388
517
  if (rect.right > containerRight + 2) {
389
- firstHidden = i;
518
+ firstHidden = btnIndex;
390
519
  }
520
+ btnIndex += btnCount;
391
521
  });
392
522
  setMeasuredOverflowIndex(firstHidden);
393
523
  };
@@ -396,7 +526,7 @@ export function Toolbar({
396
526
  ro.observe(container);
397
527
  measure();
398
528
  return () => ro.disconnect();
399
- }, [activeView, isNarrow]);
529
+ }, [activeView]);
400
530
 
401
531
  // Close overflow menu on outside click
402
532
  useEffect(() => {
@@ -410,6 +540,18 @@ export function Toolbar({
410
540
  return () => document.removeEventListener('mousedown', handleClick);
411
541
  }, [showOverflow]);
412
542
 
543
+ // Close insert menu on outside click
544
+ useEffect(() => {
545
+ if (!insertMenuAnchor) return;
546
+ const handleClick = (e: MouseEvent) => {
547
+ if (insertMenuButtonRef.current?.contains(e.target as Node)) return;
548
+ if (insertMenuRef.current?.contains(e.target as Node)) return;
549
+ closeInsertMenu();
550
+ };
551
+ document.addEventListener('mousedown', handleClick);
552
+ return () => document.removeEventListener('mousedown', handleClick);
553
+ }, [insertMenuAnchor, closeInsertMenu]);
554
+
413
555
  // Open-up vs open-down: the overflow menu is anchored to its trigger with
414
556
  // `top: 100%` by default. When the toolbar lives near the bottom of a
415
557
  // small container (e.g. a chat composer), a downward menu gets clipped.
@@ -438,16 +580,17 @@ export function Toolbar({
438
580
  // Force re-render when Tiptap selection or formatting state changes
439
581
  const [, forceUpdate] = useReducer((c: number) => c + 1, 0);
440
582
  useEffect(() => {
441
- if (!tiptapEditor) return;
442
- tiptapEditor.on('transaction', forceUpdate);
583
+ if (!formattingEditor) return;
584
+ formattingEditor.on('transaction', forceUpdate);
443
585
  return () => {
444
- tiptapEditor.off('transaction', forceUpdate);
586
+ formattingEditor.off('transaction', forceUpdate);
445
587
  };
446
- }, [tiptapEditor]);
588
+ }, [formattingEditor]);
447
589
 
448
590
  // ── Tiptap handler ─────────────────────────────────────
449
591
  const handleTiptap = useCallback(
450
592
  (id: string) => {
593
+ const tiptapEditor = formattingEditor;
451
594
  if (!tiptapEditor) return;
452
595
  const chain = tiptapEditor.chain().focus();
453
596
  switch (id) {
@@ -525,9 +668,24 @@ export function Toolbar({
525
668
  case 'table':
526
669
  tiptapEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
527
670
  break;
671
+ case 'diagram':
672
+ // Empty diagram is usable via its "+ Add first node" affordance;
673
+ // sub-headings under this block become nodes.
674
+ insertTemplateHeading(tiptapEditor, { template: 'diagram', text: 'Diagram' });
675
+ break;
676
+ case 'drawing':
677
+ // Empty drawing is usable via the canvas's "Shapes ▾" palette;
678
+ // each shape persists as a child heading.
679
+ insertTemplateHeading(tiptapEditor, { template: 'drawing', text: 'Drawing' });
680
+ break;
681
+ case 'layout':
682
+ // Seed with a starter text layer (a child sub-block) so the canvas
683
+ // isn't blank; further layers add via the toolbar Text/Box buttons.
684
+ insertLayoutBlock(tiptapEditor);
685
+ break;
528
686
  }
529
687
  },
530
- [tiptapEditor],
688
+ [formattingEditor],
531
689
  );
532
690
 
533
691
  // ── Raw markdown handler ───────────────────────────────
@@ -664,6 +822,24 @@ export function Toolbar({
664
822
  newCursorOffset = 3; // after \n|
665
823
  break;
666
824
  }
825
+ case 'diagram': {
826
+ // A diagram is just a heading with the `{[diagram]}` template
827
+ // annotation; the WYSIWYG view renders its editable canvas.
828
+ replacement = '\n## Diagram {[diagram]}\n';
829
+ newCursorOffset = 4; // start of "Diagram" (after \n## )
830
+ break;
831
+ }
832
+ case 'drawing': {
833
+ replacement = '\n## Drawing {[drawing]}\n';
834
+ newCursorOffset = 4; // start of "Drawing"
835
+ break;
836
+ }
837
+ case 'layout': {
838
+ // Seed a starter text layer (a child sub-block) so it isn't blank.
839
+ replacement = LAYOUT_STARTER_MARKDOWN;
840
+ newCursorOffset = 4; // start of "Layout" in the parent heading
841
+ break;
842
+ }
667
843
  }
668
844
 
669
845
  // Apply the edit via Monaco's executeEdits for proper undo support
@@ -727,6 +903,15 @@ export function Toolbar({
727
903
  insertion =
728
904
  '\n| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |\n';
729
905
  break;
906
+ case 'diagram':
907
+ insertion = '\n## Diagram {[diagram]}\n';
908
+ break;
909
+ case 'drawing':
910
+ insertion = '\n## Drawing {[drawing]}\n';
911
+ break;
912
+ case 'layout':
913
+ insertion = LAYOUT_STARTER_MARKDOWN;
914
+ break;
730
915
  }
731
916
  if (insertion) {
732
917
  setMarkdownSource(markdownSource + insertion);
@@ -773,7 +958,9 @@ export function Toolbar({
773
958
  else openEmojiPicker();
774
959
  return;
775
960
  }
776
- if (activeView === 'wysiwyg' && tiptapEditor) {
961
+ // A focused canvas textbox takes the formatting (handleTiptap targets
962
+ // `formattingEditor`); otherwise route to the document editor.
963
+ if (activeSceneText || (activeView === 'wysiwyg' && tiptapEditor)) {
777
964
  handleTiptap(id);
778
965
  } else {
779
966
  handleRaw(id);
@@ -782,6 +969,7 @@ export function Toolbar({
782
969
  [
783
970
  activeView,
784
971
  tiptapEditor,
972
+ activeSceneText,
785
973
  handleTiptap,
786
974
  handleRaw,
787
975
  emojiPickerAnchor,
@@ -868,17 +1056,20 @@ export function Toolbar({
868
1056
  const trimmedUrl = url.trim();
869
1057
  const trimmedText = text.trim();
870
1058
 
871
- if (linkDialog.target === 'wysiwyg' && tiptapEditor) {
1059
+ // Target the focused canvas textbox editor when one is active, else
1060
+ // the document editor (the dialog was opened from the same target).
1061
+ const wysiwygTarget = formattingEditor;
1062
+ if (linkDialog.target === 'wysiwyg' && wysiwygTarget) {
872
1063
  if (!trimmedUrl) {
873
1064
  // Empty URL on update = unlink. On insert with no URL, do nothing.
874
1065
  if (linkDialog.mode === 'update') {
875
- tiptapEditor.chain().focus().unsetLink().run();
1066
+ wysiwygTarget.chain().focus().unsetLink().run();
876
1067
  }
877
1068
  setLinkDialog(null);
878
1069
  return;
879
1070
  }
880
1071
  const visibleText = trimmedText || trimmedUrl;
881
- const chain = tiptapEditor.chain().focus();
1072
+ const chain = wysiwygTarget.chain().focus();
882
1073
  // Insert (or replace selection) with text carrying a link mark. When
883
1074
  // updating an existing link, the selection was extended to the full
884
1075
  // mark range earlier, so this replaces the entire `[text](url)`.
@@ -927,13 +1118,37 @@ export function Toolbar({
927
1118
 
928
1119
  setLinkDialog(null);
929
1120
  },
930
- [linkDialog, tiptapEditor, monacoEditor],
1121
+ [linkDialog, formattingEditor, monacoEditor],
931
1122
  );
932
1123
 
933
- const groups = ['format', 'lists', 'structure', 'insert', 'media'] as const;
1124
+ const groups = ['format', 'lists', 'structure', 'insert'] as const;
934
1125
  const isWysiwyg = activeView === 'wysiwyg' && tiptapEditor;
935
1126
  const isPreview = activeView === 'preview';
936
1127
 
1128
+ // Formatting buttons reflect/drive the focused canvas textbox when one is
1129
+ // active, else the document's WYSIWYG editor.
1130
+ const formatActive = !!activeSceneText || isWysiwyg;
1131
+ // Inline-level textboxes (diagram/drawing labels) only support inline
1132
+ // marks — grey out block formatters so the toolbar reflects what applies.
1133
+ const INLINE_SCENE_BUTTONS = new Set([
1134
+ 'bold',
1135
+ 'italic',
1136
+ 'strikethrough',
1137
+ 'code',
1138
+ 'link',
1139
+ 'emoji',
1140
+ ]);
1141
+ // `block`-level textboxes (layout) add lists/blockquote/code block on top of
1142
+ // the inline marks — but NOT headings (their content is a sub-block body, so
1143
+ // a heading there would re-parse as a sibling sub-block).
1144
+ const BLOCK_SCENE_BUTTONS = new Set([...INLINE_SCENE_BUTTONS, 'ul', 'ol', 'quote', 'codeblock']);
1145
+ const buttonAllowed = (id: string): boolean => {
1146
+ if (!sceneTextLevel) return true;
1147
+ if (sceneTextLevel === 'rich') return true;
1148
+ if (sceneTextLevel === 'block') return BLOCK_SCENE_BUTTONS.has(id);
1149
+ return INLINE_SCENE_BUTTONS.has(id);
1150
+ };
1151
+
937
1152
  // ── Progressive heading disclosure ───────────────────
938
1153
  // H1\u2013H3 are always visible. H4 appears once the document already
939
1154
  // contains an H3, H5 once it contains an H4, and H6 once it contains
@@ -962,6 +1177,9 @@ export function Toolbar({
962
1177
  if (!m) return true;
963
1178
  return Number(m[1]) <= visibleHeadingMax;
964
1179
  };
1180
+ const hasVisibleMediaButtons = MEDIA_BUTTONS.some((b) => isButtonVisible(b.id));
1181
+ const showInsertInOverflow =
1182
+ overflowIndex !== null && overflowIndex <= FIRST_MEDIA_INDEX && hasVisibleMediaButtons;
965
1183
 
966
1184
  // Detect whether cursor is inside a table (WYSIWYG mode only)
967
1185
  const isInTable = isWysiwyg ? tiptapEditor.isActive('table') : false;
@@ -980,10 +1198,12 @@ export function Toolbar({
980
1198
  const isRawView = activeView === 'raw';
981
1199
  const [rawTemplate, setRawTemplate] = useState<string | null>(null);
982
1200
  const [rawHeadingLine, setRawHeadingLine] = useState<number | null>(null);
1201
+ const [rawTransition, setRawTransition] = useState<TransitionFields>(EMPTY_TRANSITION);
983
1202
  useEffect(() => {
984
1203
  if (!isRawView || !monacoEditor) {
985
1204
  setRawTemplate(null);
986
1205
  setRawHeadingLine(null);
1206
+ setRawTransition(EMPTY_TRANSITION);
987
1207
  return;
988
1208
  }
989
1209
  const recompute = () => {
@@ -992,6 +1212,7 @@ export function Toolbar({
992
1212
  if (!model || !pos) {
993
1213
  setRawTemplate(null);
994
1214
  setRawHeadingLine(null);
1215
+ setRawTransition(EMPTY_TRANSITION);
995
1216
  return;
996
1217
  }
997
1218
  const line = model.getLineContent(pos.lineNumber);
@@ -999,9 +1220,11 @@ export function Toolbar({
999
1220
  if (!headingMatch) {
1000
1221
  setRawTemplate(null);
1001
1222
  setRawHeadingLine(null);
1223
+ setRawTransition(EMPTY_TRANSITION);
1002
1224
  return;
1003
1225
  }
1004
1226
  setRawHeadingLine(pos.lineNumber);
1227
+ setRawTransition(readHeadingLineTransition(line));
1005
1228
  const annotMatch = headingMatch[1].match(/\s*\{\[([^\]]+)\]\}[\s\]}]*$/);
1006
1229
  if (annotMatch) {
1007
1230
  // First whitespace-delimited token is the template name; the rest are params.
@@ -1115,6 +1338,52 @@ export function Toolbar({
1115
1338
  }
1116
1339
  };
1117
1340
 
1341
+ // ── Current block transition ──────────────────────────
1342
+ // Non-null exactly when a block (heading) is active — same condition as
1343
+ // the template picker. Read from the Pandoc `{…}` block (canonical), with
1344
+ // the `{[…]}` params as a fallback for hand-typed transitions.
1345
+ let currentTransition: TransitionFields | null = null;
1346
+ if (isWysiwyg && wysiwygTemplate !== null) {
1347
+ const attrs = tiptapEditor.getAttributes('heading') as {
1348
+ dataBlockAttrs?: string | null;
1349
+ dataTemplateParams?: string | null;
1350
+ };
1351
+ currentTransition = readBlockAttrsTransition(attrs.dataBlockAttrs, attrs.dataTemplateParams);
1352
+ } else if (isRawView && rawTemplate !== null) {
1353
+ currentTransition = rawTransition;
1354
+ }
1355
+
1356
+ const handleTransitionPick = (next: TransitionFields) => {
1357
+ // Raw (Monaco) — rewrite the heading line, writing into the `{…}` block.
1358
+ if (isRawView && monacoEditor) {
1359
+ const model = monacoEditor.getModel();
1360
+ const pos = monacoEditor.getPosition();
1361
+ if (!model || !pos) return;
1362
+ const lineNumber = pos.lineNumber;
1363
+ const lineText = model.getLineContent(lineNumber);
1364
+ const newLine = setHeadingLineTransition(lineText, next);
1365
+ if (newLine === lineText) return;
1366
+ monacoEditor.executeEdits('toolbar-transition-pick', [
1367
+ {
1368
+ range: {
1369
+ startLineNumber: lineNumber,
1370
+ startColumn: 1,
1371
+ endLineNumber: lineNumber,
1372
+ endColumn: lineText.length + 1,
1373
+ },
1374
+ text: newLine,
1375
+ },
1376
+ ]);
1377
+ monacoEditor.focus();
1378
+ return;
1379
+ }
1380
+ // WYSIWYG — rewrite the heading node's `dataBlockAttrs` (Pandoc inner).
1381
+ if (!tiptapEditor) return;
1382
+ const attrs = tiptapEditor.getAttributes('heading') as { dataBlockAttrs?: string | null };
1383
+ const inner = setBlockAttrsTransition(attrs.dataBlockAttrs, next);
1384
+ tiptapEditor.chain().focus().updateAttributes('heading', { dataBlockAttrs: inner }).run();
1385
+ };
1386
+
1118
1387
  return (
1119
1388
  <div
1120
1389
  className={`squisq-toolbar ${className || ''}`}
@@ -1167,8 +1436,8 @@ export function Toolbar({
1167
1436
  ))}
1168
1437
  </div>
1169
1438
  )}
1170
- {/* Formatting buttons — hidden in preview mode, narrow screens, and code mode */}
1171
- {!isPreview && !isNarrow && !isCodeMode && (
1439
+ {/* Formatting buttons — hidden in preview mode and code mode */}
1440
+ {!isPreview && !isCodeMode && (
1172
1441
  <div className="squisq-toolbar-actions" ref={actionsRef}>
1173
1442
  {groups.map((group, gi) => (
1174
1443
  <div key={group} className="squisq-toolbar-group">
@@ -1177,10 +1446,10 @@ export function Toolbar({
1177
1446
  const active =
1178
1447
  btn.id === 'emoji'
1179
1448
  ? emojiPickerAnchor !== null
1180
- : isWysiwyg
1181
- ? isTiptapActive(tiptapEditor, btn.id)
1449
+ : formatActive && formattingEditor
1450
+ ? isTiptapActive(formattingEditor, btn.id)
1182
1451
  : false;
1183
- const disabled = btn.id === 'image' && !mediaProvider;
1452
+ const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
1184
1453
  return (
1185
1454
  <button
1186
1455
  key={btn.id}
@@ -1191,15 +1460,31 @@ export function Toolbar({
1191
1460
  aria-label={btn.title}
1192
1461
  aria-pressed={active}
1193
1462
  disabled={disabled}
1194
- style={btn.iconStyle}
1195
1463
  >
1196
- {buttonIconSvg(btn.id) ?? btn.icon}
1464
+ {buttonIcon(btn)}
1197
1465
  </button>
1198
1466
  );
1199
1467
  })}
1200
1468
  </div>
1201
1469
  ))}
1202
1470
 
1471
+ {/* Insert menu button — collapses all media-group actions into a single dropdown */}
1472
+ <div className="squisq-toolbar-group">
1473
+ <div className="squisq-toolbar-separator" />
1474
+ <button
1475
+ ref={insertMenuButtonRef}
1476
+ className={`squisq-toolbar-button${insertMenuAnchor ? ' squisq-toolbar-button--active' : ''}`}
1477
+ data-btn-count={String(MEDIA_BUTTONS.length)}
1478
+ data-tooltip="Insert..."
1479
+ onClick={() => (insertMenuAnchor ? closeInsertMenu() : openInsertMenu())}
1480
+ aria-label="Insert"
1481
+ aria-expanded={insertMenuAnchor !== null}
1482
+ aria-haspopup="menu"
1483
+ >
1484
+ <Icon icon="fa-solid fa-plus" />
1485
+ </button>
1486
+ </div>
1487
+
1203
1488
  {/* Template picker — visible when the cursor is in a heading.
1204
1489
  In WYSIWYG, reads from the heading node's `dataTemplate`; in
1205
1490
  Markdown view, parses the `{[...]}` suffix on the cursor's line. */}
@@ -1216,6 +1501,18 @@ export function Toolbar({
1216
1501
  </>
1217
1502
  )}
1218
1503
 
1504
+ {/* Transition picker — visible alongside the template picker when a
1505
+ block (heading) is active. Writes `transition=` into the block's
1506
+ Pandoc `{…}` attribute block (the canonical home for transitions). */}
1507
+ {currentTransition !== null && (
1508
+ <>
1509
+ <div className="squisq-toolbar-separator" />
1510
+ <div className="squisq-toolbar-group squisq-transition-picker-group">
1511
+ <TransitionPicker value={currentTransition} onChange={handleTransitionPick} />
1512
+ </div>
1513
+ </>
1514
+ )}
1515
+
1219
1516
  {/* Table controls — visible when cursor is in a table (WYSIWYG) */}
1220
1517
  {isInTable && (
1221
1518
  <>
@@ -1392,14 +1689,17 @@ export function Toolbar({
1392
1689
  >
1393
1690
  {BUTTONS.slice(overflowIndex)
1394
1691
  .filter((b) => isButtonVisible(b.id))
1692
+ // Media buttons are represented by the synthetic Insert dropdown
1693
+ // in both the visible toolbar and the overflow menu.
1694
+ .filter((b) => b.group !== 'media')
1395
1695
  .map((btn) => {
1396
1696
  const active =
1397
1697
  btn.id === 'emoji'
1398
1698
  ? emojiPickerAnchor !== null
1399
- : isWysiwyg
1400
- ? isTiptapActive(tiptapEditor, btn.id)
1699
+ : formatActive && formattingEditor
1700
+ ? isTiptapActive(formattingEditor, btn.id)
1401
1701
  : false;
1402
- const disabled = btn.id === 'image' && !mediaProvider;
1702
+ const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
1403
1703
  return (
1404
1704
  <button
1405
1705
  key={btn.id}
@@ -1414,16 +1714,33 @@ export function Toolbar({
1414
1714
  }}
1415
1715
  disabled={disabled}
1416
1716
  >
1417
- {buttonIconSvg(btn.id) ?? (
1418
- <span className="squisq-toolbar-overflow-icon" style={btn.iconStyle}>
1419
- {btn.icon}
1420
- </span>
1421
- )}
1717
+ <span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
1422
1718
  <span>{btn.title}</span>
1423
1719
  </button>
1424
1720
  );
1425
1721
  })}
1426
1722
 
1723
+ {showInsertInOverflow && (
1724
+ <button
1725
+ className={`squisq-toolbar-overflow-item${insertMenuAnchor ? ' squisq-toolbar-overflow-item--active' : ''}`}
1726
+ onClick={(event) => {
1727
+ if (insertMenuAnchor) {
1728
+ closeInsertMenu();
1729
+ } else {
1730
+ openInsertMenu(event.currentTarget);
1731
+ }
1732
+ setShowOverflow(false);
1733
+ }}
1734
+ aria-haspopup="menu"
1735
+ aria-expanded={insertMenuAnchor !== null}
1736
+ >
1737
+ <span className="squisq-toolbar-overflow-icon">
1738
+ <Icon icon="fa-solid fa-plus" />
1739
+ </span>
1740
+ <span>Insert...</span>
1741
+ </button>
1742
+ )}
1743
+
1427
1744
  {/* Contextual: template picker in overflow */}
1428
1745
  {currentTemplate !== null && (
1429
1746
  <div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
@@ -1498,7 +1815,7 @@ export function Toolbar({
1498
1815
  {slotAfterActions}
1499
1816
  {/* Spacer — only needed when the actions container (which has flex:1
1500
1817
  and already pushes right-side items to the end) isn't rendered. */}
1501
- {(isPreview || isNarrow || isCodeMode) && <div style={{ flex: 1 }} />}
1818
+ {(isPreview || isCodeMode) && <div style={{ flex: 1 }} />}
1502
1819
  {/* Version history — renders only when the host enabled versioning
1503
1820
  and a container is wired up. The component owns its own button
1504
1821
  and popover; we just give it a slot in the toolbar. */}
@@ -1516,21 +1833,18 @@ export function Toolbar({
1516
1833
  data-tooltip="Document settings"
1517
1834
  aria-label="Document settings"
1518
1835
  >
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>
1836
+ <Icon icon="fa-solid fa-file-lines" />
1837
+ </button>
1838
+ )}
1839
+ {!isCodeMode && (
1840
+ <button
1841
+ type="button"
1842
+ className={`squisq-toolbar-button${showLayoutManager ? ' squisq-toolbar-button--active' : ''}`}
1843
+ onClick={() => setShowLayoutManager(true)}
1844
+ data-tooltip="Custom layouts"
1845
+ aria-label="Custom layouts"
1846
+ >
1847
+ <Icon icon="fa-solid fa-shapes" />
1534
1848
  </button>
1535
1849
  )}
1536
1850
  {!isCodeMode && <ViewMenuPanel />}
@@ -1543,7 +1857,7 @@ export function Toolbar({
1543
1857
  aria-pressed={showFiles}
1544
1858
  aria-label="Toggle Files panel"
1545
1859
  >
1546
- {PAPERCLIP_ICON}
1860
+ <Icon icon="fa-solid fa-paperclip" />
1547
1861
  </button>
1548
1862
  )}
1549
1863
  {/* Right slot — rightmost end of toolbar */}
@@ -1561,6 +1875,9 @@ export function Toolbar({
1561
1875
  />
1562
1876
  )}
1563
1877
 
1878
+ {/* Custom layout manager — full-window list + designer dialog. */}
1879
+ {showLayoutManager && <CustomLayoutManager onClose={() => setShowLayoutManager(false)} />}
1880
+
1564
1881
  {/* Link insert/edit dialog — shared by WYSIWYG and Raw views. */}
1565
1882
  {linkDialog && (
1566
1883
  <LinkDialog
@@ -1573,6 +1890,43 @@ export function Toolbar({
1573
1890
  />
1574
1891
  )}
1575
1892
 
1893
+ {/* Insert menu — portaled to the document body so the overflow:hidden
1894
+ actions row doesn't clip it. Position computed from trigger rect. */}
1895
+ {insertMenuAnchor &&
1896
+ createPortal(
1897
+ <div
1898
+ ref={insertMenuRef}
1899
+ className="squisq-insert-menu"
1900
+ data-theme={theme}
1901
+ style={{ position: 'fixed', top: insertMenuAnchor.top, left: insertMenuAnchor.left }}
1902
+ role="menu"
1903
+ >
1904
+ <div className="squisq-insert-menu-header">Insert</div>
1905
+ {MEDIA_BUTTONS.filter((b) => isButtonVisible(b.id)).map((btn) => {
1906
+ const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
1907
+ const stripped = btn.title.replace(/^Insert\s+/i, '');
1908
+ const label = stripped.charAt(0).toUpperCase() + stripped.slice(1);
1909
+ return (
1910
+ <button
1911
+ key={btn.id}
1912
+ ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
1913
+ className="squisq-toolbar-overflow-item"
1914
+ disabled={disabled}
1915
+ onClick={() => {
1916
+ handleAction(btn.id);
1917
+ if (btn.id !== 'emoji') closeInsertMenu();
1918
+ }}
1919
+ role="menuitem"
1920
+ >
1921
+ <span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
1922
+ <span>{label}</span>
1923
+ </button>
1924
+ );
1925
+ })}
1926
+ </div>,
1927
+ document.body,
1928
+ )}
1929
+
1576
1930
  {/* Emoji picker — portaled to the document body so the toolbar's
1577
1931
  overflow:hidden actions row doesn't clip the popover. Position
1578
1932
  is computed from the trigger button's screen rect at open. */}