@bpmnkit/editor 0.0.35 → 0.2.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/dock.js CHANGED
@@ -1,131 +1,109 @@
1
+ import { injectChromeStyles } from "./chrome.js";
1
2
  const DOCK_STYLE_ID = "bpmnkit-side-dock-styles-v1";
2
3
  const STORAGE_KEY_WIDTH = "bpmnkit-side-dock-width";
3
4
  const STORAGE_KEY_COLLAPSED = "bpmnkit-side-dock-collapsed";
4
5
  const MIN_WIDTH = 280;
5
6
  const MAX_WIDTH = 700;
6
- const DEFAULT_WIDTH = 360;
7
+ const DEFAULT_WIDTH = 340;
7
8
  const DOCK_CSS = `
8
- /* ── Side Dock ──────────────────────────────────────────────────────────── */
9
- .bpmnkit-side-dock__tab:disabled { opacity: 0.3; cursor: default; }
9
+ /* ── Side dock ───────────────────────────────────────────────────────────
10
+ The properties panel, in the bpmnkit.com design system: flat, square,
11
+ hairline-bounded, mono uppercase labels. Grounds follow the HUD theme
12
+ variables declared in css.ts, so the two read as one piece of chrome.
13
+ ──────────────────────────────────────────────────────────────────────── */
10
14
  .bpmnkit-side-dock {
11
15
  position: fixed; right: 0; top: 36px; bottom: 0;
12
16
  z-index: 9999; display: flex; flex-direction: column;
13
- background: rgba(18, 18, 26, 0.98);
14
- border-left: 1px solid rgba(255,255,255,0.1);
15
- box-shadow: -8px 0 40px rgba(0,0,0,0.6);
16
- font-family: system-ui, -apple-system, sans-serif;
17
+ background: var(--bpmnkit-chrome-ground, #ffffff);
18
+ border-left: 1px solid var(--bpmnkit-chrome-line, rgba(255, 255, 255, 0.14));
19
+ font-family: var(--bpmnkit-ds-font-sans, system-ui, -apple-system, sans-serif);
17
20
  transition: width 0.22s ease;
18
21
  }
19
- /* Pill handle — sticks out from the left edge; always visible + clickable */
22
+ /* Square handle — sticks out from the left edge; always visible + clickable */
20
23
  .bpmnkit-side-dock__collapse-handle {
21
24
  position: absolute; left: -20px; top: 50%; transform: translateY(-50%);
22
25
  width: 20px; height: 52px;
23
- background: rgba(18, 18, 26, 0.98);
24
- border: 1px solid rgba(255,255,255,0.1); border-right: none;
25
- border-radius: 8px 0 0 8px;
26
+ background: var(--bpmnkit-chrome-ground, #ffffff);
27
+ border: 1px solid var(--bpmnkit-chrome-line, rgba(255, 255, 255, 0.14)); border-right: none;
26
28
  display: flex; align-items: center; justify-content: center;
27
29
  cursor: pointer; z-index: 1;
28
- color: rgba(255,255,255,0.45); font-size: 14px; line-height: 1;
29
- transition: color 0.1s, background 0.1s;
30
+ color: var(--bpmnkit-chrome-ink-4, #9aa1aa);
31
+ font-family: var(--bpmnkit-ds-font-mono, ui-monospace, monospace);
32
+ font-size: 13px; line-height: 1;
33
+ font-variant-emoji: text;
30
34
  user-select: none;
31
35
  }
32
- .bpmnkit-side-dock__collapse-handle:hover { color: #fff; background: rgba(40,40,60,0.99); }
36
+ .bpmnkit-side-dock__collapse-handle:hover { color: var(--bpmnkit-chrome-ink, #f4f5f7); background: var(--bpmnkit-chrome-hover, rgba(255,255,255,0.07)); }
33
37
  .bpmnkit-side-dock__resize-handle {
34
38
  position: absolute; left: 0; top: 0; bottom: 0;
35
39
  width: 5px; cursor: ew-resize; z-index: 2;
36
40
  }
37
- .bpmnkit-side-dock__resize-handle:hover { background: rgba(76,142,247,0.35); }
41
+ .bpmnkit-side-dock__resize-handle:hover { background: var(--bpmnkit-chrome-accent, #c9755c); }
42
+
43
+ /* ── Tabs — equal-width row, the underline sits on the row's own rule ── */
38
44
  .bpmnkit-side-dock__tab-strip {
39
- display: flex; align-items: center; height: 38px; flex-shrink: 0;
40
- border-bottom: 1px solid rgba(255,255,255,0.08);
45
+ display: flex; align-items: stretch; height: 34px; flex-shrink: 0;
46
+ border-bottom: 1px solid var(--bpmnkit-chrome-line, rgba(255, 255, 255, 0.14));
47
+ overflow-x: auto; scrollbar-width: none;
41
48
  }
49
+ .bpmnkit-side-dock__tab-strip::-webkit-scrollbar { display: none; }
50
+ /* Equal-width while the labels fit; the strip scrolls rather than clipping them. */
42
51
  .bpmnkit-side-dock__tab {
43
- padding: 0 16px; height: 100%; background: none; border: none;
44
- border-bottom: 2px solid transparent; color: rgba(255,255,255,0.4);
45
- cursor: pointer; font-size: 12px; font-weight: 500; white-space: nowrap;
46
- transition: color 0.1s, border-color 0.1s;
47
- font-family: system-ui, -apple-system, sans-serif;
52
+ flex: 1 0 auto;
53
+ padding: 0 10px; height: 100%; background: none; border: none;
54
+ border-bottom: 2px solid transparent; color: var(--bpmnkit-chrome-ink-4, #9aa1aa);
55
+ cursor: pointer; white-space: nowrap;
56
+ font-family: var(--bpmnkit-ds-font-mono, ui-monospace, monospace);
57
+ font-size: var(--bpmnkit-ds-t-mono-micro, 10.5px);
58
+ letter-spacing: 0.08em; text-transform: uppercase;
59
+ }
60
+ .bpmnkit-side-dock__tab:hover { color: var(--bpmnkit-chrome-ink-2, #c8ccd2); }
61
+ .bpmnkit-side-dock__tab.active {
62
+ color: var(--bpmnkit-chrome-ink, #f4f5f7);
63
+ border-bottom-color: var(--bpmnkit-chrome-accent, #c9755c);
64
+ margin-bottom: -1px;
48
65
  }
49
- .bpmnkit-side-dock__tab:hover { color: rgba(255,255,255,0.75); }
50
- .bpmnkit-side-dock__tab.active { color: #4c8ef7; border-bottom-color: #4c8ef7; }
66
+ .bpmnkit-side-dock__tab:disabled { opacity: 0.3; cursor: default; }
51
67
  .bpmnkit-side-dock__pane {
52
68
  flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0;
53
69
  }
54
70
  .bpmnkit-side-dock__pane--hidden { display: none; }
55
- /* Empty state — info widget + hint, shown when no element is selected */
71
+
72
+ /* ── Empty state — info rows + hint, shown when nothing is selected ── */
56
73
  .bpmnkit-side-dock__empty {
57
74
  flex: 1; display: flex; flex-direction: column;
58
- overflow-y: auto; padding: 14px 16px;
59
- gap: 0;
75
+ overflow-y: auto; padding: 0 20px;
60
76
  }
61
77
  .bpmnkit-side-dock__info-row {
62
78
  display: flex; flex-direction: column;
63
- padding: 10px 0;
64
- border-bottom: 1px solid rgba(255,255,255,0.06);
65
- gap: 3px;
79
+ padding: 14px 0;
80
+ border-bottom: 1px solid var(--bpmnkit-chrome-line-soft, rgba(255, 255, 255, 0.08));
81
+ gap: 7px;
66
82
  }
67
83
  .bpmnkit-side-dock__info-label {
68
- font-size: 10px; font-weight: 700; text-transform: uppercase;
69
- letter-spacing: 0.08em; color: rgba(255,255,255,0.3);
84
+ font-family: var(--bpmnkit-ds-font-mono, ui-monospace, monospace);
85
+ font-size: var(--bpmnkit-ds-t-mono-micro, 10.5px); letter-spacing: 0.12em;
86
+ text-transform: uppercase; color: var(--bpmnkit-chrome-ink-4, #9aa1aa);
70
87
  }
71
88
  .bpmnkit-side-dock__info-value {
72
- font-size: 13px; color: rgba(255,255,255,0.75); word-break: break-word;
89
+ font-family: var(--bpmnkit-ds-font-mono, ui-monospace, monospace);
90
+ font-size: 12.5px; color: var(--bpmnkit-chrome-ink-2, #c8ccd2); word-break: break-word;
73
91
  }
92
+ /* A sentence, not a label — --ink-4 does not clear 4.5:1 on a light ground. */
74
93
  .bpmnkit-side-dock__empty-hint {
75
- font-size: 12px; color: rgba(255,255,255,0.25);
76
- text-align: center; padding: 20px 0;
94
+ font-family: var(--bpmnkit-ds-font-mono, ui-monospace, monospace);
95
+ font-size: var(--bpmnkit-ds-t-mono-label, 11.5px); letter-spacing: 0.04em;
96
+ color: var(--bpmnkit-chrome-ink-2, #c8ccd2);
97
+ padding: 24px 0;
77
98
  }
78
- /* Collapsed state — only the pill handle remains visible */
99
+ /* Collapsed state — only the handle remains visible */
79
100
  .bpmnkit-side-dock--collapsed .bpmnkit-side-dock__tab-strip,
80
101
  .bpmnkit-side-dock--collapsed .bpmnkit-side-dock__pane { display: none; }
81
102
  /* Push watermark left of the dock using a CSS variable updated by JS */
82
103
  .bpmnkit-watermark { right: calc(var(--bpmnkit-dock-width, 0px) + 8px) !important; }
83
- /* Light theme */
84
- [data-bpmnkit-hud-theme="light"] .bpmnkit-side-dock {
85
- background: rgba(248,248,252,0.99); border-left-color: rgba(0,0,0,0.08);
86
- box-shadow: -8px 0 40px rgba(0,0,0,0.12);
87
- }
88
- [data-bpmnkit-hud-theme="light"] .bpmnkit-side-dock__collapse-handle {
89
- background: rgba(248,248,252,0.99); border-color: rgba(0,0,0,0.08); color: rgba(0,0,0,0.4);
90
- }
91
- [data-bpmnkit-hud-theme="light"] .bpmnkit-side-dock__collapse-handle:hover {
92
- background: rgba(235,235,242,0.99); color: rgba(0,0,0,0.9);
93
- }
94
- [data-bpmnkit-hud-theme="light"] .bpmnkit-side-dock__tab-strip { border-bottom-color: rgba(0,0,0,0.07); }
95
- [data-bpmnkit-hud-theme="light"] .bpmnkit-side-dock__tab { color: rgba(0,0,0,0.4); }
96
- [data-bpmnkit-hud-theme="light"] .bpmnkit-side-dock__tab:hover { color: rgba(0,0,0,0.7); }
97
- [data-bpmnkit-hud-theme="light"] .bpmnkit-side-dock__tab.active { color: #1a56db; border-bottom-color: #1a56db; }
98
- [data-bpmnkit-hud-theme="light"] .bpmnkit-side-dock__resize-handle:hover { background: rgba(26,86,219,0.2); }
99
- [data-bpmnkit-hud-theme="light"] .bpmnkit-side-dock__info-label { color: rgba(0,0,0,0.3); }
100
- [data-bpmnkit-hud-theme="light"] .bpmnkit-side-dock__info-value { color: rgba(0,0,0,0.75); }
101
- [data-bpmnkit-hud-theme="light"] .bpmnkit-side-dock__info-row { border-bottom-color: rgba(0,0,0,0.06); }
102
- [data-bpmnkit-hud-theme="light"] .bpmnkit-side-dock__empty-hint { color: rgba(0,0,0,0.25); }
103
- /* Neon theme */
104
- [data-bpmnkit-hud-theme="neon"] .bpmnkit-side-dock {
105
- background: oklch(6% 0.04 280);
106
- border-left: 1px solid oklch(65% 0.28 280 / 0.35);
107
- box-shadow: -8px 0 40px oklch(0% 0 0 / 0.8), -1px 0 20px oklch(65% 0.28 280 / 0.12);
108
- }
109
- [data-bpmnkit-hud-theme="neon"] .bpmnkit-side-dock__collapse-handle {
110
- background: oklch(6% 0.04 280);
111
- border-color: oklch(65% 0.28 280 / 0.35);
112
- color: oklch(65% 0.28 280 / 0.6);
113
- }
114
- [data-bpmnkit-hud-theme="neon"] .bpmnkit-side-dock__collapse-handle:hover {
115
- background: oklch(12% 0.04 270 / 0.99);
116
- color: oklch(73% 0.16 280);
117
- }
118
- [data-bpmnkit-hud-theme="neon"] .bpmnkit-side-dock__tab-strip { border-bottom-color: oklch(65% 0.28 280 / 0.25); }
119
- [data-bpmnkit-hud-theme="neon"] .bpmnkit-side-dock__tab { color: oklch(50% 0.1 280); }
120
- [data-bpmnkit-hud-theme="neon"] .bpmnkit-side-dock__tab:hover { color: oklch(73% 0.16 280); }
121
- [data-bpmnkit-hud-theme="neon"] .bpmnkit-side-dock__tab.active { color: oklch(72% 0.18 185); border-bottom-color: oklch(72% 0.18 185); }
122
- [data-bpmnkit-hud-theme="neon"] .bpmnkit-side-dock__resize-handle:hover { background: oklch(65% 0.28 280 / 0.25); }
123
- [data-bpmnkit-hud-theme="neon"] .bpmnkit-side-dock__info-label { color: oklch(50% 0.06 280); }
124
- [data-bpmnkit-hud-theme="neon"] .bpmnkit-side-dock__info-value { color: oklch(73% 0.16 280); }
125
- [data-bpmnkit-hud-theme="neon"] .bpmnkit-side-dock__info-row { border-bottom-color: oklch(65% 0.28 280 / 0.1); }
126
- [data-bpmnkit-hud-theme="neon"] .bpmnkit-side-dock__empty-hint { color: oklch(45% 0.08 280); }
127
104
  `;
128
105
  function injectDockStyles() {
106
+ injectChromeStyles();
129
107
  if (document.getElementById(DOCK_STYLE_ID))
130
108
  return;
131
109
  const style = document.createElement("style");
package/dist/editor.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { OverlayManager } from "@bpmnkit/canvas";
2
- import type { Theme } from "@bpmnkit/canvas";
2
+ import type { Theme, ViewportState } from "@bpmnkit/canvas";
3
3
  import type { BpmnDefinitions, DiColor } from "@bpmnkit/core";
4
4
  import type { Translate } from "./i18n.js";
5
5
  import type { CreateShapeType, EditorEvents, EditorOptions, LabelPosition, Tool } from "./types.js";
@@ -94,10 +94,30 @@ export declare class BpmnEditor {
94
94
  }>;
95
95
  undo(): void;
96
96
  redo(): void;
97
+ /**
98
+ * Moves to a document already on the command stack — an undo or a redo.
99
+ *
100
+ * It describes itself as a `snapshot` op, which is the only honest option: a
101
+ * step back through the stack is a state change like any other, and a
102
+ * listener relaying edits elsewhere that never heard about it would be
103
+ * silently wrong from then on. There is no smaller description available —
104
+ * the inverse of an op is not something the stack records.
105
+ */
106
+ private _stepTo;
97
107
  canUndo(): boolean;
98
108
  canRedo(): boolean;
99
109
  getDefinitions(): BpmnDefinitions | null;
100
110
  applyChange(fn: (defs: BpmnDefinitions) => BpmnDefinitions): void;
111
+ /**
112
+ * The raw pan and zoom, for restoring it later.
113
+ *
114
+ * Paired with {@link setViewport} so a viewer can hand its view to an editor
115
+ * taking its place — without it the swap re-fits, and the diagram jumps under
116
+ * the cursor at the moment someone starts editing.
117
+ */
118
+ getViewport(): ViewportState;
119
+ /** Restores a viewport captured by {@link getViewport}. */
120
+ setViewport(state: Partial<ViewportState>): void;
101
121
  fitView(padding?: number): void;
102
122
  /** The host element that receives the `data-theme` attribute. */
103
123
  get container(): HTMLElement;
@@ -124,7 +144,15 @@ export declare class BpmnEditor {
124
144
  private _getDuplicateIds;
125
145
  private _setDuplicateIdWarning;
126
146
  private _renderDefs;
127
- private _executeCommand;
147
+ /**
148
+ * Performs an edit: the editor's single mutation path.
149
+ *
150
+ * Every change goes through {@link applyOp}, including the ones typed right
151
+ * here — see `ops.ts` for why local edits are not allowed their own shortcut.
152
+ * Returns the ids the op brought into being, which a caller may need for a
153
+ * follow-up (starting a label edit on a fresh annotation, say).
154
+ */
155
+ private _executeOp;
128
156
  /** Label of the change `undo()` would revert (for HUD tooltips), or null. */
129
157
  getUndoLabel(): string | null;
130
158
  /** Label of the change `redo()` would re-apply (for HUD tooltips), or null. */
package/dist/editor.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { KeyboardHandler, OverlayManager, ViewportController, computeDiagramBounds, createDefs, createGrid, injectStyles, render, } from "@bpmnkit/canvas";
2
- import { Bpmn, applyAutoLayout } from "@bpmnkit/core";
2
+ import { Bpmn } from "@bpmnkit/core";
3
3
  import { CommandStack } from "./command-stack.js";
4
4
  import { injectEditorStyles } from "./css.js";
5
5
  import { closestPort, computeWaypoints, computeWaypointsAvoiding, computeWaypointsWithPorts, diagramToScreen, labelBoundsForPosition, portFromWaypoint, screenToDiagram, } from "./geometry.js";
6
6
  import { defaultTranslate } from "./i18n.js";
7
+ import { newIdSeed } from "./id.js";
7
8
  import { LabelEditor } from "./label-editor.js";
8
- import { changeElementType as changeElementTypeFn, copyElements, createAnnotation, createAnnotationWithLink, createBoundaryEvent, createConnection, createEmptyDefinitions, createShape, deleteElements, insertEdgeWaypoint, insertShapeOnEdge, moveEdgeSegment, moveEdgeWaypoint, moveShapes, pasteElements, removeCollinearWaypoints, resizeShape, updateEdgeEndpoint, updateLabel, updateLabelPosition, updateShapeColor, } from "./modeling.js";
9
+ import { copyElements, createEmptyDefinitions, insertEdgeWaypoint, moveEdgeSegment, moveEdgeWaypoint, } from "./modeling.js";
10
+ import { applyOp } from "./ops.js";
9
11
  import { OverlayRenderer } from "./overlay.js";
10
12
  import { canAttach, canConnect, canMorph, canResize } from "./rules.js";
11
13
  import { EditorStateMachine } from "./state-machine.js";
@@ -295,8 +297,10 @@ export class BpmnEditor {
295
297
  injectEditorStyles();
296
298
  this._id = String(_instanceCounter++);
297
299
  this._t = options.translate ?? defaultTranslate;
298
- // Resolve initial theme — localStorage overrides the options.theme when persistTheme is on
299
- let initialTheme = options.theme ?? "neon";
300
+ // Resolve initial theme — localStorage overrides the options.theme when persistTheme is on.
301
+ // The shell's default is the design system's light `--canvas` ground; `neon` stays a
302
+ // white-label theme a host opts into.
303
+ let initialTheme = options.theme ?? "light";
300
304
  if (options.persistTheme) {
301
305
  try {
302
306
  const stored = localStorage.getItem("bpmnkit-theme");
@@ -374,7 +378,7 @@ export class BpmnEditor {
374
378
  previewResize: (bounds) => this._overlay.setResizePreview(bounds),
375
379
  commitResize: (id, bounds) => {
376
380
  this._overlay.setResizePreview(null);
377
- this._executeCommand((d) => resizeShape(d, id, bounds), "Resize");
381
+ this._executeOp({ kind: "resize", id, bounds }, { label: "Resize" });
378
382
  },
379
383
  previewConnect: (ghostEnd, targetId) => {
380
384
  const src = this._connectSourceBounds();
@@ -399,7 +403,7 @@ export class BpmnEditor {
399
403
  startLabelEdit: (id) => this._startLabelEdit(id),
400
404
  setHovered: (id) => this._overlay.setHovered(id, this._shapes),
401
405
  executeDelete: (ids) => {
402
- this._executeCommand((d) => deleteElements(d, ids), "Delete");
406
+ this._executeOp({ kind: "delete", ids }, { label: "Delete" });
403
407
  this._setSelection([]);
404
408
  },
405
409
  executeCopy: () => this._doCopy(),
@@ -429,7 +433,7 @@ export class BpmnEditor {
429
433
  this._overlay.setEndpointDragGhost(null);
430
434
  this._overlay.setAlignmentGuides([]);
431
435
  const snap = this._snapWaypoint(pt);
432
- this._executeCommand((d) => removeCollinearWaypoints(insertEdgeWaypoint(d, edgeId, segIdx, snap.pt), edgeId), "Add waypoint");
436
+ this._executeOp({ kind: "insertWaypoint", edgeId, segIdx, point: snap.pt }, { label: "Add waypoint" });
433
437
  },
434
438
  cancelWaypointInsert: () => {
435
439
  this._overlay.setEndpointDragGhost(null);
@@ -448,7 +452,7 @@ export class BpmnEditor {
448
452
  this._overlay.setEndpointDragGhost(null);
449
453
  this._overlay.setAlignmentGuides([]);
450
454
  const snap = this._snapWaypoint(pt);
451
- this._executeCommand((d) => removeCollinearWaypoints(moveEdgeWaypoint(d, edgeId, wpIdx, snap.pt), edgeId), "Move waypoint");
455
+ this._executeOp({ kind: "moveWaypoint", edgeId, wpIdx, point: snap.pt }, { label: "Move waypoint" });
452
456
  },
453
457
  cancelWaypointMove: () => {
454
458
  this._overlay.setEndpointDragGhost(null);
@@ -463,7 +467,7 @@ export class BpmnEditor {
463
467
  },
464
468
  commitSegmentMove: (edgeId, segIdx, isHoriz, delta) => {
465
469
  this._overlay.setEndpointDragGhost(null);
466
- this._executeCommand((d) => removeCollinearWaypoints(moveEdgeSegment(d, edgeId, segIdx, isHoriz, delta), edgeId), "Move segment");
470
+ this._executeOp({ kind: "moveSegment", edgeId, segIdx, isHoriz, delta }, { label: "Move segment" });
467
471
  },
468
472
  cancelSegmentMove: () => {
469
473
  this._overlay.setEndpointDragGhost(null);
@@ -489,7 +493,7 @@ export class BpmnEditor {
489
493
  this._stateMachine = new EditorStateMachine(callbacks);
490
494
  // ── Label editor ─────────────────────────────────────────────
491
495
  this._labelEditor = new LabelEditor(this._host, (id, text) => {
492
- this._executeCommand((d) => updateLabel(d, id, text), "Rename", `label:${id}`);
496
+ this._executeOp({ kind: "rename", id, name: text }, { label: "Rename", coalesceKey: `label:${id}` });
493
497
  this._stateMachine.setMode({ mode: "select", sub: { name: "idle", hoveredId: null } });
494
498
  }, () => {
495
499
  this._stateMachine.setMode({ mode: "select", sub: { name: "idle", hoveredId: null } });
@@ -550,7 +554,7 @@ export class BpmnEditor {
550
554
  * The operation is undoable.
551
555
  */
552
556
  autoLayout() {
553
- this._executeCommand(applyAutoLayout, "Auto-layout");
557
+ this._executeOp({ kind: "autoLayout" }, { label: "Auto-layout" });
554
558
  this.fitView();
555
559
  }
556
560
  loadDefinitions(defs) {
@@ -623,7 +627,7 @@ export class BpmnEditor {
623
627
  if (this._selectedIds.length === 0)
624
628
  return;
625
629
  const ids = [...this._selectedIds];
626
- this._executeCommand((d) => deleteElements(d, ids), "Delete");
630
+ this._executeOp({ kind: "delete", ids }, { label: "Delete" });
627
631
  this._setSelection([]);
628
632
  }
629
633
  /** Bounds of the currently-selected shapes (edges have none, and are skipped). */
@@ -676,7 +680,7 @@ export class BpmnEditor {
676
680
  moves.push({ id: b.id, dx, dy });
677
681
  }
678
682
  if (moves.length > 0)
679
- this._executeCommand((d) => moveShapes(d, moves), "Align");
683
+ this._executeOp({ kind: "move", moves }, { label: "Align" });
680
684
  }
681
685
  /**
682
686
  * Distributes the selected shapes so the gaps between them are equal along
@@ -711,7 +715,7 @@ export class BpmnEditor {
711
715
  cursor += size(b) + gap;
712
716
  }
713
717
  if (moves.length > 0)
714
- this._executeCommand((d) => moveShapes(d, moves), "Distribute");
718
+ this._executeOp({ kind: "move", moves }, { label: "Distribute" });
715
719
  }
716
720
  /**
717
721
  * Searches the model (recursively, including sub-process children) for
@@ -753,17 +757,27 @@ export class BpmnEditor {
753
757
  }
754
758
  undo() {
755
759
  const prev = this._commandStack.undo();
756
- if (prev) {
757
- this._renderDefs(prev);
758
- this._emit("diagram:change", prev);
759
- }
760
+ if (prev)
761
+ this._stepTo(prev);
760
762
  }
761
763
  redo() {
762
764
  const next = this._commandStack.redo();
763
- if (next) {
764
- this._renderDefs(next);
765
- this._emit("diagram:change", next);
766
- }
765
+ if (next)
766
+ this._stepTo(next);
767
+ }
768
+ /**
769
+ * Moves to a document already on the command stack — an undo or a redo.
770
+ *
771
+ * It describes itself as a `snapshot` op, which is the only honest option: a
772
+ * step back through the stack is a state change like any other, and a
773
+ * listener relaying edits elsewhere that never heard about it would be
774
+ * silently wrong from then on. There is no smaller description available —
775
+ * the inverse of an op is not something the stack records.
776
+ */
777
+ _stepTo(defs) {
778
+ this._renderDefs(defs);
779
+ this._emit("diagram:change", defs);
780
+ this._emit("diagram:op", { kind: "snapshot", defs }, defs);
767
781
  }
768
782
  canUndo() {
769
783
  return this._commandStack.canUndo();
@@ -775,7 +789,23 @@ export class BpmnEditor {
775
789
  return this._defs;
776
790
  }
777
791
  applyChange(fn) {
778
- this._executeCommand(fn);
792
+ if (this._readOnly || !this._defs)
793
+ return;
794
+ this._executeOp({ kind: "snapshot", defs: fn(this._defs) });
795
+ }
796
+ /**
797
+ * The raw pan and zoom, for restoring it later.
798
+ *
799
+ * Paired with {@link setViewport} so a viewer can hand its view to an editor
800
+ * taking its place — without it the swap re-fits, and the diagram jumps under
801
+ * the cursor at the moment someone starts editing.
802
+ */
803
+ getViewport() {
804
+ return this._viewport.state;
805
+ }
806
+ /** Restores a viewport captured by {@link getViewport}. */
807
+ setViewport(state) {
808
+ this._viewport.set(state);
779
809
  }
780
810
  fitView(padding = 40) {
781
811
  if (!this._defs)
@@ -986,15 +1016,31 @@ export class BpmnEditor {
986
1016
  }
987
1017
  this._emit("diagram:load", defs, { missingShapes: [], missingEdges: [] });
988
1018
  }
989
- _executeCommand(fn, label = "", coalesceKey) {
1019
+ /**
1020
+ * Performs an edit: the editor's single mutation path.
1021
+ *
1022
+ * Every change goes through {@link applyOp}, including the ones typed right
1023
+ * here — see `ops.ts` for why local edits are not allowed their own shortcut.
1024
+ * Returns the ids the op brought into being, which a caller may need for a
1025
+ * follow-up (starting a label edit on a fresh annotation, say).
1026
+ */
1027
+ _executeOp(op, options = {}) {
990
1028
  if (this._readOnly || !this._defs)
991
- return;
992
- const newDefs = fn(this._defs);
993
- this._commandStack.push(newDefs, label, coalesceKey);
994
- this._renderDefs(newDefs);
995
- if (label)
996
- this._announce(this._t(label));
997
- this._emit("diagram:change", newDefs);
1029
+ return [];
1030
+ const { defs, created } = applyOp(this._defs, op);
1031
+ // Selection is set before the render so the new element comes up selected.
1032
+ const select = options.select === "all" ? created : options.select === "first" ? created.slice(0, 1) : null;
1033
+ if (select)
1034
+ this._selectedIds = select;
1035
+ this._commandStack.push(defs, options.label ?? "", options.coalesceKey);
1036
+ this._renderDefs(defs);
1037
+ if (options.label)
1038
+ this._announce(this._t(options.label));
1039
+ this._emit("diagram:change", defs);
1040
+ this._emit("diagram:op", op, defs);
1041
+ if (select)
1042
+ this._emit("editor:select", select);
1043
+ return created;
998
1044
  }
999
1045
  /** Label of the change `undo()` would revert (for HUD tooltips), or null. */
1000
1046
  getUndoLabel() {
@@ -1101,10 +1147,10 @@ export class BpmnEditor {
1101
1147
  const moves = this._selectedIds.map((id) => ({ id, dx: snap.dx, dy: snap.dy }));
1102
1148
  const shapeId = this._selectedIds.length === 1 ? this._selectedIds[0] : undefined;
1103
1149
  if (edgeDropId && shapeId) {
1104
- this._executeCommand((d) => insertShapeOnEdge(moveShapes(d, moves), edgeDropId, shapeId), "Insert on flow");
1150
+ this._executeOp({ kind: "move", moves, onEdge: { edgeId: edgeDropId, shapeId } }, { label: "Insert on flow" });
1105
1151
  }
1106
1152
  else {
1107
- this._executeCommand((d) => moveShapes(d, moves), "Move");
1153
+ this._executeOp({ kind: "move", moves }, { label: "Move" });
1108
1154
  }
1109
1155
  if (this._isDragging) {
1110
1156
  this._isDragging = false;
@@ -1179,7 +1225,7 @@ export class BpmnEditor {
1179
1225
  }
1180
1226
  }
1181
1227
  if (moves.length > 0) {
1182
- this._executeCommand((d) => moveShapes(d, moves), "Move");
1228
+ this._executeOp({ kind: "move", moves }, { label: "Move" });
1183
1229
  }
1184
1230
  }
1185
1231
  _cancelSpace() {
@@ -1201,13 +1247,9 @@ export class BpmnEditor {
1201
1247
  return;
1202
1248
  const bounds = defaultBounds(type, actualCenter.x, actualCenter.y);
1203
1249
  if (type === "textAnnotation") {
1204
- const result = createAnnotation(this._defs, bounds);
1205
- this._selectedIds = [result.id];
1206
- this._commandStack.push(result.defs);
1207
- this._renderDefs(result.defs);
1208
- this._emit("diagram:change", result.defs);
1209
- this._emit("editor:select", [result.id]);
1210
- this._startLabelEdit(result.id);
1250
+ const [id] = this._executeOp({ kind: "createAnnotation", bounds, seed: newIdSeed() }, { select: "first" });
1251
+ if (id)
1252
+ this._startLabelEdit(id);
1211
1253
  return;
1212
1254
  }
1213
1255
  // If hovering over an activity, create a boundary event
@@ -1221,24 +1263,23 @@ export class BpmnEditor {
1221
1263
  const eventBounds = snapToBoundary(actualCenter, hostBounds, 18);
1222
1264
  // Map palette type to event definition type
1223
1265
  const eventDefType = intermediateEventDefType(type);
1224
- const result = createBoundaryEvent(this._defs, boundaryHostId, eventDefType, eventBounds);
1225
- this._selectedIds = [result.id];
1226
- this._commandStack.push(result.defs);
1227
- this._renderDefs(result.defs);
1228
- this._emit("diagram:change", result.defs);
1229
- this._emit("editor:select", [result.id]);
1266
+ this._executeOp({
1267
+ kind: "createBoundaryEvent",
1268
+ hostId: boundaryHostId,
1269
+ eventDefType,
1270
+ bounds: eventBounds,
1271
+ seed: newIdSeed(),
1272
+ }, { select: "first" });
1230
1273
  return;
1231
1274
  }
1232
1275
  }
1233
- const result = createShape(this._defs, type, bounds);
1234
- this._selectedIds = [result.id];
1235
- const finalDefs = pendingEdgeDrop
1236
- ? insertShapeOnEdge(result.defs, pendingEdgeDrop, result.id)
1237
- : result.defs;
1238
- this._commandStack.push(finalDefs);
1239
- this._renderDefs(finalDefs);
1240
- this._emit("diagram:change", finalDefs);
1241
- this._emit("editor:select", [result.id]);
1276
+ this._executeOp({
1277
+ kind: "createShape",
1278
+ type,
1279
+ bounds,
1280
+ onEdge: pendingEdgeDrop ?? undefined,
1281
+ seed: newIdSeed(),
1282
+ }, { select: "first" });
1242
1283
  }
1243
1284
  _doConnect(srcId, tgtId) {
1244
1285
  const srcShape = this._shapeById.get(srcId);
@@ -1253,7 +1294,7 @@ export class BpmnEditor {
1253
1294
  .filter((s) => s.id !== srcId && s.id !== tgtId)
1254
1295
  .map((s) => s.shape.bounds);
1255
1296
  const waypoints = computeWaypointsAvoiding(srcShape.shape.bounds, tgtShape.shape.bounds, obstacles);
1256
- this._executeCommand((d) => createConnection(d, srcId, tgtId, waypoints).defs, "Connect");
1297
+ this._executeOp({ kind: "createConnection", sourceId: srcId, targetId: tgtId, waypoints, seed: newIdSeed() }, { label: "Connect" });
1257
1298
  }
1258
1299
  _doCopy() {
1259
1300
  if (!this._defs || this._selectedIds.length === 0)
@@ -1263,20 +1304,14 @@ export class BpmnEditor {
1263
1304
  _doPaste() {
1264
1305
  if (!this._clipboard)
1265
1306
  return;
1266
- const base = this._defs ?? createEmptyDefinitions();
1267
- const result = pasteElements(base, this._clipboard, 20, 20);
1268
- this._selectedIds = result.topLevelIds;
1269
- this._commandStack.push(result.defs, "Paste");
1270
- this._renderDefs(result.defs);
1271
- this._emit("diagram:change", result.defs);
1272
- this._emit("editor:select", result.topLevelIds);
1307
+ this._executeOp({ kind: "paste", clipboard: this._clipboard, offsetX: 20, offsetY: 20, seed: newIdSeed() }, { label: "Paste", select: "all" });
1273
1308
  }
1274
1309
  _doCut() {
1275
1310
  if (!this._defs || this._selectedIds.length === 0)
1276
1311
  return;
1277
1312
  this._doCopy();
1278
1313
  const ids = [...this._selectedIds];
1279
- this._executeCommand((d) => deleteElements(d, ids), "Cut");
1314
+ this._executeOp({ kind: "delete", ids }, { label: "Cut" });
1280
1315
  this._setSelection([]);
1281
1316
  }
1282
1317
  _startLabelEdit(id) {
@@ -1388,15 +1423,17 @@ export class BpmnEditor {
1388
1423
  }
1389
1424
  const newBounds = this._smartPlaceBounds(srcBounds, sourceId, w, h);
1390
1425
  const obstacles = this._shapes.filter((s) => s.id !== sourceId).map((s) => s.shape.bounds);
1391
- const r1 = createShape(this._defs, type, newBounds, name);
1392
1426
  const waypoints = computeWaypointsAvoiding(srcBounds, newBounds, obstacles);
1393
- const r2 = createConnection(r1.defs, sourceId, r1.id, waypoints);
1394
- this._selectedIds = [r1.id];
1395
- this._commandStack.push(r2.defs);
1396
- this._renderDefs(r2.defs);
1397
- this._emit("diagram:change", r2.defs);
1398
- this._emit("editor:select", [r1.id]);
1399
- return r1.id;
1427
+ const [id] = this._executeOp({
1428
+ kind: "createConnected",
1429
+ sourceId,
1430
+ type,
1431
+ name,
1432
+ bounds: newBounds,
1433
+ waypoints,
1434
+ seed: newIdSeed(),
1435
+ }, { select: "first" });
1436
+ return id ?? null;
1400
1437
  }
1401
1438
  _smartPlaceBounds(srcBounds, sourceId, w, h) {
1402
1439
  const GAP = 60;
@@ -1493,7 +1530,7 @@ export class BpmnEditor {
1493
1530
  if (!shape)
1494
1531
  return;
1495
1532
  const labelBounds = labelBoundsForPosition(shape.shape.bounds, position);
1496
- this._executeCommand((d) => updateLabelPosition(d, shapeId, labelBounds), "Move label");
1533
+ this._executeOp({ kind: "labelPosition", id: shapeId, bounds: labelBounds }, { label: "Move label" });
1497
1534
  }
1498
1535
  /** Starts inline label editing for the element with the given id. */
1499
1536
  editLabel(id) {
@@ -1535,17 +1572,19 @@ export class BpmnEditor {
1535
1572
  width: annW,
1536
1573
  height: annH,
1537
1574
  };
1538
- const result = createAnnotationWithLink(this._defs, annBounds, sourceId, srcBounds);
1539
- this._selectedIds = [result.annotationId];
1540
- this._commandStack.push(result.defs);
1541
- this._renderDefs(result.defs);
1542
- this._emit("diagram:change", result.defs);
1543
- this._emit("editor:select", [result.annotationId]);
1544
- this._startLabelEdit(result.annotationId);
1575
+ const [id] = this._executeOp({
1576
+ kind: "createAnnotationFor",
1577
+ sourceId,
1578
+ bounds: annBounds,
1579
+ sourceBounds: srcBounds,
1580
+ seed: newIdSeed(),
1581
+ }, { select: "first" });
1582
+ if (id)
1583
+ this._startLabelEdit(id);
1545
1584
  }
1546
1585
  /** Updates the color of a shape in the diagram. Pass `{}` to clear colors. */
1547
1586
  updateColor(id, color) {
1548
- this._executeCommand((d) => updateShapeColor(d, id, color), "Change colour", `color:${id}`);
1587
+ this._executeOp({ kind: "color", id, color }, { label: "Change colour", coalesceKey: `color:${id}` });
1549
1588
  }
1550
1589
  // ── Private helpers ────────────────────────────────────────────────
1551
1590
  _setEdgeSelected(edgeId) {
@@ -1570,7 +1609,7 @@ export class BpmnEditor {
1570
1609
  const current = this._shapeById.get(id)?.flowElement?.type;
1571
1610
  if (current && !canMorph(current, newType))
1572
1611
  return;
1573
- this._executeCommand((d) => changeElementTypeFn(d, id, newType), "Change type");
1612
+ this._executeOp({ kind: "changeType", id, type: newType }, { label: "Change type" });
1574
1613
  }
1575
1614
  _findEdgeDropTarget(dx, dy) {
1576
1615
  if (this._selectedIds.length !== 1)
@@ -1675,7 +1714,7 @@ export class BpmnEditor {
1675
1714
  const newPort = isStart
1676
1715
  ? closestPort(diagPoint, srcDi.bounds)
1677
1716
  : closestPort(diagPoint, tgtDi.bounds);
1678
- this._executeCommand((d) => updateEdgeEndpoint(d, edgeId, isStart, newPort), "Reconnect");
1717
+ this._executeOp({ kind: "reconnect", edgeId, isStart, port: newPort }, { label: "Reconnect" });
1679
1718
  }
1680
1719
  _isResizable(id) {
1681
1720
  const shape = this._shapeById.get(id);