@formicoidea/labre-framework-bpmn 0.32.0 → 0.34.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/dist/actions.d.ts +202 -6
- package/dist/actions.js +427 -43
- package/dist/background.d.ts +2 -0
- package/dist/background.js +158 -0
- package/dist/commands-manifest.d.ts +18 -0
- package/dist/commands-manifest.js +226 -0
- package/dist/commands.js +496 -5
- package/dist/consts.d.ts +195 -4
- package/dist/consts.js +230 -4
- package/dist/element-renderer.d.ts +10 -4
- package/dist/element-renderer.js +14 -55
- package/dist/element-view.d.ts +119 -8
- package/dist/element-view.js +274 -30
- package/dist/export.d.ts +277 -0
- package/dist/export.js +1802 -0
- package/dist/facts.d.ts +48 -0
- package/dist/facts.js +127 -0
- package/dist/import.d.ts +69 -0
- package/dist/import.js +1476 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +47 -0
- package/dist/interchange.d.ts +109 -0
- package/dist/interchange.js +191 -0
- package/dist/morph.d.ts +61 -0
- package/dist/morph.js +118 -0
- package/dist/node/node-renderer.d.ts +0 -9
- package/dist/node/node-renderer.js +294 -17
- package/dist/pool-hit.d.ts +98 -0
- package/dist/pool-hit.js +130 -0
- package/dist/presets.d.ts +168 -0
- package/dist/presets.js +327 -0
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.js +189 -0
- package/dist/roles.d.ts +96 -0
- package/dist/roles.js +410 -0
- package/dist/rules.d.ts +199 -0
- package/dist/rules.js +1539 -0
- package/dist/templates/index.js +116 -9
- package/dist/toolbar/bpmn-senior-button.js +8 -2
- package/dist/toolbar/config.d.ts +28 -2
- package/dist/toolbar/config.js +93 -4
- package/dist/toolbar/icons.d.ts +67 -0
- package/dist/toolbar/icons.js +141 -0
- package/dist/toolbar/senior-tool.js +1 -0
- package/dist/translations.d.ts +3 -1
- package/dist/translations.js +38 -3
- package/dist/view.d.ts +6 -2
- package/dist/view.js +68 -5
- package/package.json +6 -2
package/dist/actions.js
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
|
-
import { DefaultTool } from '@formicoidea/labre-core/blocks/surface';
|
|
1
|
+
import { DefaultTool, EdgelessCRUDIdentifier, generateElementId, materializeInterchangeImport, reportInterchangeImport, runInterchangeImportFile, } from '@formicoidea/labre-core/blocks/surface';
|
|
2
2
|
import { ConnectorTool } from '@formicoidea/labre-core/gfx/connector';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { BpmnPoolElementModel, ConnectorMode, PointStyle, StrokeStyle, } from '@formicoidea/labre-core/model';
|
|
4
|
+
import { NotificationProvider, translateKey, } from '@formicoidea/labre-core/shared/services';
|
|
5
|
+
import { downloadBlob } from '@formicoidea/labre-core/shared/utils';
|
|
5
6
|
import { Bound } from '@formicoidea/labre-core/global/gfx';
|
|
6
7
|
import { GfxControllerIdentifier } from '@formicoidea/labre-core/std/gfx';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
},
|
|
18
|
-
};
|
|
8
|
+
import { ASSOCIATION_STROKE, ASSOCIATION_WIDTH, MESSAGE_STROKE, MESSAGE_WIDTH, NODE_LABEL, nodeLabelKey, NODE_SIZE, SEQUENCE_STROKE, SEQUENCE_WIDTH, } from './consts.js';
|
|
9
|
+
import { BPMN_FORMAT_ID } from './export.js';
|
|
10
|
+
import { BPMN_SVG_IMPORT, BPMN_XML_EXPORT, BPMN_XML_FORMAT, BPMN_XML_IMPORT, bpmnBoardFrom, bpmnSafeFilename, } from './interchange.js';
|
|
11
|
+
import { bpmnNodeProps } from './presets.js';
|
|
12
|
+
import { BPMN_ROLE } from './roles.js';
|
|
13
|
+
/**
|
|
14
|
+
* Standalone creation/activation actions for the BPMN toolbox — lifted out of
|
|
15
|
+
* `toolbar/bpmn-menu.ts` by PF3 so the menu becomes a pure renderer over the
|
|
16
|
+
* command registry. Telemetry is emitted once, by `runCommand`.
|
|
17
|
+
*/
|
|
19
18
|
const gfxOf = (std) => std.get(GfxControllerIdentifier);
|
|
20
19
|
function finish(gfx, id) {
|
|
21
20
|
gfx.doc.captureSync();
|
|
@@ -31,28 +30,21 @@ export function createBpmnNode(std, kind) {
|
|
|
31
30
|
return;
|
|
32
31
|
const { w, h } = NODE_SIZE[kind];
|
|
33
32
|
const { centerX: cx, centerY: cy } = gfx.viewport;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
fillColor: NODE_FILL,
|
|
41
|
-
strokeColor: preset.stroke,
|
|
42
|
-
strokeWidth: preset.width,
|
|
43
|
-
shapeStyle: ShapeStyle.General,
|
|
44
|
-
roughness: 0,
|
|
45
|
-
radius: kind === 'task' ? TASK_RADIUS : 0,
|
|
46
|
-
text: NODE_LABEL[kind] || undefined,
|
|
47
|
-
color: NEUTRAL_STROKE,
|
|
48
|
-
fontFamily: FontFamily.Inter,
|
|
49
|
-
fontSize: INNER_FONT_SIZE,
|
|
50
|
-
textAlign: 'center',
|
|
51
|
-
// BPMN symbols have normative sizes: a long label overflows rather
|
|
52
|
-
// than deforming the node
|
|
53
|
-
textFitMode: TextFitMode.Overflow,
|
|
33
|
+
// What a node IS lives in one place (`./presets.ts`), because the importer
|
|
34
|
+
// creates the same artefacts out of a `.bpmn` file and the two must not
|
|
35
|
+
// drift: a task read from a file and a task drawn here are one element type
|
|
36
|
+
// in the document, down to the stroke width. The gesture owns the BOX and
|
|
37
|
+
// nothing else.
|
|
38
|
+
const id = surface.addElement(bpmnNodeProps(kind, {
|
|
54
39
|
xywh: new Bound(cx - w / 2, cy - h / 2, w, h).serialize(),
|
|
55
|
-
|
|
40
|
+
// Translated HERE and once: the caption is document content the moment
|
|
41
|
+
// it lands, so the host's catalogue is asked at placement and never
|
|
42
|
+
// again (`nodeLabelKey`, and #183 on why a French board should not
|
|
43
|
+
// start in English).
|
|
44
|
+
text: NODE_LABEL[kind]
|
|
45
|
+
? translateKey(std, nodeLabelKey(kind), NODE_LABEL[kind])
|
|
46
|
+
: undefined,
|
|
47
|
+
}));
|
|
56
48
|
finish(gfx, id);
|
|
57
49
|
}
|
|
58
50
|
/** Create a pool (background container) centred on the viewport. */
|
|
@@ -66,6 +58,9 @@ export function createBpmnPool(std) {
|
|
|
66
58
|
const { centerX: cx, centerY: cy } = gfx.viewport;
|
|
67
59
|
const id = surface.addElement({
|
|
68
60
|
type: 'bpmnPool',
|
|
61
|
+
// The FRAME the flow objects are drawn in, and a role of its own: a rule
|
|
62
|
+
// written on the artefacts must never fall on the lane that holds them.
|
|
63
|
+
role: BPMN_ROLE.pool,
|
|
69
64
|
xywh: new Bound(cx - w / 2, cy - h / 2, w, h).serialize(),
|
|
70
65
|
});
|
|
71
66
|
finish(gfx, id);
|
|
@@ -76,14 +71,403 @@ export function createBpmnPool(std) {
|
|
|
76
71
|
* one node to another (endpoints attach to centers).
|
|
77
72
|
*/
|
|
78
73
|
export function activateBpmnSequenceFlow(std) {
|
|
79
|
-
std
|
|
74
|
+
gfxOf(std).tool.setTool(ConnectorTool, {
|
|
75
|
+
mode: ConnectorMode.Orthogonal,
|
|
76
|
+
// A TYPED edge (`docs/adr/0010`): the arrow the user is about to draw says
|
|
77
|
+
// "is followed by", so its source is what happens first. The tool carries
|
|
78
|
+
// the role so the connector is born with it rather than acquiring one
|
|
79
|
+
// afterwards.
|
|
80
|
+
role: BPMN_ROLE.sequenceFlow,
|
|
81
|
+
// The flow's look rides on the activation, never through the last-props
|
|
82
|
+
// store: the plain connector tool must keep the user's own style (#144 M1).
|
|
83
|
+
style: {
|
|
84
|
+
stroke: SEQUENCE_STROKE,
|
|
85
|
+
strokeStyle: StrokeStyle.Solid,
|
|
86
|
+
strokeWidth: SEQUENCE_WIDTH,
|
|
87
|
+
frontEndpointStyle: PointStyle.None,
|
|
88
|
+
rearEndpointStyle: PointStyle.Triangle,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
// Keep the palette open (native sub-menu behaviour).
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Arm the native connector tool, pre-styled for a BPMN message flow:
|
|
95
|
+
* orthogonal, DASHED, an open circle where the message leaves and an open
|
|
96
|
+
* arrowhead where it lands.
|
|
97
|
+
*
|
|
98
|
+
* ## The two endpoint styles, and why these two
|
|
99
|
+
*
|
|
100
|
+
* The spec's message flow starts on a small hollow circle and ends on a hollow
|
|
101
|
+
* (line-drawn) arrowhead — never the solid triangle the sequence flow uses,
|
|
102
|
+
* which is the whole visual difference between "then this happens" and "and
|
|
103
|
+
* this is what I told them". `PointStyle` offers `Circle` and `Arrow`, and they
|
|
104
|
+
* are exactly those two shapes: `Arrow` is drawn as an unfilled V
|
|
105
|
+
* (`renderRoundedPolygon(…, false)`), against `Triangle`'s filled one. The only
|
|
106
|
+
* deviation is that `Circle` is painted with the connector's `fillColor` rather
|
|
107
|
+
* than left transparent — the shape, the size and the position are the spec's.
|
|
108
|
+
*/
|
|
109
|
+
export function activateBpmnMessageFlow(std) {
|
|
110
|
+
gfxOf(std).tool.setTool(ConnectorTool, {
|
|
80
111
|
mode: ConnectorMode.Orthogonal,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
// A TYPED edge (`docs/adr/0010`), and the role its vocabulary already
|
|
113
|
+
// declared with the verb "sends a message to": the source is the
|
|
114
|
+
// participant that sends, the target the one that receives.
|
|
115
|
+
role: BPMN_ROLE.messageFlow,
|
|
116
|
+
// The flow's look rides on the activation, never through the last-props
|
|
117
|
+
// store: the plain connector tool must keep the user's own style (#144 M1).
|
|
118
|
+
style: {
|
|
119
|
+
stroke: MESSAGE_STROKE,
|
|
120
|
+
strokeStyle: StrokeStyle.Dash,
|
|
121
|
+
strokeWidth: MESSAGE_WIDTH,
|
|
122
|
+
frontEndpointStyle: PointStyle.Circle,
|
|
123
|
+
rearEndpointStyle: PointStyle.Arrow,
|
|
124
|
+
},
|
|
86
125
|
});
|
|
87
|
-
gfxOf(std).tool.setTool(ConnectorTool, { mode: ConnectorMode.Orthogonal });
|
|
88
126
|
// Keep the palette open (native sub-menu behaviour).
|
|
89
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Arm the native connector tool, pre-styled for a BPMN association: dashed, and
|
|
130
|
+
* with NO endpoint marker at either end.
|
|
131
|
+
*
|
|
132
|
+
* The missing arrowheads are the point, not an omission. An association names
|
|
133
|
+
* no relation — `bpmn:association` is the one edge role in this vocabulary
|
|
134
|
+
* declared without a `direction` block — so "this note is about that task"
|
|
135
|
+
* reads identically from either end, and an arrowhead would be the picture
|
|
136
|
+
* claiming a direction the role explicitly refuses to have. See the role's own
|
|
137
|
+
* doc comment in `./roles.ts`.
|
|
138
|
+
*
|
|
139
|
+
* On the dash (there is no dotted stroke to ask for) see
|
|
140
|
+
* {@link ASSOCIATION_STROKE}'s neighbours in `./consts.ts`.
|
|
141
|
+
*/
|
|
142
|
+
export function activateBpmnAssociation(std) {
|
|
143
|
+
gfxOf(std).tool.setTool(ConnectorTool, {
|
|
144
|
+
mode: ConnectorMode.Orthogonal,
|
|
145
|
+
role: BPMN_ROLE.association,
|
|
146
|
+
// The association's look rides on the activation, never through the
|
|
147
|
+
// last-props store: the plain connector tool must keep the user's own
|
|
148
|
+
// style (#144 M1).
|
|
149
|
+
style: {
|
|
150
|
+
stroke: ASSOCIATION_STROKE,
|
|
151
|
+
strokeStyle: StrokeStyle.Dash,
|
|
152
|
+
strokeWidth: ASSOCIATION_WIDTH,
|
|
153
|
+
frontEndpointStyle: PointStyle.None,
|
|
154
|
+
rearEndpointStyle: PointStyle.None,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
// Keep the palette open (native sub-menu behaviour).
|
|
158
|
+
}
|
|
159
|
+
/* ── Lanes (couloirs) ─────────────────────────────────────────────────── */
|
|
160
|
+
/**
|
|
161
|
+
* The lanes a pool carries, as an array that is safe to read.
|
|
162
|
+
*
|
|
163
|
+
* The value comes out of a Y.Map, so it is whatever a peer wrote: a client that
|
|
164
|
+
* got it wrong must not break the gesture on this one. Same defensive read the
|
|
165
|
+
* validation engine does on `validationExceptions`, for the same reason.
|
|
166
|
+
*/
|
|
167
|
+
export function bpmnLanesOf(model) {
|
|
168
|
+
const stored = model.lanes;
|
|
169
|
+
return Array.isArray(stored) ? stored : [];
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The pools a lane gesture acts on: every pool of the current selection that is
|
|
173
|
+
* not locked.
|
|
174
|
+
*
|
|
175
|
+
* EVERY one, not the single one — `some`, not `every`, in the same spirit as
|
|
176
|
+
* the typed-edge inversion (`docs/adr/0010` M3): lassoing two pools used to be
|
|
177
|
+
* a way to lose an affordance, and a gesture that says what it did on each of
|
|
178
|
+
* them beats an entry that vanishes. With one pool selected — the ordinary
|
|
179
|
+
* case, and the only one the toolbar offers — it is exactly "the selected pool".
|
|
180
|
+
*/
|
|
181
|
+
export function bpmnPoolsForLaneEdit(std) {
|
|
182
|
+
if (std.store.readonly)
|
|
183
|
+
return [];
|
|
184
|
+
return gfxOf(std)
|
|
185
|
+
.selection.selectedElements.filter((model) => model instanceof BpmnPoolElementModel)
|
|
186
|
+
.filter(model => !model.isLocked());
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Write a pool's lane list back, or REMOVE the prop when there is none left.
|
|
190
|
+
*
|
|
191
|
+
* The second half is what keeps the "nothing is written until the first lane"
|
|
192
|
+
* promise reversible: assigning `undefined` through the accessor would leave
|
|
193
|
+
* the key in the Y.Map holding an undefined value — invisible through the
|
|
194
|
+
* getter, but synced to every peer and shipped in every snapshot. A pool whose
|
|
195
|
+
* last lane is removed goes back to its pre-lane bytes.
|
|
196
|
+
*/
|
|
197
|
+
function writeLanes(std, model, lanes) {
|
|
198
|
+
if (lanes.length === 0) {
|
|
199
|
+
model.clearField('lanes');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
|
|
203
|
+
lanes: [...lanes],
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Append a lane to every selected pool.
|
|
208
|
+
*
|
|
209
|
+
* ## One lane, not two
|
|
210
|
+
*
|
|
211
|
+
* Adding the FIRST lane creates ONE lane, covering the whole pool. Seeding two
|
|
212
|
+
* would invent a subdivision the user did not ask for and then make them delete
|
|
213
|
+
* half of it. One lane is the honest reading of "add a lane": the pool now has
|
|
214
|
+
* a lane, it happens to be all of it, and the second click gives them the
|
|
215
|
+
* second one.
|
|
216
|
+
*
|
|
217
|
+
* Since the lane title band (PO recette, 2026-08-26) that first click is also
|
|
218
|
+
* VISIBLE: a named strip appears down the leading edge of a pool that had none.
|
|
219
|
+
* Before it, a single lane was indistinguishable from no lane at all, and the
|
|
220
|
+
* gesture looked broken until the second click.
|
|
221
|
+
*
|
|
222
|
+
* ## `Lane N` is DOCUMENT DATA, not vocabulary
|
|
223
|
+
*
|
|
224
|
+
* The default name is a plain persisted string, exactly like the pool's own
|
|
225
|
+
* `'Pool'` default: it is written into the document by this action and is the
|
|
226
|
+
* user's to rewrite from that moment on. It is deliberately NOT a `labelKey`
|
|
227
|
+
* through the translation seam — a host that ships a French catalogue must not
|
|
228
|
+
* silently retitle a lane an author named, and a name that changed language
|
|
229
|
+
* when the reader's locale did would be a document that says different things
|
|
230
|
+
* to different people. `N` is the count AFTER this lane, so the first is
|
|
231
|
+
* `Lane 1`.
|
|
232
|
+
*/
|
|
233
|
+
export function addBpmnLane(std) {
|
|
234
|
+
const pools = bpmnPoolsForLaneEdit(std);
|
|
235
|
+
if (pools.length === 0)
|
|
236
|
+
return;
|
|
237
|
+
// Before the writes: `Store.transact` is not an undo boundary, and one
|
|
238
|
+
// capture for the whole gesture is what makes several pools take their lane
|
|
239
|
+
// in a single undo step.
|
|
240
|
+
std.store.captureSync();
|
|
241
|
+
for (const model of pools) {
|
|
242
|
+
const lanes = bpmnLanesOf(model);
|
|
243
|
+
// The new lane takes an equal share: the average of what is already there
|
|
244
|
+
// is the weight that leaves every existing lane the same size relative to
|
|
245
|
+
// its neighbours, and gives the newcomer the room a typical one has. `1`
|
|
246
|
+
// for the first, where there is no average and the unit is arbitrary.
|
|
247
|
+
const size = lanes.length
|
|
248
|
+
? lanes.reduce((sum, lane) => sum + lane.size, 0) / lanes.length
|
|
249
|
+
: 1;
|
|
250
|
+
writeLanes(std, model, [
|
|
251
|
+
...lanes,
|
|
252
|
+
{ id: generateElementId(), name: `Lane ${lanes.length + 1}`, size },
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Remove the LAST lane of every selected pool.
|
|
258
|
+
*
|
|
259
|
+
* The last one and not the selected one, because a lane is not selectable: it
|
|
260
|
+
* is a slice of the pool's plot, painted by the background primitive, with no
|
|
261
|
+
* element of its own. Removing from the end is the gesture that undoes the one
|
|
262
|
+
* that added it.
|
|
263
|
+
*
|
|
264
|
+
* ## Nothing moves
|
|
265
|
+
*
|
|
266
|
+
* Elements sitting in the removed lane do NOT move. Containment here is
|
|
267
|
+
* geometric — a task is "in" a lane because its centre falls in that rectangle,
|
|
268
|
+
* which is how the audit reports it — so the lane below simply grows over them
|
|
269
|
+
* and they are in that one now. Nothing on the canvas jumps under the user's
|
|
270
|
+
* hand, and the sequence flow they drew still lands where they drew it.
|
|
271
|
+
*/
|
|
272
|
+
export function removeBpmnLane(std) {
|
|
273
|
+
const pools = bpmnPoolsForLaneEdit(std);
|
|
274
|
+
if (pools.length === 0)
|
|
275
|
+
return;
|
|
276
|
+
const withLanes = pools.filter(model => bpmnLanesOf(model).length > 0);
|
|
277
|
+
if (withLanes.length === 0)
|
|
278
|
+
return;
|
|
279
|
+
std.store.captureSync();
|
|
280
|
+
for (const model of withLanes) {
|
|
281
|
+
writeLanes(std, model, bpmnLanesOf(model).slice(0, -1));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/* ── Export (BPMN 2.0 XML) ────────────────────────────────────────────── */
|
|
285
|
+
/**
|
|
286
|
+
* The pools of the current selection, WITHOUT the two filters a lane gesture
|
|
287
|
+
* applies.
|
|
288
|
+
*
|
|
289
|
+
* `bpmnPoolsForLaneEdit` refuses a read-only document and a locked pool because
|
|
290
|
+
* it is about to write to them. An export writes nothing: it reads the board
|
|
291
|
+
* and hands the reader a file. A process published read-only, or a pool an
|
|
292
|
+
* author locked precisely because it is finished, is exactly the board somebody
|
|
293
|
+
* wants to take to bpmn.io — refusing it there would be a filter copied for the
|
|
294
|
+
* shape of it rather than for the reason.
|
|
295
|
+
*/
|
|
296
|
+
export function bpmnPoolsSelected(std) {
|
|
297
|
+
return gfxOf(std).selection.selectedElements.filter((model) => model instanceof BpmnPoolElementModel);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Everything on the surface the exporter speaks about, in document order.
|
|
301
|
+
*
|
|
302
|
+
* The half that needs an editor, and only that half: reading the surface. The
|
|
303
|
+
* picking is {@link bpmnBoardFrom}, which the interchange capability calls with
|
|
304
|
+
* the same elements and no `std` at all (`docs/adr/0012`, P3).
|
|
305
|
+
*/
|
|
306
|
+
export function bpmnBoardOf(std) {
|
|
307
|
+
return bpmnBoardFrom(gfxOf(std).surface?.elementModels ?? []);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* What the downloaded file is called, minus the extension.
|
|
311
|
+
*
|
|
312
|
+
* The document's own title first — a board is what the file is OF — then the
|
|
313
|
+
* name of the pool whose toolbar launched the export, then a last resort. Which
|
|
314
|
+
* of the three it is, is the only thing this function decides; making the
|
|
315
|
+
* answer safe to write to disk is {@link bpmnSafeFilename}, so the command and
|
|
316
|
+
* the interchange capability cannot name the same board differently.
|
|
317
|
+
*/
|
|
318
|
+
export function bpmnExportFilename(std) {
|
|
319
|
+
const title = std.store.workspace.meta.getDocMeta(std.store.id)?.title;
|
|
320
|
+
const pool = bpmnPoolsSelected(std)[0]?.name;
|
|
321
|
+
return bpmnSafeFilename(title || pool);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Serialize the whole board as BPMN 2.0 XML and hand it to the browser.
|
|
325
|
+
*
|
|
326
|
+
* Three steps, and only the first and the last know what an editor is: read the
|
|
327
|
+
* surface, run the DECLARED capability (`docs/adr/0012`), download what it
|
|
328
|
+
* produced. The middle step is not re-implemented here — the document, the
|
|
329
|
+
* filename and the content type all come out of `BPMN_XML_EXPORT.run`, so the
|
|
330
|
+
* command and the registry cannot describe the same board differently. There is
|
|
331
|
+
* one door; the registry is the label on it.
|
|
332
|
+
*
|
|
333
|
+
* A plain import rather than a DI lookup: the capability is a pure function and
|
|
334
|
+
* a value, resolving it through the container would buy nothing here, and P3 is
|
|
335
|
+
* explicit that the registry is the editor's view of these functions, not a
|
|
336
|
+
* gate in front of them.
|
|
337
|
+
*/
|
|
338
|
+
export function exportBpmnXmlFile(std) {
|
|
339
|
+
const elements = gfxOf(std).surface?.elementModels ?? [];
|
|
340
|
+
const { text, filename, mime, warnings } = BPMN_XML_EXPORT.run(elements, {
|
|
341
|
+
name: bpmnExportFilename(std),
|
|
342
|
+
});
|
|
343
|
+
// The charset is the browser's business, not the format's: `mime` is what
|
|
344
|
+
// `.bpmn` IS, and this is how a blob is told to carry it.
|
|
345
|
+
downloadBlob(new Blob([text], { type: `${mime};charset=utf-8` }), filename);
|
|
346
|
+
// The `warnings` channel, spent. The writer has been populating it since
|
|
347
|
+
// #149 and the command dropped it on the floor — a #159 review nit, and the
|
|
348
|
+
// one thing that made "an export loses things too, and the user who clicked
|
|
349
|
+
// Export is the one person entitled to be told" false in the only place a
|
|
350
|
+
// user stands. A warning is never an error: the file downloaded, and it is
|
|
351
|
+
// valid; what it could not say is what this names.
|
|
352
|
+
if (!warnings || warnings.length === 0)
|
|
353
|
+
return;
|
|
354
|
+
notifyBpmn(std, {
|
|
355
|
+
title: translateKey(std, EXPORT_WARNINGS_KEY, EXPORT_WARNINGS_FALLBACK),
|
|
356
|
+
message: warnings.join('\n'),
|
|
357
|
+
accent: 'warning',
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The one wording this file still owns: what a WRITER could not say.
|
|
362
|
+
*
|
|
363
|
+
* The import's wordings moved to the pipeline that writes them
|
|
364
|
+
* (`affine-block-surface`, `extensions/interchange-import.ts`) — one set of
|
|
365
|
+
* keys for every format, with the format's own name composed into them, so a
|
|
366
|
+
* host translates "file imported" once instead of once per format. This one
|
|
367
|
+
* stays because no other format's writer speaks through it.
|
|
368
|
+
*/
|
|
369
|
+
const EXPORT_WARNINGS_KEY = 'com.labre.commands.bpmn.exportXml.warnings';
|
|
370
|
+
const EXPORT_WARNINGS_FALLBACK = 'What this export could not write down';
|
|
371
|
+
/**
|
|
372
|
+
* The notification seam, or silence.
|
|
373
|
+
*
|
|
374
|
+
* `getOptional`, like every other call site in the library: the host injects a
|
|
375
|
+
* `NotificationService` (labreapp does, the standalone playground does not), and
|
|
376
|
+
* a framework that assumed one would be a framework the playground cannot run.
|
|
377
|
+
* Nothing here decides that an export said nothing because nobody was
|
|
378
|
+
* listening — the file downloaded either way.
|
|
379
|
+
*/
|
|
380
|
+
function notifyBpmn(std, options) {
|
|
381
|
+
std.getOptional(NotificationProvider)?.notify({
|
|
382
|
+
title: options.title,
|
|
383
|
+
message: options.message,
|
|
384
|
+
accent: options.accent,
|
|
385
|
+
// Long enough to read a paragraph of remarks, and still self-dismissing:
|
|
386
|
+
// an import report is not a modal, and a toast the user has to close is a
|
|
387
|
+
// toast that interrupts the next thing they were doing.
|
|
388
|
+
duration: 8000,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/* ── Import (BPMN 2.0 XML) ────────────────────────────────────────────── */
|
|
392
|
+
/**
|
|
393
|
+
* Write an imported board onto the surface, and give back the ids it minted.
|
|
394
|
+
*
|
|
395
|
+
* BPMN's name for {@link materializeInterchangeImport}, which is where the two
|
|
396
|
+
* passes live and are documented (`docs/adr/0012`, D3). Nothing in them was
|
|
397
|
+
* ever about BPMN except the payload key the source ids ride under, so the
|
|
398
|
+
* function moved to the surface package when the second format asked for it and
|
|
399
|
+
* this name stayed: it is what the chromium round trip calls, and a test that
|
|
400
|
+
* proves the shipped command has to keep calling the shipped function.
|
|
401
|
+
*/
|
|
402
|
+
export function materializeBpmnImport(std, elements) {
|
|
403
|
+
return materializeInterchangeImport(std, BPMN_FORMAT_ID, elements);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Say what the import did — the summary, and the remarks.
|
|
407
|
+
*
|
|
408
|
+
* BPMN's name for {@link reportInterchangeImport}, which is where the argument
|
|
409
|
+
* for a toast plus a console table lives (ADR 0012's open question 4, v1). The
|
|
410
|
+
* format is a word in the sentence now rather than a wording per format, so
|
|
411
|
+
* what a user reads is unchanged: the counts, and `BPMN` before the version the
|
|
412
|
+
* reader actually read.
|
|
413
|
+
*/
|
|
414
|
+
export function reportBpmnImport(std, report) {
|
|
415
|
+
reportInterchangeImport(std, BPMN_XML_FORMAT, report);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Read a `.bpmn` file the user picks, draw it, and say what it cost.
|
|
419
|
+
*
|
|
420
|
+
* The whole gesture is {@link runInterchangeImportFile}, over the capability
|
|
421
|
+
* BPMN declares: pick the file, run the DECLARED reader, write what it
|
|
422
|
+
* returned, fit the drawing, report. `BPMN_XML_IMPORT.run` is the same function
|
|
423
|
+
* labre-mcp calls, so the command and the registry cannot read the same file
|
|
424
|
+
* differently — one door, and the registry is the label on it. The picker's
|
|
425
|
+
* filter comes off `BPMN_XML_FORMAT` rather than off the shared `FileTypes`
|
|
426
|
+
* table, which is why `.xml` is declared there: half the tools in the wild
|
|
427
|
+
* write a process under the generic extension, and what the file actually IS is
|
|
428
|
+
* decided by the reader, which throws on anything that is not a BPMN
|
|
429
|
+
* `<definitions>`.
|
|
430
|
+
*/
|
|
431
|
+
export async function importBpmnXmlFile(std) {
|
|
432
|
+
await runInterchangeImportFile(std, BPMN_XML_IMPORT);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Read an SVG the user picks as a SKETCH, and say what it cost.
|
|
436
|
+
*
|
|
437
|
+
* The same four steps as the `.bpmn` import, over a different declared
|
|
438
|
+
* capability — which is the whole point of the seam: a second format costs a
|
|
439
|
+
* declaration and a command, not a pipeline. What differs is the PROMISE, and
|
|
440
|
+
* the promise is made by the command's own label and description before the
|
|
441
|
+
* picker ever opens (`docs/adr/0012`, P2): recognition, best effort, no
|
|
442
|
+
* round-trip, and a level-1 sketch the author then promotes.
|
|
443
|
+
*/
|
|
444
|
+
export async function importBpmnSvgFile(std) {
|
|
445
|
+
await runInterchangeImportFile(std, BPMN_SVG_IMPORT);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Rename one lane of one pool. Called by the in-place editor
|
|
449
|
+
* (`element-view.ts`); exported so a unit test can assert the write without an
|
|
450
|
+
* editor around it.
|
|
451
|
+
*
|
|
452
|
+
* An empty name REMOVES the key rather than storing `''`: the renderer already
|
|
453
|
+
* treats both as "no name", and one of the two would be a second way to say the
|
|
454
|
+
* same thing that only the bytes can tell apart.
|
|
455
|
+
*/
|
|
456
|
+
export function renameBpmnLane(std, model, index, name) {
|
|
457
|
+
const lanes = bpmnLanesOf(model);
|
|
458
|
+
const lane = lanes[index];
|
|
459
|
+
if (!lane)
|
|
460
|
+
return;
|
|
461
|
+
const trimmed = name.trim();
|
|
462
|
+
if ((lane.name ?? '') === trimmed)
|
|
463
|
+
return;
|
|
464
|
+
const next = lanes.map((entry, i) => i === index
|
|
465
|
+
? {
|
|
466
|
+
id: entry.id,
|
|
467
|
+
...(trimmed ? { name: trimmed } : {}),
|
|
468
|
+
size: entry.size,
|
|
469
|
+
}
|
|
470
|
+
: entry);
|
|
471
|
+
std.store.captureSync();
|
|
472
|
+
writeLanes(std, model, next);
|
|
473
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { POOL_BAND_FILL, POOL_BAND_WIDTH, POOL_CARD_FILL, POOL_CORNER_RADIUS, POOL_FONT_FAMILY, POOL_FRAME_COLOR, POOL_FRAME_WIDTH, POOL_LANE_BAND_WIDTH, POOL_LANE_NAME_FONT_SIZE, POOL_NAME_COLOR, POOL_NAME_FONT_SIZE, POOL_REF_HEIGHT, POOL_REF_WIDTH, } from './consts.js';
|
|
2
|
+
import { BPMN_ROLE } from './roles.js';
|
|
3
|
+
/**
|
|
4
|
+
* The BPMN pool, DECLARED (the `FrameworkBackgroundDef` primitive).
|
|
5
|
+
*
|
|
6
|
+
* This file is the whole of what makes a pool look like a pool. There is no
|
|
7
|
+
* BPMN drawing code left: the primitive paints this declaration, and would
|
|
8
|
+
* paint any other framework's the same way — what used to be ninety lines of
|
|
9
|
+
* `ctx.fillRect` / `ctx.arcTo` / `ctx.rotate` is now data a reviewer can read.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here changes the DOCUMENT. The persisted element type is still
|
|
12
|
+
* `bpmnPool` and its props are untouched — `name`, `resizeEnabled`, `rotate`,
|
|
13
|
+
* `xywh` — they are simply named by the declaration instead of being read by
|
|
14
|
+
* hand-written drawing code. A pool authored before this file existed opens
|
|
15
|
+
* with the same frame, the same band and the same participant name.
|
|
16
|
+
*
|
|
17
|
+
* ## No frame of reference
|
|
18
|
+
*
|
|
19
|
+
* A pool declares no axis and no zone, and that is deliberate: a pool is a
|
|
20
|
+
* PARTICIPANT, not a chart. Nothing about where a task sits inside the lane
|
|
21
|
+
* means anything — left-to-right is the sequence flow's business, and the flow
|
|
22
|
+
* says so with an arrow. Declaring a time axis here would invent a semantic
|
|
23
|
+
* BPMN puts on the connectors, and then judge people against it.
|
|
24
|
+
*
|
|
25
|
+
* ## The band
|
|
26
|
+
*
|
|
27
|
+
* The left margin IS the name band: `margin.left` is both the room the flow
|
|
28
|
+
* area gives up and the width of the strip the participant name is written in
|
|
29
|
+
* (see `BackgroundSideBandDef`).
|
|
30
|
+
*
|
|
31
|
+
* ## The lanes
|
|
32
|
+
*
|
|
33
|
+
* A lane (couloir) is NOT a second element type, and it is not a band either:
|
|
34
|
+
* it is a slice of THIS pool's plot, so it is declared as an instance partition
|
|
35
|
+
* (`BackgroundInstanceZonesDef`) read off the pool's own `lanes` prop. How many
|
|
36
|
+
* there are, what they are called and how the height is shared between them is
|
|
37
|
+
* a property of this pool; that a pool CAN be sliced that way is the property
|
|
38
|
+
* of BPMN declared here.
|
|
39
|
+
*
|
|
40
|
+
* Which is also why the lanes are not zones: a `zones` entry is part of the
|
|
41
|
+
* framework and identical on every element of it, and no two pools have the
|
|
42
|
+
* same lanes.
|
|
43
|
+
*
|
|
44
|
+
* Each lane wears its own title band inside the pool's — a narrower strip, no
|
|
45
|
+
* fill, one divider, the name turned on its side, exactly as BPMN 2.0 draws it.
|
|
46
|
+
* The strip is CHROME inside the lane and not a smaller lane: a task dropped on
|
|
47
|
+
* a lane's title band is in that lane, because in BPMN the band belongs to it.
|
|
48
|
+
*/
|
|
49
|
+
/** The colour code: every colour named once, never repeated as a hex. */
|
|
50
|
+
const PALETTE = {
|
|
51
|
+
card: POOL_CARD_FILL,
|
|
52
|
+
frame: POOL_FRAME_COLOR,
|
|
53
|
+
band: POOL_BAND_FILL,
|
|
54
|
+
name: POOL_NAME_COLOR,
|
|
55
|
+
};
|
|
56
|
+
export const BPMN_POOL_BACKGROUND = {
|
|
57
|
+
type: 'bpmnPool',
|
|
58
|
+
// The pool is a first-class role: rules frame against `bpmn:pool`, never
|
|
59
|
+
// against the `bpmnPool` element type. Same vocabulary the creation site
|
|
60
|
+
// stamps (`actions.ts`) and the templates ship, named once in `roles.ts`.
|
|
61
|
+
role: BPMN_ROLE.pool,
|
|
62
|
+
geometry: {
|
|
63
|
+
width: POOL_REF_WIDTH,
|
|
64
|
+
height: POOL_REF_HEIGHT,
|
|
65
|
+
// A lane is stretched in one direction all the time — long and thin as the
|
|
66
|
+
// process grows sideways, tall only when it has to hold more. Locking the
|
|
67
|
+
// proportion would fight the hand on every drag.
|
|
68
|
+
lockAspectRatio: false,
|
|
69
|
+
resizable: true,
|
|
70
|
+
margin: { top: 0, right: 0, bottom: 0, left: POOL_BAND_WIDTH },
|
|
71
|
+
},
|
|
72
|
+
instanceZones: {
|
|
73
|
+
prop: 'lanes',
|
|
74
|
+
// Horizontal bands, top to bottom in array order: a lane runs ALONG the
|
|
75
|
+
// flow, and the flow runs left to right.
|
|
76
|
+
stack: 'y',
|
|
77
|
+
// A namespace, so a lane the user calls `early` can never shadow a
|
|
78
|
+
// framework zone of that name. A pool declares no zones today; the
|
|
79
|
+
// separation is what makes that stay true if it ever does.
|
|
80
|
+
idPrefix: 'lane',
|
|
81
|
+
// The same stroke as the band divider — the two lines meet at the band's
|
|
82
|
+
// inner edge, and a lane separator that did not match would read as a
|
|
83
|
+
// different KIND of line rather than the same frame continued.
|
|
84
|
+
divider: { color: '@frame', width: POOL_FRAME_WIDTH },
|
|
85
|
+
label: {
|
|
86
|
+
// The BAND placement: a title strip at the lane's leading edge with the
|
|
87
|
+
// name turned on its side, which is how BPMN 2.0 draws a lane and how
|
|
88
|
+
// bpmn.io, Camunda and Visio all render one. The corner placement this
|
|
89
|
+
// used to declare put the name across the lane's top-left instead; the
|
|
90
|
+
// PO's visual recette (2026-08-26) settled it against the corner on
|
|
91
|
+
// notation rather than taste — a reader who knows BPMN reads a strip as a
|
|
92
|
+
// lane title and a floating corner word as a note.
|
|
93
|
+
//
|
|
94
|
+
// No fill: the strip is the participant band's subordinate, and a second
|
|
95
|
+
// grey gutter beside it would leave the flow area looking inset twice.
|
|
96
|
+
band: {
|
|
97
|
+
width: POOL_LANE_BAND_WIDTH,
|
|
98
|
+
// The frame's own line again, as the band divider is: every rule on a
|
|
99
|
+
// pool is the same stroke, so the lanes read as the frame continued.
|
|
100
|
+
divider: { color: '@frame', width: POOL_FRAME_WIDTH },
|
|
101
|
+
},
|
|
102
|
+
style: {
|
|
103
|
+
size: POOL_LANE_NAME_FONT_SIZE,
|
|
104
|
+
color: '@name',
|
|
105
|
+
weight: 600,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
chrome: {
|
|
110
|
+
fontFamily: POOL_FONT_FAMILY,
|
|
111
|
+
palette: PALETTE,
|
|
112
|
+
// An opaque white card, like every other framework background (PO recette,
|
|
113
|
+
// 26/08/2026). The hand-written renderer left the pool transparent, on the
|
|
114
|
+
// reasoning that a lane is a frame you drop nodes INTO; the review settled
|
|
115
|
+
// it the other way, and settled it on identity: a pool is a map background,
|
|
116
|
+
// so it paints a card, and a board where one framework's backdrop is
|
|
117
|
+
// see-through and every other one is not reads as a bug.
|
|
118
|
+
//
|
|
119
|
+
// The consequence is the standard framework-background behaviour, not a
|
|
120
|
+
// pool quirk: dropping a pool over strokes already on the canvas covers
|
|
121
|
+
// them, exactly as dropping a Wardley map over them does. The user's answer
|
|
122
|
+
// is the same in both cases — send the background to the back.
|
|
123
|
+
surface: {
|
|
124
|
+
fill: '@card',
|
|
125
|
+
border: {
|
|
126
|
+
color: '@frame',
|
|
127
|
+
width: POOL_FRAME_WIDTH,
|
|
128
|
+
radius: POOL_CORNER_RADIUS,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
sideBands: [
|
|
132
|
+
{
|
|
133
|
+
side: 'left',
|
|
134
|
+
fill: '@band',
|
|
135
|
+
divider: { color: '@frame', width: POOL_FRAME_WIDTH },
|
|
136
|
+
label: {
|
|
137
|
+
id: 'name',
|
|
138
|
+
// The user's own words, and only those: a participant is named by
|
|
139
|
+
// whoever draws the process, so there is no vocabulary to fall back
|
|
140
|
+
// to and no `labelKey` to declare.
|
|
141
|
+
prop: 'name',
|
|
142
|
+
// `x: 0` is the inner edge of the band; half a band width back from
|
|
143
|
+
// it is the middle of the strip.
|
|
144
|
+
anchor: { x: 0, y: 0.5, dx: -POOL_BAND_WIDTH / 2 },
|
|
145
|
+
style: {
|
|
146
|
+
size: POOL_NAME_FONT_SIZE,
|
|
147
|
+
weight: 600,
|
|
148
|
+
color: '@name',
|
|
149
|
+
// Centred ACROSS the band, not sitting on a baseline inside it.
|
|
150
|
+
baseline: 'middle',
|
|
151
|
+
},
|
|
152
|
+
// Read bottom-to-top, as the spec draws a vertical pool name.
|
|
153
|
+
vertical: true,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
};
|