@bendyline/squisq-editor-react 1.1.1 → 1.2.1

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,72 @@ 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
+ // ── Narrow-screen detection ──────────────────────────
154
+ const [isNarrow, setIsNarrow] = useState(
155
+ () => typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches,
156
+ );
157
+ useEffect(() => {
158
+ const mq = window.matchMedia('(max-width: 768px)');
159
+ const handler = (e: MediaQueryListEvent) => setIsNarrow(e.matches);
160
+ mq.addEventListener('change', handler);
161
+ return () => mq.removeEventListener('change', handler);
162
+ }, []);
163
+
164
+ // ── Overflow detection ────────────────────────────────
165
+ const actionsRef = useRef<HTMLDivElement>(null);
166
+ const [measuredOverflowIndex, setMeasuredOverflowIndex] = useState<number | null>(null);
167
+ const [showOverflow, setShowOverflow] = useState(false);
168
+ const overflowRef = useRef<HTMLDivElement>(null);
169
+
170
+ // On narrow screens, force all buttons into the overflow menu
171
+ const overflowIndex = isNarrow ? 0 : measuredOverflowIndex;
172
+
173
+ useEffect(() => {
174
+ if (isNarrow) return; // Skip measurement on narrow — everything overflows
175
+ const container = actionsRef.current;
176
+ if (!container) return;
177
+
178
+ const measure = () => {
179
+ const containerRight = container.getBoundingClientRect().right;
180
+ const children = container.querySelectorAll<HTMLElement>(
181
+ ':scope > .squisq-toolbar-group > .squisq-toolbar-button',
182
+ );
183
+ let firstHidden: number | null = null;
184
+ children.forEach((child, i) => {
185
+ if (firstHidden !== null) return;
186
+ const rect = child.getBoundingClientRect();
187
+ // A button is hidden if its right edge extends past the container
188
+ if (rect.right > containerRight + 2) {
189
+ firstHidden = i;
190
+ }
191
+ });
192
+ setMeasuredOverflowIndex(firstHidden);
193
+ };
194
+
195
+ const ro = new ResizeObserver(measure);
196
+ ro.observe(container);
197
+ measure();
198
+ return () => ro.disconnect();
199
+ }, [activeView, isNarrow]);
200
+
201
+ // Close overflow menu on outside click
202
+ useEffect(() => {
203
+ if (!showOverflow) return;
204
+ const handleClick = (e: MouseEvent) => {
205
+ if (overflowRef.current && !overflowRef.current.contains(e.target as Node)) {
206
+ setShowOverflow(false);
207
+ }
208
+ };
209
+ document.addEventListener('mousedown', handleClick);
210
+ return () => document.removeEventListener('mousedown', handleClick);
211
+ }, [showOverflow]);
212
+
144
213
  // Force re-render when Tiptap selection or formatting state changes
145
214
  const [, forceUpdate] = useReducer((c: number) => c + 1, 0);
146
215
  useEffect(() => {
@@ -202,6 +271,9 @@ export function Toolbar({
202
271
  }
203
272
  break;
204
273
  }
274
+ case 'table':
275
+ tiptapEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
276
+ break;
205
277
  }
206
278
  },
207
279
  [tiptapEditor],
@@ -296,6 +368,13 @@ export function Toolbar({
296
368
  }
297
369
  break;
298
370
  }
371
+ case 'table': {
372
+ const tpl =
373
+ '| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |';
374
+ replacement = '\n' + tpl + '\n';
375
+ newCursorOffset = 3; // after \n|
376
+ break;
377
+ }
299
378
  }
300
379
 
301
380
  // Apply the edit via Monaco's executeEdits for proper undo support
@@ -307,11 +386,6 @@ export function Toolbar({
307
386
  const startPos = model.getPositionAt(
308
387
  model.getOffsetAt(range.getStartPosition()) + newCursorOffset,
309
388
  );
310
- const _placeholderLen =
311
- replacement.length -
312
- newCursorOffset -
313
- (replacement.length -
314
- replacement.lastIndexOf(replacement.charAt(replacement.length - 1)));
315
389
  // Just place cursor after the prefix
316
390
  monacoEditor.setPosition(startPos);
317
391
  }
@@ -360,6 +434,10 @@ export function Toolbar({
360
434
  case 'link':
361
435
  insertion = '[link text](url)';
362
436
  break;
437
+ case 'table':
438
+ insertion =
439
+ '\n| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |\n';
440
+ break;
363
441
  }
364
442
  if (insertion) {
365
443
  setMarkdownSource(markdownSource + insertion);
@@ -369,8 +447,37 @@ export function Toolbar({
369
447
  [monacoEditor, markdownSource, setMarkdownSource],
370
448
  );
371
449
 
450
+ // ── Image upload handler ───────────────────────────────
451
+ const handleImageFile = useCallback(
452
+ async (file: File) => {
453
+ if (!mediaProvider) return;
454
+ const buffer = await file.arrayBuffer();
455
+ const relativePath = await mediaProvider.addMedia(file.name, buffer, file.type);
456
+ const altText = file.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
457
+
458
+ if (activeView === 'wysiwyg' && tiptapEditor) {
459
+ tiptapEditor.chain().focus().setImage({ src: relativePath, alt: altText }).run();
460
+ } else if (monacoEditor) {
461
+ const selection = monacoEditor.getSelection();
462
+ const model = monacoEditor.getModel();
463
+ if (selection && model) {
464
+ const md = `![${altText}](${relativePath})`;
465
+ monacoEditor.executeEdits('toolbar', [{ range: selection, text: md }]);
466
+ monacoEditor.focus();
467
+ }
468
+ } else {
469
+ setMarkdownSource(markdownSource + `\n![${altText}](${relativePath})\n`);
470
+ }
471
+ },
472
+ [mediaProvider, activeView, tiptapEditor, monacoEditor, markdownSource, setMarkdownSource],
473
+ );
474
+
372
475
  const handleAction = useCallback(
373
476
  (id: string) => {
477
+ if (id === 'image') {
478
+ imageInputRef.current?.click();
479
+ return;
480
+ }
374
481
  if (activeView === 'wysiwyg' && tiptapEditor) {
375
482
  handleTiptap(id);
376
483
  } else {
@@ -384,6 +491,9 @@ export function Toolbar({
384
491
  const isWysiwyg = activeView === 'wysiwyg' && tiptapEditor;
385
492
  const isPreview = activeView === 'preview';
386
493
 
494
+ // Detect whether cursor is inside a table (WYSIWYG mode only)
495
+ const isInTable = isWysiwyg ? tiptapEditor.isActive('table') : false;
496
+
387
497
  // Detect current heading template (WYSIWYG mode only)
388
498
  const currentTemplate = isWysiwyg
389
499
  ? tiptapEditor.isActive('heading')
@@ -405,14 +515,25 @@ export function Toolbar({
405
515
  }
406
516
  };
407
517
 
408
- const templateNames = getAvailableTemplates();
409
-
410
518
  return (
411
519
  <div
412
520
  className={`squisq-toolbar ${className || ''}`}
413
521
  role="toolbar"
414
522
  aria-label="Formatting toolbar"
415
523
  >
524
+ {/* Hidden file input for image picker */}
525
+ <input
526
+ ref={imageInputRef}
527
+ type="file"
528
+ accept="image/*"
529
+ style={{ display: 'none' }}
530
+ onChange={(e) => {
531
+ const file = e.target.files?.[0];
532
+ if (file) handleImageFile(file);
533
+ // Reset so the same file can be re-selected
534
+ e.target.value = '';
535
+ }}
536
+ />
416
537
  {/* Left slot — before view tabs */}
417
538
  {slotLeft}
418
539
  {/* View tabs */}
@@ -422,6 +543,7 @@ export function Toolbar({
422
543
  key={view.id}
423
544
  role="tab"
424
545
  data-view={view.id}
546
+ data-label={view.label}
425
547
  aria-selected={activeView === view.id}
426
548
  className={`squisq-toolbar-view-tab${activeView === view.id ? ' squisq-toolbar-view-tab--active' : ''}`}
427
549
  onClick={() => setActiveView(view.id)}
@@ -431,25 +553,45 @@ export function Toolbar({
431
553
  </button>
432
554
  ))}
433
555
  </div>
434
- {/* Formatting buttons — hidden in preview mode */}
435
- {!isPreview && (
436
- <div className="squisq-toolbar-actions">
556
+ {/* Formatting buttons — hidden in preview mode and on narrow screens */}
557
+ {!isPreview && !isNarrow && (
558
+ <div className="squisq-toolbar-actions" ref={actionsRef}>
437
559
  {groups.map((group, gi) => (
438
560
  <div key={group} className="squisq-toolbar-group">
439
561
  {gi > 0 && <div className="squisq-toolbar-separator" />}
440
562
  {BUTTONS.filter((b) => b.group === group).map((btn) => {
441
563
  const active = isWysiwyg ? isTiptapActive(tiptapEditor, btn.id) : false;
564
+ const disabled = btn.id === 'image' && !mediaProvider;
442
565
  return (
443
566
  <button
444
567
  key={btn.id}
445
568
  className={`squisq-toolbar-button${active ? ' squisq-toolbar-button--active' : ''}`}
446
- title={btn.title}
569
+ title={disabled ? 'Insert image (requires media provider)' : btn.title}
447
570
  onClick={() => handleAction(btn.id)}
448
571
  aria-label={btn.title}
449
572
  aria-pressed={active}
573
+ disabled={disabled}
450
574
  style={btn.iconStyle}
451
575
  >
452
- {btn.icon}
576
+ {btn.id === 'table' ? (
577
+ <svg
578
+ width="14"
579
+ height="14"
580
+ viewBox="0 0 14 14"
581
+ fill="none"
582
+ stroke="currentColor"
583
+ strokeWidth="1.4"
584
+ strokeLinecap="round"
585
+ >
586
+ <rect x="1" y="1" width="12" height="12" rx="1" />
587
+ <line x1="1" y1="5" x2="13" y2="5" />
588
+ <line x1="1" y1="9" x2="13" y2="9" />
589
+ <line x1="5" y1="1" x2="5" y2="13" />
590
+ <line x1="9" y1="1" x2="9" y2="13" />
591
+ </svg>
592
+ ) : (
593
+ btn.icon
594
+ )}
453
595
  </button>
454
596
  );
455
597
  })}
@@ -472,7 +614,7 @@ export function Toolbar({
472
614
  onChange={(e) => handleTemplatePick(e.target.value)}
473
615
  >
474
616
  <option value="">— none —</option>
475
- {templateNames.map((name) => (
617
+ {TEMPLATE_NAMES.map((name) => (
476
618
  <option key={name} value={name}>
477
619
  {name}
478
620
  </option>
@@ -482,8 +624,296 @@ export function Toolbar({
482
624
  </div>
483
625
  </>
484
626
  )}
627
+
628
+ {/* Table controls — visible when cursor is in a table (WYSIWYG) */}
629
+ {isInTable && (
630
+ <>
631
+ <div className="squisq-toolbar-separator" />
632
+ <div className="squisq-toolbar-group squisq-table-controls">
633
+ <span className="squisq-table-controls-label">Table:</span>
634
+ <button
635
+ className="squisq-toolbar-button"
636
+ title="Add column before"
637
+ onClick={() => tiptapEditor!.chain().focus().addColumnBefore().run()}
638
+ aria-label="Add column before"
639
+ >
640
+ <svg
641
+ width="16"
642
+ height="16"
643
+ viewBox="0 0 16 16"
644
+ fill="none"
645
+ stroke="currentColor"
646
+ strokeWidth="1.5"
647
+ strokeLinecap="round"
648
+ >
649
+ <rect x="7" y="2" width="8" height="12" rx="1" />
650
+ <line x1="11" y1="2" x2="11" y2="14" />
651
+ <line x1="1" y1="8" x2="4.5" y2="8" />
652
+ <line x1="2.75" y1="6.25" x2="2.75" y2="9.75" />
653
+ </svg>
654
+ </button>
655
+ <button
656
+ className="squisq-toolbar-button"
657
+ title="Add column after"
658
+ onClick={() => tiptapEditor!.chain().focus().addColumnAfter().run()}
659
+ aria-label="Add column after"
660
+ >
661
+ <svg
662
+ width="16"
663
+ height="16"
664
+ viewBox="0 0 16 16"
665
+ fill="none"
666
+ stroke="currentColor"
667
+ strokeWidth="1.5"
668
+ strokeLinecap="round"
669
+ >
670
+ <rect x="1" y="2" width="8" height="12" rx="1" />
671
+ <line x1="5" y1="2" x2="5" y2="14" />
672
+ <line x1="11.5" y1="8" x2="15" y2="8" />
673
+ <line x1="13.25" y1="6.25" x2="13.25" y2="9.75" />
674
+ </svg>
675
+ </button>
676
+ <button
677
+ className="squisq-toolbar-button"
678
+ title="Delete column"
679
+ onClick={() => tiptapEditor!.chain().focus().deleteColumn().run()}
680
+ aria-label="Delete column"
681
+ >
682
+ <svg
683
+ width="16"
684
+ height="16"
685
+ viewBox="0 0 16 16"
686
+ fill="none"
687
+ stroke="currentColor"
688
+ strokeWidth="1.5"
689
+ strokeLinecap="round"
690
+ >
691
+ <rect x="4" y="1" width="8" height="14" rx="1" />
692
+ <line x1="6" y1="5.5" x2="10" y2="10.5" />
693
+ <line x1="10" y1="5.5" x2="6" y2="10.5" />
694
+ </svg>
695
+ </button>
696
+ <button
697
+ className="squisq-toolbar-button"
698
+ title="Add row above"
699
+ onClick={() => tiptapEditor!.chain().focus().addRowBefore().run()}
700
+ aria-label="Add row above"
701
+ >
702
+ <svg
703
+ width="16"
704
+ height="16"
705
+ viewBox="0 0 16 16"
706
+ fill="none"
707
+ stroke="currentColor"
708
+ strokeWidth="1.5"
709
+ strokeLinecap="round"
710
+ >
711
+ <rect x="2" y="6" width="12" height="9" rx="1" />
712
+ <line x1="2" y1="10.5" x2="14" y2="10.5" />
713
+ <line x1="8" y1="1" x2="8" y2="4.5" />
714
+ <line x1="6.25" y1="2.75" x2="9.75" y2="2.75" />
715
+ </svg>
716
+ </button>
717
+ <button
718
+ className="squisq-toolbar-button"
719
+ title="Add row below"
720
+ onClick={() => tiptapEditor!.chain().focus().addRowAfter().run()}
721
+ aria-label="Add row below"
722
+ >
723
+ <svg
724
+ width="16"
725
+ height="16"
726
+ viewBox="0 0 16 16"
727
+ fill="none"
728
+ stroke="currentColor"
729
+ strokeWidth="1.5"
730
+ strokeLinecap="round"
731
+ >
732
+ <rect x="2" y="1" width="12" height="9" rx="1" />
733
+ <line x1="2" y1="5.5" x2="14" y2="5.5" />
734
+ <line x1="8" y1="11.5" x2="8" y2="15" />
735
+ <line x1="6.25" y1="13.25" x2="9.75" y2="13.25" />
736
+ </svg>
737
+ </button>
738
+ <button
739
+ className="squisq-toolbar-button"
740
+ title="Delete row"
741
+ onClick={() => tiptapEditor!.chain().focus().deleteRow().run()}
742
+ aria-label="Delete row"
743
+ >
744
+ <svg
745
+ width="16"
746
+ height="16"
747
+ viewBox="0 0 16 16"
748
+ fill="none"
749
+ stroke="currentColor"
750
+ strokeWidth="1.5"
751
+ strokeLinecap="round"
752
+ >
753
+ <rect x="1" y="4" width="14" height="8" rx="1" />
754
+ <line x1="5.5" y1="6" x2="10.5" y2="10" />
755
+ <line x1="10.5" y1="6" x2="5.5" y2="10" />
756
+ </svg>
757
+ </button>
758
+ <button
759
+ className="squisq-toolbar-button squisq-toolbar-button--danger"
760
+ title="Delete table"
761
+ onClick={() => tiptapEditor!.chain().focus().deleteTable().run()}
762
+ aria-label="Delete table"
763
+ >
764
+ <svg
765
+ width="16"
766
+ height="16"
767
+ viewBox="0 0 16 16"
768
+ fill="none"
769
+ stroke="currentColor"
770
+ strokeWidth="1.5"
771
+ strokeLinecap="round"
772
+ >
773
+ <rect x="1" y="1" width="14" height="14" rx="1" />
774
+ <line x1="1" y1="5.5" x2="15" y2="5.5" />
775
+ <line x1="5.5" y1="1" x2="5.5" y2="15" />
776
+ <line x1="4.5" y1="4.5" x2="11.5" y2="11.5" strokeWidth="2" />
777
+ <line x1="11.5" y1="4.5" x2="4.5" y2="11.5" strokeWidth="2" />
778
+ </svg>
779
+ </button>
780
+ </div>
781
+ </>
782
+ )}
783
+ </div>
784
+ )}
785
+
786
+ {/* Overflow menu — outside the overflow:hidden actions container */}
787
+ {!isPreview && overflowIndex !== null && (
788
+ <div className="squisq-toolbar-overflow" ref={overflowRef}>
789
+ <button
790
+ className={`squisq-toolbar-button squisq-toolbar-overflow-trigger${showOverflow ? ' squisq-toolbar-button--active' : ''}`}
791
+ title="More actions"
792
+ onClick={() => setShowOverflow((v) => !v)}
793
+ aria-label="More actions"
794
+ aria-expanded={showOverflow}
795
+ >
796
+ ···
797
+ </button>
798
+ {showOverflow && (
799
+ <div className="squisq-toolbar-overflow-menu">
800
+ {BUTTONS.slice(overflowIndex).map((btn) => {
801
+ const active = isWysiwyg ? isTiptapActive(tiptapEditor, btn.id) : false;
802
+ const disabled = btn.id === 'image' && !mediaProvider;
803
+ return (
804
+ <button
805
+ key={btn.id}
806
+ className={`squisq-toolbar-overflow-item${active ? ' squisq-toolbar-overflow-item--active' : ''}`}
807
+ title={btn.title}
808
+ onClick={() => {
809
+ handleAction(btn.id);
810
+ setShowOverflow(false);
811
+ }}
812
+ disabled={disabled}
813
+ >
814
+ {btn.id === 'table' ? (
815
+ <svg
816
+ width="14"
817
+ height="14"
818
+ viewBox="0 0 14 14"
819
+ fill="none"
820
+ stroke="currentColor"
821
+ strokeWidth="1.4"
822
+ strokeLinecap="round"
823
+ >
824
+ <rect x="1" y="1" width="12" height="12" rx="1" />
825
+ <line x1="1" y1="5" x2="13" y2="5" />
826
+ <line x1="1" y1="9" x2="13" y2="9" />
827
+ <line x1="5" y1="1" x2="5" y2="13" />
828
+ <line x1="9" y1="1" x2="9" y2="13" />
829
+ </svg>
830
+ ) : (
831
+ <span className="squisq-toolbar-overflow-icon" style={btn.iconStyle}>
832
+ {btn.icon}
833
+ </span>
834
+ )}
835
+ <span>{btn.title}</span>
836
+ </button>
837
+ );
838
+ })}
839
+
840
+ {/* Contextual: template picker in overflow */}
841
+ {currentTemplate !== null && (
842
+ <div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
843
+ <span>Template:</span>
844
+ <select
845
+ className="squisq-template-picker-select"
846
+ value={currentTemplate}
847
+ onChange={(e) => {
848
+ handleTemplatePick(e.target.value);
849
+ setShowOverflow(false);
850
+ }}
851
+ >
852
+ <option value="">— none —</option>
853
+ {TEMPLATE_NAMES.map((name) => (
854
+ <option key={name} value={name}>
855
+ {name}
856
+ </option>
857
+ ))}
858
+ </select>
859
+ </div>
860
+ )}
861
+
862
+ {/* Contextual: table controls in overflow */}
863
+ {isInTable && (
864
+ <>
865
+ <div
866
+ className="squisq-toolbar-separator"
867
+ style={{ margin: '4px 0', width: '100%', height: 1 }}
868
+ />
869
+ {[
870
+ {
871
+ label: 'Add column before',
872
+ action: () => tiptapEditor!.chain().focus().addColumnBefore().run(),
873
+ },
874
+ {
875
+ label: 'Add column after',
876
+ action: () => tiptapEditor!.chain().focus().addColumnAfter().run(),
877
+ },
878
+ {
879
+ label: 'Delete column',
880
+ action: () => tiptapEditor!.chain().focus().deleteColumn().run(),
881
+ },
882
+ {
883
+ label: 'Add row above',
884
+ action: () => tiptapEditor!.chain().focus().addRowBefore().run(),
885
+ },
886
+ {
887
+ label: 'Add row below',
888
+ action: () => tiptapEditor!.chain().focus().addRowAfter().run(),
889
+ },
890
+ {
891
+ label: 'Delete row',
892
+ action: () => tiptapEditor!.chain().focus().deleteRow().run(),
893
+ },
894
+ {
895
+ label: 'Delete table',
896
+ action: () => tiptapEditor!.chain().focus().deleteTable().run(),
897
+ },
898
+ ].map((item) => (
899
+ <button
900
+ key={item.label}
901
+ className={`squisq-toolbar-overflow-item${item.label.startsWith('Delete') ? ' squisq-toolbar-overflow-item--danger' : ''}`}
902
+ onClick={() => {
903
+ item.action();
904
+ setShowOverflow(false);
905
+ }}
906
+ >
907
+ <span>{item.label}</span>
908
+ </button>
909
+ ))}
910
+ </>
911
+ )}
912
+ </div>
913
+ )}
485
914
  </div>
486
915
  )}
916
+
487
917
  {/* After-actions slot — after formatting controls */}
488
918
  {slotAfterActions}
489
919
  {/* Spacer pushes right-side buttons to the end */}