@bendyline/squisq-editor-react 1.6.0 → 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 (68) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +476 -105
  3. package/dist/index.js +8703 -6741
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +14617 -0
  10. package/package.json +15 -7
  11. package/src/BlockPropertiesPopover.tsx +23 -7
  12. package/src/EditorContext.tsx +22 -16
  13. package/src/EditorShell.tsx +121 -35
  14. package/src/MediaBin.tsx +171 -28
  15. package/src/OutlinePanel.tsx +26 -4
  16. package/src/PreviewControls.tsx +475 -145
  17. package/src/PreviewPanel.tsx +17 -9
  18. package/src/RawEditor.tsx +10 -4
  19. package/src/TemplateAnnotation.ts +22 -7
  20. package/src/TemplateContentPreview.tsx +56 -0
  21. package/src/TemplatePicker.tsx +295 -128
  22. package/src/ThemeCustomizerPanel.tsx +22 -15
  23. package/src/Toolbar.tsx +547 -217
  24. package/src/TransitionPicker.tsx +8 -1
  25. package/src/VersionHistoryPanel.tsx +2 -2
  26. package/src/ViewSwitcher.tsx +4 -4
  27. package/src/WysiwygEditor.tsx +45 -3
  28. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  29. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  30. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  31. package/src/__tests__/diffContextSections.test.ts +39 -0
  32. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  33. package/src/__tests__/editorShellProps.test.tsx +363 -0
  34. package/src/__tests__/headingTransition.test.ts +59 -9
  35. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  36. package/src/__tests__/mediaReferences.test.ts +82 -0
  37. package/src/__tests__/previewControls.test.tsx +163 -0
  38. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  39. package/src/__tests__/templateContentPreview.test.ts +101 -0
  40. package/src/__tests__/tiptapBridge.test.ts +47 -0
  41. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  42. package/src/__tests__/useMediaRecorder.test.ts +17 -0
  43. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  44. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  45. package/src/codeContext/CodeContextZones.tsx +121 -0
  46. package/src/codeContext/diffContextSections.ts +38 -0
  47. package/src/codeContext/types.ts +75 -0
  48. package/src/diagram/DiagramWidget.tsx +6 -4
  49. package/src/headingTransition.ts +96 -21
  50. package/src/index.ts +34 -2
  51. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  52. package/src/mediaReferences.ts +299 -0
  53. package/src/recorder/hooks/useMediaRecorder.ts +9 -10
  54. package/src/scene/Scene.tsx +53 -7
  55. package/src/scene/SceneBlockWidget.tsx +6 -3
  56. package/src/scene/SceneSelection.tsx +19 -15
  57. package/src/scene/SceneSideToolbar.tsx +89 -0
  58. package/src/scene/layers/DiagramEdges.tsx +4 -3
  59. package/src/scene/layers/edgeGeometry.ts +23 -4
  60. package/src/scene/scene.css +142 -2
  61. package/src/scene/tools/ConnectTool.ts +113 -24
  62. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  63. package/src/scene/tools/SceneTool.ts +2 -0
  64. package/src/styles/code-context.css +155 -0
  65. package/src/styles/editor.css +991 -84
  66. package/src/styles/index.css +1 -0
  67. package/src/templateContentPreviewResolver.ts +353 -0
  68. 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,22 +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 { 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;
74
+ /** Content rendered immediately after the view tabs, on the left side of the
75
+ * toolbar (before the formatting controls). Used for the preview mode
76
+ * switch in Use view. */
77
+ slotAfterTabs?: ReactNode;
55
78
  /** Content rendered after the formatting controls (in the middle area). */
56
79
  slotAfterActions?: ReactNode;
57
80
  /** Content rendered at the rightmost end of the toolbar, after all other elements. */
@@ -181,6 +204,14 @@ const BUTTONS: ToolbarButton[] = [
181
204
  group: 'media',
182
205
  faIcon: 'fa-solid fa-table',
183
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
+ },
184
215
  {
185
216
  id: 'diagram',
186
217
  label: 'diagram',
@@ -226,12 +257,51 @@ const BUTTONS: ToolbarButton[] = [
226
257
  const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
227
258
  const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
228
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]));
229
269
 
230
270
  /** Renders a button's icon: a Font Awesome glyph when set, else the text label. */
231
271
  function buttonIcon(btn: ToolbarButton): ReactNode {
232
272
  return btn.faIcon ? <Icon icon={btn.faIcon} /> : btn.icon;
233
273
  }
234
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
+
235
305
  // ─── Tiptap active-state map ────────────────────────────
236
306
 
237
307
  /** Returns true if the given button id is currently active in Tiptap */
@@ -361,6 +431,44 @@ function insertLayoutBlock(editor: TiptapEditor): void {
361
431
  .run();
362
432
  }
363
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
+
364
472
  /**
365
473
  * Formatting toolbar.
366
474
  * - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
@@ -369,8 +477,10 @@ function insertLayoutBlock(editor: TiptapEditor): void {
369
477
  export function Toolbar({
370
478
  className,
371
479
  showFiles,
480
+ fileCount,
372
481
  onToggleFiles,
373
482
  slotLeft,
483
+ slotAfterTabs,
374
484
  slotAfterActions,
375
485
  slotRight,
376
486
  showPlayTab = true,
@@ -379,17 +489,23 @@ export function Toolbar({
379
489
  activeView,
380
490
  setActiveView,
381
491
  markdownSource,
492
+ doc,
382
493
  setMarkdownSource,
383
494
  tiptapEditor,
384
495
  monacoEditor,
385
496
  activeSceneText,
386
497
  mediaProvider,
387
498
  editorMode,
499
+ layoutMode,
500
+ activeBlockStartLine,
388
501
  versioning,
389
502
  allowRecording,
390
503
  documentLinkProvider,
391
- theme,
504
+ colorScheme,
505
+ mediaRevision,
506
+ bumpMediaRevision,
392
507
  } = useEditorContext();
508
+ const previewSettings = usePreviewSettingsOptional();
393
509
  // When a canvas textbox is being edited, its Tiptap instance takes over
394
510
  // the formatting buttons; otherwise they drive the document editor. The
395
511
  // `level` gates which buttons apply (inline labels vs. rich textboxes).
@@ -404,6 +520,30 @@ export function Toolbar({
404
520
  return true;
405
521
  });
406
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]);
407
547
 
408
548
  // Hidden file input for image picker
409
549
  const imageInputRef = useRef<HTMLInputElement>(null);
@@ -485,6 +625,11 @@ export function Toolbar({
485
625
  // ── Overflow detection ────────────────────────────────
486
626
  const actionsRef = useRef<HTMLDivElement>(null);
487
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('');
488
633
  const [showOverflow, setShowOverflow] = useState(false);
489
634
  const overflowRef = useRef<HTMLDivElement>(null);
490
635
 
@@ -495,6 +640,7 @@ export function Toolbar({
495
640
  const [showLayoutManager, setShowLayoutManager] = useState(false);
496
641
 
497
642
  const overflowIndex = measuredOverflowIndex;
643
+ const clippedContextual = new Set(clippedContextualKey ? clippedContextualKey.split('|') : []);
498
644
 
499
645
  useEffect(() => {
500
646
  const container = actionsRef.current;
@@ -502,39 +648,77 @@ export function Toolbar({
502
648
 
503
649
  const measure = () => {
504
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.
505
653
  const children = container.querySelectorAll<HTMLElement>(
506
- ':scope > .squisq-toolbar-group > .squisq-toolbar-button',
654
+ ':scope > .squisq-toolbar-group:not(.squisq-toolbar-contextual) > .squisq-toolbar-button',
507
655
  );
508
656
  let firstHidden: number | null = null;
509
- let btnIndex = 0;
510
657
  children.forEach((child) => {
511
658
  if (firstHidden !== null) return;
512
- // data-btn-count lets a single DOM button represent multiple BUTTONS entries
513
- // (e.g. the Insert dropdown button represents the entire media group).
514
- const btnCount = Number(child.dataset.btnCount ?? 1);
515
- const rect = child.getBoundingClientRect();
516
- // A button is hidden if its right edge extends past the container
517
- if (rect.right > containerRight + 2) {
518
- 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;
519
665
  }
520
- btnIndex += btnCount;
521
666
  });
522
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('|'));
523
679
  };
524
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.
525
685
  const ro = new ResizeObserver(measure);
526
- 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();
527
699
  measure();
528
- return () => ro.disconnect();
700
+ return () => {
701
+ ro.disconnect();
702
+ mo.disconnect();
703
+ };
529
704
  }, [activeView]);
530
705
 
531
- // 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.
532
710
  useEffect(() => {
533
711
  if (!showOverflow) return;
534
712
  const handleClick = (e: MouseEvent) => {
535
- if (overflowRef.current && !overflowRef.current.contains(e.target as Node)) {
536
- 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;
537
720
  }
721
+ setShowOverflow(false);
538
722
  };
539
723
  document.addEventListener('mousedown', handleClick);
540
724
  return () => document.removeEventListener('mousedown', handleClick);
@@ -668,6 +852,9 @@ export function Toolbar({
668
852
  case 'table':
669
853
  tiptapEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
670
854
  break;
855
+ case 'tasklist':
856
+ insertTaskList(tiptapEditor);
857
+ break;
671
858
  case 'diagram':
672
859
  // Empty diagram is usable via its "+ Add first node" affordance;
673
860
  // sub-headings under this block become nodes.
@@ -822,22 +1009,27 @@ export function Toolbar({
822
1009
  newCursorOffset = 3; // after \n|
823
1010
  break;
824
1011
  }
1012
+ case 'tasklist': {
1013
+ replacement = `\n${TASK_LIST_MARKDOWN}\n`;
1014
+ newCursorOffset = replacement.length;
1015
+ break;
1016
+ }
825
1017
  case 'diagram': {
826
1018
  // A diagram is just a heading with the `{[diagram]}` template
827
1019
  // annotation; the WYSIWYG view renders its editable canvas.
828
1020
  replacement = '\n## Diagram {[diagram]}\n';
829
- newCursorOffset = 4; // start of "Diagram" (after \n## )
1021
+ newCursorOffset = replacement.length;
830
1022
  break;
831
1023
  }
832
1024
  case 'drawing': {
833
1025
  replacement = '\n## Drawing {[drawing]}\n';
834
- newCursorOffset = 4; // start of "Drawing"
1026
+ newCursorOffset = replacement.length;
835
1027
  break;
836
1028
  }
837
1029
  case 'layout': {
838
1030
  // Seed a starter text layer (a child sub-block) so it isn't blank.
839
1031
  replacement = LAYOUT_STARTER_MARKDOWN;
840
- newCursorOffset = 4; // start of "Layout" in the parent heading
1032
+ newCursorOffset = replacement.length;
841
1033
  break;
842
1034
  }
843
1035
  }
@@ -846,13 +1038,13 @@ export function Toolbar({
846
1038
  const range = selection;
847
1039
  monacoEditor.executeEdits('toolbar', [{ range, text: replacement }]);
848
1040
 
849
- // If no selection, select the placeholder text so user can type over it
1041
+ // If no selection, move the cursor to the command's preferred edit point.
850
1042
  if (!hasSelection && newCursorOffset > 0) {
851
1043
  const startPos = model.getPositionAt(
852
1044
  model.getOffsetAt(range.getStartPosition()) + newCursorOffset,
853
1045
  );
854
- // Just place cursor after the prefix
855
1046
  monacoEditor.setPosition(startPos);
1047
+ monacoEditor.revealPositionInCenterIfOutsideViewport(startPos);
856
1048
  }
857
1049
 
858
1050
  monacoEditor.focus();
@@ -903,6 +1095,9 @@ export function Toolbar({
903
1095
  insertion =
904
1096
  '\n| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |\n';
905
1097
  break;
1098
+ case 'tasklist':
1099
+ insertion = `\n${TASK_LIST_MARKDOWN}\n`;
1100
+ break;
906
1101
  case 'diagram':
907
1102
  insertion = '\n## Diagram {[diagram]}\n';
908
1103
  break;
@@ -927,6 +1122,7 @@ export function Toolbar({
927
1122
  if (!mediaProvider) return;
928
1123
  const buffer = await file.arrayBuffer();
929
1124
  const relativePath = await mediaProvider.addMedia(file.name, buffer, file.type);
1125
+ bumpMediaRevision();
930
1126
  const altText = file.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
931
1127
 
932
1128
  if (activeView === 'wysiwyg' && tiptapEditor) {
@@ -943,7 +1139,15 @@ export function Toolbar({
943
1139
  setMarkdownSource(markdownSource + `\n![${altText}](${relativePath})\n`);
944
1140
  }
945
1141
  },
946
- [mediaProvider, activeView, tiptapEditor, monacoEditor, markdownSource, setMarkdownSource],
1142
+ [
1143
+ mediaProvider,
1144
+ activeView,
1145
+ tiptapEditor,
1146
+ monacoEditor,
1147
+ markdownSource,
1148
+ setMarkdownSource,
1149
+ bumpMediaRevision,
1150
+ ],
947
1151
  );
948
1152
 
949
1153
  const handleAction = useCallback(
@@ -1225,11 +1429,11 @@ export function Toolbar({
1225
1429
  }
1226
1430
  setRawHeadingLine(pos.lineNumber);
1227
1431
  setRawTransition(readHeadingLineTransition(line));
1228
- const annotMatch = headingMatch[1].match(/\s*\{\[([^\]]+)\]\}[\s\]}]*$/);
1432
+ const annotMatch = matchTrailingTemplateAnnotation(headingMatch[1]);
1229
1433
  if (annotMatch) {
1230
- // First whitespace-delimited token is the template name; the rest are params.
1231
- const name = annotMatch[1].trim().split(/\s+/)[0];
1232
- setRawTemplate(name);
1434
+ const tokens = tokenizeAttrTokens(annotMatch.inner.trim());
1435
+ const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
1436
+ setRawTemplate(firstIsParam ? '' : (tokens[0] ?? ''));
1233
1437
  } else {
1234
1438
  setRawTemplate('');
1235
1439
  }
@@ -1297,6 +1501,55 @@ export function Toolbar({
1297
1501
  return recommendTemplatesForBlock(profile, TEMPLATE_NAMES).recommended;
1298
1502
  }, [currentTemplate, isRawView, isWysiwyg, rawHeadingLine, wysiwygHeadingIndex, markdownSource]);
1299
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
+
1300
1553
  const handleTemplatePick = (value: string) => {
1301
1554
  // Raw (Monaco) — rewrite the heading line's annotation suffix in place.
1302
1555
  if (isRawView && monacoEditor) {
@@ -1308,9 +1561,9 @@ export function Toolbar({
1308
1561
  const headingMatch = lineText.match(/^(#{1,6}\s+)(.+)$/);
1309
1562
  if (!headingMatch) return;
1310
1563
  const prefix = headingMatch[1];
1311
- // Strip any existing trailing annotation
1312
- const bareText = headingMatch[2].replace(/\s*\{\[[^\]]+\]\}[\s\]}]*$/, '').trimEnd();
1313
- 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}`;
1314
1567
  monacoEditor.executeEdits('toolbar-template-pick', [
1315
1568
  {
1316
1569
  range: {
@@ -1327,14 +1580,22 @@ export function Toolbar({
1327
1580
  }
1328
1581
  // WYSIWYG — update the heading node attributes.
1329
1582
  if (!tiptapEditor) return;
1583
+ const attrs = tiptapEditor.getAttributes('heading') as {
1584
+ dataTemplateParams?: string | null;
1585
+ };
1586
+ const dataTemplateParams = keepBlockMetaParamString(attrs.dataTemplateParams);
1330
1587
  if (value === '') {
1331
1588
  tiptapEditor
1332
1589
  .chain()
1333
1590
  .focus()
1334
- .updateAttributes('heading', { dataTemplate: null, dataTemplateParams: null })
1591
+ .updateAttributes('heading', { dataTemplate: null, dataTemplateParams })
1335
1592
  .run();
1336
1593
  } else {
1337
- tiptapEditor.chain().focus().updateAttributes('heading', { dataTemplate: value }).run();
1594
+ tiptapEditor
1595
+ .chain()
1596
+ .focus()
1597
+ .updateAttributes('heading', { dataTemplate: value, dataTemplateParams })
1598
+ .run();
1338
1599
  }
1339
1600
  };
1340
1601
 
@@ -1377,13 +1638,36 @@ export function Toolbar({
1377
1638
  monacoEditor.focus();
1378
1639
  return;
1379
1640
  }
1380
- // 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`.
1381
1643
  if (!tiptapEditor) return;
1382
- const attrs = tiptapEditor.getAttributes('heading') as { dataBlockAttrs?: string | null };
1383
- const inner = setBlockAttrsTransition(attrs.dataBlockAttrs, next);
1384
- tiptapEditor.chain().focus().updateAttributes('heading', { dataBlockAttrs: inner }).run();
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();
1385
1657
  };
1386
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
+
1387
1671
  return (
1388
1672
  <div
1389
1673
  className={`squisq-toolbar ${className || ''}`}
@@ -1436,6 +1720,8 @@ export function Toolbar({
1436
1720
  ))}
1437
1721
  </div>
1438
1722
  )}
1723
+ {/* After-tabs slot — left side, before formatting controls (preview mode switch) */}
1724
+ {slotAfterTabs}
1439
1725
  {/* Formatting buttons — hidden in preview mode and code mode */}
1440
1726
  {!isPreview && !isCodeMode && (
1441
1727
  <div className="squisq-toolbar-actions" ref={actionsRef}>
@@ -1455,6 +1741,7 @@ export function Toolbar({
1455
1741
  key={btn.id}
1456
1742
  ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
1457
1743
  className={`squisq-toolbar-button${active ? ' squisq-toolbar-button--active' : ''}`}
1744
+ data-btn-index={BUTTON_INDEX_BY_ID.get(btn.id)}
1458
1745
  data-tooltip={disabled ? 'Insert image (requires media provider)' : btn.title}
1459
1746
  onClick={() => handleAction(btn.id)}
1460
1747
  aria-label={btn.title}
@@ -1474,7 +1761,7 @@ export function Toolbar({
1474
1761
  <button
1475
1762
  ref={insertMenuButtonRef}
1476
1763
  className={`squisq-toolbar-button${insertMenuAnchor ? ' squisq-toolbar-button--active' : ''}`}
1477
- data-btn-count={String(MEDIA_BUTTONS.length)}
1764
+ data-btn-index={FIRST_MEDIA_INDEX}
1478
1765
  data-tooltip="Insert..."
1479
1766
  onClick={() => (insertMenuAnchor ? closeInsertMenu() : openInsertMenu())}
1480
1767
  aria-label="Insert"
@@ -1487,192 +1774,202 @@ export function Toolbar({
1487
1774
 
1488
1775
  {/* Template picker — visible when the cursor is in a heading.
1489
1776
  In WYSIWYG, reads from the heading node's `dataTemplate`; in
1490
- 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. */}
1491
1781
  {currentTemplate !== null && (
1492
- <>
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
+ >
1493
1786
  <div className="squisq-toolbar-separator" />
1494
- <div className="squisq-toolbar-group squisq-template-picker">
1495
- <TemplatePicker
1496
- value={currentTemplate}
1497
- onChange={handleTemplatePick}
1498
- recommended={recommendedTemplates}
1499
- />
1500
- </div>
1501
- </>
1787
+ <TemplatePicker
1788
+ value={currentTemplate}
1789
+ onChange={handleTemplatePick}
1790
+ colorScheme={colorScheme}
1791
+ recommended={recommendedTemplates}
1792
+ previewSource={templatePreviewSource}
1793
+ />
1794
+ </div>
1502
1795
  )}
1503
1796
 
1504
1797
  {/* Transition picker — visible alongside the template picker when a
1505
1798
  block (heading) is active. Writes `transition=` into the block's
1506
1799
  Pandoc `{…}` attribute block (the canonical home for transitions). */}
1507
1800
  {currentTransition !== null && (
1508
- <>
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
+ >
1509
1805
  <div className="squisq-toolbar-separator" />
1510
- <div className="squisq-toolbar-group squisq-transition-picker-group">
1511
- <TransitionPicker value={currentTransition} onChange={handleTransitionPick} />
1512
- </div>
1513
- </>
1806
+ <TransitionPicker value={currentTransition} onChange={handleTransitionPick} />
1807
+ </div>
1514
1808
  )}
1515
1809
 
1516
1810
  {/* Table controls — visible when cursor is in a table (WYSIWYG) */}
1517
1811
  {isInTable && (
1518
- <>
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
+ >
1519
1816
  <div className="squisq-toolbar-separator" />
1520
- <div className="squisq-toolbar-group squisq-table-controls">
1521
- <span className="squisq-table-controls-label">Table:</span>
1522
- <button
1523
- className="squisq-toolbar-button"
1524
- data-tooltip="Add column before"
1525
- onClick={() => tiptapEditor!.chain().focus().addColumnBefore().run()}
1526
- 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"
1527
1832
  >
1528
- <svg
1529
- width="16"
1530
- height="16"
1531
- viewBox="0 0 16 16"
1532
- fill="none"
1533
- stroke="currentColor"
1534
- strokeWidth="1.5"
1535
- strokeLinecap="round"
1536
- >
1537
- <rect x="7" y="2" width="8" height="12" rx="1" />
1538
- <line x1="11" y1="2" x2="11" y2="14" />
1539
- <line x1="1" y1="8" x2="4.5" y2="8" />
1540
- <line x1="2.75" y1="6.25" x2="2.75" y2="9.75" />
1541
- </svg>
1542
- </button>
1543
- <button
1544
- className="squisq-toolbar-button"
1545
- data-tooltip="Add column after"
1546
- onClick={() => tiptapEditor!.chain().focus().addColumnAfter().run()}
1547
- 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"
1548
1853
  >
1549
- <svg
1550
- width="16"
1551
- height="16"
1552
- viewBox="0 0 16 16"
1553
- fill="none"
1554
- stroke="currentColor"
1555
- strokeWidth="1.5"
1556
- strokeLinecap="round"
1557
- >
1558
- <rect x="1" y="2" width="8" height="12" rx="1" />
1559
- <line x1="5" y1="2" x2="5" y2="14" />
1560
- <line x1="11.5" y1="8" x2="15" y2="8" />
1561
- <line x1="13.25" y1="6.25" x2="13.25" y2="9.75" />
1562
- </svg>
1563
- </button>
1564
- <button
1565
- className="squisq-toolbar-button"
1566
- data-tooltip="Delete column"
1567
- onClick={() => tiptapEditor!.chain().focus().deleteColumn().run()}
1568
- 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"
1569
1874
  >
1570
- <svg
1571
- width="16"
1572
- height="16"
1573
- viewBox="0 0 16 16"
1574
- fill="none"
1575
- stroke="currentColor"
1576
- strokeWidth="1.5"
1577
- strokeLinecap="round"
1578
- >
1579
- <rect x="4" y="1" width="8" height="14" rx="1" />
1580
- <line x1="6" y1="5.5" x2="10" y2="10.5" />
1581
- <line x1="10" y1="5.5" x2="6" y2="10.5" />
1582
- </svg>
1583
- </button>
1584
- <button
1585
- className="squisq-toolbar-button"
1586
- data-tooltip="Add row above"
1587
- onClick={() => tiptapEditor!.chain().focus().addRowBefore().run()}
1588
- 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"
1589
1894
  >
1590
- <svg
1591
- width="16"
1592
- height="16"
1593
- viewBox="0 0 16 16"
1594
- fill="none"
1595
- stroke="currentColor"
1596
- strokeWidth="1.5"
1597
- strokeLinecap="round"
1598
- >
1599
- <rect x="2" y="6" width="12" height="9" rx="1" />
1600
- <line x1="2" y1="10.5" x2="14" y2="10.5" />
1601
- <line x1="8" y1="1" x2="8" y2="4.5" />
1602
- <line x1="6.25" y1="2.75" x2="9.75" y2="2.75" />
1603
- </svg>
1604
- </button>
1605
- <button
1606
- className="squisq-toolbar-button"
1607
- data-tooltip="Add row below"
1608
- onClick={() => tiptapEditor!.chain().focus().addRowAfter().run()}
1609
- 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"
1610
1915
  >
1611
- <svg
1612
- width="16"
1613
- height="16"
1614
- viewBox="0 0 16 16"
1615
- fill="none"
1616
- stroke="currentColor"
1617
- strokeWidth="1.5"
1618
- strokeLinecap="round"
1619
- >
1620
- <rect x="2" y="1" width="12" height="9" rx="1" />
1621
- <line x1="2" y1="5.5" x2="14" y2="5.5" />
1622
- <line x1="8" y1="11.5" x2="8" y2="15" />
1623
- <line x1="6.25" y1="13.25" x2="9.75" y2="13.25" />
1624
- </svg>
1625
- </button>
1626
- <button
1627
- className="squisq-toolbar-button"
1628
- data-tooltip="Delete row"
1629
- onClick={() => tiptapEditor!.chain().focus().deleteRow().run()}
1630
- 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"
1631
1936
  >
1632
- <svg
1633
- width="16"
1634
- height="16"
1635
- viewBox="0 0 16 16"
1636
- fill="none"
1637
- stroke="currentColor"
1638
- strokeWidth="1.5"
1639
- strokeLinecap="round"
1640
- >
1641
- <rect x="1" y="4" width="14" height="8" rx="1" />
1642
- <line x1="5.5" y1="6" x2="10.5" y2="10" />
1643
- <line x1="10.5" y1="6" x2="5.5" y2="10" />
1644
- </svg>
1645
- </button>
1646
- <button
1647
- className="squisq-toolbar-button squisq-toolbar-button--danger"
1648
- data-tooltip="Delete table"
1649
- onClick={() => tiptapEditor!.chain().focus().deleteTable().run()}
1650
- 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"
1651
1956
  >
1652
- <svg
1653
- width="16"
1654
- height="16"
1655
- viewBox="0 0 16 16"
1656
- fill="none"
1657
- stroke="currentColor"
1658
- strokeWidth="1.5"
1659
- strokeLinecap="round"
1660
- >
1661
- <rect x="1" y="1" width="14" height="14" rx="1" />
1662
- <line x1="1" y1="5.5" x2="15" y2="5.5" />
1663
- <line x1="5.5" y1="1" x2="5.5" y2="15" />
1664
- <line x1="4.5" y1="4.5" x2="11.5" y2="11.5" strokeWidth="2" />
1665
- <line x1="11.5" y1="4.5" x2="4.5" y2="11.5" strokeWidth="2" />
1666
- </svg>
1667
- </button>
1668
- </div>
1669
- </>
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>
1670
1965
  )}
1671
1966
  </div>
1672
1967
  )}
1673
1968
 
1674
- {/* Overflow menu — outside the overflow:hidden actions container */}
1675
- {!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) && (
1676
1973
  <div className="squisq-toolbar-overflow" ref={overflowRef}>
1677
1974
  <button
1678
1975
  className={`squisq-toolbar-button squisq-toolbar-overflow-trigger${showOverflow ? ' squisq-toolbar-button--active' : ''}`}
@@ -1687,7 +1984,7 @@ export function Toolbar({
1687
1984
  <div
1688
1985
  className={`squisq-toolbar-overflow-menu squisq-toolbar-overflow-menu--${overflowPlacement}`}
1689
1986
  >
1690
- {BUTTONS.slice(overflowIndex)
1987
+ {BUTTONS.slice(overflowIndex ?? BUTTONS.length)
1691
1988
  .filter((b) => isButtonVisible(b.id))
1692
1989
  // Media buttons are represented by the synthetic Insert dropdown
1693
1990
  // in both the visible toolbar and the overflow menu.
@@ -1741,23 +2038,48 @@ export function Toolbar({
1741
2038
  </button>
1742
2039
  )}
1743
2040
 
1744
- {/* Contextual: template picker in overflow */}
1745
- {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 && (
1746
2058
  <div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
1747
- <span>Template:</span>
1748
2059
  <TemplatePicker
1749
- value={currentTemplate}
2060
+ value={currentTemplate!}
1750
2061
  onChange={(v) => {
1751
2062
  handleTemplatePick(v);
1752
2063
  setShowOverflow(false);
1753
2064
  }}
2065
+ colorScheme={colorScheme}
1754
2066
  recommended={recommendedTemplates}
2067
+ previewSource={templatePreviewSource}
1755
2068
  />
1756
2069
  </div>
1757
2070
  )}
1758
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
+
1759
2081
  {/* Contextual: table controls in overflow */}
1760
- {isInTable && (
2082
+ {isInTable && clippedContextual.has('table') && (
1761
2083
  <>
1762
2084
  <div
1763
2085
  className="squisq-toolbar-separator"
@@ -1813,9 +2135,12 @@ export function Toolbar({
1813
2135
 
1814
2136
  {/* After-actions slot — after formatting controls */}
1815
2137
  {slotAfterActions}
1816
- {/* Spacer — only needed when the actions container (which has flex:1
1817
- and already pushes right-side items to the end) isn't rendered. */}
1818
- {(isPreview || isCodeMode) && <div style={{ flex: 1 }} />}
2138
+ {/* Spacer — pushes right-side items to the end when the flex:1 actions
2139
+ container isn't rendered. In preview mode PreviewToolbarControls
2140
+ supplies its own flex:1 filler (and measures that leftover width to
2141
+ decide whether to collapse), so a second spacer here would split the
2142
+ slack and make the controls collapse too early. */}
2143
+ {isCodeMode && <div style={{ flex: 1 }} />}
1819
2144
  {/* Version history — renders only when the host enabled versioning
1820
2145
  and a container is wired up. The component owns its own button
1821
2146
  and popover; we just give it a slot in the toolbar. */}
@@ -1853,11 +2178,16 @@ export function Toolbar({
1853
2178
  <button
1854
2179
  className={`squisq-toolbar-button squisq-toolbar-files-toggle${showFiles ? ' squisq-toolbar-button--active' : ''}`}
1855
2180
  onClick={onToggleFiles}
1856
- data-tooltip={showFiles ? 'Hide Files panel' : 'Show Files panel'}
2181
+ data-tooltip={`${showFiles ? 'Hide' : 'Show'} Files panel${resolvedFileCount > 0 ? ` (${fileCountLabel(resolvedFileCount)})` : ''}`}
1857
2182
  aria-pressed={showFiles}
1858
- aria-label="Toggle Files panel"
2183
+ aria-label={`Toggle Files panel${resolvedFileCount > 0 ? `, ${fileCountLabel(resolvedFileCount)}` : ''}`}
1859
2184
  >
1860
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
+ )}
1861
2191
  </button>
1862
2192
  )}
1863
2193
  {/* Right slot — rightmost end of toolbar */}
@@ -1897,7 +2227,7 @@ export function Toolbar({
1897
2227
  <div
1898
2228
  ref={insertMenuRef}
1899
2229
  className="squisq-insert-menu"
1900
- data-theme={theme}
2230
+ data-theme={colorScheme}
1901
2231
  style={{ position: 'fixed', top: insertMenuAnchor.top, left: insertMenuAnchor.left }}
1902
2232
  role="menu"
1903
2233
  >
@@ -1937,7 +2267,7 @@ export function Toolbar({
1937
2267
  onSelect={handleEmojiSelect}
1938
2268
  onClose={closeEmojiPicker}
1939
2269
  anchorRef={emojiButtonRef as React.RefObject<HTMLElement>}
1940
- theme={theme === 'dark' ? 'dark' : 'light'}
2270
+ theme={colorScheme === 'dark' ? 'dark' : 'light'}
1941
2271
  style={{
1942
2272
  position: 'fixed',
1943
2273
  top: emojiPickerAnchor.top,