@bendyline/squisq-editor-react 1.2.2 → 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.
- package/dist/EditorContext.d.ts +65 -1
- package/dist/EditorContext.d.ts.map +1 -1
- package/dist/EditorContext.js +31 -4
- package/dist/EditorContext.js.map +1 -1
- package/dist/EditorShell.d.ts +101 -2
- package/dist/EditorShell.d.ts.map +1 -1
- package/dist/EditorShell.js +20 -8
- package/dist/EditorShell.js.map +1 -1
- package/dist/ImageNodeView.d.ts.map +1 -1
- package/dist/ImageNodeView.js +12 -2
- package/dist/ImageNodeView.js.map +1 -1
- package/dist/MediaBin.d.ts.map +1 -1
- package/dist/MediaBin.js +16 -1
- package/dist/MediaBin.js.map +1 -1
- package/dist/MentionExtension.d.ts +22 -0
- package/dist/MentionExtension.d.ts.map +1 -0
- package/dist/MentionExtension.js +242 -0
- package/dist/MentionExtension.js.map +1 -0
- package/dist/RawEditor.d.ts +8 -1
- package/dist/RawEditor.d.ts.map +1 -1
- package/dist/RawEditor.js +167 -30
- package/dist/RawEditor.js.map +1 -1
- package/dist/TemplateAnnotation.d.ts.map +1 -1
- package/dist/TemplateAnnotation.js +4 -2
- package/dist/TemplateAnnotation.js.map +1 -1
- package/dist/Toolbar.d.ts +7 -1
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar.js +57 -18
- package/dist/Toolbar.js.map +1 -1
- package/dist/Tooltip.d.ts +10 -0
- package/dist/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip.js +104 -0
- package/dist/Tooltip.js.map +1 -0
- package/dist/ViewSwitcher.d.ts +1 -1
- package/dist/ViewSwitcher.d.ts.map +1 -1
- package/dist/ViewSwitcher.js +10 -4
- package/dist/ViewSwitcher.js.map +1 -1
- package/dist/WysiwygEditor.d.ts +13 -2
- package/dist/WysiwygEditor.d.ts.map +1 -1
- package/dist/WysiwygEditor.js +239 -4
- package/dist/WysiwygEditor.js.map +1 -1
- package/dist/__tests__/detectMarkdown.test.d.ts +2 -0
- package/dist/__tests__/detectMarkdown.test.d.ts.map +1 -0
- package/dist/__tests__/detectMarkdown.test.js +69 -0
- package/dist/__tests__/detectMarkdown.test.js.map +1 -0
- package/dist/__tests__/fileKind.test.d.ts +2 -0
- package/dist/__tests__/fileKind.test.d.ts.map +1 -0
- package/dist/__tests__/fileKind.test.js +81 -0
- package/dist/__tests__/fileKind.test.js.map +1 -0
- package/dist/__tests__/tiptapBridge.test.js +36 -0
- package/dist/__tests__/tiptapBridge.test.js.map +1 -1
- package/dist/detectMarkdown.d.ts +20 -0
- package/dist/detectMarkdown.d.ts.map +1 -0
- package/dist/detectMarkdown.js +61 -0
- package/dist/detectMarkdown.js.map +1 -0
- package/dist/fileKind.d.ts +30 -0
- package/dist/fileKind.d.ts.map +1 -0
- package/dist/fileKind.js +123 -0
- package/dist/fileKind.js.map +1 -0
- package/dist/hooks/useFileDrop.d.ts.map +1 -1
- package/dist/hooks/useFileDrop.js +9 -7
- package/dist/hooks/useFileDrop.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/mediaDragMime.d.ts +17 -0
- package/dist/mediaDragMime.d.ts.map +1 -0
- package/dist/mediaDragMime.js +22 -0
- package/dist/mediaDragMime.js.map +1 -0
- package/dist/tiptapBridge.d.ts.map +1 -1
- package/dist/tiptapBridge.js +58 -2
- package/dist/tiptapBridge.js.map +1 -1
- package/package.json +9 -7
- package/src/EditorContext.tsx +106 -3
- package/src/EditorShell.tsx +195 -15
- package/src/ImageNodeView.tsx +15 -2
- package/src/MediaBin.tsx +23 -1
- package/src/MentionExtension.tsx +258 -0
- package/src/RawEditor.tsx +193 -37
- package/src/TemplateAnnotation.ts +4 -2
- package/src/Toolbar.tsx +111 -48
- package/src/Tooltip.tsx +124 -0
- package/src/ViewSwitcher.tsx +15 -5
- package/src/WysiwygEditor.tsx +270 -5
- package/src/__tests__/detectMarkdown.test.ts +88 -0
- package/src/__tests__/fileKind.test.ts +96 -0
- package/src/__tests__/tiptapBridge.test.ts +44 -0
- package/src/detectMarkdown.ts +62 -0
- package/src/fileKind.ts +134 -0
- package/src/hooks/useFileDrop.ts +10 -6
- package/src/index.ts +11 -0
- package/src/mediaDragMime.ts +32 -0
- package/src/styles/editor.css +214 -8
- 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: '
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
{ id: '
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
920
|
-
|
|
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
|
-
|
|
989
|
+
data-tooltip={showFiles ? 'Hide Files panel' : 'Show Files panel'}
|
|
927
990
|
aria-pressed={showFiles}
|
|
928
991
|
aria-label="Toggle Files panel"
|
|
929
992
|
>
|
package/src/Tooltip.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/ViewSwitcher.tsx
CHANGED
|
@@ -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: '
|
|
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
|
-
{
|
|
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>
|