@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.
- package/CHANGELOG.md +18 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/panels/control-sets.d.ts +42 -0
- package/dist/panels/control-sets.d.ts.map +1 -0
- package/dist/panels/control-sets.js +63 -0
- package/dist/panels/control-sets.js.map +1 -0
- package/dist/panels/property-panel.d.ts.map +1 -1
- package/dist/panels/property-panel.js +244 -149
- package/dist/panels/property-panel.js.map +1 -1
- package/package.json +2 -2
- package/src/styles.css +6 -0
|
@@ -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
|
|
30
|
+
const selected = [...selection]
|
|
30
31
|
.map((id) => scene.elements.get(id))
|
|
31
32
|
.filter((s) => s !== undefined);
|
|
32
|
-
if (
|
|
33
|
+
if (selected.length === 0)
|
|
33
34
|
return null;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
// (
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
overflow.push(_jsx(
|
|
123
|
-
overflow.push(_jsx(
|
|
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
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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.
|
|
418
|
+
* `opacity` through `editor.updateTextStyle` (element style for text,
|
|
419
|
+
* label style for labelable shapes).
|
|
291
420
|
*/
|
|
292
|
-
const ColorOpacityControl = ({ shapes
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
902
|
+
editor.updateTextStyle([s.id], { textDecoration: { ...cur, [key]: on } });
|
|
808
903
|
}
|
|
809
904
|
}
|
|
810
905
|
};
|