@bendyline/squisq-editor-react 1.5.2 → 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 +16910 -6844
- 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 +216 -29
- 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 +61 -31
- 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 +17 -2
- 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 +105 -0
- package/src/useTimelineClock.ts +76 -0
- package/src/utils/dropUtils.ts +1 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SceneBlockExtension — Tiptap plugin that mounts a Scene-based editor
|
|
3
|
+
* below any heading whose `dataTemplate` is `layout` or `drawing`.
|
|
4
|
+
*
|
|
5
|
+
* Same shape as `DiagramExtension`: each matching heading gets a
|
|
6
|
+
* ProseMirror widget decoration that renders a `<SceneBlockWidget>`
|
|
7
|
+
* (React) into a contenteditable=false host. Sub-headings of the
|
|
8
|
+
* matched block are hidden via a `data-squisq-scene-child` attribute
|
|
9
|
+
* so the canvas is the sole visual representation of the section in
|
|
10
|
+
* the WYSIWYG view (mirrors the diagram hide rule).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Extension } from '@tiptap/core';
|
|
14
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
15
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
|
16
|
+
import type { EditorState } from '@tiptap/pm/state';
|
|
17
|
+
import type { Editor } from '@tiptap/react';
|
|
18
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
19
|
+
import { createElement } from 'react';
|
|
20
|
+
import type { Node as PMNode } from '@tiptap/pm/model';
|
|
21
|
+
import { parsePandocAttrTokens } from '@bendyline/squisq/markdown';
|
|
22
|
+
import { SceneBlockWidget, type SceneBlockMode } from './SceneBlockWidget';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Stable identifier for a layout/drawing heading — used as part of the
|
|
26
|
+
* widget decoration's `key` so the canvas survives attribute-only doc
|
|
27
|
+
* changes (drag/resize commits write into the heading's
|
|
28
|
+
* `data-block-attrs`).
|
|
29
|
+
*/
|
|
30
|
+
export function getHeadingKey(node: PMNode): string {
|
|
31
|
+
const raw = (node.attrs as Record<string, unknown>).dataBlockAttrs;
|
|
32
|
+
if (typeof raw === 'string' && raw.length > 0) {
|
|
33
|
+
const attrs = parsePandocAttrTokens(raw);
|
|
34
|
+
if (attrs.id) return attrs.id;
|
|
35
|
+
}
|
|
36
|
+
let text = '';
|
|
37
|
+
node.content.forEach((child) => {
|
|
38
|
+
if (child.isText) text += child.text ?? '';
|
|
39
|
+
});
|
|
40
|
+
return (
|
|
41
|
+
text
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
.replace(/[^\w\s-]/g, '')
|
|
44
|
+
.replace(/[\s_]+/g, '-')
|
|
45
|
+
.replace(/-+/g, '-')
|
|
46
|
+
.replace(/^-|-$/g, '') || 'block'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Find the current document position of a layout/drawing heading by
|
|
52
|
+
* its stable key. Returns `null` if the heading no longer exists.
|
|
53
|
+
*/
|
|
54
|
+
export function findSceneHeadingPos(
|
|
55
|
+
editor: Editor,
|
|
56
|
+
headingKey: string,
|
|
57
|
+
mode: SceneBlockMode,
|
|
58
|
+
): number | null {
|
|
59
|
+
const expectedTemplate = mode;
|
|
60
|
+
let found: number | null = null;
|
|
61
|
+
editor.state.doc.descendants((node, pos) => {
|
|
62
|
+
if (found != null) return false;
|
|
63
|
+
if (node.type.name !== 'heading') return;
|
|
64
|
+
const attrs = node.attrs as { dataTemplate?: string };
|
|
65
|
+
if (attrs.dataTemplate !== expectedTemplate) return;
|
|
66
|
+
if (getHeadingKey(node) === headingKey) {
|
|
67
|
+
found = pos;
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const KEY = new PluginKey('squisq-scene-block');
|
|
75
|
+
const TEMPLATE_MODE: Record<string, SceneBlockMode> = {
|
|
76
|
+
layout: 'layout',
|
|
77
|
+
drawing: 'drawing',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
interface SceneRoot {
|
|
81
|
+
el: HTMLElement;
|
|
82
|
+
root: Root;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildDecorations(state: EditorState, editor: Editor): DecorationSet {
|
|
86
|
+
const decos: Decoration[] = [];
|
|
87
|
+
|
|
88
|
+
state.doc.descendants((node, pos) => {
|
|
89
|
+
if (node.type.name !== 'heading') return;
|
|
90
|
+
const attrs = node.attrs as { level: number; dataTemplate?: string };
|
|
91
|
+
const mode = attrs.dataTemplate ? TEMPLATE_MODE[attrs.dataTemplate] : undefined;
|
|
92
|
+
if (!mode) return;
|
|
93
|
+
const parentDepth = attrs.level;
|
|
94
|
+
const widgetPos = pos + node.nodeSize;
|
|
95
|
+
const parentPos = pos;
|
|
96
|
+
const headingKey = getHeadingKey(node);
|
|
97
|
+
decos.push(
|
|
98
|
+
Decoration.widget(
|
|
99
|
+
widgetPos,
|
|
100
|
+
(view) => {
|
|
101
|
+
const container = document.createElement('div');
|
|
102
|
+
container.className = 'squisq-scene-widget-host';
|
|
103
|
+
container.contentEditable = 'false';
|
|
104
|
+
container.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
105
|
+
container.addEventListener('keydown', (e) => e.stopPropagation());
|
|
106
|
+
|
|
107
|
+
const root = createRoot(container);
|
|
108
|
+
root.render(
|
|
109
|
+
createElement(SceneBlockWidget, {
|
|
110
|
+
editor,
|
|
111
|
+
headingKey,
|
|
112
|
+
fallbackParentPos: parentPos,
|
|
113
|
+
mode,
|
|
114
|
+
host: view.dom.parentElement ?? view.dom,
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
(container as HTMLElement & { __squisqSceneRoot?: SceneRoot }).__squisqSceneRoot = {
|
|
119
|
+
el: container,
|
|
120
|
+
root,
|
|
121
|
+
};
|
|
122
|
+
return container;
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
destroy: (dom) => {
|
|
126
|
+
const ref = (dom as HTMLElement & { __squisqSceneRoot?: SceneRoot }).__squisqSceneRoot;
|
|
127
|
+
if (ref) setTimeout(() => ref.root.unmount(), 0);
|
|
128
|
+
},
|
|
129
|
+
side: 1,
|
|
130
|
+
ignoreSelection: true,
|
|
131
|
+
// Stable key so the canvas survives attribute-only doc changes
|
|
132
|
+
// (drag/resize commits) — see DiagramExtension for the same
|
|
133
|
+
// pattern and rationale.
|
|
134
|
+
key: `squisq-scene-${mode}-${headingKey}`,
|
|
135
|
+
},
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Hide direct sub-headings of this block — the Scene canvas is the
|
|
140
|
+
// visible representation of any nested structure.
|
|
141
|
+
let cursor = pos + node.nodeSize;
|
|
142
|
+
while (cursor < state.doc.content.size) {
|
|
143
|
+
const child = state.doc.nodeAt(cursor);
|
|
144
|
+
if (!child) break;
|
|
145
|
+
if (child.type.name === 'heading') {
|
|
146
|
+
const childLevel = (child.attrs as { level: number }).level;
|
|
147
|
+
if (childLevel <= parentDepth) break;
|
|
148
|
+
decos.push(
|
|
149
|
+
Decoration.node(cursor, cursor + child.nodeSize, {
|
|
150
|
+
'data-squisq-scene-child': 'true',
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
cursor += child.nodeSize;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return DecorationSet.create(state.doc, decos);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface SceneBlockExtensionOptions {
|
|
162
|
+
/** When false, the extension is inert. */
|
|
163
|
+
enabled?: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const SceneBlockExtension = Extension.create<SceneBlockExtensionOptions>({
|
|
167
|
+
name: 'squisqSceneBlock',
|
|
168
|
+
|
|
169
|
+
addOptions() {
|
|
170
|
+
return { enabled: true };
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
addProseMirrorPlugins() {
|
|
174
|
+
const editor = this.editor as Editor;
|
|
175
|
+
const enabled = this.options.enabled !== false;
|
|
176
|
+
if (!enabled) return [];
|
|
177
|
+
|
|
178
|
+
return [
|
|
179
|
+
new Plugin({
|
|
180
|
+
key: KEY,
|
|
181
|
+
state: {
|
|
182
|
+
init: (_config, state) => buildDecorations(state, editor),
|
|
183
|
+
apply: (tr, oldDecos, _oldState, newState) => {
|
|
184
|
+
if (!tr.docChanged) return oldDecos;
|
|
185
|
+
return buildDecorations(newState, editor);
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
props: {
|
|
189
|
+
decorations(state) {
|
|
190
|
+
return this.getState(state);
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
];
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
export default SceneBlockExtension;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SceneBlockToolbar — the shared chrome that sits *above* a diagram /
|
|
3
|
+
* drawing / layout canvas (right-aligned, outside the canvas box) and
|
|
4
|
+
* hosts the tools for CRUDing the block's shapes.
|
|
5
|
+
*
|
|
6
|
+
* It renders two groups:
|
|
7
|
+
* - **Tools** — mode buttons that drive the Scene's `activeToolId`
|
|
8
|
+
* (Select / Shape / Pen / Text / Connect …). Hidden when a block has
|
|
9
|
+
* only one tool (e.g. layout's Select-only set), since a lone mode
|
|
10
|
+
* button is noise.
|
|
11
|
+
* - **Actions** — one-shot buttons (Create a shape, Delete the
|
|
12
|
+
* selection, …). These are domain commands, not modes.
|
|
13
|
+
*
|
|
14
|
+
* Each host (DiagramWidget / SceneBlockWidget) owns the active-tool and
|
|
15
|
+
* selection state and supplies the tools + actions appropriate to its
|
|
16
|
+
* block type, so the bar looks and behaves consistently everywhere while
|
|
17
|
+
* the vocabulary differs per block.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ReactNode } from 'react';
|
|
21
|
+
import type { SceneTool } from './tools/SceneTool';
|
|
22
|
+
import { Icon } from '../Icon';
|
|
23
|
+
|
|
24
|
+
export type HAlign = 'left' | 'center' | 'right';
|
|
25
|
+
export type VAlign = 'top' | 'middle' | 'bottom';
|
|
26
|
+
|
|
27
|
+
/** Text alignment controls shown when a textbox is the current selection. */
|
|
28
|
+
export interface SceneAlignment {
|
|
29
|
+
textAlign: HAlign;
|
|
30
|
+
verticalAlign: VAlign;
|
|
31
|
+
onTextAlign: (v: HAlign) => void;
|
|
32
|
+
onVerticalAlign: (v: VAlign) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SceneBlockAction {
|
|
36
|
+
id: string;
|
|
37
|
+
label: string;
|
|
38
|
+
/** Optional glyph/icon; falls back to the label text. */
|
|
39
|
+
icon?: ReactNode;
|
|
40
|
+
title?: string;
|
|
41
|
+
onClick: () => void;
|
|
42
|
+
disabled?: boolean;
|
|
43
|
+
/** Render with the destructive (red) treatment — used for Delete. */
|
|
44
|
+
danger?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SceneBlockToolbarProps {
|
|
48
|
+
/** Mode tools that drive the Scene's active tool. */
|
|
49
|
+
tools?: readonly SceneTool[];
|
|
50
|
+
activeToolId?: string;
|
|
51
|
+
onSelectTool?: (id: string) => void;
|
|
52
|
+
/** One-shot command buttons (create / delete / …). */
|
|
53
|
+
actions?: readonly SceneBlockAction[];
|
|
54
|
+
/** Text-alignment controls — shown when a textbox is the selection. */
|
|
55
|
+
alignment?: SceneAlignment;
|
|
56
|
+
/**
|
|
57
|
+
* Per-selection style controls (Fill / Stroke / Color swatches, connector
|
|
58
|
+
* dropdowns) shown inline next to the tools when something is selected.
|
|
59
|
+
*/
|
|
60
|
+
properties?: ReactNode;
|
|
61
|
+
/**
|
|
62
|
+
* A popover anchored *below* a specific tool button (e.g. the shape
|
|
63
|
+
* palette under the Shape tool). Rendered as a dropdown so it pops down
|
|
64
|
+
* from the button in the toolbar.
|
|
65
|
+
*/
|
|
66
|
+
toolPopover?: { toolId: string; content: ReactNode };
|
|
67
|
+
/** Extra controls rendered at the start of the bar (e.g. a shape palette). */
|
|
68
|
+
leading?: ReactNode;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const H_ALIGNS: { value: HAlign; icon: string; label: string }[] = [
|
|
72
|
+
{ value: 'left', icon: 'fa-solid fa-align-left', label: 'Align left' },
|
|
73
|
+
{ value: 'center', icon: 'fa-solid fa-align-center', label: 'Align center' },
|
|
74
|
+
{ value: 'right', icon: 'fa-solid fa-align-right', label: 'Align right' },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** Vertical-align glyph: a box with a bar pinned to top / middle / bottom. */
|
|
78
|
+
function VAlignIcon({ pos }: { pos: VAlign }) {
|
|
79
|
+
const y = pos === 'top' ? 3.5 : pos === 'middle' ? 7.5 : 11.5;
|
|
80
|
+
return (
|
|
81
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
82
|
+
<rect x="2.5" y="1.5" width="11" height="13" rx="1" stroke="currentColor" strokeWidth="1.3" />
|
|
83
|
+
<line
|
|
84
|
+
x1="5"
|
|
85
|
+
y1={y}
|
|
86
|
+
x2="11"
|
|
87
|
+
y2={y}
|
|
88
|
+
stroke="currentColor"
|
|
89
|
+
strokeWidth="2"
|
|
90
|
+
strokeLinecap="round"
|
|
91
|
+
/>
|
|
92
|
+
</svg>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const V_ALIGNS: { value: VAlign; label: string }[] = [
|
|
97
|
+
{ value: 'top', label: 'Align top' },
|
|
98
|
+
{ value: 'middle', label: 'Align middle' },
|
|
99
|
+
{ value: 'bottom', label: 'Align bottom' },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
export function SceneBlockToolbar({
|
|
103
|
+
tools = [],
|
|
104
|
+
activeToolId,
|
|
105
|
+
onSelectTool,
|
|
106
|
+
actions = [],
|
|
107
|
+
alignment,
|
|
108
|
+
properties,
|
|
109
|
+
toolPopover,
|
|
110
|
+
leading,
|
|
111
|
+
}: SceneBlockToolbarProps) {
|
|
112
|
+
// A single mode (e.g. layout's Select) isn't worth a button — there's
|
|
113
|
+
// nothing to switch to. Still render the bar for its actions/leading.
|
|
114
|
+
const showTools = tools.length > 1;
|
|
115
|
+
if (!showTools && actions.length === 0 && !leading && !alignment && !properties) return null;
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="squisq-scene-block-toolbar" role="toolbar" aria-label="Canvas tools">
|
|
119
|
+
{leading}
|
|
120
|
+
{alignment && (
|
|
121
|
+
<div className="squisq-scene-block-tools" aria-label="Text alignment">
|
|
122
|
+
{H_ALIGNS.map((a) => (
|
|
123
|
+
<button
|
|
124
|
+
key={a.value}
|
|
125
|
+
type="button"
|
|
126
|
+
className={`squisq-scene-tool${alignment.textAlign === a.value ? ' squisq-scene-tool--active' : ''}`}
|
|
127
|
+
onClick={() => alignment.onTextAlign(a.value)}
|
|
128
|
+
title={a.label}
|
|
129
|
+
aria-label={a.label}
|
|
130
|
+
aria-pressed={alignment.textAlign === a.value}
|
|
131
|
+
>
|
|
132
|
+
<Icon icon={a.icon} />
|
|
133
|
+
</button>
|
|
134
|
+
))}
|
|
135
|
+
{V_ALIGNS.map((a) => (
|
|
136
|
+
<button
|
|
137
|
+
key={a.value}
|
|
138
|
+
type="button"
|
|
139
|
+
className={`squisq-scene-tool${alignment.verticalAlign === a.value ? ' squisq-scene-tool--active' : ''}`}
|
|
140
|
+
onClick={() => alignment.onVerticalAlign(a.value)}
|
|
141
|
+
title={a.label}
|
|
142
|
+
aria-label={a.label}
|
|
143
|
+
aria-pressed={alignment.verticalAlign === a.value}
|
|
144
|
+
>
|
|
145
|
+
<VAlignIcon pos={a.value} />
|
|
146
|
+
</button>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
{showTools && (
|
|
151
|
+
<div className="squisq-scene-block-tools">
|
|
152
|
+
{tools.map((t) => {
|
|
153
|
+
const button = (
|
|
154
|
+
<button
|
|
155
|
+
key={t.id}
|
|
156
|
+
type="button"
|
|
157
|
+
className={`squisq-scene-tool${activeToolId === t.id ? ' squisq-scene-tool--active' : ''}`}
|
|
158
|
+
onClick={() => onSelectTool?.(t.id)}
|
|
159
|
+
title={t.shortcut ? `${t.label} (${t.shortcut.toUpperCase()})` : t.label}
|
|
160
|
+
aria-pressed={activeToolId === t.id}
|
|
161
|
+
>
|
|
162
|
+
{t.icon ?? <span className="squisq-scene-tool-label">{t.label}</span>}
|
|
163
|
+
</button>
|
|
164
|
+
);
|
|
165
|
+
// Anchor a dropdown (e.g. the shape palette) below this button.
|
|
166
|
+
if (toolPopover && toolPopover.toolId === t.id) {
|
|
167
|
+
return (
|
|
168
|
+
<span key={t.id} className="squisq-scene-tool-popover-anchor">
|
|
169
|
+
{button}
|
|
170
|
+
{toolPopover.content}
|
|
171
|
+
</span>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return button;
|
|
175
|
+
})}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
{properties}
|
|
179
|
+
{actions.length > 0 && (
|
|
180
|
+
<div className="squisq-scene-block-actions">
|
|
181
|
+
{actions.map((a) => (
|
|
182
|
+
<button
|
|
183
|
+
key={a.id}
|
|
184
|
+
type="button"
|
|
185
|
+
className={`squisq-scene-action${a.danger ? ' squisq-scene-action--danger' : ''}`}
|
|
186
|
+
onClick={a.onClick}
|
|
187
|
+
disabled={a.disabled}
|
|
188
|
+
title={a.title ?? a.label}
|
|
189
|
+
aria-label={a.title ?? a.label}
|
|
190
|
+
>
|
|
191
|
+
{a.icon && <span className="squisq-scene-action-icon">{a.icon}</span>}
|
|
192
|
+
<span className="squisq-scene-action-label">{a.label}</span>
|
|
193
|
+
</button>
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export default SceneBlockToolbar;
|