@bpmnkit/core 0.0.25 → 0.0.27

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.
@@ -201,19 +201,38 @@ function buildLaneShapes(lanes, nodes, dx, dy, poolY, poolHeaderWidth, laneConte
201
201
  const mB = accB && accB.count > 0 ? accB.sum / accB.count : Number.POSITIVE_INFINITY;
202
202
  return mA - mB;
203
203
  });
204
- const tileH = Math.round(poolHeight / sortedLanes.length);
205
- return sortedLanes.map((lane, i) => ({
206
- id: `${lane.id}_di`,
207
- bpmnElement: lane.id,
208
- isHorizontal: true,
209
- bounds: {
210
- x: Math.round(poolHeaderWidth),
211
- y: Math.round(poolY + i * tileH),
212
- width: Math.round(laneContentWidth),
213
- height: Math.round(i === sortedLanes.length - 1 ? poolHeight - i * tileH : tileH),
214
- },
215
- unknownAttributes: {},
216
- }));
204
+ // Compute proportional weight per lane: node count × row height, minimum 1 row
205
+ const MIN_LANE_H = 80;
206
+ const weights = sortedLanes.map((lane) => {
207
+ const count = nodes.filter((n) => elemToLane.get(n.id) === lane.id).length;
208
+ return Math.max(count, 1);
209
+ });
210
+ const totalWeight = weights.reduce((a, b) => a + b, 0);
211
+ // Scale proportionally to poolHeight so all lanes fill the pool exactly
212
+ const scaledHeights = weights.map((w) => Math.round((w / totalWeight) * poolHeight));
213
+ // Fix last lane for rounding drift
214
+ if (scaledHeights.length > 0) {
215
+ scaledHeights[scaledHeights.length - 1] =
216
+ poolHeight - scaledHeights.slice(0, -1).reduce((a, b) => a + b, 0);
217
+ }
218
+ let cumulativeY = 0;
219
+ return sortedLanes.map((lane, i) => {
220
+ const laneH = scaledHeights[i] ?? MIN_LANE_H;
221
+ const shape = {
222
+ id: `${lane.id}_di`,
223
+ bpmnElement: lane.id,
224
+ isHorizontal: true,
225
+ bounds: {
226
+ x: Math.round(poolHeaderWidth),
227
+ y: Math.round(poolY + cumulativeY),
228
+ width: Math.round(laneContentWidth),
229
+ height: Math.round(laneH),
230
+ },
231
+ unknownAttributes: {},
232
+ };
233
+ cumulativeY += laneH;
234
+ return shape;
235
+ });
217
236
  }
218
237
  function addAnnotationShapes(process, layoutNodes, annLocalBounds, allShapes, allEdges, dx, dy) {
219
238
  if (process.textAnnotations.length === 0 && process.associations.length === 0)
@@ -5,6 +5,11 @@ export interface ElementOptions {
5
5
  name?: string;
6
6
  isForCompensation?: boolean;
7
7
  }
8
+ /** Options for send/receive task elements. */
9
+ export interface MessageTaskOptions extends ElementOptions {
10
+ /** Message name — generates or reuses a root <bpmn:message> and sets messageRef. */
11
+ messageName?: string;
12
+ }
8
13
  /** Options for creating a start event. */
9
14
  export interface StartEventOptions extends ElementOptions {
10
15
  /** Timer duration (ISO 8601) — creates a timer start event. */
@@ -252,6 +257,10 @@ export declare class BranchBuilder {
252
257
  readonly _textAnnotations: BpmnTextAnnotation[];
253
258
  /** @internal */
254
259
  readonly _associations: BpmnAssociation[];
260
+ /** @internal – ID of the last gateway added in this branch (for nested branch() support). */
261
+ private currentGatewayId;
262
+ /** @internal – Open ends of nested branches waiting to auto-connect to the next element. */
263
+ private openBranchEnds;
255
264
  private readonly _annCounters;
256
265
  private readonly rootErrors;
257
266
  private readonly rootMessages;
@@ -269,8 +278,10 @@ export declare class BranchBuilder {
269
278
  * Supports forward references (element created later) and backward references (loops).
270
279
  */
271
280
  connectTo(targetId: string): this;
272
- /** @internal – ID of the last element added (or the gateway if branch is empty) */
273
- get _lastNodeId(): string;
281
+ /** @internal – ID of the last element added (or undefined if branches are open). */
282
+ get _lastNodeId(): string | undefined;
283
+ /** @internal – Open ends of nested branches that have not yet been connected. */
284
+ get _openBranchEnds(): string[];
274
285
  /** Attach a text annotation to the element at the current cursor position. */
275
286
  textAnnotation(text: string): this;
276
287
  /** Attach a text annotation to an element by explicit ID. */
@@ -278,8 +289,8 @@ export declare class BranchBuilder {
278
289
  serviceTask(id: string, options: ServiceTaskOptions): this;
279
290
  userTask(id: string, options?: UserTaskOptions): this;
280
291
  scriptTask(id: string, options: ScriptTaskOptions): this;
281
- sendTask(id: string, options?: ElementOptions): this;
282
- receiveTask(id: string, options?: ElementOptions): this;
292
+ sendTask(id: string, options?: MessageTaskOptions): this;
293
+ receiveTask(id: string, options?: MessageTaskOptions): this;
283
294
  businessRuleTask(id: string, options?: BusinessRuleTaskOptions): this;
284
295
  callActivity(id: string, options: CallActivityOptions): this;
285
296
  /** Add an abstract task with no Zeebe extensions. */
@@ -292,6 +303,32 @@ export declare class BranchBuilder {
292
303
  parallelGateway(id: string, options?: ElementOptions): this;
293
304
  inclusiveGateway(id: string, options?: GatewayOptions): this;
294
305
  eventBasedGateway(id: string, options?: ElementOptions): this;
306
+ /**
307
+ * Add a boundary event attached to an existing activity in this branch.
308
+ *
309
+ * The boundary event is NOT connected by a sequence flow — it attaches via
310
+ * `attachedToRef`. The builder cursor advances to the boundary event so
311
+ * subsequent elements chain from it. Use `withBoundary()` if you want the
312
+ * cursor to return to the task afterward.
313
+ */
314
+ boundaryEvent(id: string, options: BoundaryEventOptions): this;
315
+ /**
316
+ * Attach a boundary event to the preceding task and build its outgoing path,
317
+ * then restore the branch cursor to the preceding task so the main branch flow continues.
318
+ *
319
+ * @param id - ID for the boundary event element.
320
+ * @param options - Boundary event options (without `attachedTo` — inferred from cursor).
321
+ * @param handler - Callback that chains elements from the boundary event.
322
+ */
323
+ withBoundary(id: string, options: Omit<BoundaryEventOptions, "attachedTo">, handler: (b: BranchBuilder) => void): this;
324
+ /**
325
+ * Create a named branch from the last gateway added inside this branch.
326
+ *
327
+ * Works identically to the top-level `ProcessBuilder.branch()` — use
328
+ * `.condition(expr)` or `.defaultFlow()` inside the callback, finish with
329
+ * `.connectTo(id)` or an `.endEvent()` to terminate the nested branch.
330
+ */
331
+ branch(name: string, callback: (b: BranchBuilder) => void): this;
295
332
  }
296
333
  /** Builder for the contents of a sub-process or ad-hoc sub-process. */
297
334
  export declare class SubProcessContentBuilder {
@@ -307,6 +344,9 @@ export declare class SubProcessContentBuilder {
307
344
  private lastNodeId;
308
345
  private currentGatewayId;
309
346
  private openBranchEnds;
347
+ private readonly rootMessages;
348
+ /** @internal */
349
+ constructor(rootMessages?: BpmnMessage[]);
310
350
  private addElement;
311
351
  startEvent(id?: string, options?: StartEventOptions): this;
312
352
  endEvent(id?: string, options?: EndEventOptions): this;
@@ -317,8 +357,8 @@ export declare class SubProcessContentBuilder {
317
357
  userTask(id: string, options?: UserTaskOptions): this;
318
358
  businessRuleTask(id: string, options?: BusinessRuleTaskOptions): this;
319
359
  callActivity(id: string, options: CallActivityOptions): this;
320
- sendTask(id: string, options?: ElementOptions): this;
321
- receiveTask(id: string, options?: ElementOptions): this;
360
+ sendTask(id: string, options?: MessageTaskOptions): this;
361
+ receiveTask(id: string, options?: MessageTaskOptions): this;
322
362
  /** Add an abstract task with no Zeebe extensions. */
323
363
  task(id: string, options?: ElementOptions): this;
324
364
  exclusiveGateway(id: string, options?: GatewayOptions): this;
@@ -419,9 +459,9 @@ export declare class ProcessBuilder {
419
459
  /** Add a user task with optional form reference. */
420
460
  userTask(id: string, options?: UserTaskOptions): this;
421
461
  /** Add a send task (aspirational). */
422
- sendTask(id: string, options?: ElementOptions): this;
462
+ sendTask(id: string, options?: MessageTaskOptions): this;
423
463
  /** Add a receive task (aspirational). */
424
- receiveTask(id: string, options?: ElementOptions): this;
464
+ receiveTask(id: string, options?: MessageTaskOptions): this;
425
465
  /** Add a business rule task. */
426
466
  businessRuleTask(id: string, options?: BusinessRuleTaskOptions): this;
427
467
  /** Add a call activity referencing another process. */
@@ -7,6 +7,14 @@ const EXPORTER_VERSION = "0.0.23";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Internal helpers
9
9
  // ---------------------------------------------------------------------------
10
+ function resolveMessage(messageName, rootMessages) {
11
+ let existing = rootMessages.find((m) => m.name === messageName);
12
+ if (!existing) {
13
+ existing = { id: generateId("Message"), name: messageName, unknownAttributes: {} };
14
+ rootMessages.push(existing);
15
+ }
16
+ return existing.id;
17
+ }
10
18
  function buildEventDefinitions(opts, rootErrors, rootMessages, rootSignals, rootEscalations) {
11
19
  const defs = [];
12
20
  if (opts.timerDuration || opts.timerDate || opts.timerCycle) {
@@ -34,15 +42,9 @@ function buildEventDefinitions(opts, rootErrors, rootMessages, rootSignals, root
34
42
  defs.push({ type: "error", errorRef });
35
43
  }
36
44
  if (opts.messageName !== undefined) {
37
- let messageRef = opts.messageName;
38
- if (rootMessages) {
39
- let existing = rootMessages.find((m) => m.name === opts.messageName);
40
- if (!existing) {
41
- existing = { id: generateId("Message"), name: opts.messageName, unknownAttributes: {} };
42
- rootMessages.push(existing);
43
- }
44
- messageRef = existing.id;
45
- }
45
+ const messageRef = rootMessages
46
+ ? resolveMessage(opts.messageName, rootMessages)
47
+ : opts.messageName;
46
48
  defs.push({ type: "message", messageRef });
47
49
  }
48
50
  if (opts.signalName !== undefined) {
@@ -454,6 +456,10 @@ export class BranchBuilder {
454
456
  _textAnnotations = [];
455
457
  /** @internal */
456
458
  _associations = [];
459
+ /** @internal – ID of the last gateway added in this branch (for nested branch() support). */
460
+ currentGatewayId;
461
+ /** @internal – Open ends of nested branches waiting to auto-connect to the next element. */
462
+ openBranchEnds = [];
457
463
  _annCounters = new Map();
458
464
  rootErrors;
459
465
  rootMessages;
@@ -481,22 +487,34 @@ export class BranchBuilder {
481
487
  }
482
488
  addElement(element) {
483
489
  this._elements.push(element);
484
- const flowId = generateId("Flow");
485
- const flow = {
486
- id: flowId,
487
- sourceRef: this.lastNodeId,
488
- targetRef: element.id,
489
- name: this.isFirstElement ? this.branchName : undefined,
490
- conditionExpression: this.isFirstElement && this.pendingCondition
491
- ? makeConditionExpression(this.pendingCondition)
492
- : undefined,
493
- extensionElements: [],
494
- unknownAttributes: {},
495
- };
496
- this._flows.push(flow);
497
- if (this.isFirstElement && this.pendingDefault) {
498
- this._defaultFlowId = flowId;
490
+ if (this.lastNodeId) {
491
+ const flowId = generateId("Flow");
492
+ const flow = {
493
+ id: flowId,
494
+ sourceRef: this.lastNodeId,
495
+ targetRef: element.id,
496
+ name: this.isFirstElement ? this.branchName : undefined,
497
+ conditionExpression: this.isFirstElement && this.pendingCondition
498
+ ? makeConditionExpression(this.pendingCondition)
499
+ : undefined,
500
+ extensionElements: [],
501
+ unknownAttributes: {},
502
+ };
503
+ this._flows.push(flow);
504
+ if (this.isFirstElement && this.pendingDefault) {
505
+ this._defaultFlowId = flowId;
506
+ }
499
507
  }
508
+ for (const branchEnd of this.openBranchEnds) {
509
+ this._flows.push({
510
+ id: generateId("Flow"),
511
+ sourceRef: branchEnd,
512
+ targetRef: element.id,
513
+ extensionElements: [],
514
+ unknownAttributes: {},
515
+ });
516
+ }
517
+ this.openBranchEnds = [];
500
518
  this.isFirstElement = false;
501
519
  this.lastNodeId = element.id;
502
520
  return this;
@@ -509,6 +527,7 @@ export class BranchBuilder {
509
527
  const flowId = generateId("Flow");
510
528
  const flow = {
511
529
  id: flowId,
530
+ // biome-ignore lint/style/noNonNullAssertion: lastNodeId starts as gatewayId and is always defined in pre-branch context
512
531
  sourceRef: this.lastNodeId,
513
532
  targetRef: targetId,
514
533
  name: this.isFirstElement ? this.branchName : undefined,
@@ -527,13 +546,18 @@ export class BranchBuilder {
527
546
  this._connected = true;
528
547
  return this;
529
548
  }
530
- /** @internal – ID of the last element added (or the gateway if branch is empty) */
549
+ /** @internal – ID of the last element added (or undefined if branches are open). */
531
550
  get _lastNodeId() {
532
551
  return this.lastNodeId;
533
552
  }
553
+ /** @internal – Open ends of nested branches that have not yet been connected. */
554
+ get _openBranchEnds() {
555
+ return this.openBranchEnds;
556
+ }
534
557
  // ---- Annotations ----
535
558
  /** Attach a text annotation to the element at the current cursor position. */
536
559
  textAnnotation(text) {
560
+ // biome-ignore lint/style/noNonNullAssertion: lastNodeId starts as gatewayId and is always defined in pre-branch context
537
561
  return this.annotate(this.lastNodeId, text);
538
562
  }
539
563
  /** Attach a text annotation to an element by explicit ID. */
@@ -565,12 +589,16 @@ export class BranchBuilder {
565
589
  const el = makeFlowElement(id, "sendTask", options);
566
590
  if (options?.isForCompensation)
567
591
  el.isForCompensation = true;
592
+ if (options?.messageName)
593
+ el.messageRef = resolveMessage(options.messageName, this.rootMessages);
568
594
  return this.addElement(el);
569
595
  }
570
596
  receiveTask(id, options) {
571
597
  const el = makeFlowElement(id, "receiveTask", options);
572
598
  if (options?.isForCompensation)
573
599
  el.isForCompensation = true;
600
+ if (options?.messageName)
601
+ el.messageRef = resolveMessage(options.messageName, this.rootMessages);
574
602
  return this.addElement(el);
575
603
  }
576
604
  businessRuleTask(id, options) {
@@ -615,17 +643,121 @@ export class BranchBuilder {
615
643
  return this.addElement(el);
616
644
  }
617
645
  exclusiveGateway(id, options) {
646
+ this.currentGatewayId = id;
618
647
  return this.addElement(makeExclusiveGatewayEl(id, options));
619
648
  }
620
649
  parallelGateway(id, options) {
650
+ this.currentGatewayId = id;
621
651
  return this.addElement(makeFlowElement(id, "parallelGateway", options));
622
652
  }
623
653
  inclusiveGateway(id, options) {
654
+ this.currentGatewayId = id;
624
655
  return this.addElement(makeInclusiveGatewayEl(id, options));
625
656
  }
626
657
  eventBasedGateway(id, options) {
658
+ this.currentGatewayId = id;
627
659
  return this.addElement(makeFlowElement(id, "eventBasedGateway", options));
628
660
  }
661
+ /**
662
+ * Add a boundary event attached to an existing activity in this branch.
663
+ *
664
+ * The boundary event is NOT connected by a sequence flow — it attaches via
665
+ * `attachedToRef`. The builder cursor advances to the boundary event so
666
+ * subsequent elements chain from it. Use `withBoundary()` if you want the
667
+ * cursor to return to the task afterward.
668
+ */
669
+ boundaryEvent(id, options) {
670
+ const element = makeFlowElement(id, "boundaryEvent", options);
671
+ if (element.type === "boundaryEvent") {
672
+ element.attachedToRef = options.attachedTo;
673
+ element.cancelActivity = options.cancelActivity;
674
+ element.eventDefinitions = buildEventDefinitions(options, this.rootErrors, this.rootMessages, this.rootSignals, this.rootEscalations);
675
+ }
676
+ // Push directly — no sequence flow, boundary events attach via attachedToRef
677
+ this._elements.push(element);
678
+ this.lastNodeId = element.id;
679
+ this.isFirstElement = false;
680
+ return this;
681
+ }
682
+ /**
683
+ * Attach a boundary event to the preceding task and build its outgoing path,
684
+ * then restore the branch cursor to the preceding task so the main branch flow continues.
685
+ *
686
+ * @param id - ID for the boundary event element.
687
+ * @param options - Boundary event options (without `attachedTo` — inferred from cursor).
688
+ * @param handler - Callback that chains elements from the boundary event.
689
+ */
690
+ withBoundary(id, options, handler) {
691
+ const attachedTo = this.lastNodeId;
692
+ if (!attachedTo || attachedTo === this.gatewayId) {
693
+ throw new Error("withBoundary() must follow a task element inside the branch. Current builder position has no active task.");
694
+ }
695
+ const attachedEl = this._elements.find((n) => n.id === attachedTo);
696
+ if (attachedEl?.type === "boundaryEvent") {
697
+ throw new Error("withBoundary() cannot attach to a boundary event. It must follow a task or activity element.");
698
+ }
699
+ const savedLast = this.lastNodeId;
700
+ const savedGateway = this.currentGatewayId;
701
+ const savedConnected = this._connected;
702
+ const savedOpenEnds = [...this.openBranchEnds];
703
+ this.openBranchEnds = [];
704
+ // Create and push the boundary event (no sequence flow)
705
+ this.boundaryEvent(id, { ...options, attachedTo });
706
+ // Build the boundary event's outgoing path
707
+ handler(this);
708
+ // Restore cursor to the task so the branch main flow continues
709
+ this.lastNodeId = savedLast;
710
+ this.currentGatewayId = savedGateway;
711
+ this._connected = savedConnected;
712
+ this.openBranchEnds = savedOpenEnds;
713
+ return this;
714
+ }
715
+ /**
716
+ * Create a named branch from the last gateway added inside this branch.
717
+ *
718
+ * Works identically to the top-level `ProcessBuilder.branch()` — use
719
+ * `.condition(expr)` or `.defaultFlow()` inside the callback, finish with
720
+ * `.connectTo(id)` or an `.endEvent()` to terminate the nested branch.
721
+ */
722
+ branch(name, callback) {
723
+ if (!this.currentGatewayId) {
724
+ throw new Error("branch() must be called after a gateway element");
725
+ }
726
+ const b = new BranchBuilder(this.currentGatewayId, name, this.rootErrors, this.rootMessages, this.rootSignals, this.rootEscalations);
727
+ callback(b);
728
+ for (const el of b._elements) {
729
+ if (this._elements.some((n) => n.id === el.id)) {
730
+ throw new Error(`Duplicate element ID "${el.id}"`);
731
+ }
732
+ this._elements.push(el);
733
+ }
734
+ for (const fl of b._flows)
735
+ this._flows.push(fl);
736
+ for (const ann of b._textAnnotations)
737
+ this._textAnnotations.push(ann);
738
+ for (const assoc of b._associations)
739
+ this._associations.push(assoc);
740
+ if (b._defaultFlowId) {
741
+ const gw = this._elements.find((n) => n.id === this.currentGatewayId);
742
+ if (gw && (gw.type === "exclusiveGateway" || gw.type === "inclusiveGateway")) {
743
+ gw.default = b._defaultFlowId;
744
+ }
745
+ }
746
+ if (!b._connected) {
747
+ const allEnds = [
748
+ ...(b._lastNodeId !== undefined ? [b._lastNodeId] : []),
749
+ ...b._openBranchEnds,
750
+ ];
751
+ for (const endId of allEnds) {
752
+ const endEl = this._elements.find((n) => n.id === endId);
753
+ if (endEl && endEl.type !== "endEvent") {
754
+ this.openBranchEnds.push(endId);
755
+ }
756
+ }
757
+ }
758
+ this.lastNodeId = undefined;
759
+ return this;
760
+ }
629
761
  }
630
762
  // ---------------------------------------------------------------------------
631
763
  // Sub-process content builder
@@ -644,6 +776,11 @@ export class SubProcessContentBuilder {
644
776
  lastNodeId;
645
777
  currentGatewayId;
646
778
  openBranchEnds = [];
779
+ rootMessages;
780
+ /** @internal */
781
+ constructor(rootMessages = []) {
782
+ this.rootMessages = rootMessages;
783
+ }
647
784
  addElement(element) {
648
785
  if (this._elements.some((n) => n.id === element.id)) {
649
786
  throw new Error(`Duplicate element ID "${element.id}" in sub-process`);
@@ -719,12 +856,16 @@ export class SubProcessContentBuilder {
719
856
  const el = makeFlowElement(id, "sendTask", options);
720
857
  if (options?.isForCompensation)
721
858
  el.isForCompensation = true;
859
+ if (options?.messageName)
860
+ el.messageRef = resolveMessage(options.messageName, this.rootMessages);
722
861
  return this.addElement(el);
723
862
  }
724
863
  receiveTask(id, options) {
725
864
  const el = makeFlowElement(id, "receiveTask", options);
726
865
  if (options?.isForCompensation)
727
866
  el.isForCompensation = true;
867
+ if (options?.messageName)
868
+ el.messageRef = resolveMessage(options.messageName, this.rootMessages);
728
869
  return this.addElement(el);
729
870
  }
730
871
  /** Add an abstract task with no Zeebe extensions. */
@@ -799,10 +940,16 @@ export class SubProcessContentBuilder {
799
940
  gw.default = b._defaultFlowId;
800
941
  }
801
942
  }
802
- if (!b._connected && b._elements.length > 0) {
803
- const lastEl = b._elements[b._elements.length - 1];
804
- if (lastEl && lastEl.type !== "endEvent") {
805
- this.openBranchEnds.push(b._lastNodeId);
943
+ if (!b._connected) {
944
+ const allEnds = [
945
+ ...(b._lastNodeId !== undefined ? [b._lastNodeId] : []),
946
+ ...b._openBranchEnds,
947
+ ];
948
+ for (const endId of allEnds) {
949
+ const endEl = this._elements.find((n) => n.id === endId);
950
+ if (endEl && endEl.type !== "endEvent") {
951
+ this.openBranchEnds.push(endId);
952
+ }
806
953
  }
807
954
  }
808
955
  this.lastNodeId = undefined;
@@ -1075,6 +1222,8 @@ export class ProcessBuilder {
1075
1222
  const el = makeFlowElement(id, "sendTask", options);
1076
1223
  if (options?.isForCompensation)
1077
1224
  el.isForCompensation = true;
1225
+ if (options?.messageName)
1226
+ el.messageRef = resolveMessage(options.messageName, this.rootMessages);
1078
1227
  this.addFlowElement(el);
1079
1228
  return this;
1080
1229
  }
@@ -1083,6 +1232,8 @@ export class ProcessBuilder {
1083
1232
  const el = makeFlowElement(id, "receiveTask", options);
1084
1233
  if (options?.isForCompensation)
1085
1234
  el.isForCompensation = true;
1235
+ if (options?.messageName)
1236
+ el.messageRef = resolveMessage(options.messageName, this.rootMessages);
1086
1237
  this.addFlowElement(el);
1087
1238
  return this;
1088
1239
  }
@@ -1175,10 +1326,16 @@ export class ProcessBuilder {
1175
1326
  }
1176
1327
  // Track the branch's open end so the next element auto-connects from it.
1177
1328
  // Skip branches that terminated at an end event (those are intentional dead-ends).
1178
- if (!b._connected && b._elements.length > 0) {
1179
- const lastEl = b._elements[b._elements.length - 1];
1180
- if (lastEl && lastEl.type !== "endEvent") {
1181
- this.openBranchEnds.push(b._lastNodeId);
1329
+ if (!b._connected) {
1330
+ const allEnds = [
1331
+ ...(b._lastNodeId !== undefined ? [b._lastNodeId] : []),
1332
+ ...b._openBranchEnds,
1333
+ ];
1334
+ for (const endId of allEnds) {
1335
+ const endEl = this.flowElements.find((n) => n.id === endId);
1336
+ if (endEl && endEl.type !== "endEvent") {
1337
+ this.openBranchEnds.push(endId);
1338
+ }
1182
1339
  }
1183
1340
  }
1184
1341
  this.lastNodeId = undefined;
@@ -1217,7 +1374,7 @@ export class ProcessBuilder {
1217
1374
  // ---- Sub-processes ----
1218
1375
  /** Add an ad-hoc sub-process with optional AI agent or multi-instance configuration. */
1219
1376
  adHocSubProcess(id, content, options) {
1220
- const sub = new SubProcessContentBuilder();
1377
+ const sub = new SubProcessContentBuilder(this.rootMessages);
1221
1378
  content(sub);
1222
1379
  insertJoinGateways(sub._elements, sub._flows);
1223
1380
  recomputeIncomingOutgoing(sub._elements, sub._flows);
@@ -1285,7 +1442,7 @@ export class ProcessBuilder {
1285
1442
  }
1286
1443
  /** Add a sub-process (aspirational). */
1287
1444
  subProcess(id, content, options) {
1288
- const sub = new SubProcessContentBuilder();
1445
+ const sub = new SubProcessContentBuilder(this.rootMessages);
1289
1446
  content(sub);
1290
1447
  insertJoinGateways(sub._elements, sub._flows);
1291
1448
  recomputeIncomingOutgoing(sub._elements, sub._flows);
@@ -1304,7 +1461,7 @@ export class ProcessBuilder {
1304
1461
  }
1305
1462
  /** Add an event sub-process. Triggered by its start event — no incoming or outgoing sequence flows. */
1306
1463
  eventSubProcess(id, content, options) {
1307
- const sub = new SubProcessContentBuilder();
1464
+ const sub = new SubProcessContentBuilder(this.rootMessages);
1308
1465
  content(sub);
1309
1466
  insertJoinGateways(sub._elements, sub._flows);
1310
1467
  recomputeIncomingOutgoing(sub._elements, sub._flows);
@@ -152,10 +152,12 @@ export interface BpmnCallActivity extends BpmnFlowNodeBase {
152
152
  }
153
153
  export interface BpmnSendTask extends BpmnFlowNodeBase {
154
154
  type: "sendTask";
155
+ messageRef?: string;
155
156
  loopCharacteristics?: BpmnMultiInstanceLoopCharacteristics;
156
157
  }
157
158
  export interface BpmnReceiveTask extends BpmnFlowNodeBase {
158
159
  type: "receiveTask";
160
+ messageRef?: string;
159
161
  loopCharacteristics?: BpmnMultiInstanceLoopCharacteristics;
160
162
  }
161
163
  export interface BpmnAdHocSubProcess extends BpmnFlowNodeBase {
@@ -303,8 +303,6 @@ function parseFlowElement(element) {
303
303
  case "serviceTask":
304
304
  case "scriptTask":
305
305
  case "userTask":
306
- case "sendTask":
307
- case "receiveTask":
308
306
  case "businessRuleTask":
309
307
  case "manualTask":
310
308
  case "callActivity":
@@ -314,6 +312,15 @@ function parseFlowElement(element) {
314
312
  loopCharacteristics: parseLoopCharacteristics(element),
315
313
  isForCompensation: attr(element, "isForCompensation") === "true" ? true : undefined,
316
314
  };
315
+ case "sendTask":
316
+ case "receiveTask":
317
+ return {
318
+ ...base,
319
+ type: ln,
320
+ messageRef: attr(element, "messageRef"),
321
+ loopCharacteristics: parseLoopCharacteristics(element),
322
+ isForCompensation: attr(element, "isForCompensation") === "true" ? true : undefined,
323
+ };
317
324
  case "adHocSubProcess":
318
325
  return {
319
326
  ...base,
@@ -190,13 +190,17 @@ function serializeFlowElement(fe, ns) {
190
190
  case "serviceTask":
191
191
  case "scriptTask":
192
192
  case "userTask":
193
- case "sendTask":
194
- case "receiveTask":
195
193
  case "businessRuleTask":
196
194
  case "manualTask":
197
195
  case "callActivity":
198
196
  children.push(...serializeLoopCharacteristics(fe.loopCharacteristics, bp));
199
197
  break;
198
+ case "sendTask":
199
+ case "receiveTask":
200
+ if (fe.messageRef)
201
+ attrs.messageRef = fe.messageRef;
202
+ children.push(...serializeLoopCharacteristics(fe.loopCharacteristics, bp));
203
+ break;
200
204
  case "adHocSubProcess":
201
205
  children.push(...serializeLoopCharacteristics(fe.loopCharacteristics, bp));
202
206
  children.push(...serializeProcessContents(fe, ns));
@@ -36,10 +36,21 @@ function repositionBoundaryEvents(flowElements, result) {
36
36
  ps.add(edge.sourceRef);
37
37
  predIds.set(edge.targetRef, ps);
38
38
  }
39
+ const allChainNodes = new Set();
39
40
  for (const [hostId, beIds] of boundaryMap) {
40
41
  const hostNode = nodeById.get(hostId);
41
42
  if (!hostNode)
42
43
  continue;
44
+ // Pre-compute distribution parameters (all boundary events share the same fixed size).
45
+ // Distribute events evenly along the bottom edge, centered on the task.
46
+ // effectiveSpacing guarantees events don't overlap (min bW + 4px gap).
47
+ const firstBeNode = nodeById.get(beIds[0] ?? "");
48
+ const bW = firstBeNode?.bounds.width ?? 36;
49
+ const bH = firstBeNode?.bounds.height ?? 36;
50
+ const n = beIds.length;
51
+ const effectiveSpacing = Math.max(Math.round(hostNode.bounds.width / (n + 1)), bW + 4);
52
+ const groupWidth = Math.max(0, n - 1) * effectiveSpacing;
53
+ const groupStartCenterX = Math.round(hostNode.bounds.x + hostNode.bounds.width / 2 - groupWidth / 2);
43
54
  for (let i = 0; i < beIds.length; i++) {
44
55
  const beId = beIds[i];
45
56
  if (!beId)
@@ -47,11 +58,9 @@ function repositionBoundaryEvents(flowElements, result) {
47
58
  const beNode = nodeById.get(beId);
48
59
  if (!beNode)
49
60
  continue;
50
- const bW = beNode.bounds.width;
51
- const bH = beNode.bounds.height;
52
- // Place boundary event on the bottom edge of the host task, stacking leftward
53
- const rightEdge = hostNode.bounds.x + hostNode.bounds.width;
54
- beNode.bounds.x = Math.round(rightEdge - bW / 2 - i * (bW + 4));
61
+ // bW / bH come from the pre-loop computation (all BEs are fixed 36×36)
62
+ // Center-bottom distribution: single event → task center; multiple → even spread
63
+ beNode.bounds.x = Math.round(groupStartCenterX + i * effectiveSpacing - bW / 2);
55
64
  beNode.bounds.y = Math.round(hostNode.bounds.y + hostNode.bounds.height - bH / 2);
56
65
  if (beNode.labelBounds) {
57
66
  beNode.labelBounds.x = beNode.bounds.x + Math.round(bW / 2 - beNode.labelBounds.width / 2);
@@ -72,6 +81,9 @@ function repositionBoundaryEvents(flowElements, result) {
72
81
  queue.push(...(succIds.get(id) ?? []));
73
82
  }
74
83
  }
84
+ // Record all chain members so the forward pass can identify them.
85
+ for (const cid of chainSet)
86
+ allChainNodes.add(cid);
75
87
  // Each boundary event's chain gets its own vertical lane
76
88
  let maxChainH = 0;
77
89
  for (const id of chainOrder) {
@@ -133,6 +145,116 @@ function repositionBoundaryEvents(flowElements, result) {
133
145
  }
134
146
  }
135
147
  }
148
+ // Forward-placement pass: any node not in any chain but whose predecessor
149
+ // has been relocated further right must be pushed rightward.
150
+ // Process in topological order (Kahn's algorithm over the sequenceFlow graph).
151
+ const inDegree = new Map();
152
+ for (const id of nodeById.keys()) {
153
+ inDegree.set(id, (predIds.get(id) ?? new Set()).size);
154
+ }
155
+ const topoQueue = [];
156
+ for (const [id, deg] of inDegree) {
157
+ if (deg === 0)
158
+ topoQueue.push(id);
159
+ }
160
+ const topoOrder = [];
161
+ while (topoQueue.length > 0) {
162
+ const id = topoQueue.shift();
163
+ if (!id)
164
+ break;
165
+ topoOrder.push(id);
166
+ for (const succId of succIds.get(id) ?? []) {
167
+ const newDeg = (inDegree.get(succId) ?? 1) - 1;
168
+ inDegree.set(succId, newDeg);
169
+ if (newDeg === 0)
170
+ topoQueue.push(succId);
171
+ }
172
+ }
173
+ const movedInPass = new Set();
174
+ for (const id of topoOrder) {
175
+ if (allChainNodes.has(id))
176
+ continue;
177
+ const node = nodeById.get(id);
178
+ if (!node)
179
+ continue;
180
+ const preds = predIds.get(id) ?? new Set();
181
+ if (preds.size === 0)
182
+ continue;
183
+ let maxPredRight = 0;
184
+ for (const predId of preds) {
185
+ const pred = nodeById.get(predId);
186
+ if (pred)
187
+ maxPredRight = Math.max(maxPredRight, pred.bounds.x + pred.bounds.width);
188
+ }
189
+ const minX = maxPredRight + CHAIN_GAP;
190
+ if (minX > node.bounds.x) {
191
+ const delta = minX - node.bounds.x;
192
+ node.bounds.x = minX;
193
+ if (node.labelBounds)
194
+ node.labelBounds.x += delta;
195
+ movedInPass.add(id);
196
+ }
197
+ }
198
+ // Spatial bump: if a moved node now overlaps a non-chain node on a parallel
199
+ // path (no predecessor/successor relationship), push it clear and cascade.
200
+ let bumped = true;
201
+ while (bumped) {
202
+ bumped = false;
203
+ for (const movedId of movedInPass) {
204
+ const moved = nodeById.get(movedId);
205
+ if (!moved)
206
+ continue;
207
+ const movedRight = moved.bounds.x + moved.bounds.width;
208
+ for (const [otherId, other] of nodeById) {
209
+ if (otherId === movedId)
210
+ continue;
211
+ if (allChainNodes.has(otherId))
212
+ continue;
213
+ if (movedInPass.has(otherId))
214
+ continue;
215
+ // Check y overlap
216
+ if (other.bounds.y + other.bounds.height <= moved.bounds.y)
217
+ continue;
218
+ if (other.bounds.y >= moved.bounds.y + moved.bounds.height)
219
+ continue;
220
+ // Check x overlap (moved node intrudes into other's space)
221
+ if (other.bounds.x >= movedRight)
222
+ continue;
223
+ if (other.bounds.x + other.bounds.width <= moved.bounds.x)
224
+ continue;
225
+ // Push other right of moved
226
+ const newX = movedRight + CHAIN_GAP;
227
+ if (newX > other.bounds.x) {
228
+ const delta = newX - other.bounds.x;
229
+ other.bounds.x = newX;
230
+ if (other.labelBounds)
231
+ other.labelBounds.x += delta;
232
+ movedInPass.add(otherId);
233
+ bumped = true;
234
+ }
235
+ }
236
+ }
237
+ }
238
+ // Re-route edges where a chain source now points at a moved target,
239
+ // or where the source itself was moved by the forward pass.
240
+ for (const edge of result.edges) {
241
+ const srcMoved = movedInPass.has(edge.sourceRef);
242
+ const tgtMoved = movedInPass.has(edge.targetRef);
243
+ if (!srcMoved && !tgtMoved)
244
+ continue;
245
+ const src = nodeById.get(edge.sourceRef);
246
+ const tgt = nodeById.get(edge.targetRef);
247
+ if (!src || !tgt)
248
+ continue;
249
+ const srcX = Math.round(src.bounds.x + src.bounds.width);
250
+ const srcY = Math.round(src.bounds.y + src.bounds.height / 2);
251
+ const tgtX = Math.round(tgt.bounds.x);
252
+ const tgtY = Math.round(tgt.bounds.y + tgt.bounds.height / 2);
253
+ edge.waypoints = [
254
+ { x: srcX, y: srcY },
255
+ { x: tgtX, y: tgtY },
256
+ ];
257
+ }
136
258
  }
137
259
  /**
138
260
  * Auto-layout a BPMN process using the Sugiyama/layered algorithm.
@@ -1,5 +1,36 @@
1
1
  import { layoutFlowNodes } from "./layout-engine.js";
2
2
  import { SUBPROCESS_PADDING } from "./types.js";
3
+ const ADHOC_MAX_COLS = 4;
4
+ const ADHOC_H_GAP = 50;
5
+ const ADHOC_V_GAP = 80;
6
+ /**
7
+ * Rearrange disconnected adHocSubProcess tool nodes into a grid.
8
+ * Nodes start at (0, 0) so the subprocess padding offset applies cleanly.
9
+ */
10
+ function applyAdHocGridLayout(nodes) {
11
+ if (nodes.length === 0)
12
+ return;
13
+ const cols = Math.min(nodes.length, ADHOC_MAX_COLS);
14
+ const cellW = nodes.reduce((max, n) => Math.max(max, n.bounds.width), 0);
15
+ const cellH = nodes.reduce((max, n) => Math.max(max, n.bounds.height), 0);
16
+ for (let i = 0; i < nodes.length; i++) {
17
+ const col = i % cols;
18
+ const row = Math.floor(i / cols);
19
+ const n = nodes[i];
20
+ if (!n)
21
+ continue;
22
+ const newX = col * (cellW + ADHOC_H_GAP) + Math.round((cellW - n.bounds.width) / 2);
23
+ const newY = row * (cellH + ADHOC_V_GAP) + Math.round((cellH - n.bounds.height) / 2);
24
+ const dx = newX - n.bounds.x;
25
+ const dy = newY - n.bounds.y;
26
+ n.bounds.x = newX;
27
+ n.bounds.y = newY;
28
+ if (n.labelBounds) {
29
+ n.labelBounds.x += dx;
30
+ n.labelBounds.y += dy;
31
+ }
32
+ }
33
+ }
3
34
  /**
4
35
  * Check if a node type is a sub-process container.
5
36
  */
@@ -22,6 +53,13 @@ export function layoutSubProcesses(layoutNodes, nodeIndex) {
22
53
  if (!subProcess.flowElements || subProcess.flowElements.length === 0)
23
54
  continue;
24
55
  const childResult = layoutFlowNodes(subProcess.flowElements, subProcess.sequenceFlows ?? []);
56
+ // For adHocSubProcess with no sequence flows, rearrange into a compact grid
57
+ // instead of a single long horizontal row.
58
+ if (bpmnNode.type === "adHocSubProcess" &&
59
+ (subProcess.sequenceFlows?.length ?? 0) === 0 &&
60
+ childResult.nodes.length > 0) {
61
+ applyAdHocGridLayout(childResult.nodes);
62
+ }
25
63
  if (childResult.nodes.length === 0)
26
64
  continue;
27
65
  // Compute bounding box of child elements
@@ -5,13 +5,13 @@ export declare const ELEMENT_SIZES: Record<string, {
5
5
  height: number;
6
6
  }>;
7
7
  /** Virtual grid cell dimensions for element placement. */
8
- export declare const GRID_CELL_WIDTH = 130;
8
+ export declare const GRID_CELL_WIDTH = 150;
9
9
  export declare const GRID_CELL_HEIGHT = 140;
10
10
  /** Minimum spacing between elements (derived from grid). */
11
11
  export declare const HORIZONTAL_SPACING: number;
12
12
  export declare const VERTICAL_SPACING: number;
13
13
  /** Padding inside sub-process containers. */
14
- export declare const SUBPROCESS_PADDING = 20;
14
+ export declare const SUBPROCESS_PADDING = 50;
15
15
  /** Edge-label sizing constants (used for placement & collision detection). */
16
16
  export declare const LABEL_CHAR_WIDTH = 7;
17
17
  export declare const LABEL_MIN_WIDTH = 40;
@@ -22,13 +22,13 @@ export const ELEMENT_SIZES = {
22
22
  eventSubProcess: { width: 100, height: 80 },
23
23
  };
24
24
  /** Virtual grid cell dimensions for element placement. */
25
- export const GRID_CELL_WIDTH = 130;
25
+ export const GRID_CELL_WIDTH = 150;
26
26
  export const GRID_CELL_HEIGHT = 140;
27
27
  /** Minimum spacing between elements (derived from grid). */
28
28
  export const HORIZONTAL_SPACING = GRID_CELL_WIDTH - 100; // 100 = max element width
29
29
  export const VERTICAL_SPACING = GRID_CELL_HEIGHT - 80; // 80 = max element height
30
30
  /** Padding inside sub-process containers. */
31
- export const SUBPROCESS_PADDING = 20;
31
+ export const SUBPROCESS_PADDING = 50;
32
32
  /** Edge-label sizing constants (used for placement & collision detection). */
33
33
  export const LABEL_CHAR_WIDTH = 7;
34
34
  export const LABEL_MIN_WIDTH = 40;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bpmnkit/core",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",