@formicoidea/labre-framework-bpmn 0.43.1 → 0.45.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/dist/import.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { InterchangeImportError, } from '@formicoidea/labre-core/blocks/surface';
2
2
  import { ConnectorMode, PointStyle, StrokeStyle } from '@formicoidea/labre-core/model';
3
3
  import { Bound } from '@formicoidea/labre-core/global/gfx';
4
- import { ASSOCIATION_STROKE, ASSOCIATION_WIDTH, MESSAGE_STROKE, MESSAGE_WIDTH, NODE_SIZE, POOL_BAND_WIDTH, POOL_REF_HEIGHT, POOL_REF_WIDTH, SEQUENCE_STROKE, SEQUENCE_WIDTH, } from './consts.js';
4
+ import { ASSOCIATION_STROKE, ASSOCIATION_WIDTH, bpmnLabelMode, MESSAGE_STROKE, MESSAGE_WIDTH, NODE_SIZE, POOL_BAND_WIDTH, POOL_REF_HEIGHT, POOL_REF_WIDTH, SEQUENCE_STROKE, SEQUENCE_WIDTH, } from './consts.js';
5
5
  import { BPMN_FORMAT_ID, BPMN_NS, BPMN_OWN_DECLARATIONS, BPMN_SCOPE, BPMN_XML_OF_KIND, escapeAttr, escapeText, } from './export.js';
6
- import { bpmnNodeProps } from './presets.js';
6
+ import { bpmnLabelBoxUnder, bpmnLabelProps, bpmnNodeProps } from './presets.js';
7
7
  import { BPMN_ROLE } from './roles.js';
8
8
  /**
9
9
  * The remarks whose wording is FIXED, as `[key, English]` pairs — one entry
@@ -158,8 +158,23 @@ export const BPMN_IMPORT_ERRORS = {
158
158
  * `source` / `target` below name the **source file's** ids rather than surface
159
159
  * ones. The caller creates the elements, folds the array into a map from each
160
160
  * element's `interchange.bpmn.id` to the id the surface minted for it, and
161
- * rewrites the two endpoints. Every element carries that id, so the map is a
162
- * fold over the very array this returned; nothing else is needed to finish it.
161
+ * rewrites the two endpoints. Every element a flow can name carries that id, so
162
+ * the map is a fold over the very array this returned.
163
+ *
164
+ * ## The gravitating label, and the second name it needs
165
+ *
166
+ * An event, a gateway or a data shape wears its `name` UNDER the symbol, as a
167
+ * free `bpmn:label` text grouped with it (R38, `bpmnLabelMode`) — the element
168
+ * the palette draws. Imported, that is three elements where the file wrote
169
+ * one: the node (no `text`), the label, and a native `group` holding the two.
170
+ * The label and the group are Labre's and no file ever named them, so they
171
+ * carry no `interchange` payload, and the group cannot point at its children
172
+ * by a source id. The three therefore carry a PROVISIONAL `id`
173
+ * (`bpmn-import-<n>`, UML's arrangement): `surface.addElement` overwrites it,
174
+ * and `materializeInterchangeImport` rewrites the group's `children` through
175
+ * it. The group is written LAST, after what it holds. An export reads the name
176
+ * back through the group (`bpmnBoardFrom`), which is what closes the round
177
+ * trip. An unnamed event stays a bare node: no label is invented.
163
178
  *
164
179
  * ## Three states, and no fourth
165
180
  *
@@ -269,16 +284,6 @@ export const BPMN_KIND_OF_XML = new Map(Object.entries(BPMN_XML_OF_KIND).map(([k
269
284
  xmlKindKey(mapping.element, mapping.eventDefinition),
270
285
  kind,
271
286
  ]));
272
- /**
273
- * `.bpmn`'s scope vocabulary — where a carried fragment came off (D2, as
274
- * amended in #157), and where the writer puts it back.
275
- *
276
- * Declared in `export.ts` and re-exported here, for the reason
277
- * {@link BPMN_FORMAT_ID} is: the reader files a fragment under a scope and the
278
- * writer looks it up under one, and a table written twice is a table that
279
- * drifts. See its doc comment there for what a scope means.
280
- */
281
- export { BPMN_SCOPE };
282
287
  /**
283
288
  * The three edge elements, and the role each one IS.
284
289
  *
@@ -671,9 +676,14 @@ function boundOf(draft) {
671
676
  * did not.
672
677
  */
673
678
  function layOutTheUndrawn(drafts, minted) {
679
+ // A label counts as placed when its node was: it hangs under a box the file
680
+ // drew, and the sweep must not land something on top of it. A group has no
681
+ // box of its own, and an edge is routed between its ends.
674
682
  const placed = drafts.filter(draft => draft.needsLayout === undefined &&
683
+ draft.follows?.needsLayout === undefined &&
675
684
  draft !== minted &&
676
- draft.kind !== 'edge');
685
+ draft.kind !== 'edge' &&
686
+ draft.kind !== 'group');
677
687
  const boxes = placed.map(boundOf);
678
688
  const maxX = boxes.length > 0 ? Math.max(...boxes.map(box => box.x + box.w)) : 0;
679
689
  const minY = boxes.length > 0 ? Math.min(...boxes.map(box => box.y)) : 0;
@@ -686,14 +696,25 @@ function layOutTheUndrawn(drafts, minted) {
686
696
  seat++;
687
697
  draft.props.xywh = new Bound(maxX + SWEEP_GAP + column * SWEEP_STEP, minY + row * SWEEP_STEP, draft.needsLayout.w, draft.needsLayout.h).serialize();
688
698
  }
699
+ // A label follows its node AFTER the sweep, so a swept event keeps its name
700
+ // under it rather than at the origin it was drafted at.
701
+ for (const draft of drafts) {
702
+ if (!draft.follows?.needsLayout)
703
+ continue;
704
+ const node = boundOf(draft.follows);
705
+ const { x, y } = bpmnLabelBoxUnder(node.x + node.w / 2, node.y + node.h);
706
+ const label = boundOf(draft);
707
+ draft.props.xywh = new Bound(x, y, label.w, label.h).serialize();
708
+ }
689
709
  // The pool minted for a file that had no participant (D6) is sized LAST, to
690
710
  // hold everything: a pool's plot is what decides which artefacts are in it,
691
711
  // and an artefact drawn outside every plot would be exported back into a
692
- // process of its own.
712
+ // process of its own. The labels are inside too, or a name would hang below
713
+ // the frame of the process it names.
693
714
  if (!minted)
694
715
  return;
695
716
  const inside = drafts
696
- .filter(draft => draft !== minted && draft.kind === 'node')
717
+ .filter(draft => draft !== minted && (draft.kind === 'node' || draft.kind === 'label'))
697
718
  .map(boundOf);
698
719
  if (inside.length === 0)
699
720
  return;
@@ -726,6 +747,9 @@ export function importBpmnXml(source, context = {}) {
726
747
  let quarantined = 0;
727
748
  let explicitRoutes = 0;
728
749
  const drafts = [];
750
+ /** The provisional names a gravitating label's group is wired by. */
751
+ let provisional = 0;
752
+ const mint = () => `bpmn-import-${++provisional}`;
729
753
  const seenSourceIds = new Set();
730
754
  /** Source ids that became an artefact a flow may attach to: pools and nodes. */
731
755
  const mappedSourceIds = new Set();
@@ -1287,23 +1311,64 @@ export function importBpmnXml(source, context = {}) {
1287
1311
  carryChild(payload, BPMN_SCOPE.self, child, sourceId);
1288
1312
  }
1289
1313
  const size = NODE_SIZE[kind];
1290
- drafts.push({
1314
+ const box = bounds
1315
+ ? new Bound(bounds.x, bounds.y, bounds.w, bounds.h)
1316
+ : new Bound(0, 0, size.w, size.h);
1317
+ // An external kind's name is not the shape's to hold (R38): it goes to a
1318
+ // grouped label under the symbol, and the node is drawn bare.
1319
+ const gravitating = text !== '' && bpmnLabelMode(kind) === 'external';
1320
+ const order = shape?.index ?? Number.POSITIVE_INFINITY;
1321
+ const nodeDraft = {
1291
1322
  props: bpmnNodeProps(kind, {
1292
- xywh: (bounds
1293
- ? new Bound(bounds.x, bounds.y, bounds.w, bounds.h)
1294
- : new Bound(0, 0, size.w, size.h)).serialize(),
1295
- text: text || undefined,
1296
- // The file's own box, which this reader may not rewrite — so the label
1297
- // is fitted to it rather than painted past it (#184). A shape the file
1298
- // did not draw is laid out at the pack's own size below, and wants the
1299
- // pack's own type: no fit, and an element identical to a drawn one.
1300
- ...(bounds ? { fitLabel: true } : {}),
1323
+ xywh: box.serialize(),
1324
+ ...(gravitating
1325
+ ? {}
1326
+ : {
1327
+ text: text || undefined,
1328
+ // The file's own box, which this reader may not rewrite — so the
1329
+ // label is fitted to it rather than painted past it (#184). A
1330
+ // shape the file did not draw is laid out at the pack's own size
1331
+ // below, and wants the pack's own type: no fit, and an element
1332
+ // identical to a drawn one.
1333
+ ...(bounds ? { fitLabel: true } : {}),
1334
+ }),
1301
1335
  }),
1302
1336
  payload,
1303
- order: shape?.index ?? Number.POSITIVE_INFINITY,
1337
+ order,
1304
1338
  kind: 'node',
1305
1339
  ...(bounds ? {} : { needsLayout: size }),
1306
- });
1340
+ };
1341
+ drafts.push(nodeDraft);
1342
+ if (gravitating) {
1343
+ // ponytail: the label is always placed centred under the symbol, and a
1344
+ // `BPMNLabel`'s own bounds are ignored for placement (they are still
1345
+ // carried verbatim as DI, so the file gets them back). Ceiling: a file
1346
+ // that set its label beside an event lands it under. Upgrade: read the
1347
+ // `dc:Bounds` of the shape's `BPMNLabel` and place the text there.
1348
+ const nodeId = mint();
1349
+ const labelId = mint();
1350
+ nodeDraft.props.id = nodeId;
1351
+ const under = bpmnLabelBoxUnder(box.x + box.w / 2, box.y + box.h);
1352
+ // Same order as the node, pushed after it: the stable sort keeps the
1353
+ // node, then its label, then the group that holds both.
1354
+ drafts.push({
1355
+ props: { ...bpmnLabelProps(text, under.x, under.y), id: labelId },
1356
+ payload: {},
1357
+ order,
1358
+ kind: 'label',
1359
+ follows: nodeDraft,
1360
+ });
1361
+ drafts.push({
1362
+ props: {
1363
+ type: 'group',
1364
+ id: mint(),
1365
+ children: { [nodeId]: true, [labelId]: true },
1366
+ },
1367
+ payload: {},
1368
+ order,
1369
+ kind: 'group',
1370
+ });
1371
+ }
1307
1372
  if (sourceId !== undefined && bounds)
1308
1373
  nodeBounds.set(sourceId, bounds);
1309
1374
  if (!bounds) {
@@ -1621,6 +1686,9 @@ export function importBpmnXml(source, context = {}) {
1621
1686
  : { ...draft.props, interchange: { [BPMN_FORMAT_ID]: payload } };
1622
1687
  });
1623
1688
  const lanes = ordered.reduce((total, draft) => total + (Array.isArray(draft.props.lanes) ? draft.props.lanes.length : 0), 0);
1689
+ // A gravitating label and its group are how a node wears its name, not
1690
+ // artefacts of the file: counting them would report one event as three.
1691
+ const artefacts = ordered.filter(draft => draft.kind !== 'label' && draft.kind !== 'group').length;
1624
1692
  const sourceVersion = sourceVersionOf(definitions);
1625
1693
  return {
1626
1694
  elements,
@@ -1628,7 +1696,7 @@ export function importBpmnXml(source, context = {}) {
1628
1696
  // Everything that became a drawn, editable artefact: the pools, the flow
1629
1697
  // objects, the arrows — and the LANES, which are drawn and editable and
1630
1698
  // are not elements of their own.
1631
- mapped: ordered.length + lanes,
1699
+ mapped: artefacts + lanes,
1632
1700
  carried,
1633
1701
  quarantined,
1634
1702
  notes,
@@ -81,21 +81,16 @@ export declare const BPMN_XML_FORMAT: InterchangeFormat;
81
81
  * Connectors are deliberately NOT counted: a neutral arrow states nothing
82
82
  * (`docs/adr/0010`), so there is nothing to lose, which is the reason
83
83
  * `export.ts` has always dropped one in silence. Neither is a generated legend
84
- * — see {@link legendMemberIds}.
84
+ * — see {@link legendMemberIds} — nor a gravitating label and the group that
85
+ * binds it to its node, which the file DOES carry, as the node's `name` — see
86
+ * {@link gravitatingLabels}.
85
87
  */
86
88
  export declare function bpmnBoardFrom(elements: readonly GfxPrimitiveElementModel[]): BpmnExportBoard;
87
89
  /**
88
- * A name a file system will accept, minus the extension.
89
- *
90
- * Every character a file system reserves becomes `-`, whitespace runs collapse,
91
- * and the result is capped: `process` is a better download than one a browser
92
- * silently refuses.
93
- *
94
- * The trailing `[. ]` trim is the Windows tail case and it is not decorative: a
95
- * board called "Order to cash." would otherwise download as
96
- * `Order to cash..bpmn`, and Windows strips trailing dots and spaces from a
97
- * name anyway — so the extension is what would get eaten. Trimmed AFTER the
98
- * cap, because the cap can create one.
90
+ * A name a file system will accept, minus the extension: the shared
91
+ * `safeFilename` with BPMN's fallback — `process` is a better download than
92
+ * one a browser silently refuses. Held to it by
93
+ * `safe-filename-parity.unit.spec.ts`.
99
94
  */
100
95
  export declare function bpmnSafeFilename(raw: string | undefined): string;
101
96
  /** `bpmn:bpmn:export` — the registry's first capability. */
@@ -1,6 +1,7 @@
1
1
  import { interchangeCapabilityId, LEGEND_ROLE, parseSvgSketch, SVG_SKETCH_EXTENSION, SVG_SKETCH_FORMAT_ID, SVG_SKETCH_MIME, } from '@formicoidea/labre-core/blocks/surface';
2
- import { BpmnNodeElementModel, BpmnPoolElementModel, ConnectorElementModel, } from '@formicoidea/labre-core/model';
2
+ import { BpmnNodeElementModel, BpmnPoolElementModel, ConnectorElementModel, GroupElementModel, TextElementModel, } from '@formicoidea/labre-core/model';
3
3
  import { fillPlaceholders } from '@formicoidea/labre-core/shared/services';
4
+ import { safeFilename } from '@formicoidea/labre-core/shared/utils';
4
5
  import { BPMN_FORMAT_ID, exportBpmnXmlWithWarnings, } from './export.js';
5
6
  import { bpmnPoolOf } from './facts.js';
6
7
  import { importBpmnXml } from './import.js';
@@ -86,6 +87,48 @@ function legendMemberIds(elements) {
86
87
  }
87
88
  return members;
88
89
  }
90
+ /**
91
+ * Every node's gravitating name (R38), and the elements that carried it.
92
+ *
93
+ * A native group holding exactly one picked node and exactly one `bpmn:label`
94
+ * text binds the two: the text is the node's name, whatever the geometry says
95
+ * (the palette and the importer both write that pair). GROUP membership is the
96
+ * binding, as it is for Wardley's composites (`labelOfComposite`, `morph.ts`),
97
+ * because a label dragged aside is still the name and a stray text that
98
+ * happens to sit under an event is not.
99
+ *
100
+ * `consumed` is what the file does carry after all — the bound label, and the
101
+ * group when it holds nothing but BPMN nodes and their labels — so the
102
+ * left-out count does not report a name that IS written. An unbound
103
+ * `bpmn:label` stays counted: its words are in no file.
104
+ *
105
+ * Direct children only, read defensively: they came out of a Y.Map.
106
+ */
107
+ function gravitatingLabels(elements, picked) {
108
+ const byId = new Map(elements.map(element => [element.id, element]));
109
+ const labels = new Map();
110
+ const consumed = new Set();
111
+ const isLabel = (element) => element instanceof TextElementModel && element.role === BPMN_ROLE.label;
112
+ for (const group of elements) {
113
+ if (!(group instanceof GroupElementModel))
114
+ continue;
115
+ const ids = group.childIds;
116
+ if (!Array.isArray(ids))
117
+ continue;
118
+ const members = ids.map(id => typeof id === 'string' ? byId.get(id) : undefined);
119
+ const nodes = members.filter((member) => member !== undefined && picked.has(member.id));
120
+ const texts = members.filter((member) => isLabel(member));
121
+ if (nodes.length === 1 && texts.length === 1) {
122
+ labels.set(nodes[0].id, texts[0].text?.toString() ?? '');
123
+ consumed.add(texts[0].id);
124
+ }
125
+ if (members.length > 0 &&
126
+ members.every(member => member && (picked.has(member.id) || isLabel(member)))) {
127
+ consumed.add(group.id);
128
+ }
129
+ }
130
+ return { labels, consumed };
131
+ }
89
132
  /**
90
133
  * The artefacts the exporter speaks about, picked out of a surface's elements
91
134
  * and kept in the order they were given.
@@ -127,7 +170,9 @@ function legendMemberIds(elements) {
127
170
  * Connectors are deliberately NOT counted: a neutral arrow states nothing
128
171
  * (`docs/adr/0010`), so there is nothing to lose, which is the reason
129
172
  * `export.ts` has always dropped one in silence. Neither is a generated legend
130
- * — see {@link legendMemberIds}.
173
+ * — see {@link legendMemberIds} — nor a gravitating label and the group that
174
+ * binds it to its node, which the file DOES carry, as the node's `name` — see
175
+ * {@link gravitatingLabels}.
131
176
  */
132
177
  export function bpmnBoardFrom(elements) {
133
178
  const pools = [];
@@ -160,32 +205,26 @@ export function bpmnBoardFrom(elements) {
160
205
  // audit and the serializer use, so what is reported left out is what the rest
161
206
  // of the framework agrees is in there.
162
207
  const legend = legendMemberIds(elements);
208
+ const { labels, consumed } = gravitatingLabels(elements, new Set(nodes.map(node => node.id)));
163
209
  const leftOut = roleless.filter(element => !legend.has(element.id) &&
210
+ !consumed.has(element.id) &&
164
211
  bpmnPoolOf(pools, element.elementBound) !== null).length;
165
- return { pools, nodes, connectors, ...(leftOut > 0 ? { leftOut } : {}) };
212
+ return {
213
+ pools,
214
+ nodes,
215
+ connectors,
216
+ ...(labels.size > 0 ? { labels } : {}),
217
+ ...(leftOut > 0 ? { leftOut } : {}),
218
+ };
166
219
  }
167
220
  /**
168
- * A name a file system will accept, minus the extension.
169
- *
170
- * Every character a file system reserves becomes `-`, whitespace runs collapse,
171
- * and the result is capped: `process` is a better download than one a browser
172
- * silently refuses.
173
- *
174
- * The trailing `[. ]` trim is the Windows tail case and it is not decorative: a
175
- * board called "Order to cash." would otherwise download as
176
- * `Order to cash..bpmn`, and Windows strips trailing dots and spaces from a
177
- * name anyway — so the extension is what would get eaten. Trimmed AFTER the
178
- * cap, because the cap can create one.
221
+ * A name a file system will accept, minus the extension: the shared
222
+ * `safeFilename` with BPMN's fallback — `process` is a better download than
223
+ * one a browser silently refuses. Held to it by
224
+ * `safe-filename-parity.unit.spec.ts`.
179
225
  */
180
226
  export function bpmnSafeFilename(raw) {
181
- const safe = (raw ?? '')
182
- .trim()
183
- .replaceAll(/[\\/:*?"<>|]/g, '-')
184
- .replaceAll(/\s+/g, ' ')
185
- .trim()
186
- .slice(0, 120)
187
- .replace(/[. ]+$/, '');
188
- return safe || 'process';
227
+ return safeFilename(raw, 'process');
189
228
  }
190
229
  /* ── The capability ───────────────────────────────────────────────────── */
191
230
  /**
package/dist/morph.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type MorphSpec } from '@formicoidea/labre-core/blocks/surface';
2
- import { type BpmnNodeKind } from '@formicoidea/labre-core/model';
2
+ import { BpmnNodeElementModel, type BpmnNodeKind } from '@formicoidea/labre-core/model';
3
+ import type { BlockStdScope } from '@formicoidea/labre-core/std';
4
+ import type { GfxPrimitiveElementModel } from '@formicoidea/labre-core/std/gfx';
3
5
  /**
4
6
  * What a BPMN artefact may BECOME — the declaration behind the "Change type"
5
7
  * dropdown on a selected node's contextual toolbar.
@@ -42,6 +44,31 @@ import { type BpmnNodeKind } from '@formicoidea/labre-core/model';
42
44
  * `roles.ts` already gives them in the role tree.
43
45
  */
44
46
  export declare const BPMN_MORPH_FAMILIES: readonly (readonly BpmnNodeKind[])[];
47
+ /**
48
+ * The BPMN node a selected GROUP is the artefact of — `undefined` when it is
49
+ * not one.
50
+ *
51
+ * An event, a gateway or a data shape is born as a native group of the symbol
52
+ * and its `bpmn:label` (`createBpmnNode`, R38), so a click selects the GROUP
53
+ * and the `kind` lives on the node inside it. Mirrors
54
+ * `wardleyNodeOfComponent`: only a `BpmnNodeElementModel` of an EXTERNAL kind
55
+ * is a candidate (the label, a connector or an activity somebody grouped in
56
+ * beside it are not), and TWO candidates is a refusal rather than a first-wins
57
+ * pick — morphing "it" in a group of two events would mean choosing one by
58
+ * document order.
59
+ */
60
+ export declare function bpmnNodeOfComposite(model: GfxPrimitiveElementModel): BpmnNodeElementModel | undefined;
61
+ /**
62
+ * The name an artefact should carry once it has morphed — or `null` when the
63
+ * name is the AUTHOR's.
64
+ *
65
+ * Exactly one case rewrites: the label still says the SOURCE kind's seed,
66
+ * English or translated, letter for letter. A "Start event" that became a
67
+ * timer start and still read "Start event" would contradict its own glyph;
68
+ * anything typed over the seed is content and survives the morph untouched —
69
+ * the rule `wardleyMorphedLabel` states, for the same reason.
70
+ */
71
+ export declare function bpmnMorphedLabel(from: BpmnNodeKind, to: BpmnNodeKind, rawText: string | null | undefined, std?: BlockStdScope): string | null;
45
72
  /**
46
73
  * BPMN's morph declaration, handed to the generic `morphToolbarConfig`.
47
74
  *
@@ -59,3 +86,12 @@ export declare const BPMN_MORPH_FAMILIES: readonly (readonly BpmnNodeKind[])[];
59
86
  * the presets on the day one gains a member that styles itself differently.
60
87
  */
61
88
  export declare const BPMN_MORPH_SPEC: MorphSpec<BpmnNodeKind>;
89
+ /**
90
+ * The same declaration on the GROUP row: an event, a gateway or a data shape
91
+ * is a native group of the symbol and its label (R38), and the group is what a
92
+ * click on one selects. `modelType` is therefore `GroupElementModel`, and
93
+ * everything that makes that safe is {@link bpmnNodeOfComposite} — the
94
+ * toolbar's homogeneity test only proves each selected element is A group.
95
+ * Mirrors `WARDLEY_MORPH_SPEC`.
96
+ */
97
+ export declare const BPMN_GROUP_MORPH_SPEC: MorphSpec<BpmnNodeKind>;
package/dist/morph.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { morphLabel, } from '@formicoidea/labre-core/blocks/surface';
2
- import { BpmnNodeElementModel } from '@formicoidea/labre-core/model';
2
+ import { BpmnNodeElementModel, GroupElementModel, TextElementModel, } from '@formicoidea/labre-core/model';
3
+ import { translateKey } from '@formicoidea/labre-core/shared/services';
3
4
  import { bpmnCommandIcons, bpmnCommands } from './commands.js';
5
+ import { bpmnLabelMode, NODE_LABEL, nodeLabelKey } from './consts.js';
4
6
  import { bpmnMorphClears, bpmnMorphProps } from './presets.js';
5
- import { BPMN_ROLE_OF_KIND } from './roles.js';
7
+ import { BPMN_ROLE, BPMN_ROLE_OF_KIND } from './roles.js';
6
8
  /**
7
9
  * What a BPMN artefact may BECOME — the declaration behind the "Change type"
8
10
  * dropdown on a selected node's contextual toolbar.
@@ -88,6 +90,112 @@ function iconOf(kind) {
88
90
  const iconKey = NODE_COMMANDS.get(kind)?.iconKey;
89
91
  return ((iconKey && bpmnCommandIcons[iconKey]) || bpmnCommandIcons['bpmn.task']);
90
92
  }
93
+ /* ── The gravitating label (R38) ───────────────────────────────────────── */
94
+ /**
95
+ * Every element under a group, however deeply nested — the same walk as
96
+ * Wardley's `descendants`, so an author who grouped a composite with
97
+ * something else still has it resolved.
98
+ */
99
+ function* descendants(group) {
100
+ for (const child of group.childElements) {
101
+ yield child;
102
+ if (child instanceof GroupElementModel)
103
+ yield* descendants(child);
104
+ }
105
+ }
106
+ /**
107
+ * The BPMN node a selected GROUP is the artefact of — `undefined` when it is
108
+ * not one.
109
+ *
110
+ * An event, a gateway or a data shape is born as a native group of the symbol
111
+ * and its `bpmn:label` (`createBpmnNode`, R38), so a click selects the GROUP
112
+ * and the `kind` lives on the node inside it. Mirrors
113
+ * `wardleyNodeOfComponent`: only a `BpmnNodeElementModel` of an EXTERNAL kind
114
+ * is a candidate (the label, a connector or an activity somebody grouped in
115
+ * beside it are not), and TWO candidates is a refusal rather than a first-wins
116
+ * pick — morphing "it" in a group of two events would mean choosing one by
117
+ * document order.
118
+ */
119
+ export function bpmnNodeOfComposite(model) {
120
+ if (!(model instanceof GroupElementModel))
121
+ return undefined;
122
+ let found;
123
+ for (const child of descendants(model)) {
124
+ if (!(child instanceof BpmnNodeElementModel))
125
+ continue;
126
+ if (bpmnLabelMode(child.kind) !== 'external')
127
+ continue;
128
+ if (found)
129
+ return undefined;
130
+ found = child;
131
+ }
132
+ return found;
133
+ }
134
+ /**
135
+ * The one `bpmn:label` of the composite the selected element belongs to —
136
+ * the group itself when the group row morphed, the node's group when the node
137
+ * row did. `undefined` when there is not exactly one.
138
+ */
139
+ function labelOfComposite(selected) {
140
+ const group = selected instanceof GroupElementModel
141
+ ? selected
142
+ : selected.group instanceof GroupElementModel
143
+ ? selected.group
144
+ : undefined;
145
+ if (!group)
146
+ return undefined;
147
+ let found;
148
+ for (const child of descendants(group)) {
149
+ if (!(child instanceof TextElementModel))
150
+ continue;
151
+ if (child.role !== BPMN_ROLE.label)
152
+ continue;
153
+ if (found)
154
+ return undefined;
155
+ found = child;
156
+ }
157
+ return found;
158
+ }
159
+ /**
160
+ * The name an artefact should carry once it has morphed — or `null` when the
161
+ * name is the AUTHOR's.
162
+ *
163
+ * Exactly one case rewrites: the label still says the SOURCE kind's seed,
164
+ * English or translated, letter for letter. A "Start event" that became a
165
+ * timer start and still read "Start event" would contradict its own glyph;
166
+ * anything typed over the seed is content and survives the morph untouched —
167
+ * the rule `wardleyMorphedLabel` states, for the same reason.
168
+ */
169
+ export function bpmnMorphedLabel(from, to, rawText, std) {
170
+ const text = (rawText ?? '').trim();
171
+ const fromSeed = std
172
+ ? translateKey(std, nodeLabelKey(from), NODE_LABEL[from])
173
+ : NODE_LABEL[from];
174
+ if (text !== NODE_LABEL[from] && text !== fromSeed)
175
+ return null;
176
+ return std
177
+ ? translateKey(std, nodeLabelKey(to), NODE_LABEL[to])
178
+ : NODE_LABEL[to];
179
+ }
180
+ /**
181
+ * The structural half of a morph on an external kind: rewrite the grouped
182
+ * label when it is still the source kind's seed. The families never cross
183
+ * inscribed ⇄ external, so nothing moves in or out of the shape; an inscribed
184
+ * kind has no grouped label and this does nothing for it.
185
+ */
186
+ function bpmnMorphLabel(selected, from, to, std) {
187
+ const label = labelOfComposite(selected);
188
+ if (!label || label.isLocked())
189
+ return;
190
+ const text = label.text.toString().trim();
191
+ const next = bpmnMorphedLabel(from, to, text, std);
192
+ if (next === null || next === text)
193
+ return;
194
+ label.surface.store.transact(() => {
195
+ label.text.delete(0, label.text.length);
196
+ label.text.insert(0, next);
197
+ });
198
+ }
91
199
  /**
92
200
  * BPMN's morph declaration, handed to the generic `morphToolbarConfig`.
93
201
  *
@@ -112,7 +220,21 @@ export const BPMN_MORPH_SPEC = {
112
220
  roleOf: kind => BPMN_ROLE_OF_KIND[kind],
113
221
  propsOf: bpmnMorphProps,
114
222
  clearOf: bpmnMorphClears,
223
+ afterMorph: bpmnMorphLabel,
115
224
  labelOf,
116
225
  iconOf,
117
226
  label: morphLabel('com.labre.morph.toolbar.label', 'Change type'),
118
227
  };
228
+ /**
229
+ * The same declaration on the GROUP row: an event, a gateway or a data shape
230
+ * is a native group of the symbol and its label (R38), and the group is what a
231
+ * click on one selects. `modelType` is therefore `GroupElementModel`, and
232
+ * everything that makes that safe is {@link bpmnNodeOfComposite} — the
233
+ * toolbar's homogeneity test only proves each selected element is A group.
234
+ * Mirrors `WARDLEY_MORPH_SPEC`.
235
+ */
236
+ export const BPMN_GROUP_MORPH_SPEC = {
237
+ ...BPMN_MORPH_SPEC,
238
+ modelType: GroupElementModel,
239
+ resolveTarget: bpmnNodeOfComposite,
240
+ };
@@ -1,6 +1,6 @@
1
1
  import { type ElementRenderer } from '@formicoidea/labre-core/blocks/surface';
2
2
  import { type BpmnNodeElementModel } from '@formicoidea/labre-core/model';
3
3
  export declare const bpmnNode: ElementRenderer<BpmnNodeElementModel>;
4
- export declare const BpmnNodeRendererExtension: import("@formicoidea/labre-core/store").ExtensionType & {
4
+ export declare const BpmnNodeRendererExtension: import("@formicoidea/labre-core/_pkgs/store").ExtensionType & {
5
5
  identifier: import("@formicoidea/labre-core/_pkgs/global/di").ServiceIdentifier<ElementRenderer<BpmnNodeElementModel>>;
6
6
  };
@@ -1,12 +1,33 @@
1
1
  import { type BpmnNodeElementModel } from '@formicoidea/labre-core/model';
2
2
  import { GfxElementModelView } from '@formicoidea/labre-core/std/gfx';
3
+ /**
4
+ * Whether a double-click on this node opens the shape's own inner-text editor.
5
+ *
6
+ * An INSCRIBED kind (activities, annotation, group) wears its name inside the
7
+ * shape, so the gesture lands there, exactly like a native shape.
8
+ *
9
+ * An EXTERNAL kind (events, gateways, data shapes) wears its name as a separate
10
+ * `bpmn:label` text grouped under it (R38), which is edited by double-clicking
11
+ * that text — `TextElementView`'s job, not this one's. The symbol itself has
12
+ * nothing to open, the same answer Wardley's node view gives its circles: an
13
+ * editor mounted here would write a second, invisible name into a field that
14
+ * must stay empty. No redirect to the label either: Wardley does not redirect,
15
+ * and the label is one click away inside the group.
16
+ *
17
+ * The one exception is a LEGACY node: an event drawn before R38 may carry inner
18
+ * text (nothing is migrated, `BPMN_EXTERNAL_LABEL_KINDS`). That text is still
19
+ * painted, so it must still be editable — hence "already has text" reopens
20
+ * the editor, and emptying it closes the door behind it.
21
+ */
22
+ export declare function bpmnNodeEditsInnerText(model: BpmnNodeElementModel): boolean;
3
23
  /**
4
24
  * View for a BPMN flow-object node. Registering it ensures `gfx.view.get(model)`
5
25
  * returns a view (required so move / select / connector interactions work).
6
26
  *
7
- * BPMN nodes are native shapes, so we reuse the shape inner-text editor: a
8
- * double-click mounts the editable text overlay (`mountShapeTextEditor`),
9
- * exactly like a native shape (used to label the task).
27
+ * BPMN nodes are native shapes, so an inscribed kind reuses the shape
28
+ * inner-text editor: a double-click mounts the editable text overlay
29
+ * (`mountShapeTextEditor`), exactly like a native shape. Which kinds do is
30
+ * {@link bpmnNodeEditsInnerText}'s call.
10
31
  *
11
32
  * Mirrors {@link EdgyNodeView}.
12
33
  */
@@ -1,13 +1,39 @@
1
1
  import { mountShapeTextEditor } from '@formicoidea/labre-core/gfx/shape';
2
2
  import { ShapeElementModel, } from '@formicoidea/labre-core/model';
3
3
  import { GfxElementModelView } from '@formicoidea/labre-core/std/gfx';
4
+ import { bpmnLabelMode } from '../consts.js';
5
+ /**
6
+ * Whether a double-click on this node opens the shape's own inner-text editor.
7
+ *
8
+ * An INSCRIBED kind (activities, annotation, group) wears its name inside the
9
+ * shape, so the gesture lands there, exactly like a native shape.
10
+ *
11
+ * An EXTERNAL kind (events, gateways, data shapes) wears its name as a separate
12
+ * `bpmn:label` text grouped under it (R38), which is edited by double-clicking
13
+ * that text — `TextElementView`'s job, not this one's. The symbol itself has
14
+ * nothing to open, the same answer Wardley's node view gives its circles: an
15
+ * editor mounted here would write a second, invisible name into a field that
16
+ * must stay empty. No redirect to the label either: Wardley does not redirect,
17
+ * and the label is one click away inside the group.
18
+ *
19
+ * The one exception is a LEGACY node: an event drawn before R38 may carry inner
20
+ * text (nothing is migrated, `BPMN_EXTERNAL_LABEL_KINDS`). That text is still
21
+ * painted, so it must still be editable — hence "already has text" reopens
22
+ * the editor, and emptying it closes the door behind it.
23
+ */
24
+ export function bpmnNodeEditsInnerText(model) {
25
+ if (bpmnLabelMode(model.kind) === 'inscribed')
26
+ return true;
27
+ return (model.text?.length ?? 0) > 0;
28
+ }
4
29
  /**
5
30
  * View for a BPMN flow-object node. Registering it ensures `gfx.view.get(model)`
6
31
  * returns a view (required so move / select / connector interactions work).
7
32
  *
8
- * BPMN nodes are native shapes, so we reuse the shape inner-text editor: a
9
- * double-click mounts the editable text overlay (`mountShapeTextEditor`),
10
- * exactly like a native shape (used to label the task).
33
+ * BPMN nodes are native shapes, so an inscribed kind reuses the shape
34
+ * inner-text editor: a double-click mounts the editable text overlay
35
+ * (`mountShapeTextEditor`), exactly like a native shape. Which kinds do is
36
+ * {@link bpmnNodeEditsInnerText}'s call.
11
37
  *
12
38
  * Mirrors {@link EdgyNodeView}.
13
39
  */
@@ -19,7 +45,8 @@ export class BpmnNodeView extends GfxElementModelView {
19
45
  const edgeless = this.std.view.getBlock(this.std.store.root.id);
20
46
  if (edgeless &&
21
47
  !this.model.isLocked() &&
22
- this.model instanceof ShapeElementModel) {
48
+ this.model instanceof ShapeElementModel &&
49
+ bpmnNodeEditsInnerText(this.model)) {
23
50
  mountShapeTextEditor(this.model, edgeless);
24
51
  }
25
52
  });