@bendyline/squisq-editor-react 1.1.1 → 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.
package/src/Toolbar.tsx CHANGED
@@ -8,11 +8,14 @@
8
8
  */
9
9
 
10
10
  import type { ReactNode } from 'react';
11
- import { useCallback, useEffect, useReducer } from 'react';
11
+ import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
12
12
  import type { Editor as TiptapEditor } from '@tiptap/core';
13
13
  import { useEditorContext, type EditorView } from './EditorContext';
14
14
  import { getAvailableTemplates } from '@bendyline/squisq/doc';
15
15
 
16
+ /** Template names are static — computed once at module load. */
17
+ const TEMPLATE_NAMES = getAvailableTemplates();
18
+
16
19
  const VIEWS: { id: EditorView; label: string; shortcut: string }[] = [
17
20
  { id: 'wysiwyg', label: 'Editor', shortcut: '⌘1' },
18
21
  { id: 'raw', label: 'Raw', shortcut: '⌘2' },
@@ -84,6 +87,8 @@ const BUTTONS: ToolbarButton[] = [
84
87
  { id: 'codeblock', label: '{ }', icon: '{ }', title: 'Code block', group: 'insert' },
85
88
  { id: 'hr', label: '—', icon: '—', title: 'Horizontal rule', group: 'insert' },
86
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' },
87
92
  ];
88
93
 
89
94
  // ─── Tiptap active-state map ────────────────────────────
@@ -139,8 +144,57 @@ export function Toolbar({
139
144
  setMarkdownSource,
140
145
  tiptapEditor,
141
146
  monacoEditor,
147
+ mediaProvider,
142
148
  } = useEditorContext();
143
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
+
144
198
  // Force re-render when Tiptap selection or formatting state changes
145
199
  const [, forceUpdate] = useReducer((c: number) => c + 1, 0);
146
200
  useEffect(() => {
@@ -202,6 +256,9 @@ export function Toolbar({
202
256
  }
203
257
  break;
204
258
  }
259
+ case 'table':
260
+ tiptapEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
261
+ break;
205
262
  }
206
263
  },
207
264
  [tiptapEditor],
@@ -296,6 +353,13 @@ export function Toolbar({
296
353
  }
297
354
  break;
298
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
+ }
299
363
  }
300
364
 
301
365
  // Apply the edit via Monaco's executeEdits for proper undo support
@@ -307,11 +371,6 @@ export function Toolbar({
307
371
  const startPos = model.getPositionAt(
308
372
  model.getOffsetAt(range.getStartPosition()) + newCursorOffset,
309
373
  );
310
- const _placeholderLen =
311
- replacement.length -
312
- newCursorOffset -
313
- (replacement.length -
314
- replacement.lastIndexOf(replacement.charAt(replacement.length - 1)));
315
374
  // Just place cursor after the prefix
316
375
  monacoEditor.setPosition(startPos);
317
376
  }
@@ -360,6 +419,10 @@ export function Toolbar({
360
419
  case 'link':
361
420
  insertion = '[link text](url)';
362
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;
363
426
  }
364
427
  if (insertion) {
365
428
  setMarkdownSource(markdownSource + insertion);
@@ -369,8 +432,37 @@ export function Toolbar({
369
432
  [monacoEditor, markdownSource, setMarkdownSource],
370
433
  );
371
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
+
372
460
  const handleAction = useCallback(
373
461
  (id: string) => {
462
+ if (id === 'image') {
463
+ imageInputRef.current?.click();
464
+ return;
465
+ }
374
466
  if (activeView === 'wysiwyg' && tiptapEditor) {
375
467
  handleTiptap(id);
376
468
  } else {
@@ -384,6 +476,9 @@ export function Toolbar({
384
476
  const isWysiwyg = activeView === 'wysiwyg' && tiptapEditor;
385
477
  const isPreview = activeView === 'preview';
386
478
 
479
+ // Detect whether cursor is inside a table (WYSIWYG mode only)
480
+ const isInTable = isWysiwyg ? tiptapEditor.isActive('table') : false;
481
+
387
482
  // Detect current heading template (WYSIWYG mode only)
388
483
  const currentTemplate = isWysiwyg
389
484
  ? tiptapEditor.isActive('heading')
@@ -405,14 +500,25 @@ export function Toolbar({
405
500
  }
406
501
  };
407
502
 
408
- const templateNames = getAvailableTemplates();
409
-
410
503
  return (
411
504
  <div
412
505
  className={`squisq-toolbar ${className || ''}`}
413
506
  role="toolbar"
414
507
  aria-label="Formatting toolbar"
415
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
+ />
416
522
  {/* Left slot — before view tabs */}
417
523
  {slotLeft}
418
524
  {/* View tabs */}
@@ -422,6 +528,7 @@ export function Toolbar({
422
528
  key={view.id}
423
529
  role="tab"
424
530
  data-view={view.id}
531
+ data-label={view.label}
425
532
  aria-selected={activeView === view.id}
426
533
  className={`squisq-toolbar-view-tab${activeView === view.id ? ' squisq-toolbar-view-tab--active' : ''}`}
427
534
  onClick={() => setActiveView(view.id)}
@@ -433,23 +540,43 @@ export function Toolbar({
433
540
  </div>
434
541
  {/* Formatting buttons — hidden in preview mode */}
435
542
  {!isPreview && (
436
- <div className="squisq-toolbar-actions">
543
+ <div className="squisq-toolbar-actions" ref={actionsRef}>
437
544
  {groups.map((group, gi) => (
438
545
  <div key={group} className="squisq-toolbar-group">
439
546
  {gi > 0 && <div className="squisq-toolbar-separator" />}
440
547
  {BUTTONS.filter((b) => b.group === group).map((btn) => {
441
548
  const active = isWysiwyg ? isTiptapActive(tiptapEditor, btn.id) : false;
549
+ const disabled = btn.id === 'image' && !mediaProvider;
442
550
  return (
443
551
  <button
444
552
  key={btn.id}
445
553
  className={`squisq-toolbar-button${active ? ' squisq-toolbar-button--active' : ''}`}
446
- title={btn.title}
554
+ title={disabled ? 'Insert image (requires media provider)' : btn.title}
447
555
  onClick={() => handleAction(btn.id)}
448
556
  aria-label={btn.title}
449
557
  aria-pressed={active}
558
+ disabled={disabled}
450
559
  style={btn.iconStyle}
451
560
  >
452
- {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
+ )}
453
580
  </button>
454
581
  );
455
582
  })}
@@ -472,7 +599,7 @@ export function Toolbar({
472
599
  onChange={(e) => handleTemplatePick(e.target.value)}
473
600
  >
474
601
  <option value="">— none —</option>
475
- {templateNames.map((name) => (
602
+ {TEMPLATE_NAMES.map((name) => (
476
603
  <option key={name} value={name}>
477
604
  {name}
478
605
  </option>
@@ -482,8 +609,296 @@ export function Toolbar({
482
609
  </div>
483
610
  </>
484
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
+ )}
768
+ </div>
769
+ )}
770
+
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
+ )}
485
899
  </div>
486
900
  )}
901
+
487
902
  {/* After-actions slot — after formatting controls */}
488
903
  {slotAfterActions}
489
904
  {/* Spacer pushes right-side buttons to the end */}