@formicoidea/labre-framework-edgy 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +24 -0
- package/src/consts.ts +50 -0
- package/src/descriptor.ts +8 -0
- package/src/effects.ts +14 -0
- package/src/element-renderer.ts +208 -0
- package/src/element-view.ts +145 -0
- package/src/index.ts +1 -0
- package/src/label-layout.ts +105 -0
- package/src/node/consts.ts +56 -0
- package/src/node/node-renderer.ts +64 -0
- package/src/node/node-view.ts +33 -0
- package/src/templates/index.ts +254 -0
- package/src/toolbar/config.ts +96 -0
- package/src/toolbar/edgy-menu.ts +242 -0
- package/src/toolbar/edgy-senior-button.ts +102 -0
- package/src/toolbar/icons.ts +38 -0
- package/src/toolbar/node-config.ts +202 -0
- package/src/toolbar/senior-tool.ts +11 -0
- package/src/view.ts +39 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mountShapeTextEditor } from '@formicoidea/labre-core/gfx/shape';
|
|
2
|
+
import {
|
|
3
|
+
type EdgyNodeElementModel,
|
|
4
|
+
ShapeElementModel,
|
|
5
|
+
} from '@formicoidea/labre-core/model';
|
|
6
|
+
import { GfxElementModelView } from '@formicoidea/labre-core/std/gfx';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* View for an EDGY base-element node. Registering it ensures `gfx.view.get(model)`
|
|
10
|
+
* returns a view (required so move / select / connector interactions work).
|
|
11
|
+
*
|
|
12
|
+
* EDGY nodes are native shapes, so we reuse the shape inner-text editor: a
|
|
13
|
+
* double-click mounts the editable text overlay (`mountShapeTextEditor`), exactly
|
|
14
|
+
* like a native shape. We deliberately do NOT inherit the polygon vertex-editing
|
|
15
|
+
* overlay (the Activity chevron should keep its shape).
|
|
16
|
+
*/
|
|
17
|
+
export class EdgyNodeView extends GfxElementModelView<EdgyNodeElementModel> {
|
|
18
|
+
static override type: string = 'edgyNode';
|
|
19
|
+
|
|
20
|
+
override onCreated(): void {
|
|
21
|
+
super.onCreated();
|
|
22
|
+
this.on('dblclick', () => {
|
|
23
|
+
const edgeless = this.std.view.getBlock(this.std.store.root!.id);
|
|
24
|
+
if (
|
|
25
|
+
edgeless &&
|
|
26
|
+
!this.model.isLocked() &&
|
|
27
|
+
this.model instanceof ShapeElementModel
|
|
28
|
+
) {
|
|
29
|
+
mountShapeTextEditor(this.model, edgeless);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {
|
|
2
|
+
makeTemplateSnapshot,
|
|
3
|
+
type SurfaceElementsJSON,
|
|
4
|
+
surfaceText,
|
|
5
|
+
type Template,
|
|
6
|
+
type TemplateCategory,
|
|
7
|
+
} from '@formicoidea/labre-core/gfx/template';
|
|
8
|
+
import {
|
|
9
|
+
ConnectorMode,
|
|
10
|
+
FontFamily,
|
|
11
|
+
PointStyle,
|
|
12
|
+
ShapeStyle,
|
|
13
|
+
StrokeStyle,
|
|
14
|
+
TextAlign,
|
|
15
|
+
} from '@formicoidea/labre-core/model';
|
|
16
|
+
|
|
17
|
+
import { REF_H, REF_W } from '../consts';
|
|
18
|
+
import {
|
|
19
|
+
ACTIVITY_VERTICES,
|
|
20
|
+
INNER_FONT_SIZE,
|
|
21
|
+
NODE_FILL,
|
|
22
|
+
NODE_STROKE,
|
|
23
|
+
NODE_STROKE_WIDTH,
|
|
24
|
+
OUTCOME_RADIUS,
|
|
25
|
+
} from '../node/consts';
|
|
26
|
+
|
|
27
|
+
// EDGY facet palette (header / pale sub-card).
|
|
28
|
+
const C = {
|
|
29
|
+
identity: ['#1ec873', '#9fe6c2'],
|
|
30
|
+
organisation: ['#4fd0ea', '#c2eef8'],
|
|
31
|
+
architecture: ['#2f6ff0', '#b3c8f7'],
|
|
32
|
+
product: ['#cf8cff', '#e7ccff'],
|
|
33
|
+
experience: ['#f5246e', '#ffc0d4'],
|
|
34
|
+
brand: ['#eeba51', '#f7e1ad'],
|
|
35
|
+
} as const;
|
|
36
|
+
const JOURNEY_PINK = '#f3a3c0';
|
|
37
|
+
|
|
38
|
+
// ── element helpers ───────────────────────────────────────────────────
|
|
39
|
+
function rect(
|
|
40
|
+
x: number,
|
|
41
|
+
y: number,
|
|
42
|
+
w: number,
|
|
43
|
+
h: number,
|
|
44
|
+
opts: { fill?: string; stroke?: string; sw?: number; radius?: number; text?: string; textColor?: string; fontSize?: number } = {}
|
|
45
|
+
) {
|
|
46
|
+
const el: Record<string, unknown> = {
|
|
47
|
+
type: 'shape',
|
|
48
|
+
shapeType: 'rect',
|
|
49
|
+
filled: true,
|
|
50
|
+
fillColor: opts.fill ?? '#ffffff',
|
|
51
|
+
strokeColor: opts.stroke ?? NODE_STROKE,
|
|
52
|
+
strokeWidth: opts.sw ?? NODE_STROKE_WIDTH,
|
|
53
|
+
shapeStyle: ShapeStyle.General,
|
|
54
|
+
roughness: 0,
|
|
55
|
+
radius: opts.radius ?? 0,
|
|
56
|
+
xywh: `[${x},${y},${w},${h}]`,
|
|
57
|
+
};
|
|
58
|
+
if (opts.text != null) {
|
|
59
|
+
el.text = surfaceText(opts.text);
|
|
60
|
+
el.color = opts.textColor ?? NODE_STROKE;
|
|
61
|
+
el.fontFamily = FontFamily.Inter;
|
|
62
|
+
el.fontSize = opts.fontSize ?? 16;
|
|
63
|
+
el.textAlign = TextAlign.Center;
|
|
64
|
+
}
|
|
65
|
+
return el;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** An EDGY node (kind drives the native shape): outcome/object box, people, activity chevron. */
|
|
69
|
+
function enode(
|
|
70
|
+
kind: 'outcome' | 'object' | 'people' | 'activity',
|
|
71
|
+
x: number,
|
|
72
|
+
y: number,
|
|
73
|
+
w: number,
|
|
74
|
+
h: number,
|
|
75
|
+
opts: { fill?: string; text?: string; textColor?: string; fontSize?: number } = {}
|
|
76
|
+
) {
|
|
77
|
+
const el: Record<string, unknown> = {
|
|
78
|
+
type: 'edgyNode',
|
|
79
|
+
kind,
|
|
80
|
+
filled: true,
|
|
81
|
+
fillColor: opts.fill ?? NODE_FILL,
|
|
82
|
+
strokeColor: NODE_STROKE,
|
|
83
|
+
strokeWidth: kind === 'people' ? 0 : NODE_STROKE_WIDTH,
|
|
84
|
+
shapeStyle: ShapeStyle.General,
|
|
85
|
+
roughness: 0,
|
|
86
|
+
shapeType: kind === 'people' ? 'ellipse' : kind === 'activity' ? 'polygon' : 'rect',
|
|
87
|
+
radius: kind === 'outcome' ? OUTCOME_RADIUS : 0,
|
|
88
|
+
xywh: `[${x},${y},${w},${h}]`,
|
|
89
|
+
};
|
|
90
|
+
if (kind === 'activity') el.vertices = ACTIVITY_VERTICES;
|
|
91
|
+
if (opts.text != null) {
|
|
92
|
+
el.text = surfaceText(opts.text);
|
|
93
|
+
el.color = opts.textColor ?? NODE_STROKE;
|
|
94
|
+
el.fontFamily = FontFamily.Inter;
|
|
95
|
+
el.fontSize = opts.fontSize ?? INNER_FONT_SIZE;
|
|
96
|
+
el.textAlign = TextAlign.Center;
|
|
97
|
+
}
|
|
98
|
+
return el;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function label(x: number, y: number, w: number, h: number, str: string, opts: { color?: string; fontSize?: number; align?: TextAlign } = {}) {
|
|
102
|
+
return {
|
|
103
|
+
type: 'text',
|
|
104
|
+
text: surfaceText(str),
|
|
105
|
+
color: opts.color ?? NODE_STROKE,
|
|
106
|
+
fontFamily: FontFamily.Inter,
|
|
107
|
+
fontSize: opts.fontSize ?? 16,
|
|
108
|
+
textAlign: opts.align ?? TextAlign.Center,
|
|
109
|
+
xywh: `[${x},${y},${w},${h}]`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function line(x1: number, y1: number, x2: number, y2: number, opts: { arrow?: boolean; dash?: boolean; sw?: number } = {}) {
|
|
114
|
+
return {
|
|
115
|
+
type: 'connector',
|
|
116
|
+
mode: ConnectorMode.Orthogonal,
|
|
117
|
+
stroke: NODE_STROKE,
|
|
118
|
+
strokeWidth: opts.sw ?? 2,
|
|
119
|
+
strokeStyle: opts.dash ? StrokeStyle.Dash : StrokeStyle.Solid,
|
|
120
|
+
frontEndpointStyle: PointStyle.None,
|
|
121
|
+
rearEndpointStyle: opts.arrow ? PointStyle.Triangle : PointStyle.None,
|
|
122
|
+
source: { position: [x1, y1] },
|
|
123
|
+
target: { position: [x2, y2] },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Facets overview (six facets, sub-cards, facets/intersections) ─────
|
|
128
|
+
function facetsOverview(): SurfaceElementsJSON {
|
|
129
|
+
const out: SurfaceElementsJSON = {};
|
|
130
|
+
const tall = (key: keyof typeof C, x: number, name: string, cards: [string, string, string]) => {
|
|
131
|
+
const [hdr, sub] = C[key];
|
|
132
|
+
out[`${key}F`] = rect(x, 140, 192, 460, { fill: hdr, stroke: hdr, radius: 16 });
|
|
133
|
+
out[`${key}N`] = label(x, 168, 192, 28, name, { color: '#ffffff', fontSize: 18 });
|
|
134
|
+
out[`${key}1`] = rect(x + 16, 208, 160, 80, { fill: sub, stroke: sub, radius: 6, text: cards[0], fontSize: 16 });
|
|
135
|
+
out[`${key}2`] = rect(x + 16, 300, 160, 80, { fill: sub, stroke: sub, radius: 6, text: cards[1], fontSize: 16 });
|
|
136
|
+
out[`${key}3`] = enode('activity', x + 16, 392, 160, 84, { fill: sub, text: cards[2], fontSize: 16 });
|
|
137
|
+
};
|
|
138
|
+
const low = (key: keyof typeof C, x: number, name: string) => {
|
|
139
|
+
const [hdr, sub] = C[key];
|
|
140
|
+
out[`${key}F`] = rect(x, 300, 192, 424, { fill: hdr, stroke: hdr, radius: 16 });
|
|
141
|
+
out[`${key}1`] = rect(x + 16, 360, 160, 92, { fill: sub, stroke: sub, radius: 6, text: name, fontSize: 16 });
|
|
142
|
+
out[`${key}N`] = label(x, 686, 192, 28, name, { color: '#ffffff', fontSize: 18 });
|
|
143
|
+
};
|
|
144
|
+
// lower facets first (drawn behind the tall ones at the overlaps)
|
|
145
|
+
low('organisation', 240, 'Organisation');
|
|
146
|
+
low('product', 664, 'Product');
|
|
147
|
+
low('brand', 1088, 'Brand');
|
|
148
|
+
tall('identity', 40, 'Identity', ['Purpose', 'Content', 'Story']);
|
|
149
|
+
tall('architecture', 464, 'Architecture', ['Capability', 'Asset', 'Process']);
|
|
150
|
+
tall('experience', 888, 'Experience', ['Task', 'Channel', 'Journey']);
|
|
151
|
+
out.fLabel = label(464, 80, 192, 28, 'Facets', { fontSize: 18 });
|
|
152
|
+
out.fBar = line(136, 120, 984, 120);
|
|
153
|
+
out.iLabel = label(664, 740, 192, 28, 'Intersections', { fontSize: 18 });
|
|
154
|
+
out.iBar = line(336, 728, 1184, 728);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Customer journey ──────────────────────────────────────────────────
|
|
159
|
+
function journey(): SurfaceElementsJSON {
|
|
160
|
+
const step = (i: number, x: number) =>
|
|
161
|
+
enode('activity', x, 132, 224, 116, { fill: JOURNEY_PINK, text: `Journey step ${i}`, textColor: '#ffffff', fontSize: 18 });
|
|
162
|
+
const ch = (x: number, n: string) => enode('object', x, 392, 184, 96, { fill: JOURNEY_PINK, text: `Channel ${n}`, fontSize: 16 });
|
|
163
|
+
const tk = (x: number, n: string) => enode('object', x, 540, 168, 92, { fill: JOURNEY_PINK, text: `Task ${n}`, fontSize: 16 });
|
|
164
|
+
return {
|
|
165
|
+
cust: enode('people', 40, 120, 64, 64, { fill: '#ffffff' }),
|
|
166
|
+
custL: label(20, 192, 104, 24, 'Customer', { fontSize: 14 }),
|
|
167
|
+
band: { type: 'shape', shapeType: 'polygon', vertices: [[0, 0], [0.86, 0], [1, 0.5], [0.86, 1], [0, 1]], filled: true, fillColor: JOURNEY_PINK, strokeColor: JOURNEY_PINK, strokeWidth: 1, shapeStyle: ShapeStyle.General, roughness: 0, xywh: '[150,108,860,164]' },
|
|
168
|
+
bandL: label(170, 116, 120, 24, 'Journey', { color: '#ffffff', fontSize: 16, align: TextAlign.Left }),
|
|
169
|
+
s1: step(1, 240),
|
|
170
|
+
s2: step(2, 500),
|
|
171
|
+
s3: step(3, 760),
|
|
172
|
+
rightTask: enode('object', 1060, 132, 168, 92, { text: 'Task', fontSize: 16 }),
|
|
173
|
+
c1: ch(258, 'X'), c2: ch(518, 'Y'), c3: ch(778, 'Z'),
|
|
174
|
+
t1: tk(266, 'A'), t2: tk(526, 'B'), t3: tk(786, 'C'),
|
|
175
|
+
trav1: label(258, 350, 184, 20, 'traverses', { fontSize: 13, color: '#5f6368' }),
|
|
176
|
+
trav2: label(518, 350, 184, 20, 'traverses', { fontSize: 13, color: '#5f6368' }),
|
|
177
|
+
trav3: label(778, 350, 184, 20, 'traverses', { fontSize: 13, color: '#5f6368' }),
|
|
178
|
+
use1: label(258, 504, 184, 20, 'uses', { fontSize: 13, color: '#5f6368' }),
|
|
179
|
+
use2: label(518, 504, 184, 20, 'uses', { fontSize: 13, color: '#5f6368' }),
|
|
180
|
+
use3: label(778, 504, 184, 20, 'uses', { fontSize: 13, color: '#5f6368' }),
|
|
181
|
+
l1: line(350, 248, 350, 392), l2: line(610, 248, 610, 392), l3: line(870, 248, 870, 392),
|
|
182
|
+
l4: line(350, 488, 350, 540), l5: line(610, 488, 610, 540), l6: line(870, 488, 870, 540),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Service blueprint (six swimlanes) ─────────────────────────────────
|
|
187
|
+
function blueprint(): SurfaceElementsJSON {
|
|
188
|
+
const lanes = ['Physical Evidence', 'Customer Actions', 'On-stage Actions', 'Back-stage Actions', 'Support Processes', 'Support Systems'];
|
|
189
|
+
const laneFill = ['#fdeef2', '#fbd5e0', '#dff0fb', '#d4e9f8', '#d4e9f8', '#d4e9f8'];
|
|
190
|
+
const out: SurfaceElementsJSON = {};
|
|
191
|
+
lanes.forEach((name, i) => {
|
|
192
|
+
const y = 40 + i * 150;
|
|
193
|
+
out[`lane${i}`] = rect(36, y, 1280, 150, { fill: laneFill[i], stroke: laneFill[i], sw: 0 });
|
|
194
|
+
out[`laneL${i}`] = label(52, y + 12, 260, 22, name, { fontSize: 15, align: TextAlign.Left });
|
|
195
|
+
});
|
|
196
|
+
const chev = (x: number, y: number, fill: string, t: string) => enode('activity', x, y, 200, 60, { fill, text: t, fontSize: 14 });
|
|
197
|
+
const box = (x: number, y: number, fill: string, t: string) => enode('object', x, y, 200, 60, { fill, text: t, fontSize: 14 });
|
|
198
|
+
const P = C.experience[1], B = C.architecture[1];
|
|
199
|
+
Object.assign(out, {
|
|
200
|
+
af: rect(280, 64, 220, 70, { text: 'Admission Form' }),
|
|
201
|
+
cf: rect(1010, 64, 220, 70, { text: 'Confirmation' }),
|
|
202
|
+
sf: chev(280, 214, P, 'Send Form'), fs: chev(600, 214, P, 'Follow status'), rd: chev(1010, 214, P, 'Receive decision'),
|
|
203
|
+
fh: chev(280, 364, B, 'Form handling'), cs: chev(600, 364, B, 'Customer support'), ct: chev(1010, 364, B, 'Contact'),
|
|
204
|
+
csv: chev(280, 514, B, 'Customer service'), cfu: box(1010, 514, B, 'Customer follow-up'),
|
|
205
|
+
pf: chev(600, 664, B, 'Process Form'), dec: chev(900, 664, B, 'Decision [Accept|Reject]'),
|
|
206
|
+
crm: box(280, 814, B, 'CRM Application'), cms: box(600, 814, B, 'Case Management'), pay: box(900, 814, B, 'Payments System'),
|
|
207
|
+
a1: line(380, 134, 380, 214, { arrow: true }),
|
|
208
|
+
a2: line(480, 244, 600, 244, { arrow: true }),
|
|
209
|
+
a3: line(800, 244, 1010, 244, { arrow: true }),
|
|
210
|
+
a4: line(320, 274, 320, 364, { arrow: true }),
|
|
211
|
+
a5: line(320, 424, 320, 514, { arrow: true }),
|
|
212
|
+
a6: line(700, 724, 900, 694, { arrow: true }),
|
|
213
|
+
a7: line(380, 874, 600, 844, { arrow: true }),
|
|
214
|
+
a8: line(800, 844, 900, 844, { arrow: true }),
|
|
215
|
+
});
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Organisation chart ────────────────────────────────────────────────
|
|
220
|
+
function orgChart(): SurfaceElementsJSON {
|
|
221
|
+
const cyan = C.organisation[0];
|
|
222
|
+
const u = (x: number, y: number, w: number, t: string) => enode('object', x, y, w, 64, { fill: cyan, text: t, fontSize: 16 });
|
|
223
|
+
return {
|
|
224
|
+
org: u(420, 40, 180, 'Organisation'),
|
|
225
|
+
a: u(120, 200, 200, 'Business Unit A'), b: u(410, 200, 200, 'Business Unit B'), c: u(700, 200, 200, 'Business Unit C'),
|
|
226
|
+
a1: u(60, 360, 170, 'Group A-1'), a2: u(260, 360, 170, 'Group A-2'), c1: u(715, 360, 170, 'Group C-1'),
|
|
227
|
+
e1: line(510, 104, 510, 150), e2: line(220, 150, 800, 150),
|
|
228
|
+
e3: line(220, 150, 220, 200), e4: line(510, 150, 510, 200), e5: line(800, 150, 800, 200),
|
|
229
|
+
e6: line(220, 264, 220, 320), e7: line(145, 320, 345, 320),
|
|
230
|
+
e8: line(145, 320, 145, 360), e9: line(345, 320, 345, 360),
|
|
231
|
+
e10: line(800, 264, 800, 360),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const single = (el: Record<string, unknown>): SurfaceElementsJSON => ({ a: el });
|
|
236
|
+
const ATTRS = 'width="100%" height="100%" viewBox="0 0 135 80" xmlns="http://www.w3.org/2000/svg"';
|
|
237
|
+
function tpl(name: string, preview: string, elements: SurfaceElementsJSON): Template {
|
|
238
|
+
return { name, type: 'template', preview, content: makeTemplateSnapshot(elements, name) };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export const edgyTemplateCategory: TemplateCategory = {
|
|
242
|
+
name: 'EDGY',
|
|
243
|
+
templates: [
|
|
244
|
+
tpl('Facets overview', `<svg ${ATTRS} fill="none"><rect x="6" y="20" width="18" height="46" rx="3" fill="#1ec873"/><rect x="46" y="20" width="18" height="46" rx="3" fill="#2f6ff0"/><rect x="86" y="20" width="18" height="46" rx="3" fill="#f5246e"/><rect x="26" y="30" width="18" height="40" rx="3" fill="#4fd0ea"/><rect x="66" y="30" width="18" height="40" rx="3" fill="#cf8cff"/><rect x="106" y="30" width="18" height="40" rx="3" fill="#eeba51"/></svg>`, facetsOverview()),
|
|
245
|
+
tpl('Customer journey', `<svg ${ATTRS} fill="none"><path d="M20 24 H110 L122 40 L110 56 H20 Z" fill="#f3a3c0"/><rect x="26" y="30" width="22" height="20" fill="none" stroke="#fff"/><rect x="54" y="30" width="22" height="20" fill="none" stroke="#fff"/><rect x="82" y="30" width="22" height="20" fill="none" stroke="#fff"/></svg>`, journey()),
|
|
246
|
+
tpl('Service blueprint', `<svg ${ATTRS} fill="none"><rect x="8" y="14" width="119" height="16" fill="#fbd5e0"/><rect x="8" y="32" width="119" height="34" fill="#d4e9f8"/><rect x="20" y="18" width="22" height="9" fill="#f5246e" opacity="0.5"/><rect x="20" y="40" width="22" height="9" fill="#2f6ff0" opacity="0.4"/><rect x="60" y="40" width="22" height="9" fill="#2f6ff0" opacity="0.4"/></svg>`, blueprint()),
|
|
247
|
+
tpl('Organisation chart', `<svg ${ATTRS} fill="none"><rect x="52" y="12" width="32" height="14" rx="2" fill="#4fd0ea"/><rect x="14" y="38" width="32" height="14" rx="2" fill="#4fd0ea"/><rect x="52" y="38" width="32" height="14" rx="2" fill="#4fd0ea"/><rect x="90" y="38" width="32" height="14" rx="2" fill="#4fd0ea"/><path d="M68 26 V32 M30 32 H106 M30 32 V38 M68 32 V38 M106 32 V38" stroke="#262626"/></svg>`, orgChart()),
|
|
248
|
+
tpl('Facets diagram', `<svg ${ATTRS}><circle cx="55" cy="34" r="18" fill="#00ea4e" opacity="0.9"/><circle cx="80" cy="34" r="18" fill="#034cee" opacity="0.9"/><circle cx="67" cy="54" r="18" fill="#ff0056" opacity="0.9"/></svg>`, single({ type: 'edgy', xywh: `[0,0,${REF_W * 1.5},${REF_H * 1.5}]` })),
|
|
249
|
+
tpl('People', `<svg ${ATTRS} fill="#262626"><circle cx="67" cy="32" r="9" fill="none" stroke="#262626" stroke-width="2.4"/><path d="M50 60 a17 17 0 0 1 34 0" fill="none" stroke="#262626" stroke-width="2.4"/></svg>`, { n: enode('people', 0, 0, 64, 64), l: label(-28, 70, 120, 24, 'People', { fontSize: 16 }) }),
|
|
250
|
+
tpl('Outcome', `<svg ${ATTRS} fill="none"><rect x="20" y="24" width="95" height="34" rx="6" stroke="#262626" stroke-width="2"/></svg>`, single(enode('outcome', 0, 0, 130, 80, { text: 'Outcome' }))),
|
|
251
|
+
tpl('Object', `<svg ${ATTRS} fill="none"><rect x="20" y="24" width="95" height="34" stroke="#262626" stroke-width="2"/></svg>`, single(enode('object', 0, 0, 130, 80, { text: 'Object' }))),
|
|
252
|
+
tpl('Activity', `<svg ${ATTRS} fill="none"><path d="M20 24 H98 L116 41 H116 L98 58 H20 Z" stroke="#262626" stroke-width="2" stroke-linejoin="round"/></svg>`, single(enode('activity', 0, 0, 140, 80, { text: 'Activity' }))),
|
|
253
|
+
],
|
|
254
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { EdgelessCRUDIdentifier } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { EdgyFacetsElementModel } from '@formicoidea/labre-core/model';
|
|
3
|
+
import {
|
|
4
|
+
type ToolbarContext,
|
|
5
|
+
type ToolbarModuleConfig,
|
|
6
|
+
ToolbarModuleExtension,
|
|
7
|
+
} from '@formicoidea/labre-core/shared/services';
|
|
8
|
+
import { BlockFlavourIdentifier } from '@formicoidea/labre-core/std';
|
|
9
|
+
import { html, type TemplateResult } from 'lit';
|
|
10
|
+
|
|
11
|
+
const ResizeIcon = html`<svg
|
|
12
|
+
width="24"
|
|
13
|
+
height="24"
|
|
14
|
+
viewBox="0 0 24 24"
|
|
15
|
+
fill="none"
|
|
16
|
+
stroke="currentColor"
|
|
17
|
+
stroke-width="1.6"
|
|
18
|
+
stroke-linecap="round"
|
|
19
|
+
stroke-linejoin="round"
|
|
20
|
+
>
|
|
21
|
+
<path d="M9 5H5v4M15 19h4v-4" />
|
|
22
|
+
<path d="M5 5l6 6M19 19l-6-6" />
|
|
23
|
+
</svg>`;
|
|
24
|
+
|
|
25
|
+
/** Tag — show / hide the facet name labels. */
|
|
26
|
+
const LabelsIcon = html`<svg
|
|
27
|
+
width="24"
|
|
28
|
+
height="24"
|
|
29
|
+
viewBox="0 0 24 24"
|
|
30
|
+
fill="none"
|
|
31
|
+
stroke="currentColor"
|
|
32
|
+
stroke-width="1.6"
|
|
33
|
+
stroke-linecap="round"
|
|
34
|
+
stroke-linejoin="round"
|
|
35
|
+
>
|
|
36
|
+
<path d="M3 8.5l6-4.5 12 0 0 16-12 0-6-4.5z" />
|
|
37
|
+
<circle cx="8" cy="12" r="1.4" />
|
|
38
|
+
</svg>`;
|
|
39
|
+
|
|
40
|
+
type EdgyToggleProp = 'resizeEnabled' | 'showLabels';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a toolbar toggle that flips a boolean flag on every selected facets
|
|
44
|
+
* diagram: `active` reflects the current state, `run` flips it (with an undo
|
|
45
|
+
* checkpoint).
|
|
46
|
+
*/
|
|
47
|
+
function booleanToggle(
|
|
48
|
+
id: string,
|
|
49
|
+
tooltip: string,
|
|
50
|
+
icon: TemplateResult,
|
|
51
|
+
prop: EdgyToggleProp
|
|
52
|
+
) {
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
tooltip,
|
|
56
|
+
icon,
|
|
57
|
+
active(ctx: ToolbarContext) {
|
|
58
|
+
const models = ctx.getSurfaceModelsByType(EdgyFacetsElementModel);
|
|
59
|
+
return models.length > 0 && models.every(model => model[prop]);
|
|
60
|
+
},
|
|
61
|
+
run(ctx: ToolbarContext) {
|
|
62
|
+
const models = ctx.getSurfaceModelsByType(EdgyFacetsElementModel);
|
|
63
|
+
if (!models.length) return;
|
|
64
|
+
|
|
65
|
+
const enable = !models.every(model => model[prop]);
|
|
66
|
+
ctx.std.store.captureSync();
|
|
67
|
+
const crud = ctx.std.get(EdgelessCRUDIdentifier);
|
|
68
|
+
for (const model of models) {
|
|
69
|
+
crud.updateElement(model.id, { [prop]: enable });
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const edgyToolbarConfig = {
|
|
76
|
+
actions: [
|
|
77
|
+
booleanToggle(
|
|
78
|
+
'a.toggle-resize',
|
|
79
|
+
'Enable / lock resizing',
|
|
80
|
+
ResizeIcon,
|
|
81
|
+
'resizeEnabled'
|
|
82
|
+
),
|
|
83
|
+
booleanToggle(
|
|
84
|
+
'b.toggle-labels',
|
|
85
|
+
'Show / hide facet labels',
|
|
86
|
+
LabelsIcon,
|
|
87
|
+
'showLabels'
|
|
88
|
+
),
|
|
89
|
+
],
|
|
90
|
+
when: ctx => ctx.getSurfaceModelsByType(EdgyFacetsElementModel).length > 0,
|
|
91
|
+
} as const satisfies ToolbarModuleConfig;
|
|
92
|
+
|
|
93
|
+
export const edgyToolbarExtension = ToolbarModuleExtension({
|
|
94
|
+
id: BlockFlavourIdentifier('affine:surface:edgy'),
|
|
95
|
+
config: edgyToolbarConfig,
|
|
96
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { DefaultTool } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { createGroupCommand } from '@formicoidea/labre-core/gfx/group';
|
|
3
|
+
import { EmptyTool } from '@formicoidea/labre-core/gfx/pointer';
|
|
4
|
+
import { FontFamily, ShapeStyle } from '@formicoidea/labre-core/model';
|
|
5
|
+
import { TelemetryProvider } from '@formicoidea/labre-core/shared/services';
|
|
6
|
+
import { EdgelessToolbarToolMixin } from '@formicoidea/labre-core/widgets/edgeless-toolbar';
|
|
7
|
+
import { Bound } from '@formicoidea/labre-core/global/gfx';
|
|
8
|
+
import type { GfxController } from '@formicoidea/labre-core/std/gfx';
|
|
9
|
+
import { css, html, LitElement } from 'lit';
|
|
10
|
+
|
|
11
|
+
import { REF_H, REF_W } from '../consts';
|
|
12
|
+
import {
|
|
13
|
+
ACTIVITY_VERTICES,
|
|
14
|
+
INNER_FONT_SIZE,
|
|
15
|
+
LABEL_FONT_SIZE,
|
|
16
|
+
LABEL_GAP,
|
|
17
|
+
NODE_FILL,
|
|
18
|
+
NODE_LABEL,
|
|
19
|
+
NODE_SIZE,
|
|
20
|
+
NODE_STROKE,
|
|
21
|
+
NODE_STROKE_WIDTH,
|
|
22
|
+
OUTCOME_RADIUS,
|
|
23
|
+
} from '../node/consts';
|
|
24
|
+
import {
|
|
25
|
+
edgyActivityIcon,
|
|
26
|
+
edgyFacetsIcon,
|
|
27
|
+
edgyObjectIcon,
|
|
28
|
+
edgyOutcomeIcon,
|
|
29
|
+
edgyPeopleIcon,
|
|
30
|
+
} from './icons';
|
|
31
|
+
|
|
32
|
+
type Surface = NonNullable<GfxController['surface']>;
|
|
33
|
+
type BoxKind = 'outcome' | 'object' | 'activity';
|
|
34
|
+
|
|
35
|
+
/** Default facets-diagram size (REF aspect, scaled up so it reads on canvas). */
|
|
36
|
+
const FACETS_SCALE = 1.5;
|
|
37
|
+
|
|
38
|
+
/** Height of the native People free-text label. */
|
|
39
|
+
const LABEL_H = LABEL_FONT_SIZE + 8;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The popover above the toolbar for the EDGY toolbox. Items create the facets
|
|
43
|
+
* diagram (the on-click Venn) and the four base-element prefab shapes — all
|
|
44
|
+
* native shapes so they stay editable.
|
|
45
|
+
*/
|
|
46
|
+
export class EdgelessEdgyMenu extends EdgelessToolbarToolMixin(LitElement) {
|
|
47
|
+
static override styles = css`
|
|
48
|
+
:host {
|
|
49
|
+
position: absolute;
|
|
50
|
+
display: flex;
|
|
51
|
+
z-index: -1;
|
|
52
|
+
}
|
|
53
|
+
.menu-content {
|
|
54
|
+
display: flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
justify-content: center;
|
|
57
|
+
}
|
|
58
|
+
.button-group-container {
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: 14px;
|
|
62
|
+
fill: var(--affine-icon-color);
|
|
63
|
+
}
|
|
64
|
+
.button-group-container svg {
|
|
65
|
+
width: 24px;
|
|
66
|
+
height: 24px;
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
override type = EmptyTool;
|
|
71
|
+
|
|
72
|
+
/** Create the Enterprise Design Facets diagram centred on the viewport. */
|
|
73
|
+
private _createFacets() {
|
|
74
|
+
const { gfx } = this;
|
|
75
|
+
if (!gfx.surface) return;
|
|
76
|
+
|
|
77
|
+
const width = REF_W * FACETS_SCALE;
|
|
78
|
+
const height = REF_H * FACETS_SCALE;
|
|
79
|
+
const { centerX, centerY } = gfx.viewport;
|
|
80
|
+
const id = gfx.surface.addElement({
|
|
81
|
+
type: 'edgy',
|
|
82
|
+
xywh: new Bound(
|
|
83
|
+
centerX - width / 2,
|
|
84
|
+
centerY - height / 2,
|
|
85
|
+
width,
|
|
86
|
+
height
|
|
87
|
+
).serialize(),
|
|
88
|
+
});
|
|
89
|
+
this._track('facets');
|
|
90
|
+
this._finish(id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Add a native free-text label (Inter), used for the People node. */
|
|
94
|
+
private _addLabel(surface: Surface, text: string, x: number, y: number) {
|
|
95
|
+
return surface.addElement({
|
|
96
|
+
type: 'text',
|
|
97
|
+
text,
|
|
98
|
+
fontFamily: FontFamily.Inter,
|
|
99
|
+
fontSize: LABEL_FONT_SIZE,
|
|
100
|
+
color: NODE_STROKE,
|
|
101
|
+
textAlign: 'center',
|
|
102
|
+
xywh: new Bound(x, y, 120, LABEL_H).serialize(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private _group(ids: string[]) {
|
|
107
|
+
const [, result] = this.edgeless.std.command.exec(createGroupCommand, {
|
|
108
|
+
elements: ids,
|
|
109
|
+
});
|
|
110
|
+
return result.groupId || ids[0];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Shared props for an EDGY node shape. */
|
|
114
|
+
private _baseShapeProps(kind: BoxKind | 'people') {
|
|
115
|
+
return {
|
|
116
|
+
type: 'edgyNode' as const,
|
|
117
|
+
kind,
|
|
118
|
+
filled: true,
|
|
119
|
+
fillColor: NODE_FILL,
|
|
120
|
+
strokeColor: NODE_STROKE,
|
|
121
|
+
shapeStyle: ShapeStyle.General,
|
|
122
|
+
roughness: 0,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Create a box base element (outcome / object / activity) with inner text. */
|
|
127
|
+
private _createBox(kind: BoxKind) {
|
|
128
|
+
const surface = this.gfx.surface;
|
|
129
|
+
if (!surface) return;
|
|
130
|
+
|
|
131
|
+
const { w, h } = NODE_SIZE[kind];
|
|
132
|
+
const { centerX: cx, centerY: cy } = this.gfx.viewport;
|
|
133
|
+
const shapeType = kind === 'activity' ? 'polygon' : 'rect';
|
|
134
|
+
|
|
135
|
+
const id = surface.addElement({
|
|
136
|
+
...this._baseShapeProps(kind),
|
|
137
|
+
shapeType,
|
|
138
|
+
strokeWidth: NODE_STROKE_WIDTH,
|
|
139
|
+
radius: kind === 'outcome' ? OUTCOME_RADIUS : 0,
|
|
140
|
+
vertices: kind === 'activity' ? ACTIVITY_VERTICES : null,
|
|
141
|
+
text: NODE_LABEL[kind],
|
|
142
|
+
color: NODE_STROKE,
|
|
143
|
+
fontFamily: FontFamily.Inter,
|
|
144
|
+
fontSize: INNER_FONT_SIZE,
|
|
145
|
+
textAlign: 'center',
|
|
146
|
+
xywh: new Bound(cx - w / 2, cy - h / 2, w, h).serialize(),
|
|
147
|
+
});
|
|
148
|
+
this._track(`node:${kind}`);
|
|
149
|
+
this._finish(id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create the People base element: an (invisible) ellipse decorated with the
|
|
154
|
+
* person glyph by the renderer, plus a native text label below, grouped.
|
|
155
|
+
*/
|
|
156
|
+
private _createPeople() {
|
|
157
|
+
const surface = this.gfx.surface;
|
|
158
|
+
if (!surface) return;
|
|
159
|
+
|
|
160
|
+
const { w, h } = NODE_SIZE.people;
|
|
161
|
+
const { centerX: cx, centerY: cy } = this.gfx.viewport;
|
|
162
|
+
|
|
163
|
+
const nodeId = surface.addElement({
|
|
164
|
+
...this._baseShapeProps('people'),
|
|
165
|
+
shapeType: 'ellipse',
|
|
166
|
+
// No visible outline — People is just the glyph; the ellipse is the bound.
|
|
167
|
+
strokeWidth: 0,
|
|
168
|
+
xywh: new Bound(cx - w / 2, cy - h / 2, w, h).serialize(),
|
|
169
|
+
});
|
|
170
|
+
const labelId = this._addLabel(
|
|
171
|
+
surface,
|
|
172
|
+
NODE_LABEL.people,
|
|
173
|
+
cx - 60,
|
|
174
|
+
cy + h / 2 + LABEL_GAP
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
this._track('node:people');
|
|
178
|
+
this._finish(this._group([nodeId, labelId]));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private _finish(id: string) {
|
|
182
|
+
const { gfx } = this;
|
|
183
|
+
gfx.doc.captureSync();
|
|
184
|
+
gfx.tool.setTool(DefaultTool);
|
|
185
|
+
gfx.selection.set({ elements: [id], editing: false });
|
|
186
|
+
// Keep the palette open (native sub-menu behaviour).
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private _track(element: string) {
|
|
190
|
+
this.edgeless.std.getOptional(TelemetryProvider)?.track(
|
|
191
|
+
'FrameworkElementAdded',
|
|
192
|
+
{
|
|
193
|
+
framework: 'edgy',
|
|
194
|
+
element,
|
|
195
|
+
page: 'whiteboard editor',
|
|
196
|
+
segment: 'edgy toolbox',
|
|
197
|
+
module: 'edgy menu',
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
override render() {
|
|
203
|
+
return html`
|
|
204
|
+
<edgeless-slide-menu>
|
|
205
|
+
<div class="menu-content">
|
|
206
|
+
<div class="button-group-container">
|
|
207
|
+
<edgeless-tool-icon-button
|
|
208
|
+
.tooltip=${'Enterprise Design facets'}
|
|
209
|
+
@click=${this._createFacets}
|
|
210
|
+
>
|
|
211
|
+
${edgyFacetsIcon}
|
|
212
|
+
</edgeless-tool-icon-button>
|
|
213
|
+
<edgeless-tool-icon-button
|
|
214
|
+
.tooltip=${'People'}
|
|
215
|
+
@click=${this._createPeople}
|
|
216
|
+
>
|
|
217
|
+
${edgyPeopleIcon}
|
|
218
|
+
</edgeless-tool-icon-button>
|
|
219
|
+
<edgeless-tool-icon-button
|
|
220
|
+
.tooltip=${'Outcome'}
|
|
221
|
+
@click=${() => this._createBox('outcome')}
|
|
222
|
+
>
|
|
223
|
+
${edgyOutcomeIcon}
|
|
224
|
+
</edgeless-tool-icon-button>
|
|
225
|
+
<edgeless-tool-icon-button
|
|
226
|
+
.tooltip=${'Object'}
|
|
227
|
+
@click=${() => this._createBox('object')}
|
|
228
|
+
>
|
|
229
|
+
${edgyObjectIcon}
|
|
230
|
+
</edgeless-tool-icon-button>
|
|
231
|
+
<edgeless-tool-icon-button
|
|
232
|
+
.tooltip=${'Activity'}
|
|
233
|
+
@click=${() => this._createBox('activity')}
|
|
234
|
+
>
|
|
235
|
+
${edgyActivityIcon}
|
|
236
|
+
</edgeless-tool-icon-button>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</edgeless-slide-menu>
|
|
240
|
+
`;
|
|
241
|
+
}
|
|
242
|
+
}
|