@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.
- package/dist/EditorContext.d.ts +6 -2
- package/dist/EditorContext.d.ts.map +1 -1
- package/dist/EditorContext.js +3 -1
- package/dist/EditorContext.js.map +1 -1
- package/dist/EditorShell.d.ts +11 -1
- package/dist/EditorShell.d.ts.map +1 -1
- package/dist/EditorShell.js +9 -7
- package/dist/EditorShell.js.map +1 -1
- package/dist/ImageNodeView.d.ts +15 -0
- package/dist/ImageNodeView.d.ts.map +1 -0
- package/dist/ImageNodeView.js +52 -0
- package/dist/ImageNodeView.js.map +1 -0
- package/dist/PreviewControls.d.ts +41 -0
- package/dist/PreviewControls.d.ts.map +1 -0
- package/dist/PreviewControls.js +201 -0
- package/dist/PreviewControls.js.map +1 -0
- package/dist/PreviewPanel.d.ts +7 -7
- package/dist/PreviewPanel.d.ts.map +1 -1
- package/dist/PreviewPanel.js +183 -199
- package/dist/PreviewPanel.js.map +1 -1
- package/dist/Toolbar.d.ts +8 -1
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar.js +145 -20
- package/dist/Toolbar.js.map +1 -1
- package/dist/WysiwygEditor.d.ts.map +1 -1
- package/dist/WysiwygEditor.js +3 -1
- package/dist/WysiwygEditor.js.map +1 -1
- package/dist/__tests__/tiptapBridge.test.d.ts +2 -0
- package/dist/__tests__/tiptapBridge.test.d.ts.map +1 -0
- package/dist/__tests__/tiptapBridge.test.js +241 -0
- package/dist/__tests__/tiptapBridge.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tiptapBridge.d.ts.map +1 -1
- package/dist/tiptapBridge.js +146 -5
- package/dist/tiptapBridge.js.map +1 -1
- package/package.json +5 -4
- package/src/EditorContext.tsx +8 -1
- package/src/EditorShell.tsx +71 -32
- package/src/ImageNodeView.tsx +70 -0
- package/src/PreviewControls.tsx +340 -0
- package/src/PreviewPanel.tsx +216 -287
- package/src/Toolbar.tsx +449 -17
- package/src/WysiwygEditor.tsx +3 -1
- package/src/__tests__/tiptapBridge.test.ts +290 -0
- package/src/index.ts +6 -0
- package/src/styles/editor.css +257 -16
- 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 {
|
|
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: '
|
|
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({
|
|
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 = ``;
|
|
450
|
+
monacoEditor.executeEdits('toolbar', [{ range: selection, text: md }]);
|
|
451
|
+
monacoEditor.focus();
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
setMarkdownSource(markdownSource + `\n\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.
|
|
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
|
-
{
|
|
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
|
-
{/*
|
|
474
|
-
{
|
|
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
|
}
|
package/src/WysiwygEditor.tsx
CHANGED
|
@@ -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
|
>
|