@bendyline/squisq-editor-react 1.2.1 → 1.3.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 (105) hide show
  1. package/dist/EditorContext.d.ts +65 -1
  2. package/dist/EditorContext.d.ts.map +1 -1
  3. package/dist/EditorContext.js +31 -4
  4. package/dist/EditorContext.js.map +1 -1
  5. package/dist/EditorShell.d.ts +101 -2
  6. package/dist/EditorShell.d.ts.map +1 -1
  7. package/dist/EditorShell.js +20 -8
  8. package/dist/EditorShell.js.map +1 -1
  9. package/dist/ImageNodeView.d.ts.map +1 -1
  10. package/dist/ImageNodeView.js +12 -2
  11. package/dist/ImageNodeView.js.map +1 -1
  12. package/dist/MediaBin.d.ts.map +1 -1
  13. package/dist/MediaBin.js +16 -1
  14. package/dist/MediaBin.js.map +1 -1
  15. package/dist/MentionExtension.d.ts +22 -0
  16. package/dist/MentionExtension.d.ts.map +1 -0
  17. package/dist/MentionExtension.js +242 -0
  18. package/dist/MentionExtension.js.map +1 -0
  19. package/dist/PreviewPanel.d.ts +3 -8
  20. package/dist/PreviewPanel.d.ts.map +1 -1
  21. package/dist/PreviewPanel.js +4 -282
  22. package/dist/PreviewPanel.js.map +1 -1
  23. package/dist/RawEditor.d.ts +8 -1
  24. package/dist/RawEditor.d.ts.map +1 -1
  25. package/dist/RawEditor.js +167 -30
  26. package/dist/RawEditor.js.map +1 -1
  27. package/dist/TemplateAnnotation.d.ts.map +1 -1
  28. package/dist/TemplateAnnotation.js +4 -2
  29. package/dist/TemplateAnnotation.js.map +1 -1
  30. package/dist/Toolbar.d.ts +7 -1
  31. package/dist/Toolbar.d.ts.map +1 -1
  32. package/dist/Toolbar.js +57 -18
  33. package/dist/Toolbar.js.map +1 -1
  34. package/dist/Tooltip.d.ts +10 -0
  35. package/dist/Tooltip.d.ts.map +1 -0
  36. package/dist/Tooltip.js +104 -0
  37. package/dist/Tooltip.js.map +1 -0
  38. package/dist/ViewSwitcher.d.ts +1 -1
  39. package/dist/ViewSwitcher.d.ts.map +1 -1
  40. package/dist/ViewSwitcher.js +10 -4
  41. package/dist/ViewSwitcher.js.map +1 -1
  42. package/dist/WysiwygEditor.d.ts +13 -2
  43. package/dist/WysiwygEditor.d.ts.map +1 -1
  44. package/dist/WysiwygEditor.js +239 -4
  45. package/dist/WysiwygEditor.js.map +1 -1
  46. package/dist/__tests__/detectMarkdown.test.d.ts +2 -0
  47. package/dist/__tests__/detectMarkdown.test.d.ts.map +1 -0
  48. package/dist/__tests__/detectMarkdown.test.js +69 -0
  49. package/dist/__tests__/detectMarkdown.test.js.map +1 -0
  50. package/dist/__tests__/fileKind.test.d.ts +2 -0
  51. package/dist/__tests__/fileKind.test.d.ts.map +1 -0
  52. package/dist/__tests__/fileKind.test.js +81 -0
  53. package/dist/__tests__/fileKind.test.js.map +1 -0
  54. package/dist/__tests__/tiptapBridge.test.js +36 -0
  55. package/dist/__tests__/tiptapBridge.test.js.map +1 -1
  56. package/dist/buildPreviewDoc.d.ts +22 -0
  57. package/dist/buildPreviewDoc.d.ts.map +1 -0
  58. package/dist/buildPreviewDoc.js +212 -0
  59. package/dist/buildPreviewDoc.js.map +1 -0
  60. package/dist/detectMarkdown.d.ts +20 -0
  61. package/dist/detectMarkdown.d.ts.map +1 -0
  62. package/dist/detectMarkdown.js +61 -0
  63. package/dist/detectMarkdown.js.map +1 -0
  64. package/dist/fileKind.d.ts +30 -0
  65. package/dist/fileKind.d.ts.map +1 -0
  66. package/dist/fileKind.js +123 -0
  67. package/dist/fileKind.js.map +1 -0
  68. package/dist/hooks/useFileDrop.d.ts.map +1 -1
  69. package/dist/hooks/useFileDrop.js +9 -7
  70. package/dist/hooks/useFileDrop.js.map +1 -1
  71. package/dist/index.d.ts +5 -1
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +6 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/mediaDragMime.d.ts +17 -0
  76. package/dist/mediaDragMime.d.ts.map +1 -0
  77. package/dist/mediaDragMime.js +22 -0
  78. package/dist/mediaDragMime.js.map +1 -0
  79. package/dist/tiptapBridge.d.ts.map +1 -1
  80. package/dist/tiptapBridge.js +58 -2
  81. package/dist/tiptapBridge.js.map +1 -1
  82. package/package.json +9 -7
  83. package/src/EditorContext.tsx +106 -3
  84. package/src/EditorShell.tsx +195 -15
  85. package/src/ImageNodeView.tsx +15 -2
  86. package/src/MediaBin.tsx +23 -1
  87. package/src/MentionExtension.tsx +258 -0
  88. package/src/PreviewPanel.tsx +5 -333
  89. package/src/RawEditor.tsx +193 -37
  90. package/src/TemplateAnnotation.ts +4 -2
  91. package/src/Toolbar.tsx +111 -48
  92. package/src/Tooltip.tsx +124 -0
  93. package/src/ViewSwitcher.tsx +15 -5
  94. package/src/WysiwygEditor.tsx +270 -5
  95. package/src/__tests__/detectMarkdown.test.ts +88 -0
  96. package/src/__tests__/fileKind.test.ts +96 -0
  97. package/src/__tests__/tiptapBridge.test.ts +44 -0
  98. package/src/buildPreviewDoc.ts +254 -0
  99. package/src/detectMarkdown.ts +62 -0
  100. package/src/fileKind.ts +134 -0
  101. package/src/hooks/useFileDrop.ts +10 -6
  102. package/src/index.ts +14 -0
  103. package/src/mediaDragMime.ts +32 -0
  104. package/src/styles/editor.css +214 -8
  105. package/src/tiptapBridge.ts +66 -2
package/src/Toolbar.tsx CHANGED
@@ -16,9 +16,9 @@ import { getAvailableTemplates } from '@bendyline/squisq/doc';
16
16
  /** Template names are static — computed once at module load. */
17
17
  const TEMPLATE_NAMES = getAvailableTemplates();
18
18
 
19
- const VIEWS: { id: EditorView; label: string; shortcut: string }[] = [
19
+ const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
20
20
  { id: 'wysiwyg', label: 'Editor', shortcut: '⌘1' },
21
- { id: 'raw', label: 'Raw', shortcut: '⌘2' },
21
+ { id: 'raw', label: 'Markdown', shortLabel: 'MD', shortcut: '⌘2' },
22
22
  { id: 'preview', label: 'Play', shortcut: '⌘3' },
23
23
  ];
24
24
 
@@ -35,6 +35,12 @@ export interface ToolbarProps {
35
35
  slotAfterActions?: ReactNode;
36
36
  /** Content rendered at the rightmost end of the toolbar, after all other elements. */
37
37
  slotRight?: ReactNode;
38
+ /**
39
+ * Whether to include the "Play" (preview) tab in the view switcher.
40
+ * Defaults to true. Hosts that don't want the slideshow preview — e.g.
41
+ * editing free-form prompts — can pass false to suppress it.
42
+ */
43
+ showPlayTab?: boolean;
38
44
  }
39
45
 
40
46
  interface ToolbarButton {
@@ -42,13 +48,13 @@ interface ToolbarButton {
42
48
  label: string;
43
49
  icon: string;
44
50
  title: string;
45
- group: 'format' | 'structure' | 'insert';
51
+ group: 'format' | 'lists' | 'structure' | 'insert' | 'media';
46
52
  /** CSS font style for the icon (e.g. italic for the I button) */
47
53
  iconStyle?: React.CSSProperties;
48
54
  }
49
55
 
50
56
  const BUTTONS: ToolbarButton[] = [
51
- // Format group
57
+ // Format group — B/I/S trio.
52
58
  {
53
59
  id: 'bold',
54
60
  label: 'B',
@@ -73,22 +79,27 @@ const BUTTONS: ToolbarButton[] = [
73
79
  group: 'format',
74
80
  iconStyle: { textDecoration: 'line-through' },
75
81
  },
76
- { id: 'code', label: '<>', icon: '`', title: 'Inline code', group: 'format' },
82
+
83
+ // Lists group — sits between format and structure so bullets/numbers
84
+ // are adjacent to the inline formatters people reach for together.
85
+ { id: 'ul', label: '•', icon: '•', title: 'Bullet list', group: 'lists' },
86
+ { id: 'ol', label: '1.', icon: '1.', title: 'Numbered list', group: 'lists' },
77
87
 
78
88
  // Structure group
79
89
  { id: 'h1', label: 'H1', icon: 'H1', title: 'Heading 1', group: 'structure' },
80
90
  { id: 'h2', label: 'H2', icon: 'H2', title: 'Heading 2', group: 'structure' },
81
91
  { id: 'h3', label: 'H3', icon: 'H3', title: 'Heading 3', group: 'structure' },
82
- { id: 'quote', label: '❝', icon: '❝', title: 'Blockquote', group: 'structure' },
83
92
 
84
- // Insert group
85
- { id: 'ul', label: '', icon: '', title: 'Bullet list', group: 'insert' },
86
- { id: 'ol', label: '1.', icon: '1.', title: 'Numbered list', group: 'insert' },
93
+ // Insert group — block-level inserts (quote, code blocks, rules)
94
+ { id: 'quote', label: '', icon: '', title: 'Blockquote', group: 'insert' },
87
95
  { id: 'codeblock', label: '{ }', icon: '{ }', title: 'Code block', group: 'insert' },
96
+ { id: 'code', label: '</>', icon: '</>', title: 'Inline code', group: 'insert' },
88
97
  { id: 'hr', label: '—', icon: '—', title: 'Horizontal rule', group: 'insert' },
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' },
98
+
99
+ // Media group links, tables, images
100
+ { id: 'link', label: '🔗', icon: '🔗', title: 'Insert link', group: 'media' },
101
+ { id: 'table', label: 'table', icon: '', title: 'Insert table', group: 'media' },
102
+ { id: 'image', label: '🖼', icon: '🖼', title: 'Insert image', group: 'media' },
92
103
  ];
93
104
 
94
105
  // ─── Tiptap active-state map ────────────────────────────
@@ -136,6 +147,7 @@ export function Toolbar({
136
147
  slotLeft,
137
148
  slotAfterActions,
138
149
  slotRight,
150
+ showPlayTab = true,
139
151
  }: ToolbarProps) {
140
152
  const {
141
153
  activeView,
@@ -145,7 +157,17 @@ export function Toolbar({
145
157
  tiptapEditor,
146
158
  monacoEditor,
147
159
  mediaProvider,
160
+ editorMode,
148
161
  } = useEditorContext();
162
+ const isCodeMode = editorMode === 'code';
163
+ // In code mode only the raw view is meaningful; the WYSIWYG and Preview
164
+ // surfaces aren't mounted, so hide their tabs.
165
+ const visibleViews = VIEWS.filter((v) => {
166
+ if (isCodeMode) return v.id === 'raw';
167
+ if (v.id === 'preview' && !showPlayTab) return false;
168
+ return true;
169
+ });
170
+ const showViewTabs = visibleViews.length > 1;
149
171
 
150
172
  // Hidden file input for image picker
151
173
  const imageInputRef = useRef<HTMLInputElement>(null);
@@ -210,6 +232,31 @@ export function Toolbar({
210
232
  return () => document.removeEventListener('mousedown', handleClick);
211
233
  }, [showOverflow]);
212
234
 
235
+ // Open-up vs open-down: the overflow menu is anchored to its trigger with
236
+ // `top: 100%` by default. When the toolbar lives near the bottom of a
237
+ // small container (e.g. a chat composer), a downward menu gets clipped.
238
+ // Measure on open and flip the anchor to `bottom: 100%` if the space
239
+ // above the trigger is larger than the space below.
240
+ const [overflowPlacement, setOverflowPlacement] = useState<'down' | 'up'>('down');
241
+ useEffect(() => {
242
+ if (!showOverflow || !overflowRef.current) return;
243
+ const trigger = overflowRef.current.querySelector<HTMLElement>(
244
+ '.squisq-toolbar-overflow-trigger',
245
+ );
246
+ if (!trigger) return;
247
+ const rect = trigger.getBoundingClientRect();
248
+ const spaceBelow = window.innerHeight - rect.bottom;
249
+ const spaceAbove = rect.top;
250
+ // Assume a typical menu height; exact measurement is unreliable on first
251
+ // open because the menu hasn't rendered yet when this runs.
252
+ const ESTIMATED_MENU_HEIGHT = 260;
253
+ if (spaceBelow < ESTIMATED_MENU_HEIGHT && spaceAbove > spaceBelow) {
254
+ setOverflowPlacement('up');
255
+ } else {
256
+ setOverflowPlacement('down');
257
+ }
258
+ }, [showOverflow]);
259
+
213
260
  // Force re-render when Tiptap selection or formatting state changes
214
261
  const [, forceUpdate] = useReducer((c: number) => c + 1, 0);
215
262
  useEffect(() => {
@@ -487,7 +534,7 @@ export function Toolbar({
487
534
  [activeView, tiptapEditor, handleTiptap, handleRaw],
488
535
  );
489
536
 
490
- const groups = ['format', 'structure', 'insert'] as const;
537
+ const groups = ['format', 'lists', 'structure', 'insert', 'media'] as const;
491
538
  const isWysiwyg = activeView === 'wysiwyg' && tiptapEditor;
492
539
  const isPreview = activeView === 'preview';
493
540
 
@@ -536,25 +583,39 @@ export function Toolbar({
536
583
  />
537
584
  {/* Left slot — before view tabs */}
538
585
  {slotLeft}
539
- {/* View tabs */}
540
- <div className="squisq-toolbar-view-tabs" role="tablist" aria-label="Editor view">
541
- {VIEWS.map((view) => (
542
- <button
543
- key={view.id}
544
- role="tab"
545
- data-view={view.id}
546
- data-label={view.label}
547
- aria-selected={activeView === view.id}
548
- className={`squisq-toolbar-view-tab${activeView === view.id ? ' squisq-toolbar-view-tab--active' : ''}`}
549
- onClick={() => setActiveView(view.id)}
550
- title={`${view.label} (${view.shortcut})`}
551
- >
552
- {view.label}
553
- </button>
554
- ))}
555
- </div>
556
- {/* Formatting buttons — hidden in preview mode and on narrow screens */}
557
- {!isPreview && !isNarrow && (
586
+ {/* View tabs — hidden when only one view is available (e.g. code mode). */}
587
+ {showViewTabs && (
588
+ <div className="squisq-toolbar-view-tabs" role="tablist" aria-label="Editor view">
589
+ {visibleViews.map((view) => (
590
+ <button
591
+ key={view.id}
592
+ role="tab"
593
+ data-view={view.id}
594
+ aria-selected={activeView === view.id}
595
+ className={`squisq-toolbar-view-tab${activeView === view.id ? ' squisq-toolbar-view-tab--active' : ''}`}
596
+ onClick={() => setActiveView(view.id)}
597
+ data-tooltip={`${view.label} (${view.shortcut})`}
598
+ >
599
+ <span
600
+ className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--long"
601
+ data-label={view.label}
602
+ >
603
+ {view.label}
604
+ </span>
605
+ {view.shortLabel && view.shortLabel !== view.label && (
606
+ <span
607
+ className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--short"
608
+ data-label={view.shortLabel}
609
+ >
610
+ {view.shortLabel}
611
+ </span>
612
+ )}
613
+ </button>
614
+ ))}
615
+ </div>
616
+ )}
617
+ {/* Formatting buttons — hidden in preview mode, narrow screens, and code mode */}
618
+ {!isPreview && !isNarrow && !isCodeMode && (
558
619
  <div className="squisq-toolbar-actions" ref={actionsRef}>
559
620
  {groups.map((group, gi) => (
560
621
  <div key={group} className="squisq-toolbar-group">
@@ -566,7 +627,7 @@ export function Toolbar({
566
627
  <button
567
628
  key={btn.id}
568
629
  className={`squisq-toolbar-button${active ? ' squisq-toolbar-button--active' : ''}`}
569
- title={disabled ? 'Insert image (requires media provider)' : btn.title}
630
+ data-tooltip={disabled ? 'Insert image (requires media provider)' : btn.title}
570
631
  onClick={() => handleAction(btn.id)}
571
632
  aria-label={btn.title}
572
633
  aria-pressed={active}
@@ -605,7 +666,7 @@ export function Toolbar({
605
666
  <div className="squisq-toolbar-group squisq-template-picker">
606
667
  <label
607
668
  className="squisq-template-picker-label"
608
- title="Block template for this heading"
669
+ data-tooltip="Block template for this heading"
609
670
  >
610
671
  Template:
611
672
  <select
@@ -633,7 +694,7 @@ export function Toolbar({
633
694
  <span className="squisq-table-controls-label">Table:</span>
634
695
  <button
635
696
  className="squisq-toolbar-button"
636
- title="Add column before"
697
+ data-tooltip="Add column before"
637
698
  onClick={() => tiptapEditor!.chain().focus().addColumnBefore().run()}
638
699
  aria-label="Add column before"
639
700
  >
@@ -654,7 +715,7 @@ export function Toolbar({
654
715
  </button>
655
716
  <button
656
717
  className="squisq-toolbar-button"
657
- title="Add column after"
718
+ data-tooltip="Add column after"
658
719
  onClick={() => tiptapEditor!.chain().focus().addColumnAfter().run()}
659
720
  aria-label="Add column after"
660
721
  >
@@ -675,7 +736,7 @@ export function Toolbar({
675
736
  </button>
676
737
  <button
677
738
  className="squisq-toolbar-button"
678
- title="Delete column"
739
+ data-tooltip="Delete column"
679
740
  onClick={() => tiptapEditor!.chain().focus().deleteColumn().run()}
680
741
  aria-label="Delete column"
681
742
  >
@@ -695,7 +756,7 @@ export function Toolbar({
695
756
  </button>
696
757
  <button
697
758
  className="squisq-toolbar-button"
698
- title="Add row above"
759
+ data-tooltip="Add row above"
699
760
  onClick={() => tiptapEditor!.chain().focus().addRowBefore().run()}
700
761
  aria-label="Add row above"
701
762
  >
@@ -716,7 +777,7 @@ export function Toolbar({
716
777
  </button>
717
778
  <button
718
779
  className="squisq-toolbar-button"
719
- title="Add row below"
780
+ data-tooltip="Add row below"
720
781
  onClick={() => tiptapEditor!.chain().focus().addRowAfter().run()}
721
782
  aria-label="Add row below"
722
783
  >
@@ -737,7 +798,7 @@ export function Toolbar({
737
798
  </button>
738
799
  <button
739
800
  className="squisq-toolbar-button"
740
- title="Delete row"
801
+ data-tooltip="Delete row"
741
802
  onClick={() => tiptapEditor!.chain().focus().deleteRow().run()}
742
803
  aria-label="Delete row"
743
804
  >
@@ -757,7 +818,7 @@ export function Toolbar({
757
818
  </button>
758
819
  <button
759
820
  className="squisq-toolbar-button squisq-toolbar-button--danger"
760
- title="Delete table"
821
+ data-tooltip="Delete table"
761
822
  onClick={() => tiptapEditor!.chain().focus().deleteTable().run()}
762
823
  aria-label="Delete table"
763
824
  >
@@ -784,11 +845,11 @@ export function Toolbar({
784
845
  )}
785
846
 
786
847
  {/* Overflow menu — outside the overflow:hidden actions container */}
787
- {!isPreview && overflowIndex !== null && (
848
+ {!isPreview && !isCodeMode && overflowIndex !== null && (
788
849
  <div className="squisq-toolbar-overflow" ref={overflowRef}>
789
850
  <button
790
851
  className={`squisq-toolbar-button squisq-toolbar-overflow-trigger${showOverflow ? ' squisq-toolbar-button--active' : ''}`}
791
- title="More actions"
852
+ data-tooltip="More actions"
792
853
  onClick={() => setShowOverflow((v) => !v)}
793
854
  aria-label="More actions"
794
855
  aria-expanded={showOverflow}
@@ -796,7 +857,9 @@ export function Toolbar({
796
857
  ···
797
858
  </button>
798
859
  {showOverflow && (
799
- <div className="squisq-toolbar-overflow-menu">
860
+ <div
861
+ className={`squisq-toolbar-overflow-menu squisq-toolbar-overflow-menu--${overflowPlacement}`}
862
+ >
800
863
  {BUTTONS.slice(overflowIndex).map((btn) => {
801
864
  const active = isWysiwyg ? isTiptapActive(tiptapEditor, btn.id) : false;
802
865
  const disabled = btn.id === 'image' && !mediaProvider;
@@ -804,7 +867,6 @@ export function Toolbar({
804
867
  <button
805
868
  key={btn.id}
806
869
  className={`squisq-toolbar-overflow-item${active ? ' squisq-toolbar-overflow-item--active' : ''}`}
807
- title={btn.title}
808
870
  onClick={() => {
809
871
  handleAction(btn.id);
810
872
  setShowOverflow(false);
@@ -916,14 +978,15 @@ export function Toolbar({
916
978
 
917
979
  {/* After-actions slot — after formatting controls */}
918
980
  {slotAfterActions}
919
- {/* Spacer pushes right-side buttons to the end */}
920
- <div style={{ flex: 1 }} />
981
+ {/* Spacer only needed when the actions container (which has flex:1
982
+ and already pushes right-side items to the end) isn't rendered. */}
983
+ {(isPreview || isNarrow || isCodeMode) && <div style={{ flex: 1 }} />}
921
984
  {/* Files toggle — visible when callback is provided */}
922
985
  {onToggleFiles && (
923
986
  <button
924
987
  className={`squisq-toolbar-button squisq-toolbar-files-toggle${showFiles ? ' squisq-toolbar-button--active' : ''}`}
925
988
  onClick={onToggleFiles}
926
- title={showFiles ? 'Hide Files panel' : 'Show Files panel'}
989
+ data-tooltip={showFiles ? 'Hide Files panel' : 'Show Files panel'}
927
990
  aria-pressed={showFiles}
928
991
  aria-label="Toggle Files panel"
929
992
  >
@@ -0,0 +1,124 @@
1
+ /**
2
+ * TooltipLayer
3
+ *
4
+ * A single portal-mounted tooltip that activates on hover over any element
5
+ * with a `data-tooltip` attribute. Shorter delay than native browser
6
+ * tooltips and fires regardless of window focus, making toolbar hints feel
7
+ * immediate. Mount once near the root of the editor shell.
8
+ */
9
+
10
+ import { useEffect, useRef, useState } from 'react';
11
+ import { createPortal } from 'react-dom';
12
+
13
+ const SHOW_DELAY_MS = 180;
14
+
15
+ interface TooltipState {
16
+ label: string;
17
+ top: number;
18
+ left: number;
19
+ }
20
+
21
+ export function TooltipLayer() {
22
+ const [state, setState] = useState<TooltipState | null>(null);
23
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
24
+ const currentTargetRef = useRef<HTMLElement | null>(null);
25
+ // Visibility tracked in a ref so `handleOver` can decide whether to swap
26
+ // the label immediately vs. re-delay — reading it from state would force
27
+ // the effect to re-run (and re-register all listeners) on every change.
28
+ const visibleRef = useRef(false);
29
+
30
+ useEffect(() => {
31
+ const clearTimer = () => {
32
+ if (timerRef.current) {
33
+ clearTimeout(timerRef.current);
34
+ timerRef.current = null;
35
+ }
36
+ };
37
+
38
+ const hide = () => {
39
+ clearTimer();
40
+ currentTargetRef.current = null;
41
+ visibleRef.current = false;
42
+ setState(null);
43
+ };
44
+
45
+ const show = (el: HTMLElement, label: string) => {
46
+ const rect = el.getBoundingClientRect();
47
+ visibleRef.current = true;
48
+ setState({
49
+ label,
50
+ top: rect.bottom + 6,
51
+ left: rect.left + rect.width / 2,
52
+ });
53
+ };
54
+
55
+ const handleOver = (e: MouseEvent) => {
56
+ const el = (e.target as Element | null)?.closest?.<HTMLElement>('[data-tooltip]');
57
+ if (!el) return;
58
+ if (el === currentTargetRef.current) return;
59
+ const label = el.getAttribute('data-tooltip');
60
+ if (!label) return;
61
+
62
+ // Switching from one tooltip target to another: if a tooltip is
63
+ // already visible, swap the label immediately (no re-delay).
64
+ const wasVisible = visibleRef.current;
65
+ currentTargetRef.current = el;
66
+ clearTimer();
67
+
68
+ if (wasVisible) {
69
+ show(el, label);
70
+ } else {
71
+ timerRef.current = setTimeout(() => {
72
+ if (currentTargetRef.current === el && document.body.contains(el)) {
73
+ show(el, label);
74
+ }
75
+ }, SHOW_DELAY_MS);
76
+ }
77
+ };
78
+
79
+ const handleOut = (e: MouseEvent) => {
80
+ const target = currentTargetRef.current;
81
+ if (!target) return;
82
+ const related = e.relatedTarget as Node | null;
83
+ // Still hovering inside the same target (e.g. moved over a child)
84
+ if (related && target.contains(related)) return;
85
+ // Moving to another element with a tooltip — handleOver will swap in.
86
+ const relatedTooltip = (related as Element | null)?.closest?.('[data-tooltip]');
87
+ if (relatedTooltip) return;
88
+ hide();
89
+ };
90
+
91
+ const handleScroll = () => hide();
92
+ const handleBlur = () => hide();
93
+
94
+ document.addEventListener('mouseover', handleOver);
95
+ document.addEventListener('mouseout', handleOut);
96
+ document.addEventListener('scroll', handleScroll, true);
97
+ window.addEventListener('blur', handleBlur);
98
+
99
+ return () => {
100
+ clearTimer();
101
+ document.removeEventListener('mouseover', handleOver);
102
+ document.removeEventListener('mouseout', handleOut);
103
+ document.removeEventListener('scroll', handleScroll, true);
104
+ window.removeEventListener('blur', handleBlur);
105
+ };
106
+ }, []);
107
+
108
+ if (!state) return null;
109
+
110
+ return createPortal(
111
+ <div
112
+ role="tooltip"
113
+ className="squisq-tooltip"
114
+ style={{
115
+ position: 'fixed',
116
+ top: state.top,
117
+ left: state.left,
118
+ }}
119
+ >
120
+ {state.label}
121
+ </div>,
122
+ document.body,
123
+ );
124
+ }
@@ -6,8 +6,8 @@
6
6
 
7
7
  import { useEditorContext, type EditorView } from './EditorContext';
8
8
 
9
- const VIEWS: { id: EditorView; label: string; shortcut: string }[] = [
10
- { id: 'raw', label: 'Raw', shortcut: '⌘1' },
9
+ const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
10
+ { id: 'raw', label: 'Markdown', shortLabel: 'MD', shortcut: '⌘1' },
11
11
  { id: 'wysiwyg', label: 'Editor', shortcut: '⌘2' },
12
12
  { id: 'preview', label: 'Preview', shortcut: '⌘3' },
13
13
  ];
@@ -21,7 +21,12 @@ export interface ViewSwitcherProps {
21
21
  * Tab-style view switcher for the three editor modes.
22
22
  */
23
23
  export function ViewSwitcher({ className }: ViewSwitcherProps) {
24
- const { activeView, setActiveView } = useEditorContext();
24
+ const { activeView, setActiveView, editorMode } = useEditorContext();
25
+ // In code mode, only the raw view is meaningful. With just one entry in
26
+ // the tab list there's nothing to switch between, so suppress the whole
27
+ // switcher rather than render a single lonely tab.
28
+ const visibleViews = editorMode === 'code' ? VIEWS.filter((v) => v.id === 'raw') : VIEWS;
29
+ if (visibleViews.length <= 1) return null;
25
30
 
26
31
  return (
27
32
  <div
@@ -29,7 +34,7 @@ export function ViewSwitcher({ className }: ViewSwitcherProps) {
29
34
  role="tablist"
30
35
  aria-label="Editor view"
31
36
  >
32
- {VIEWS.map((view) => (
37
+ {visibleViews.map((view) => (
33
38
  <button
34
39
  key={view.id}
35
40
  role="tab"
@@ -38,7 +43,12 @@ export function ViewSwitcher({ className }: ViewSwitcherProps) {
38
43
  onClick={() => setActiveView(view.id)}
39
44
  title={`${view.label} (${view.shortcut})`}
40
45
  >
41
- {view.label}
46
+ <span className="squisq-view-tab-label squisq-view-tab-label--long">{view.label}</span>
47
+ {view.shortLabel && view.shortLabel !== view.label && (
48
+ <span className="squisq-view-tab-label squisq-view-tab-label--short">
49
+ {view.shortLabel}
50
+ </span>
51
+ )}
42
52
  </button>
43
53
  ))}
44
54
  </div>