@bendyline/squisq-editor-react 1.6.1 → 1.6.2

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 (44) hide show
  1. package/dist/index.d.ts +87 -41
  2. package/dist/index.js +8263 -6705
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles/index.css +841 -91
  5. package/package.json +4 -4
  6. package/src/BlockPropertiesPopover.tsx +23 -7
  7. package/src/EditorShell.tsx +65 -20
  8. package/src/MediaBin.tsx +171 -28
  9. package/src/PreviewControls.tsx +177 -44
  10. package/src/PreviewPanel.tsx +2 -0
  11. package/src/TemplateAnnotation.ts +22 -7
  12. package/src/TemplateContentPreview.tsx +56 -0
  13. package/src/TemplatePicker.tsx +295 -128
  14. package/src/ThemeCustomizerPanel.tsx +22 -15
  15. package/src/Toolbar.tsx +527 -207
  16. package/src/TransitionPicker.tsx +8 -1
  17. package/src/ViewSwitcher.tsx +4 -4
  18. package/src/WysiwygEditor.tsx +45 -3
  19. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  20. package/src/__tests__/editorShellProps.test.tsx +268 -1
  21. package/src/__tests__/headingTransition.test.ts +59 -9
  22. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  23. package/src/__tests__/mediaReferences.test.ts +82 -0
  24. package/src/__tests__/previewControls.test.tsx +94 -1
  25. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  26. package/src/__tests__/templateContentPreview.test.ts +101 -0
  27. package/src/__tests__/tiptapBridge.test.ts +47 -0
  28. package/src/diagram/DiagramWidget.tsx +6 -4
  29. package/src/headingTransition.ts +96 -21
  30. package/src/index.ts +2 -1
  31. package/src/mediaReferences.ts +299 -0
  32. package/src/scene/Scene.tsx +53 -7
  33. package/src/scene/SceneBlockWidget.tsx +6 -3
  34. package/src/scene/SceneSelection.tsx +19 -15
  35. package/src/scene/SceneSideToolbar.tsx +89 -0
  36. package/src/scene/layers/DiagramEdges.tsx +4 -3
  37. package/src/scene/layers/edgeGeometry.ts +23 -4
  38. package/src/scene/scene.css +142 -2
  39. package/src/scene/tools/ConnectTool.ts +113 -24
  40. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  41. package/src/scene/tools/SceneTool.ts +2 -0
  42. package/src/styles/editor.css +862 -101
  43. package/src/templateContentPreviewResolver.ts +353 -0
  44. package/src/tiptapBridge.ts +60 -33
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_ID,
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,30 @@ 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 { usePreviewSettingsOptional } from './PreviewControls';
39
54
 
40
55
  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' },
56
+ { id: 'wysiwyg', label: 'Write', shortcut: '⌘1' },
57
+ { id: 'raw', label: 'Source', shortcut: '⌘2' },
58
+ { id: 'preview', label: 'Use', shortcut: '⌘3' },
44
59
  ];
45
60
 
61
+ const BLOCK_META_KEYS = new Set<string>(Object.keys(KNOWN_BLOCK_META_KEYS));
62
+
46
63
  export interface ToolbarProps {
47
64
  /** Additional class name */
48
65
  className?: string;
49
66
  /** Whether the Files panel is currently shown */
50
67
  showFiles?: boolean;
68
+ /** Number of files currently available through the MediaProvider. */
69
+ fileCount?: number;
51
70
  /** Toggle the Files panel. When provided, a "Files" button appears in the toolbar. */
52
71
  onToggleFiles?: () => void;
53
72
  /** Content rendered at the left edge of the toolbar, before the view tabs. */
54
73
  slotLeft?: ReactNode;
55
74
  /** Content rendered immediately after the view tabs, on the left side of the
56
75
  * toolbar (before the formatting controls). Used for the preview mode
57
- * switch in Play view. */
76
+ * switch in Use view. */
58
77
  slotAfterTabs?: ReactNode;
59
78
  /** Content rendered after the formatting controls (in the middle area). */
60
79
  slotAfterActions?: ReactNode;
@@ -185,6 +204,14 @@ const BUTTONS: ToolbarButton[] = [
185
204
  group: 'media',
186
205
  faIcon: 'fa-solid fa-table',
187
206
  },
207
+ {
208
+ id: 'tasklist',
209
+ label: 'tasks',
210
+ icon: '',
211
+ title: 'Insert Task List',
212
+ group: 'media',
213
+ faIcon: 'fa-solid fa-list-check',
214
+ },
188
215
  {
189
216
  id: 'diagram',
190
217
  label: 'diagram',
@@ -230,12 +257,51 @@ const BUTTONS: ToolbarButton[] = [
230
257
  const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
231
258
  const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
232
259
  const INSERT_MENU_WIDTH = 200;
260
+ const TASK_LIST_ITEMS = ['Task 1', 'Task 2', 'Task 3'] as const;
261
+ const TASK_LIST_MARKDOWN = TASK_LIST_ITEMS.map((item) => `- [ ] ${item}`).join('\n');
262
+
263
+ // BUTTONS position per id — stamped on each rendered button as
264
+ // data-btn-index so the overflow measurement can map a clipped DOM button
265
+ // back to its BUTTONS entry. Walking a counter over the DOM instead would
266
+ // drift whenever some entries render no button of their own (hidden H5/H6
267
+ // heading levels, the media group collapsed behind the Insert dropdown).
268
+ const BUTTON_INDEX_BY_ID = new Map(BUTTONS.map((b, i) => [b.id, i]));
233
269
 
234
270
  /** Renders a button's icon: a Font Awesome glyph when set, else the text label. */
235
271
  function buttonIcon(btn: ToolbarButton): ReactNode {
236
272
  return btn.faIcon ? <Icon icon={btn.faIcon} /> : btn.icon;
237
273
  }
238
274
 
275
+ function fileCountLabel(count: number): string {
276
+ return `${count} ${count === 1 ? 'file' : 'files'}`;
277
+ }
278
+
279
+ function fileCountBadge(count: number): string {
280
+ return count > 99 ? '99+' : String(count);
281
+ }
282
+
283
+ function taskListContent(): JSONContent {
284
+ return {
285
+ type: 'taskList',
286
+ content: TASK_LIST_ITEMS.map((item) => ({
287
+ type: 'taskItem',
288
+ attrs: { checked: false },
289
+ content: [
290
+ {
291
+ type: 'paragraph',
292
+ content: [{ type: 'text', text: item }],
293
+ },
294
+ ],
295
+ })),
296
+ };
297
+ }
298
+
299
+ function insertTaskList(editor: TiptapEditor): void {
300
+ const supportsTaskList = !!editor.schema.nodes.taskList && !!editor.schema.nodes.taskItem;
301
+ const content = supportsTaskList ? taskListContent() : TASK_LIST_MARKDOWN;
302
+ editor.chain().focus().insertContent(content).run();
303
+ }
304
+
239
305
  // ─── Tiptap active-state map ────────────────────────────
240
306
 
241
307
  /** Returns true if the given button id is currently active in Tiptap */
@@ -365,6 +431,44 @@ function insertLayoutBlock(editor: TiptapEditor): void {
365
431
  .run();
366
432
  }
367
433
 
434
+ function findBlockBySourceLine(blocks: readonly Block[], lineNumber: number): Block | null {
435
+ for (const block of blocks) {
436
+ const pos = block.sourceHeading?.position;
437
+ if (!pos) continue;
438
+ if (pos.start.line <= lineNumber && pos.end.line >= lineNumber) return block;
439
+ }
440
+ return null;
441
+ }
442
+
443
+ function splitTemplateAnnotationParams(text: string): { text: string; params: string[] } {
444
+ const match = matchTrailingTemplateAnnotation(text);
445
+ if (!match) return { text: text.trimEnd(), params: [] };
446
+ const tokens = tokenizeAttrTokens(match.inner.trim());
447
+ const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
448
+ return {
449
+ text: text.slice(0, match.index).trimEnd(),
450
+ params: keepBlockMetaParamTokens(tokens.slice(firstIsParam ? 0 : 1)),
451
+ };
452
+ }
453
+
454
+ function buildTemplateAnnotation(template: string, params: string[]): string | null {
455
+ const parts = template ? [template, ...params] : params;
456
+ return parts.length > 0 ? `{[${parts.join(' ')}]}` : null;
457
+ }
458
+
459
+ function keepBlockMetaParamTokens(tokens: readonly string[]): string[] {
460
+ return tokens.filter((token) => {
461
+ const kv = splitKeyValueToken(token);
462
+ return kv ? BLOCK_META_KEYS.has(kv.key) : false;
463
+ });
464
+ }
465
+
466
+ function keepBlockMetaParamString(inner: string | null | undefined): string | null {
467
+ if (!inner) return null;
468
+ const params = keepBlockMetaParamTokens(tokenizeAttrTokens(inner));
469
+ return params.length > 0 ? params.join(' ') : null;
470
+ }
471
+
368
472
  /**
369
473
  * Formatting toolbar.
370
474
  * - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
@@ -373,6 +477,7 @@ function insertLayoutBlock(editor: TiptapEditor): void {
373
477
  export function Toolbar({
374
478
  className,
375
479
  showFiles,
480
+ fileCount,
376
481
  onToggleFiles,
377
482
  slotLeft,
378
483
  slotAfterTabs,
@@ -384,17 +489,23 @@ export function Toolbar({
384
489
  activeView,
385
490
  setActiveView,
386
491
  markdownSource,
492
+ doc,
387
493
  setMarkdownSource,
388
494
  tiptapEditor,
389
495
  monacoEditor,
390
496
  activeSceneText,
391
497
  mediaProvider,
392
498
  editorMode,
499
+ layoutMode,
500
+ activeBlockStartLine,
393
501
  versioning,
394
502
  allowRecording,
395
503
  documentLinkProvider,
396
504
  colorScheme,
505
+ mediaRevision,
506
+ bumpMediaRevision,
397
507
  } = useEditorContext();
508
+ const previewSettings = usePreviewSettingsOptional();
398
509
  // When a canvas textbox is being edited, its Tiptap instance takes over
399
510
  // the formatting buttons; otherwise they drive the document editor. The
400
511
  // `level` gates which buttons apply (inline labels vs. rich textboxes).
@@ -409,6 +520,30 @@ export function Toolbar({
409
520
  return true;
410
521
  });
411
522
  const showViewTabs = visibleViews.length > 1;
523
+ const [scannedFileCount, setScannedFileCount] = useState(0);
524
+ const resolvedFileCount = fileCount ?? scannedFileCount;
525
+
526
+ useEffect(() => {
527
+ if (fileCount !== undefined) return;
528
+ if (!mediaProvider) {
529
+ setScannedFileCount(0);
530
+ return;
531
+ }
532
+
533
+ let cancelled = false;
534
+ mediaProvider.listMedia().then(
535
+ (entries) => {
536
+ if (!cancelled) setScannedFileCount(entries.length);
537
+ },
538
+ () => {
539
+ if (!cancelled) setScannedFileCount(0);
540
+ },
541
+ );
542
+
543
+ return () => {
544
+ cancelled = true;
545
+ };
546
+ }, [fileCount, mediaProvider, mediaRevision, showFiles]);
412
547
 
413
548
  // Hidden file input for image picker
414
549
  const imageInputRef = useRef<HTMLInputElement>(null);
@@ -490,6 +625,11 @@ export function Toolbar({
490
625
  // ── Overflow detection ────────────────────────────────
491
626
  const actionsRef = useRef<HTMLDivElement>(null);
492
627
  const [measuredOverflowIndex, setMeasuredOverflowIndex] = useState<number | null>(null);
628
+ // '|'-joined data-contextual ids of the contextual groups (template /
629
+ // transition pickers, table controls) whose right edge doesn't fit in the
630
+ // actions row. Stored as a string so identical measurements bail out of
631
+ // re-rendering.
632
+ const [clippedContextualKey, setClippedContextualKey] = useState('');
493
633
  const [showOverflow, setShowOverflow] = useState(false);
494
634
  const overflowRef = useRef<HTMLDivElement>(null);
495
635
 
@@ -500,6 +640,7 @@ export function Toolbar({
500
640
  const [showLayoutManager, setShowLayoutManager] = useState(false);
501
641
 
502
642
  const overflowIndex = measuredOverflowIndex;
643
+ const clippedContextual = new Set(clippedContextualKey ? clippedContextualKey.split('|') : []);
503
644
 
504
645
  useEffect(() => {
505
646
  const container = actionsRef.current;
@@ -507,39 +648,77 @@ export function Toolbar({
507
648
 
508
649
  const measure = () => {
509
650
  const containerRight = container.getBoundingClientRect().right;
651
+ // Contextual groups aren't BUTTONS entries, so they must not shift the
652
+ // btnIndex mapping below — measure them separately.
510
653
  const children = container.querySelectorAll<HTMLElement>(
511
- ':scope > .squisq-toolbar-group > .squisq-toolbar-button',
654
+ ':scope > .squisq-toolbar-group:not(.squisq-toolbar-contextual) > .squisq-toolbar-button',
512
655
  );
513
656
  let firstHidden: number | null = null;
514
- let btnIndex = 0;
515
657
  children.forEach((child) => {
516
658
  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;
659
+ // A button is hidden if its right edge extends past the container.
660
+ // Its data-btn-index carries its BUTTONS position (see
661
+ // BUTTON_INDEX_BY_ID for why the DOM can't be walked positionally).
662
+ if (child.getBoundingClientRect().right > containerRight + 2) {
663
+ const index = Number(child.dataset.btnIndex);
664
+ firstHidden = Number.isNaN(index) ? null : index;
524
665
  }
525
- btnIndex += btnCount;
526
666
  });
527
667
  setMeasuredOverflowIndex(firstHidden);
668
+ // Contextual groups (template/transition pickers, table controls) that
669
+ // don't fit move into the overflow menu instead of painting cropped.
670
+ const clipped: string[] = [];
671
+ container
672
+ .querySelectorAll<HTMLElement>(':scope > .squisq-toolbar-contextual')
673
+ .forEach((group) => {
674
+ if (group.getBoundingClientRect().right > containerRight + 2) {
675
+ clipped.push(group.dataset.contextual ?? '');
676
+ }
677
+ });
678
+ setClippedContextualKey(clipped.join('|'));
528
679
  };
529
680
 
681
+ // Observe the row and every group in it: contextual groups mount/unmount
682
+ // with the cursor context and change width with their value, all without
683
+ // resizing the flex:1 container itself — a container-only observer would
684
+ // go stale.
530
685
  const ro = new ResizeObserver(measure);
531
- ro.observe(container);
686
+ const observeAll = () => {
687
+ ro.disconnect();
688
+ ro.observe(container);
689
+ container
690
+ .querySelectorAll<HTMLElement>(':scope > .squisq-toolbar-group')
691
+ .forEach((group) => ro.observe(group));
692
+ };
693
+ const mo = new MutationObserver(() => {
694
+ observeAll();
695
+ measure();
696
+ });
697
+ mo.observe(container, { childList: true });
698
+ observeAll();
532
699
  measure();
533
- return () => ro.disconnect();
700
+ return () => {
701
+ ro.disconnect();
702
+ mo.disconnect();
703
+ };
534
704
  }, [activeView]);
535
705
 
536
- // Close overflow menu on outside click
706
+ // Close overflow menu on outside click. Clicks inside the template
707
+ // gallery / transition flyout portals count as inside: those popovers are
708
+ // hosted by pickers living in this menu, and closing the menu on their
709
+ // mousedown would unmount them before the click completes.
537
710
  useEffect(() => {
538
711
  if (!showOverflow) return;
539
712
  const handleClick = (e: MouseEvent) => {
540
- if (overflowRef.current && !overflowRef.current.contains(e.target as Node)) {
541
- setShowOverflow(false);
713
+ const target = e.target as Node;
714
+ if (overflowRef.current?.contains(target)) return;
715
+ if (
716
+ target instanceof Element &&
717
+ target.closest(`#${TEMPLATE_GALLERY_PORTAL_ID}, #${TRANSITION_FLYOUT_PORTAL_ID}`)
718
+ ) {
719
+ return;
542
720
  }
721
+ setShowOverflow(false);
543
722
  };
544
723
  document.addEventListener('mousedown', handleClick);
545
724
  return () => document.removeEventListener('mousedown', handleClick);
@@ -673,6 +852,9 @@ export function Toolbar({
673
852
  case 'table':
674
853
  tiptapEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
675
854
  break;
855
+ case 'tasklist':
856
+ insertTaskList(tiptapEditor);
857
+ break;
676
858
  case 'diagram':
677
859
  // Empty diagram is usable via its "+ Add first node" affordance;
678
860
  // sub-headings under this block become nodes.
@@ -827,6 +1009,11 @@ export function Toolbar({
827
1009
  newCursorOffset = 3; // after \n|
828
1010
  break;
829
1011
  }
1012
+ case 'tasklist': {
1013
+ replacement = `\n${TASK_LIST_MARKDOWN}\n`;
1014
+ newCursorOffset = replacement.length;
1015
+ break;
1016
+ }
830
1017
  case 'diagram': {
831
1018
  // A diagram is just a heading with the `{[diagram]}` template
832
1019
  // annotation; the WYSIWYG view renders its editable canvas.
@@ -908,6 +1095,9 @@ export function Toolbar({
908
1095
  insertion =
909
1096
  '\n| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |\n';
910
1097
  break;
1098
+ case 'tasklist':
1099
+ insertion = `\n${TASK_LIST_MARKDOWN}\n`;
1100
+ break;
911
1101
  case 'diagram':
912
1102
  insertion = '\n## Diagram {[diagram]}\n';
913
1103
  break;
@@ -932,6 +1122,7 @@ export function Toolbar({
932
1122
  if (!mediaProvider) return;
933
1123
  const buffer = await file.arrayBuffer();
934
1124
  const relativePath = await mediaProvider.addMedia(file.name, buffer, file.type);
1125
+ bumpMediaRevision();
935
1126
  const altText = file.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
936
1127
 
937
1128
  if (activeView === 'wysiwyg' && tiptapEditor) {
@@ -948,7 +1139,15 @@ export function Toolbar({
948
1139
  setMarkdownSource(markdownSource + `\n![${altText}](${relativePath})\n`);
949
1140
  }
950
1141
  },
951
- [mediaProvider, activeView, tiptapEditor, monacoEditor, markdownSource, setMarkdownSource],
1142
+ [
1143
+ mediaProvider,
1144
+ activeView,
1145
+ tiptapEditor,
1146
+ monacoEditor,
1147
+ markdownSource,
1148
+ setMarkdownSource,
1149
+ bumpMediaRevision,
1150
+ ],
952
1151
  );
953
1152
 
954
1153
  const handleAction = useCallback(
@@ -1230,11 +1429,11 @@ export function Toolbar({
1230
1429
  }
1231
1430
  setRawHeadingLine(pos.lineNumber);
1232
1431
  setRawTransition(readHeadingLineTransition(line));
1233
- const annotMatch = headingMatch[1].match(/\s*\{\[([^\]]+)\]\}[\s\]}]*$/);
1432
+ const annotMatch = matchTrailingTemplateAnnotation(headingMatch[1]);
1234
1433
  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);
1434
+ const tokens = tokenizeAttrTokens(annotMatch.inner.trim());
1435
+ const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
1436
+ setRawTemplate(firstIsParam ? '' : (tokens[0] ?? ''));
1238
1437
  } else {
1239
1438
  setRawTemplate('');
1240
1439
  }
@@ -1302,6 +1501,55 @@ export function Toolbar({
1302
1501
  return recommendTemplatesForBlock(profile, TEMPLATE_NAMES).recommended;
1303
1502
  }, [currentTemplate, isRawView, isWysiwyg, rawHeadingLine, wysiwygHeadingIndex, markdownSource]);
1304
1503
 
1504
+ const templatePreviewBlock = useMemo(() => {
1505
+ if (!doc || currentTemplate === null) return null;
1506
+ const flat = flattenBlocks(doc.blocks);
1507
+ if (flat.length === 0) return null;
1508
+
1509
+ if (isRawView && rawHeadingLine !== null) {
1510
+ return findBlockBySourceLine(flat, rawHeadingLine);
1511
+ }
1512
+
1513
+ if (isWysiwyg) {
1514
+ if (layoutMode !== 'document' && activeBlockStartLine !== null) {
1515
+ return findBlockBySourceLine(flat, activeBlockStartLine);
1516
+ }
1517
+ if (wysiwygHeadingIndex !== null) {
1518
+ return flat[wysiwygHeadingIndex] ?? null;
1519
+ }
1520
+ }
1521
+
1522
+ return null;
1523
+ }, [
1524
+ activeBlockStartLine,
1525
+ currentTemplate,
1526
+ doc,
1527
+ isRawView,
1528
+ isWysiwyg,
1529
+ layoutMode,
1530
+ rawHeadingLine,
1531
+ wysiwygHeadingIndex,
1532
+ ]);
1533
+
1534
+ const templatePreviewSource = useMemo(
1535
+ () =>
1536
+ templatePreviewBlock
1537
+ ? {
1538
+ block: templatePreviewBlock,
1539
+ theme: previewSettings?.activeTheme ?? DEFAULT_THEME,
1540
+ viewport: previewSettings?.activeViewport ?? VIEWPORT_PRESETS.landscape,
1541
+ basePath: '/',
1542
+ mediaProvider,
1543
+ }
1544
+ : undefined,
1545
+ [
1546
+ mediaProvider,
1547
+ previewSettings?.activeTheme,
1548
+ previewSettings?.activeViewport,
1549
+ templatePreviewBlock,
1550
+ ],
1551
+ );
1552
+
1305
1553
  const handleTemplatePick = (value: string) => {
1306
1554
  // Raw (Monaco) — rewrite the heading line's annotation suffix in place.
1307
1555
  if (isRawView && monacoEditor) {
@@ -1313,9 +1561,9 @@ export function Toolbar({
1313
1561
  const headingMatch = lineText.match(/^(#{1,6}\s+)(.+)$/);
1314
1562
  if (!headingMatch) return;
1315
1563
  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}]}`;
1564
+ const { text: bareText, params } = splitTemplateAnnotationParams(headingMatch[2]);
1565
+ const annotation = buildTemplateAnnotation(value, params);
1566
+ const newLine = annotation ? `${prefix}${bareText} ${annotation}` : `${prefix}${bareText}`;
1319
1567
  monacoEditor.executeEdits('toolbar-template-pick', [
1320
1568
  {
1321
1569
  range: {
@@ -1332,14 +1580,22 @@ export function Toolbar({
1332
1580
  }
1333
1581
  // WYSIWYG — update the heading node attributes.
1334
1582
  if (!tiptapEditor) return;
1583
+ const attrs = tiptapEditor.getAttributes('heading') as {
1584
+ dataTemplateParams?: string | null;
1585
+ };
1586
+ const dataTemplateParams = keepBlockMetaParamString(attrs.dataTemplateParams);
1335
1587
  if (value === '') {
1336
1588
  tiptapEditor
1337
1589
  .chain()
1338
1590
  .focus()
1339
- .updateAttributes('heading', { dataTemplate: null, dataTemplateParams: null })
1591
+ .updateAttributes('heading', { dataTemplate: null, dataTemplateParams })
1340
1592
  .run();
1341
1593
  } else {
1342
- tiptapEditor.chain().focus().updateAttributes('heading', { dataTemplate: value }).run();
1594
+ tiptapEditor
1595
+ .chain()
1596
+ .focus()
1597
+ .updateAttributes('heading', { dataTemplate: value, dataTemplateParams })
1598
+ .run();
1343
1599
  }
1344
1600
  };
1345
1601
 
@@ -1382,13 +1638,36 @@ export function Toolbar({
1382
1638
  monacoEditor.focus();
1383
1639
  return;
1384
1640
  }
1385
- // WYSIWYG — rewrite the heading node's `dataBlockAttrs` (Pandoc inner).
1641
+ // WYSIWYG — write transitions into `{[…]}` params and migrate legacy
1642
+ // Pandoc transition keys out of `dataBlockAttrs`.
1386
1643
  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();
1644
+ const attrs = tiptapEditor.getAttributes('heading') as {
1645
+ dataBlockAttrs?: string | null;
1646
+ dataTemplateParams?: string | null;
1647
+ };
1648
+ const updated = setHeadingAttrsTransition(attrs.dataBlockAttrs, attrs.dataTemplateParams, next);
1649
+ tiptapEditor
1650
+ .chain()
1651
+ .focus()
1652
+ .updateAttributes('heading', {
1653
+ dataBlockAttrs: updated.blockAttrsInner,
1654
+ dataTemplateParams: updated.templateParams,
1655
+ })
1656
+ .run();
1390
1657
  };
1391
1658
 
1659
+ // ── Per-block controls in the overflow menu ────────────
1660
+ // The template + transition pickers configure the active heading block, so
1661
+ // when either is pushed into the ··· menu we group them under a labeled
1662
+ // "<name> Block:" heading. `currentTemplate`/`currentTransition` are non-null
1663
+ // together (both gated on a heading being active); an empty template string
1664
+ // is a heading with no visual template, labeled "Heading".
1665
+ const showTemplateInOverflow = currentTemplate !== null && clippedContextual.has('template');
1666
+ const showTransitionInOverflow =
1667
+ currentTransition !== null && clippedContextual.has('transition');
1668
+ const showBlockSectionInOverflow = showTemplateInOverflow || showTransitionInOverflow;
1669
+ const overflowBlockLabel = currentTemplate ? templateLabel(currentTemplate) : 'Heading';
1670
+
1392
1671
  return (
1393
1672
  <div
1394
1673
  className={`squisq-toolbar ${className || ''}`}
@@ -1462,6 +1741,7 @@ export function Toolbar({
1462
1741
  key={btn.id}
1463
1742
  ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
1464
1743
  className={`squisq-toolbar-button${active ? ' squisq-toolbar-button--active' : ''}`}
1744
+ data-btn-index={BUTTON_INDEX_BY_ID.get(btn.id)}
1465
1745
  data-tooltip={disabled ? 'Insert image (requires media provider)' : btn.title}
1466
1746
  onClick={() => handleAction(btn.id)}
1467
1747
  aria-label={btn.title}
@@ -1481,7 +1761,7 @@ export function Toolbar({
1481
1761
  <button
1482
1762
  ref={insertMenuButtonRef}
1483
1763
  className={`squisq-toolbar-button${insertMenuAnchor ? ' squisq-toolbar-button--active' : ''}`}
1484
- data-btn-count={String(MEDIA_BUTTONS.length)}
1764
+ data-btn-index={FIRST_MEDIA_INDEX}
1485
1765
  data-tooltip="Insert..."
1486
1766
  onClick={() => (insertMenuAnchor ? closeInsertMenu() : openInsertMenu())}
1487
1767
  aria-label="Insert"
@@ -1494,192 +1774,202 @@ export function Toolbar({
1494
1774
 
1495
1775
  {/* Template picker — visible when the cursor is in a heading.
1496
1776
  In WYSIWYG, reads from the heading node's `dataTemplate`; in
1497
- Markdown view, parses the `{[...]}` suffix on the cursor's line. */}
1777
+ Markdown view, parses the `{[...]}` suffix on the cursor's line.
1778
+ The `squisq-toolbar-contextual` class + data-contextual id opt
1779
+ this group (and the two below) into the clipping measurement:
1780
+ when it doesn't fit it hides here and moves into the ··· menu. */}
1498
1781
  {currentTemplate !== null && (
1499
- <>
1782
+ <div
1783
+ className={`squisq-toolbar-group squisq-toolbar-contextual squisq-template-picker${clippedContextual.has('template') ? ' squisq-toolbar-contextual--clipped' : ''}`}
1784
+ data-contextual="template"
1785
+ >
1500
1786
  <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
- </>
1787
+ <TemplatePicker
1788
+ value={currentTemplate}
1789
+ onChange={handleTemplatePick}
1790
+ colorScheme={colorScheme}
1791
+ recommended={recommendedTemplates}
1792
+ previewSource={templatePreviewSource}
1793
+ />
1794
+ </div>
1509
1795
  )}
1510
1796
 
1511
1797
  {/* Transition picker — visible alongside the template picker when a
1512
1798
  block (heading) is active. Writes `transition=` into the block's
1513
1799
  Pandoc `{…}` attribute block (the canonical home for transitions). */}
1514
1800
  {currentTransition !== null && (
1515
- <>
1801
+ <div
1802
+ className={`squisq-toolbar-group squisq-toolbar-contextual squisq-transition-picker-group${clippedContextual.has('transition') ? ' squisq-toolbar-contextual--clipped' : ''}`}
1803
+ data-contextual="transition"
1804
+ >
1516
1805
  <div className="squisq-toolbar-separator" />
1517
- <div className="squisq-toolbar-group squisq-transition-picker-group">
1518
- <TransitionPicker value={currentTransition} onChange={handleTransitionPick} />
1519
- </div>
1520
- </>
1806
+ <TransitionPicker value={currentTransition} onChange={handleTransitionPick} />
1807
+ </div>
1521
1808
  )}
1522
1809
 
1523
1810
  {/* Table controls — visible when cursor is in a table (WYSIWYG) */}
1524
1811
  {isInTable && (
1525
- <>
1812
+ <div
1813
+ className={`squisq-toolbar-group squisq-toolbar-contextual squisq-table-controls${clippedContextual.has('table') ? ' squisq-toolbar-contextual--clipped' : ''}`}
1814
+ data-contextual="table"
1815
+ >
1526
1816
  <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"
1817
+ <span className="squisq-table-controls-label">Table:</span>
1818
+ <button
1819
+ className="squisq-toolbar-button"
1820
+ data-tooltip="Add column before"
1821
+ onClick={() => tiptapEditor!.chain().focus().addColumnBefore().run()}
1822
+ aria-label="Add column before"
1823
+ >
1824
+ <svg
1825
+ width="16"
1826
+ height="16"
1827
+ viewBox="0 0 16 16"
1828
+ fill="none"
1829
+ stroke="currentColor"
1830
+ strokeWidth="1.5"
1831
+ strokeLinecap="round"
1534
1832
  >
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"
1833
+ <rect x="7" y="2" width="8" height="12" rx="1" />
1834
+ <line x1="11" y1="2" x2="11" y2="14" />
1835
+ <line x1="1" y1="8" x2="4.5" y2="8" />
1836
+ <line x1="2.75" y1="6.25" x2="2.75" y2="9.75" />
1837
+ </svg>
1838
+ </button>
1839
+ <button
1840
+ className="squisq-toolbar-button"
1841
+ data-tooltip="Add column after"
1842
+ onClick={() => tiptapEditor!.chain().focus().addColumnAfter().run()}
1843
+ aria-label="Add column after"
1844
+ >
1845
+ <svg
1846
+ width="16"
1847
+ height="16"
1848
+ viewBox="0 0 16 16"
1849
+ fill="none"
1850
+ stroke="currentColor"
1851
+ strokeWidth="1.5"
1852
+ strokeLinecap="round"
1555
1853
  >
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"
1854
+ <rect x="1" y="2" width="8" height="12" rx="1" />
1855
+ <line x1="5" y1="2" x2="5" y2="14" />
1856
+ <line x1="11.5" y1="8" x2="15" y2="8" />
1857
+ <line x1="13.25" y1="6.25" x2="13.25" y2="9.75" />
1858
+ </svg>
1859
+ </button>
1860
+ <button
1861
+ className="squisq-toolbar-button"
1862
+ data-tooltip="Delete column"
1863
+ onClick={() => tiptapEditor!.chain().focus().deleteColumn().run()}
1864
+ aria-label="Delete column"
1865
+ >
1866
+ <svg
1867
+ width="16"
1868
+ height="16"
1869
+ viewBox="0 0 16 16"
1870
+ fill="none"
1871
+ stroke="currentColor"
1872
+ strokeWidth="1.5"
1873
+ strokeLinecap="round"
1576
1874
  >
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"
1875
+ <rect x="4" y="1" width="8" height="14" rx="1" />
1876
+ <line x1="6" y1="5.5" x2="10" y2="10.5" />
1877
+ <line x1="10" y1="5.5" x2="6" y2="10.5" />
1878
+ </svg>
1879
+ </button>
1880
+ <button
1881
+ className="squisq-toolbar-button"
1882
+ data-tooltip="Add row above"
1883
+ onClick={() => tiptapEditor!.chain().focus().addRowBefore().run()}
1884
+ aria-label="Add row above"
1885
+ >
1886
+ <svg
1887
+ width="16"
1888
+ height="16"
1889
+ viewBox="0 0 16 16"
1890
+ fill="none"
1891
+ stroke="currentColor"
1892
+ strokeWidth="1.5"
1893
+ strokeLinecap="round"
1596
1894
  >
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"
1895
+ <rect x="2" y="6" width="12" height="9" rx="1" />
1896
+ <line x1="2" y1="10.5" x2="14" y2="10.5" />
1897
+ <line x1="8" y1="1" x2="8" y2="4.5" />
1898
+ <line x1="6.25" y1="2.75" x2="9.75" y2="2.75" />
1899
+ </svg>
1900
+ </button>
1901
+ <button
1902
+ className="squisq-toolbar-button"
1903
+ data-tooltip="Add row below"
1904
+ onClick={() => tiptapEditor!.chain().focus().addRowAfter().run()}
1905
+ aria-label="Add row below"
1906
+ >
1907
+ <svg
1908
+ width="16"
1909
+ height="16"
1910
+ viewBox="0 0 16 16"
1911
+ fill="none"
1912
+ stroke="currentColor"
1913
+ strokeWidth="1.5"
1914
+ strokeLinecap="round"
1617
1915
  >
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"
1916
+ <rect x="2" y="1" width="12" height="9" rx="1" />
1917
+ <line x1="2" y1="5.5" x2="14" y2="5.5" />
1918
+ <line x1="8" y1="11.5" x2="8" y2="15" />
1919
+ <line x1="6.25" y1="13.25" x2="9.75" y2="13.25" />
1920
+ </svg>
1921
+ </button>
1922
+ <button
1923
+ className="squisq-toolbar-button"
1924
+ data-tooltip="Delete row"
1925
+ onClick={() => tiptapEditor!.chain().focus().deleteRow().run()}
1926
+ aria-label="Delete row"
1927
+ >
1928
+ <svg
1929
+ width="16"
1930
+ height="16"
1931
+ viewBox="0 0 16 16"
1932
+ fill="none"
1933
+ stroke="currentColor"
1934
+ strokeWidth="1.5"
1935
+ strokeLinecap="round"
1638
1936
  >
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"
1937
+ <rect x="1" y="4" width="14" height="8" rx="1" />
1938
+ <line x1="5.5" y1="6" x2="10.5" y2="10" />
1939
+ <line x1="10.5" y1="6" x2="5.5" y2="10" />
1940
+ </svg>
1941
+ </button>
1942
+ <button
1943
+ className="squisq-toolbar-button squisq-toolbar-button--danger"
1944
+ data-tooltip="Delete table"
1945
+ onClick={() => tiptapEditor!.chain().focus().deleteTable().run()}
1946
+ aria-label="Delete table"
1947
+ >
1948
+ <svg
1949
+ width="16"
1950
+ height="16"
1951
+ viewBox="0 0 16 16"
1952
+ fill="none"
1953
+ stroke="currentColor"
1954
+ strokeWidth="1.5"
1955
+ strokeLinecap="round"
1658
1956
  >
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
- </>
1957
+ <rect x="1" y="1" width="14" height="14" rx="1" />
1958
+ <line x1="1" y1="5.5" x2="15" y2="5.5" />
1959
+ <line x1="5.5" y1="1" x2="5.5" y2="15" />
1960
+ <line x1="4.5" y1="4.5" x2="11.5" y2="11.5" strokeWidth="2" />
1961
+ <line x1="11.5" y1="4.5" x2="4.5" y2="11.5" strokeWidth="2" />
1962
+ </svg>
1963
+ </button>
1964
+ </div>
1677
1965
  )}
1678
1966
  </div>
1679
1967
  )}
1680
1968
 
1681
- {/* Overflow menu — outside the overflow:hidden actions container */}
1682
- {!isPreview && !isCodeMode && overflowIndex !== null && (
1969
+ {/* Overflow menu — outside the overflow:hidden actions container.
1970
+ Also appears when a contextual group (template/transition picker,
1971
+ table controls) is clipped, even if every plain button fits. */}
1972
+ {!isPreview && !isCodeMode && (overflowIndex !== null || clippedContextual.size > 0) && (
1683
1973
  <div className="squisq-toolbar-overflow" ref={overflowRef}>
1684
1974
  <button
1685
1975
  className={`squisq-toolbar-button squisq-toolbar-overflow-trigger${showOverflow ? ' squisq-toolbar-button--active' : ''}`}
@@ -1694,7 +1984,7 @@ export function Toolbar({
1694
1984
  <div
1695
1985
  className={`squisq-toolbar-overflow-menu squisq-toolbar-overflow-menu--${overflowPlacement}`}
1696
1986
  >
1697
- {BUTTONS.slice(overflowIndex)
1987
+ {BUTTONS.slice(overflowIndex ?? BUTTONS.length)
1698
1988
  .filter((b) => isButtonVisible(b.id))
1699
1989
  // Media buttons are represented by the synthetic Insert dropdown
1700
1990
  // in both the visible toolbar and the overflow menu.
@@ -1748,23 +2038,48 @@ export function Toolbar({
1748
2038
  </button>
1749
2039
  )}
1750
2040
 
1751
- {/* Contextual: template picker in overflow */}
1752
- {currentTemplate !== null && (
2041
+ {/* Per-block section — the template + transition pickers move
2042
+ here when clipped, under a divider and a "<name> Block:"
2043
+ heading that names the block they configure. */}
2044
+ {showBlockSectionInOverflow && (
2045
+ <>
2046
+ <div
2047
+ className="squisq-toolbar-separator"
2048
+ style={{ margin: '4px 0', width: '100%', height: 1 }}
2049
+ />
2050
+ <div className="squisq-toolbar-overflow-heading">{overflowBlockLabel} Block:</div>
2051
+ </>
2052
+ )}
2053
+
2054
+ {/* Contextual: template picker in overflow — only when the
2055
+ inline one is clipped, so the control lives in exactly one
2056
+ visible place at a time. */}
2057
+ {showTemplateInOverflow && (
1753
2058
  <div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
1754
- <span>Template:</span>
1755
2059
  <TemplatePicker
1756
- value={currentTemplate}
2060
+ value={currentTemplate!}
1757
2061
  onChange={(v) => {
1758
2062
  handleTemplatePick(v);
1759
2063
  setShowOverflow(false);
1760
2064
  }}
2065
+ colorScheme={colorScheme}
1761
2066
  recommended={recommendedTemplates}
2067
+ previewSource={templatePreviewSource}
1762
2068
  />
1763
2069
  </div>
1764
2070
  )}
1765
2071
 
2072
+ {/* Contextual: transition picker in overflow — mirrors the
2073
+ template picker above. The menu stays open on change since
2074
+ the flyout carries follow-up controls (direction/duration). */}
2075
+ {showTransitionInOverflow && (
2076
+ <div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
2077
+ <TransitionPicker value={currentTransition!} onChange={handleTransitionPick} />
2078
+ </div>
2079
+ )}
2080
+
1766
2081
  {/* Contextual: table controls in overflow */}
1767
- {isInTable && (
2082
+ {isInTable && clippedContextual.has('table') && (
1768
2083
  <>
1769
2084
  <div
1770
2085
  className="squisq-toolbar-separator"
@@ -1863,11 +2178,16 @@ export function Toolbar({
1863
2178
  <button
1864
2179
  className={`squisq-toolbar-button squisq-toolbar-files-toggle${showFiles ? ' squisq-toolbar-button--active' : ''}`}
1865
2180
  onClick={onToggleFiles}
1866
- data-tooltip={showFiles ? 'Hide Files panel' : 'Show Files panel'}
2181
+ data-tooltip={`${showFiles ? 'Hide' : 'Show'} Files panel${resolvedFileCount > 0 ? ` (${fileCountLabel(resolvedFileCount)})` : ''}`}
1867
2182
  aria-pressed={showFiles}
1868
- aria-label="Toggle Files panel"
2183
+ aria-label={`Toggle Files panel${resolvedFileCount > 0 ? `, ${fileCountLabel(resolvedFileCount)}` : ''}`}
1869
2184
  >
1870
2185
  <Icon icon="fa-solid fa-paperclip" />
2186
+ {resolvedFileCount > 0 && (
2187
+ <span className="squisq-toolbar-files-badge" aria-hidden="true">
2188
+ {fileCountBadge(resolvedFileCount)}
2189
+ </span>
2190
+ )}
1871
2191
  </button>
1872
2192
  )}
1873
2193
  {/* Right slot — rightmost end of toolbar */}