@oh-just-another/react-ui 0.62.0 → 0.63.1

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.
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { isValidElement, useEffect, useRef, useState, } from "react";
3
- import { AlignCenter, AlignCenterHorizontal, AlignEndHorizontal, AlignLeft, AlignRight, AlignStartHorizontal, Bold, CaseSensitive, Download, Circle, Crop, Diamond, FileText, Hexagon as HexagonIcon, Highlighter, Image as ImageIcon, IndentDecrease, IndentIncrease, Italic, Link as LinkIcon, List, ListFilter, ListOrdered, Lock as LockIcon, MessageCircle, Minus, MoreHorizontal, MoreVertical, MoveRight, Proportions, RectangleHorizontal, RectangleVertical, SmilePlus, Spline, Square, Squircle, Star as StarIcon, StickyNote, SquareDashed, SquareDot, Tag, Strikethrough, Trash2, Tv, Underline, Upload, UserRound, Waypoints, Triangle as TriangleIcon, } from "lucide-react";
4
- import { getBinaryFile, isText, isImage, canCarryLabel, isSticky, isEmoji, isFrame, isRectangle, isEllipse, isBrush, isPolygon, sliceRuns, IMAGE_MASK_POLYGON_PRESETS, } from "@oh-just-another/scene";
5
- import { FRAME_SIZE_PRESETS, LABEL_DEFAULT_FONT_SIZE, STICKY_SIZE_PRESETS, TEXT_DEFAULT_FONT_FAMILY, } from "@oh-just-another/state";
2
+ import { Fragment, isValidElement, useEffect, useRef, useState, } from "react";
3
+ import { AlignCenter, AlignCenterHorizontal, AlignCenterVertical, AlignEndHorizontal, AlignEndVertical, AlignHorizontalDistributeCenter, AlignStartVertical, AlignVerticalDistributeCenter, Group as GroupIcon, LayoutGrid, Ungroup as UngroupIcon, AlignLeft, AlignRight, AlignStartHorizontal, Bold, CaseSensitive, Download, Circle, Crop, Diamond, FileText, Hexagon as HexagonIcon, Highlighter, Image as ImageIcon, IndentDecrease, IndentIncrease, Italic, Link as LinkIcon, List, ListFilter, ListOrdered, Lock as LockIcon, MessageCircle, Minus, MoreHorizontal, MoreVertical, MoveRight, Proportions, RectangleHorizontal, RectangleVertical, SmilePlus, Spline, Square, Squircle, Star as StarIcon, StickyNote, SquareDashed, SquareDot, Tag, Strikethrough, Trash2, Tv, Underline, Upload, UserRound, Waypoints, Triangle as TriangleIcon, } from "lucide-react";
4
+ import { getBinaryFile, isText, isImage, canCarryLabel, isSticky, isEmoji, isFrame, isGroup, getDescendantsOf, isRectangle, isEllipse, isBrush, isPolygon, sliceRuns, IMAGE_MASK_POLYGON_PRESETS, } from "@oh-just-another/scene";
5
+ import { FRAME_SIZE_PRESETS, LABEL_DEFAULT_FONT_SIZE, STICKY_SIZE_PRESETS, TEXT_DEFAULT_FONT_FAMILY, defaultActionRegistry, formatHotkey, } from "@oh-just-another/state";
6
6
  import { useDiagramOptional, useReadOnly, useScene, useSelectedLink, useSelection, } from "../core/hooks.js";
7
7
  import { useEditorSelector } from "../core/context.js";
8
8
  import { useContextMenuController } from "../menus/context-menu-controller.js";
@@ -10,6 +10,7 @@ import { ColorSwatchPicker } from "../color/color-swatch-picker.js";
10
10
  import { Popover } from "../primitives/popover.js";
11
11
  import { SegmentedControl } from "../primitives/segmented-control.js";
12
12
  import { Slider } from "../primitives/slider.js";
13
+ import { SEPARATOR, intersectControlSets, } from "./control-sets.js";
13
14
  import { BRUSH_WIDTH_MAX, BRUSH_WIDTH_MIN, TEXT_FONT_SIZE_MAX, TEXT_FONT_SIZE_MIN, TEXT_FONT_SIZE_PRESETS, TEXT_FONT_STACKS, EMOJI_QUICK_PICKS, IMAGE_MASK_DEFAULT_RADIUS, CONTROL_ICON, } from "../core/constants.js";
14
15
  export const PropertyPanel = ({ style, className, mobile = false }) => {
15
16
  const selection = useSelection();
@@ -26,101 +27,37 @@ export const PropertyPanel = ({ style, className, mobile = false }) => {
26
27
  // splits its controls into `primary` (always visible) and `overflow`
27
28
  // (behind the ⋮ on mobile; inline on desktop).
28
29
  if (selection.size > 0) {
29
- const shapes = [...selection]
30
+ const selected = [...selection]
30
31
  .map((id) => scene.elements.get(id))
31
32
  .filter((s) => s !== undefined);
32
- if (shapes.length === 0)
33
+ if (selected.length === 0)
33
34
  return null;
34
- const allText = shapes.every((s) => isText(s));
35
- const allImage = shapes.every((s) => isImage(s));
36
- const allBrush = shapes.every((s) => isBrush(s));
37
- const allFrame = shapes.every((s) => isFrame(s));
38
- const allSticky = shapes.every((s) => isSticky(s));
39
- const allEmoji = shapes.every((s) => isEmoji(s));
40
- // Branch layouts mirror the target design (design.md): per-type clusters
41
- // separated by dividers, then a shared tail
42
- // (`… | z-order | align | actions | comment | lock | ⋯`).
43
- const primary = [];
44
- const overflow = [];
45
- // Shared head: the type switcher always leads the toolbar (the
46
- // control renders nothing for kinds outside the switch-type matrix).
47
- if (shapes.every((s) => isConvertible(s))) {
48
- primary.push(_jsx(ConvertTypeControl, { shapes: shapes }, "convert"), _jsx(Divider, {}, "d-type"));
49
- }
50
- if (allSticky) {
51
- // text (with Auto size) | size S/M/L | background | tags / author.
52
- // Text controls always show — a note without text yet edits the
53
- // defaults its first text will take (create-on-write in the editor).
54
- {
55
- const labelViews = shapes.map(labelView);
56
- primary.push(_jsx(FontFamilyControl, { shapes: labelViews, labelMode: true }, "l-family"), _jsx(FontSizeControl, { shapes: labelViews, labelMode: true, allowAuto: true }, "l-size"), _jsx(TextDecorationControl, { shapes: labelViews, labelMode: true }, "l-decor"), _jsx(TextAlignControl, { shapes: labelViews, labelMode: true }, "l-align"), _jsx(Divider, {}, "d-sticky"));
57
- }
58
- primary.push(_jsx(StickySizeControl, { shapes: shapes }, "size"), _jsx(FillOpacityControl, { shapes: shapes }, "bg"));
59
- overflow.push(_jsx(StickyTagControl, { shapes: shapes }, "tags"), _jsx(StickyAuthorControl, { shapes: shapes }, "author"));
60
- }
61
- else if (allEmoji) {
62
- primary.push(_jsx(EmojiPickerControl, { shapes: shapes }, "glyph"));
63
- }
64
- else if (allFrame) {
65
- // A frame's border is fixed chrome (dashed outline); only its body fill
66
- // is user-configurable. No stroke / width / dash / roundness controls.
67
- primary.push(_jsx(FillControl, { shapes: shapes }, "fill"));
68
- if (shapes.length === 1) {
69
- primary.push(_jsx(FrameRatioControl, { shapes: shapes }, "ratio"));
70
- }
71
- }
72
- else if (allText) {
73
- // font family | size | style | align | link — then colors.
74
- primary.push(_jsx(FontFamilyControl, { shapes: shapes }, "family"), _jsx(FontSizeControl, { shapes: shapes }, "size"), _jsx(TextDecorationControl, { shapes: shapes }, "decor"), _jsx(TextAlignControl, { shapes: shapes }, "align"), _jsx(ListControl, { shapes: shapes }, "list"));
75
- overflow.push(_jsx(LinkControl, { shapes: shapes }, "link"), _jsx(Divider, {}, "d-color"), _jsx(ColorOpacityControl, { shapes: shapes }, "color"), _jsx(HighlightControl, { shapes: shapes }, "highlight"));
76
- }
77
- else if (allImage) {
78
- // An image's pixels are the content — fill/stroke make no sense.
79
- // name | replace | download | alt | crop | opacity.
80
- if (shapes.length === 1) {
81
- primary.push(_jsx(ImageNameControl, { shapes: shapes }, "name"));
82
- overflow.push(_jsx(ReplaceImageControl, { shapes: shapes }, "replace"), _jsx(DownloadImageControl, { shapes: shapes }, "download"), _jsx(ImageAltControl, { shapes: shapes }, "alt"), _jsx(Divider, {}, "d-crop"));
83
- }
84
- overflow.push(_jsx(CropControl, { shapes: shapes }, "crop"), _jsx(MaskControl, { shapes: shapes }, "mask"), _jsx(OpacityControl, { shapes: shapes }, "opacity"));
85
- }
86
- else if (selectionBuckets(shapes).size > 1) {
87
- // MIXED types (reference behaviour): the toolbar SHRINKS to the
88
- // shared tail below, plus a Filter that narrows the actual
89
- // selection to one type bucket. Per-type controls come back once
90
- // the selection is uniform again.
91
- primary.push(_jsx(SelectionFilterControl, { shapes: shapes }, "filter"));
92
- }
93
- else {
94
- // label text controls (for every shape type that can carry text, even
95
- // before it has any) | border group (color / width / dash / corners) |
96
- // fill group (color / opacity) | link.
97
- if (shapes.every((s) => canCarryLabel(s))) {
98
- // Pseudo-shapes exposing the LABEL's font + style so the text
99
- // controls read label values; `labelMode` reroutes their writes
100
- // through the label APIs.
101
- const labelViews = shapes.map(labelView);
102
- primary.push(_jsx(FontFamilyControl, { shapes: labelViews, labelMode: true }, "l-family"), _jsx(FontSizeControl, { shapes: labelViews, labelMode: true }, "l-size"), _jsx(TextDecorationControl, { shapes: labelViews, labelMode: true }, "l-decor"), _jsx(TextAlignControl, { shapes: labelViews, labelMode: true }, "l-align"), _jsx(ColorOpacityControl, { shapes: labelViews, labelMode: true }, "l-color"), _jsx(HighlightControl, { shapes: labelViews, labelMode: true }, "l-highlight"), _jsx(Divider, {}, "d-label"));
103
- }
104
- if (allBrush) {
105
- // Brush widths are baked per point — `style.strokeWidth` has no
106
- // effect, so brush-only selections get a slider that re-bases the
107
- // baked widths, plus the plain stroke color picker.
108
- primary.push(_jsx(StrokeControl, { shapes: shapes }, "stroke"), _jsx(BrushWidthControl, { shapes: shapes }, "width"));
109
- }
110
- else {
111
- primary.push(_jsx(BorderGroupControl, { shapes: shapes }, "border"), _jsx(FillOpacityControl, { shapes: shapes }, "fill"));
112
- }
113
- // PanelShell already separates `primary` from `overflow`.
114
- overflow.push(_jsx(LinkControl, { shapes: shapes }, "link"));
35
+ // A selected group stands for its leaf descendants (reference
36
+ // behaviour): the toolbar shows — and writes — what those children share;
37
+ // the group element itself carries no editable style.
38
+ const targets = expandGroups(scene, selected);
39
+ if (targets.length === 0)
40
+ return null;
41
+ const single = targets.length === 1;
42
+ // Per-element control sets, intersected by control id for ≥2 targets
43
+ // (see control-sets.ts): uniform selections keep their type's multi set,
44
+ // mixed ones keep only what every member offers.
45
+ const set = intersectControlSets(targets.map((s) => controlSetFor(s, single ? "single" : "multi")));
46
+ const primary = renderControlSet(set.primary, targets);
47
+ const overflow = renderControlSet(set.overflow, targets);
48
+ // Mixed types: a Filter that narrows the actual selection to one type
49
+ // bucket leads the row, so the per-type controls come back on demand.
50
+ if (!single && selectionBuckets(targets).size > 1) {
51
+ primary.unshift(_jsx(SelectionFilterControl, { shapes: targets }, "filter"));
115
52
  }
116
- // Shared tail for every selection type:
117
- // `link (frame / image) | comment | lock | ⋯`.
118
- // Text and shapes carry their link control in the type cluster above;
119
- // z-order, align / distribute, duplicate / delete, group / ungroup and
120
- // flip all live in the context menu ().
121
- if (allFrame || allImage)
122
- overflow.push(_jsx(LinkControl, { shapes: shapes }, "link"));
123
- overflow.push(_jsx(CommentControl, { shapes: shapes }, "comment"), _jsx(Divider, {}, "d-lock"), _jsx(LockControl, {}, "lock"), _jsx(MoreButton, {}, "more"));
53
+ // Shared tail: `arrange · group · ungroup | comment | lock | ⋯`.
54
+ // Arrange / group / ungroup gate themselves on the selection; comment is
55
+ // single-only; z-order, duplicate / delete and flip stay in the context
56
+ // menu (⋯).
57
+ overflow.push(_jsx(Divider, {}, "d-arrange"), _jsx(ArrangeControl, { targets: targets }, "arrange"), _jsx(GroupControls, { selected: selected }, "group"), _jsx(Divider, {}, "d-comment"));
58
+ if (single)
59
+ overflow.push(_jsx(CommentControl, { shapes: targets }, "comment"));
60
+ overflow.push(_jsx(Divider, {}, "d-lock"), _jsx(LockControl, {}, "lock"), _jsx(MoreButton, {}, "more"));
124
61
  return (_jsx(PanelShell, { mobile: mobile, primary: primary, overflow: overflow, className: className, style: style }));
125
62
  }
126
63
  if (selectedLinkId !== null) {
@@ -145,11 +82,202 @@ export const PropertyPanel = ({ style, className, mobile = false }) => {
145
82
  }
146
83
  return null;
147
84
  };
85
+ const control = (id, render) => ({ id, payload: render });
86
+ /**
87
+ * The text-carrying view of an element: text elements as they are, labelable
88
+ * shapes through their embedded label (`labelView`), so one control reads
89
+ * font / style / colour off a shape + text selection alike. Writes go through
90
+ * the label-aware `editor.updateTextStyle` / `updateTextProps`.
91
+ */
92
+ const textView = (s) => (isText(s) ? s : labelView(s));
93
+ const textViews = (targets) => targets.map(textView);
94
+ /** Shared text-carrier cluster ids — identical for text and labelled shapes. */
95
+ const TEXT_CLUSTER = [
96
+ control("font-family", (t) => _jsx(FontFamilyControl, { shapes: textViews(t) })),
97
+ control("font-size", (t) => _jsx(FontSizeControl, { shapes: textViews(t) })),
98
+ control("text-decoration", (t) => _jsx(TextDecorationControl, { shapes: textViews(t) })),
99
+ control("text-align", (t) => _jsx(TextAlignControl, { shapes: textViews(t) })),
100
+ ];
101
+ const TEXT_COLOR_CLUSTER = [
102
+ control("text-color", (t) => _jsx(ColorOpacityControl, { shapes: textViews(t) })),
103
+ control("highlight", (t) => _jsx(HighlightControl, { shapes: textViews(t) })),
104
+ ];
105
+ const CONVERT = control("convert", (t) => _jsx(ConvertTypeControl, { shapes: t }));
106
+ const LINK = control("link", (t) => _jsx(LinkControl, { shapes: t }));
107
+ /**
108
+ * Ordered controls one element offers. `multi` is the single set minus what
109
+ * makes no sense for several elements: the hyperlink, the text list type,
110
+ * per-file image actions, the frame ratio. Sticky notes use their own
111
+ * font ids (auto-size font, note fonts) so a note + shape selection shares
112
+ * only the style / alignment controls, as in the reference.
113
+ */
114
+ const controlSetFor = (s, mode) => {
115
+ const single = mode === "single";
116
+ const convert = isConvertible(s) ? [CONVERT, SEPARATOR] : [];
117
+ if (isSticky(s)) {
118
+ return {
119
+ primary: [
120
+ ...convert,
121
+ control("sticky-font-family", (t) => _jsx(FontFamilyControl, { shapes: textViews(t) })),
122
+ control("sticky-font-size", (t) => _jsx(FontSizeControl, { shapes: textViews(t), allowAuto: true })),
123
+ ...TEXT_CLUSTER.slice(2),
124
+ SEPARATOR,
125
+ control("sticky-size", (t) => _jsx(StickySizeControl, { shapes: t })),
126
+ control("sticky-bg", (t) => _jsx(FillOpacityControl, { shapes: t })),
127
+ ],
128
+ overflow: [
129
+ control("sticky-tags", (t) => _jsx(StickyTagControl, { shapes: t })),
130
+ control("sticky-author", (t) => _jsx(StickyAuthorControl, { shapes: t })),
131
+ ],
132
+ };
133
+ }
134
+ if (isEmoji(s)) {
135
+ return { primary: [control("emoji", (t) => _jsx(EmojiPickerControl, { shapes: t }))], overflow: [] };
136
+ }
137
+ if (isFrame(s)) {
138
+ // A frame's border is fixed chrome; only its body fill is configurable.
139
+ return {
140
+ primary: [
141
+ control("frame-fill", (t) => _jsx(FillControl, { shapes: t })),
142
+ ...(single ? [control("frame-ratio", (t) => _jsx(FrameRatioControl, { shapes: t }))] : []),
143
+ ],
144
+ overflow: single ? [LINK] : [],
145
+ };
146
+ }
147
+ if (isText(s)) {
148
+ return {
149
+ primary: [
150
+ ...convert,
151
+ ...TEXT_CLUSTER,
152
+ ...(single ? [control("list", (t) => _jsx(ListControl, { shapes: t }))] : []),
153
+ ],
154
+ overflow: [...(single ? [LINK, SEPARATOR] : []), ...TEXT_COLOR_CLUSTER],
155
+ };
156
+ }
157
+ if (isImage(s)) {
158
+ // Pixels are the content — no fill / stroke. Per-file actions are
159
+ // single-only; crop / mask / opacity apply in bulk.
160
+ return {
161
+ primary: single ? [control("image-name", (t) => _jsx(ImageNameControl, { shapes: t }))] : [],
162
+ overflow: [
163
+ ...(single
164
+ ? [
165
+ control("image-replace", (t) => _jsx(ReplaceImageControl, { shapes: t })),
166
+ control("image-download", (t) => _jsx(DownloadImageControl, { shapes: t })),
167
+ control("image-alt", (t) => _jsx(ImageAltControl, { shapes: t })),
168
+ SEPARATOR,
169
+ ]
170
+ : []),
171
+ control("image-crop", (t) => _jsx(CropControl, { shapes: t })),
172
+ control("image-mask", (t) => _jsx(MaskControl, { shapes: t })),
173
+ control("image-opacity", (t) => _jsx(OpacityControl, { shapes: t })),
174
+ ],
175
+ };
176
+ }
177
+ if (isGroup(s))
178
+ return { primary: [], overflow: [] };
179
+ const body = isBrush(s)
180
+ ? [
181
+ // Brush widths are baked per point — `style.strokeWidth` has no
182
+ // effect, so brushes get a slider that re-bases the baked widths.
183
+ control("stroke", (t) => _jsx(StrokeControl, { shapes: t })),
184
+ control("brush-width", (t) => _jsx(BrushWidthControl, { shapes: t })),
185
+ ]
186
+ : [
187
+ control("border", (t) => _jsx(BorderGroupControl, { shapes: t })),
188
+ control("fill", (t) => _jsx(FillOpacityControl, { shapes: t })),
189
+ ];
190
+ return {
191
+ primary: [
192
+ ...convert,
193
+ ...(canCarryLabel(s) ? [...TEXT_CLUSTER, ...TEXT_COLOR_CLUSTER, SEPARATOR] : []),
194
+ ...body,
195
+ ],
196
+ overflow: single ? [LINK] : [],
197
+ };
198
+ };
199
+ const renderControlSet = (entries, targets) => entries.map((e, i) => e === SEPARATOR ? (_jsx(Divider, {}, `sep-${String(i)}`)) : (_jsx(Fragment, { children: e.payload(targets) }, e.id)));
200
+ /**
201
+ * Replace every selected group by its leaf descendants (nested groups
202
+ * flattened); plain elements pass through. Order and uniqueness preserved.
203
+ */
204
+ const expandGroups = (scene, selected) => {
205
+ const out = [];
206
+ const seen = new Set();
207
+ const push = (s) => {
208
+ if (seen.has(s.id))
209
+ return;
210
+ seen.add(s.id);
211
+ out.push(s);
212
+ };
213
+ for (const s of selected) {
214
+ if (!isGroup(s)) {
215
+ push(s);
216
+ continue;
217
+ }
218
+ for (const d of getDescendantsOf(scene, s.id))
219
+ if (!isGroup(d))
220
+ push(d);
221
+ }
222
+ return out;
223
+ };
224
+ // ---------------------------------------------------------------------------
225
+ // Tail: arrange / group / ungroup (registry-driven)
226
+ // ---------------------------------------------------------------------------
227
+ const ARRANGE_ALIGN = [
228
+ { id: "align-left", Icon: AlignStartVertical },
229
+ { id: "align-h-center", Icon: AlignCenterVertical },
230
+ { id: "align-right", Icon: AlignEndVertical },
231
+ { id: "align-top", Icon: AlignStartHorizontal },
232
+ { id: "align-v-center", Icon: AlignCenterHorizontal },
233
+ { id: "align-bottom", Icon: AlignEndHorizontal },
234
+ ];
235
+ const ARRANGE_DISTRIBUTE = [
236
+ { id: "distribute-horizontal", Icon: AlignHorizontalDistributeCenter },
237
+ { id: "distribute-vertical", Icon: AlignVerticalDistributeCenter },
238
+ ];
239
+ /** One registry action as a toolbar / popover button (disabled by its predicate). */
240
+ const ActionIconButton = ({ id, Icon, }) => {
241
+ const editor = useDiagramOptional();
242
+ const action = defaultActionRegistry.get(id);
243
+ if (!editor || !action)
244
+ return null;
245
+ const enabled = action.predicate ? action.predicate({ editor }) : true;
246
+ const matchers = action.hotkey === undefined
247
+ ? []
248
+ : Array.isArray(action.hotkey)
249
+ ? action.hotkey
250
+ : [action.hotkey];
251
+ const first = matchers[0];
252
+ const label = action.label ?? id;
253
+ return (_jsx("button", { type: "button", className: "du-sel-icon-button", "aria-label": label, title: first ? `${label} (${formatHotkey(first)})` : label, disabled: !enabled, onClick: () => {
254
+ defaultActionRegistry.dispatch(id, { editor });
255
+ }, children: _jsx(Icon, { ...CONTROL_ICON, "aria-hidden": true }) }));
256
+ };
257
+ /**
258
+ * Arrange popover for ≥2 targets: align (left / centre / right, top /
259
+ * middle / bottom) and distribute (horizontally / vertically, from 3). The
260
+ * buttons follow the registry predicates, so a single whole group — two
261
+ * children but one selected id — shows them disabled, as in the reference.
262
+ */
263
+ const ArrangeControl = ({ targets }) => {
264
+ if (targets.length < 2)
265
+ return null;
266
+ return (_jsx(Popover, { ariaLabel: "Arrange", trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", "aria-label": "Arrange", title: "Arrange", children: _jsx(LayoutGrid, { ...CONTROL_ICON, "aria-hidden": true }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Align" }), _jsx("div", { className: "du-sel-arrange-row", children: ARRANGE_ALIGN.map((a) => (_jsx(ActionIconButton, { id: a.id, Icon: a.Icon }, a.id))) }), _jsx("header", { className: "du-sel-popover-label", children: "Distribute" }), _jsx("div", { className: "du-sel-arrange-row", children: ARRANGE_DISTRIBUTE.map((a) => (_jsx(ActionIconButton, { id: a.id, Icon: a.Icon }, a.id))) })] }) }));
267
+ };
148
268
  /**
149
- * Lays out the primary / overflow control groups. Desktop = one floating
150
- * pill row (primary · divider · overflow). Mobile = a primary row with a
151
- * vertical-dots that expands a wrapped overflow grid below it.
269
+ * Group / Ungroup buttons. Group needs ≥2 selected ids; Ungroup needs a
270
+ * group among them (both show for a group + something else). Neither shows
271
+ * while editing inside a group (double-clicked in): the children are being
272
+ * edited in place, not regrouped.
152
273
  */
274
+ const GroupControls = ({ selected }) => {
275
+ const enteredGroup = useEditorSelector((e) => e.enteredGroup, null);
276
+ if (enteredGroup !== null)
277
+ return null;
278
+ const hasGroup = selected.some((s) => isGroup(s));
279
+ return (_jsxs(_Fragment, { children: [selected.length >= 2 ? _jsx(ActionIconButton, { id: "group-selection", Icon: GroupIcon }) : null, hasGroup ? _jsx(ActionIconButton, { id: "ungroup-selection", Icon: UngroupIcon }) : null] }));
280
+ };
153
281
  const PanelShell = ({ mobile, primary, overflow, className, style, }) => {
154
282
  const [expanded, setExpanded] = useState(false);
155
283
  const panelRef = useRef(null);
@@ -287,9 +415,10 @@ const useTextRunRange = (shapes) => {
287
415
  * Combined color & opacity control — a single swatch trigger whose
288
416
  * popover has both the palette and an opacity slider. Used for the text
289
417
  * panel in place of separate Fill + Opacity triggers. Writes `fill` and
290
- * `opacity` through `editor.updateStyle`.
418
+ * `opacity` through `editor.updateTextStyle` (element style for text,
419
+ * label style for labelable shapes).
291
420
  */
292
- const ColorOpacityControl = ({ shapes, labelMode, }) => {
421
+ const ColorOpacityControl = ({ shapes }) => {
293
422
  const editor = useDiagramOptional();
294
423
  const runRange = useTextRunRange(shapes);
295
424
  if (!editor)
@@ -309,19 +438,13 @@ const ColorOpacityControl = ({ shapes, labelMode, }) => {
309
438
  fill: v ?? "transparent",
310
439
  });
311
440
  }
312
- else if (labelMode) {
313
- editor.updateLabelStyle(ids, { fill: v ?? "transparent" });
314
- }
315
441
  else {
316
- editor.updateStyle(ids, { fill: v ?? "transparent" });
442
+ editor.updateTextStyle(ids, { fill: v ?? "transparent" });
317
443
  }
318
444
  }, onEyedrop: (cb) => {
319
445
  editor.beginEyedropperPick(cb);
320
446
  } }), _jsx("header", { className: "du-sel-popover-label", children: "Opacity" }), _jsx(Slider, { value: pct, min: 0, max: 100, step: 5, ariaLabel: "Opacity", valueLabel: pct === null ? "—" : `${pct}%`, onChange: (v) => {
321
- if (labelMode)
322
- editor.updateLabelStyle(ids, { opacity: v / 100 });
323
- else
324
- editor.updateStyle(ids, { opacity: v / 100 });
447
+ editor.updateTextStyle(ids, { opacity: v / 100 });
325
448
  } })] }) }));
326
449
  };
327
450
  /** Sticky size presets (S / M / L squares). */
@@ -599,7 +722,7 @@ const ListControl = ({ shapes }) => {
599
722
  * characters (styled runs); otherwise on the whole element(s). Picking
600
723
  * "no color" clears the highlight.
601
724
  */
602
- const HighlightControl = ({ shapes, labelMode, }) => {
725
+ const HighlightControl = ({ shapes }) => {
603
726
  const editor = useDiagramOptional();
604
727
  const runRange = useTextRunRange(shapes);
605
728
  if (!editor)
@@ -611,11 +734,8 @@ const HighlightControl = ({ shapes, labelMode, }) => {
611
734
  if (runRange) {
612
735
  editor.applyTextStyleToRange(runRange.target.id, runRange.from, runRange.to, partial);
613
736
  }
614
- else if (labelMode) {
615
- editor.updateLabelStyle(ids, partial);
616
- }
617
737
  else {
618
- editor.updateStyle(ids, partial);
738
+ editor.updateTextStyle(ids, partial);
619
739
  }
620
740
  }, onEyedrop: (cb) => {
621
741
  editor.beginEyedropperPick(cb);
@@ -640,7 +760,7 @@ const FillControl = ({ shapes }) => {
640
760
  // `textAlign` / `textBaseline` are `TextStyle` fields written via
641
761
  // `editor.updateStyle`.
642
762
  // ---------------------------------------------------------------------------
643
- const FontSizeControl = ({ shapes, labelMode, allowAuto, }) => {
763
+ const FontSizeControl = ({ shapes, allowAuto, }) => {
644
764
  const editor = useDiagramOptional();
645
765
  if (!editor)
646
766
  return null;
@@ -657,18 +777,12 @@ const FontSizeControl = ({ shapes, labelMode, allowAuto, }) => {
657
777
  label: p.label,
658
778
  icon: _jsx("span", { style: { fontSize: 11, fontWeight: 600 }, children: p.label }),
659
779
  })), onChange: (v) => {
660
- if (labelMode)
661
- editor.updateLabelProps(ids, { fontSize: v });
662
- else
663
- editor.updateTextProps(ids, { fontSize: v });
780
+ editor.updateTextProps(ids, { fontSize: v });
664
781
  } }), _jsx(Slider, { value: value, min: TEXT_FONT_SIZE_MIN, max: TEXT_FONT_SIZE_MAX, step: 1, ariaLabel: "Font size", valueLabel: value === null ? "—" : `${value}px`, onChange: (v) => {
665
- if (labelMode)
666
- editor.updateLabelProps(ids, { fontSize: v });
667
- else
668
- editor.updateTextProps(ids, { fontSize: v });
782
+ editor.updateTextProps(ids, { fontSize: v });
669
783
  } })] }) }));
670
784
  };
671
- const FontFamilyControl = ({ shapes, labelMode, }) => {
785
+ const FontFamilyControl = ({ shapes }) => {
672
786
  const editor = useDiagramOptional();
673
787
  if (!editor)
674
788
  return null;
@@ -677,10 +791,7 @@ const FontFamilyControl = ({ shapes, labelMode, }) => {
677
791
  const current = TEXT_FONT_STACKS.find((f) => f.value === value);
678
792
  const label = value === null ? "Mixed" : (current?.label ?? "Custom");
679
793
  return (_jsx(Popover, { ariaLabel: "Font family", trigger: _jsx("button", { type: "button", className: "du-sel-text-button", title: "Font family", "aria-label": `Font family ${label}`, style: value ? { fontFamily: value } : undefined, children: label }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Font family" }), TEXT_FONT_STACKS.map((f) => (_jsx("button", { type: "button", role: "menuitemradio", "aria-checked": f.value === value, className: `du-sel-menu-row${f.value === value ? " is-active" : ""}`, style: { fontFamily: f.value }, onClick: () => {
680
- if (labelMode)
681
- editor.updateLabelProps(ids, { fontFamily: f.value });
682
- else
683
- editor.updateTextProps(ids, { fontFamily: f.value });
794
+ editor.updateTextProps(ids, { fontFamily: f.value });
684
795
  }, children: f.label }, f.value)))] }) }));
685
796
  };
686
797
  /**
@@ -689,7 +800,7 @@ const FontFamilyControl = ({ shapes, labelMode, }) => {
689
800
  * `textBaseline`). One trigger keeps the toolbar row compact and matches
690
801
  * the target design's grouped alignment control.
691
802
  */
692
- const TextAlignControl = ({ shapes, labelMode, }) => {
803
+ const TextAlignControl = ({ shapes }) => {
693
804
  const editor = useDiagramOptional();
694
805
  if (!editor)
695
806
  return null;
@@ -705,10 +816,7 @@ const TextAlignControl = ({ shapes, labelMode, }) => {
705
816
  },
706
817
  { value: "right", label: "Right", icon: _jsx(AlignRight, { ...CONTROL_ICON }) },
707
818
  ], onChange: (v) => {
708
- if (labelMode)
709
- editor.updateLabelStyle(ids, { textAlign: v });
710
- else
711
- editor.updateStyle(ids, { textAlign: v });
819
+ editor.updateTextStyle(ids, { textAlign: v });
712
820
  } }), _jsx(SegmentedControl, { ariaLabel: "Vertical text alignment", value: valign, options: [
713
821
  {
714
822
  value: "top",
@@ -726,10 +834,7 @@ const TextAlignControl = ({ shapes, labelMode, }) => {
726
834
  icon: _jsx(AlignEndHorizontal, { ...CONTROL_ICON }),
727
835
  },
728
836
  ], onChange: (v) => {
729
- if (labelMode)
730
- editor.updateLabelStyle(ids, { textBaseline: v });
731
- else
732
- editor.updateStyle(ids, { textBaseline: v });
837
+ editor.updateTextStyle(ids, { textBaseline: v });
733
838
  } })] }) }));
734
839
  };
735
840
  /**
@@ -749,7 +854,7 @@ const TextStyleToggle = ({ active, label, icon, onClick, }) => (_jsx("button", {
749
854
  * underline/strikethrough→merged `textDecoration`. Active = every
750
855
  * selected shape already has that decoration on.
751
856
  */
752
- const TextDecorationControl = ({ shapes, labelMode, }) => {
857
+ const TextDecorationControl = ({ shapes }) => {
753
858
  const editor = useDiagramOptional();
754
859
  const runRange = useTextRunRange(shapes);
755
860
  if (!editor)
@@ -778,24 +883,14 @@ const TextDecorationControl = ({ shapes, labelMode, }) => {
778
883
  if (runRange) {
779
884
  editor.applyTextStyleToRange(runRange.target.id, runRange.from, runRange.to, partial);
780
885
  }
781
- else if (labelMode) {
782
- editor.updateLabelStyle(ids, partial);
783
- }
784
886
  else {
785
- editor.updateStyle(ids, partial);
887
+ editor.updateTextStyle(ids, partial);
786
888
  }
787
889
  };
788
890
  // Toggling underline/strikethrough must preserve the other flag. Range mode
789
891
  // replaces the whole `textDecoration`, so rebuild both flags from the
790
892
  // range's current state; whole-element mode merges per shape.
791
893
  const setDecoration = (key, on) => {
792
- if (labelMode && !runRange) {
793
- for (const s of shapes) {
794
- const cur = s.style?.textDecoration ?? {};
795
- editor.updateLabelStyle([s.id], { textDecoration: { ...cur, [key]: on } });
796
- }
797
- return;
798
- }
799
894
  if (runRange) {
800
895
  editor.applyTextStyleToRange(runRange.target.id, runRange.from, runRange.to, {
801
896
  textDecoration: { underline: allUnderline, strikethrough: allStrike, [key]: on },
@@ -804,7 +899,7 @@ const TextDecorationControl = ({ shapes, labelMode, }) => {
804
899
  else {
805
900
  for (const s of shapes) {
806
901
  const cur = s.style?.textDecoration ?? {};
807
- editor.updateStyle([s.id], { textDecoration: { ...cur, [key]: on } });
902
+ editor.updateTextStyle([s.id], { textDecoration: { ...cur, [key]: on } });
808
903
  }
809
904
  }
810
905
  };