@bpmnkit/canvas 0.0.27 → 0.0.29
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/canvas.d.ts +126 -1
- package/dist/canvas.js +528 -30
- package/dist/css.d.ts +1 -1
- package/dist/css.js +83 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +3 -1
- package/dist/measure.d.ts +8 -0
- package/dist/measure.js +114 -0
- package/dist/overlays.d.ts +86 -0
- package/dist/overlays.js +140 -0
- package/dist/renderer.d.ts +71 -8
- package/dist/renderer.js +525 -249
- package/dist/scene.d.ts +51 -0
- package/dist/scene.js +140 -0
- package/dist/types.d.ts +94 -3
- package/package.json +2 -2
package/dist/canvas.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { Bpmn } from "@bpmnkit/core";
|
|
2
|
-
import { injectStyles } from "./css.js";
|
|
1
|
+
import { Bpmn, applyAutoLayout, checkDiCompleteness, planeForElement } from "@bpmnkit/core";
|
|
2
|
+
import { CANVAS_CSS, injectStyles } from "./css.js";
|
|
3
3
|
import { KeyboardHandler } from "./keyboard.js";
|
|
4
|
-
import {
|
|
4
|
+
import { OverlayManager } from "./overlays.js";
|
|
5
|
+
import { computeDiagramBounds, createDefs, createGrid } from "./renderer.js";
|
|
6
|
+
import { Scene } from "./scene.js";
|
|
5
7
|
import { ViewportController } from "./viewport.js";
|
|
6
8
|
const NS = "http://www.w3.org/2000/svg";
|
|
7
9
|
let _instanceCounter = 0;
|
|
@@ -64,9 +66,12 @@ export class BpmnCanvas {
|
|
|
64
66
|
_labelsG;
|
|
65
67
|
_gridPattern = null;
|
|
66
68
|
_markerId = "";
|
|
69
|
+
_breadcrumb;
|
|
67
70
|
// ── Sub-systems ───────────────────────────────────────────────────
|
|
68
71
|
_viewport;
|
|
69
72
|
_keyboard;
|
|
73
|
+
_overlays;
|
|
74
|
+
_scene;
|
|
70
75
|
_plugins = [];
|
|
71
76
|
// ── State ─────────────────────────────────────────────────────────
|
|
72
77
|
_shapes = [];
|
|
@@ -74,6 +79,24 @@ export class BpmnCanvas {
|
|
|
74
79
|
_currentDefs = null;
|
|
75
80
|
_theme;
|
|
76
81
|
_fit;
|
|
82
|
+
_layoutMissingDi;
|
|
83
|
+
/** Elements from the last load that had no diagram interchange. */
|
|
84
|
+
_importWarnings = { missingShapes: [], missingEdges: [] };
|
|
85
|
+
/** The DI plane currently rendered. */
|
|
86
|
+
_currentPlane = null;
|
|
87
|
+
/** Element ids (this document) that own a plane and can be drilled into. */
|
|
88
|
+
_planeElementIds = new Set();
|
|
89
|
+
/** Breadcrumb path from the root plane to the current one. */
|
|
90
|
+
_planeStack = [];
|
|
91
|
+
/** CSS classes applied via {@link addMarker}, keyed by element id. */
|
|
92
|
+
_markers = new Map();
|
|
93
|
+
/** Element id currently under the pointer (for hover enter/leave events). */
|
|
94
|
+
_hoverId = null;
|
|
95
|
+
/**
|
|
96
|
+
* Set once the user pans/zooms so a container resize preserves their
|
|
97
|
+
* viewport instead of force-fitting the diagram.
|
|
98
|
+
*/
|
|
99
|
+
_userMovedViewport = false;
|
|
77
100
|
// ── Event emitter ─────────────────────────────────────────────────
|
|
78
101
|
_listeners = new Map();
|
|
79
102
|
constructor(options) {
|
|
@@ -81,6 +104,7 @@ export class BpmnCanvas {
|
|
|
81
104
|
this._id = String(_instanceCounter++);
|
|
82
105
|
this._theme = options.theme ?? "auto";
|
|
83
106
|
this._fit = options.fit ?? "contain";
|
|
107
|
+
this._layoutMissingDi = options.layoutMissingDi ?? "off";
|
|
84
108
|
// ── Build DOM ────────────────────────────────────────────────
|
|
85
109
|
const container = options.container;
|
|
86
110
|
container.innerHTML = "";
|
|
@@ -95,6 +119,12 @@ export class BpmnCanvas {
|
|
|
95
119
|
this._svg = document.createElementNS(NS, "svg");
|
|
96
120
|
this._svg.setAttribute("aria-hidden", "true");
|
|
97
121
|
this._host.appendChild(this._svg);
|
|
122
|
+
// Drill-down breadcrumb (hidden until a sub-process is opened)
|
|
123
|
+
this._breadcrumb = document.createElement("div");
|
|
124
|
+
this._breadcrumb.className = "bpmnkit-breadcrumb";
|
|
125
|
+
this._breadcrumb.setAttribute("aria-label", "Diagram plane");
|
|
126
|
+
this._breadcrumb.style.display = "none";
|
|
127
|
+
this._host.appendChild(this._breadcrumb);
|
|
98
128
|
// Arrow marker defs
|
|
99
129
|
this._markerId = createDefs(this._svg, this._id);
|
|
100
130
|
// Dot grid
|
|
@@ -113,10 +143,24 @@ export class BpmnCanvas {
|
|
|
113
143
|
this._viewportG.appendChild(this._edgesG);
|
|
114
144
|
this._viewportG.appendChild(this._shapesG);
|
|
115
145
|
this._viewportG.appendChild(this._labelsG);
|
|
146
|
+
// Scene owns the layer content + the id→graphics registry.
|
|
147
|
+
this._scene = new Scene({
|
|
148
|
+
containers: this._containersG,
|
|
149
|
+
edges: this._edgesG,
|
|
150
|
+
shapes: this._shapesG,
|
|
151
|
+
labels: this._labelsG,
|
|
152
|
+
}, this._id);
|
|
116
153
|
// ── Viewport controller ───────────────────────────────────────
|
|
117
154
|
this._viewport = new ViewportController(this._host, this._svg, this._viewportG, this._gridPattern, (state) => {
|
|
118
155
|
this._emit("viewport:change", state);
|
|
119
156
|
});
|
|
157
|
+
// ── Overlays ──────────────────────────────────────────────────
|
|
158
|
+
this._overlays = new OverlayManager({
|
|
159
|
+
hostEl: this._host,
|
|
160
|
+
getScale: () => this._viewport.state.scale,
|
|
161
|
+
getBBox: (id) => this.getAbsoluteBBox(id),
|
|
162
|
+
onViewportChange: (cb) => this.on("viewport:change", cb),
|
|
163
|
+
});
|
|
120
164
|
// ── Keyboard ──────────────────────────────────────────────────
|
|
121
165
|
this._keyboard = new KeyboardHandler(this._host, this._viewport, () => this.fitView(), (id) => {
|
|
122
166
|
const shape = this._shapes.find((s) => s.id === id);
|
|
@@ -137,11 +181,55 @@ export class BpmnCanvas {
|
|
|
137
181
|
// returns the root <svg> (reproducible in flex/scroll containers). Fall back
|
|
138
182
|
// to e.target for test environments where elementFromPoint isn't reliable.
|
|
139
183
|
const fromPoint = document.elementFromPoint(e.clientX, e.clientY);
|
|
140
|
-
const
|
|
141
|
-
|
|
184
|
+
const domTarget = fromPoint ?? e.target;
|
|
185
|
+
// Drill-down button takes precedence over element selection.
|
|
186
|
+
const drill = domTarget
|
|
187
|
+
.closest("[data-bpmnkit-drilldown]")
|
|
188
|
+
?.getAttribute("data-bpmnkit-drilldown");
|
|
189
|
+
if (drill) {
|
|
190
|
+
this.showPlane(drill);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const target = domTarget.closest("[data-bpmnkit-id]");
|
|
142
194
|
const id = target?.getAttribute("data-bpmnkit-id");
|
|
143
195
|
if (id)
|
|
144
196
|
this._emit("element:click", id, e);
|
|
197
|
+
else
|
|
198
|
+
this._emit("canvas:click", e);
|
|
199
|
+
});
|
|
200
|
+
// ── Hover / out ───────────────────────────────────────────────
|
|
201
|
+
this._svg.addEventListener("pointermove", (e) => {
|
|
202
|
+
// Suppress hover changes while a button is held (panning/pinching).
|
|
203
|
+
if (e.buttons !== 0)
|
|
204
|
+
return;
|
|
205
|
+
const id = this._elementIdForEvent(e);
|
|
206
|
+
if (id === this._hoverId)
|
|
207
|
+
return;
|
|
208
|
+
if (this._hoverId !== null)
|
|
209
|
+
this._emit("element:out", this._hoverId);
|
|
210
|
+
this._hoverId = id;
|
|
211
|
+
if (id !== null)
|
|
212
|
+
this._emit("element:hover", id, e);
|
|
213
|
+
});
|
|
214
|
+
// ── Double-click ──────────────────────────────────────────────
|
|
215
|
+
this._svg.addEventListener("dblclick", (e) => {
|
|
216
|
+
const id = this._elementIdForEvent(e);
|
|
217
|
+
if (id)
|
|
218
|
+
this._emit("element:dblclick", id, e);
|
|
219
|
+
});
|
|
220
|
+
// ── Context menu ──────────────────────────────────────────────
|
|
221
|
+
this._svg.addEventListener("contextmenu", (e) => {
|
|
222
|
+
const id = this._elementIdForEvent(e);
|
|
223
|
+
if (id)
|
|
224
|
+
this._emit("element:contextmenu", id, e);
|
|
225
|
+
});
|
|
226
|
+
// ── Track user viewport interaction ───────────────────────────
|
|
227
|
+
this._svg.addEventListener("wheel", () => {
|
|
228
|
+
this._userMovedViewport = true;
|
|
229
|
+
});
|
|
230
|
+
this._svg.addEventListener("pointerup", () => {
|
|
231
|
+
if (this._viewport.didPan)
|
|
232
|
+
this._userMovedViewport = true;
|
|
145
233
|
});
|
|
146
234
|
// ── Install plugins ───────────────────────────────────────────
|
|
147
235
|
if (options.plugins) {
|
|
@@ -153,9 +241,10 @@ export class BpmnCanvas {
|
|
|
153
241
|
if (options.xml) {
|
|
154
242
|
this.load(options.xml);
|
|
155
243
|
}
|
|
156
|
-
// Re-fit on container resize
|
|
244
|
+
// Re-fit on container resize — but only while the user hasn't taken
|
|
245
|
+
// control of the viewport, so an explicit pan/zoom survives a resize.
|
|
157
246
|
const ro = new ResizeObserver(() => {
|
|
158
|
-
if (this._currentDefs)
|
|
247
|
+
if (this._currentDefs && !this._userMovedViewport)
|
|
159
248
|
this.fitView();
|
|
160
249
|
});
|
|
161
250
|
ro.observe(this._host);
|
|
@@ -177,33 +266,117 @@ export class BpmnCanvas {
|
|
|
177
266
|
* Use this when you already have the parsed model from `@bpmnkit/core`.
|
|
178
267
|
*/
|
|
179
268
|
loadDefinitions(defs) {
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
this.
|
|
184
|
-
this.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
269
|
+
// Report elements that have no diagram interchange (they would otherwise
|
|
270
|
+
// be invisible). Warnings describe the *source* model, even when we
|
|
271
|
+
// auto-layout below.
|
|
272
|
+
this._importWarnings = checkDiCompleteness(defs);
|
|
273
|
+
const hasMissingDi = this._importWarnings.missingShapes.length > 0 || this._importWarnings.missingEdges.length > 0;
|
|
274
|
+
if (hasMissingDi) {
|
|
275
|
+
const outcome = this._layoutMissingDi === "all" ? "auto-laying out" : "they will not render";
|
|
276
|
+
const { missingShapes, missingEdges } = this._importWarnings;
|
|
277
|
+
console.warn(`[bpmnkit] diagram has ${missingShapes.length} element(s) and ${missingEdges.length} connection(s) without diagram interchange — ${outcome}`);
|
|
278
|
+
}
|
|
279
|
+
// Optionally lay out a copy so DI-less elements become visible. The
|
|
280
|
+
// caller's `defs` is never mutated (applyAutoLayout returns a fresh copy).
|
|
281
|
+
const rendered = this._layoutMissingDi === "all" && hasMissingDi ? applyAutoLayout(defs) : defs;
|
|
282
|
+
this._currentDefs = rendered;
|
|
283
|
+
this._planeElementIds = new Set(rendered.diagrams.map((d) => d.plane.bpmnElement));
|
|
284
|
+
// Start at the primary (first) plane and reset the breadcrumb.
|
|
285
|
+
const root = rendered.diagrams[0]?.plane;
|
|
286
|
+
this._planeStack = root
|
|
287
|
+
? [{ id: root.bpmnElement, name: this._planeName(root.bpmnElement) }]
|
|
288
|
+
: [];
|
|
289
|
+
// A freshly loaded diagram should auto-fit again until the user interacts.
|
|
290
|
+
this._userMovedViewport = false;
|
|
291
|
+
this._renderPlane(root ?? null);
|
|
292
|
+
this._emit("diagram:load", rendered, this._importWarnings);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Returns the elements from the last {@link load}/{@link loadDefinitions}
|
|
296
|
+
* that had no diagram interchange (and so were auto-laid-out or skipped).
|
|
297
|
+
*/
|
|
298
|
+
getImportWarnings() {
|
|
299
|
+
return {
|
|
300
|
+
missingShapes: [...this._importWarnings.missingShapes],
|
|
301
|
+
missingEdges: [...this._importWarnings.missingEdges],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Lists every DI plane in the current document (the primary plane plus any
|
|
306
|
+
* collapsed sub-processes that carry their own layout).
|
|
307
|
+
*/
|
|
308
|
+
getPlanes() {
|
|
309
|
+
if (!this._currentDefs)
|
|
310
|
+
return [];
|
|
311
|
+
return this._currentDefs.diagrams.map((d) => ({
|
|
312
|
+
id: d.plane.bpmnElement,
|
|
313
|
+
name: this._planeName(d.plane.bpmnElement),
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Shows the plane identified by a DI plane `bpmnElement` (a process/
|
|
318
|
+
* collaboration id or a collapsed sub-process id). Drilling into a
|
|
319
|
+
* sub-process extends the breadcrumb; navigating to an ancestor trims it.
|
|
320
|
+
* No-op if the id has no plane. Fires `plane:change`.
|
|
321
|
+
*/
|
|
322
|
+
showPlane(planeElementId) {
|
|
323
|
+
if (!this._currentDefs)
|
|
324
|
+
return;
|
|
325
|
+
const isRoot = this._currentDefs.diagrams[0]?.plane.bpmnElement === planeElementId;
|
|
326
|
+
const plane = isRoot
|
|
327
|
+
? this._currentDefs.diagrams[0]?.plane
|
|
328
|
+
: planeForElement(this._currentDefs, planeElementId);
|
|
329
|
+
if (!plane)
|
|
330
|
+
return;
|
|
331
|
+
const fromId = this._currentPlane?.bpmnElement ?? "";
|
|
332
|
+
if (fromId === planeElementId)
|
|
333
|
+
return;
|
|
334
|
+
// Navigate up if already in the breadcrumb, otherwise drill down.
|
|
335
|
+
const existing = this._planeStack.findIndex((c) => c.id === planeElementId);
|
|
336
|
+
if (existing >= 0) {
|
|
337
|
+
this._planeStack = this._planeStack.slice(0, existing + 1);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
this._planeStack.push({ id: planeElementId, name: this._planeName(planeElementId) });
|
|
341
|
+
}
|
|
342
|
+
this._userMovedViewport = false;
|
|
343
|
+
this._renderPlane(plane);
|
|
344
|
+
this._emit("plane:change", fromId, planeElementId);
|
|
345
|
+
}
|
|
346
|
+
/** Renders a specific plane into the (cleared) layers and refits. */
|
|
347
|
+
_renderPlane(plane) {
|
|
348
|
+
this._markers.clear();
|
|
349
|
+
this._overlays.clear();
|
|
350
|
+
this._hoverId = null;
|
|
351
|
+
this._currentPlane = plane;
|
|
352
|
+
if (this._currentDefs && plane) {
|
|
353
|
+
this._scene.render(this._currentDefs, plane, this._planeElementIds);
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
this._scene.clear();
|
|
357
|
+
}
|
|
358
|
+
this._shapes = this._scene.getShapes();
|
|
359
|
+
this._edges = this._scene.getEdges();
|
|
191
360
|
this._keyboard.setShapes(this._shapes);
|
|
361
|
+
this._updateBreadcrumb();
|
|
192
362
|
if (this._fit !== "none") {
|
|
193
363
|
// Defer fit to next frame so the SVG has been laid out
|
|
194
364
|
requestAnimationFrame(() => this.fitView());
|
|
195
365
|
}
|
|
196
|
-
this._emit("diagram:load", defs);
|
|
197
366
|
}
|
|
198
367
|
/** Clears the canvas and fires `diagram:clear`. */
|
|
199
368
|
clear() {
|
|
200
|
-
this.
|
|
201
|
-
this._edgesG.innerHTML = "";
|
|
202
|
-
this._shapesG.innerHTML = "";
|
|
203
|
-
this._labelsG.innerHTML = "";
|
|
369
|
+
this._scene.clear();
|
|
204
370
|
this._shapes = [];
|
|
205
371
|
this._edges = [];
|
|
372
|
+
this._markers.clear();
|
|
373
|
+
this._overlays.clear();
|
|
374
|
+
this._hoverId = null;
|
|
206
375
|
this._currentDefs = null;
|
|
376
|
+
this._currentPlane = null;
|
|
377
|
+
this._planeStack = [];
|
|
378
|
+
this._planeElementIds.clear();
|
|
379
|
+
this._updateBreadcrumb();
|
|
207
380
|
this._emit("diagram:clear");
|
|
208
381
|
}
|
|
209
382
|
/**
|
|
@@ -213,7 +386,7 @@ export class BpmnCanvas {
|
|
|
213
386
|
fitView(padding = 40) {
|
|
214
387
|
if (!this._currentDefs)
|
|
215
388
|
return;
|
|
216
|
-
const bounds = computeDiagramBounds(this._currentDefs);
|
|
389
|
+
const bounds = computeDiagramBounds(this._currentDefs, this._currentPlane ?? undefined);
|
|
217
390
|
if (!bounds)
|
|
218
391
|
return;
|
|
219
392
|
const svgW = this._svg.clientWidth;
|
|
@@ -238,21 +411,217 @@ export class BpmnCanvas {
|
|
|
238
411
|
this._theme = theme;
|
|
239
412
|
this._applyTheme(theme);
|
|
240
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* HTML overlays anchored to diagram elements (badges, tooltips, panels).
|
|
416
|
+
* @example
|
|
417
|
+
* ```typescript
|
|
418
|
+
* canvas.overlays.add("Task_1", {
|
|
419
|
+
* position: { top: -8, right: -8 },
|
|
420
|
+
* html: `<span class="badge">!</span>`,
|
|
421
|
+
* });
|
|
422
|
+
* ```
|
|
423
|
+
*/
|
|
424
|
+
get overlays() {
|
|
425
|
+
return this._overlays;
|
|
426
|
+
}
|
|
427
|
+
/** Returns the rendered shape or edge for a BPMN id, or `undefined` (O(1)). */
|
|
428
|
+
getElement(id) {
|
|
429
|
+
return this._scene.getElement(id);
|
|
430
|
+
}
|
|
431
|
+
/** Returns the `<g>` graphics element for a BPMN id, or `undefined` (O(1)). */
|
|
432
|
+
getGraphics(id) {
|
|
433
|
+
return this._scene.getGraphics(id);
|
|
434
|
+
}
|
|
435
|
+
/** Iterates every rendered element (shapes and edges). */
|
|
436
|
+
forEachElement(fn) {
|
|
437
|
+
this._scene.forEach(fn);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Re-renders a single element's `<g>` in place from the current model,
|
|
441
|
+
* preserving markers/selection classes — the incremental-update path used
|
|
442
|
+
* for cheap edits. No-op for an unknown id.
|
|
443
|
+
*/
|
|
444
|
+
updateElement(id) {
|
|
445
|
+
this._scene.updateElement(id);
|
|
446
|
+
this._shapes = this._scene.getShapes();
|
|
447
|
+
this._edges = this._scene.getEdges();
|
|
448
|
+
this._keyboard.setShapes(this._shapes);
|
|
449
|
+
this._overlays.reposition();
|
|
450
|
+
}
|
|
241
451
|
/** Zooms in by 25% centred on the canvas. */
|
|
242
452
|
zoomIn() {
|
|
243
453
|
const { width, height } = this._svg.getBoundingClientRect();
|
|
454
|
+
this._userMovedViewport = true;
|
|
244
455
|
this._viewport.zoomAt(width / 2, height / 2, 1.25);
|
|
245
456
|
}
|
|
246
457
|
/** Zooms out by 25% centred on the canvas. */
|
|
247
458
|
zoomOut() {
|
|
248
459
|
const { width, height } = this._svg.getBoundingClientRect();
|
|
460
|
+
this._userMovedViewport = true;
|
|
249
461
|
this._viewport.zoomAt(width / 2, height / 2, 0.8);
|
|
250
462
|
}
|
|
251
463
|
/** Resets to 100% zoom, centred on the canvas. */
|
|
252
464
|
resetZoom() {
|
|
253
465
|
const { width, height } = this._svg.getBoundingClientRect();
|
|
466
|
+
this._userMovedViewport = true;
|
|
254
467
|
this._viewport.set({ scale: 1, tx: width / 2, ty: height / 2 });
|
|
255
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* Adjusts the zoom. Pass `"fit"` (or no argument) to fit the whole diagram;
|
|
471
|
+
* pass a number for an absolute scale, optionally keeping `center`
|
|
472
|
+
* (screen-space pixels relative to the host) fixed.
|
|
473
|
+
*/
|
|
474
|
+
zoom(scaleOrFit = "fit", center) {
|
|
475
|
+
if (scaleOrFit === "fit") {
|
|
476
|
+
this._userMovedViewport = false;
|
|
477
|
+
this.fitView();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
this._userMovedViewport = true;
|
|
481
|
+
const { width, height } = this._svg.getBoundingClientRect();
|
|
482
|
+
const cx = center?.x ?? width / 2;
|
|
483
|
+
const cy = center?.y ?? height / 2;
|
|
484
|
+
const current = this._viewport.state.scale;
|
|
485
|
+
if (current > 0)
|
|
486
|
+
this._viewport.zoomAt(cx, cy, scaleOrFit / current);
|
|
487
|
+
}
|
|
488
|
+
/** Returns the visible region in diagram coordinates plus the zoom scale. */
|
|
489
|
+
viewbox() {
|
|
490
|
+
const { tx, ty, scale } = this._viewport.state;
|
|
491
|
+
const { width, height } = this._svg.getBoundingClientRect();
|
|
492
|
+
return { x: -tx / scale, y: -ty / scale, width: width / scale, height: height / scale, scale };
|
|
493
|
+
}
|
|
494
|
+
/** Pans (without changing zoom) so the element with the given id is centred. */
|
|
495
|
+
scrollToElement(id) {
|
|
496
|
+
const box = this._diagramBounds(id);
|
|
497
|
+
if (!box)
|
|
498
|
+
return;
|
|
499
|
+
const { scale } = this._viewport.state;
|
|
500
|
+
const { width, height } = this._svg.getBoundingClientRect();
|
|
501
|
+
const centerX = box.x + box.width / 2;
|
|
502
|
+
const centerY = box.y + box.height / 2;
|
|
503
|
+
this._userMovedViewport = true;
|
|
504
|
+
this._viewport.set({ tx: width / 2 - centerX * scale, ty: height / 2 - centerY * scale });
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Returns the element's bounding box in screen pixels relative to the host,
|
|
508
|
+
* or `null` if the element is not found.
|
|
509
|
+
*/
|
|
510
|
+
getAbsoluteBBox(id) {
|
|
511
|
+
const box = this._diagramBounds(id);
|
|
512
|
+
if (!box)
|
|
513
|
+
return null;
|
|
514
|
+
const { tx, ty, scale } = this._viewport.state;
|
|
515
|
+
return {
|
|
516
|
+
x: box.x * scale + tx,
|
|
517
|
+
y: box.y * scale + ty,
|
|
518
|
+
width: box.width * scale,
|
|
519
|
+
height: box.height * scale,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Serializes the current diagram to a standalone SVG string with theme
|
|
524
|
+
* colours inlined, so it renders correctly outside the page.
|
|
525
|
+
* @param opts.bounds `"diagram"` (default) exports the whole diagram;
|
|
526
|
+
* `"viewport"` exports only the currently visible region.
|
|
527
|
+
*/
|
|
528
|
+
exportSvg(opts) {
|
|
529
|
+
const box = opts?.bounds === "viewport"
|
|
530
|
+
? (() => {
|
|
531
|
+
const v = this.viewbox();
|
|
532
|
+
return { x: v.x, y: v.y, width: v.width, height: v.height };
|
|
533
|
+
})()
|
|
534
|
+
: (() => {
|
|
535
|
+
const b = this._currentDefs
|
|
536
|
+
? computeDiagramBounds(this._currentDefs, this._currentPlane ?? undefined)
|
|
537
|
+
: null;
|
|
538
|
+
const pad = 20;
|
|
539
|
+
return b
|
|
540
|
+
? {
|
|
541
|
+
x: b.minX - pad,
|
|
542
|
+
y: b.minY - pad,
|
|
543
|
+
width: b.maxX - b.minX + pad * 2,
|
|
544
|
+
height: b.maxY - b.minY + pad * 2,
|
|
545
|
+
}
|
|
546
|
+
: { x: 0, y: 0, width: 0, height: 0 };
|
|
547
|
+
})();
|
|
548
|
+
const svg = document.createElementNS(NS, "svg");
|
|
549
|
+
svg.setAttribute("xmlns", NS);
|
|
550
|
+
svg.setAttribute("viewBox", `${box.x} ${box.y} ${box.width} ${box.height}`);
|
|
551
|
+
svg.setAttribute("width", String(Math.round(box.width)));
|
|
552
|
+
svg.setAttribute("height", String(Math.round(box.height)));
|
|
553
|
+
const style = document.createElementNS(NS, "style");
|
|
554
|
+
style.textContent = this._exportStyles();
|
|
555
|
+
svg.appendChild(style);
|
|
556
|
+
// Marker/pattern defs so `url(#…)` references resolve inside the export.
|
|
557
|
+
const liveDefs = this._svg.querySelector("defs");
|
|
558
|
+
if (liveDefs)
|
|
559
|
+
svg.appendChild(liveDefs.cloneNode(true));
|
|
560
|
+
// Diagram content lives in the layer groups in diagram coordinates (the
|
|
561
|
+
// pan/zoom transform is on the viewport group, which is intentionally
|
|
562
|
+
// excluded so the viewBox alone frames the content).
|
|
563
|
+
for (const layer of [this._containersG, this._edgesG, this._shapesG, this._labelsG]) {
|
|
564
|
+
svg.appendChild(layer.cloneNode(true));
|
|
565
|
+
}
|
|
566
|
+
return new XMLSerializer().serializeToString(svg);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Rasterizes the current diagram to a PNG data URL at `scale`× the diagram
|
|
570
|
+
* size. Browser-only (requires `Image`/`<canvas>`).
|
|
571
|
+
*/
|
|
572
|
+
exportPng(scale = 2) {
|
|
573
|
+
const svgString = this.exportSvg();
|
|
574
|
+
const match = svgString.match(/width="(\d+)" height="(\d+)"/);
|
|
575
|
+
const w = (match ? Number(match[1]) : 0) * scale;
|
|
576
|
+
const h = (match ? Number(match[2]) : 0) * scale;
|
|
577
|
+
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;
|
|
578
|
+
return new Promise((resolve, reject) => {
|
|
579
|
+
const img = new Image();
|
|
580
|
+
img.onload = () => {
|
|
581
|
+
const canvas = document.createElement("canvas");
|
|
582
|
+
canvas.width = w;
|
|
583
|
+
canvas.height = h;
|
|
584
|
+
const ctx = canvas.getContext("2d");
|
|
585
|
+
if (!ctx) {
|
|
586
|
+
reject(new Error("2D canvas context unavailable"));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
590
|
+
resolve(canvas.toDataURL("image/png"));
|
|
591
|
+
};
|
|
592
|
+
img.onerror = () => reject(new Error("Failed to rasterize SVG"));
|
|
593
|
+
img.src = url;
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
/** Adds a CSS class to the element with the given BPMN id. No-op if not found. */
|
|
597
|
+
addMarker(id, cls) {
|
|
598
|
+
const element = this._findElement(id);
|
|
599
|
+
if (!element)
|
|
600
|
+
return;
|
|
601
|
+
element.classList.add(cls);
|
|
602
|
+
let set = this._markers.get(id);
|
|
603
|
+
if (!set) {
|
|
604
|
+
set = new Set();
|
|
605
|
+
this._markers.set(id, set);
|
|
606
|
+
}
|
|
607
|
+
set.add(cls);
|
|
608
|
+
}
|
|
609
|
+
/** Removes a CSS class from the element with the given BPMN id. */
|
|
610
|
+
removeMarker(id, cls) {
|
|
611
|
+
this._findElement(id)?.classList.remove(cls);
|
|
612
|
+
this._markers.get(id)?.delete(cls);
|
|
613
|
+
}
|
|
614
|
+
/** Returns whether the element with the given id currently has the CSS class. */
|
|
615
|
+
hasMarker(id, cls) {
|
|
616
|
+
return this._findElement(id)?.classList.contains(cls) ?? false;
|
|
617
|
+
}
|
|
618
|
+
/** Toggles a CSS class on the element with the given id. */
|
|
619
|
+
toggleMarker(id, cls) {
|
|
620
|
+
if (this.hasMarker(id, cls))
|
|
621
|
+
this.removeMarker(id, cls);
|
|
622
|
+
else
|
|
623
|
+
this.addMarker(id, cls);
|
|
624
|
+
}
|
|
256
625
|
/**
|
|
257
626
|
* Subscribes to a canvas event. Returns an unsubscribe function.
|
|
258
627
|
*
|
|
@@ -282,23 +651,25 @@ export class BpmnCanvas {
|
|
|
282
651
|
*/
|
|
283
652
|
highlight(ids, variant) {
|
|
284
653
|
const cls = `bpmnkit-highlight--${variant}`;
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
if (idSet.has(id))
|
|
288
|
-
element.classList.add(cls);
|
|
289
|
-
}
|
|
654
|
+
for (const id of ids)
|
|
655
|
+
this.addMarker(id, cls);
|
|
290
656
|
}
|
|
291
657
|
/** Removes all highlight classes added by {@link highlight}. */
|
|
292
658
|
clearHighlights() {
|
|
293
659
|
for (const { element } of [...this._shapes, ...this._edges]) {
|
|
294
660
|
element.classList.remove("bpmnkit-highlight--changed", "bpmnkit-highlight--new");
|
|
295
661
|
}
|
|
662
|
+
for (const set of this._markers.values()) {
|
|
663
|
+
set.delete("bpmnkit-highlight--changed");
|
|
664
|
+
set.delete("bpmnkit-highlight--new");
|
|
665
|
+
}
|
|
296
666
|
}
|
|
297
667
|
/** Destroys the canvas, removing all DOM nodes and event listeners. */
|
|
298
668
|
destroy() {
|
|
299
669
|
this._ro.disconnect();
|
|
300
670
|
this._viewport.destroy();
|
|
301
671
|
this._keyboard.destroy();
|
|
672
|
+
this._overlays.destroy();
|
|
302
673
|
for (const plugin of this._plugins)
|
|
303
674
|
plugin.uninstall?.();
|
|
304
675
|
this._plugins.length = 0;
|
|
@@ -306,6 +677,121 @@ export class BpmnCanvas {
|
|
|
306
677
|
this._host.remove();
|
|
307
678
|
}
|
|
308
679
|
// ── Private ───────────────────────────────────────────────────────
|
|
680
|
+
/** Finds the SVG group for a shape or edge by BPMN id. */
|
|
681
|
+
_findElement(id) {
|
|
682
|
+
return this._scene.getGraphics(id);
|
|
683
|
+
}
|
|
684
|
+
/** Design tokens to resolve from the live host for a self-contained export. */
|
|
685
|
+
static _EXPORT_TOKENS = [
|
|
686
|
+
"--bpmnkit-bg",
|
|
687
|
+
"--bpmnkit-grid",
|
|
688
|
+
"--bpmnkit-shape-fill",
|
|
689
|
+
"--bpmnkit-shape-stroke",
|
|
690
|
+
"--bpmnkit-flow-stroke",
|
|
691
|
+
"--bpmnkit-text",
|
|
692
|
+
"--bpmnkit-highlight",
|
|
693
|
+
"--bpmnkit-focus",
|
|
694
|
+
"--bpmnkit-warn",
|
|
695
|
+
"--bpmnkit-success",
|
|
696
|
+
];
|
|
697
|
+
/** Stylesheet for the exported SVG: resolved theme tokens + the canvas CSS. */
|
|
698
|
+
_exportStyles() {
|
|
699
|
+
const decls = [];
|
|
700
|
+
if (typeof getComputedStyle !== "undefined") {
|
|
701
|
+
const cs = getComputedStyle(this._host);
|
|
702
|
+
for (const token of BpmnCanvas._EXPORT_TOKENS) {
|
|
703
|
+
const value = cs.getPropertyValue(token).trim();
|
|
704
|
+
if (value)
|
|
705
|
+
decls.push(`${token}: ${value};`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const rootRule = decls.length ? `svg { ${decls.join(" ")} }\n` : "";
|
|
709
|
+
return `${rootRule}${CANVAS_CSS}`;
|
|
710
|
+
}
|
|
711
|
+
/** A human-readable label for a plane's `bpmnElement` (name, or a fallback). */
|
|
712
|
+
_planeName(planeElementId) {
|
|
713
|
+
const el = this._findFlowElementById(planeElementId);
|
|
714
|
+
if (el)
|
|
715
|
+
return el.name ?? "Sub-process";
|
|
716
|
+
return "Process";
|
|
717
|
+
}
|
|
718
|
+
/** Recursively finds a flow element by id across all processes/sub-processes. */
|
|
719
|
+
_findFlowElementById(id) {
|
|
720
|
+
if (!this._currentDefs)
|
|
721
|
+
return undefined;
|
|
722
|
+
const walk = (elements) => {
|
|
723
|
+
for (const el of elements) {
|
|
724
|
+
if (el.id === id)
|
|
725
|
+
return el;
|
|
726
|
+
if ("flowElements" in el) {
|
|
727
|
+
const found = walk(el.flowElements);
|
|
728
|
+
if (found)
|
|
729
|
+
return found;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return undefined;
|
|
733
|
+
};
|
|
734
|
+
for (const proc of this._currentDefs.processes) {
|
|
735
|
+
const found = walk(proc.flowElements);
|
|
736
|
+
if (found)
|
|
737
|
+
return found;
|
|
738
|
+
}
|
|
739
|
+
return undefined;
|
|
740
|
+
}
|
|
741
|
+
/** Rebuilds the breadcrumb bar from the current plane stack. */
|
|
742
|
+
_updateBreadcrumb() {
|
|
743
|
+
if (this._planeStack.length <= 1) {
|
|
744
|
+
this._breadcrumb.style.display = "none";
|
|
745
|
+
this._breadcrumb.replaceChildren();
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
this._breadcrumb.style.display = "";
|
|
749
|
+
this._breadcrumb.replaceChildren();
|
|
750
|
+
this._planeStack.forEach((crumb, i) => {
|
|
751
|
+
if (i > 0) {
|
|
752
|
+
const sep = document.createElement("span");
|
|
753
|
+
sep.className = "bpmnkit-breadcrumb-sep";
|
|
754
|
+
sep.textContent = "›";
|
|
755
|
+
this._breadcrumb.appendChild(sep);
|
|
756
|
+
}
|
|
757
|
+
const btn = document.createElement("button");
|
|
758
|
+
btn.type = "button";
|
|
759
|
+
btn.className = "bpmnkit-breadcrumb-crumb";
|
|
760
|
+
btn.textContent = crumb.name;
|
|
761
|
+
const targetId = crumb.id;
|
|
762
|
+
btn.addEventListener("click", () => this.showPlane(targetId));
|
|
763
|
+
this._breadcrumb.appendChild(btn);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Resolves the BPMN element id under a pointer/mouse event, or `null`.
|
|
768
|
+
* Prefers `elementFromPoint` (correct when native SVG hit-testing returns
|
|
769
|
+
* the root `<svg>` in flex/scroll containers) and falls back to the event
|
|
770
|
+
* target (for environments where `elementFromPoint` is unavailable).
|
|
771
|
+
*/
|
|
772
|
+
_elementIdForEvent(e) {
|
|
773
|
+
const fromPoint = document.elementFromPoint(e.clientX, e.clientY);
|
|
774
|
+
const target = fromPoint?.closest("[data-bpmnkit-id]") ??
|
|
775
|
+
e.target?.closest("[data-bpmnkit-id]");
|
|
776
|
+
return target?.getAttribute("data-bpmnkit-id") ?? null;
|
|
777
|
+
}
|
|
778
|
+
/** Diagram-coordinate bounds of a shape (from DI) or edge (waypoint bbox). */
|
|
779
|
+
_diagramBounds(id) {
|
|
780
|
+
const shape = this._shapes.find((s) => s.id === id);
|
|
781
|
+
if (shape) {
|
|
782
|
+
const { x, y, width, height } = shape.shape.bounds;
|
|
783
|
+
return { x, y, width, height };
|
|
784
|
+
}
|
|
785
|
+
const edge = this._edges.find((e) => e.id === id);
|
|
786
|
+
if (edge && edge.edge.waypoints.length > 0) {
|
|
787
|
+
const xs = edge.edge.waypoints.map((w) => w.x);
|
|
788
|
+
const ys = edge.edge.waypoints.map((w) => w.y);
|
|
789
|
+
const minX = Math.min(...xs);
|
|
790
|
+
const minY = Math.min(...ys);
|
|
791
|
+
return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
|
|
792
|
+
}
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
309
795
|
_applyTheme(theme) {
|
|
310
796
|
const resolved = theme === "auto"
|
|
311
797
|
? window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
@@ -326,11 +812,23 @@ export class BpmnCanvas {
|
|
|
326
812
|
svg: this._svg,
|
|
327
813
|
viewportEl: this._viewportG,
|
|
328
814
|
getViewport: () => this._viewport.state,
|
|
329
|
-
setViewport: (s) =>
|
|
815
|
+
setViewport: (s) => {
|
|
816
|
+
this._userMovedViewport = true;
|
|
817
|
+
this._viewport.set(s);
|
|
818
|
+
},
|
|
330
819
|
getShapes: () => [...this._shapes],
|
|
331
820
|
getEdges: () => [...this._edges],
|
|
332
821
|
getTheme: () => this._theme,
|
|
333
822
|
setTheme: (theme) => this.setTheme(theme),
|
|
823
|
+
overlays: this._overlays,
|
|
824
|
+
addMarker: (id, cls) => this.addMarker(id, cls),
|
|
825
|
+
removeMarker: (id, cls) => this.removeMarker(id, cls),
|
|
826
|
+
hasMarker: (id, cls) => this.hasMarker(id, cls),
|
|
827
|
+
toggleMarker: (id, cls) => this.toggleMarker(id, cls),
|
|
828
|
+
zoom: (scaleOrFit, center) => this.zoom(scaleOrFit, center),
|
|
829
|
+
viewbox: () => this.viewbox(),
|
|
830
|
+
scrollToElement: (id) => this.scrollToElement(id),
|
|
831
|
+
getAbsoluteBBox: (id) => this.getAbsoluteBBox(id),
|
|
334
832
|
on: (event, handler) => this.on(event, handler),
|
|
335
833
|
emit: (event, ...args) => this._emit(event, ...args),
|
|
336
834
|
};
|