@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.
Files changed (165) hide show
  1. package/dist/index.d.ts +757 -20
  2. package/dist/index.js +16910 -6844
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -4
  5. package/src/BlockCardView.tsx +121 -0
  6. package/src/BlockPreviewPanel.tsx +69 -0
  7. package/src/BlockPropertiesPopover.tsx +191 -0
  8. package/src/EditorContext.tsx +143 -0
  9. package/src/EditorShell.tsx +200 -120
  10. package/src/FolderView.tsx +131 -0
  11. package/src/Icon.tsx +26 -0
  12. package/src/ImageEditor.tsx +69 -22
  13. package/src/OutlinePanel.tsx +38 -3
  14. package/src/PlainHtmlPreview.tsx +30 -3
  15. package/src/PreviewControls.tsx +180 -8
  16. package/src/RawEditor.tsx +216 -29
  17. package/src/RecorderEntry.tsx +9 -16
  18. package/src/TemplateAnnotation.ts +44 -0
  19. package/src/TemplatePicker.tsx +329 -54
  20. package/src/ThemeCustomizerPanel.tsx +30 -336
  21. package/src/ThemePicker.tsx +112 -3
  22. package/src/TimelineBlockPreview.tsx +37 -0
  23. package/src/TimelineTrack.tsx +671 -0
  24. package/src/Toolbar.tsx +528 -174
  25. package/src/Tooltip.tsx +22 -4
  26. package/src/TransitionPicker.tsx +351 -0
  27. package/src/VersionHistoryPanel.tsx +61 -31
  28. package/src/ViewMenuPanel.tsx +17 -14
  29. package/src/WysiwygEditor.tsx +161 -65
  30. package/src/__tests__/blockProperties.test.ts +92 -0
  31. package/src/__tests__/blockRange.test.ts +105 -0
  32. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  33. package/src/__tests__/createShapeLayer.test.ts +46 -0
  34. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  35. package/src/__tests__/embeddedMedia.test.ts +48 -0
  36. package/src/__tests__/headingTransition.test.ts +138 -0
  37. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  38. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  39. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  40. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  41. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  42. package/src/__tests__/timelineSource.test.ts +134 -0
  43. package/src/__tests__/tiptapBridge.test.ts +92 -0
  44. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  45. package/src/__tests__/tooltip.test.tsx +72 -0
  46. package/src/__tests__/transitionCatalog.test.ts +64 -0
  47. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  48. package/src/__tests__/useMediaRecorder.test.ts +24 -0
  49. package/src/__tests__/useTimelineClock.test.ts +21 -0
  50. package/src/blockProperties.ts +88 -0
  51. package/src/blockRange.ts +132 -0
  52. package/src/buildPreviewDoc.ts +98 -9
  53. package/src/customTemplates/AddBin.tsx +126 -0
  54. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  55. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  56. package/src/customTemplates/LayerToolbar.tsx +580 -0
  57. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  58. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  59. package/src/customTemplates/__tests__/library.test.ts +88 -0
  60. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  61. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  62. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  63. package/src/customTemplates/designer.css +673 -0
  64. package/src/customTemplates/index.ts +31 -0
  65. package/src/customTemplates/library.ts +97 -0
  66. package/src/customTemplates/normalizePositions.ts +75 -0
  67. package/src/customTemplates/shapeDefs.ts +131 -0
  68. package/src/customTemplates/thumbnail.tsx +63 -0
  69. package/src/customTemplates/tokenDefs.ts +60 -0
  70. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  71. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  72. package/src/customThemes/CustomThemeContext.tsx +179 -0
  73. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  74. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  75. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  76. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  77. package/src/customThemes/customThemeLibrary.ts +97 -0
  78. package/src/customThemes/index.ts +31 -0
  79. package/src/customThemes/themeControls.tsx +229 -0
  80. package/src/customThemes/themeDraft.ts +272 -0
  81. package/src/customThemes/useDocCustomThemes.ts +49 -0
  82. package/src/diagram/DiagramCanvas.tsx +240 -0
  83. package/src/diagram/DiagramExtension.ts +209 -0
  84. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  85. package/src/diagram/DiagramWidget.tsx +270 -0
  86. package/src/diagram/diagramCommands.ts +604 -0
  87. package/src/diagram/diagramConstants.ts +17 -0
  88. package/src/diagram/useDiagramData.ts +126 -0
  89. package/src/embeddedMedia.ts +78 -0
  90. package/src/frontmatter.ts +29 -0
  91. package/src/headingTransition.ts +231 -0
  92. package/src/imageEditor/CanvasSurface.tsx +383 -88
  93. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  94. package/src/imageEditor/Toolbar.tsx +229 -16
  95. package/src/imageEditor/createShapeLayer.ts +280 -0
  96. package/src/imageEditor/icons.tsx +34 -114
  97. package/src/imageEditor/image-editor.css +54 -5
  98. package/src/imageEditor/state.ts +23 -3
  99. package/src/index.ts +77 -0
  100. package/src/recorder/RecorderModal.tsx +120 -53
  101. package/src/recorder/RecorderPanel.tsx +2 -26
  102. package/src/recorder/hooks/useMediaRecorder.ts +17 -2
  103. package/src/recorder/insertMediaBlock.ts +30 -0
  104. package/src/resolveBlockVisual.ts +33 -0
  105. package/src/scene/Scene.tsx +540 -0
  106. package/src/scene/SceneBlockExtension.ts +198 -0
  107. package/src/scene/SceneBlockToolbar.tsx +201 -0
  108. package/src/scene/SceneBlockWidget.tsx +434 -0
  109. package/src/scene/ScenePropsBar.tsx +85 -0
  110. package/src/scene/SceneSelection.tsx +107 -0
  111. package/src/scene/SceneViewport.tsx +102 -0
  112. package/src/scene/ShapePalette.tsx +181 -0
  113. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  114. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  115. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  116. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  117. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  118. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  119. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  120. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  121. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  122. package/src/scene/adapters/blockLayers.ts +159 -0
  123. package/src/scene/commands/SceneCommand.ts +70 -0
  124. package/src/scene/commands/drawingCommands.ts +318 -0
  125. package/src/scene/commands/layoutCommands.ts +301 -0
  126. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  127. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  128. package/src/scene/hooks/useSceneSelection.ts +62 -0
  129. package/src/scene/index.ts +95 -0
  130. package/src/scene/layers/DiagramEdges.tsx +127 -0
  131. package/src/scene/layers/edgeGeometry.ts +77 -0
  132. package/src/scene/layers/nodeCard.tsx +145 -0
  133. package/src/scene/layers/renderLayer.tsx +70 -0
  134. package/src/scene/layers/shapeLayers.ts +201 -0
  135. package/src/scene/paths/bezierEdit.ts +208 -0
  136. package/src/scene/scene.css +649 -0
  137. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  138. package/src/scene/text/sceneTextChannel.ts +40 -0
  139. package/src/scene/text/sceneTextConfig.ts +27 -0
  140. package/src/scene/text/sceneTiptap.ts +36 -0
  141. package/src/scene/text/useSceneTextEditing.ts +39 -0
  142. package/src/scene/tools/ConnectTool.ts +111 -0
  143. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  144. package/src/scene/tools/PathTool.ts +158 -0
  145. package/src/scene/tools/PlaceTool.ts +47 -0
  146. package/src/scene/tools/SceneTool.ts +75 -0
  147. package/src/scene/tools/SelectTool.ts +284 -0
  148. package/src/scene/tools/ShapeTool.ts +144 -0
  149. package/src/scene/tools/TextTool.ts +72 -0
  150. package/src/scene/tools/TokenTool.ts +95 -0
  151. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  152. package/src/styles/diagram.css +183 -0
  153. package/src/styles/editor.css +1615 -203
  154. package/src/styles/folder-view.css +210 -0
  155. package/src/styles/image-edit-affordance.css +2 -2
  156. package/src/styles/index.css +4 -0
  157. package/src/timelineSource.ts +244 -0
  158. package/src/tiptapBridge.ts +115 -34
  159. package/src/tooltipPlacement.ts +13 -0
  160. package/src/transitionCatalog.ts +159 -0
  161. package/src/types/monaco-shims.d.ts +10 -0
  162. package/src/useBlockNavigator.ts +153 -0
  163. package/src/useMonacoLoader.ts +105 -0
  164. package/src/useTimelineClock.ts +76 -0
  165. package/src/utils/dropUtils.ts +1 -1
@@ -0,0 +1,434 @@
1
+ /**
2
+ * SceneBlockWidget — Squisq-Scene-backed canvas mounted under a
3
+ * heading whose `dataTemplate` is `layout` or `drawing`.
4
+ *
5
+ * Owns the maximize toggle and picks the right adapter for the mode.
6
+ * Resolves the heading's position dynamically each render via
7
+ * `headingKey`, so the widget survives attribute-only doc changes
8
+ * without going stale when content above shifts.
9
+ *
10
+ * In `drawing` mode it also hosts the shape palette (gallery of shape
11
+ * kinds) and the properties panel for the current selection — surfaced
12
+ * from the drawing adapter + the Scene's `onSelectionChange`.
13
+ */
14
+
15
+ import { useEffect, useMemo, useRef, useState } from 'react';
16
+ import type { ReactNode } from 'react';
17
+ import type { Editor } from '@tiptap/react';
18
+ import type { Layer } from '@bendyline/squisq/schemas';
19
+ import { Scene } from './Scene';
20
+ import { useLayoutAdapter } from './adapters/LayoutAdapter';
21
+ import { useDrawingAdapter, type ConnectorStyleInput } from './adapters/DrawingAdapter';
22
+ import type { SceneCommand, SceneEdge } from './commands/SceneCommand';
23
+ import { findSceneHeadingPos } from './SceneBlockExtension';
24
+ import { shapeIdFromLayerId } from './layers/shapeLayers';
25
+ import { ShapePalette } from './ShapePalette';
26
+ import { ScenePropsBar } from './ScenePropsBar';
27
+ import { SceneBlockToolbar, type SceneBlockAction, type SceneAlignment } from './SceneBlockToolbar';
28
+ import type { SceneTextEditConfig } from './text/sceneTextConfig';
29
+ import { markdownToTiptap } from '../tiptapBridge';
30
+ import { Icon } from '../Icon';
31
+ import { DiagramMaximizedOverlay } from '../diagram/DiagramMaximizedOverlay';
32
+
33
+ export type SceneBlockMode = 'layout' | 'drawing';
34
+
35
+ interface SceneBlockWidgetProps {
36
+ editor: Editor;
37
+ headingKey: string;
38
+ fallbackParentPos: number;
39
+ mode: SceneBlockMode;
40
+ /** Host element for portal targeting (used by maximize overlay). */
41
+ host?: HTMLElement | null;
42
+ }
43
+
44
+ // Default viewport for layout/drawing surfaces. Matches Squisq's 16:9
45
+ // preview so layouts authored here render correctly when previewed.
46
+ const SCENE_VIEWPORT = { width: 1920, height: 1080 };
47
+
48
+ // Connector style vocabularies for the inline properties (drawing edges).
49
+ const MARKERS = ['none', 'arrow', 'open', 'diamond', 'circle', 'square'];
50
+ const ROUTINGS = ['straight', 'orthogonal', 'curved'];
51
+ const LINE_STYLES = ['solid', 'dashed', 'dotted'];
52
+ const lineStyleOf = (dasharray: string | undefined): string =>
53
+ dasharray === '8 6' ? 'dashed' : dasharray === '2 6' ? 'dotted' : 'solid';
54
+
55
+ export function SceneBlockWidget({
56
+ editor,
57
+ headingKey,
58
+ fallbackParentPos,
59
+ mode,
60
+ host,
61
+ }: SceneBlockWidgetProps) {
62
+ const [parentPos, setParentPos] = useState<number>(
63
+ () => findSceneHeadingPos(editor, headingKey, mode) ?? fallbackParentPos,
64
+ );
65
+ useEffect(() => {
66
+ const onUpdate = () => {
67
+ const next = findSceneHeadingPos(editor, headingKey, mode);
68
+ if (next != null && next !== parentPos) setParentPos(next);
69
+ };
70
+ editor.on('transaction', onUpdate);
71
+ return () => {
72
+ editor.off('transaction', onUpdate);
73
+ };
74
+ }, [editor, headingKey, mode, parentPos]);
75
+
76
+ const layout = useLayoutAdapter(editor, parentPos);
77
+ const drawing = useDrawingAdapter(editor, parentPos);
78
+ const isDrawing = mode === 'drawing';
79
+ const adapter = isDrawing ? drawing : layout;
80
+
81
+ // Inline text editing config. Refs keep `getHtml`/`commit` reading the
82
+ // live layers/dispatch while the config object stays stable (so the
83
+ // open editor isn't re-seeded mid-edit). Layout stores rich HTML; drawing
84
+ // stores inline markdown in the shape's heading text.
85
+ const layersRef = useRef(adapter.layers);
86
+ layersRef.current = adapter.layers;
87
+ const dispatchRef = useRef(adapter.dispatch);
88
+ dispatchRef.current = adapter.dispatch;
89
+ const textConfig = useMemo<SceneTextEditConfig>(
90
+ () => ({
91
+ resolveEditableId: (id) => {
92
+ const l = layersRef.current.find((x) => x.id === id);
93
+ return l && l.type === 'text' ? id : null;
94
+ },
95
+ getHtml: (id) => {
96
+ const l = layersRef.current.find((x) => x.id === id);
97
+ if (!l || l.type !== 'text') return '';
98
+ if (!isDrawing && l.content.html?.trim()) return l.content.html;
99
+ return markdownToTiptap(l.content.text ?? '');
100
+ },
101
+ commit: (id, { html, text }) => {
102
+ if (isDrawing) {
103
+ // Drawing labels persist as plain heading text for now (rich
104
+ // marks on shape labels are a follow-up — the rendering
105
+ // foundation is in place via `content.html`). Store plain text so
106
+ // nothing leaks literal markdown into the label.
107
+ dispatchRef.current({ kind: 'renameLayer', id, label: text });
108
+ } else {
109
+ dispatchRef.current({ kind: 'setLayerText', id, text, html });
110
+ }
111
+ },
112
+ // Layout textboxes use `block` (marks + lists, no headings) because their
113
+ // content is stored as the child sub-block's markdown body.
114
+ level: isDrawing ? 'inline' : 'block',
115
+ }),
116
+ [isDrawing],
117
+ );
118
+
119
+ const [maximized, setMaximized] = useState(false);
120
+ const [activeToolId, setActiveToolId] = useState<string>('select');
121
+ const [paletteOpen, setPaletteOpen] = useState(false);
122
+ const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
123
+
124
+ const selectedShapeId = isDrawing && selectedLayerId ? shapeIdFromLayerId(selectedLayerId) : null;
125
+
126
+ const canvas = (
127
+ <Scene
128
+ viewport={SCENE_VIEWPORT}
129
+ layers={adapter.layers}
130
+ edges={adapter.edges}
131
+ tools={adapter.tools}
132
+ onCommand={adapter.dispatch}
133
+ onSelectionChange={(ids) => setSelectedLayerId(ids.values().next().value ?? null)}
134
+ activeToolId={activeToolId}
135
+ onActiveToolIdChange={setActiveToolId}
136
+ layerFollows={adapter.layerFollows}
137
+ renderExtras={adapter.renderExtras}
138
+ showMaximize
139
+ maximized={maximized}
140
+ onToggleMaximize={() => setMaximized((m) => !m)}
141
+ showToolbar={false}
142
+ textEditing={textConfig}
143
+ />
144
+ );
145
+
146
+ // ── Shared toolbar (above the canvas) ──────────────────
147
+ // Drawing creates shapes via its mode tools (Shape/Pen/Text/Connect);
148
+ // layout has only Select, so it gets explicit Add buttons. Both share
149
+ // a Delete action keyed off the current selection.
150
+ const canDelete = isDrawing ? !!selectedLayerId || !!drawing.selectedEdgeId : !!selectedLayerId;
151
+ const deleteSelected = () => {
152
+ if (selectedLayerId) {
153
+ adapter.dispatch({ kind: 'removeLayer', id: selectedLayerId });
154
+ setSelectedLayerId(null);
155
+ return;
156
+ }
157
+ if (isDrawing && drawing.selectedEdge) {
158
+ adapter.dispatch({
159
+ kind: 'removeEdge',
160
+ source: drawing.selectedEdge.source,
161
+ target: drawing.selectedEdge.target,
162
+ });
163
+ }
164
+ };
165
+ const freshLayerId = (prefix: string) => {
166
+ const used = new Set(adapter.layers.map((l) => l.id));
167
+ let i = 1;
168
+ while (used.has(`${prefix}-${i}`)) i++;
169
+ return `${prefix}-${i}`;
170
+ };
171
+ const addLayoutLayer = (layer: Layer) => adapter.dispatch({ kind: 'addLayer', layer });
172
+
173
+ const deleteAction: SceneBlockAction = {
174
+ id: 'delete',
175
+ label: 'Delete',
176
+ icon: <Icon icon="fa-solid fa-trash" />,
177
+ title: 'Delete selected',
178
+ danger: true,
179
+ disabled: !canDelete,
180
+ onClick: deleteSelected,
181
+ };
182
+ const actions: SceneBlockAction[] = isDrawing
183
+ ? [deleteAction]
184
+ : [
185
+ {
186
+ id: 'add-text',
187
+ label: 'Text',
188
+ icon: <Icon icon="fa-solid fa-font" />,
189
+ title: 'Add text',
190
+ onClick: () =>
191
+ addLayoutLayer({
192
+ type: 'text',
193
+ id: freshLayerId('text'),
194
+ content: {
195
+ text: 'Text',
196
+ style: {
197
+ fontSize: 48,
198
+ color: '#1e293b',
199
+ textAlign: 'center',
200
+ verticalAlign: 'middle',
201
+ },
202
+ },
203
+ position: { x: 660, y: 440, width: 600, height: 200 },
204
+ }),
205
+ },
206
+ {
207
+ id: 'add-box',
208
+ label: 'Box',
209
+ icon: <Icon icon="fa-solid fa-square" />,
210
+ title: 'Add box',
211
+ onClick: () =>
212
+ addLayoutLayer({
213
+ type: 'shape',
214
+ id: freshLayerId('box'),
215
+ content: {
216
+ shape: 'rect',
217
+ fill: '#e0e7ff',
218
+ stroke: '#6366f1',
219
+ strokeWidth: 2,
220
+ borderRadius: 8,
221
+ },
222
+ position: { x: 760, y: 390, width: 400, height: 300 },
223
+ }),
224
+ },
225
+ deleteAction,
226
+ ];
227
+
228
+ // Text-alignment controls — shown when a layout textbox is selected. They
229
+ // set the layer's whole-box `style.textAlign` / `style.verticalAlign`
230
+ // (the LayoutAdapter persists arbitrary `content.*` paths; drawing/diagram
231
+ // labels don't yet, so this is layout-only for now).
232
+ const selectedLayer = selectedLayerId
233
+ ? adapter.layers.find((l) => l.id === selectedLayerId)
234
+ : undefined;
235
+ const alignment: SceneAlignment | undefined =
236
+ mode === 'layout' && selectedLayer?.type === 'text'
237
+ ? {
238
+ textAlign: selectedLayer.content.style.textAlign ?? 'left',
239
+ verticalAlign: selectedLayer.content.style.verticalAlign ?? 'top',
240
+ onTextAlign: (v) =>
241
+ adapter.dispatch({
242
+ kind: 'setLayerAttr',
243
+ id: selectedLayer.id,
244
+ path: 'content.style.textAlign',
245
+ value: v,
246
+ }),
247
+ onVerticalAlign: (v) =>
248
+ adapter.dispatch({
249
+ kind: 'setLayerAttr',
250
+ id: selectedLayer.id,
251
+ path: 'content.style.verticalAlign',
252
+ value: v,
253
+ }),
254
+ }
255
+ : undefined;
256
+
257
+ // Per-selection style controls, rendered inline in the contextual toolbar
258
+ // (Fill/Stroke for shapes & boxes, Color for text, marker/line/routing for
259
+ // a drawing connector). Replaces the old floating properties panel.
260
+ const propsBar = buildPropsBar({
261
+ isDrawing,
262
+ selectedLayer,
263
+ selectedShapeId,
264
+ selectedEdge: isDrawing ? drawing.selectedEdge : null,
265
+ setShapeParam: drawing.setShapeParam,
266
+ setConnectorStyle: drawing.setConnectorStyle,
267
+ dispatch: adapter.dispatch,
268
+ });
269
+
270
+ const toolbar = (
271
+ <SceneBlockToolbar
272
+ tools={adapter.tools}
273
+ activeToolId={activeToolId}
274
+ // Clicking the Shape tool opens its palette (dropping down from the
275
+ // button); other tools just activate.
276
+ onSelectTool={(id) => {
277
+ setActiveToolId(id);
278
+ if (id === 'draw') setPaletteOpen(true);
279
+ }}
280
+ actions={actions}
281
+ alignment={alignment}
282
+ properties={propsBar}
283
+ toolPopover={
284
+ isDrawing && paletteOpen
285
+ ? {
286
+ toolId: 'draw',
287
+ content: (
288
+ <ShapePalette
289
+ onPick={(kind) => {
290
+ drawing.setPendingKind(kind);
291
+ setActiveToolId('draw');
292
+ setPaletteOpen(false);
293
+ }}
294
+ onClose={() => setPaletteOpen(false)}
295
+ />
296
+ ),
297
+ }
298
+ : undefined
299
+ }
300
+ />
301
+ );
302
+
303
+ const body = <div className="squisq-scene-stage">{canvas}</div>;
304
+ const sideToolbar = <div className="squisq-scene-side-toolbar">{toolbar}</div>;
305
+
306
+ if (maximized) {
307
+ return (
308
+ <div className="squisq-scene-inline-placeholder">
309
+ <DiagramMaximizedOverlay host={host ?? null} onClose={() => setMaximized(false)}>
310
+ <div className="squisq-scene-block-max">
311
+ {body}
312
+ {sideToolbar}
313
+ </div>
314
+ </DiagramMaximizedOverlay>
315
+ </div>
316
+ );
317
+ }
318
+
319
+ return (
320
+ <div className="squisq-scene-shell">
321
+ <div className="squisq-scene-inline">{body}</div>
322
+ {sideToolbar}
323
+ </div>
324
+ );
325
+ }
326
+
327
+ /**
328
+ * Build the inline per-selection style controls for the contextual toolbar.
329
+ * - A drawing connector → marker / line / routing dropdowns.
330
+ * - A text layer → a single Color swatch (drawing text color is the shape's
331
+ * `stroke`; layout text color is `content.style.color`).
332
+ * - A shape / box / path → Fill + Stroke swatches.
333
+ * Returns null when the selection has nothing styleable.
334
+ */
335
+ function buildPropsBar(args: {
336
+ isDrawing: boolean;
337
+ selectedLayer: Layer | undefined;
338
+ selectedShapeId: string | null;
339
+ selectedEdge: SceneEdge | null;
340
+ setShapeParam: (id: string, key: string, value: string) => void;
341
+ setConnectorStyle: (id: string, style: ConnectorStyleInput) => void;
342
+ dispatch: (cmd: SceneCommand) => void;
343
+ }): ReactNode {
344
+ const {
345
+ isDrawing,
346
+ selectedLayer,
347
+ selectedShapeId,
348
+ selectedEdge,
349
+ setShapeParam,
350
+ setConnectorStyle,
351
+ dispatch,
352
+ } = args;
353
+
354
+ if (isDrawing && selectedEdge) {
355
+ const e = selectedEdge;
356
+ const set = (style: ConnectorStyleInput) => setConnectorStyle(e.id, style);
357
+ return (
358
+ <ScenePropsBar
359
+ selects={[
360
+ {
361
+ label: 'End',
362
+ value: e.endMarker ?? 'arrow',
363
+ options: MARKERS,
364
+ onChange: (v) => set({ endStyle: v }),
365
+ },
366
+ {
367
+ label: 'Start',
368
+ value: e.startMarker ?? 'none',
369
+ options: MARKERS,
370
+ onChange: (v) => set({ startStyle: v }),
371
+ },
372
+ {
373
+ label: 'Line',
374
+ value: lineStyleOf(e.dasharray),
375
+ options: LINE_STYLES,
376
+ onChange: (v) => set({ lineStyle: v }),
377
+ },
378
+ {
379
+ label: 'Routing',
380
+ value: e.routing ?? 'straight',
381
+ options: ROUTINGS,
382
+ onChange: (v) => set({ routing: v }),
383
+ },
384
+ ]}
385
+ />
386
+ );
387
+ }
388
+
389
+ if (!selectedLayer) return null;
390
+
391
+ // Drawing maps a shape's params via its heading id; layout writes layer
392
+ // attribute paths through the adapter's dispatch.
393
+ const setColor = (param: string, path: string) => (v: string) => {
394
+ if (isDrawing && selectedShapeId) setShapeParam(selectedShapeId, param, v);
395
+ else dispatch({ kind: 'setLayerAttr', id: selectedLayer.id, path, value: v });
396
+ };
397
+
398
+ if (selectedLayer.type === 'text') {
399
+ // Drawing stores a text shape's color in its `stroke` param.
400
+ return (
401
+ <ScenePropsBar
402
+ colors={[
403
+ {
404
+ label: 'Color',
405
+ value: selectedLayer.content.style.color,
406
+ onChange: setColor('stroke', 'content.style.color'),
407
+ },
408
+ ]}
409
+ />
410
+ );
411
+ }
412
+
413
+ if (selectedLayer.type === 'shape' || selectedLayer.type === 'path') {
414
+ return (
415
+ <ScenePropsBar
416
+ colors={[
417
+ {
418
+ label: 'Fill',
419
+ value: selectedLayer.content.fill,
420
+ allowNone: true,
421
+ onChange: setColor('fill', 'content.fill'),
422
+ },
423
+ {
424
+ label: 'Stroke',
425
+ value: selectedLayer.content.stroke,
426
+ onChange: setColor('stroke', 'content.stroke'),
427
+ },
428
+ ]}
429
+ />
430
+ );
431
+ }
432
+
433
+ return null;
434
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * ScenePropsBar — compact, inline per-selection style controls that live
3
+ * INSIDE the contextual toolbar (next to the tool / create buttons), rather
4
+ * than in a separate floating panel.
5
+ *
6
+ * It's intentionally generic: the host (SceneBlockWidget / DiagramWidget)
7
+ * decides what the current selection exposes — color swatches (Fill / Stroke
8
+ * / text Color) and/or dropdowns (a connector's markers, line style, routing)
9
+ * — and passes them as plain `colors` / `selects` descriptors. Renders
10
+ * nothing when the selection has no styleable props.
11
+ */
12
+
13
+ interface ScenePropColor {
14
+ label: string;
15
+ /** Current color (`#rrggbb`); shown in the swatch. */
16
+ value?: string;
17
+ /** Show a "none" button that clears the color (emits `'none'`). */
18
+ allowNone?: boolean;
19
+ onChange: (value: string) => void;
20
+ }
21
+
22
+ interface ScenePropSelect {
23
+ label: string;
24
+ value: string;
25
+ options: readonly string[];
26
+ onChange: (value: string) => void;
27
+ }
28
+
29
+ export interface ScenePropsBarProps {
30
+ colors?: readonly ScenePropColor[];
31
+ selects?: readonly ScenePropSelect[];
32
+ }
33
+
34
+ export function ScenePropsBar({ colors = [], selects = [] }: ScenePropsBarProps) {
35
+ if (colors.length === 0 && selects.length === 0) return null;
36
+ return (
37
+ <div className="squisq-scene-block-props" role="group" aria-label="Selection style">
38
+ {colors.map((c) => (
39
+ <span className="squisq-scene-prop" key={c.label}>
40
+ <span className="squisq-scene-prop-label">{c.label}</span>
41
+ <input
42
+ type="color"
43
+ className="squisq-scene-prop-color"
44
+ aria-label={`${c.label} color`}
45
+ value={toHexInput(c.value)}
46
+ onChange={(e) => c.onChange(e.target.value)}
47
+ />
48
+ {c.allowNone && (
49
+ <button
50
+ type="button"
51
+ className="squisq-scene-prop-clear"
52
+ onClick={() => c.onChange('none')}
53
+ >
54
+ none
55
+ </button>
56
+ )}
57
+ </span>
58
+ ))}
59
+ {selects.map((s) => (
60
+ <label className="squisq-scene-prop" key={s.label}>
61
+ <span className="squisq-scene-prop-label">{s.label}</span>
62
+ <select
63
+ className="squisq-scene-prop-select"
64
+ value={s.value}
65
+ onChange={(e) => s.onChange(e.target.value)}
66
+ >
67
+ {s.options.map((o) => (
68
+ <option key={o} value={o}>
69
+ {o}
70
+ </option>
71
+ ))}
72
+ </select>
73
+ </label>
74
+ ))}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ /** Coerce an arbitrary color (or `none`/undefined) to a `#rrggbb` the native
80
+ * color input can display. Non-hex values fall back to black. */
81
+ function toHexInput(value: string | undefined): string {
82
+ return value && /^#[0-9a-fA-F]{6}$/.test(value) ? value : '#000000';
83
+ }
84
+
85
+ export default ScenePropsBar;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * SceneSelection — selection outline + corner handles for resize.
3
+ *
4
+ * Renders a dashed bounding box per selected layer plus eight grip
5
+ * handles (four corners + four edges). The Scene wires the handles to
6
+ * SelectTool's `beginHandleDrag` so a pointer-down on a handle starts a
7
+ * resize gesture instead of a move.
8
+ *
9
+ * All coordinates are viewport units — the parent group transform
10
+ * carries pan/zoom. Handle dimensions and stroke widths use
11
+ * `vector-effect: non-scaling-stroke` (set in scene.css) so they stay
12
+ * the same pixel size regardless of zoom.
13
+ */
14
+
15
+ import type { HitTestable } from './hooks/useSceneHitTest';
16
+
17
+ export type ResizeCorner = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
18
+
19
+ interface SceneSelectionProps {
20
+ selection: ReadonlySet<string>;
21
+ hitItems: readonly HitTestable[];
22
+ /** Live offset during a move-drag (applied to outline + handles). */
23
+ liveOffset?: { dx: number; dy: number } | null;
24
+ /** Live resize bounds, applied to the matching layer's outline. */
25
+ liveResize?: {
26
+ layerId: string;
27
+ bounds: { x: number; y: number; width: number; height: number };
28
+ } | null;
29
+ /** Fired when the user presses a corner/edge handle. */
30
+ onHandlePointerDown?: (e: React.PointerEvent<SVGElement>, corner: ResizeCorner) => void;
31
+ }
32
+
33
+ const HANDLE_SIZE = 8;
34
+
35
+ export function SceneSelection({
36
+ selection,
37
+ hitItems,
38
+ liveOffset,
39
+ liveResize,
40
+ onHandlePointerDown,
41
+ }: SceneSelectionProps) {
42
+ if (selection.size === 0) return null;
43
+ const selected = hitItems.filter((it) => selection.has(it.id));
44
+ if (selected.length === 0) return null;
45
+ const offset = liveOffset ?? { dx: 0, dy: 0 };
46
+
47
+ return (
48
+ <g className="squisq-scene-selection-group">
49
+ {selected.map((it) => {
50
+ // Prefer the live-resize bounds for the matching layer; otherwise
51
+ // apply the move offset.
52
+ let x: number;
53
+ let y: number;
54
+ let w: number;
55
+ let h: number;
56
+ if (liveResize && liveResize.layerId === it.id) {
57
+ x = liveResize.bounds.x;
58
+ y = liveResize.bounds.y;
59
+ w = liveResize.bounds.width;
60
+ h = liveResize.bounds.height;
61
+ } else {
62
+ x = it.bounds.x + offset.dx;
63
+ y = it.bounds.y + offset.dy;
64
+ w = it.bounds.width;
65
+ h = it.bounds.height;
66
+ }
67
+ const corners: { corner: ResizeCorner; cx: number; cy: number; cursor: string }[] = [
68
+ { corner: 'nw', cx: x, cy: y, cursor: 'nwse-resize' },
69
+ { corner: 'n', cx: x + w / 2, cy: y, cursor: 'ns-resize' },
70
+ { corner: 'ne', cx: x + w, cy: y, cursor: 'nesw-resize' },
71
+ { corner: 'e', cx: x + w, cy: y + h / 2, cursor: 'ew-resize' },
72
+ { corner: 'se', cx: x + w, cy: y + h, cursor: 'nwse-resize' },
73
+ { corner: 's', cx: x + w / 2, cy: y + h, cursor: 'ns-resize' },
74
+ { corner: 'sw', cx: x, cy: y + h, cursor: 'nesw-resize' },
75
+ { corner: 'w', cx: x, cy: y + h / 2, cursor: 'ew-resize' },
76
+ ];
77
+ return (
78
+ <g key={it.id} className="squisq-scene-selection">
79
+ <rect
80
+ x={x}
81
+ y={y}
82
+ width={w}
83
+ height={h}
84
+ className="squisq-scene-selection-outline"
85
+ pointerEvents="none"
86
+ />
87
+ {corners.map(({ corner, cx, cy, cursor }) => (
88
+ <rect
89
+ key={corner}
90
+ x={cx - HANDLE_SIZE / 2}
91
+ y={cy - HANDLE_SIZE / 2}
92
+ width={HANDLE_SIZE}
93
+ height={HANDLE_SIZE}
94
+ className="squisq-scene-selection-handle"
95
+ style={{ cursor }}
96
+ onPointerDown={
97
+ onHandlePointerDown ? (e) => onHandlePointerDown(e, corner) : undefined
98
+ }
99
+ data-corner={corner}
100
+ />
101
+ ))}
102
+ </g>
103
+ );
104
+ })}
105
+ </g>
106
+ );
107
+ }