@bendyline/squisq-editor-react 1.5.3 → 1.6.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/index.d.ts +757 -20
- package/dist/index.js +16875 -6883
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +143 -0
- package/src/EditorShell.tsx +200 -120
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +38 -3
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +180 -8
- package/src/RawEditor.tsx +169 -13
- package/src/RecorderEntry.tsx +9 -16
- package/src/TemplateAnnotation.ts +44 -0
- package/src/TemplatePicker.tsx +329 -54
- package/src/ThemeCustomizerPanel.tsx +30 -336
- package/src/ThemePicker.tsx +112 -3
- package/src/TimelineBlockPreview.tsx +37 -0
- package/src/TimelineTrack.tsx +671 -0
- package/src/Toolbar.tsx +528 -174
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +2 -14
- package/src/ViewMenuPanel.tsx +17 -14
- package/src/WysiwygEditor.tsx +161 -65
- package/src/__tests__/blockProperties.test.ts +92 -0
- package/src/__tests__/blockRange.test.ts +105 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
- package/src/__tests__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
- package/src/__tests__/embeddedMedia.test.ts +48 -0
- package/src/__tests__/headingTransition.test.ts +138 -0
- package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
- package/src/__tests__/recorderMediaInsert.test.ts +86 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
- package/src/__tests__/templatePickerMetadata.test.ts +32 -0
- package/src/__tests__/timelineSource.test.ts +134 -0
- package/src/__tests__/tiptapBridge.test.ts +92 -0
- package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
- package/src/__tests__/tooltip.test.tsx +72 -0
- package/src/__tests__/transitionCatalog.test.ts +64 -0
- package/src/__tests__/useBlockNavigator.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +24 -0
- package/src/__tests__/useTimelineClock.test.ts +21 -0
- package/src/blockProperties.ts +88 -0
- package/src/blockRange.ts +132 -0
- package/src/buildPreviewDoc.ts +98 -9
- package/src/customTemplates/AddBin.tsx +126 -0
- package/src/customTemplates/CustomLayoutManager.tsx +233 -0
- package/src/customTemplates/CustomTemplateContext.tsx +182 -0
- package/src/customTemplates/LayerToolbar.tsx +580 -0
- package/src/customTemplates/ShapeGlyph.tsx +47 -0
- package/src/customTemplates/TemplateDesigner.tsx +430 -0
- package/src/customTemplates/__tests__/library.test.ts +88 -0
- package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
- package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
- package/src/customTemplates/designer.css +673 -0
- package/src/customTemplates/index.ts +31 -0
- package/src/customTemplates/library.ts +97 -0
- package/src/customTemplates/normalizePositions.ts +75 -0
- package/src/customTemplates/shapeDefs.ts +131 -0
- package/src/customTemplates/thumbnail.tsx +63 -0
- package/src/customTemplates/tokenDefs.ts +60 -0
- package/src/customTemplates/useDocCustomTemplates.ts +52 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
- package/src/customThemes/CustomThemeContext.tsx +179 -0
- package/src/customThemes/CustomThemeDialog.tsx +286 -0
- package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
- package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
- package/src/customThemes/customThemeLibrary.ts +97 -0
- package/src/customThemes/index.ts +31 -0
- package/src/customThemes/themeControls.tsx +229 -0
- package/src/customThemes/themeDraft.ts +272 -0
- package/src/customThemes/useDocCustomThemes.ts +49 -0
- package/src/diagram/DiagramCanvas.tsx +240 -0
- package/src/diagram/DiagramExtension.ts +209 -0
- package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
- package/src/diagram/DiagramWidget.tsx +270 -0
- package/src/diagram/diagramCommands.ts +604 -0
- package/src/diagram/diagramConstants.ts +17 -0
- package/src/diagram/useDiagramData.ts +126 -0
- package/src/embeddedMedia.ts +78 -0
- package/src/frontmatter.ts +29 -0
- package/src/headingTransition.ts +231 -0
- package/src/imageEditor/CanvasSurface.tsx +383 -88
- package/src/imageEditor/PropertiesPanel.tsx +47 -1
- package/src/imageEditor/Toolbar.tsx +229 -16
- package/src/imageEditor/createShapeLayer.ts +280 -0
- package/src/imageEditor/icons.tsx +34 -114
- package/src/imageEditor/image-editor.css +54 -5
- package/src/imageEditor/state.ts +23 -3
- package/src/index.ts +77 -0
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +8 -1
- package/src/recorder/insertMediaBlock.ts +30 -0
- package/src/resolveBlockVisual.ts +33 -0
- package/src/scene/Scene.tsx +540 -0
- package/src/scene/SceneBlockExtension.ts +198 -0
- package/src/scene/SceneBlockToolbar.tsx +201 -0
- package/src/scene/SceneBlockWidget.tsx +434 -0
- package/src/scene/ScenePropsBar.tsx +85 -0
- package/src/scene/SceneSelection.tsx +107 -0
- package/src/scene/SceneViewport.tsx +102 -0
- package/src/scene/ShapePalette.tsx +181 -0
- package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
- package/src/scene/__tests__/bezierEdit.test.ts +85 -0
- package/src/scene/__tests__/blockLayers.test.ts +57 -0
- package/src/scene/__tests__/shapeLayers.test.ts +106 -0
- package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
- package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
- package/src/scene/adapters/DiagramAdapter.ts +168 -0
- package/src/scene/adapters/DrawingAdapter.ts +415 -0
- package/src/scene/adapters/LayoutAdapter.ts +310 -0
- package/src/scene/adapters/blockLayers.ts +159 -0
- package/src/scene/commands/SceneCommand.ts +70 -0
- package/src/scene/commands/drawingCommands.ts +318 -0
- package/src/scene/commands/layoutCommands.ts +301 -0
- package/src/scene/hooks/useSceneHitTest.ts +105 -0
- package/src/scene/hooks/useScenePanZoom.ts +147 -0
- package/src/scene/hooks/useSceneSelection.ts +62 -0
- package/src/scene/index.ts +95 -0
- package/src/scene/layers/DiagramEdges.tsx +127 -0
- package/src/scene/layers/edgeGeometry.ts +77 -0
- package/src/scene/layers/nodeCard.tsx +145 -0
- package/src/scene/layers/renderLayer.tsx +70 -0
- package/src/scene/layers/shapeLayers.ts +201 -0
- package/src/scene/paths/bezierEdit.ts +208 -0
- package/src/scene/scene.css +649 -0
- package/src/scene/text/SceneTextOverlay.tsx +161 -0
- package/src/scene/text/sceneTextChannel.ts +40 -0
- package/src/scene/text/sceneTextConfig.ts +27 -0
- package/src/scene/text/sceneTiptap.ts +36 -0
- package/src/scene/text/useSceneTextEditing.ts +39 -0
- package/src/scene/tools/ConnectTool.ts +111 -0
- package/src/scene/tools/DrawingConnectTool.ts +161 -0
- package/src/scene/tools/PathTool.ts +158 -0
- package/src/scene/tools/PlaceTool.ts +47 -0
- package/src/scene/tools/SceneTool.ts +75 -0
- package/src/scene/tools/SelectTool.ts +284 -0
- package/src/scene/tools/ShapeTool.ts +144 -0
- package/src/scene/tools/TextTool.ts +72 -0
- package/src/scene/tools/TokenTool.ts +95 -0
- package/src/scene/tools/createDrawShapeTool.ts +82 -0
- package/src/styles/diagram.css +183 -0
- package/src/styles/editor.css +1615 -203
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +4 -0
- package/src/timelineSource.ts +244 -0
- package/src/tiptapBridge.ts +115 -34
- package/src/tooltipPlacement.ts +13 -0
- package/src/transitionCatalog.ts +159 -0
- package/src/types/monaco-shims.d.ts +10 -0
- package/src/useBlockNavigator.ts +153 -0
- package/src/useMonacoLoader.ts +23 -1
- package/src/useTimelineClock.ts +76 -0
- package/src/utils/dropUtils.ts +1 -1
package/src/Toolbar.tsx
CHANGED
|
@@ -10,17 +10,30 @@
|
|
|
10
10
|
import type { ReactNode } from 'react';
|
|
11
11
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
|
12
12
|
import type { Editor as TiptapEditor } from '@tiptap/core';
|
|
13
|
+
import { TextSelection } from '@tiptap/pm/state';
|
|
14
|
+
import { Fragment } from '@tiptap/pm/model';
|
|
13
15
|
import type { IRange } from 'monaco-editor';
|
|
14
16
|
import { useEditorContext, type EditorView } from './EditorContext';
|
|
15
17
|
import { VersionHistoryPanel } from './VersionHistoryPanel';
|
|
16
18
|
import { RecorderEntry } from './RecorderEntry';
|
|
17
19
|
import { ViewMenuPanel } from './ViewMenuPanel';
|
|
18
20
|
import { TemplatePicker, TEMPLATE_NAMES } from './TemplatePicker';
|
|
21
|
+
import { TransitionPicker } from './TransitionPicker';
|
|
22
|
+
import {
|
|
23
|
+
readHeadingLineTransition,
|
|
24
|
+
setHeadingLineTransition,
|
|
25
|
+
readBlockAttrsTransition,
|
|
26
|
+
setBlockAttrsTransition,
|
|
27
|
+
EMPTY_TRANSITION,
|
|
28
|
+
type TransitionFields,
|
|
29
|
+
} from './headingTransition';
|
|
19
30
|
import { profileBlockContents, recommendTemplatesForBlock } from '@bendyline/squisq/recommend';
|
|
20
31
|
import { findBlockSliceAtLine, findBlockSliceByHeadingIndex } from './blockSlice';
|
|
21
32
|
import { LinkDialog } from './LinkDialog';
|
|
22
33
|
import { DocumentSettingsDialog } from './DocumentSettingsDialog';
|
|
23
34
|
import { EmojiPicker, EMOJI_PICKER_WIDTH, EMOJI_PICKER_MAX_HEIGHT } from './EmojiPicker';
|
|
35
|
+
import { CustomLayoutManager } from './customTemplates/CustomLayoutManager';
|
|
36
|
+
import { Icon } from './Icon';
|
|
24
37
|
import type { PickerEntry } from './emojiData';
|
|
25
38
|
import { createPortal } from 'react-dom';
|
|
26
39
|
|
|
@@ -54,11 +67,12 @@ export interface ToolbarProps {
|
|
|
54
67
|
interface ToolbarButton {
|
|
55
68
|
id: string;
|
|
56
69
|
label: string;
|
|
70
|
+
/** Text glyph shown when the button has no Font Awesome icon (headings). */
|
|
57
71
|
icon: string;
|
|
58
72
|
title: string;
|
|
59
73
|
group: 'format' | 'lists' | 'structure' | 'insert' | 'media';
|
|
60
|
-
/**
|
|
61
|
-
|
|
74
|
+
/** Font Awesome class string (e.g. `"fa-solid fa-bold"`); omitted for text buttons. */
|
|
75
|
+
faIcon?: string;
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
const BUTTONS: ToolbarButton[] = [
|
|
@@ -69,7 +83,7 @@ const BUTTONS: ToolbarButton[] = [
|
|
|
69
83
|
icon: 'B',
|
|
70
84
|
title: 'Bold (Ctrl+B)',
|
|
71
85
|
group: 'format',
|
|
72
|
-
|
|
86
|
+
faIcon: 'fa-solid fa-bold',
|
|
73
87
|
},
|
|
74
88
|
{
|
|
75
89
|
id: 'italic',
|
|
@@ -77,7 +91,7 @@ const BUTTONS: ToolbarButton[] = [
|
|
|
77
91
|
icon: 'I',
|
|
78
92
|
title: 'Italic (Ctrl+I)',
|
|
79
93
|
group: 'format',
|
|
80
|
-
|
|
94
|
+
faIcon: 'fa-solid fa-italic',
|
|
81
95
|
},
|
|
82
96
|
{
|
|
83
97
|
id: 'strikethrough',
|
|
@@ -85,15 +99,30 @@ const BUTTONS: ToolbarButton[] = [
|
|
|
85
99
|
icon: 'S',
|
|
86
100
|
title: 'Strikethrough',
|
|
87
101
|
group: 'format',
|
|
88
|
-
|
|
102
|
+
faIcon: 'fa-solid fa-strikethrough',
|
|
89
103
|
},
|
|
90
104
|
|
|
91
105
|
// Lists group — sits between format and structure so bullets/numbers
|
|
92
106
|
// are adjacent to the inline formatters people reach for together.
|
|
93
|
-
{
|
|
94
|
-
|
|
107
|
+
{
|
|
108
|
+
id: 'ul',
|
|
109
|
+
label: '•',
|
|
110
|
+
icon: '•',
|
|
111
|
+
title: 'Bullet list',
|
|
112
|
+
group: 'lists',
|
|
113
|
+
faIcon: 'fa-solid fa-list-ul',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'ol',
|
|
117
|
+
label: '1.',
|
|
118
|
+
icon: '1.',
|
|
119
|
+
title: 'Numbered list',
|
|
120
|
+
group: 'lists',
|
|
121
|
+
faIcon: 'fa-solid fa-list-ol',
|
|
122
|
+
},
|
|
95
123
|
|
|
96
|
-
// Structure group
|
|
124
|
+
// Structure group — headings keep their text labels; Font Awesome Free
|
|
125
|
+
// has no numbered (H1–H6) heading glyphs, and the numerals are clearer.
|
|
97
126
|
{ id: 'h1', label: 'H1', icon: 'H1', title: 'Heading 1', group: 'structure' },
|
|
98
127
|
{ id: 'h2', label: 'H2', icon: 'H2', title: 'Heading 2', group: 'structure' },
|
|
99
128
|
{ id: 'h3', label: 'H3', icon: 'H3', title: 'Heading 3', group: 'structure' },
|
|
@@ -102,119 +131,105 @@ const BUTTONS: ToolbarButton[] = [
|
|
|
102
131
|
{ id: 'h6', label: 'H6', icon: 'H6', title: 'Heading 6', group: 'structure' },
|
|
103
132
|
|
|
104
133
|
// Insert group — block-level inserts (quote, code blocks, rules)
|
|
105
|
-
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
134
|
+
{
|
|
135
|
+
id: 'quote',
|
|
136
|
+
label: '❝',
|
|
137
|
+
icon: '❝',
|
|
138
|
+
title: 'Blockquote',
|
|
139
|
+
group: 'insert',
|
|
140
|
+
faIcon: 'fa-solid fa-quote-left',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'codeblock',
|
|
144
|
+
label: '{ }',
|
|
145
|
+
icon: '{ }',
|
|
146
|
+
title: 'Code block',
|
|
147
|
+
group: 'insert',
|
|
148
|
+
faIcon: 'fa-solid fa-file-code',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: 'code',
|
|
152
|
+
label: '</>',
|
|
153
|
+
icon: '</>',
|
|
154
|
+
title: 'Inline code',
|
|
155
|
+
group: 'insert',
|
|
156
|
+
faIcon: 'fa-solid fa-code',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: 'hr',
|
|
160
|
+
label: '—',
|
|
161
|
+
icon: '—',
|
|
162
|
+
title: 'Horizontal rule',
|
|
163
|
+
group: 'insert',
|
|
164
|
+
faIcon: 'fa-solid fa-minus',
|
|
165
|
+
},
|
|
109
166
|
|
|
110
167
|
// Media group — links, tables, images, emoji
|
|
111
|
-
{
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
168
|
+
{
|
|
169
|
+
id: 'link',
|
|
170
|
+
label: '🔗',
|
|
171
|
+
icon: '🔗',
|
|
172
|
+
title: 'Insert link',
|
|
173
|
+
group: 'media',
|
|
174
|
+
faIcon: 'fa-solid fa-link',
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 'table',
|
|
178
|
+
label: 'table',
|
|
179
|
+
icon: '',
|
|
180
|
+
title: 'Insert table',
|
|
181
|
+
group: 'media',
|
|
182
|
+
faIcon: 'fa-solid fa-table',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'diagram',
|
|
186
|
+
label: 'diagram',
|
|
187
|
+
icon: '',
|
|
188
|
+
title: 'Insert diagram',
|
|
189
|
+
group: 'media',
|
|
190
|
+
faIcon: 'fa-solid fa-diagram-project',
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'drawing',
|
|
194
|
+
label: 'drawing',
|
|
195
|
+
icon: '',
|
|
196
|
+
title: 'Insert drawing',
|
|
197
|
+
group: 'media',
|
|
198
|
+
faIcon: 'fa-solid fa-pen-nib',
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'layout',
|
|
202
|
+
label: 'layout',
|
|
203
|
+
icon: '',
|
|
204
|
+
title: 'Insert layout',
|
|
205
|
+
group: 'media',
|
|
206
|
+
faIcon: 'fa-solid fa-object-group',
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: 'image',
|
|
210
|
+
label: '🖼',
|
|
211
|
+
icon: '🖼',
|
|
212
|
+
title: 'Insert image',
|
|
213
|
+
group: 'media',
|
|
214
|
+
faIcon: 'fa-solid fa-image',
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: 'emoji',
|
|
218
|
+
label: '😊',
|
|
219
|
+
icon: '😊',
|
|
220
|
+
title: 'Insert emoji',
|
|
221
|
+
group: 'media',
|
|
222
|
+
faIcon: 'fa-solid fa-face-smile',
|
|
223
|
+
},
|
|
115
224
|
];
|
|
116
225
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
fill="none"
|
|
125
|
-
stroke="currentColor"
|
|
126
|
-
strokeWidth="1.4"
|
|
127
|
-
strokeLinecap="round"
|
|
128
|
-
>
|
|
129
|
-
<rect x="1" y="1" width="12" height="12" rx="1" />
|
|
130
|
-
<line x1="1" y1="5" x2="13" y2="5" />
|
|
131
|
-
<line x1="1" y1="9" x2="13" y2="9" />
|
|
132
|
-
<line x1="5" y1="1" x2="5" y2="13" />
|
|
133
|
-
<line x1="9" y1="1" x2="9" y2="13" />
|
|
134
|
-
</svg>
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
const LINK_ICON = (
|
|
138
|
-
<svg
|
|
139
|
-
width="14"
|
|
140
|
-
height="14"
|
|
141
|
-
viewBox="0 0 14 14"
|
|
142
|
-
fill="none"
|
|
143
|
-
stroke="currentColor"
|
|
144
|
-
strokeWidth="1.4"
|
|
145
|
-
strokeLinecap="round"
|
|
146
|
-
strokeLinejoin="round"
|
|
147
|
-
>
|
|
148
|
-
<path d="M5.75 8.25 L8.25 5.75" />
|
|
149
|
-
<path d="M6.5 3.75 L8 2.25 a2.5 2.5 0 0 1 3.54 3.54 L10 7.25" />
|
|
150
|
-
<path d="M7.5 10.25 L6 11.75 a2.5 2.5 0 0 1 -3.54 -3.54 L4 6.75" />
|
|
151
|
-
</svg>
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
const IMAGE_ICON = (
|
|
155
|
-
<svg
|
|
156
|
-
width="14"
|
|
157
|
-
height="14"
|
|
158
|
-
viewBox="0 0 14 14"
|
|
159
|
-
fill="none"
|
|
160
|
-
stroke="currentColor"
|
|
161
|
-
strokeWidth="1.4"
|
|
162
|
-
strokeLinecap="round"
|
|
163
|
-
strokeLinejoin="round"
|
|
164
|
-
>
|
|
165
|
-
<rect x="1.5" y="2.5" width="11" height="9" rx="1" />
|
|
166
|
-
<circle cx="5" cy="5.5" r="0.9" />
|
|
167
|
-
<path d="M2 10 L5.5 7 L8 9 L10 7.5 L12.5 10" />
|
|
168
|
-
</svg>
|
|
169
|
-
);
|
|
170
|
-
|
|
171
|
-
const PAPERCLIP_ICON = (
|
|
172
|
-
<svg
|
|
173
|
-
width="14"
|
|
174
|
-
height="14"
|
|
175
|
-
viewBox="0 0 14 14"
|
|
176
|
-
fill="none"
|
|
177
|
-
stroke="currentColor"
|
|
178
|
-
strokeWidth="1.4"
|
|
179
|
-
strokeLinecap="round"
|
|
180
|
-
strokeLinejoin="round"
|
|
181
|
-
>
|
|
182
|
-
<path d="M11 4 L5.5 9.5 a1.75 1.75 0 0 0 2.5 2.5 L12.5 7.5 a3 3 0 0 0 -4.25 -4.25 L3 8.5 a4.25 4.25 0 0 0 6 6 L13 10.5" />
|
|
183
|
-
</svg>
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
const EMOJI_ICON = (
|
|
187
|
-
<svg
|
|
188
|
-
width="14"
|
|
189
|
-
height="14"
|
|
190
|
-
viewBox="0 0 14 14"
|
|
191
|
-
fill="none"
|
|
192
|
-
stroke="currentColor"
|
|
193
|
-
strokeWidth="1.4"
|
|
194
|
-
strokeLinecap="round"
|
|
195
|
-
strokeLinejoin="round"
|
|
196
|
-
>
|
|
197
|
-
<circle cx="7" cy="7" r="5.25" />
|
|
198
|
-
<circle cx="5.25" cy="5.75" r="0.6" fill="currentColor" stroke="none" />
|
|
199
|
-
<circle cx="8.75" cy="5.75" r="0.6" fill="currentColor" stroke="none" />
|
|
200
|
-
<path d="M4.75 8.5 a2.5 2.5 0 0 0 4.5 0" />
|
|
201
|
-
</svg>
|
|
202
|
-
);
|
|
203
|
-
|
|
204
|
-
/** Returns an SVG element when the button id maps to one, otherwise null. */
|
|
205
|
-
function buttonIconSvg(id: string): React.ReactNode | null {
|
|
206
|
-
switch (id) {
|
|
207
|
-
case 'table':
|
|
208
|
-
return TABLE_ICON;
|
|
209
|
-
case 'link':
|
|
210
|
-
return LINK_ICON;
|
|
211
|
-
case 'image':
|
|
212
|
-
return IMAGE_ICON;
|
|
213
|
-
case 'emoji':
|
|
214
|
-
return EMOJI_ICON;
|
|
215
|
-
default:
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
226
|
+
const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
|
|
227
|
+
const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
|
|
228
|
+
const INSERT_MENU_WIDTH = 200;
|
|
229
|
+
|
|
230
|
+
/** Renders a button's icon: a Font Awesome glyph when set, else the text label. */
|
|
231
|
+
function buttonIcon(btn: ToolbarButton): ReactNode {
|
|
232
|
+
return btn.faIcon ? <Icon icon={btn.faIcon} /> : btn.icon;
|
|
218
233
|
}
|
|
219
234
|
|
|
220
235
|
// ─── Tiptap active-state map ────────────────────────────
|
|
@@ -256,6 +271,96 @@ function isTiptapActive(editor: TiptapEditor, id: string): boolean {
|
|
|
256
271
|
}
|
|
257
272
|
}
|
|
258
273
|
|
|
274
|
+
// ─── Scene-block inserts (diagram / drawing / layout) ───
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A freshly-inserted layout starts with one centered text layer so the
|
|
278
|
+
* canvas isn't a blank surface. Each layer is a readable child sub-block
|
|
279
|
+
* (`### {#id} {[type …]}`); a text layer's content is its markdown body.
|
|
280
|
+
* Coordinates are absolute within the 1920×1080 scene viewport
|
|
281
|
+
* (`SceneBlockWidget`). See `scene/commands/layoutCommands.ts`.
|
|
282
|
+
*/
|
|
283
|
+
const LAYOUT_STARTER_TEXT_PARAMS =
|
|
284
|
+
'x=360 y=380 width=1200 height=320 fontSize=64 fontWeight=bold align=center valign=middle color="#1e293b"';
|
|
285
|
+
/** Multi-line markdown for a new layout (raw / code views). */
|
|
286
|
+
const LAYOUT_STARTER_MARKDOWN = `\n## Layout {[layout]}\n\n### {#text-1} {[text ${LAYOUT_STARTER_TEXT_PARAMS}]}\n\nLayout\n`;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Insert a block-level heading carrying a Scene template (`diagram` /
|
|
290
|
+
* `drawing` / `layout`) at the top level, after the block the caret sits
|
|
291
|
+
* in. Going through a command (rather than `insertContent` at the caret)
|
|
292
|
+
* keeps the heading from being coerced into inline text when the caret is
|
|
293
|
+
* nested inside a list item or other block. The matching extension
|
|
294
|
+
* (DiagramExtension / SceneBlockExtension) then mounts the editable canvas
|
|
295
|
+
* right below it.
|
|
296
|
+
*/
|
|
297
|
+
function insertTemplateHeading(
|
|
298
|
+
editor: TiptapEditor,
|
|
299
|
+
opts: { template: string; text: string; level?: number; blockAttrs?: string | null },
|
|
300
|
+
): void {
|
|
301
|
+
editor
|
|
302
|
+
.chain()
|
|
303
|
+
.focus()
|
|
304
|
+
.command(({ tr, state, dispatch }) => {
|
|
305
|
+
const headingType = state.schema.nodes.heading;
|
|
306
|
+
if (!headingType) return false;
|
|
307
|
+
const heading = headingType.create(
|
|
308
|
+
{
|
|
309
|
+
level: opts.level ?? 2,
|
|
310
|
+
dataTemplate: opts.template,
|
|
311
|
+
dataBlockAttrs: opts.blockAttrs ?? null,
|
|
312
|
+
},
|
|
313
|
+
state.schema.text(opts.text),
|
|
314
|
+
);
|
|
315
|
+
const { $from } = state.selection;
|
|
316
|
+
const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
|
|
317
|
+
if (dispatch) {
|
|
318
|
+
tr.insert(insertPos, heading);
|
|
319
|
+
// Drop the caret into the new heading's text so it can be renamed.
|
|
320
|
+
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
323
|
+
})
|
|
324
|
+
.run();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Insert a `{[layout]}` block seeded with one text layer. The layer is a
|
|
329
|
+
* child sub-block heading (`### {#text-1} {[text …]}`) whose markdown body
|
|
330
|
+
* ("Layout") is the text content — the readable counterpart to the old
|
|
331
|
+
* base64 `layers=` blob. The parent + child + body go in as one fragment so
|
|
332
|
+
* the layout widget mounts with the seed already present.
|
|
333
|
+
*/
|
|
334
|
+
function insertLayoutBlock(editor: TiptapEditor): void {
|
|
335
|
+
editor
|
|
336
|
+
.chain()
|
|
337
|
+
.focus()
|
|
338
|
+
.command(({ tr, state, dispatch }) => {
|
|
339
|
+
const headingType = state.schema.nodes.heading;
|
|
340
|
+
const paragraphType = state.schema.nodes.paragraph;
|
|
341
|
+
if (!headingType || !paragraphType) return false;
|
|
342
|
+
const parent = headingType.create(
|
|
343
|
+
{ level: 2, dataTemplate: 'layout' },
|
|
344
|
+
state.schema.text('Layout'),
|
|
345
|
+
);
|
|
346
|
+
const child = headingType.create({
|
|
347
|
+
level: 3,
|
|
348
|
+
dataTemplate: 'text',
|
|
349
|
+
dataTemplateParams: LAYOUT_STARTER_TEXT_PARAMS,
|
|
350
|
+
dataBlockAttrs: '#text-1',
|
|
351
|
+
});
|
|
352
|
+
const body = paragraphType.create(null, state.schema.text('Layout'));
|
|
353
|
+
const { $from } = state.selection;
|
|
354
|
+
const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
|
|
355
|
+
if (dispatch) {
|
|
356
|
+
tr.insert(insertPos, Fragment.fromArray([parent, child, body]));
|
|
357
|
+
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
|
|
358
|
+
}
|
|
359
|
+
return true;
|
|
360
|
+
})
|
|
361
|
+
.run();
|
|
362
|
+
}
|
|
363
|
+
|
|
259
364
|
/**
|
|
260
365
|
* Formatting toolbar.
|
|
261
366
|
* - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
|
|
@@ -277,6 +382,7 @@ export function Toolbar({
|
|
|
277
382
|
setMarkdownSource,
|
|
278
383
|
tiptapEditor,
|
|
279
384
|
monacoEditor,
|
|
385
|
+
activeSceneText,
|
|
280
386
|
mediaProvider,
|
|
281
387
|
editorMode,
|
|
282
388
|
versioning,
|
|
@@ -284,6 +390,11 @@ export function Toolbar({
|
|
|
284
390
|
documentLinkProvider,
|
|
285
391
|
theme,
|
|
286
392
|
} = useEditorContext();
|
|
393
|
+
// When a canvas textbox is being edited, its Tiptap instance takes over
|
|
394
|
+
// the formatting buttons; otherwise they drive the document editor. The
|
|
395
|
+
// `level` gates which buttons apply (inline labels vs. rich textboxes).
|
|
396
|
+
const formattingEditor = activeSceneText?.editor ?? tiptapEditor;
|
|
397
|
+
const sceneTextLevel = activeSceneText?.level ?? null;
|
|
287
398
|
const isCodeMode = editorMode === 'code';
|
|
288
399
|
// In code mode only the raw view is meaningful; the WYSIWYG and Preview
|
|
289
400
|
// surfaces aren't mounted, so hide their tabs.
|
|
@@ -347,17 +458,30 @@ export function Toolbar({
|
|
|
347
458
|
|
|
348
459
|
const closeEmojiPicker = useCallback(() => setEmojiPickerAnchor(null), []);
|
|
349
460
|
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
461
|
+
// Insert menu — toolbar-anchored dropdown that replaces the individual media-group
|
|
462
|
+
// buttons with a single "+" button. Portaled out to avoid overflow:hidden clipping.
|
|
463
|
+
const insertMenuButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
464
|
+
const insertMenuRef = useRef<HTMLDivElement | null>(null);
|
|
465
|
+
const [insertMenuAnchor, setInsertMenuAnchor] = useState<{ top: number; left: number } | null>(
|
|
466
|
+
null,
|
|
353
467
|
);
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
468
|
+
|
|
469
|
+
const openInsertMenu = useCallback((trigger?: HTMLElement | null) => {
|
|
470
|
+
const btn = trigger ?? insertMenuButtonRef.current;
|
|
471
|
+
if (!btn) return;
|
|
472
|
+
const rect = btn.getBoundingClientRect();
|
|
473
|
+
const gap = 4;
|
|
474
|
+
const margin = 8;
|
|
475
|
+
const vw = window.innerWidth;
|
|
476
|
+
let left = rect.left;
|
|
477
|
+
if (left + INSERT_MENU_WIDTH + margin > vw) {
|
|
478
|
+
left = Math.max(margin, rect.right - INSERT_MENU_WIDTH);
|
|
479
|
+
}
|
|
480
|
+
setInsertMenuAnchor({ top: rect.bottom + gap, left });
|
|
359
481
|
}, []);
|
|
360
482
|
|
|
483
|
+
const closeInsertMenu = useCallback(() => setInsertMenuAnchor(null), []);
|
|
484
|
+
|
|
361
485
|
// ── Overflow detection ────────────────────────────────
|
|
362
486
|
const actionsRef = useRef<HTMLDivElement>(null);
|
|
363
487
|
const [measuredOverflowIndex, setMeasuredOverflowIndex] = useState<number | null>(null);
|
|
@@ -367,11 +491,12 @@ export function Toolbar({
|
|
|
367
491
|
// Document settings (frontmatter) dialog
|
|
368
492
|
const [showDocSettings, setShowDocSettings] = useState(false);
|
|
369
493
|
|
|
370
|
-
//
|
|
371
|
-
const
|
|
494
|
+
// Custom layout manager dialog (list of doc/library layouts + designer)
|
|
495
|
+
const [showLayoutManager, setShowLayoutManager] = useState(false);
|
|
496
|
+
|
|
497
|
+
const overflowIndex = measuredOverflowIndex;
|
|
372
498
|
|
|
373
499
|
useEffect(() => {
|
|
374
|
-
if (isNarrow) return; // Skip measurement on narrow — everything overflows
|
|
375
500
|
const container = actionsRef.current;
|
|
376
501
|
if (!container) return;
|
|
377
502
|
|
|
@@ -381,13 +506,18 @@ export function Toolbar({
|
|
|
381
506
|
':scope > .squisq-toolbar-group > .squisq-toolbar-button',
|
|
382
507
|
);
|
|
383
508
|
let firstHidden: number | null = null;
|
|
384
|
-
|
|
509
|
+
let btnIndex = 0;
|
|
510
|
+
children.forEach((child) => {
|
|
385
511
|
if (firstHidden !== null) return;
|
|
512
|
+
// data-btn-count lets a single DOM button represent multiple BUTTONS entries
|
|
513
|
+
// (e.g. the Insert dropdown button represents the entire media group).
|
|
514
|
+
const btnCount = Number(child.dataset.btnCount ?? 1);
|
|
386
515
|
const rect = child.getBoundingClientRect();
|
|
387
516
|
// A button is hidden if its right edge extends past the container
|
|
388
517
|
if (rect.right > containerRight + 2) {
|
|
389
|
-
firstHidden =
|
|
518
|
+
firstHidden = btnIndex;
|
|
390
519
|
}
|
|
520
|
+
btnIndex += btnCount;
|
|
391
521
|
});
|
|
392
522
|
setMeasuredOverflowIndex(firstHidden);
|
|
393
523
|
};
|
|
@@ -396,7 +526,7 @@ export function Toolbar({
|
|
|
396
526
|
ro.observe(container);
|
|
397
527
|
measure();
|
|
398
528
|
return () => ro.disconnect();
|
|
399
|
-
}, [activeView
|
|
529
|
+
}, [activeView]);
|
|
400
530
|
|
|
401
531
|
// Close overflow menu on outside click
|
|
402
532
|
useEffect(() => {
|
|
@@ -410,6 +540,18 @@ export function Toolbar({
|
|
|
410
540
|
return () => document.removeEventListener('mousedown', handleClick);
|
|
411
541
|
}, [showOverflow]);
|
|
412
542
|
|
|
543
|
+
// Close insert menu on outside click
|
|
544
|
+
useEffect(() => {
|
|
545
|
+
if (!insertMenuAnchor) return;
|
|
546
|
+
const handleClick = (e: MouseEvent) => {
|
|
547
|
+
if (insertMenuButtonRef.current?.contains(e.target as Node)) return;
|
|
548
|
+
if (insertMenuRef.current?.contains(e.target as Node)) return;
|
|
549
|
+
closeInsertMenu();
|
|
550
|
+
};
|
|
551
|
+
document.addEventListener('mousedown', handleClick);
|
|
552
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
553
|
+
}, [insertMenuAnchor, closeInsertMenu]);
|
|
554
|
+
|
|
413
555
|
// Open-up vs open-down: the overflow menu is anchored to its trigger with
|
|
414
556
|
// `top: 100%` by default. When the toolbar lives near the bottom of a
|
|
415
557
|
// small container (e.g. a chat composer), a downward menu gets clipped.
|
|
@@ -438,16 +580,17 @@ export function Toolbar({
|
|
|
438
580
|
// Force re-render when Tiptap selection or formatting state changes
|
|
439
581
|
const [, forceUpdate] = useReducer((c: number) => c + 1, 0);
|
|
440
582
|
useEffect(() => {
|
|
441
|
-
if (!
|
|
442
|
-
|
|
583
|
+
if (!formattingEditor) return;
|
|
584
|
+
formattingEditor.on('transaction', forceUpdate);
|
|
443
585
|
return () => {
|
|
444
|
-
|
|
586
|
+
formattingEditor.off('transaction', forceUpdate);
|
|
445
587
|
};
|
|
446
|
-
}, [
|
|
588
|
+
}, [formattingEditor]);
|
|
447
589
|
|
|
448
590
|
// ── Tiptap handler ─────────────────────────────────────
|
|
449
591
|
const handleTiptap = useCallback(
|
|
450
592
|
(id: string) => {
|
|
593
|
+
const tiptapEditor = formattingEditor;
|
|
451
594
|
if (!tiptapEditor) return;
|
|
452
595
|
const chain = tiptapEditor.chain().focus();
|
|
453
596
|
switch (id) {
|
|
@@ -525,9 +668,24 @@ export function Toolbar({
|
|
|
525
668
|
case 'table':
|
|
526
669
|
tiptapEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
|
527
670
|
break;
|
|
671
|
+
case 'diagram':
|
|
672
|
+
// Empty diagram is usable via its "+ Add first node" affordance;
|
|
673
|
+
// sub-headings under this block become nodes.
|
|
674
|
+
insertTemplateHeading(tiptapEditor, { template: 'diagram', text: 'Diagram' });
|
|
675
|
+
break;
|
|
676
|
+
case 'drawing':
|
|
677
|
+
// Empty drawing is usable via the canvas's "Shapes ▾" palette;
|
|
678
|
+
// each shape persists as a child heading.
|
|
679
|
+
insertTemplateHeading(tiptapEditor, { template: 'drawing', text: 'Drawing' });
|
|
680
|
+
break;
|
|
681
|
+
case 'layout':
|
|
682
|
+
// Seed with a starter text layer (a child sub-block) so the canvas
|
|
683
|
+
// isn't blank; further layers add via the toolbar Text/Box buttons.
|
|
684
|
+
insertLayoutBlock(tiptapEditor);
|
|
685
|
+
break;
|
|
528
686
|
}
|
|
529
687
|
},
|
|
530
|
-
[
|
|
688
|
+
[formattingEditor],
|
|
531
689
|
);
|
|
532
690
|
|
|
533
691
|
// ── Raw markdown handler ───────────────────────────────
|
|
@@ -664,6 +822,24 @@ export function Toolbar({
|
|
|
664
822
|
newCursorOffset = 3; // after \n|
|
|
665
823
|
break;
|
|
666
824
|
}
|
|
825
|
+
case 'diagram': {
|
|
826
|
+
// A diagram is just a heading with the `{[diagram]}` template
|
|
827
|
+
// annotation; the WYSIWYG view renders its editable canvas.
|
|
828
|
+
replacement = '\n## Diagram {[diagram]}\n';
|
|
829
|
+
newCursorOffset = 4; // start of "Diagram" (after \n## )
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
case 'drawing': {
|
|
833
|
+
replacement = '\n## Drawing {[drawing]}\n';
|
|
834
|
+
newCursorOffset = 4; // start of "Drawing"
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
case 'layout': {
|
|
838
|
+
// Seed a starter text layer (a child sub-block) so it isn't blank.
|
|
839
|
+
replacement = LAYOUT_STARTER_MARKDOWN;
|
|
840
|
+
newCursorOffset = 4; // start of "Layout" in the parent heading
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
667
843
|
}
|
|
668
844
|
|
|
669
845
|
// Apply the edit via Monaco's executeEdits for proper undo support
|
|
@@ -727,6 +903,15 @@ export function Toolbar({
|
|
|
727
903
|
insertion =
|
|
728
904
|
'\n| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |\n';
|
|
729
905
|
break;
|
|
906
|
+
case 'diagram':
|
|
907
|
+
insertion = '\n## Diagram {[diagram]}\n';
|
|
908
|
+
break;
|
|
909
|
+
case 'drawing':
|
|
910
|
+
insertion = '\n## Drawing {[drawing]}\n';
|
|
911
|
+
break;
|
|
912
|
+
case 'layout':
|
|
913
|
+
insertion = LAYOUT_STARTER_MARKDOWN;
|
|
914
|
+
break;
|
|
730
915
|
}
|
|
731
916
|
if (insertion) {
|
|
732
917
|
setMarkdownSource(markdownSource + insertion);
|
|
@@ -773,7 +958,9 @@ export function Toolbar({
|
|
|
773
958
|
else openEmojiPicker();
|
|
774
959
|
return;
|
|
775
960
|
}
|
|
776
|
-
|
|
961
|
+
// A focused canvas textbox takes the formatting (handleTiptap targets
|
|
962
|
+
// `formattingEditor`); otherwise route to the document editor.
|
|
963
|
+
if (activeSceneText || (activeView === 'wysiwyg' && tiptapEditor)) {
|
|
777
964
|
handleTiptap(id);
|
|
778
965
|
} else {
|
|
779
966
|
handleRaw(id);
|
|
@@ -782,6 +969,7 @@ export function Toolbar({
|
|
|
782
969
|
[
|
|
783
970
|
activeView,
|
|
784
971
|
tiptapEditor,
|
|
972
|
+
activeSceneText,
|
|
785
973
|
handleTiptap,
|
|
786
974
|
handleRaw,
|
|
787
975
|
emojiPickerAnchor,
|
|
@@ -868,17 +1056,20 @@ export function Toolbar({
|
|
|
868
1056
|
const trimmedUrl = url.trim();
|
|
869
1057
|
const trimmedText = text.trim();
|
|
870
1058
|
|
|
871
|
-
|
|
1059
|
+
// Target the focused canvas textbox editor when one is active, else
|
|
1060
|
+
// the document editor (the dialog was opened from the same target).
|
|
1061
|
+
const wysiwygTarget = formattingEditor;
|
|
1062
|
+
if (linkDialog.target === 'wysiwyg' && wysiwygTarget) {
|
|
872
1063
|
if (!trimmedUrl) {
|
|
873
1064
|
// Empty URL on update = unlink. On insert with no URL, do nothing.
|
|
874
1065
|
if (linkDialog.mode === 'update') {
|
|
875
|
-
|
|
1066
|
+
wysiwygTarget.chain().focus().unsetLink().run();
|
|
876
1067
|
}
|
|
877
1068
|
setLinkDialog(null);
|
|
878
1069
|
return;
|
|
879
1070
|
}
|
|
880
1071
|
const visibleText = trimmedText || trimmedUrl;
|
|
881
|
-
const chain =
|
|
1072
|
+
const chain = wysiwygTarget.chain().focus();
|
|
882
1073
|
// Insert (or replace selection) with text carrying a link mark. When
|
|
883
1074
|
// updating an existing link, the selection was extended to the full
|
|
884
1075
|
// mark range earlier, so this replaces the entire `[text](url)`.
|
|
@@ -927,13 +1118,37 @@ export function Toolbar({
|
|
|
927
1118
|
|
|
928
1119
|
setLinkDialog(null);
|
|
929
1120
|
},
|
|
930
|
-
[linkDialog,
|
|
1121
|
+
[linkDialog, formattingEditor, monacoEditor],
|
|
931
1122
|
);
|
|
932
1123
|
|
|
933
|
-
const groups = ['format', 'lists', 'structure', 'insert'
|
|
1124
|
+
const groups = ['format', 'lists', 'structure', 'insert'] as const;
|
|
934
1125
|
const isWysiwyg = activeView === 'wysiwyg' && tiptapEditor;
|
|
935
1126
|
const isPreview = activeView === 'preview';
|
|
936
1127
|
|
|
1128
|
+
// Formatting buttons reflect/drive the focused canvas textbox when one is
|
|
1129
|
+
// active, else the document's WYSIWYG editor.
|
|
1130
|
+
const formatActive = !!activeSceneText || isWysiwyg;
|
|
1131
|
+
// Inline-level textboxes (diagram/drawing labels) only support inline
|
|
1132
|
+
// marks — grey out block formatters so the toolbar reflects what applies.
|
|
1133
|
+
const INLINE_SCENE_BUTTONS = new Set([
|
|
1134
|
+
'bold',
|
|
1135
|
+
'italic',
|
|
1136
|
+
'strikethrough',
|
|
1137
|
+
'code',
|
|
1138
|
+
'link',
|
|
1139
|
+
'emoji',
|
|
1140
|
+
]);
|
|
1141
|
+
// `block`-level textboxes (layout) add lists/blockquote/code block on top of
|
|
1142
|
+
// the inline marks — but NOT headings (their content is a sub-block body, so
|
|
1143
|
+
// a heading there would re-parse as a sibling sub-block).
|
|
1144
|
+
const BLOCK_SCENE_BUTTONS = new Set([...INLINE_SCENE_BUTTONS, 'ul', 'ol', 'quote', 'codeblock']);
|
|
1145
|
+
const buttonAllowed = (id: string): boolean => {
|
|
1146
|
+
if (!sceneTextLevel) return true;
|
|
1147
|
+
if (sceneTextLevel === 'rich') return true;
|
|
1148
|
+
if (sceneTextLevel === 'block') return BLOCK_SCENE_BUTTONS.has(id);
|
|
1149
|
+
return INLINE_SCENE_BUTTONS.has(id);
|
|
1150
|
+
};
|
|
1151
|
+
|
|
937
1152
|
// ── Progressive heading disclosure ───────────────────
|
|
938
1153
|
// H1\u2013H3 are always visible. H4 appears once the document already
|
|
939
1154
|
// contains an H3, H5 once it contains an H4, and H6 once it contains
|
|
@@ -962,6 +1177,9 @@ export function Toolbar({
|
|
|
962
1177
|
if (!m) return true;
|
|
963
1178
|
return Number(m[1]) <= visibleHeadingMax;
|
|
964
1179
|
};
|
|
1180
|
+
const hasVisibleMediaButtons = MEDIA_BUTTONS.some((b) => isButtonVisible(b.id));
|
|
1181
|
+
const showInsertInOverflow =
|
|
1182
|
+
overflowIndex !== null && overflowIndex <= FIRST_MEDIA_INDEX && hasVisibleMediaButtons;
|
|
965
1183
|
|
|
966
1184
|
// Detect whether cursor is inside a table (WYSIWYG mode only)
|
|
967
1185
|
const isInTable = isWysiwyg ? tiptapEditor.isActive('table') : false;
|
|
@@ -980,10 +1198,12 @@ export function Toolbar({
|
|
|
980
1198
|
const isRawView = activeView === 'raw';
|
|
981
1199
|
const [rawTemplate, setRawTemplate] = useState<string | null>(null);
|
|
982
1200
|
const [rawHeadingLine, setRawHeadingLine] = useState<number | null>(null);
|
|
1201
|
+
const [rawTransition, setRawTransition] = useState<TransitionFields>(EMPTY_TRANSITION);
|
|
983
1202
|
useEffect(() => {
|
|
984
1203
|
if (!isRawView || !monacoEditor) {
|
|
985
1204
|
setRawTemplate(null);
|
|
986
1205
|
setRawHeadingLine(null);
|
|
1206
|
+
setRawTransition(EMPTY_TRANSITION);
|
|
987
1207
|
return;
|
|
988
1208
|
}
|
|
989
1209
|
const recompute = () => {
|
|
@@ -992,6 +1212,7 @@ export function Toolbar({
|
|
|
992
1212
|
if (!model || !pos) {
|
|
993
1213
|
setRawTemplate(null);
|
|
994
1214
|
setRawHeadingLine(null);
|
|
1215
|
+
setRawTransition(EMPTY_TRANSITION);
|
|
995
1216
|
return;
|
|
996
1217
|
}
|
|
997
1218
|
const line = model.getLineContent(pos.lineNumber);
|
|
@@ -999,9 +1220,11 @@ export function Toolbar({
|
|
|
999
1220
|
if (!headingMatch) {
|
|
1000
1221
|
setRawTemplate(null);
|
|
1001
1222
|
setRawHeadingLine(null);
|
|
1223
|
+
setRawTransition(EMPTY_TRANSITION);
|
|
1002
1224
|
return;
|
|
1003
1225
|
}
|
|
1004
1226
|
setRawHeadingLine(pos.lineNumber);
|
|
1227
|
+
setRawTransition(readHeadingLineTransition(line));
|
|
1005
1228
|
const annotMatch = headingMatch[1].match(/\s*\{\[([^\]]+)\]\}[\s\]}]*$/);
|
|
1006
1229
|
if (annotMatch) {
|
|
1007
1230
|
// First whitespace-delimited token is the template name; the rest are params.
|
|
@@ -1115,6 +1338,52 @@ export function Toolbar({
|
|
|
1115
1338
|
}
|
|
1116
1339
|
};
|
|
1117
1340
|
|
|
1341
|
+
// ── Current block transition ──────────────────────────
|
|
1342
|
+
// Non-null exactly when a block (heading) is active — same condition as
|
|
1343
|
+
// the template picker. Read from the Pandoc `{…}` block (canonical), with
|
|
1344
|
+
// the `{[…]}` params as a fallback for hand-typed transitions.
|
|
1345
|
+
let currentTransition: TransitionFields | null = null;
|
|
1346
|
+
if (isWysiwyg && wysiwygTemplate !== null) {
|
|
1347
|
+
const attrs = tiptapEditor.getAttributes('heading') as {
|
|
1348
|
+
dataBlockAttrs?: string | null;
|
|
1349
|
+
dataTemplateParams?: string | null;
|
|
1350
|
+
};
|
|
1351
|
+
currentTransition = readBlockAttrsTransition(attrs.dataBlockAttrs, attrs.dataTemplateParams);
|
|
1352
|
+
} else if (isRawView && rawTemplate !== null) {
|
|
1353
|
+
currentTransition = rawTransition;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const handleTransitionPick = (next: TransitionFields) => {
|
|
1357
|
+
// Raw (Monaco) — rewrite the heading line, writing into the `{…}` block.
|
|
1358
|
+
if (isRawView && monacoEditor) {
|
|
1359
|
+
const model = monacoEditor.getModel();
|
|
1360
|
+
const pos = monacoEditor.getPosition();
|
|
1361
|
+
if (!model || !pos) return;
|
|
1362
|
+
const lineNumber = pos.lineNumber;
|
|
1363
|
+
const lineText = model.getLineContent(lineNumber);
|
|
1364
|
+
const newLine = setHeadingLineTransition(lineText, next);
|
|
1365
|
+
if (newLine === lineText) return;
|
|
1366
|
+
monacoEditor.executeEdits('toolbar-transition-pick', [
|
|
1367
|
+
{
|
|
1368
|
+
range: {
|
|
1369
|
+
startLineNumber: lineNumber,
|
|
1370
|
+
startColumn: 1,
|
|
1371
|
+
endLineNumber: lineNumber,
|
|
1372
|
+
endColumn: lineText.length + 1,
|
|
1373
|
+
},
|
|
1374
|
+
text: newLine,
|
|
1375
|
+
},
|
|
1376
|
+
]);
|
|
1377
|
+
monacoEditor.focus();
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
// WYSIWYG — rewrite the heading node's `dataBlockAttrs` (Pandoc inner).
|
|
1381
|
+
if (!tiptapEditor) return;
|
|
1382
|
+
const attrs = tiptapEditor.getAttributes('heading') as { dataBlockAttrs?: string | null };
|
|
1383
|
+
const inner = setBlockAttrsTransition(attrs.dataBlockAttrs, next);
|
|
1384
|
+
tiptapEditor.chain().focus().updateAttributes('heading', { dataBlockAttrs: inner }).run();
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1118
1387
|
return (
|
|
1119
1388
|
<div
|
|
1120
1389
|
className={`squisq-toolbar ${className || ''}`}
|
|
@@ -1167,8 +1436,8 @@ export function Toolbar({
|
|
|
1167
1436
|
))}
|
|
1168
1437
|
</div>
|
|
1169
1438
|
)}
|
|
1170
|
-
{/* Formatting buttons — hidden in preview mode
|
|
1171
|
-
{!isPreview && !
|
|
1439
|
+
{/* Formatting buttons — hidden in preview mode and code mode */}
|
|
1440
|
+
{!isPreview && !isCodeMode && (
|
|
1172
1441
|
<div className="squisq-toolbar-actions" ref={actionsRef}>
|
|
1173
1442
|
{groups.map((group, gi) => (
|
|
1174
1443
|
<div key={group} className="squisq-toolbar-group">
|
|
@@ -1177,10 +1446,10 @@ export function Toolbar({
|
|
|
1177
1446
|
const active =
|
|
1178
1447
|
btn.id === 'emoji'
|
|
1179
1448
|
? emojiPickerAnchor !== null
|
|
1180
|
-
:
|
|
1181
|
-
? isTiptapActive(
|
|
1449
|
+
: formatActive && formattingEditor
|
|
1450
|
+
? isTiptapActive(formattingEditor, btn.id)
|
|
1182
1451
|
: false;
|
|
1183
|
-
const disabled = btn.id === 'image' && !mediaProvider;
|
|
1452
|
+
const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
|
|
1184
1453
|
return (
|
|
1185
1454
|
<button
|
|
1186
1455
|
key={btn.id}
|
|
@@ -1191,15 +1460,31 @@ export function Toolbar({
|
|
|
1191
1460
|
aria-label={btn.title}
|
|
1192
1461
|
aria-pressed={active}
|
|
1193
1462
|
disabled={disabled}
|
|
1194
|
-
style={btn.iconStyle}
|
|
1195
1463
|
>
|
|
1196
|
-
{
|
|
1464
|
+
{buttonIcon(btn)}
|
|
1197
1465
|
</button>
|
|
1198
1466
|
);
|
|
1199
1467
|
})}
|
|
1200
1468
|
</div>
|
|
1201
1469
|
))}
|
|
1202
1470
|
|
|
1471
|
+
{/* Insert menu button — collapses all media-group actions into a single dropdown */}
|
|
1472
|
+
<div className="squisq-toolbar-group">
|
|
1473
|
+
<div className="squisq-toolbar-separator" />
|
|
1474
|
+
<button
|
|
1475
|
+
ref={insertMenuButtonRef}
|
|
1476
|
+
className={`squisq-toolbar-button${insertMenuAnchor ? ' squisq-toolbar-button--active' : ''}`}
|
|
1477
|
+
data-btn-count={String(MEDIA_BUTTONS.length)}
|
|
1478
|
+
data-tooltip="Insert..."
|
|
1479
|
+
onClick={() => (insertMenuAnchor ? closeInsertMenu() : openInsertMenu())}
|
|
1480
|
+
aria-label="Insert"
|
|
1481
|
+
aria-expanded={insertMenuAnchor !== null}
|
|
1482
|
+
aria-haspopup="menu"
|
|
1483
|
+
>
|
|
1484
|
+
<Icon icon="fa-solid fa-plus" />
|
|
1485
|
+
</button>
|
|
1486
|
+
</div>
|
|
1487
|
+
|
|
1203
1488
|
{/* Template picker — visible when the cursor is in a heading.
|
|
1204
1489
|
In WYSIWYG, reads from the heading node's `dataTemplate`; in
|
|
1205
1490
|
Markdown view, parses the `{[...]}` suffix on the cursor's line. */}
|
|
@@ -1216,6 +1501,18 @@ export function Toolbar({
|
|
|
1216
1501
|
</>
|
|
1217
1502
|
)}
|
|
1218
1503
|
|
|
1504
|
+
{/* Transition picker — visible alongside the template picker when a
|
|
1505
|
+
block (heading) is active. Writes `transition=` into the block's
|
|
1506
|
+
Pandoc `{…}` attribute block (the canonical home for transitions). */}
|
|
1507
|
+
{currentTransition !== null && (
|
|
1508
|
+
<>
|
|
1509
|
+
<div className="squisq-toolbar-separator" />
|
|
1510
|
+
<div className="squisq-toolbar-group squisq-transition-picker-group">
|
|
1511
|
+
<TransitionPicker value={currentTransition} onChange={handleTransitionPick} />
|
|
1512
|
+
</div>
|
|
1513
|
+
</>
|
|
1514
|
+
)}
|
|
1515
|
+
|
|
1219
1516
|
{/* Table controls — visible when cursor is in a table (WYSIWYG) */}
|
|
1220
1517
|
{isInTable && (
|
|
1221
1518
|
<>
|
|
@@ -1392,14 +1689,17 @@ export function Toolbar({
|
|
|
1392
1689
|
>
|
|
1393
1690
|
{BUTTONS.slice(overflowIndex)
|
|
1394
1691
|
.filter((b) => isButtonVisible(b.id))
|
|
1692
|
+
// Media buttons are represented by the synthetic Insert dropdown
|
|
1693
|
+
// in both the visible toolbar and the overflow menu.
|
|
1694
|
+
.filter((b) => b.group !== 'media')
|
|
1395
1695
|
.map((btn) => {
|
|
1396
1696
|
const active =
|
|
1397
1697
|
btn.id === 'emoji'
|
|
1398
1698
|
? emojiPickerAnchor !== null
|
|
1399
|
-
:
|
|
1400
|
-
? isTiptapActive(
|
|
1699
|
+
: formatActive && formattingEditor
|
|
1700
|
+
? isTiptapActive(formattingEditor, btn.id)
|
|
1401
1701
|
: false;
|
|
1402
|
-
const disabled = btn.id === 'image' && !mediaProvider;
|
|
1702
|
+
const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
|
|
1403
1703
|
return (
|
|
1404
1704
|
<button
|
|
1405
1705
|
key={btn.id}
|
|
@@ -1414,16 +1714,33 @@ export function Toolbar({
|
|
|
1414
1714
|
}}
|
|
1415
1715
|
disabled={disabled}
|
|
1416
1716
|
>
|
|
1417
|
-
{
|
|
1418
|
-
<span className="squisq-toolbar-overflow-icon" style={btn.iconStyle}>
|
|
1419
|
-
{btn.icon}
|
|
1420
|
-
</span>
|
|
1421
|
-
)}
|
|
1717
|
+
<span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
|
|
1422
1718
|
<span>{btn.title}</span>
|
|
1423
1719
|
</button>
|
|
1424
1720
|
);
|
|
1425
1721
|
})}
|
|
1426
1722
|
|
|
1723
|
+
{showInsertInOverflow && (
|
|
1724
|
+
<button
|
|
1725
|
+
className={`squisq-toolbar-overflow-item${insertMenuAnchor ? ' squisq-toolbar-overflow-item--active' : ''}`}
|
|
1726
|
+
onClick={(event) => {
|
|
1727
|
+
if (insertMenuAnchor) {
|
|
1728
|
+
closeInsertMenu();
|
|
1729
|
+
} else {
|
|
1730
|
+
openInsertMenu(event.currentTarget);
|
|
1731
|
+
}
|
|
1732
|
+
setShowOverflow(false);
|
|
1733
|
+
}}
|
|
1734
|
+
aria-haspopup="menu"
|
|
1735
|
+
aria-expanded={insertMenuAnchor !== null}
|
|
1736
|
+
>
|
|
1737
|
+
<span className="squisq-toolbar-overflow-icon">
|
|
1738
|
+
<Icon icon="fa-solid fa-plus" />
|
|
1739
|
+
</span>
|
|
1740
|
+
<span>Insert...</span>
|
|
1741
|
+
</button>
|
|
1742
|
+
)}
|
|
1743
|
+
|
|
1427
1744
|
{/* Contextual: template picker in overflow */}
|
|
1428
1745
|
{currentTemplate !== null && (
|
|
1429
1746
|
<div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
|
|
@@ -1498,7 +1815,7 @@ export function Toolbar({
|
|
|
1498
1815
|
{slotAfterActions}
|
|
1499
1816
|
{/* Spacer — only needed when the actions container (which has flex:1
|
|
1500
1817
|
and already pushes right-side items to the end) isn't rendered. */}
|
|
1501
|
-
{(isPreview ||
|
|
1818
|
+
{(isPreview || isCodeMode) && <div style={{ flex: 1 }} />}
|
|
1502
1819
|
{/* Version history — renders only when the host enabled versioning
|
|
1503
1820
|
and a container is wired up. The component owns its own button
|
|
1504
1821
|
and popover; we just give it a slot in the toolbar. */}
|
|
@@ -1516,21 +1833,18 @@ export function Toolbar({
|
|
|
1516
1833
|
data-tooltip="Document settings"
|
|
1517
1834
|
aria-label="Document settings"
|
|
1518
1835
|
>
|
|
1519
|
-
<
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
strokeLinecap="round"
|
|
1532
|
-
/>
|
|
1533
|
-
</svg>
|
|
1836
|
+
<Icon icon="fa-solid fa-file-lines" />
|
|
1837
|
+
</button>
|
|
1838
|
+
)}
|
|
1839
|
+
{!isCodeMode && (
|
|
1840
|
+
<button
|
|
1841
|
+
type="button"
|
|
1842
|
+
className={`squisq-toolbar-button${showLayoutManager ? ' squisq-toolbar-button--active' : ''}`}
|
|
1843
|
+
onClick={() => setShowLayoutManager(true)}
|
|
1844
|
+
data-tooltip="Custom layouts"
|
|
1845
|
+
aria-label="Custom layouts"
|
|
1846
|
+
>
|
|
1847
|
+
<Icon icon="fa-solid fa-shapes" />
|
|
1534
1848
|
</button>
|
|
1535
1849
|
)}
|
|
1536
1850
|
{!isCodeMode && <ViewMenuPanel />}
|
|
@@ -1543,7 +1857,7 @@ export function Toolbar({
|
|
|
1543
1857
|
aria-pressed={showFiles}
|
|
1544
1858
|
aria-label="Toggle Files panel"
|
|
1545
1859
|
>
|
|
1546
|
-
|
|
1860
|
+
<Icon icon="fa-solid fa-paperclip" />
|
|
1547
1861
|
</button>
|
|
1548
1862
|
)}
|
|
1549
1863
|
{/* Right slot — rightmost end of toolbar */}
|
|
@@ -1561,6 +1875,9 @@ export function Toolbar({
|
|
|
1561
1875
|
/>
|
|
1562
1876
|
)}
|
|
1563
1877
|
|
|
1878
|
+
{/* Custom layout manager — full-window list + designer dialog. */}
|
|
1879
|
+
{showLayoutManager && <CustomLayoutManager onClose={() => setShowLayoutManager(false)} />}
|
|
1880
|
+
|
|
1564
1881
|
{/* Link insert/edit dialog — shared by WYSIWYG and Raw views. */}
|
|
1565
1882
|
{linkDialog && (
|
|
1566
1883
|
<LinkDialog
|
|
@@ -1573,6 +1890,43 @@ export function Toolbar({
|
|
|
1573
1890
|
/>
|
|
1574
1891
|
)}
|
|
1575
1892
|
|
|
1893
|
+
{/* Insert menu — portaled to the document body so the overflow:hidden
|
|
1894
|
+
actions row doesn't clip it. Position computed from trigger rect. */}
|
|
1895
|
+
{insertMenuAnchor &&
|
|
1896
|
+
createPortal(
|
|
1897
|
+
<div
|
|
1898
|
+
ref={insertMenuRef}
|
|
1899
|
+
className="squisq-insert-menu"
|
|
1900
|
+
data-theme={theme}
|
|
1901
|
+
style={{ position: 'fixed', top: insertMenuAnchor.top, left: insertMenuAnchor.left }}
|
|
1902
|
+
role="menu"
|
|
1903
|
+
>
|
|
1904
|
+
<div className="squisq-insert-menu-header">Insert</div>
|
|
1905
|
+
{MEDIA_BUTTONS.filter((b) => isButtonVisible(b.id)).map((btn) => {
|
|
1906
|
+
const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
|
|
1907
|
+
const stripped = btn.title.replace(/^Insert\s+/i, '');
|
|
1908
|
+
const label = stripped.charAt(0).toUpperCase() + stripped.slice(1);
|
|
1909
|
+
return (
|
|
1910
|
+
<button
|
|
1911
|
+
key={btn.id}
|
|
1912
|
+
ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
|
|
1913
|
+
className="squisq-toolbar-overflow-item"
|
|
1914
|
+
disabled={disabled}
|
|
1915
|
+
onClick={() => {
|
|
1916
|
+
handleAction(btn.id);
|
|
1917
|
+
if (btn.id !== 'emoji') closeInsertMenu();
|
|
1918
|
+
}}
|
|
1919
|
+
role="menuitem"
|
|
1920
|
+
>
|
|
1921
|
+
<span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
|
|
1922
|
+
<span>{label}</span>
|
|
1923
|
+
</button>
|
|
1924
|
+
);
|
|
1925
|
+
})}
|
|
1926
|
+
</div>,
|
|
1927
|
+
document.body,
|
|
1928
|
+
)}
|
|
1929
|
+
|
|
1576
1930
|
{/* Emoji picker — portaled to the document body so the toolbar's
|
|
1577
1931
|
overflow:hidden actions row doesn't clip the popover. Position
|
|
1578
1932
|
is computed from the trigger button's screen rect at open. */}
|