@bendyline/squisq-editor-react 1.6.1 → 2.0.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 (208) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1304 -208
  3. package/dist/index.js +22838 -14494
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +2384 -408
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +48 -17
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +403 -270
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +276 -40
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +466 -93
  20. package/src/PreviewPanel.tsx +313 -77
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +34 -10
  24. package/src/TemplateContentPreview.tsx +56 -0
  25. package/src/TemplatePicker.tsx +355 -134
  26. package/src/ThemeCustomizerPanel.tsx +30 -15
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +719 -238
  30. package/src/TransitionPicker.tsx +44 -5
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/ViewSwitcher.tsx +4 -4
  33. package/src/WysiwygEditor.tsx +167 -92
  34. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  35. package/src/__tests__/blockTagActivity.test.ts +183 -0
  36. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  37. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  38. package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
  39. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +390 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +53 -15
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +38 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  54. package/src/__tests__/mediaReferences.test.ts +82 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +312 -6
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  66. package/src/__tests__/statusBar.test.tsx +27 -0
  67. package/src/__tests__/teleprompter.test.tsx +190 -0
  68. package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
  69. package/src/__tests__/templateContentPreview.test.ts +101 -0
  70. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  71. package/src/__tests__/tiptapBridge.test.ts +70 -0
  72. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  73. package/src/__tests__/transformStyleId.test.ts +13 -0
  74. package/src/__tests__/useImageEditor.test.tsx +67 -0
  75. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  76. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  77. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  78. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  79. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  80. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  81. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  82. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  83. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  84. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  85. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  86. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  87. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  88. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  89. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  90. package/src/asciiDiagram/asciiPaste.ts +19 -0
  91. package/src/blockTagActivity.ts +233 -0
  92. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  93. package/src/buildPreviewDoc.ts +19 -12
  94. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  95. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  96. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  97. package/src/customTemplates/__tests__/library.test.ts +8 -0
  98. package/src/customTemplates/designer.css +158 -99
  99. package/src/customTemplates/library.ts +16 -3
  100. package/src/customThemes/CustomThemeContext.tsx +6 -0
  101. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  102. package/src/customThemes/ImportThemeSection.tsx +178 -0
  103. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  104. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  105. package/src/customThemes/customThemeLibrary.ts +7 -4
  106. package/src/customThemes/index.ts +7 -1
  107. package/src/customThemes/themeDraft.ts +23 -7
  108. package/src/diagram/DiagramCanvas.tsx +9 -4
  109. package/src/diagram/types.ts +35 -0
  110. package/src/editorScrollSync.ts +73 -0
  111. package/src/emojiData.ts +3 -23
  112. package/src/headingTransition.ts +85 -25
  113. package/src/imageEditor/CanvasSurface.tsx +29 -23
  114. package/src/imageEditor/LayersPanel.tsx +78 -3
  115. package/src/imageEditor/Toolbar.tsx +5 -1
  116. package/src/imageEditor/icons.tsx +4 -0
  117. package/src/imageEditor/image-editor.css +75 -0
  118. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  119. package/src/imageEditor/useImageEditor.ts +71 -14
  120. package/src/index.ts +193 -20
  121. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  122. package/src/jsonEditor/RenderNode.tsx +24 -3
  123. package/src/jsonEditor/editors.tsx +86 -19
  124. package/src/mediaEntries.ts +12 -0
  125. package/src/mediaReferences.ts +299 -0
  126. package/src/monaco.ts +35 -0
  127. package/src/monacoWorkers.ts +89 -0
  128. package/src/outlineSource.ts +171 -0
  129. package/src/presentation/PresentationMode.tsx +596 -0
  130. package/src/rawEditorIsolation.ts +18 -0
  131. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  132. package/src/resolveBlockVisual.ts +10 -4
  133. package/src/scene/Scene.tsx +101 -25
  134. package/src/scene/SceneBlockExtension.ts +17 -10
  135. package/src/scene/SceneBlockWidget.tsx +11 -4
  136. package/src/scene/SceneSelection.tsx +19 -15
  137. package/src/scene/SceneSideToolbar.tsx +89 -0
  138. package/src/scene/SceneViewport.tsx +7 -3
  139. package/src/scene/ShapePalette.tsx +17 -2
  140. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  141. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  142. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  143. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  144. package/src/scene/commands/SceneCommand.ts +4 -1
  145. package/src/scene/index.ts +1 -6
  146. package/src/scene/layers/DiagramEdges.tsx +24 -9
  147. package/src/scene/layers/edgeGeometry.ts +31 -4
  148. package/src/scene/layers/nodeCard.tsx +27 -8
  149. package/src/scene/scene.css +145 -2
  150. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  151. package/src/scene/text/sceneTextChannel.ts +26 -20
  152. package/src/scene/text/sceneTextConfig.ts +4 -0
  153. package/src/scene/tools/ConnectTool.ts +126 -28
  154. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  155. package/src/scene/tools/SceneTool.ts +10 -0
  156. package/src/scene/tools/SelectTool.ts +34 -18
  157. package/src/scene/tools/ShapeTool.ts +2 -3
  158. package/src/styles/ascii-diagram.css +79 -0
  159. package/src/styles/ascii-timeline.css +499 -0
  160. package/src/styles/editor.css +1566 -219
  161. package/src/styles/index.css +3 -0
  162. package/src/styles/tree-view.css +139 -0
  163. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  164. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  165. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  166. package/src/teleprompter/TeleprompterView.tsx +338 -0
  167. package/src/teleprompter/canvasRenderer.ts +161 -0
  168. package/src/teleprompter/floatingWindow.ts +352 -0
  169. package/src/teleprompter/index.ts +61 -0
  170. package/src/teleprompter/pcmWorklet.ts +62 -0
  171. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  172. package/src/teleprompter/recording/narrationSave.ts +134 -0
  173. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  174. package/src/teleprompter/scrollModel.ts +85 -0
  175. package/src/teleprompter/teleprompterTheme.ts +303 -0
  176. package/src/teleprompter/types.ts +38 -0
  177. package/src/teleprompter/useFloatingWindow.ts +74 -0
  178. package/src/teleprompter/useMicAnalysis.ts +211 -0
  179. package/src/teleprompter/useTeleprompter.ts +421 -0
  180. package/src/templateContentPreviewResolver.ts +360 -0
  181. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  182. package/src/timeline/TimelineViewExtension.ts +252 -0
  183. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  184. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  185. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  186. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  187. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  188. package/src/timeline/timelineCommands.ts +367 -0
  189. package/src/timeline/timelineData.ts +54 -0
  190. package/src/timeline/timelineOps.ts +277 -0
  191. package/src/timeline/timelinePaste.ts +8 -0
  192. package/src/tiptapBridge.ts +84 -49
  193. package/src/transformStyleId.ts +17 -0
  194. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  195. package/src/treeview/TreeViewExtension.ts +250 -0
  196. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  197. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  198. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  199. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  200. package/src/treeview/treeOps.ts +172 -0
  201. package/src/treeview/treePaste.ts +13 -0
  202. package/src/treeview/treeViewCommands.ts +94 -0
  203. package/src/treeview/treeViewData.ts +41 -0
  204. package/src/useMonacoLoader.ts +11 -34
  205. package/src/wysiwygImageUpload.ts +113 -0
  206. package/src/diagram/DiagramExtension.ts +0 -209
  207. package/src/diagram/DiagramWidget.tsx +0 -270
  208. package/src/diagram/useDiagramData.ts +0 -126
package/src/Toolbar.tsx CHANGED
@@ -9,21 +9,35 @@
9
9
 
10
10
  import type { ReactNode } from 'react';
11
11
  import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
12
- import type { Editor as TiptapEditor } from '@tiptap/core';
12
+ import type { Editor as TiptapEditor, JSONContent } from '@tiptap/core';
13
13
  import { TextSelection } from '@tiptap/pm/state';
14
14
  import { Fragment } from '@tiptap/pm/model';
15
15
  import type { IRange } from 'monaco-editor';
16
+ import type { Block } from '@bendyline/squisq/schemas';
17
+ import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
18
+ import { DEFAULT_THEME, flattenBlocks } from '@bendyline/squisq/doc';
19
+ import {
20
+ KNOWN_BLOCK_META_KEYS,
21
+ matchTrailingTemplateAnnotation,
22
+ splitKeyValueToken,
23
+ tokenizeAttrTokens,
24
+ } from '@bendyline/squisq/markdown';
16
25
  import { useEditorContext, type EditorView } from './EditorContext';
17
26
  import { VersionHistoryPanel } from './VersionHistoryPanel';
18
27
  import { RecorderEntry } from './RecorderEntry';
19
28
  import { ViewMenuPanel } from './ViewMenuPanel';
20
- import { TemplatePicker, TEMPLATE_NAMES } from './TemplatePicker';
21
- import { TransitionPicker } from './TransitionPicker';
29
+ import {
30
+ TemplatePicker,
31
+ TEMPLATE_NAMES,
32
+ TEMPLATE_GALLERY_PORTAL_SELECTOR,
33
+ templateLabel,
34
+ } from './TemplatePicker';
35
+ import { TransitionPicker, TRANSITION_FLYOUT_PORTAL_ID } from './TransitionPicker';
22
36
  import {
23
37
  readHeadingLineTransition,
24
38
  setHeadingLineTransition,
25
39
  readBlockAttrsTransition,
26
- setBlockAttrsTransition,
40
+ setHeadingAttrsTransition,
27
41
  EMPTY_TRANSITION,
28
42
  type TransitionFields,
29
43
  } from './headingTransition';
@@ -36,25 +50,31 @@ import { CustomLayoutManager } from './customTemplates/CustomLayoutManager';
36
50
  import { Icon } from './Icon';
37
51
  import type { PickerEntry } from './emojiData';
38
52
  import { createPortal } from 'react-dom';
53
+ import { PreviewModeMenu, displayModeLabel, usePreviewSettingsOptional } from './PreviewControls';
54
+ import { filterVisibleMediaEntries } from './mediaEntries';
39
55
 
40
56
  const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
41
- { id: 'wysiwyg', label: 'Editor', shortcut: '⌘1' },
42
- { id: 'raw', label: 'Markdown', shortLabel: 'MD', shortcut: '⌘2' },
43
- { id: 'preview', label: 'Play', shortcut: '⌘3' },
57
+ { id: 'wysiwyg', label: 'Write', shortcut: '⌘1' },
58
+ { id: 'raw', label: 'Source', shortcut: '⌘2' },
59
+ { id: 'preview', label: 'Use', shortcut: '⌘3' },
44
60
  ];
45
61
 
62
+ const BLOCK_META_KEYS = new Set<string>(Object.keys(KNOWN_BLOCK_META_KEYS));
63
+
46
64
  export interface ToolbarProps {
47
65
  /** Additional class name */
48
66
  className?: string;
49
67
  /** Whether the Files panel is currently shown */
50
68
  showFiles?: boolean;
69
+ /** Number of files currently available through the MediaProvider. */
70
+ fileCount?: number;
51
71
  /** Toggle the Files panel. When provided, a "Files" button appears in the toolbar. */
52
72
  onToggleFiles?: () => void;
53
73
  /** Content rendered at the left edge of the toolbar, before the view tabs. */
54
74
  slotLeft?: ReactNode;
55
75
  /** Content rendered immediately after the view tabs, on the left side of the
56
76
  * toolbar (before the formatting controls). Used for the preview mode
57
- * switch in Play view. */
77
+ * switch in Use view. */
58
78
  slotAfterTabs?: ReactNode;
59
79
  /** Content rendered after the formatting controls (in the middle area). */
60
80
  slotAfterActions?: ReactNode;
@@ -185,6 +205,14 @@ const BUTTONS: ToolbarButton[] = [
185
205
  group: 'media',
186
206
  faIcon: 'fa-solid fa-table',
187
207
  },
208
+ {
209
+ id: 'tasklist',
210
+ label: 'tasks',
211
+ icon: '',
212
+ title: 'Insert Task List',
213
+ group: 'media',
214
+ faIcon: 'fa-solid fa-list-check',
215
+ },
188
216
  {
189
217
  id: 'diagram',
190
218
  label: 'diagram',
@@ -193,6 +221,22 @@ const BUTTONS: ToolbarButton[] = [
193
221
  group: 'media',
194
222
  faIcon: 'fa-solid fa-diagram-project',
195
223
  },
224
+ {
225
+ id: 'tree',
226
+ label: 'tree',
227
+ icon: '',
228
+ title: 'Insert tree',
229
+ group: 'media',
230
+ faIcon: 'fa-solid fa-folder-tree',
231
+ },
232
+ {
233
+ id: 'timeline',
234
+ label: 'timeline',
235
+ icon: '',
236
+ title: 'Insert timeline',
237
+ group: 'media',
238
+ faIcon: 'fa-solid fa-timeline',
239
+ },
196
240
  {
197
241
  id: 'drawing',
198
242
  label: 'drawing',
@@ -230,12 +274,51 @@ const BUTTONS: ToolbarButton[] = [
230
274
  const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
231
275
  const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
232
276
  const INSERT_MENU_WIDTH = 200;
277
+ const TASK_LIST_ITEMS = ['Task 1', 'Task 2', 'Task 3'] as const;
278
+ const TASK_LIST_MARKDOWN = TASK_LIST_ITEMS.map((item) => `- [ ] ${item}`).join('\n');
279
+
280
+ // BUTTONS position per id — stamped on each rendered button as
281
+ // data-btn-index so the overflow measurement can map a clipped DOM button
282
+ // back to its BUTTONS entry. Walking a counter over the DOM instead would
283
+ // drift whenever some entries render no button of their own (hidden H5/H6
284
+ // heading levels, the media group collapsed behind the Insert dropdown).
285
+ const BUTTON_INDEX_BY_ID = new Map(BUTTONS.map((b, i) => [b.id, i]));
233
286
 
234
287
  /** Renders a button's icon: a Font Awesome glyph when set, else the text label. */
235
288
  function buttonIcon(btn: ToolbarButton): ReactNode {
236
289
  return btn.faIcon ? <Icon icon={btn.faIcon} /> : btn.icon;
237
290
  }
238
291
 
292
+ function fileCountLabel(count: number): string {
293
+ return `${count} ${count === 1 ? 'file' : 'files'}`;
294
+ }
295
+
296
+ function fileCountBadge(count: number): string {
297
+ return count > 99 ? '99+' : String(count);
298
+ }
299
+
300
+ function taskListContent(): JSONContent {
301
+ return {
302
+ type: 'taskList',
303
+ content: TASK_LIST_ITEMS.map((item) => ({
304
+ type: 'taskItem',
305
+ attrs: { checked: false },
306
+ content: [
307
+ {
308
+ type: 'paragraph',
309
+ content: [{ type: 'text', text: item }],
310
+ },
311
+ ],
312
+ })),
313
+ };
314
+ }
315
+
316
+ function insertTaskList(editor: TiptapEditor): void {
317
+ const supportsTaskList = !!editor.schema.nodes.taskList && !!editor.schema.nodes.taskItem;
318
+ const content = supportsTaskList ? taskListContent() : TASK_LIST_MARKDOWN;
319
+ editor.chain().focus().insertContent(content).run();
320
+ }
321
+
239
322
  // ─── Tiptap active-state map ────────────────────────────
240
323
 
241
324
  /** Returns true if the given button id is currently active in Tiptap */
@@ -289,6 +372,79 @@ const LAYOUT_STARTER_TEXT_PARAMS =
289
372
  /** Multi-line markdown for a new layout (raw / code views). */
290
373
  const LAYOUT_STARTER_MARKDOWN = `\n## Layout {[layout]}\n\n### {#text-1} {[text ${LAYOUT_STARTER_TEXT_PARAMS}]}\n\nLayout\n`;
291
374
 
375
+ /**
376
+ * Starter art for a new diagram — diagrams are authored as ASCII-art code
377
+ * fences (the fence is the source of truth; `AsciiDiagramExtension` mounts
378
+ * the interactive canvas over it). This exact art is the codec's own
379
+ * canonical rendering of a two-node flow, so it re-parses and re-renders
380
+ * byte-stably.
381
+ */
382
+ const DIAGRAM_STARTER_ART = [
383
+ '┌─────────┐',
384
+ '│ Start │',
385
+ '└────┬────┘',
386
+ ' │',
387
+ ' ▼',
388
+ '┌────┴────┐',
389
+ '│ Next │',
390
+ '└─────────┘',
391
+ ].join('\n');
392
+ /**
393
+ * Fenced form for raw / code views — tagged with the explicit `diagram`
394
+ * language so the block's identity survives markdown ↔ Tiptap round-trips
395
+ * (the language class round-trips; fence meta does not).
396
+ */
397
+ const DIAGRAM_STARTER_MARKDOWN = '\n```diagram\n' + DIAGRAM_STARTER_ART + '\n```\n';
398
+
399
+ /** Insert a starter ASCII-diagram code fence after the current top-level block. */
400
+ function insertAsciiDiagramBlock(editor: TiptapEditor): void {
401
+ insertFenceBlock(editor, DIAGRAM_STARTER_ART, 'diagram');
402
+ }
403
+
404
+ /**
405
+ * Starter art for a new file tree — an ASCII tree fence (the fence is the
406
+ * source of truth; `TreeViewExtension` mounts the interactive outline).
407
+ */
408
+ const TREE_STARTER_ART = ['src/', '├── index.ts', '└── utils/', ' └── helpers.ts'].join('\n');
409
+ const TREE_STARTER_MARKDOWN = '\n```tree\n' + TREE_STARTER_ART + '\n```\n';
410
+
411
+ /** Insert a starter ASCII tree code fence after the current top-level block. */
412
+ function insertTreeBlock(editor: TiptapEditor): void {
413
+ insertFenceBlock(editor, TREE_STARTER_ART, 'tree');
414
+ }
415
+
416
+ /** Starter multi-event rail for the authored timeline view. */
417
+ const TIMELINE_STARTER_ART =
418
+ 'Milestones: ● Start {#start} ─────● Review {#review} ─────● Ship {#ship} ───►';
419
+ const TIMELINE_STARTER_MARKDOWN = '\n```timeline\n' + TIMELINE_STARTER_ART + '\n```\n';
420
+
421
+ /** Insert a starter ASCII timeline code fence after the current top-level block. */
422
+ function insertTimelineBlock(editor: TiptapEditor): void {
423
+ insertFenceBlock(editor, TIMELINE_STARTER_ART, 'timeline');
424
+ }
425
+
426
+ /**
427
+ * Insert a code fence after the current top-level block. `lang` tags the
428
+ * fence's `language` attribute — pass the explicit authored-view tag so its
429
+ * identity round-trips through markdown and Tiptap.
430
+ */
431
+ function insertFenceBlock(editor: TiptapEditor, art: string, lang?: string): void {
432
+ editor
433
+ .chain()
434
+ .focus()
435
+ .command(({ tr, state, dispatch }) => {
436
+ const codeBlockType = state.schema.nodes.codeBlock;
437
+ if (!codeBlockType) return false;
438
+ const attrs = lang ? { language: lang } : null;
439
+ const block = codeBlockType.create(attrs, state.schema.text(art));
440
+ const { $from } = state.selection;
441
+ const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
442
+ if (dispatch) tr.insert(insertPos, block);
443
+ return true;
444
+ })
445
+ .run();
446
+ }
447
+
292
448
  /**
293
449
  * Insert a block-level heading carrying a Scene template (`diagram` /
294
450
  * `drawing` / `layout`) at the top level, after the block the caret sits
@@ -365,6 +521,44 @@ function insertLayoutBlock(editor: TiptapEditor): void {
365
521
  .run();
366
522
  }
367
523
 
524
+ function findBlockBySourceLine(blocks: readonly Block[], lineNumber: number): Block | null {
525
+ for (const block of blocks) {
526
+ const pos = block.sourceHeading?.position;
527
+ if (!pos) continue;
528
+ if (pos.start.line <= lineNumber && pos.end.line >= lineNumber) return block;
529
+ }
530
+ return null;
531
+ }
532
+
533
+ function splitTemplateAnnotationParams(text: string): { text: string; params: string[] } {
534
+ const match = matchTrailingTemplateAnnotation(text);
535
+ if (!match) return { text: text.trimEnd(), params: [] };
536
+ const tokens = tokenizeAttrTokens(match.inner.trim());
537
+ const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
538
+ return {
539
+ text: text.slice(0, match.index).trimEnd(),
540
+ params: keepBlockMetaParamTokens(tokens.slice(firstIsParam ? 0 : 1)),
541
+ };
542
+ }
543
+
544
+ function buildTemplateAnnotation(template: string, params: string[]): string | null {
545
+ const parts = template ? [template, ...params] : params;
546
+ return parts.length > 0 ? `{[${parts.join(' ')}]}` : null;
547
+ }
548
+
549
+ function keepBlockMetaParamTokens(tokens: readonly string[]): string[] {
550
+ return tokens.filter((token) => {
551
+ const kv = splitKeyValueToken(token);
552
+ return kv ? BLOCK_META_KEYS.has(kv.key) : false;
553
+ });
554
+ }
555
+
556
+ function keepBlockMetaParamString(inner: string | null | undefined): string | null {
557
+ if (!inner) return null;
558
+ const params = keepBlockMetaParamTokens(tokenizeAttrTokens(inner));
559
+ return params.length > 0 ? params.join(' ') : null;
560
+ }
561
+
368
562
  /**
369
563
  * Formatting toolbar.
370
564
  * - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
@@ -373,6 +567,7 @@ function insertLayoutBlock(editor: TiptapEditor): void {
373
567
  export function Toolbar({
374
568
  className,
375
569
  showFiles,
570
+ fileCount,
376
571
  onToggleFiles,
377
572
  slotLeft,
378
573
  slotAfterTabs,
@@ -384,17 +579,24 @@ export function Toolbar({
384
579
  activeView,
385
580
  setActiveView,
386
581
  markdownSource,
582
+ doc,
387
583
  setMarkdownSource,
388
584
  tiptapEditor,
389
585
  monacoEditor,
390
586
  activeSceneText,
391
587
  mediaProvider,
392
588
  editorMode,
589
+ layoutMode,
590
+ activeBlockStartLine,
393
591
  versioning,
394
592
  allowRecording,
395
593
  documentLinkProvider,
396
594
  colorScheme,
595
+ mediaRevision,
596
+ bumpMediaRevision,
397
597
  } = useEditorContext();
598
+ const previewSettings = usePreviewSettingsOptional();
599
+ const [useModeMenuRequest, requestUseModeMenu] = useReducer((count: number) => count + 1, 0);
398
600
  // When a canvas textbox is being edited, its Tiptap instance takes over
399
601
  // the formatting buttons; otherwise they drive the document editor. The
400
602
  // `level` gates which buttons apply (inline labels vs. rich textboxes).
@@ -409,6 +611,30 @@ export function Toolbar({
409
611
  return true;
410
612
  });
411
613
  const showViewTabs = visibleViews.length > 1;
614
+ const [scannedFileCount, setScannedFileCount] = useState(0);
615
+ const resolvedFileCount = fileCount ?? scannedFileCount;
616
+
617
+ useEffect(() => {
618
+ if (fileCount !== undefined) return;
619
+ if (!mediaProvider) {
620
+ setScannedFileCount(0);
621
+ return;
622
+ }
623
+
624
+ let cancelled = false;
625
+ mediaProvider.listMedia().then(
626
+ (entries) => {
627
+ if (!cancelled) setScannedFileCount(filterVisibleMediaEntries(entries).length);
628
+ },
629
+ () => {
630
+ if (!cancelled) setScannedFileCount(0);
631
+ },
632
+ );
633
+
634
+ return () => {
635
+ cancelled = true;
636
+ };
637
+ }, [fileCount, mediaProvider, mediaRevision, showFiles]);
412
638
 
413
639
  // Hidden file input for image picker
414
640
  const imageInputRef = useRef<HTMLInputElement>(null);
@@ -490,6 +716,11 @@ export function Toolbar({
490
716
  // ── Overflow detection ────────────────────────────────
491
717
  const actionsRef = useRef<HTMLDivElement>(null);
492
718
  const [measuredOverflowIndex, setMeasuredOverflowIndex] = useState<number | null>(null);
719
+ // '|'-joined data-contextual ids of the contextual groups (template /
720
+ // transition pickers, table controls) whose right edge doesn't fit in the
721
+ // actions row. Stored as a string so identical measurements bail out of
722
+ // re-rendering.
723
+ const [clippedContextualKey, setClippedContextualKey] = useState('');
493
724
  const [showOverflow, setShowOverflow] = useState(false);
494
725
  const overflowRef = useRef<HTMLDivElement>(null);
495
726
 
@@ -500,6 +731,7 @@ export function Toolbar({
500
731
  const [showLayoutManager, setShowLayoutManager] = useState(false);
501
732
 
502
733
  const overflowIndex = measuredOverflowIndex;
734
+ const clippedContextual = new Set(clippedContextualKey ? clippedContextualKey.split('|') : []);
503
735
 
504
736
  useEffect(() => {
505
737
  const container = actionsRef.current;
@@ -507,39 +739,79 @@ export function Toolbar({
507
739
 
508
740
  const measure = () => {
509
741
  const containerRight = container.getBoundingClientRect().right;
742
+ // Contextual groups aren't BUTTONS entries, so they must not shift the
743
+ // btnIndex mapping below — measure them separately.
510
744
  const children = container.querySelectorAll<HTMLElement>(
511
- ':scope > .squisq-toolbar-group > .squisq-toolbar-button',
745
+ ':scope > .squisq-toolbar-group:not(.squisq-toolbar-contextual) > .squisq-toolbar-button',
512
746
  );
513
747
  let firstHidden: number | null = null;
514
- let btnIndex = 0;
515
748
  children.forEach((child) => {
516
749
  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);
520
- const rect = child.getBoundingClientRect();
521
- // A button is hidden if its right edge extends past the container
522
- if (rect.right > containerRight + 2) {
523
- firstHidden = btnIndex;
750
+ // A button is hidden if its right edge extends past the container.
751
+ // Its data-btn-index carries its BUTTONS position (see
752
+ // BUTTON_INDEX_BY_ID for why the DOM can't be walked positionally).
753
+ if (child.getBoundingClientRect().right > containerRight + 2) {
754
+ const index = Number(child.dataset.btnIndex);
755
+ firstHidden = Number.isNaN(index) ? null : index;
524
756
  }
525
- btnIndex += btnCount;
526
757
  });
527
758
  setMeasuredOverflowIndex(firstHidden);
759
+ // Contextual groups (template/transition pickers, table controls) that
760
+ // don't fit move into the overflow menu instead of painting cropped.
761
+ const clipped: string[] = [];
762
+ container
763
+ .querySelectorAll<HTMLElement>(':scope > .squisq-toolbar-contextual')
764
+ .forEach((group) => {
765
+ if (group.getBoundingClientRect().right > containerRight + 2) {
766
+ clipped.push(group.dataset.contextual ?? '');
767
+ }
768
+ });
769
+ setClippedContextualKey(clipped.join('|'));
528
770
  };
529
771
 
530
- const ro = new ResizeObserver(measure);
531
- ro.observe(container);
772
+ // Observe the row and every group in it: contextual groups mount/unmount
773
+ // with the cursor context and change width with their value, all without
774
+ // resizing the flex:1 container itself — a container-only observer would
775
+ // go stale.
776
+ const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
777
+ const observeAll = () => {
778
+ ro?.disconnect();
779
+ ro?.observe(container);
780
+ container
781
+ .querySelectorAll<HTMLElement>(':scope > .squisq-toolbar-group')
782
+ .forEach((group) => ro?.observe(group));
783
+ };
784
+ const mo = new MutationObserver(() => {
785
+ observeAll();
786
+ measure();
787
+ });
788
+ mo.observe(container, { childList: true });
789
+ if (!ro) window.addEventListener('resize', measure);
790
+ observeAll();
532
791
  measure();
533
- return () => ro.disconnect();
792
+ return () => {
793
+ ro?.disconnect();
794
+ if (!ro) window.removeEventListener('resize', measure);
795
+ mo.disconnect();
796
+ };
534
797
  }, [activeView]);
535
798
 
536
- // Close overflow menu on outside click
799
+ // Close overflow menu on outside click. Clicks inside the template
800
+ // gallery / transition flyout portals count as inside: those popovers are
801
+ // hosted by pickers living in this menu, and closing the menu on their
802
+ // mousedown would unmount them before the click completes.
537
803
  useEffect(() => {
538
804
  if (!showOverflow) return;
539
805
  const handleClick = (e: MouseEvent) => {
540
- if (overflowRef.current && !overflowRef.current.contains(e.target as Node)) {
541
- setShowOverflow(false);
806
+ const target = e.target as Node;
807
+ if (overflowRef.current?.contains(target)) return;
808
+ if (
809
+ target instanceof Element &&
810
+ target.closest(`${TEMPLATE_GALLERY_PORTAL_SELECTOR}, #${TRANSITION_FLYOUT_PORTAL_ID}`)
811
+ ) {
812
+ return;
542
813
  }
814
+ setShowOverflow(false);
543
815
  };
544
816
  document.addEventListener('mousedown', handleClick);
545
817
  return () => document.removeEventListener('mousedown', handleClick);
@@ -673,10 +945,23 @@ export function Toolbar({
673
945
  case 'table':
674
946
  tiptapEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
675
947
  break;
948
+ case 'tasklist':
949
+ insertTaskList(tiptapEditor);
950
+ break;
676
951
  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' });
952
+ // Diagrams are ASCII-art code fences; the AsciiDiagramExtension
953
+ // mounts the interactive canvas over the inserted starter.
954
+ insertAsciiDiagramBlock(tiptapEditor);
955
+ break;
956
+ case 'tree':
957
+ // File trees are ASCII tree fences; TreeViewExtension mounts the
958
+ // interactive outline over the inserted starter.
959
+ insertTreeBlock(tiptapEditor);
960
+ break;
961
+ case 'timeline':
962
+ // The authored fence is immediately replaced by the WYSIWYG rail
963
+ // canvas; Preview uses the same events as vector layers.
964
+ insertTimelineBlock(tiptapEditor);
680
965
  break;
681
966
  case 'drawing':
682
967
  // Empty drawing is usable via the canvas's "Shapes ▾" palette;
@@ -827,10 +1112,25 @@ export function Toolbar({
827
1112
  newCursorOffset = 3; // after \n|
828
1113
  break;
829
1114
  }
1115
+ case 'tasklist': {
1116
+ replacement = `\n${TASK_LIST_MARKDOWN}\n`;
1117
+ newCursorOffset = replacement.length;
1118
+ break;
1119
+ }
830
1120
  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';
1121
+ // A diagram is an ASCII-art code fence; the WYSIWYG view
1122
+ // renders its editable canvas, preview renders it as a slide.
1123
+ replacement = DIAGRAM_STARTER_MARKDOWN;
1124
+ newCursorOffset = replacement.length;
1125
+ break;
1126
+ }
1127
+ case 'tree': {
1128
+ replacement = TREE_STARTER_MARKDOWN;
1129
+ newCursorOffset = replacement.length;
1130
+ break;
1131
+ }
1132
+ case 'timeline': {
1133
+ replacement = TIMELINE_STARTER_MARKDOWN;
834
1134
  newCursorOffset = replacement.length;
835
1135
  break;
836
1136
  }
@@ -908,8 +1208,17 @@ export function Toolbar({
908
1208
  insertion =
909
1209
  '\n| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |\n';
910
1210
  break;
1211
+ case 'tasklist':
1212
+ insertion = `\n${TASK_LIST_MARKDOWN}\n`;
1213
+ break;
911
1214
  case 'diagram':
912
- insertion = '\n## Diagram {[diagram]}\n';
1215
+ insertion = DIAGRAM_STARTER_MARKDOWN;
1216
+ break;
1217
+ case 'tree':
1218
+ insertion = TREE_STARTER_MARKDOWN;
1219
+ break;
1220
+ case 'timeline':
1221
+ insertion = TIMELINE_STARTER_MARKDOWN;
913
1222
  break;
914
1223
  case 'drawing':
915
1224
  insertion = '\n## Drawing {[drawing]}\n';
@@ -932,6 +1241,7 @@ export function Toolbar({
932
1241
  if (!mediaProvider) return;
933
1242
  const buffer = await file.arrayBuffer();
934
1243
  const relativePath = await mediaProvider.addMedia(file.name, buffer, file.type);
1244
+ bumpMediaRevision();
935
1245
  const altText = file.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
936
1246
 
937
1247
  if (activeView === 'wysiwyg' && tiptapEditor) {
@@ -948,7 +1258,15 @@ export function Toolbar({
948
1258
  setMarkdownSource(markdownSource + `\n![${altText}](${relativePath})\n`);
949
1259
  }
950
1260
  },
951
- [mediaProvider, activeView, tiptapEditor, monacoEditor, markdownSource, setMarkdownSource],
1261
+ [
1262
+ mediaProvider,
1263
+ activeView,
1264
+ tiptapEditor,
1265
+ monacoEditor,
1266
+ markdownSource,
1267
+ setMarkdownSource,
1268
+ bumpMediaRevision,
1269
+ ],
952
1270
  );
953
1271
 
954
1272
  const handleAction = useCallback(
@@ -1230,11 +1548,11 @@ export function Toolbar({
1230
1548
  }
1231
1549
  setRawHeadingLine(pos.lineNumber);
1232
1550
  setRawTransition(readHeadingLineTransition(line));
1233
- const annotMatch = headingMatch[1].match(/\s*\{\[([^\]]+)\]\}[\s\]}]*$/);
1551
+ const annotMatch = matchTrailingTemplateAnnotation(headingMatch[1]);
1234
1552
  if (annotMatch) {
1235
- // First whitespace-delimited token is the template name; the rest are params.
1236
- const name = annotMatch[1].trim().split(/\s+/)[0];
1237
- setRawTemplate(name);
1553
+ const tokens = tokenizeAttrTokens(annotMatch.inner.trim());
1554
+ const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
1555
+ setRawTemplate(firstIsParam ? '' : (tokens[0] ?? ''));
1238
1556
  } else {
1239
1557
  setRawTemplate('');
1240
1558
  }
@@ -1302,6 +1620,57 @@ export function Toolbar({
1302
1620
  return recommendTemplatesForBlock(profile, TEMPLATE_NAMES).recommended;
1303
1621
  }, [currentTemplate, isRawView, isWysiwyg, rawHeadingLine, wysiwygHeadingIndex, markdownSource]);
1304
1622
 
1623
+ const templatePreviewBlock = useMemo(() => {
1624
+ if (!doc || currentTemplate === null) return null;
1625
+ const flat = flattenBlocks(doc.blocks);
1626
+ if (flat.length === 0) return null;
1627
+
1628
+ if (isRawView && rawHeadingLine !== null) {
1629
+ return findBlockBySourceLine(flat, rawHeadingLine);
1630
+ }
1631
+
1632
+ if (isWysiwyg) {
1633
+ if (layoutMode !== 'document' && activeBlockStartLine !== null) {
1634
+ return findBlockBySourceLine(flat, activeBlockStartLine);
1635
+ }
1636
+ if (wysiwygHeadingIndex !== null) {
1637
+ return flat[wysiwygHeadingIndex] ?? null;
1638
+ }
1639
+ }
1640
+
1641
+ return null;
1642
+ }, [
1643
+ activeBlockStartLine,
1644
+ currentTemplate,
1645
+ doc,
1646
+ isRawView,
1647
+ isWysiwyg,
1648
+ layoutMode,
1649
+ rawHeadingLine,
1650
+ wysiwygHeadingIndex,
1651
+ ]);
1652
+
1653
+ const templatePreviewSource = useMemo(
1654
+ () =>
1655
+ templatePreviewBlock
1656
+ ? {
1657
+ block: templatePreviewBlock,
1658
+ theme: previewSettings?.activeTheme ?? DEFAULT_THEME,
1659
+ viewport: previewSettings?.activeViewport ?? VIEWPORT_PRESETS.landscape,
1660
+ basePath: '/',
1661
+ mediaProvider,
1662
+ customTemplates: doc?.customTemplates,
1663
+ }
1664
+ : undefined,
1665
+ [
1666
+ mediaProvider,
1667
+ doc?.customTemplates,
1668
+ previewSettings?.activeTheme,
1669
+ previewSettings?.activeViewport,
1670
+ templatePreviewBlock,
1671
+ ],
1672
+ );
1673
+
1305
1674
  const handleTemplatePick = (value: string) => {
1306
1675
  // Raw (Monaco) — rewrite the heading line's annotation suffix in place.
1307
1676
  if (isRawView && monacoEditor) {
@@ -1313,9 +1682,9 @@ export function Toolbar({
1313
1682
  const headingMatch = lineText.match(/^(#{1,6}\s+)(.+)$/);
1314
1683
  if (!headingMatch) return;
1315
1684
  const prefix = headingMatch[1];
1316
- // Strip any existing trailing annotation
1317
- const bareText = headingMatch[2].replace(/\s*\{\[[^\]]+\]\}[\s\]}]*$/, '').trimEnd();
1318
- const newLine = value === '' ? `${prefix}${bareText}` : `${prefix}${bareText} {[${value}]}`;
1685
+ const { text: bareText, params } = splitTemplateAnnotationParams(headingMatch[2]);
1686
+ const annotation = buildTemplateAnnotation(value, params);
1687
+ const newLine = annotation ? `${prefix}${bareText} ${annotation}` : `${prefix}${bareText}`;
1319
1688
  monacoEditor.executeEdits('toolbar-template-pick', [
1320
1689
  {
1321
1690
  range: {
@@ -1332,14 +1701,22 @@ export function Toolbar({
1332
1701
  }
1333
1702
  // WYSIWYG — update the heading node attributes.
1334
1703
  if (!tiptapEditor) return;
1704
+ const attrs = tiptapEditor.getAttributes('heading') as {
1705
+ dataTemplateParams?: string | null;
1706
+ };
1707
+ const dataTemplateParams = keepBlockMetaParamString(attrs.dataTemplateParams);
1335
1708
  if (value === '') {
1336
1709
  tiptapEditor
1337
1710
  .chain()
1338
1711
  .focus()
1339
- .updateAttributes('heading', { dataTemplate: null, dataTemplateParams: null })
1712
+ .updateAttributes('heading', { dataTemplate: null, dataTemplateParams })
1340
1713
  .run();
1341
1714
  } else {
1342
- tiptapEditor.chain().focus().updateAttributes('heading', { dataTemplate: value }).run();
1715
+ tiptapEditor
1716
+ .chain()
1717
+ .focus()
1718
+ .updateAttributes('heading', { dataTemplate: value, dataTemplateParams })
1719
+ .run();
1343
1720
  }
1344
1721
  };
1345
1722
 
@@ -1382,13 +1759,36 @@ export function Toolbar({
1382
1759
  monacoEditor.focus();
1383
1760
  return;
1384
1761
  }
1385
- // WYSIWYG — rewrite the heading node's `dataBlockAttrs` (Pandoc inner).
1762
+ // WYSIWYG — write transitions into `{[…]}` params and migrate legacy
1763
+ // Pandoc transition keys out of `dataBlockAttrs`.
1386
1764
  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();
1765
+ const attrs = tiptapEditor.getAttributes('heading') as {
1766
+ dataBlockAttrs?: string | null;
1767
+ dataTemplateParams?: string | null;
1768
+ };
1769
+ const updated = setHeadingAttrsTransition(attrs.dataBlockAttrs, attrs.dataTemplateParams, next);
1770
+ tiptapEditor
1771
+ .chain()
1772
+ .focus()
1773
+ .updateAttributes('heading', {
1774
+ dataBlockAttrs: updated.blockAttrsInner,
1775
+ dataTemplateParams: updated.templateParams,
1776
+ })
1777
+ .run();
1390
1778
  };
1391
1779
 
1780
+ // ── Per-block controls in the overflow menu ────────────
1781
+ // The template + transition pickers configure the active heading block, so
1782
+ // when either is pushed into the ··· menu we group them under a labeled
1783
+ // "<name> Block:" heading. `currentTemplate`/`currentTransition` are non-null
1784
+ // together (both gated on a heading being active); an empty template string
1785
+ // is a heading with no visual template, labeled "Heading".
1786
+ const showTemplateInOverflow = currentTemplate !== null && clippedContextual.has('template');
1787
+ const showTransitionInOverflow =
1788
+ currentTransition !== null && clippedContextual.has('transition');
1789
+ const showBlockSectionInOverflow = showTemplateInOverflow || showTransitionInOverflow;
1790
+ const overflowBlockLabel = currentTemplate ? templateLabel(currentTemplate) : 'Heading';
1791
+
1392
1792
  return (
1393
1793
  <div
1394
1794
  className={`squisq-toolbar ${className || ''}`}
@@ -1413,35 +1813,65 @@ export function Toolbar({
1413
1813
  {/* View tabs — hidden when only one view is available (e.g. code mode). */}
1414
1814
  {showViewTabs && (
1415
1815
  <div className="squisq-toolbar-view-tabs" role="tablist" aria-label="Editor view">
1416
- {visibleViews.map((view) => (
1417
- <button
1418
- key={view.id}
1419
- role="tab"
1420
- data-view={view.id}
1421
- aria-selected={activeView === view.id}
1422
- className={`squisq-toolbar-view-tab${activeView === view.id ? ' squisq-toolbar-view-tab--active' : ''}`}
1423
- onClick={() => setActiveView(view.id)}
1424
- data-tooltip={`${view.label} (${view.shortcut})`}
1425
- >
1426
- <span
1427
- className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--long"
1428
- data-label={view.label}
1816
+ {visibleViews.map((view) => {
1817
+ const viewLabel =
1818
+ view.id === 'preview' && previewSettings
1819
+ ? displayModeLabel(previewSettings.activeDisplayMode)
1820
+ : view.label;
1821
+ const tab = (
1822
+ <button
1823
+ role="tab"
1824
+ data-view={view.id}
1825
+ aria-selected={activeView === view.id}
1826
+ className={`squisq-toolbar-view-tab${activeView === view.id ? ' squisq-toolbar-view-tab--active' : ''}`}
1827
+ onClick={() => {
1828
+ if (view.id === 'preview' && activeView === 'preview' && previewSettings) {
1829
+ requestUseModeMenu();
1830
+ return;
1831
+ }
1832
+ setActiveView(view.id);
1833
+ }}
1834
+ data-tooltip={`${viewLabel} (${view.shortcut})`}
1429
1835
  >
1430
- {view.label}
1431
- </span>
1432
- {view.shortLabel && view.shortLabel !== view.label && (
1433
1836
  <span
1434
- className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--short"
1435
- data-label={view.shortLabel}
1837
+ className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--long"
1838
+ data-label={viewLabel}
1436
1839
  >
1437
- {view.shortLabel}
1840
+ {viewLabel}
1438
1841
  </span>
1439
- )}
1440
- </button>
1441
- ))}
1842
+ {view.shortLabel && view.shortLabel !== view.label && (
1843
+ <span
1844
+ className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--short"
1845
+ data-label={view.shortLabel}
1846
+ >
1847
+ {view.shortLabel}
1848
+ </span>
1849
+ )}
1850
+ </button>
1851
+ );
1852
+
1853
+ if (view.id !== 'preview' || !previewSettings) {
1854
+ return (
1855
+ <div key={view.id} className="squisq-toolbar-view-tab-wrap" role="presentation">
1856
+ {tab}
1857
+ </div>
1858
+ );
1859
+ }
1860
+
1861
+ return (
1862
+ <div
1863
+ key={view.id}
1864
+ className={`squisq-toolbar-view-tab-wrap squisq-toolbar-use-tab${activeView === 'preview' ? ' squisq-toolbar-use-tab--active' : ''}`}
1865
+ role="presentation"
1866
+ >
1867
+ {tab}
1868
+ <PreviewModeMenu openRequest={useModeMenuRequest} />
1869
+ </div>
1870
+ );
1871
+ })}
1442
1872
  </div>
1443
1873
  )}
1444
- {/* After-tabs slot — left side, before formatting controls (preview mode switch) */}
1874
+ {/* After-tabs slot — left side, before formatting or preview controls. */}
1445
1875
  {slotAfterTabs}
1446
1876
  {/* Formatting buttons — hidden in preview mode and code mode */}
1447
1877
  {!isPreview && !isCodeMode && (
@@ -1462,6 +1892,7 @@ export function Toolbar({
1462
1892
  key={btn.id}
1463
1893
  ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
1464
1894
  className={`squisq-toolbar-button${active ? ' squisq-toolbar-button--active' : ''}`}
1895
+ data-btn-index={BUTTON_INDEX_BY_ID.get(btn.id)}
1465
1896
  data-tooltip={disabled ? 'Insert image (requires media provider)' : btn.title}
1466
1897
  onClick={() => handleAction(btn.id)}
1467
1898
  aria-label={btn.title}
@@ -1481,7 +1912,7 @@ export function Toolbar({
1481
1912
  <button
1482
1913
  ref={insertMenuButtonRef}
1483
1914
  className={`squisq-toolbar-button${insertMenuAnchor ? ' squisq-toolbar-button--active' : ''}`}
1484
- data-btn-count={String(MEDIA_BUTTONS.length)}
1915
+ data-btn-index={FIRST_MEDIA_INDEX}
1485
1916
  data-tooltip="Insert..."
1486
1917
  onClick={() => (insertMenuAnchor ? closeInsertMenu() : openInsertMenu())}
1487
1918
  aria-label="Insert"
@@ -1494,192 +1925,207 @@ export function Toolbar({
1494
1925
 
1495
1926
  {/* Template picker — visible when the cursor is in a heading.
1496
1927
  In WYSIWYG, reads from the heading node's `dataTemplate`; in
1497
- Markdown view, parses the `{[...]}` suffix on the cursor's line. */}
1928
+ Markdown view, parses the `{[...]}` suffix on the cursor's line.
1929
+ The `squisq-toolbar-contextual` class + data-contextual id opt
1930
+ this group (and the two below) into the clipping measurement:
1931
+ when it doesn't fit it hides here and moves into the ··· menu. */}
1498
1932
  {currentTemplate !== null && (
1499
- <>
1933
+ <div
1934
+ className={`squisq-toolbar-group squisq-toolbar-contextual squisq-template-picker${clippedContextual.has('template') ? ' squisq-toolbar-contextual--clipped' : ''}`}
1935
+ data-contextual="template"
1936
+ >
1500
1937
  <div className="squisq-toolbar-separator" />
1501
- <div className="squisq-toolbar-group squisq-template-picker">
1502
- <TemplatePicker
1503
- value={currentTemplate}
1504
- onChange={handleTemplatePick}
1505
- recommended={recommendedTemplates}
1506
- />
1507
- </div>
1508
- </>
1938
+ <TemplatePicker
1939
+ value={currentTemplate}
1940
+ onChange={handleTemplatePick}
1941
+ colorScheme={colorScheme}
1942
+ recommended={recommendedTemplates}
1943
+ previewSource={templatePreviewSource}
1944
+ />
1945
+ </div>
1509
1946
  )}
1510
1947
 
1511
1948
  {/* Transition picker — visible alongside the template picker when a
1512
1949
  block (heading) is active. Writes `transition=` into the block's
1513
1950
  Pandoc `{…}` attribute block (the canonical home for transitions). */}
1514
1951
  {currentTransition !== null && (
1515
- <>
1952
+ <div
1953
+ className={`squisq-toolbar-group squisq-toolbar-contextual squisq-transition-picker-group${clippedContextual.has('transition') ? ' squisq-toolbar-contextual--clipped' : ''}`}
1954
+ data-contextual="transition"
1955
+ >
1516
1956
  <div className="squisq-toolbar-separator" />
1517
- <div className="squisq-toolbar-group squisq-transition-picker-group">
1518
- <TransitionPicker value={currentTransition} onChange={handleTransitionPick} />
1519
- </div>
1520
- </>
1957
+ <TransitionPicker
1958
+ value={currentTransition}
1959
+ onChange={handleTransitionPick}
1960
+ colorScheme={colorScheme}
1961
+ accentColor={previewSettings?.activeTheme.colors.primary}
1962
+ />
1963
+ </div>
1521
1964
  )}
1522
1965
 
1523
1966
  {/* Table controls — visible when cursor is in a table (WYSIWYG) */}
1524
1967
  {isInTable && (
1525
- <>
1968
+ <div
1969
+ className={`squisq-toolbar-group squisq-toolbar-contextual squisq-table-controls${clippedContextual.has('table') ? ' squisq-toolbar-contextual--clipped' : ''}`}
1970
+ data-contextual="table"
1971
+ >
1526
1972
  <div className="squisq-toolbar-separator" />
1527
- <div className="squisq-toolbar-group squisq-table-controls">
1528
- <span className="squisq-table-controls-label">Table:</span>
1529
- <button
1530
- className="squisq-toolbar-button"
1531
- data-tooltip="Add column before"
1532
- onClick={() => tiptapEditor!.chain().focus().addColumnBefore().run()}
1533
- aria-label="Add column before"
1973
+ <span className="squisq-table-controls-label">Table:</span>
1974
+ <button
1975
+ className="squisq-toolbar-button"
1976
+ data-tooltip="Add column before"
1977
+ onClick={() => tiptapEditor!.chain().focus().addColumnBefore().run()}
1978
+ aria-label="Add column before"
1979
+ >
1980
+ <svg
1981
+ width="16"
1982
+ height="16"
1983
+ viewBox="0 0 16 16"
1984
+ fill="none"
1985
+ stroke="currentColor"
1986
+ strokeWidth="1.5"
1987
+ strokeLinecap="round"
1534
1988
  >
1535
- <svg
1536
- width="16"
1537
- height="16"
1538
- viewBox="0 0 16 16"
1539
- fill="none"
1540
- stroke="currentColor"
1541
- strokeWidth="1.5"
1542
- strokeLinecap="round"
1543
- >
1544
- <rect x="7" y="2" width="8" height="12" rx="1" />
1545
- <line x1="11" y1="2" x2="11" y2="14" />
1546
- <line x1="1" y1="8" x2="4.5" y2="8" />
1547
- <line x1="2.75" y1="6.25" x2="2.75" y2="9.75" />
1548
- </svg>
1549
- </button>
1550
- <button
1551
- className="squisq-toolbar-button"
1552
- data-tooltip="Add column after"
1553
- onClick={() => tiptapEditor!.chain().focus().addColumnAfter().run()}
1554
- aria-label="Add column after"
1989
+ <rect x="7" y="2" width="8" height="12" rx="1" />
1990
+ <line x1="11" y1="2" x2="11" y2="14" />
1991
+ <line x1="1" y1="8" x2="4.5" y2="8" />
1992
+ <line x1="2.75" y1="6.25" x2="2.75" y2="9.75" />
1993
+ </svg>
1994
+ </button>
1995
+ <button
1996
+ className="squisq-toolbar-button"
1997
+ data-tooltip="Add column after"
1998
+ onClick={() => tiptapEditor!.chain().focus().addColumnAfter().run()}
1999
+ aria-label="Add column after"
2000
+ >
2001
+ <svg
2002
+ width="16"
2003
+ height="16"
2004
+ viewBox="0 0 16 16"
2005
+ fill="none"
2006
+ stroke="currentColor"
2007
+ strokeWidth="1.5"
2008
+ strokeLinecap="round"
1555
2009
  >
1556
- <svg
1557
- width="16"
1558
- height="16"
1559
- viewBox="0 0 16 16"
1560
- fill="none"
1561
- stroke="currentColor"
1562
- strokeWidth="1.5"
1563
- strokeLinecap="round"
1564
- >
1565
- <rect x="1" y="2" width="8" height="12" rx="1" />
1566
- <line x1="5" y1="2" x2="5" y2="14" />
1567
- <line x1="11.5" y1="8" x2="15" y2="8" />
1568
- <line x1="13.25" y1="6.25" x2="13.25" y2="9.75" />
1569
- </svg>
1570
- </button>
1571
- <button
1572
- className="squisq-toolbar-button"
1573
- data-tooltip="Delete column"
1574
- onClick={() => tiptapEditor!.chain().focus().deleteColumn().run()}
1575
- aria-label="Delete column"
2010
+ <rect x="1" y="2" width="8" height="12" rx="1" />
2011
+ <line x1="5" y1="2" x2="5" y2="14" />
2012
+ <line x1="11.5" y1="8" x2="15" y2="8" />
2013
+ <line x1="13.25" y1="6.25" x2="13.25" y2="9.75" />
2014
+ </svg>
2015
+ </button>
2016
+ <button
2017
+ className="squisq-toolbar-button"
2018
+ data-tooltip="Delete column"
2019
+ onClick={() => tiptapEditor!.chain().focus().deleteColumn().run()}
2020
+ aria-label="Delete column"
2021
+ >
2022
+ <svg
2023
+ width="16"
2024
+ height="16"
2025
+ viewBox="0 0 16 16"
2026
+ fill="none"
2027
+ stroke="currentColor"
2028
+ strokeWidth="1.5"
2029
+ strokeLinecap="round"
1576
2030
  >
1577
- <svg
1578
- width="16"
1579
- height="16"
1580
- viewBox="0 0 16 16"
1581
- fill="none"
1582
- stroke="currentColor"
1583
- strokeWidth="1.5"
1584
- strokeLinecap="round"
1585
- >
1586
- <rect x="4" y="1" width="8" height="14" rx="1" />
1587
- <line x1="6" y1="5.5" x2="10" y2="10.5" />
1588
- <line x1="10" y1="5.5" x2="6" y2="10.5" />
1589
- </svg>
1590
- </button>
1591
- <button
1592
- className="squisq-toolbar-button"
1593
- data-tooltip="Add row above"
1594
- onClick={() => tiptapEditor!.chain().focus().addRowBefore().run()}
1595
- aria-label="Add row above"
2031
+ <rect x="4" y="1" width="8" height="14" rx="1" />
2032
+ <line x1="6" y1="5.5" x2="10" y2="10.5" />
2033
+ <line x1="10" y1="5.5" x2="6" y2="10.5" />
2034
+ </svg>
2035
+ </button>
2036
+ <button
2037
+ className="squisq-toolbar-button"
2038
+ data-tooltip="Add row above"
2039
+ onClick={() => tiptapEditor!.chain().focus().addRowBefore().run()}
2040
+ aria-label="Add row above"
2041
+ >
2042
+ <svg
2043
+ width="16"
2044
+ height="16"
2045
+ viewBox="0 0 16 16"
2046
+ fill="none"
2047
+ stroke="currentColor"
2048
+ strokeWidth="1.5"
2049
+ strokeLinecap="round"
1596
2050
  >
1597
- <svg
1598
- width="16"
1599
- height="16"
1600
- viewBox="0 0 16 16"
1601
- fill="none"
1602
- stroke="currentColor"
1603
- strokeWidth="1.5"
1604
- strokeLinecap="round"
1605
- >
1606
- <rect x="2" y="6" width="12" height="9" rx="1" />
1607
- <line x1="2" y1="10.5" x2="14" y2="10.5" />
1608
- <line x1="8" y1="1" x2="8" y2="4.5" />
1609
- <line x1="6.25" y1="2.75" x2="9.75" y2="2.75" />
1610
- </svg>
1611
- </button>
1612
- <button
1613
- className="squisq-toolbar-button"
1614
- data-tooltip="Add row below"
1615
- onClick={() => tiptapEditor!.chain().focus().addRowAfter().run()}
1616
- aria-label="Add row below"
2051
+ <rect x="2" y="6" width="12" height="9" rx="1" />
2052
+ <line x1="2" y1="10.5" x2="14" y2="10.5" />
2053
+ <line x1="8" y1="1" x2="8" y2="4.5" />
2054
+ <line x1="6.25" y1="2.75" x2="9.75" y2="2.75" />
2055
+ </svg>
2056
+ </button>
2057
+ <button
2058
+ className="squisq-toolbar-button"
2059
+ data-tooltip="Add row below"
2060
+ onClick={() => tiptapEditor!.chain().focus().addRowAfter().run()}
2061
+ aria-label="Add row below"
2062
+ >
2063
+ <svg
2064
+ width="16"
2065
+ height="16"
2066
+ viewBox="0 0 16 16"
2067
+ fill="none"
2068
+ stroke="currentColor"
2069
+ strokeWidth="1.5"
2070
+ strokeLinecap="round"
1617
2071
  >
1618
- <svg
1619
- width="16"
1620
- height="16"
1621
- viewBox="0 0 16 16"
1622
- fill="none"
1623
- stroke="currentColor"
1624
- strokeWidth="1.5"
1625
- strokeLinecap="round"
1626
- >
1627
- <rect x="2" y="1" width="12" height="9" rx="1" />
1628
- <line x1="2" y1="5.5" x2="14" y2="5.5" />
1629
- <line x1="8" y1="11.5" x2="8" y2="15" />
1630
- <line x1="6.25" y1="13.25" x2="9.75" y2="13.25" />
1631
- </svg>
1632
- </button>
1633
- <button
1634
- className="squisq-toolbar-button"
1635
- data-tooltip="Delete row"
1636
- onClick={() => tiptapEditor!.chain().focus().deleteRow().run()}
1637
- aria-label="Delete row"
2072
+ <rect x="2" y="1" width="12" height="9" rx="1" />
2073
+ <line x1="2" y1="5.5" x2="14" y2="5.5" />
2074
+ <line x1="8" y1="11.5" x2="8" y2="15" />
2075
+ <line x1="6.25" y1="13.25" x2="9.75" y2="13.25" />
2076
+ </svg>
2077
+ </button>
2078
+ <button
2079
+ className="squisq-toolbar-button"
2080
+ data-tooltip="Delete row"
2081
+ onClick={() => tiptapEditor!.chain().focus().deleteRow().run()}
2082
+ aria-label="Delete row"
2083
+ >
2084
+ <svg
2085
+ width="16"
2086
+ height="16"
2087
+ viewBox="0 0 16 16"
2088
+ fill="none"
2089
+ stroke="currentColor"
2090
+ strokeWidth="1.5"
2091
+ strokeLinecap="round"
1638
2092
  >
1639
- <svg
1640
- width="16"
1641
- height="16"
1642
- viewBox="0 0 16 16"
1643
- fill="none"
1644
- stroke="currentColor"
1645
- strokeWidth="1.5"
1646
- strokeLinecap="round"
1647
- >
1648
- <rect x="1" y="4" width="14" height="8" rx="1" />
1649
- <line x1="5.5" y1="6" x2="10.5" y2="10" />
1650
- <line x1="10.5" y1="6" x2="5.5" y2="10" />
1651
- </svg>
1652
- </button>
1653
- <button
1654
- className="squisq-toolbar-button squisq-toolbar-button--danger"
1655
- data-tooltip="Delete table"
1656
- onClick={() => tiptapEditor!.chain().focus().deleteTable().run()}
1657
- aria-label="Delete table"
2093
+ <rect x="1" y="4" width="14" height="8" rx="1" />
2094
+ <line x1="5.5" y1="6" x2="10.5" y2="10" />
2095
+ <line x1="10.5" y1="6" x2="5.5" y2="10" />
2096
+ </svg>
2097
+ </button>
2098
+ <button
2099
+ className="squisq-toolbar-button squisq-toolbar-button--danger"
2100
+ data-tooltip="Delete table"
2101
+ onClick={() => tiptapEditor!.chain().focus().deleteTable().run()}
2102
+ aria-label="Delete table"
2103
+ >
2104
+ <svg
2105
+ width="16"
2106
+ height="16"
2107
+ viewBox="0 0 16 16"
2108
+ fill="none"
2109
+ stroke="currentColor"
2110
+ strokeWidth="1.5"
2111
+ strokeLinecap="round"
1658
2112
  >
1659
- <svg
1660
- width="16"
1661
- height="16"
1662
- viewBox="0 0 16 16"
1663
- fill="none"
1664
- stroke="currentColor"
1665
- strokeWidth="1.5"
1666
- strokeLinecap="round"
1667
- >
1668
- <rect x="1" y="1" width="14" height="14" rx="1" />
1669
- <line x1="1" y1="5.5" x2="15" y2="5.5" />
1670
- <line x1="5.5" y1="1" x2="5.5" y2="15" />
1671
- <line x1="4.5" y1="4.5" x2="11.5" y2="11.5" strokeWidth="2" />
1672
- <line x1="11.5" y1="4.5" x2="4.5" y2="11.5" strokeWidth="2" />
1673
- </svg>
1674
- </button>
1675
- </div>
1676
- </>
2113
+ <rect x="1" y="1" width="14" height="14" rx="1" />
2114
+ <line x1="1" y1="5.5" x2="15" y2="5.5" />
2115
+ <line x1="5.5" y1="1" x2="5.5" y2="15" />
2116
+ <line x1="4.5" y1="4.5" x2="11.5" y2="11.5" strokeWidth="2" />
2117
+ <line x1="11.5" y1="4.5" x2="4.5" y2="11.5" strokeWidth="2" />
2118
+ </svg>
2119
+ </button>
2120
+ </div>
1677
2121
  )}
1678
2122
  </div>
1679
2123
  )}
1680
2124
 
1681
- {/* Overflow menu — outside the overflow:hidden actions container */}
1682
- {!isPreview && !isCodeMode && overflowIndex !== null && (
2125
+ {/* Overflow menu — outside the overflow:hidden actions container.
2126
+ Also appears when a contextual group (template/transition picker,
2127
+ table controls) is clipped, even if every plain button fits. */}
2128
+ {!isPreview && !isCodeMode && (overflowIndex !== null || clippedContextual.size > 0) && (
1683
2129
  <div className="squisq-toolbar-overflow" ref={overflowRef}>
1684
2130
  <button
1685
2131
  className={`squisq-toolbar-button squisq-toolbar-overflow-trigger${showOverflow ? ' squisq-toolbar-button--active' : ''}`}
@@ -1694,7 +2140,7 @@ export function Toolbar({
1694
2140
  <div
1695
2141
  className={`squisq-toolbar-overflow-menu squisq-toolbar-overflow-menu--${overflowPlacement}`}
1696
2142
  >
1697
- {BUTTONS.slice(overflowIndex)
2143
+ {BUTTONS.slice(overflowIndex ?? BUTTONS.length)
1698
2144
  .filter((b) => isButtonVisible(b.id))
1699
2145
  // Media buttons are represented by the synthetic Insert dropdown
1700
2146
  // in both the visible toolbar and the overflow menu.
@@ -1748,23 +2194,53 @@ export function Toolbar({
1748
2194
  </button>
1749
2195
  )}
1750
2196
 
1751
- {/* Contextual: template picker in overflow */}
1752
- {currentTemplate !== null && (
2197
+ {/* Per-block section — the template + transition pickers move
2198
+ here when clipped, under a divider and a "<name> Block:"
2199
+ heading that names the block they configure. */}
2200
+ {showBlockSectionInOverflow && (
2201
+ <>
2202
+ <div
2203
+ className="squisq-toolbar-separator"
2204
+ style={{ margin: '4px 0', width: '100%', height: 1 }}
2205
+ />
2206
+ <div className="squisq-toolbar-overflow-heading">{overflowBlockLabel} Block:</div>
2207
+ </>
2208
+ )}
2209
+
2210
+ {/* Contextual: template picker in overflow — only when the
2211
+ inline one is clipped, so the control lives in exactly one
2212
+ visible place at a time. */}
2213
+ {showTemplateInOverflow && (
1753
2214
  <div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
1754
- <span>Template:</span>
1755
2215
  <TemplatePicker
1756
- value={currentTemplate}
2216
+ value={currentTemplate!}
1757
2217
  onChange={(v) => {
1758
2218
  handleTemplatePick(v);
1759
2219
  setShowOverflow(false);
1760
2220
  }}
2221
+ colorScheme={colorScheme}
1761
2222
  recommended={recommendedTemplates}
2223
+ previewSource={templatePreviewSource}
2224
+ />
2225
+ </div>
2226
+ )}
2227
+
2228
+ {/* Contextual: transition picker in overflow — mirrors the
2229
+ template picker above. The menu stays open on change since
2230
+ the flyout carries follow-up controls (direction/duration). */}
2231
+ {showTransitionInOverflow && (
2232
+ <div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
2233
+ <TransitionPicker
2234
+ value={currentTransition!}
2235
+ onChange={handleTransitionPick}
2236
+ colorScheme={colorScheme}
2237
+ accentColor={previewSettings?.activeTheme.colors.primary}
1762
2238
  />
1763
2239
  </div>
1764
2240
  )}
1765
2241
 
1766
2242
  {/* Contextual: table controls in overflow */}
1767
- {isInTable && (
2243
+ {isInTable && clippedContextual.has('table') && (
1768
2244
  <>
1769
2245
  <div
1770
2246
  className="squisq-toolbar-separator"
@@ -1863,11 +2339,16 @@ export function Toolbar({
1863
2339
  <button
1864
2340
  className={`squisq-toolbar-button squisq-toolbar-files-toggle${showFiles ? ' squisq-toolbar-button--active' : ''}`}
1865
2341
  onClick={onToggleFiles}
1866
- data-tooltip={showFiles ? 'Hide Files panel' : 'Show Files panel'}
2342
+ data-tooltip={`${showFiles ? 'Hide' : 'Show'} Files panel${resolvedFileCount > 0 ? ` (${fileCountLabel(resolvedFileCount)})` : ''}`}
1867
2343
  aria-pressed={showFiles}
1868
- aria-label="Toggle Files panel"
2344
+ aria-label={`Toggle Files panel${resolvedFileCount > 0 ? `, ${fileCountLabel(resolvedFileCount)}` : ''}`}
1869
2345
  >
1870
2346
  <Icon icon="fa-solid fa-paperclip" />
2347
+ {resolvedFileCount > 0 && (
2348
+ <span className="squisq-toolbar-files-badge" aria-hidden="true">
2349
+ {fileCountBadge(resolvedFileCount)}
2350
+ </span>
2351
+ )}
1871
2352
  </button>
1872
2353
  )}
1873
2354
  {/* Right slot — rightmost end of toolbar */}