@bendyline/squisq-editor-react 1.1.0 → 1.2.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 (50) hide show
  1. package/dist/EditorContext.d.ts +6 -2
  2. package/dist/EditorContext.d.ts.map +1 -1
  3. package/dist/EditorContext.js +3 -1
  4. package/dist/EditorContext.js.map +1 -1
  5. package/dist/EditorShell.d.ts +11 -1
  6. package/dist/EditorShell.d.ts.map +1 -1
  7. package/dist/EditorShell.js +9 -7
  8. package/dist/EditorShell.js.map +1 -1
  9. package/dist/ImageNodeView.d.ts +15 -0
  10. package/dist/ImageNodeView.d.ts.map +1 -0
  11. package/dist/ImageNodeView.js +52 -0
  12. package/dist/ImageNodeView.js.map +1 -0
  13. package/dist/PreviewControls.d.ts +41 -0
  14. package/dist/PreviewControls.d.ts.map +1 -0
  15. package/dist/PreviewControls.js +201 -0
  16. package/dist/PreviewControls.js.map +1 -0
  17. package/dist/PreviewPanel.d.ts +7 -7
  18. package/dist/PreviewPanel.d.ts.map +1 -1
  19. package/dist/PreviewPanel.js +183 -199
  20. package/dist/PreviewPanel.js.map +1 -1
  21. package/dist/Toolbar.d.ts +8 -1
  22. package/dist/Toolbar.d.ts.map +1 -1
  23. package/dist/Toolbar.js +145 -20
  24. package/dist/Toolbar.js.map +1 -1
  25. package/dist/WysiwygEditor.d.ts.map +1 -1
  26. package/dist/WysiwygEditor.js +3 -1
  27. package/dist/WysiwygEditor.js.map +1 -1
  28. package/dist/__tests__/tiptapBridge.test.d.ts +2 -0
  29. package/dist/__tests__/tiptapBridge.test.d.ts.map +1 -0
  30. package/dist/__tests__/tiptapBridge.test.js +241 -0
  31. package/dist/__tests__/tiptapBridge.test.js.map +1 -0
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/tiptapBridge.d.ts.map +1 -1
  37. package/dist/tiptapBridge.js +146 -5
  38. package/dist/tiptapBridge.js.map +1 -1
  39. package/package.json +5 -4
  40. package/src/EditorContext.tsx +8 -1
  41. package/src/EditorShell.tsx +71 -32
  42. package/src/ImageNodeView.tsx +70 -0
  43. package/src/PreviewControls.tsx +340 -0
  44. package/src/PreviewPanel.tsx +216 -287
  45. package/src/Toolbar.tsx +449 -17
  46. package/src/WysiwygEditor.tsx +3 -1
  47. package/src/__tests__/tiptapBridge.test.ts +290 -0
  48. package/src/index.ts +6 -0
  49. package/src/styles/editor.css +257 -16
  50. package/src/tiptapBridge.ts +164 -6
package/src/Toolbar.tsx CHANGED
@@ -7,15 +7,19 @@
7
7
  * Hidden in Preview mode.
8
8
  */
9
9
 
10
- import { useCallback, useEffect, useReducer } from 'react';
10
+ import type { ReactNode } from 'react';
11
+ import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
11
12
  import type { Editor as TiptapEditor } from '@tiptap/core';
12
13
  import { useEditorContext, type EditorView } from './EditorContext';
13
14
  import { getAvailableTemplates } from '@bendyline/squisq/doc';
14
15
 
16
+ /** Template names are static — computed once at module load. */
17
+ const TEMPLATE_NAMES = getAvailableTemplates();
18
+
15
19
  const VIEWS: { id: EditorView; label: string; shortcut: string }[] = [
16
20
  { id: 'wysiwyg', label: 'Editor', shortcut: '⌘1' },
17
21
  { id: 'raw', label: 'Raw', shortcut: '⌘2' },
18
- { id: 'preview', label: 'Preview', shortcut: '⌘3' },
22
+ { id: 'preview', label: 'Play', shortcut: '⌘3' },
19
23
  ];
20
24
 
21
25
  export interface ToolbarProps {
@@ -25,6 +29,12 @@ export interface ToolbarProps {
25
29
  showFiles?: boolean;
26
30
  /** Toggle the Files panel. When provided, a "Files" button appears in the toolbar. */
27
31
  onToggleFiles?: () => void;
32
+ /** Content rendered at the left edge of the toolbar, before the view tabs. */
33
+ slotLeft?: ReactNode;
34
+ /** Content rendered after the formatting controls (in the middle area). */
35
+ slotAfterActions?: ReactNode;
36
+ /** Content rendered at the rightmost end of the toolbar, after all other elements. */
37
+ slotRight?: ReactNode;
28
38
  }
29
39
 
30
40
  interface ToolbarButton {
@@ -77,6 +87,8 @@ const BUTTONS: ToolbarButton[] = [
77
87
  { id: 'codeblock', label: '{ }', icon: '{ }', title: 'Code block', group: 'insert' },
78
88
  { id: 'hr', label: '—', icon: '—', title: 'Horizontal rule', group: 'insert' },
79
89
  { id: 'link', label: '🔗', icon: '🔗', title: 'Insert link', group: 'insert' },
90
+ { id: 'table', label: 'table', icon: '', title: 'Insert table', group: 'insert' },
91
+ { id: 'image', label: '🖼', icon: '🖼', title: 'Insert image', group: 'insert' },
80
92
  ];
81
93
 
82
94
  // ─── Tiptap active-state map ────────────────────────────
@@ -117,7 +129,14 @@ function isTiptapActive(editor: TiptapEditor, id: string): boolean {
117
129
  * - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
118
130
  * - Raw: appends markdown syntax to the source
119
131
  */
120
- export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
132
+ export function Toolbar({
133
+ className,
134
+ showFiles,
135
+ onToggleFiles,
136
+ slotLeft,
137
+ slotAfterActions,
138
+ slotRight,
139
+ }: ToolbarProps) {
121
140
  const {
122
141
  activeView,
123
142
  setActiveView,
@@ -125,8 +144,57 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
125
144
  setMarkdownSource,
126
145
  tiptapEditor,
127
146
  monacoEditor,
147
+ mediaProvider,
128
148
  } = useEditorContext();
129
149
 
150
+ // Hidden file input for image picker
151
+ const imageInputRef = useRef<HTMLInputElement>(null);
152
+
153
+ // ── Overflow detection ────────────────────────────────
154
+ const actionsRef = useRef<HTMLDivElement>(null);
155
+ const [overflowIndex, setOverflowIndex] = useState<number | null>(null);
156
+ const [showOverflow, setShowOverflow] = useState(false);
157
+ const overflowRef = useRef<HTMLDivElement>(null);
158
+
159
+ useEffect(() => {
160
+ const container = actionsRef.current;
161
+ if (!container) return;
162
+
163
+ const measure = () => {
164
+ const containerRight = container.getBoundingClientRect().right;
165
+ const children = container.querySelectorAll<HTMLElement>(
166
+ ':scope > .squisq-toolbar-group > .squisq-toolbar-button',
167
+ );
168
+ let firstHidden: number | null = null;
169
+ children.forEach((child, i) => {
170
+ if (firstHidden !== null) return;
171
+ const rect = child.getBoundingClientRect();
172
+ // A button is hidden if its right edge extends past the container
173
+ if (rect.right > containerRight + 2) {
174
+ firstHidden = i;
175
+ }
176
+ });
177
+ setOverflowIndex(firstHidden);
178
+ };
179
+
180
+ const ro = new ResizeObserver(measure);
181
+ ro.observe(container);
182
+ measure();
183
+ return () => ro.disconnect();
184
+ }, [activeView]);
185
+
186
+ // Close overflow menu on outside click
187
+ useEffect(() => {
188
+ if (!showOverflow) return;
189
+ const handleClick = (e: MouseEvent) => {
190
+ if (overflowRef.current && !overflowRef.current.contains(e.target as Node)) {
191
+ setShowOverflow(false);
192
+ }
193
+ };
194
+ document.addEventListener('mousedown', handleClick);
195
+ return () => document.removeEventListener('mousedown', handleClick);
196
+ }, [showOverflow]);
197
+
130
198
  // Force re-render when Tiptap selection or formatting state changes
131
199
  const [, forceUpdate] = useReducer((c: number) => c + 1, 0);
132
200
  useEffect(() => {
@@ -188,6 +256,9 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
188
256
  }
189
257
  break;
190
258
  }
259
+ case 'table':
260
+ tiptapEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
261
+ break;
191
262
  }
192
263
  },
193
264
  [tiptapEditor],
@@ -282,6 +353,13 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
282
353
  }
283
354
  break;
284
355
  }
356
+ case 'table': {
357
+ const tpl =
358
+ '| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |';
359
+ replacement = '\n' + tpl + '\n';
360
+ newCursorOffset = 3; // after \n|
361
+ break;
362
+ }
285
363
  }
286
364
 
287
365
  // Apply the edit via Monaco's executeEdits for proper undo support
@@ -293,11 +371,6 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
293
371
  const startPos = model.getPositionAt(
294
372
  model.getOffsetAt(range.getStartPosition()) + newCursorOffset,
295
373
  );
296
- const _placeholderLen =
297
- replacement.length -
298
- newCursorOffset -
299
- (replacement.length -
300
- replacement.lastIndexOf(replacement.charAt(replacement.length - 1)));
301
374
  // Just place cursor after the prefix
302
375
  monacoEditor.setPosition(startPos);
303
376
  }
@@ -346,6 +419,10 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
346
419
  case 'link':
347
420
  insertion = '[link text](url)';
348
421
  break;
422
+ case 'table':
423
+ insertion =
424
+ '\n| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |\n';
425
+ break;
349
426
  }
350
427
  if (insertion) {
351
428
  setMarkdownSource(markdownSource + insertion);
@@ -355,8 +432,37 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
355
432
  [monacoEditor, markdownSource, setMarkdownSource],
356
433
  );
357
434
 
435
+ // ── Image upload handler ───────────────────────────────
436
+ const handleImageFile = useCallback(
437
+ async (file: File) => {
438
+ if (!mediaProvider) return;
439
+ const buffer = await file.arrayBuffer();
440
+ const relativePath = await mediaProvider.addMedia(file.name, buffer, file.type);
441
+ const altText = file.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
442
+
443
+ if (activeView === 'wysiwyg' && tiptapEditor) {
444
+ tiptapEditor.chain().focus().setImage({ src: relativePath, alt: altText }).run();
445
+ } else if (monacoEditor) {
446
+ const selection = monacoEditor.getSelection();
447
+ const model = monacoEditor.getModel();
448
+ if (selection && model) {
449
+ const md = `![${altText}](${relativePath})`;
450
+ monacoEditor.executeEdits('toolbar', [{ range: selection, text: md }]);
451
+ monacoEditor.focus();
452
+ }
453
+ } else {
454
+ setMarkdownSource(markdownSource + `\n![${altText}](${relativePath})\n`);
455
+ }
456
+ },
457
+ [mediaProvider, activeView, tiptapEditor, monacoEditor, markdownSource, setMarkdownSource],
458
+ );
459
+
358
460
  const handleAction = useCallback(
359
461
  (id: string) => {
462
+ if (id === 'image') {
463
+ imageInputRef.current?.click();
464
+ return;
465
+ }
360
466
  if (activeView === 'wysiwyg' && tiptapEditor) {
361
467
  handleTiptap(id);
362
468
  } else {
@@ -370,6 +476,9 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
370
476
  const isWysiwyg = activeView === 'wysiwyg' && tiptapEditor;
371
477
  const isPreview = activeView === 'preview';
372
478
 
479
+ // Detect whether cursor is inside a table (WYSIWYG mode only)
480
+ const isInTable = isWysiwyg ? tiptapEditor.isActive('table') : false;
481
+
373
482
  // Detect current heading template (WYSIWYG mode only)
374
483
  const currentTemplate = isWysiwyg
375
484
  ? tiptapEditor.isActive('heading')
@@ -391,14 +500,27 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
391
500
  }
392
501
  };
393
502
 
394
- const templateNames = getAvailableTemplates();
395
-
396
503
  return (
397
504
  <div
398
505
  className={`squisq-toolbar ${className || ''}`}
399
506
  role="toolbar"
400
507
  aria-label="Formatting toolbar"
401
508
  >
509
+ {/* Hidden file input for image picker */}
510
+ <input
511
+ ref={imageInputRef}
512
+ type="file"
513
+ accept="image/*"
514
+ style={{ display: 'none' }}
515
+ onChange={(e) => {
516
+ const file = e.target.files?.[0];
517
+ if (file) handleImageFile(file);
518
+ // Reset so the same file can be re-selected
519
+ e.target.value = '';
520
+ }}
521
+ />
522
+ {/* Left slot — before view tabs */}
523
+ {slotLeft}
402
524
  {/* View tabs */}
403
525
  <div className="squisq-toolbar-view-tabs" role="tablist" aria-label="Editor view">
404
526
  {VIEWS.map((view) => (
@@ -406,6 +528,7 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
406
528
  key={view.id}
407
529
  role="tab"
408
530
  data-view={view.id}
531
+ data-label={view.label}
409
532
  aria-selected={activeView === view.id}
410
533
  className={`squisq-toolbar-view-tab${activeView === view.id ? ' squisq-toolbar-view-tab--active' : ''}`}
411
534
  onClick={() => setActiveView(view.id)}
@@ -415,26 +538,45 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
415
538
  </button>
416
539
  ))}
417
540
  </div>
418
-
419
541
  {/* Formatting buttons — hidden in preview mode */}
420
542
  {!isPreview && (
421
- <div className="squisq-toolbar-actions">
543
+ <div className="squisq-toolbar-actions" ref={actionsRef}>
422
544
  {groups.map((group, gi) => (
423
545
  <div key={group} className="squisq-toolbar-group">
424
546
  {gi > 0 && <div className="squisq-toolbar-separator" />}
425
547
  {BUTTONS.filter((b) => b.group === group).map((btn) => {
426
548
  const active = isWysiwyg ? isTiptapActive(tiptapEditor, btn.id) : false;
549
+ const disabled = btn.id === 'image' && !mediaProvider;
427
550
  return (
428
551
  <button
429
552
  key={btn.id}
430
553
  className={`squisq-toolbar-button${active ? ' squisq-toolbar-button--active' : ''}`}
431
- title={btn.title}
554
+ title={disabled ? 'Insert image (requires media provider)' : btn.title}
432
555
  onClick={() => handleAction(btn.id)}
433
556
  aria-label={btn.title}
434
557
  aria-pressed={active}
558
+ disabled={disabled}
435
559
  style={btn.iconStyle}
436
560
  >
437
- {btn.icon}
561
+ {btn.id === 'table' ? (
562
+ <svg
563
+ width="14"
564
+ height="14"
565
+ viewBox="0 0 14 14"
566
+ fill="none"
567
+ stroke="currentColor"
568
+ strokeWidth="1.4"
569
+ strokeLinecap="round"
570
+ >
571
+ <rect x="1" y="1" width="12" height="12" rx="1" />
572
+ <line x1="1" y1="5" x2="13" y2="5" />
573
+ <line x1="1" y1="9" x2="13" y2="9" />
574
+ <line x1="5" y1="1" x2="5" y2="13" />
575
+ <line x1="9" y1="1" x2="9" y2="13" />
576
+ </svg>
577
+ ) : (
578
+ btn.icon
579
+ )}
438
580
  </button>
439
581
  );
440
582
  })}
@@ -457,7 +599,7 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
457
599
  onChange={(e) => handleTemplatePick(e.target.value)}
458
600
  >
459
601
  <option value="">— none —</option>
460
- {templateNames.map((name) => (
602
+ {TEMPLATE_NAMES.map((name) => (
461
603
  <option key={name} value={name}>
462
604
  {name}
463
605
  </option>
@@ -467,12 +609,300 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
467
609
  </div>
468
610
  </>
469
611
  )}
612
+
613
+ {/* Table controls — visible when cursor is in a table (WYSIWYG) */}
614
+ {isInTable && (
615
+ <>
616
+ <div className="squisq-toolbar-separator" />
617
+ <div className="squisq-toolbar-group squisq-table-controls">
618
+ <span className="squisq-table-controls-label">Table:</span>
619
+ <button
620
+ className="squisq-toolbar-button"
621
+ title="Add column before"
622
+ onClick={() => tiptapEditor!.chain().focus().addColumnBefore().run()}
623
+ aria-label="Add column before"
624
+ >
625
+ <svg
626
+ width="16"
627
+ height="16"
628
+ viewBox="0 0 16 16"
629
+ fill="none"
630
+ stroke="currentColor"
631
+ strokeWidth="1.5"
632
+ strokeLinecap="round"
633
+ >
634
+ <rect x="7" y="2" width="8" height="12" rx="1" />
635
+ <line x1="11" y1="2" x2="11" y2="14" />
636
+ <line x1="1" y1="8" x2="4.5" y2="8" />
637
+ <line x1="2.75" y1="6.25" x2="2.75" y2="9.75" />
638
+ </svg>
639
+ </button>
640
+ <button
641
+ className="squisq-toolbar-button"
642
+ title="Add column after"
643
+ onClick={() => tiptapEditor!.chain().focus().addColumnAfter().run()}
644
+ aria-label="Add column after"
645
+ >
646
+ <svg
647
+ width="16"
648
+ height="16"
649
+ viewBox="0 0 16 16"
650
+ fill="none"
651
+ stroke="currentColor"
652
+ strokeWidth="1.5"
653
+ strokeLinecap="round"
654
+ >
655
+ <rect x="1" y="2" width="8" height="12" rx="1" />
656
+ <line x1="5" y1="2" x2="5" y2="14" />
657
+ <line x1="11.5" y1="8" x2="15" y2="8" />
658
+ <line x1="13.25" y1="6.25" x2="13.25" y2="9.75" />
659
+ </svg>
660
+ </button>
661
+ <button
662
+ className="squisq-toolbar-button"
663
+ title="Delete column"
664
+ onClick={() => tiptapEditor!.chain().focus().deleteColumn().run()}
665
+ aria-label="Delete column"
666
+ >
667
+ <svg
668
+ width="16"
669
+ height="16"
670
+ viewBox="0 0 16 16"
671
+ fill="none"
672
+ stroke="currentColor"
673
+ strokeWidth="1.5"
674
+ strokeLinecap="round"
675
+ >
676
+ <rect x="4" y="1" width="8" height="14" rx="1" />
677
+ <line x1="6" y1="5.5" x2="10" y2="10.5" />
678
+ <line x1="10" y1="5.5" x2="6" y2="10.5" />
679
+ </svg>
680
+ </button>
681
+ <button
682
+ className="squisq-toolbar-button"
683
+ title="Add row above"
684
+ onClick={() => tiptapEditor!.chain().focus().addRowBefore().run()}
685
+ aria-label="Add row above"
686
+ >
687
+ <svg
688
+ width="16"
689
+ height="16"
690
+ viewBox="0 0 16 16"
691
+ fill="none"
692
+ stroke="currentColor"
693
+ strokeWidth="1.5"
694
+ strokeLinecap="round"
695
+ >
696
+ <rect x="2" y="6" width="12" height="9" rx="1" />
697
+ <line x1="2" y1="10.5" x2="14" y2="10.5" />
698
+ <line x1="8" y1="1" x2="8" y2="4.5" />
699
+ <line x1="6.25" y1="2.75" x2="9.75" y2="2.75" />
700
+ </svg>
701
+ </button>
702
+ <button
703
+ className="squisq-toolbar-button"
704
+ title="Add row below"
705
+ onClick={() => tiptapEditor!.chain().focus().addRowAfter().run()}
706
+ aria-label="Add row below"
707
+ >
708
+ <svg
709
+ width="16"
710
+ height="16"
711
+ viewBox="0 0 16 16"
712
+ fill="none"
713
+ stroke="currentColor"
714
+ strokeWidth="1.5"
715
+ strokeLinecap="round"
716
+ >
717
+ <rect x="2" y="1" width="12" height="9" rx="1" />
718
+ <line x1="2" y1="5.5" x2="14" y2="5.5" />
719
+ <line x1="8" y1="11.5" x2="8" y2="15" />
720
+ <line x1="6.25" y1="13.25" x2="9.75" y2="13.25" />
721
+ </svg>
722
+ </button>
723
+ <button
724
+ className="squisq-toolbar-button"
725
+ title="Delete row"
726
+ onClick={() => tiptapEditor!.chain().focus().deleteRow().run()}
727
+ aria-label="Delete row"
728
+ >
729
+ <svg
730
+ width="16"
731
+ height="16"
732
+ viewBox="0 0 16 16"
733
+ fill="none"
734
+ stroke="currentColor"
735
+ strokeWidth="1.5"
736
+ strokeLinecap="round"
737
+ >
738
+ <rect x="1" y="4" width="14" height="8" rx="1" />
739
+ <line x1="5.5" y1="6" x2="10.5" y2="10" />
740
+ <line x1="10.5" y1="6" x2="5.5" y2="10" />
741
+ </svg>
742
+ </button>
743
+ <button
744
+ className="squisq-toolbar-button squisq-toolbar-button--danger"
745
+ title="Delete table"
746
+ onClick={() => tiptapEditor!.chain().focus().deleteTable().run()}
747
+ aria-label="Delete table"
748
+ >
749
+ <svg
750
+ width="16"
751
+ height="16"
752
+ viewBox="0 0 16 16"
753
+ fill="none"
754
+ stroke="currentColor"
755
+ strokeWidth="1.5"
756
+ strokeLinecap="round"
757
+ >
758
+ <rect x="1" y="1" width="14" height="14" rx="1" />
759
+ <line x1="1" y1="5.5" x2="15" y2="5.5" />
760
+ <line x1="5.5" y1="1" x2="5.5" y2="15" />
761
+ <line x1="4.5" y1="4.5" x2="11.5" y2="11.5" strokeWidth="2" />
762
+ <line x1="11.5" y1="4.5" x2="4.5" y2="11.5" strokeWidth="2" />
763
+ </svg>
764
+ </button>
765
+ </div>
766
+ </>
767
+ )}
470
768
  </div>
471
769
  )}
472
770
 
473
- {/* Spacer pushes right-side buttons to the end */}
474
- {onToggleFiles && <div style={{ flex: 1 }} />}
771
+ {/* Overflow menu outside the overflow:hidden actions container */}
772
+ {!isPreview && overflowIndex !== null && (
773
+ <div className="squisq-toolbar-overflow" ref={overflowRef}>
774
+ <button
775
+ className={`squisq-toolbar-button squisq-toolbar-overflow-trigger${showOverflow ? ' squisq-toolbar-button--active' : ''}`}
776
+ title="More actions"
777
+ onClick={() => setShowOverflow((v) => !v)}
778
+ aria-label="More actions"
779
+ aria-expanded={showOverflow}
780
+ >
781
+ ···
782
+ </button>
783
+ {showOverflow && (
784
+ <div className="squisq-toolbar-overflow-menu">
785
+ {BUTTONS.slice(overflowIndex).map((btn) => {
786
+ const active = isWysiwyg ? isTiptapActive(tiptapEditor, btn.id) : false;
787
+ const disabled = btn.id === 'image' && !mediaProvider;
788
+ return (
789
+ <button
790
+ key={btn.id}
791
+ className={`squisq-toolbar-overflow-item${active ? ' squisq-toolbar-overflow-item--active' : ''}`}
792
+ title={btn.title}
793
+ onClick={() => {
794
+ handleAction(btn.id);
795
+ setShowOverflow(false);
796
+ }}
797
+ disabled={disabled}
798
+ >
799
+ {btn.id === 'table' ? (
800
+ <svg
801
+ width="14"
802
+ height="14"
803
+ viewBox="0 0 14 14"
804
+ fill="none"
805
+ stroke="currentColor"
806
+ strokeWidth="1.4"
807
+ strokeLinecap="round"
808
+ >
809
+ <rect x="1" y="1" width="12" height="12" rx="1" />
810
+ <line x1="1" y1="5" x2="13" y2="5" />
811
+ <line x1="1" y1="9" x2="13" y2="9" />
812
+ <line x1="5" y1="1" x2="5" y2="13" />
813
+ <line x1="9" y1="1" x2="9" y2="13" />
814
+ </svg>
815
+ ) : (
816
+ <span className="squisq-toolbar-overflow-icon" style={btn.iconStyle}>
817
+ {btn.icon}
818
+ </span>
819
+ )}
820
+ <span>{btn.title}</span>
821
+ </button>
822
+ );
823
+ })}
824
+
825
+ {/* Contextual: template picker in overflow */}
826
+ {currentTemplate !== null && (
827
+ <div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
828
+ <span>Template:</span>
829
+ <select
830
+ className="squisq-template-picker-select"
831
+ value={currentTemplate}
832
+ onChange={(e) => {
833
+ handleTemplatePick(e.target.value);
834
+ setShowOverflow(false);
835
+ }}
836
+ >
837
+ <option value="">— none —</option>
838
+ {TEMPLATE_NAMES.map((name) => (
839
+ <option key={name} value={name}>
840
+ {name}
841
+ </option>
842
+ ))}
843
+ </select>
844
+ </div>
845
+ )}
846
+
847
+ {/* Contextual: table controls in overflow */}
848
+ {isInTable && (
849
+ <>
850
+ <div
851
+ className="squisq-toolbar-separator"
852
+ style={{ margin: '4px 0', width: '100%', height: 1 }}
853
+ />
854
+ {[
855
+ {
856
+ label: 'Add column before',
857
+ action: () => tiptapEditor!.chain().focus().addColumnBefore().run(),
858
+ },
859
+ {
860
+ label: 'Add column after',
861
+ action: () => tiptapEditor!.chain().focus().addColumnAfter().run(),
862
+ },
863
+ {
864
+ label: 'Delete column',
865
+ action: () => tiptapEditor!.chain().focus().deleteColumn().run(),
866
+ },
867
+ {
868
+ label: 'Add row above',
869
+ action: () => tiptapEditor!.chain().focus().addRowBefore().run(),
870
+ },
871
+ {
872
+ label: 'Add row below',
873
+ action: () => tiptapEditor!.chain().focus().addRowAfter().run(),
874
+ },
875
+ {
876
+ label: 'Delete row',
877
+ action: () => tiptapEditor!.chain().focus().deleteRow().run(),
878
+ },
879
+ {
880
+ label: 'Delete table',
881
+ action: () => tiptapEditor!.chain().focus().deleteTable().run(),
882
+ },
883
+ ].map((item) => (
884
+ <button
885
+ key={item.label}
886
+ className={`squisq-toolbar-overflow-item${item.label.startsWith('Delete') ? ' squisq-toolbar-overflow-item--danger' : ''}`}
887
+ onClick={() => {
888
+ item.action();
889
+ setShowOverflow(false);
890
+ }}
891
+ >
892
+ <span>{item.label}</span>
893
+ </button>
894
+ ))}
895
+ </>
896
+ )}
897
+ </div>
898
+ )}
899
+ </div>
900
+ )}
475
901
 
902
+ {/* After-actions slot — after formatting controls */}
903
+ {slotAfterActions}
904
+ {/* Spacer pushes right-side buttons to the end */}
905
+ <div style={{ flex: 1 }} />
476
906
  {/* Files toggle — visible when callback is provided */}
477
907
  {onToggleFiles && (
478
908
  <button
@@ -485,6 +915,8 @@ export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
485
915
  {'\u{1F4CE}'}
486
916
  </button>
487
917
  )}
918
+ {/* Right slot — rightmost end of toolbar */}
919
+ {slotRight}
488
920
  </div>
489
921
  );
490
922
  }
@@ -21,6 +21,7 @@ import TaskList from '@tiptap/extension-task-list';
21
21
  import TaskItem from '@tiptap/extension-task-item';
22
22
  import Placeholder from '@tiptap/extension-placeholder';
23
23
  import { HeadingWithTemplate } from './TemplateAnnotation';
24
+ import { ImageWithMediaProvider } from './ImageNodeView';
24
25
  import { useEditorContext } from './EditorContext';
25
26
  import { markdownToTiptap, tiptapToMarkdown } from './tiptapBridge';
26
27
 
@@ -73,6 +74,7 @@ export function WysiwygEditor({
73
74
  TableHeader,
74
75
  TaskList,
75
76
  TaskItem.configure({ nested: true }),
77
+ ImageWithMediaProvider.configure({ inline: false }),
76
78
  Placeholder.configure({ placeholder }),
77
79
  ],
78
80
  content: markdownToTiptap(stripFrontmatter(markdownSource).body),
@@ -117,7 +119,7 @@ export function WysiwygEditor({
117
119
 
118
120
  return (
119
121
  <div
120
- className={className}
122
+ className={`squisq-wysiwyg-container${className ? ` ${className}` : ''}`}
121
123
  style={{ width: '100%', height: '100%', overflow: 'auto' }}
122
124
  data-testid="wysiwyg-container"
123
125
  >