@bendyline/squisq-editor-react 1.6.0 → 1.6.2
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/README.md +57 -10
- package/dist/index.d.ts +476 -105
- package/dist/index.js +8703 -6741
- package/dist/index.js.map +1 -1
- package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
- package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
- package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
- package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
- package/dist/styles/index.css +14617 -0
- package/package.json +15 -7
- package/src/BlockPropertiesPopover.tsx +23 -7
- package/src/EditorContext.tsx +22 -16
- package/src/EditorShell.tsx +121 -35
- package/src/MediaBin.tsx +171 -28
- package/src/OutlinePanel.tsx +26 -4
- package/src/PreviewControls.tsx +475 -145
- package/src/PreviewPanel.tsx +17 -9
- package/src/RawEditor.tsx +10 -4
- package/src/TemplateAnnotation.ts +22 -7
- package/src/TemplateContentPreview.tsx +56 -0
- package/src/TemplatePicker.tsx +295 -128
- package/src/ThemeCustomizerPanel.tsx +22 -15
- package/src/Toolbar.tsx +547 -217
- package/src/TransitionPicker.tsx +8 -1
- package/src/VersionHistoryPanel.tsx +2 -2
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +45 -3
- package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
- package/src/__tests__/codeContextSectionView.test.tsx +95 -0
- package/src/__tests__/codeContextZoneManager.test.ts +127 -0
- package/src/__tests__/diffContextSections.test.ts +39 -0
- package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
- package/src/__tests__/editorShellProps.test.tsx +363 -0
- package/src/__tests__/headingTransition.test.ts +59 -9
- package/src/__tests__/imageEditorShell.test.tsx +23 -0
- package/src/__tests__/mediaReferences.test.ts +82 -0
- package/src/__tests__/previewControls.test.tsx +163 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
- package/src/__tests__/templateContentPreview.test.ts +101 -0
- package/src/__tests__/tiptapBridge.test.ts +47 -0
- package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
- package/src/__tests__/useMediaRecorder.test.ts +17 -0
- package/src/codeContext/CodeContextSectionView.tsx +124 -0
- package/src/codeContext/CodeContextZoneManager.ts +149 -0
- package/src/codeContext/CodeContextZones.tsx +121 -0
- package/src/codeContext/diffContextSections.ts +38 -0
- package/src/codeContext/types.ts +75 -0
- package/src/diagram/DiagramWidget.tsx +6 -4
- package/src/headingTransition.ts +96 -21
- package/src/index.ts +34 -2
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/mediaReferences.ts +299 -0
- package/src/recorder/hooks/useMediaRecorder.ts +9 -10
- package/src/scene/Scene.tsx +53 -7
- package/src/scene/SceneBlockWidget.tsx +6 -3
- package/src/scene/SceneSelection.tsx +19 -15
- package/src/scene/SceneSideToolbar.tsx +89 -0
- package/src/scene/layers/DiagramEdges.tsx +4 -3
- package/src/scene/layers/edgeGeometry.ts +23 -4
- package/src/scene/scene.css +142 -2
- package/src/scene/tools/ConnectTool.ts +113 -24
- package/src/scene/tools/DrawingConnectTool.ts +86 -16
- package/src/scene/tools/SceneTool.ts +2 -0
- package/src/styles/code-context.css +155 -0
- package/src/styles/editor.css +991 -84
- package/src/styles/index.css +1 -0
- package/src/templateContentPreviewResolver.ts +353 -0
- package/src/tiptapBridge.ts +60 -33
package/src/Toolbar.tsx
CHANGED
|
@@ -9,21 +9,35 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ReactNode } from 'react';
|
|
11
11
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
|
12
|
-
import type { Editor as TiptapEditor } from '@tiptap/core';
|
|
12
|
+
import type { Editor as TiptapEditor, JSONContent } from '@tiptap/core';
|
|
13
13
|
import { TextSelection } from '@tiptap/pm/state';
|
|
14
14
|
import { Fragment } from '@tiptap/pm/model';
|
|
15
15
|
import type { IRange } from 'monaco-editor';
|
|
16
|
+
import type { Block } from '@bendyline/squisq/schemas';
|
|
17
|
+
import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
|
|
18
|
+
import { DEFAULT_THEME, flattenBlocks } from '@bendyline/squisq/doc';
|
|
19
|
+
import {
|
|
20
|
+
KNOWN_BLOCK_META_KEYS,
|
|
21
|
+
matchTrailingTemplateAnnotation,
|
|
22
|
+
splitKeyValueToken,
|
|
23
|
+
tokenizeAttrTokens,
|
|
24
|
+
} from '@bendyline/squisq/markdown';
|
|
16
25
|
import { useEditorContext, type EditorView } from './EditorContext';
|
|
17
26
|
import { VersionHistoryPanel } from './VersionHistoryPanel';
|
|
18
27
|
import { RecorderEntry } from './RecorderEntry';
|
|
19
28
|
import { ViewMenuPanel } from './ViewMenuPanel';
|
|
20
|
-
import {
|
|
21
|
-
|
|
29
|
+
import {
|
|
30
|
+
TemplatePicker,
|
|
31
|
+
TEMPLATE_NAMES,
|
|
32
|
+
TEMPLATE_GALLERY_PORTAL_ID,
|
|
33
|
+
templateLabel,
|
|
34
|
+
} from './TemplatePicker';
|
|
35
|
+
import { TransitionPicker, TRANSITION_FLYOUT_PORTAL_ID } from './TransitionPicker';
|
|
22
36
|
import {
|
|
23
37
|
readHeadingLineTransition,
|
|
24
38
|
setHeadingLineTransition,
|
|
25
39
|
readBlockAttrsTransition,
|
|
26
|
-
|
|
40
|
+
setHeadingAttrsTransition,
|
|
27
41
|
EMPTY_TRANSITION,
|
|
28
42
|
type TransitionFields,
|
|
29
43
|
} from './headingTransition';
|
|
@@ -36,22 +50,31 @@ import { CustomLayoutManager } from './customTemplates/CustomLayoutManager';
|
|
|
36
50
|
import { Icon } from './Icon';
|
|
37
51
|
import type { PickerEntry } from './emojiData';
|
|
38
52
|
import { createPortal } from 'react-dom';
|
|
53
|
+
import { usePreviewSettingsOptional } from './PreviewControls';
|
|
39
54
|
|
|
40
55
|
const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
|
|
41
|
-
{ id: 'wysiwyg', label: '
|
|
42
|
-
{ id: 'raw', label: '
|
|
43
|
-
{ id: 'preview', label: '
|
|
56
|
+
{ id: 'wysiwyg', label: 'Write', shortcut: '⌘1' },
|
|
57
|
+
{ id: 'raw', label: 'Source', shortcut: '⌘2' },
|
|
58
|
+
{ id: 'preview', label: 'Use', shortcut: '⌘3' },
|
|
44
59
|
];
|
|
45
60
|
|
|
61
|
+
const BLOCK_META_KEYS = new Set<string>(Object.keys(KNOWN_BLOCK_META_KEYS));
|
|
62
|
+
|
|
46
63
|
export interface ToolbarProps {
|
|
47
64
|
/** Additional class name */
|
|
48
65
|
className?: string;
|
|
49
66
|
/** Whether the Files panel is currently shown */
|
|
50
67
|
showFiles?: boolean;
|
|
68
|
+
/** Number of files currently available through the MediaProvider. */
|
|
69
|
+
fileCount?: number;
|
|
51
70
|
/** Toggle the Files panel. When provided, a "Files" button appears in the toolbar. */
|
|
52
71
|
onToggleFiles?: () => void;
|
|
53
72
|
/** Content rendered at the left edge of the toolbar, before the view tabs. */
|
|
54
73
|
slotLeft?: ReactNode;
|
|
74
|
+
/** Content rendered immediately after the view tabs, on the left side of the
|
|
75
|
+
* toolbar (before the formatting controls). Used for the preview mode
|
|
76
|
+
* switch in Use view. */
|
|
77
|
+
slotAfterTabs?: ReactNode;
|
|
55
78
|
/** Content rendered after the formatting controls (in the middle area). */
|
|
56
79
|
slotAfterActions?: ReactNode;
|
|
57
80
|
/** Content rendered at the rightmost end of the toolbar, after all other elements. */
|
|
@@ -181,6 +204,14 @@ const BUTTONS: ToolbarButton[] = [
|
|
|
181
204
|
group: 'media',
|
|
182
205
|
faIcon: 'fa-solid fa-table',
|
|
183
206
|
},
|
|
207
|
+
{
|
|
208
|
+
id: 'tasklist',
|
|
209
|
+
label: 'tasks',
|
|
210
|
+
icon: '',
|
|
211
|
+
title: 'Insert Task List',
|
|
212
|
+
group: 'media',
|
|
213
|
+
faIcon: 'fa-solid fa-list-check',
|
|
214
|
+
},
|
|
184
215
|
{
|
|
185
216
|
id: 'diagram',
|
|
186
217
|
label: 'diagram',
|
|
@@ -226,12 +257,51 @@ const BUTTONS: ToolbarButton[] = [
|
|
|
226
257
|
const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
|
|
227
258
|
const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
|
|
228
259
|
const INSERT_MENU_WIDTH = 200;
|
|
260
|
+
const TASK_LIST_ITEMS = ['Task 1', 'Task 2', 'Task 3'] as const;
|
|
261
|
+
const TASK_LIST_MARKDOWN = TASK_LIST_ITEMS.map((item) => `- [ ] ${item}`).join('\n');
|
|
262
|
+
|
|
263
|
+
// BUTTONS position per id — stamped on each rendered button as
|
|
264
|
+
// data-btn-index so the overflow measurement can map a clipped DOM button
|
|
265
|
+
// back to its BUTTONS entry. Walking a counter over the DOM instead would
|
|
266
|
+
// drift whenever some entries render no button of their own (hidden H5/H6
|
|
267
|
+
// heading levels, the media group collapsed behind the Insert dropdown).
|
|
268
|
+
const BUTTON_INDEX_BY_ID = new Map(BUTTONS.map((b, i) => [b.id, i]));
|
|
229
269
|
|
|
230
270
|
/** Renders a button's icon: a Font Awesome glyph when set, else the text label. */
|
|
231
271
|
function buttonIcon(btn: ToolbarButton): ReactNode {
|
|
232
272
|
return btn.faIcon ? <Icon icon={btn.faIcon} /> : btn.icon;
|
|
233
273
|
}
|
|
234
274
|
|
|
275
|
+
function fileCountLabel(count: number): string {
|
|
276
|
+
return `${count} ${count === 1 ? 'file' : 'files'}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function fileCountBadge(count: number): string {
|
|
280
|
+
return count > 99 ? '99+' : String(count);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function taskListContent(): JSONContent {
|
|
284
|
+
return {
|
|
285
|
+
type: 'taskList',
|
|
286
|
+
content: TASK_LIST_ITEMS.map((item) => ({
|
|
287
|
+
type: 'taskItem',
|
|
288
|
+
attrs: { checked: false },
|
|
289
|
+
content: [
|
|
290
|
+
{
|
|
291
|
+
type: 'paragraph',
|
|
292
|
+
content: [{ type: 'text', text: item }],
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
})),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function insertTaskList(editor: TiptapEditor): void {
|
|
300
|
+
const supportsTaskList = !!editor.schema.nodes.taskList && !!editor.schema.nodes.taskItem;
|
|
301
|
+
const content = supportsTaskList ? taskListContent() : TASK_LIST_MARKDOWN;
|
|
302
|
+
editor.chain().focus().insertContent(content).run();
|
|
303
|
+
}
|
|
304
|
+
|
|
235
305
|
// ─── Tiptap active-state map ────────────────────────────
|
|
236
306
|
|
|
237
307
|
/** Returns true if the given button id is currently active in Tiptap */
|
|
@@ -361,6 +431,44 @@ function insertLayoutBlock(editor: TiptapEditor): void {
|
|
|
361
431
|
.run();
|
|
362
432
|
}
|
|
363
433
|
|
|
434
|
+
function findBlockBySourceLine(blocks: readonly Block[], lineNumber: number): Block | null {
|
|
435
|
+
for (const block of blocks) {
|
|
436
|
+
const pos = block.sourceHeading?.position;
|
|
437
|
+
if (!pos) continue;
|
|
438
|
+
if (pos.start.line <= lineNumber && pos.end.line >= lineNumber) return block;
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function splitTemplateAnnotationParams(text: string): { text: string; params: string[] } {
|
|
444
|
+
const match = matchTrailingTemplateAnnotation(text);
|
|
445
|
+
if (!match) return { text: text.trimEnd(), params: [] };
|
|
446
|
+
const tokens = tokenizeAttrTokens(match.inner.trim());
|
|
447
|
+
const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
|
|
448
|
+
return {
|
|
449
|
+
text: text.slice(0, match.index).trimEnd(),
|
|
450
|
+
params: keepBlockMetaParamTokens(tokens.slice(firstIsParam ? 0 : 1)),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function buildTemplateAnnotation(template: string, params: string[]): string | null {
|
|
455
|
+
const parts = template ? [template, ...params] : params;
|
|
456
|
+
return parts.length > 0 ? `{[${parts.join(' ')}]}` : null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function keepBlockMetaParamTokens(tokens: readonly string[]): string[] {
|
|
460
|
+
return tokens.filter((token) => {
|
|
461
|
+
const kv = splitKeyValueToken(token);
|
|
462
|
+
return kv ? BLOCK_META_KEYS.has(kv.key) : false;
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function keepBlockMetaParamString(inner: string | null | undefined): string | null {
|
|
467
|
+
if (!inner) return null;
|
|
468
|
+
const params = keepBlockMetaParamTokens(tokenizeAttrTokens(inner));
|
|
469
|
+
return params.length > 0 ? params.join(' ') : null;
|
|
470
|
+
}
|
|
471
|
+
|
|
364
472
|
/**
|
|
365
473
|
* Formatting toolbar.
|
|
366
474
|
* - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
|
|
@@ -369,8 +477,10 @@ function insertLayoutBlock(editor: TiptapEditor): void {
|
|
|
369
477
|
export function Toolbar({
|
|
370
478
|
className,
|
|
371
479
|
showFiles,
|
|
480
|
+
fileCount,
|
|
372
481
|
onToggleFiles,
|
|
373
482
|
slotLeft,
|
|
483
|
+
slotAfterTabs,
|
|
374
484
|
slotAfterActions,
|
|
375
485
|
slotRight,
|
|
376
486
|
showPlayTab = true,
|
|
@@ -379,17 +489,23 @@ export function Toolbar({
|
|
|
379
489
|
activeView,
|
|
380
490
|
setActiveView,
|
|
381
491
|
markdownSource,
|
|
492
|
+
doc,
|
|
382
493
|
setMarkdownSource,
|
|
383
494
|
tiptapEditor,
|
|
384
495
|
monacoEditor,
|
|
385
496
|
activeSceneText,
|
|
386
497
|
mediaProvider,
|
|
387
498
|
editorMode,
|
|
499
|
+
layoutMode,
|
|
500
|
+
activeBlockStartLine,
|
|
388
501
|
versioning,
|
|
389
502
|
allowRecording,
|
|
390
503
|
documentLinkProvider,
|
|
391
|
-
|
|
504
|
+
colorScheme,
|
|
505
|
+
mediaRevision,
|
|
506
|
+
bumpMediaRevision,
|
|
392
507
|
} = useEditorContext();
|
|
508
|
+
const previewSettings = usePreviewSettingsOptional();
|
|
393
509
|
// When a canvas textbox is being edited, its Tiptap instance takes over
|
|
394
510
|
// the formatting buttons; otherwise they drive the document editor. The
|
|
395
511
|
// `level` gates which buttons apply (inline labels vs. rich textboxes).
|
|
@@ -404,6 +520,30 @@ export function Toolbar({
|
|
|
404
520
|
return true;
|
|
405
521
|
});
|
|
406
522
|
const showViewTabs = visibleViews.length > 1;
|
|
523
|
+
const [scannedFileCount, setScannedFileCount] = useState(0);
|
|
524
|
+
const resolvedFileCount = fileCount ?? scannedFileCount;
|
|
525
|
+
|
|
526
|
+
useEffect(() => {
|
|
527
|
+
if (fileCount !== undefined) return;
|
|
528
|
+
if (!mediaProvider) {
|
|
529
|
+
setScannedFileCount(0);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
let cancelled = false;
|
|
534
|
+
mediaProvider.listMedia().then(
|
|
535
|
+
(entries) => {
|
|
536
|
+
if (!cancelled) setScannedFileCount(entries.length);
|
|
537
|
+
},
|
|
538
|
+
() => {
|
|
539
|
+
if (!cancelled) setScannedFileCount(0);
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
return () => {
|
|
544
|
+
cancelled = true;
|
|
545
|
+
};
|
|
546
|
+
}, [fileCount, mediaProvider, mediaRevision, showFiles]);
|
|
407
547
|
|
|
408
548
|
// Hidden file input for image picker
|
|
409
549
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -485,6 +625,11 @@ export function Toolbar({
|
|
|
485
625
|
// ── Overflow detection ────────────────────────────────
|
|
486
626
|
const actionsRef = useRef<HTMLDivElement>(null);
|
|
487
627
|
const [measuredOverflowIndex, setMeasuredOverflowIndex] = useState<number | null>(null);
|
|
628
|
+
// '|'-joined data-contextual ids of the contextual groups (template /
|
|
629
|
+
// transition pickers, table controls) whose right edge doesn't fit in the
|
|
630
|
+
// actions row. Stored as a string so identical measurements bail out of
|
|
631
|
+
// re-rendering.
|
|
632
|
+
const [clippedContextualKey, setClippedContextualKey] = useState('');
|
|
488
633
|
const [showOverflow, setShowOverflow] = useState(false);
|
|
489
634
|
const overflowRef = useRef<HTMLDivElement>(null);
|
|
490
635
|
|
|
@@ -495,6 +640,7 @@ export function Toolbar({
|
|
|
495
640
|
const [showLayoutManager, setShowLayoutManager] = useState(false);
|
|
496
641
|
|
|
497
642
|
const overflowIndex = measuredOverflowIndex;
|
|
643
|
+
const clippedContextual = new Set(clippedContextualKey ? clippedContextualKey.split('|') : []);
|
|
498
644
|
|
|
499
645
|
useEffect(() => {
|
|
500
646
|
const container = actionsRef.current;
|
|
@@ -502,39 +648,77 @@ export function Toolbar({
|
|
|
502
648
|
|
|
503
649
|
const measure = () => {
|
|
504
650
|
const containerRight = container.getBoundingClientRect().right;
|
|
651
|
+
// Contextual groups aren't BUTTONS entries, so they must not shift the
|
|
652
|
+
// btnIndex mapping below — measure them separately.
|
|
505
653
|
const children = container.querySelectorAll<HTMLElement>(
|
|
506
|
-
':scope > .squisq-toolbar-group > .squisq-toolbar-button',
|
|
654
|
+
':scope > .squisq-toolbar-group:not(.squisq-toolbar-contextual) > .squisq-toolbar-button',
|
|
507
655
|
);
|
|
508
656
|
let firstHidden: number | null = null;
|
|
509
|
-
let btnIndex = 0;
|
|
510
657
|
children.forEach((child) => {
|
|
511
658
|
if (firstHidden !== null) return;
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
firstHidden = btnIndex;
|
|
659
|
+
// A button is hidden if its right edge extends past the container.
|
|
660
|
+
// Its data-btn-index carries its BUTTONS position (see
|
|
661
|
+
// BUTTON_INDEX_BY_ID for why the DOM can't be walked positionally).
|
|
662
|
+
if (child.getBoundingClientRect().right > containerRight + 2) {
|
|
663
|
+
const index = Number(child.dataset.btnIndex);
|
|
664
|
+
firstHidden = Number.isNaN(index) ? null : index;
|
|
519
665
|
}
|
|
520
|
-
btnIndex += btnCount;
|
|
521
666
|
});
|
|
522
667
|
setMeasuredOverflowIndex(firstHidden);
|
|
668
|
+
// Contextual groups (template/transition pickers, table controls) that
|
|
669
|
+
// don't fit move into the overflow menu instead of painting cropped.
|
|
670
|
+
const clipped: string[] = [];
|
|
671
|
+
container
|
|
672
|
+
.querySelectorAll<HTMLElement>(':scope > .squisq-toolbar-contextual')
|
|
673
|
+
.forEach((group) => {
|
|
674
|
+
if (group.getBoundingClientRect().right > containerRight + 2) {
|
|
675
|
+
clipped.push(group.dataset.contextual ?? '');
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
setClippedContextualKey(clipped.join('|'));
|
|
523
679
|
};
|
|
524
680
|
|
|
681
|
+
// Observe the row and every group in it: contextual groups mount/unmount
|
|
682
|
+
// with the cursor context and change width with their value, all without
|
|
683
|
+
// resizing the flex:1 container itself — a container-only observer would
|
|
684
|
+
// go stale.
|
|
525
685
|
const ro = new ResizeObserver(measure);
|
|
526
|
-
|
|
686
|
+
const observeAll = () => {
|
|
687
|
+
ro.disconnect();
|
|
688
|
+
ro.observe(container);
|
|
689
|
+
container
|
|
690
|
+
.querySelectorAll<HTMLElement>(':scope > .squisq-toolbar-group')
|
|
691
|
+
.forEach((group) => ro.observe(group));
|
|
692
|
+
};
|
|
693
|
+
const mo = new MutationObserver(() => {
|
|
694
|
+
observeAll();
|
|
695
|
+
measure();
|
|
696
|
+
});
|
|
697
|
+
mo.observe(container, { childList: true });
|
|
698
|
+
observeAll();
|
|
527
699
|
measure();
|
|
528
|
-
return () =>
|
|
700
|
+
return () => {
|
|
701
|
+
ro.disconnect();
|
|
702
|
+
mo.disconnect();
|
|
703
|
+
};
|
|
529
704
|
}, [activeView]);
|
|
530
705
|
|
|
531
|
-
// Close overflow menu on outside click
|
|
706
|
+
// Close overflow menu on outside click. Clicks inside the template
|
|
707
|
+
// gallery / transition flyout portals count as inside: those popovers are
|
|
708
|
+
// hosted by pickers living in this menu, and closing the menu on their
|
|
709
|
+
// mousedown would unmount them before the click completes.
|
|
532
710
|
useEffect(() => {
|
|
533
711
|
if (!showOverflow) return;
|
|
534
712
|
const handleClick = (e: MouseEvent) => {
|
|
535
|
-
|
|
536
|
-
|
|
713
|
+
const target = e.target as Node;
|
|
714
|
+
if (overflowRef.current?.contains(target)) return;
|
|
715
|
+
if (
|
|
716
|
+
target instanceof Element &&
|
|
717
|
+
target.closest(`#${TEMPLATE_GALLERY_PORTAL_ID}, #${TRANSITION_FLYOUT_PORTAL_ID}`)
|
|
718
|
+
) {
|
|
719
|
+
return;
|
|
537
720
|
}
|
|
721
|
+
setShowOverflow(false);
|
|
538
722
|
};
|
|
539
723
|
document.addEventListener('mousedown', handleClick);
|
|
540
724
|
return () => document.removeEventListener('mousedown', handleClick);
|
|
@@ -668,6 +852,9 @@ export function Toolbar({
|
|
|
668
852
|
case 'table':
|
|
669
853
|
tiptapEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
|
670
854
|
break;
|
|
855
|
+
case 'tasklist':
|
|
856
|
+
insertTaskList(tiptapEditor);
|
|
857
|
+
break;
|
|
671
858
|
case 'diagram':
|
|
672
859
|
// Empty diagram is usable via its "+ Add first node" affordance;
|
|
673
860
|
// sub-headings under this block become nodes.
|
|
@@ -822,22 +1009,27 @@ export function Toolbar({
|
|
|
822
1009
|
newCursorOffset = 3; // after \n|
|
|
823
1010
|
break;
|
|
824
1011
|
}
|
|
1012
|
+
case 'tasklist': {
|
|
1013
|
+
replacement = `\n${TASK_LIST_MARKDOWN}\n`;
|
|
1014
|
+
newCursorOffset = replacement.length;
|
|
1015
|
+
break;
|
|
1016
|
+
}
|
|
825
1017
|
case 'diagram': {
|
|
826
1018
|
// A diagram is just a heading with the `{[diagram]}` template
|
|
827
1019
|
// annotation; the WYSIWYG view renders its editable canvas.
|
|
828
1020
|
replacement = '\n## Diagram {[diagram]}\n';
|
|
829
|
-
newCursorOffset =
|
|
1021
|
+
newCursorOffset = replacement.length;
|
|
830
1022
|
break;
|
|
831
1023
|
}
|
|
832
1024
|
case 'drawing': {
|
|
833
1025
|
replacement = '\n## Drawing {[drawing]}\n';
|
|
834
|
-
newCursorOffset =
|
|
1026
|
+
newCursorOffset = replacement.length;
|
|
835
1027
|
break;
|
|
836
1028
|
}
|
|
837
1029
|
case 'layout': {
|
|
838
1030
|
// Seed a starter text layer (a child sub-block) so it isn't blank.
|
|
839
1031
|
replacement = LAYOUT_STARTER_MARKDOWN;
|
|
840
|
-
newCursorOffset =
|
|
1032
|
+
newCursorOffset = replacement.length;
|
|
841
1033
|
break;
|
|
842
1034
|
}
|
|
843
1035
|
}
|
|
@@ -846,13 +1038,13 @@ export function Toolbar({
|
|
|
846
1038
|
const range = selection;
|
|
847
1039
|
monacoEditor.executeEdits('toolbar', [{ range, text: replacement }]);
|
|
848
1040
|
|
|
849
|
-
// If no selection,
|
|
1041
|
+
// If no selection, move the cursor to the command's preferred edit point.
|
|
850
1042
|
if (!hasSelection && newCursorOffset > 0) {
|
|
851
1043
|
const startPos = model.getPositionAt(
|
|
852
1044
|
model.getOffsetAt(range.getStartPosition()) + newCursorOffset,
|
|
853
1045
|
);
|
|
854
|
-
// Just place cursor after the prefix
|
|
855
1046
|
monacoEditor.setPosition(startPos);
|
|
1047
|
+
monacoEditor.revealPositionInCenterIfOutsideViewport(startPos);
|
|
856
1048
|
}
|
|
857
1049
|
|
|
858
1050
|
monacoEditor.focus();
|
|
@@ -903,6 +1095,9 @@ export function Toolbar({
|
|
|
903
1095
|
insertion =
|
|
904
1096
|
'\n| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |\n';
|
|
905
1097
|
break;
|
|
1098
|
+
case 'tasklist':
|
|
1099
|
+
insertion = `\n${TASK_LIST_MARKDOWN}\n`;
|
|
1100
|
+
break;
|
|
906
1101
|
case 'diagram':
|
|
907
1102
|
insertion = '\n## Diagram {[diagram]}\n';
|
|
908
1103
|
break;
|
|
@@ -927,6 +1122,7 @@ export function Toolbar({
|
|
|
927
1122
|
if (!mediaProvider) return;
|
|
928
1123
|
const buffer = await file.arrayBuffer();
|
|
929
1124
|
const relativePath = await mediaProvider.addMedia(file.name, buffer, file.type);
|
|
1125
|
+
bumpMediaRevision();
|
|
930
1126
|
const altText = file.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
|
|
931
1127
|
|
|
932
1128
|
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
@@ -943,7 +1139,15 @@ export function Toolbar({
|
|
|
943
1139
|
setMarkdownSource(markdownSource + `\n\n`);
|
|
944
1140
|
}
|
|
945
1141
|
},
|
|
946
|
-
[
|
|
1142
|
+
[
|
|
1143
|
+
mediaProvider,
|
|
1144
|
+
activeView,
|
|
1145
|
+
tiptapEditor,
|
|
1146
|
+
monacoEditor,
|
|
1147
|
+
markdownSource,
|
|
1148
|
+
setMarkdownSource,
|
|
1149
|
+
bumpMediaRevision,
|
|
1150
|
+
],
|
|
947
1151
|
);
|
|
948
1152
|
|
|
949
1153
|
const handleAction = useCallback(
|
|
@@ -1225,11 +1429,11 @@ export function Toolbar({
|
|
|
1225
1429
|
}
|
|
1226
1430
|
setRawHeadingLine(pos.lineNumber);
|
|
1227
1431
|
setRawTransition(readHeadingLineTransition(line));
|
|
1228
|
-
const annotMatch = headingMatch[1]
|
|
1432
|
+
const annotMatch = matchTrailingTemplateAnnotation(headingMatch[1]);
|
|
1229
1433
|
if (annotMatch) {
|
|
1230
|
-
|
|
1231
|
-
const
|
|
1232
|
-
setRawTemplate(
|
|
1434
|
+
const tokens = tokenizeAttrTokens(annotMatch.inner.trim());
|
|
1435
|
+
const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
|
|
1436
|
+
setRawTemplate(firstIsParam ? '' : (tokens[0] ?? ''));
|
|
1233
1437
|
} else {
|
|
1234
1438
|
setRawTemplate('');
|
|
1235
1439
|
}
|
|
@@ -1297,6 +1501,55 @@ export function Toolbar({
|
|
|
1297
1501
|
return recommendTemplatesForBlock(profile, TEMPLATE_NAMES).recommended;
|
|
1298
1502
|
}, [currentTemplate, isRawView, isWysiwyg, rawHeadingLine, wysiwygHeadingIndex, markdownSource]);
|
|
1299
1503
|
|
|
1504
|
+
const templatePreviewBlock = useMemo(() => {
|
|
1505
|
+
if (!doc || currentTemplate === null) return null;
|
|
1506
|
+
const flat = flattenBlocks(doc.blocks);
|
|
1507
|
+
if (flat.length === 0) return null;
|
|
1508
|
+
|
|
1509
|
+
if (isRawView && rawHeadingLine !== null) {
|
|
1510
|
+
return findBlockBySourceLine(flat, rawHeadingLine);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
if (isWysiwyg) {
|
|
1514
|
+
if (layoutMode !== 'document' && activeBlockStartLine !== null) {
|
|
1515
|
+
return findBlockBySourceLine(flat, activeBlockStartLine);
|
|
1516
|
+
}
|
|
1517
|
+
if (wysiwygHeadingIndex !== null) {
|
|
1518
|
+
return flat[wysiwygHeadingIndex] ?? null;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
return null;
|
|
1523
|
+
}, [
|
|
1524
|
+
activeBlockStartLine,
|
|
1525
|
+
currentTemplate,
|
|
1526
|
+
doc,
|
|
1527
|
+
isRawView,
|
|
1528
|
+
isWysiwyg,
|
|
1529
|
+
layoutMode,
|
|
1530
|
+
rawHeadingLine,
|
|
1531
|
+
wysiwygHeadingIndex,
|
|
1532
|
+
]);
|
|
1533
|
+
|
|
1534
|
+
const templatePreviewSource = useMemo(
|
|
1535
|
+
() =>
|
|
1536
|
+
templatePreviewBlock
|
|
1537
|
+
? {
|
|
1538
|
+
block: templatePreviewBlock,
|
|
1539
|
+
theme: previewSettings?.activeTheme ?? DEFAULT_THEME,
|
|
1540
|
+
viewport: previewSettings?.activeViewport ?? VIEWPORT_PRESETS.landscape,
|
|
1541
|
+
basePath: '/',
|
|
1542
|
+
mediaProvider,
|
|
1543
|
+
}
|
|
1544
|
+
: undefined,
|
|
1545
|
+
[
|
|
1546
|
+
mediaProvider,
|
|
1547
|
+
previewSettings?.activeTheme,
|
|
1548
|
+
previewSettings?.activeViewport,
|
|
1549
|
+
templatePreviewBlock,
|
|
1550
|
+
],
|
|
1551
|
+
);
|
|
1552
|
+
|
|
1300
1553
|
const handleTemplatePick = (value: string) => {
|
|
1301
1554
|
// Raw (Monaco) — rewrite the heading line's annotation suffix in place.
|
|
1302
1555
|
if (isRawView && monacoEditor) {
|
|
@@ -1308,9 +1561,9 @@ export function Toolbar({
|
|
|
1308
1561
|
const headingMatch = lineText.match(/^(#{1,6}\s+)(.+)$/);
|
|
1309
1562
|
if (!headingMatch) return;
|
|
1310
1563
|
const prefix = headingMatch[1];
|
|
1311
|
-
|
|
1312
|
-
const
|
|
1313
|
-
const newLine =
|
|
1564
|
+
const { text: bareText, params } = splitTemplateAnnotationParams(headingMatch[2]);
|
|
1565
|
+
const annotation = buildTemplateAnnotation(value, params);
|
|
1566
|
+
const newLine = annotation ? `${prefix}${bareText} ${annotation}` : `${prefix}${bareText}`;
|
|
1314
1567
|
monacoEditor.executeEdits('toolbar-template-pick', [
|
|
1315
1568
|
{
|
|
1316
1569
|
range: {
|
|
@@ -1327,14 +1580,22 @@ export function Toolbar({
|
|
|
1327
1580
|
}
|
|
1328
1581
|
// WYSIWYG — update the heading node attributes.
|
|
1329
1582
|
if (!tiptapEditor) return;
|
|
1583
|
+
const attrs = tiptapEditor.getAttributes('heading') as {
|
|
1584
|
+
dataTemplateParams?: string | null;
|
|
1585
|
+
};
|
|
1586
|
+
const dataTemplateParams = keepBlockMetaParamString(attrs.dataTemplateParams);
|
|
1330
1587
|
if (value === '') {
|
|
1331
1588
|
tiptapEditor
|
|
1332
1589
|
.chain()
|
|
1333
1590
|
.focus()
|
|
1334
|
-
.updateAttributes('heading', { dataTemplate: null, dataTemplateParams
|
|
1591
|
+
.updateAttributes('heading', { dataTemplate: null, dataTemplateParams })
|
|
1335
1592
|
.run();
|
|
1336
1593
|
} else {
|
|
1337
|
-
tiptapEditor
|
|
1594
|
+
tiptapEditor
|
|
1595
|
+
.chain()
|
|
1596
|
+
.focus()
|
|
1597
|
+
.updateAttributes('heading', { dataTemplate: value, dataTemplateParams })
|
|
1598
|
+
.run();
|
|
1338
1599
|
}
|
|
1339
1600
|
};
|
|
1340
1601
|
|
|
@@ -1377,13 +1638,36 @@ export function Toolbar({
|
|
|
1377
1638
|
monacoEditor.focus();
|
|
1378
1639
|
return;
|
|
1379
1640
|
}
|
|
1380
|
-
// WYSIWYG —
|
|
1641
|
+
// WYSIWYG — write transitions into `{[…]}` params and migrate legacy
|
|
1642
|
+
// Pandoc transition keys out of `dataBlockAttrs`.
|
|
1381
1643
|
if (!tiptapEditor) return;
|
|
1382
|
-
const attrs = tiptapEditor.getAttributes('heading') as {
|
|
1383
|
-
|
|
1384
|
-
|
|
1644
|
+
const attrs = tiptapEditor.getAttributes('heading') as {
|
|
1645
|
+
dataBlockAttrs?: string | null;
|
|
1646
|
+
dataTemplateParams?: string | null;
|
|
1647
|
+
};
|
|
1648
|
+
const updated = setHeadingAttrsTransition(attrs.dataBlockAttrs, attrs.dataTemplateParams, next);
|
|
1649
|
+
tiptapEditor
|
|
1650
|
+
.chain()
|
|
1651
|
+
.focus()
|
|
1652
|
+
.updateAttributes('heading', {
|
|
1653
|
+
dataBlockAttrs: updated.blockAttrsInner,
|
|
1654
|
+
dataTemplateParams: updated.templateParams,
|
|
1655
|
+
})
|
|
1656
|
+
.run();
|
|
1385
1657
|
};
|
|
1386
1658
|
|
|
1659
|
+
// ── Per-block controls in the overflow menu ────────────
|
|
1660
|
+
// The template + transition pickers configure the active heading block, so
|
|
1661
|
+
// when either is pushed into the ··· menu we group them under a labeled
|
|
1662
|
+
// "<name> Block:" heading. `currentTemplate`/`currentTransition` are non-null
|
|
1663
|
+
// together (both gated on a heading being active); an empty template string
|
|
1664
|
+
// is a heading with no visual template, labeled "Heading".
|
|
1665
|
+
const showTemplateInOverflow = currentTemplate !== null && clippedContextual.has('template');
|
|
1666
|
+
const showTransitionInOverflow =
|
|
1667
|
+
currentTransition !== null && clippedContextual.has('transition');
|
|
1668
|
+
const showBlockSectionInOverflow = showTemplateInOverflow || showTransitionInOverflow;
|
|
1669
|
+
const overflowBlockLabel = currentTemplate ? templateLabel(currentTemplate) : 'Heading';
|
|
1670
|
+
|
|
1387
1671
|
return (
|
|
1388
1672
|
<div
|
|
1389
1673
|
className={`squisq-toolbar ${className || ''}`}
|
|
@@ -1436,6 +1720,8 @@ export function Toolbar({
|
|
|
1436
1720
|
))}
|
|
1437
1721
|
</div>
|
|
1438
1722
|
)}
|
|
1723
|
+
{/* After-tabs slot — left side, before formatting controls (preview mode switch) */}
|
|
1724
|
+
{slotAfterTabs}
|
|
1439
1725
|
{/* Formatting buttons — hidden in preview mode and code mode */}
|
|
1440
1726
|
{!isPreview && !isCodeMode && (
|
|
1441
1727
|
<div className="squisq-toolbar-actions" ref={actionsRef}>
|
|
@@ -1455,6 +1741,7 @@ export function Toolbar({
|
|
|
1455
1741
|
key={btn.id}
|
|
1456
1742
|
ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
|
|
1457
1743
|
className={`squisq-toolbar-button${active ? ' squisq-toolbar-button--active' : ''}`}
|
|
1744
|
+
data-btn-index={BUTTON_INDEX_BY_ID.get(btn.id)}
|
|
1458
1745
|
data-tooltip={disabled ? 'Insert image (requires media provider)' : btn.title}
|
|
1459
1746
|
onClick={() => handleAction(btn.id)}
|
|
1460
1747
|
aria-label={btn.title}
|
|
@@ -1474,7 +1761,7 @@ export function Toolbar({
|
|
|
1474
1761
|
<button
|
|
1475
1762
|
ref={insertMenuButtonRef}
|
|
1476
1763
|
className={`squisq-toolbar-button${insertMenuAnchor ? ' squisq-toolbar-button--active' : ''}`}
|
|
1477
|
-
data-btn-
|
|
1764
|
+
data-btn-index={FIRST_MEDIA_INDEX}
|
|
1478
1765
|
data-tooltip="Insert..."
|
|
1479
1766
|
onClick={() => (insertMenuAnchor ? closeInsertMenu() : openInsertMenu())}
|
|
1480
1767
|
aria-label="Insert"
|
|
@@ -1487,192 +1774,202 @@ export function Toolbar({
|
|
|
1487
1774
|
|
|
1488
1775
|
{/* Template picker — visible when the cursor is in a heading.
|
|
1489
1776
|
In WYSIWYG, reads from the heading node's `dataTemplate`; in
|
|
1490
|
-
Markdown view, parses the `{[...]}` suffix on the cursor's line.
|
|
1777
|
+
Markdown view, parses the `{[...]}` suffix on the cursor's line.
|
|
1778
|
+
The `squisq-toolbar-contextual` class + data-contextual id opt
|
|
1779
|
+
this group (and the two below) into the clipping measurement:
|
|
1780
|
+
when it doesn't fit it hides here and moves into the ··· menu. */}
|
|
1491
1781
|
{currentTemplate !== null && (
|
|
1492
|
-
|
|
1782
|
+
<div
|
|
1783
|
+
className={`squisq-toolbar-group squisq-toolbar-contextual squisq-template-picker${clippedContextual.has('template') ? ' squisq-toolbar-contextual--clipped' : ''}`}
|
|
1784
|
+
data-contextual="template"
|
|
1785
|
+
>
|
|
1493
1786
|
<div className="squisq-toolbar-separator" />
|
|
1494
|
-
<
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1787
|
+
<TemplatePicker
|
|
1788
|
+
value={currentTemplate}
|
|
1789
|
+
onChange={handleTemplatePick}
|
|
1790
|
+
colorScheme={colorScheme}
|
|
1791
|
+
recommended={recommendedTemplates}
|
|
1792
|
+
previewSource={templatePreviewSource}
|
|
1793
|
+
/>
|
|
1794
|
+
</div>
|
|
1502
1795
|
)}
|
|
1503
1796
|
|
|
1504
1797
|
{/* Transition picker — visible alongside the template picker when a
|
|
1505
1798
|
block (heading) is active. Writes `transition=` into the block's
|
|
1506
1799
|
Pandoc `{…}` attribute block (the canonical home for transitions). */}
|
|
1507
1800
|
{currentTransition !== null && (
|
|
1508
|
-
|
|
1801
|
+
<div
|
|
1802
|
+
className={`squisq-toolbar-group squisq-toolbar-contextual squisq-transition-picker-group${clippedContextual.has('transition') ? ' squisq-toolbar-contextual--clipped' : ''}`}
|
|
1803
|
+
data-contextual="transition"
|
|
1804
|
+
>
|
|
1509
1805
|
<div className="squisq-toolbar-separator" />
|
|
1510
|
-
<
|
|
1511
|
-
|
|
1512
|
-
</div>
|
|
1513
|
-
</>
|
|
1806
|
+
<TransitionPicker value={currentTransition} onChange={handleTransitionPick} />
|
|
1807
|
+
</div>
|
|
1514
1808
|
)}
|
|
1515
1809
|
|
|
1516
1810
|
{/* Table controls — visible when cursor is in a table (WYSIWYG) */}
|
|
1517
1811
|
{isInTable && (
|
|
1518
|
-
|
|
1812
|
+
<div
|
|
1813
|
+
className={`squisq-toolbar-group squisq-toolbar-contextual squisq-table-controls${clippedContextual.has('table') ? ' squisq-toolbar-contextual--clipped' : ''}`}
|
|
1814
|
+
data-contextual="table"
|
|
1815
|
+
>
|
|
1519
1816
|
<div className="squisq-toolbar-separator" />
|
|
1520
|
-
<
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1817
|
+
<span className="squisq-table-controls-label">Table:</span>
|
|
1818
|
+
<button
|
|
1819
|
+
className="squisq-toolbar-button"
|
|
1820
|
+
data-tooltip="Add column before"
|
|
1821
|
+
onClick={() => tiptapEditor!.chain().focus().addColumnBefore().run()}
|
|
1822
|
+
aria-label="Add column before"
|
|
1823
|
+
>
|
|
1824
|
+
<svg
|
|
1825
|
+
width="16"
|
|
1826
|
+
height="16"
|
|
1827
|
+
viewBox="0 0 16 16"
|
|
1828
|
+
fill="none"
|
|
1829
|
+
stroke="currentColor"
|
|
1830
|
+
strokeWidth="1.5"
|
|
1831
|
+
strokeLinecap="round"
|
|
1527
1832
|
>
|
|
1528
|
-
<
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1833
|
+
<rect x="7" y="2" width="8" height="12" rx="1" />
|
|
1834
|
+
<line x1="11" y1="2" x2="11" y2="14" />
|
|
1835
|
+
<line x1="1" y1="8" x2="4.5" y2="8" />
|
|
1836
|
+
<line x1="2.75" y1="6.25" x2="2.75" y2="9.75" />
|
|
1837
|
+
</svg>
|
|
1838
|
+
</button>
|
|
1839
|
+
<button
|
|
1840
|
+
className="squisq-toolbar-button"
|
|
1841
|
+
data-tooltip="Add column after"
|
|
1842
|
+
onClick={() => tiptapEditor!.chain().focus().addColumnAfter().run()}
|
|
1843
|
+
aria-label="Add column after"
|
|
1844
|
+
>
|
|
1845
|
+
<svg
|
|
1846
|
+
width="16"
|
|
1847
|
+
height="16"
|
|
1848
|
+
viewBox="0 0 16 16"
|
|
1849
|
+
fill="none"
|
|
1850
|
+
stroke="currentColor"
|
|
1851
|
+
strokeWidth="1.5"
|
|
1852
|
+
strokeLinecap="round"
|
|
1548
1853
|
>
|
|
1549
|
-
<
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1854
|
+
<rect x="1" y="2" width="8" height="12" rx="1" />
|
|
1855
|
+
<line x1="5" y1="2" x2="5" y2="14" />
|
|
1856
|
+
<line x1="11.5" y1="8" x2="15" y2="8" />
|
|
1857
|
+
<line x1="13.25" y1="6.25" x2="13.25" y2="9.75" />
|
|
1858
|
+
</svg>
|
|
1859
|
+
</button>
|
|
1860
|
+
<button
|
|
1861
|
+
className="squisq-toolbar-button"
|
|
1862
|
+
data-tooltip="Delete column"
|
|
1863
|
+
onClick={() => tiptapEditor!.chain().focus().deleteColumn().run()}
|
|
1864
|
+
aria-label="Delete column"
|
|
1865
|
+
>
|
|
1866
|
+
<svg
|
|
1867
|
+
width="16"
|
|
1868
|
+
height="16"
|
|
1869
|
+
viewBox="0 0 16 16"
|
|
1870
|
+
fill="none"
|
|
1871
|
+
stroke="currentColor"
|
|
1872
|
+
strokeWidth="1.5"
|
|
1873
|
+
strokeLinecap="round"
|
|
1569
1874
|
>
|
|
1570
|
-
<
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1875
|
+
<rect x="4" y="1" width="8" height="14" rx="1" />
|
|
1876
|
+
<line x1="6" y1="5.5" x2="10" y2="10.5" />
|
|
1877
|
+
<line x1="10" y1="5.5" x2="6" y2="10.5" />
|
|
1878
|
+
</svg>
|
|
1879
|
+
</button>
|
|
1880
|
+
<button
|
|
1881
|
+
className="squisq-toolbar-button"
|
|
1882
|
+
data-tooltip="Add row above"
|
|
1883
|
+
onClick={() => tiptapEditor!.chain().focus().addRowBefore().run()}
|
|
1884
|
+
aria-label="Add row above"
|
|
1885
|
+
>
|
|
1886
|
+
<svg
|
|
1887
|
+
width="16"
|
|
1888
|
+
height="16"
|
|
1889
|
+
viewBox="0 0 16 16"
|
|
1890
|
+
fill="none"
|
|
1891
|
+
stroke="currentColor"
|
|
1892
|
+
strokeWidth="1.5"
|
|
1893
|
+
strokeLinecap="round"
|
|
1589
1894
|
>
|
|
1590
|
-
<
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1895
|
+
<rect x="2" y="6" width="12" height="9" rx="1" />
|
|
1896
|
+
<line x1="2" y1="10.5" x2="14" y2="10.5" />
|
|
1897
|
+
<line x1="8" y1="1" x2="8" y2="4.5" />
|
|
1898
|
+
<line x1="6.25" y1="2.75" x2="9.75" y2="2.75" />
|
|
1899
|
+
</svg>
|
|
1900
|
+
</button>
|
|
1901
|
+
<button
|
|
1902
|
+
className="squisq-toolbar-button"
|
|
1903
|
+
data-tooltip="Add row below"
|
|
1904
|
+
onClick={() => tiptapEditor!.chain().focus().addRowAfter().run()}
|
|
1905
|
+
aria-label="Add row below"
|
|
1906
|
+
>
|
|
1907
|
+
<svg
|
|
1908
|
+
width="16"
|
|
1909
|
+
height="16"
|
|
1910
|
+
viewBox="0 0 16 16"
|
|
1911
|
+
fill="none"
|
|
1912
|
+
stroke="currentColor"
|
|
1913
|
+
strokeWidth="1.5"
|
|
1914
|
+
strokeLinecap="round"
|
|
1610
1915
|
>
|
|
1611
|
-
<
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1916
|
+
<rect x="2" y="1" width="12" height="9" rx="1" />
|
|
1917
|
+
<line x1="2" y1="5.5" x2="14" y2="5.5" />
|
|
1918
|
+
<line x1="8" y1="11.5" x2="8" y2="15" />
|
|
1919
|
+
<line x1="6.25" y1="13.25" x2="9.75" y2="13.25" />
|
|
1920
|
+
</svg>
|
|
1921
|
+
</button>
|
|
1922
|
+
<button
|
|
1923
|
+
className="squisq-toolbar-button"
|
|
1924
|
+
data-tooltip="Delete row"
|
|
1925
|
+
onClick={() => tiptapEditor!.chain().focus().deleteRow().run()}
|
|
1926
|
+
aria-label="Delete row"
|
|
1927
|
+
>
|
|
1928
|
+
<svg
|
|
1929
|
+
width="16"
|
|
1930
|
+
height="16"
|
|
1931
|
+
viewBox="0 0 16 16"
|
|
1932
|
+
fill="none"
|
|
1933
|
+
stroke="currentColor"
|
|
1934
|
+
strokeWidth="1.5"
|
|
1935
|
+
strokeLinecap="round"
|
|
1631
1936
|
>
|
|
1632
|
-
<
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1937
|
+
<rect x="1" y="4" width="14" height="8" rx="1" />
|
|
1938
|
+
<line x1="5.5" y1="6" x2="10.5" y2="10" />
|
|
1939
|
+
<line x1="10.5" y1="6" x2="5.5" y2="10" />
|
|
1940
|
+
</svg>
|
|
1941
|
+
</button>
|
|
1942
|
+
<button
|
|
1943
|
+
className="squisq-toolbar-button squisq-toolbar-button--danger"
|
|
1944
|
+
data-tooltip="Delete table"
|
|
1945
|
+
onClick={() => tiptapEditor!.chain().focus().deleteTable().run()}
|
|
1946
|
+
aria-label="Delete table"
|
|
1947
|
+
>
|
|
1948
|
+
<svg
|
|
1949
|
+
width="16"
|
|
1950
|
+
height="16"
|
|
1951
|
+
viewBox="0 0 16 16"
|
|
1952
|
+
fill="none"
|
|
1953
|
+
stroke="currentColor"
|
|
1954
|
+
strokeWidth="1.5"
|
|
1955
|
+
strokeLinecap="round"
|
|
1651
1956
|
>
|
|
1652
|
-
<
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
>
|
|
1661
|
-
<rect x="1" y="1" width="14" height="14" rx="1" />
|
|
1662
|
-
<line x1="1" y1="5.5" x2="15" y2="5.5" />
|
|
1663
|
-
<line x1="5.5" y1="1" x2="5.5" y2="15" />
|
|
1664
|
-
<line x1="4.5" y1="4.5" x2="11.5" y2="11.5" strokeWidth="2" />
|
|
1665
|
-
<line x1="11.5" y1="4.5" x2="4.5" y2="11.5" strokeWidth="2" />
|
|
1666
|
-
</svg>
|
|
1667
|
-
</button>
|
|
1668
|
-
</div>
|
|
1669
|
-
</>
|
|
1957
|
+
<rect x="1" y="1" width="14" height="14" rx="1" />
|
|
1958
|
+
<line x1="1" y1="5.5" x2="15" y2="5.5" />
|
|
1959
|
+
<line x1="5.5" y1="1" x2="5.5" y2="15" />
|
|
1960
|
+
<line x1="4.5" y1="4.5" x2="11.5" y2="11.5" strokeWidth="2" />
|
|
1961
|
+
<line x1="11.5" y1="4.5" x2="4.5" y2="11.5" strokeWidth="2" />
|
|
1962
|
+
</svg>
|
|
1963
|
+
</button>
|
|
1964
|
+
</div>
|
|
1670
1965
|
)}
|
|
1671
1966
|
</div>
|
|
1672
1967
|
)}
|
|
1673
1968
|
|
|
1674
|
-
{/* Overflow menu — outside the overflow:hidden actions container
|
|
1675
|
-
|
|
1969
|
+
{/* Overflow menu — outside the overflow:hidden actions container.
|
|
1970
|
+
Also appears when a contextual group (template/transition picker,
|
|
1971
|
+
table controls) is clipped, even if every plain button fits. */}
|
|
1972
|
+
{!isPreview && !isCodeMode && (overflowIndex !== null || clippedContextual.size > 0) && (
|
|
1676
1973
|
<div className="squisq-toolbar-overflow" ref={overflowRef}>
|
|
1677
1974
|
<button
|
|
1678
1975
|
className={`squisq-toolbar-button squisq-toolbar-overflow-trigger${showOverflow ? ' squisq-toolbar-button--active' : ''}`}
|
|
@@ -1687,7 +1984,7 @@ export function Toolbar({
|
|
|
1687
1984
|
<div
|
|
1688
1985
|
className={`squisq-toolbar-overflow-menu squisq-toolbar-overflow-menu--${overflowPlacement}`}
|
|
1689
1986
|
>
|
|
1690
|
-
{BUTTONS.slice(overflowIndex)
|
|
1987
|
+
{BUTTONS.slice(overflowIndex ?? BUTTONS.length)
|
|
1691
1988
|
.filter((b) => isButtonVisible(b.id))
|
|
1692
1989
|
// Media buttons are represented by the synthetic Insert dropdown
|
|
1693
1990
|
// in both the visible toolbar and the overflow menu.
|
|
@@ -1741,23 +2038,48 @@ export function Toolbar({
|
|
|
1741
2038
|
</button>
|
|
1742
2039
|
)}
|
|
1743
2040
|
|
|
1744
|
-
{/*
|
|
1745
|
-
|
|
2041
|
+
{/* Per-block section — the template + transition pickers move
|
|
2042
|
+
here when clipped, under a divider and a "<name> Block:"
|
|
2043
|
+
heading that names the block they configure. */}
|
|
2044
|
+
{showBlockSectionInOverflow && (
|
|
2045
|
+
<>
|
|
2046
|
+
<div
|
|
2047
|
+
className="squisq-toolbar-separator"
|
|
2048
|
+
style={{ margin: '4px 0', width: '100%', height: 1 }}
|
|
2049
|
+
/>
|
|
2050
|
+
<div className="squisq-toolbar-overflow-heading">{overflowBlockLabel} Block:</div>
|
|
2051
|
+
</>
|
|
2052
|
+
)}
|
|
2053
|
+
|
|
2054
|
+
{/* Contextual: template picker in overflow — only when the
|
|
2055
|
+
inline one is clipped, so the control lives in exactly one
|
|
2056
|
+
visible place at a time. */}
|
|
2057
|
+
{showTemplateInOverflow && (
|
|
1746
2058
|
<div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
|
|
1747
|
-
<span>Template:</span>
|
|
1748
2059
|
<TemplatePicker
|
|
1749
|
-
value={currentTemplate}
|
|
2060
|
+
value={currentTemplate!}
|
|
1750
2061
|
onChange={(v) => {
|
|
1751
2062
|
handleTemplatePick(v);
|
|
1752
2063
|
setShowOverflow(false);
|
|
1753
2064
|
}}
|
|
2065
|
+
colorScheme={colorScheme}
|
|
1754
2066
|
recommended={recommendedTemplates}
|
|
2067
|
+
previewSource={templatePreviewSource}
|
|
1755
2068
|
/>
|
|
1756
2069
|
</div>
|
|
1757
2070
|
)}
|
|
1758
2071
|
|
|
2072
|
+
{/* Contextual: transition picker in overflow — mirrors the
|
|
2073
|
+
template picker above. The menu stays open on change since
|
|
2074
|
+
the flyout carries follow-up controls (direction/duration). */}
|
|
2075
|
+
{showTransitionInOverflow && (
|
|
2076
|
+
<div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
|
|
2077
|
+
<TransitionPicker value={currentTransition!} onChange={handleTransitionPick} />
|
|
2078
|
+
</div>
|
|
2079
|
+
)}
|
|
2080
|
+
|
|
1759
2081
|
{/* Contextual: table controls in overflow */}
|
|
1760
|
-
{isInTable && (
|
|
2082
|
+
{isInTable && clippedContextual.has('table') && (
|
|
1761
2083
|
<>
|
|
1762
2084
|
<div
|
|
1763
2085
|
className="squisq-toolbar-separator"
|
|
@@ -1813,9 +2135,12 @@ export function Toolbar({
|
|
|
1813
2135
|
|
|
1814
2136
|
{/* After-actions slot — after formatting controls */}
|
|
1815
2137
|
{slotAfterActions}
|
|
1816
|
-
{/* Spacer —
|
|
1817
|
-
|
|
1818
|
-
|
|
2138
|
+
{/* Spacer — pushes right-side items to the end when the flex:1 actions
|
|
2139
|
+
container isn't rendered. In preview mode PreviewToolbarControls
|
|
2140
|
+
supplies its own flex:1 filler (and measures that leftover width to
|
|
2141
|
+
decide whether to collapse), so a second spacer here would split the
|
|
2142
|
+
slack and make the controls collapse too early. */}
|
|
2143
|
+
{isCodeMode && <div style={{ flex: 1 }} />}
|
|
1819
2144
|
{/* Version history — renders only when the host enabled versioning
|
|
1820
2145
|
and a container is wired up. The component owns its own button
|
|
1821
2146
|
and popover; we just give it a slot in the toolbar. */}
|
|
@@ -1853,11 +2178,16 @@ export function Toolbar({
|
|
|
1853
2178
|
<button
|
|
1854
2179
|
className={`squisq-toolbar-button squisq-toolbar-files-toggle${showFiles ? ' squisq-toolbar-button--active' : ''}`}
|
|
1855
2180
|
onClick={onToggleFiles}
|
|
1856
|
-
data-tooltip={showFiles ? 'Hide
|
|
2181
|
+
data-tooltip={`${showFiles ? 'Hide' : 'Show'} Files panel${resolvedFileCount > 0 ? ` (${fileCountLabel(resolvedFileCount)})` : ''}`}
|
|
1857
2182
|
aria-pressed={showFiles}
|
|
1858
|
-
aria-label=
|
|
2183
|
+
aria-label={`Toggle Files panel${resolvedFileCount > 0 ? `, ${fileCountLabel(resolvedFileCount)}` : ''}`}
|
|
1859
2184
|
>
|
|
1860
2185
|
<Icon icon="fa-solid fa-paperclip" />
|
|
2186
|
+
{resolvedFileCount > 0 && (
|
|
2187
|
+
<span className="squisq-toolbar-files-badge" aria-hidden="true">
|
|
2188
|
+
{fileCountBadge(resolvedFileCount)}
|
|
2189
|
+
</span>
|
|
2190
|
+
)}
|
|
1861
2191
|
</button>
|
|
1862
2192
|
)}
|
|
1863
2193
|
{/* Right slot — rightmost end of toolbar */}
|
|
@@ -1897,7 +2227,7 @@ export function Toolbar({
|
|
|
1897
2227
|
<div
|
|
1898
2228
|
ref={insertMenuRef}
|
|
1899
2229
|
className="squisq-insert-menu"
|
|
1900
|
-
data-theme={
|
|
2230
|
+
data-theme={colorScheme}
|
|
1901
2231
|
style={{ position: 'fixed', top: insertMenuAnchor.top, left: insertMenuAnchor.left }}
|
|
1902
2232
|
role="menu"
|
|
1903
2233
|
>
|
|
@@ -1937,7 +2267,7 @@ export function Toolbar({
|
|
|
1937
2267
|
onSelect={handleEmojiSelect}
|
|
1938
2268
|
onClose={closeEmojiPicker}
|
|
1939
2269
|
anchorRef={emojiButtonRef as React.RefObject<HTMLElement>}
|
|
1940
|
-
theme={
|
|
2270
|
+
theme={colorScheme === 'dark' ? 'dark' : 'light'}
|
|
1941
2271
|
style={{
|
|
1942
2272
|
position: 'fixed',
|
|
1943
2273
|
top: emojiPickerAnchor.top,
|