@bpmn-nova/studio 0.3.4-preview → 0.3.5-preview

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.
@@ -394,6 +394,8 @@ export class BpmnViewer {
394
394
  this.projectedRuntime = null;
395
395
  this.runtimePresentation = null;
396
396
  this.selection = null;
397
+ this._sidebarTransitionActive = false;
398
+ this._sidebarViewport = null;
397
399
  this._detailsCleanup = null;
398
400
  this._detailsMotion = null;
399
401
  this._detailsRoot = null;
@@ -424,11 +426,7 @@ export class BpmnViewer {
424
426
  throw error;
425
427
  }
426
428
  this._resizeObserver = typeof ResizeObserver === 'function'
427
- ? new ResizeObserver(() => {
428
- if (this.projection !== 'auto') return;
429
- const next = this._resolveProjection();
430
- if (next !== this.activeProjection) this.refresh();
431
- })
429
+ ? new ResizeObserver(() => this._refreshAutoProjection())
432
430
  : null;
433
431
  this._resizeObserver?.observe(options.container);
434
432
  }
@@ -436,10 +434,29 @@ export class BpmnViewer {
436
434
  _resolveProjection() {
437
435
  if (this.projection !== 'auto') return this.projection;
438
436
  if (!this.runtime) return 'standard';
437
+ if (this._sidebarTransitionActive) return this.activeProjection;
439
438
  const width = this.container.clientWidth || this.container.getBoundingClientRect?.().width || 0;
440
439
  return width > 0 && width < 720 ? 'compact' : 'approval';
441
440
  }
442
441
 
442
+ // The Shell owns the transition. Do not replace the renderer at intermediate widths.
443
+ _setSidebarTransitionActive(active) {
444
+ if (this._sidebarTransitionActive === Boolean(active)) return;
445
+ this._sidebarTransitionActive = Boolean(active);
446
+ if (active) {
447
+ if (this.activeProjection !== 'compact') this._sidebarViewport = this.renderer?.getViewportState?.() || this._sidebarViewport;
448
+ } else this._refreshAutoProjection();
449
+ }
450
+
451
+ _refreshAutoProjection() {
452
+ if (this._sidebarTransitionActive || this.projection !== 'auto') return;
453
+ const next = this._resolveProjection();
454
+ if (next === this.activeProjection) return;
455
+ if (this.activeProjection !== 'compact') this._sidebarViewport = this.renderer?.getViewportState?.() || this._sidebarViewport;
456
+ this.refresh();
457
+ if (this.activeProjection !== 'compact' && this._sidebarViewport) this.renderer?.setViewportState?.(this._sidebarViewport);
458
+ }
459
+
443
460
  _rebuildProjection() {
444
461
  const previousProjection = this.activeProjection;
445
462
  this.activeProjection = this._resolveProjection();
@@ -494,33 +511,59 @@ export class BpmnViewer {
494
511
  return [];
495
512
  }
496
513
 
497
- _emitTraceClick(payload) {
498
- let element = payload.element || null;
499
- if (element && payload.targetType === 'edge') {
500
- const sourceIds = element.sourceEdgeIds?.length ? element.sourceEdgeIds : [element.id];
514
+ _resolveTraceClick(payload) {
515
+ if (!payload) return null;
516
+ const targetType = payload.targetType;
517
+ const transitionId = payload.transition?.id || payload.traceItem?.transitionId;
518
+ const transition = targetType === 'transition' && transitionId
519
+ ? this.runtimePresentation.getTransition(transitionId)
520
+ : null;
521
+ if (targetType === 'transition' && !transition) return null;
522
+ const elementId = transition?.sourceElementId || payload.elementId || payload.element?.id || payload.traceItem?.elementId;
523
+ let selectedElement;
524
+ if (targetType === 'edge') {
525
+ const current = this.projectedModel.edges.find((edge) => edge.id === elementId)
526
+ || this.model.edges.find((edge) => edge.id === elementId);
527
+ if (!current) return null;
528
+ const sourceIds = current.sourceEdgeIds?.length ? current.sourceEdgeIds : [current.id];
501
529
  const sourceEdges = sourceIds.map((id) => this.model.edges.find((edge) => edge.id === id)).filter(Boolean);
502
- element = sourceEdges.find((edge) => element.name && edge.name === element.name) || sourceEdges[0] || element;
503
- } else if (element?.id && payload.targetType !== 'edge') {
504
- element = this.model.nodes.find((node) => node.id === element.id) || element;
505
- }
506
- const presentation = payload.presentation || (element?.id && payload.targetType !== 'edge'
507
- ? this.runtimePresentation.getNode(element.id)
508
- : null);
509
- this.onTraceClick({
510
- targetType: payload.targetType,
511
- elementId: payload.elementId || element?.id,
512
- element,
513
- visitId: payload.visitId ?? payload.traceItem?.visitId ?? null,
514
- traceItem: payload.traceItem || null,
515
- transition: payload.transition || null,
530
+ selectedElement = sourceEdges.find((edge) => current.name && edge.name === current.name) || sourceEdges[0] || current;
531
+ } else selectedElement = this.model.nodes.find((node) => node.id === elementId) || null;
532
+ if (!selectedElement && !transition) return null;
533
+ const presentation = selectedElement?.id && targetType !== 'edge'
534
+ ? this.runtimePresentation.getNode(selectedElement.id)
535
+ : null;
536
+ const visitId = payload.visitId ?? payload.traceItem?.visitId ?? null;
537
+ const traceItems = this.traceProjection?.items || [];
538
+ const traceItem = traceItems.find((item) => payload.traceItem?.id && item.id === payload.traceItem.id)
539
+ || (targetType === 'transition' ? traceItems.find((item) => item.transitionId === transitionId) : null)
540
+ || (visitId ? traceItems.find((item) => item.elementId === elementId && item.visitId === visitId) : null)
541
+ || null;
542
+ const visit = visitId ? presentation?.visits?.find((item) => item.id === visitId) : null;
543
+ if (targetType === 'visit' && visitId && !traceItem && !visit) return null;
544
+ const records = traceItem?.records || visit?.records;
545
+ return {
546
+ targetType,
547
+ elementId: selectedElement?.id || elementId,
548
+ element: selectedElement,
549
+ visitId,
550
+ traceItem,
551
+ transition,
516
552
  presentation,
517
- activityInstances: this._activityInstancesFor({ traceItem: payload.traceItem, presentation }),
518
- actions: payload.traceItem?.actions || (payload.transition?.action ? [payload.transition.action] : presentation?.actions) || [],
553
+ activityInstances: records ? [...records] : this._activityInstancesFor({ presentation }),
554
+ actions: traceItem?.actions || visit?.actions || (transition?.action ? [transition.action] : presentation?.actions) || [],
519
555
  runtime: this.runtime,
520
556
  projection: this.activeProjection,
521
557
  originalEvent: payload.originalEvent,
522
558
  viewer: this,
523
- });
559
+ };
560
+ }
561
+
562
+ _emitTraceClick(payload) {
563
+ const resolved = this._resolveTraceClick(payload);
564
+ if (!resolved) return;
565
+ this.options._onPanelSelection?.(resolved);
566
+ this.onTraceClick(resolved);
524
567
  }
525
568
 
526
569
  _mountRenderer() {
@@ -554,7 +597,7 @@ export class BpmnViewer {
554
597
  onDetailsRequest: (item, anchor, event) => {
555
598
  const node = this.model.nodes.find((candidate) => candidate.id === item.elementId);
556
599
  if (!node) return;
557
- this._select('node', node, event, { emitTrace: false });
600
+ this._select('node', node, event, { emitTrace: false, traceItem: item });
558
601
  const presentation = this.runtimePresentation.getNode(node.id);
559
602
  if (presentation.hasDetails && this.runtimeDetailsOptions.autoOpen) {
560
603
  this._openRuntimeDetails(node, presentation, anchor, {
@@ -564,6 +607,7 @@ export class BpmnViewer {
564
607
  },
565
608
  onTransitionRequest: (item, anchor, event) => {
566
609
  const transition = this.runtimePresentation.getTransition(item.transitionId);
610
+ this.options._onPanelSelection?.(this._resolveTraceClick({ targetType: 'transition', transition, traceItem: item, originalEvent: event }));
567
611
  if (this.runtimeDetailsOptions.autoOpen) {
568
612
  this._openRuntimeTransitionDetails(transition, anchor, {
569
613
  restoreFocusVisible: event ? event.detail === 0 : Boolean(anchor?.matches?.(':focus-visible')),
@@ -602,17 +646,19 @@ export class BpmnViewer {
602
646
  });
603
647
  }
604
648
 
605
- _select(kind, element, originalEvent = undefined, { emitTrace = true } = {}) {
649
+ _select(kind, element, originalEvent = undefined, { emitTrace = true, traceItem = null } = {}) {
606
650
  this.selection = { kind, id: element.id };
607
651
  this.renderer.setSelection(this.selection);
608
652
  const presentation = kind === 'node' ? this.runtimePresentation.getNode(element.id) : null;
609
653
  this.onElementClick({ kind, element, runtime: this.runtime, presentation });
610
654
  if (emitTrace) this._emitTraceClick({ targetType: kind, element, presentation, originalEvent });
655
+ else this.options._onPanelSelection?.(this._resolveTraceClick({ targetType: traceItem ? 'visit' : kind, element, presentation, traceItem, originalEvent }));
611
656
  }
612
657
 
613
658
  clearSelection() {
614
659
  this.selection = null;
615
660
  this.renderer.setSelection(null);
661
+ this.options._onPanelSelection?.(null);
616
662
  this.onElementClick(null);
617
663
  }
618
664
 
@@ -923,6 +969,7 @@ export class BpmnViewer {
923
969
  this._rebuildProjection();
924
970
  this._mountRenderer();
925
971
  if (this.selection) this.renderer.setSelection?.(this.selection);
972
+ this.options._onPanelSelection?.();
926
973
  }
927
974
 
928
975
  setModel(model) { this.model = model; this._svgExportAssetCache.clear(); this.refresh(); }
@@ -938,23 +985,25 @@ export class BpmnViewer {
938
985
  this.refresh();
939
986
  requestAnimationFrame(() => this.renderer.fitView());
940
987
  }
941
- setDisplayOptions({ timeline, runtimeDetails, runtimeTraceOptions, runtimeAssetResolver } = {}) {
988
+ setDisplayOptions({ timeline, runtimeDetails, runtimeTraceOptions, runtimeAssetResolver, replace = false } = {}) {
942
989
  let changed = false;
943
990
  if (runtimeAssetResolver !== undefined) {
944
991
  this.runtimeAssetResolver = runtimeAssetResolver || null;
945
992
  this._svgExportAssetCache.clear();
946
993
  changed = true;
947
994
  }
948
- if (runtimeTraceOptions !== undefined) {
949
- this.runtimeTraceOptions = { ...this.runtimeTraceOptions, ...(runtimeTraceOptions || {}) };
995
+ if (replace || runtimeTraceOptions !== undefined) {
996
+ this.runtimeTraceOptions = replace
997
+ ? { ...(runtimeTraceOptions || {}) }
998
+ : { ...this.runtimeTraceOptions, ...(runtimeTraceOptions || {}) };
950
999
  changed = true;
951
1000
  }
952
- if (timeline !== undefined) {
953
- this.timelineOptions = mergeTimelineOptions(this.timelineOptions, timeline || {});
1001
+ if (replace || timeline !== undefined) {
1002
+ this.timelineOptions = mergeTimelineOptions(replace ? {} : this.timelineOptions, timeline || {});
954
1003
  changed = true;
955
1004
  }
956
- if (runtimeDetails !== undefined) {
957
- this.runtimeDetailsOptions = mergeRuntimeDetailsOptions(this.runtimeDetailsOptions, runtimeDetails || {});
1005
+ if (replace || runtimeDetails !== undefined) {
1006
+ this.runtimeDetailsOptions = mergeRuntimeDetailsOptions(replace ? {} : this.runtimeDetailsOptions, runtimeDetails || {});
958
1007
  changed = true;
959
1008
  }
960
1009
  if (changed) this.refresh();
@@ -1013,6 +1062,8 @@ export class BpmnViewer {
1013
1062
  fitView() { this.renderer.fitView(); }
1014
1063
  zoomBy(delta) { this.renderer.zoomBy(delta); }
1015
1064
  destroy() {
1065
+ this._sidebarTransitionActive = false;
1066
+ this._sidebarViewport = null;
1016
1067
  this._svgExportPreview?.close?.({ immediate: true });
1017
1068
  this._svgExportPreview = null;
1018
1069
  this._svgExportAssetCache.clear();
@@ -0,0 +1,60 @@
1
+ // Snapshot only public data. Never freeze the Controller, Viewer, or their source models.
2
+ function immutableCopy(value, seen = new WeakMap()) {
3
+ if (!value || typeof value !== 'object') return value;
4
+ if (seen.has(value)) return seen.get(value);
5
+ const copy = Array.isArray(value) ? [] : {};
6
+ seen.set(value, copy);
7
+ for (const key of Object.keys(value)) {
8
+ Object.defineProperty(copy, key, {
9
+ value: immutableCopy(value[key], seen),
10
+ enumerable: true,
11
+ configurable: true,
12
+ writable: true,
13
+ });
14
+ }
15
+ return Object.freeze(copy);
16
+ }
17
+
18
+ const EMPTY_SELECTION = Object.freeze({ selection: null, selectedElement: null, trace: null });
19
+
20
+ /** Resolve current facts rather than retaining a clicked node or an old Runtime snapshot. */
21
+ export function createPanelSelection(studio, viewer, previousTrace = null) {
22
+ if (!viewer) {
23
+ const selection = studio.selection;
24
+ const selectedElement = studio.getSelectedElement();
25
+ if (!selection || !selectedElement) return EMPTY_SELECTION;
26
+ return immutableCopy({ selection, selectedElement, trace: null });
27
+ }
28
+
29
+ // An explicit clear must stay cleared across refreshes, even when the Viewer still
30
+ // has an older visual selection (for example after a selected visit disappears).
31
+ if (!previousTrace) return EMPTY_SELECTION;
32
+ const current = viewer._resolveTraceClick(previousTrace);
33
+ if (!current) return EMPTY_SELECTION;
34
+ const { viewer: liveViewer, originalEvent: _originalEvent, ...traceData } = current;
35
+ const selection = current.element
36
+ ? { kind: current.targetType === 'edge' ? 'edge' : 'node', id: current.element.id }
37
+ : null;
38
+ const snapshot = immutableCopy({ selection, selectedElement: current.element || null, trace: traceData });
39
+ return Object.freeze({
40
+ ...snapshot,
41
+ trace: Object.freeze({ ...snapshot.trace, viewer: liveViewer }),
42
+ });
43
+ }
44
+
45
+ /** Ignore fresh object identity so refreshes and paired timeline callbacks notify only once. */
46
+ export function samePanelSelection(previous, next) {
47
+ const pairs = new WeakMap();
48
+ const same = (left, right) => {
49
+ if (Object.is(left, right)) return true;
50
+ if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false;
51
+ if (pairs.has(left)) return pairs.get(left) === right;
52
+ pairs.set(left, right);
53
+ const keys = Object.keys(left);
54
+ if (keys.length !== Object.keys(right).length) return false;
55
+ return keys.every((key) => Object.hasOwn(right, key) && (key === 'viewer'
56
+ ? left[key] === right[key]
57
+ : same(left[key], right[key])));
58
+ };
59
+ return same(previous, next);
60
+ }