@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.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 { computeDiagramBounds, createDefs, createGrid, render } from "./renderer.js";
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 target = fromPoint?.closest("[data-bpmnkit-id]") ??
141
- e.target.closest("[data-bpmnkit-id]");
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
- // Clear previous content
181
- this._containersG.innerHTML = "";
182
- this._edgesG.innerHTML = "";
183
- this._shapesG.innerHTML = "";
184
- this._labelsG.innerHTML = "";
185
- this._shapes = [];
186
- this._edges = [];
187
- this._currentDefs = defs;
188
- const result = render(defs, this._containersG, this._edgesG, this._shapesG, this._labelsG, this._markerId, this._id);
189
- this._shapes = result.shapes;
190
- this._edges = result.edges;
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._containersG.innerHTML = "";
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 idSet = new Set(ids);
286
- for (const { id, element } of [...this._shapes, ...this._edges]) {
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) => this._viewport.set(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
  };