@bpmn-nova/studio 0.3.4-preview → 0.3.6-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.
@@ -0,0 +1,167 @@
1
+ import { parseRuntimeInstant, compareRuntimeOrder, runtimeInstant } from './time.js';
2
+ import { pushDiagnostic } from './diagnostics.js';
3
+
4
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'skipped']);
5
+
6
+ export function resolveVisitStatus(records = []) {
7
+ if (!records.length) return 'idle';
8
+ if (records.some((item) => item.status === 'failed')) return 'failed';
9
+ if (records.some((item) => item.status === 'active')) return 'active';
10
+ if (records.every((item) => item.status === 'skipped')) return 'skipped';
11
+ if (records.some((item) => item.status === 'cancelled') && !records.some((item) => item.status === 'completed')) return 'cancelled';
12
+ return 'completed';
13
+ }
14
+
15
+ export function resolveEffectiveStatus(visits = []) {
16
+ const records = visits.flatMap((visit) => visit.records || []);
17
+ if (!records.length) return 'idle';
18
+ if (records.some((item) => item.status === 'active')) return 'active';
19
+ if (records.some((item) => item.status === 'failed')) return 'failed';
20
+ if (records.some((item) => item.status === 'completed')) return 'completed';
21
+ if (records.some((item) => item.status === 'cancelled') && !records.some((item) => item.status === 'completed')) return 'cancelled';
22
+ if (records.every((item) => item.status === 'skipped')) return 'skipped';
23
+ return resolveVisitStatus(records);
24
+ }
25
+
26
+ export function groupVisits(records = []) {
27
+ const groups = new Map();
28
+ records.forEach((record, index) => {
29
+ const key = record.visitId
30
+ ? `visit:${record.visitId}`
31
+ : record.multiInstanceId
32
+ ? `multi:${record.multiInstanceId}`
33
+ : `record:${record.id || index}`;
34
+ if (!groups.has(key)) {
35
+ groups.set(key, {
36
+ id: record.visitId || record.multiInstanceId || record.id || String(index),
37
+ records: [],
38
+ index,
39
+ });
40
+ }
41
+ groups.get(key).records.push(record);
42
+ });
43
+ return [...groups.values()]
44
+ .sort((left, right) => compareRuntimeOrder(visitOrderKey(left), visitOrderKey(right)))
45
+ .map((visit, index) => ({ id: visit.id, records: visit.records, round: index + 1, index: visit.index }));
46
+ }
47
+
48
+ export function markEffectiveVisits(visits = [], { elementId, transitions = [], diagnostics = null } = {}) {
49
+ if (visits.length <= 1) {
50
+ return visits.map((visit) => ({ ...visit, effective: true, superseded: false }));
51
+ }
52
+ const ranges = visits.map((visit, index) => ({ visit, index, ...visitRange(visit) }));
53
+ const superseded = new Set();
54
+
55
+ for (const visit of visits) {
56
+ for (const record of visit.records) {
57
+ const target = record.supersedesVisitId;
58
+ if (!target) continue;
59
+ if (visits.some((candidate) => candidate.id === target)) superseded.add(target);
60
+ else {
61
+ pushDiagnostic(diagnostics, {
62
+ code: 'unknown-superseded-visit',
63
+ level: 'warning',
64
+ message: 'supersedesVisitId 未匹配到同节点 Visit。',
65
+ fieldPath: `activities.supersedesVisitId`,
66
+ elementId,
67
+ activityId: record.id,
68
+ visitId: visit.id,
69
+ });
70
+ }
71
+ }
72
+ }
73
+
74
+ const inbound = transitions.filter((transition) => ['reject', 'return'].includes(transition.type) && transition.targetElementId === elementId);
75
+ const timed = ranges.some((range) => range.start != null || range.end != null);
76
+
77
+ for (let earlierIndex = 0; earlierIndex < ranges.length; earlierIndex += 1) {
78
+ for (let laterIndex = earlierIndex + 1; laterIndex < ranges.length; laterIndex += 1) {
79
+ const earlier = ranges[earlierIndex];
80
+ const later = ranges[laterIndex];
81
+ if (superseded.has(later.visit.id)) continue;
82
+ const sequential = isSequentialReplacement(earlier, later, inbound);
83
+ const overlapping = isOverlappingOpen(earlier, later);
84
+ if (overlapping && !sequential) {
85
+ pushDiagnostic(diagnostics, {
86
+ code: 'concurrent-visits',
87
+ level: 'info',
88
+ message: '同一节点存在时间重叠且未声明替代关系的独立 Visit。',
89
+ fieldPath: 'activities.visitId',
90
+ elementId,
91
+ visitId: earlier.visit.id,
92
+ });
93
+ continue;
94
+ }
95
+ if (sequential) superseded.add(earlier.visit.id);
96
+ }
97
+ }
98
+
99
+ if (!timed) {
100
+ const activeCount = ranges.filter((range) => range.status === 'active').length;
101
+ if (activeCount <= 1) {
102
+ visits.slice(0, -1).forEach((visit) => superseded.add(visit.id));
103
+ } else {
104
+ pushDiagnostic(diagnostics, {
105
+ code: 'ambiguous-visit-order',
106
+ level: 'warning',
107
+ message: '多个 Visit 缺少可比较时间,未将历史 active 解释为先后重办。',
108
+ fieldPath: 'activities.startTime',
109
+ elementId,
110
+ });
111
+ }
112
+ }
113
+
114
+ return visits.map((visit) => {
115
+ const isSuperseded = superseded.has(visit.id);
116
+ return { ...visit, effective: !isSuperseded, superseded: isSuperseded };
117
+ });
118
+ }
119
+
120
+ function visitOrderKey(visit) {
121
+ let instant = null;
122
+ visit.records.forEach((record) => {
123
+ const start = runtimeInstant(record.startTime);
124
+ const end = runtimeInstant(record.endTime);
125
+ const candidate = start ?? end;
126
+ if (candidate == null) return;
127
+ instant = instant == null ? candidate : Math.min(instant, candidate);
128
+ });
129
+ return { instant, index: visit.index || 0 };
130
+ }
131
+
132
+ function visitRange(visit) {
133
+ let start = null;
134
+ let end = null;
135
+ let open = false;
136
+ for (const record of visit.records) {
137
+ const started = parseRuntimeInstant(record.startTime).instant;
138
+ const ended = parseRuntimeInstant(record.endTime).instant;
139
+ if (started != null) start = start == null ? started : Math.min(start, started);
140
+ if (ended != null) end = end == null ? ended : Math.max(end, ended);
141
+ if (record.status === 'active' && ended == null) open = true;
142
+ }
143
+ const status = resolveVisitStatus(visit.records);
144
+ if (status === 'active') open = true;
145
+ return { start, end, open, status };
146
+ }
147
+
148
+ function isOverlappingOpen(earlier, later) {
149
+ if (!earlier.open) return false;
150
+ if (later.start == null) return earlier.open && later.open;
151
+ if (earlier.end == null) return true;
152
+ return later.start < earlier.end;
153
+ }
154
+
155
+ function isSequentialReplacement(earlier, later, inbound) {
156
+ if (!TERMINAL.has(earlier.status) && !earlier.open) return false;
157
+ if (earlier.open && !TERMINAL.has(earlier.status)) return false;
158
+ if (earlier.end != null && later.start != null && later.start >= earlier.end) return true;
159
+ if (TERMINAL.has(earlier.status) && !earlier.open && later.start != null && earlier.start != null && later.start > earlier.start) return true;
160
+ const laterInstant = later.start ?? later.end;
161
+ return inbound.some((transition) => {
162
+ const at = runtimeInstant(transition.occurredAt || transition.time);
163
+ if (at == null || laterInstant == null) return inbound.length > 0 && TERMINAL.has(earlier.status);
164
+ const afterEarlier = earlier.end == null || at >= earlier.end || (earlier.start != null && at >= earlier.start);
165
+ return afterEarlier && at <= laterInstant;
166
+ });
167
+ }
@@ -248,7 +248,7 @@ export class BpmnViewer {
248
248
  setModel(model: ProcessModel): void
249
249
  setRuntime(runtime: ProcessInstanceSnapshot | null): void
250
250
  refreshPresentation(): void
251
- setDisplayOptions(options: { timeline?: ViewerTimelineOptions; runtimeDetails?: RuntimeDetailsOptions; runtimeTraceOptions?: RuntimeTraceProjectionOptions; runtimeAssetResolver?: RuntimeAssetResolver | null }): void
251
+ setDisplayOptions(options: { timeline?: ViewerTimelineOptions; runtimeDetails?: RuntimeDetailsOptions; runtimeTraceOptions?: RuntimeTraceProjectionOptions; runtimeAssetResolver?: RuntimeAssetResolver | null; replace?: boolean }): void
252
252
  setProjection(projection: ViewerProjection): void
253
253
  setTheme(theme: NovaThemeInput): NovaThemeState
254
254
  setThemeMode(mode: NovaThemeMode): NovaThemeState
@@ -1,7 +1,7 @@
1
1
  import { cloneModel, createEdge, getScopeGraph, NODE_DEFINITIONS } from '../core/index.js';
2
2
  import { createDefaultIconRegistry, createIconElement } from '../icons/index.js';
3
3
  import { DiagramRenderer } from '../renderer-svg/index.js';
4
- import { createRuntimePresentation, normalizeRuntime } from '../runtime/index.js';
4
+ import { compareRuntimeOrder, createRuntimePresentation, formatRuntimeInstant, normalizeRuntime, parseRuntimeInstant } from '../runtime/index.js';
5
5
  import { createRuntimeTraceProjection } from './runtime-trace.js';
6
6
  import { renderDefaultRuntimeTimeline, RuntimeTimelineHost } from './timeline.js';
7
7
  import { renderRuntimeApprovalContent } from './runtime-content.js';
@@ -51,7 +51,7 @@ function renderRuntimeAction({ container, viewer, action, resolveAsset, compact
51
51
  element('em', '', appearance.label),
52
52
  );
53
53
  header.appendChild(heading);
54
- if (action.occurredAt) header.appendChild(element('time', '', action.occurredAt));
54
+ if (action.occurredAt) header.appendChild(element('time', '', formatRuntimeInstant(action.occurredAt)));
55
55
  article.appendChild(header);
56
56
  if (action.targets?.length) article.appendChild(element('div', 'mb-runtime-action-targets', `目标:${action.targets.map((target) => target.name).join('、')}`));
57
57
  if (action.targetElementId && !action.targets?.length) article.appendChild(element('div', 'mb-runtime-action-targets', `目标节点:${action.targetElementId}`));
@@ -104,7 +104,7 @@ export function renderDefaultRuntimeDetails({ container, viewer, node, presentat
104
104
  applyRuntimeTone(recordStatus, viewer?.runtimeAppearance?.resolveStatus(record.status));
105
105
  title.append(element('strong', '', record.participant?.name || record.assignee || '待分配审批人'), recordStatus);
106
106
  copy.appendChild(title);
107
- const time = [record.startTime, record.endTime].filter(Boolean).join(' — ');
107
+ const time = [record.startTime, record.endTime].map((value) => formatRuntimeInstant(value)).filter(Boolean).join(' — ');
108
108
  if (time) copy.appendChild(element('time', '', time));
109
109
  row.append(avatar, copy);
110
110
  body.appendChild(row);
@@ -113,7 +113,10 @@ export function renderDefaultRuntimeDetails({ container, viewer, node, presentat
113
113
  const renderedActionIds = new Set();
114
114
  if (!visits.length) body.appendChild(element('div', 'mb-runtime-details-empty', presentation.summary || presentation.statusLabel));
115
115
  visits.forEach((visit) => {
116
- if (presentation.visits.length > 1) body.appendChild(element('div', 'mb-runtime-details-round', `第 ${visit.round} 次处理`));
116
+ if (presentation.visits.length > 1) {
117
+ const historical = visit.effective === false || visit.superseded;
118
+ body.appendChild(element('div', 'mb-runtime-details-round', historical ? `第 ${visit.round} 次处理 · 历史` : `第 ${visit.round} 次处理`));
119
+ }
117
120
  [...visit.records].reverse().forEach(appendRecord);
118
121
  [...visit.actions].reverse().forEach((action) => {
119
122
  renderRuntimeAction({ container: body, viewer, action, resolveAsset });
@@ -130,7 +133,7 @@ export function renderDefaultRuntimeDetails({ container, viewer, node, presentat
130
133
  const block = element('article', `mb-runtime-details-transition type-${transition.type}`);
131
134
  applyRuntimeTone(block, viewer?.runtimeAppearance?.resolveTransition(transition).tone);
132
135
  block.appendChild(element('strong', '', transition.type === 'reject' ? `驳回至 ${transition.targetName || transition.targetElementId}` : `退回至 ${transition.targetName || transition.targetElementId}`));
133
- const meta = [transition.operator, transition.occurredAt || transition.time].filter(Boolean).join(' · ');
136
+ const meta = [transition.operator, formatRuntimeInstant(transition.occurredAt || transition.time)].filter(Boolean).join(' · ');
134
137
  if (meta) block.appendChild(element('span', '', meta));
135
138
  if (transition.action && !renderedActionIds.has(transition.action.id)) {
136
139
  renderRuntimeApprovalContent({
@@ -189,7 +192,7 @@ export function renderDefaultRuntimeTransitionDetails({
189
192
  actorStatus,
190
193
  );
191
194
  actorCopy.appendChild(actorTitle);
192
- const time = transition.action?.occurredAt || transition.occurredAt || transition.time;
195
+ const time = formatRuntimeInstant(transition.action?.occurredAt || transition.occurredAt || transition.time);
193
196
  if (time) actorCopy.appendChild(element('time', '', time));
194
197
  actor.append(avatar, actorCopy);
195
198
  body.appendChild(actor);
@@ -312,7 +315,10 @@ function projectRuntime(runtime, projectedModel) {
312
315
  .map((id) => normalized.edgeVisits?.filter((visit) => visit.edgeId === id).at(-1))
313
316
  .filter(Boolean);
314
317
  if (visits.length === edge.sourceEdgeIds.length) {
315
- const latest = visits.sort((a, b) => String(a.occurredAt || a.time || '').localeCompare(String(b.occurredAt || b.time || ''))).at(-1);
318
+ const latest = visits
319
+ .map((visit, index) => ({ visit, index, instant: parseRuntimeInstant(visit.occurredAt || visit.time).instant }))
320
+ .sort((left, right) => compareRuntimeOrder(left, right))
321
+ .at(-1)?.visit;
316
322
  projectedEdgeVisits.push({ ...latest, id: `ProjectedVisit_${edge.id}`, edgeId: edge.id });
317
323
  }
318
324
  }
@@ -394,6 +400,12 @@ export class BpmnViewer {
394
400
  this.projectedRuntime = null;
395
401
  this.runtimePresentation = null;
396
402
  this.selection = null;
403
+ this._sidebarTransitionActive = false;
404
+ this._sidebarViewport = null;
405
+ this._lastViewport = null;
406
+ this._timelineScroll = 0;
407
+ this._silentViewport = false;
408
+ this._pendingFit = false;
397
409
  this._detailsCleanup = null;
398
410
  this._detailsMotion = null;
399
411
  this._detailsRoot = null;
@@ -425,9 +437,11 @@ export class BpmnViewer {
425
437
  }
426
438
  this._resizeObserver = typeof ResizeObserver === 'function'
427
439
  ? new ResizeObserver(() => {
428
- if (this.projection !== 'auto') return;
429
- const next = this._resolveProjection();
430
- if (next !== this.activeProjection) this.refresh();
440
+ this._refreshAutoProjection();
441
+ if (this._pendingFit && this._containerHasSize()) {
442
+ this._pendingFit = false;
443
+ this.renderer?.fitView?.();
444
+ }
431
445
  })
432
446
  : null;
433
447
  this._resizeObserver?.observe(options.container);
@@ -436,10 +450,33 @@ export class BpmnViewer {
436
450
  _resolveProjection() {
437
451
  if (this.projection !== 'auto') return this.projection;
438
452
  if (!this.runtime) return 'standard';
453
+ if (this._sidebarTransitionActive) return this.activeProjection;
439
454
  const width = this.container.clientWidth || this.container.getBoundingClientRect?.().width || 0;
440
455
  return width > 0 && width < 720 ? 'compact' : 'approval';
441
456
  }
442
457
 
458
+ // The Shell owns the transition. Do not replace the renderer at intermediate widths.
459
+ _setSidebarTransitionActive(active) {
460
+ if (this._sidebarTransitionActive === Boolean(active)) return;
461
+ this._sidebarTransitionActive = Boolean(active);
462
+ if (active) {
463
+ if (this.activeProjection !== 'compact') this._sidebarViewport = this.renderer?.getViewportState?.() || this._sidebarViewport;
464
+ } else this._refreshAutoProjection();
465
+ }
466
+
467
+ _refreshAutoProjection() {
468
+ if (this._sidebarTransitionActive || this.projection !== 'auto') return;
469
+ const next = this._resolveProjection();
470
+ if (next === this.activeProjection) return;
471
+ if (this.activeProjection !== 'compact') this._sidebarViewport = this.renderer?.getViewportState?.() || this._sidebarViewport;
472
+ this.refresh({ preserveViewport: this.activeProjection !== 'compact' });
473
+ if (this.activeProjection !== 'compact' && this._sidebarViewport) {
474
+ this._silentViewport = true;
475
+ try { this.renderer?.setViewportState?.(this._sidebarViewport); }
476
+ finally { this._silentViewport = false; }
477
+ }
478
+ }
479
+
443
480
  _rebuildProjection() {
444
481
  const previousProjection = this.activeProjection;
445
482
  this.activeProjection = this._resolveProjection();
@@ -494,33 +531,59 @@ export class BpmnViewer {
494
531
  return [];
495
532
  }
496
533
 
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];
534
+ _resolveTraceClick(payload) {
535
+ if (!payload) return null;
536
+ const targetType = payload.targetType;
537
+ const transitionId = payload.transition?.id || payload.traceItem?.transitionId;
538
+ const transition = targetType === 'transition' && transitionId
539
+ ? this.runtimePresentation.getTransition(transitionId)
540
+ : null;
541
+ if (targetType === 'transition' && !transition) return null;
542
+ const elementId = transition?.sourceElementId || payload.elementId || payload.element?.id || payload.traceItem?.elementId;
543
+ let selectedElement;
544
+ if (targetType === 'edge') {
545
+ const current = this.projectedModel.edges.find((edge) => edge.id === elementId)
546
+ || this.model.edges.find((edge) => edge.id === elementId);
547
+ if (!current) return null;
548
+ const sourceIds = current.sourceEdgeIds?.length ? current.sourceEdgeIds : [current.id];
501
549
  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,
550
+ selectedElement = sourceEdges.find((edge) => current.name && edge.name === current.name) || sourceEdges[0] || current;
551
+ } else selectedElement = this.model.nodes.find((node) => node.id === elementId) || null;
552
+ if (!selectedElement && !transition) return null;
553
+ const presentation = selectedElement?.id && targetType !== 'edge'
554
+ ? this.runtimePresentation.getNode(selectedElement.id)
555
+ : null;
556
+ const visitId = payload.visitId ?? payload.traceItem?.visitId ?? null;
557
+ const traceItems = this.traceProjection?.items || [];
558
+ const traceItem = traceItems.find((item) => payload.traceItem?.id && item.id === payload.traceItem.id)
559
+ || (targetType === 'transition' ? traceItems.find((item) => item.transitionId === transitionId) : null)
560
+ || (visitId ? traceItems.find((item) => item.elementId === elementId && item.visitId === visitId) : null)
561
+ || null;
562
+ const visit = visitId ? presentation?.visits?.find((item) => item.id === visitId) : null;
563
+ if (targetType === 'visit' && visitId && !traceItem && !visit) return null;
564
+ const records = traceItem?.records || visit?.records;
565
+ return {
566
+ targetType,
567
+ elementId: selectedElement?.id || elementId,
568
+ element: selectedElement,
569
+ visitId,
570
+ traceItem,
571
+ transition,
516
572
  presentation,
517
- activityInstances: this._activityInstancesFor({ traceItem: payload.traceItem, presentation }),
518
- actions: payload.traceItem?.actions || (payload.transition?.action ? [payload.transition.action] : presentation?.actions) || [],
573
+ activityInstances: records ? [...records] : this._activityInstancesFor({ presentation }),
574
+ actions: traceItem?.actions || visit?.actions || (transition?.action ? [transition.action] : presentation?.actions) || [],
519
575
  runtime: this.runtime,
520
576
  projection: this.activeProjection,
521
577
  originalEvent: payload.originalEvent,
522
578
  viewer: this,
523
- });
579
+ };
580
+ }
581
+
582
+ _emitTraceClick(payload) {
583
+ const resolved = this._resolveTraceClick(payload);
584
+ if (!resolved) return;
585
+ this.options._onPanelSelection?.(resolved);
586
+ this.onTraceClick(resolved);
524
587
  }
525
588
 
526
589
  _mountRenderer() {
@@ -554,7 +617,7 @@ export class BpmnViewer {
554
617
  onDetailsRequest: (item, anchor, event) => {
555
618
  const node = this.model.nodes.find((candidate) => candidate.id === item.elementId);
556
619
  if (!node) return;
557
- this._select('node', node, event, { emitTrace: false });
620
+ this._select('node', node, event, { emitTrace: false, traceItem: item });
558
621
  const presentation = this.runtimePresentation.getNode(node.id);
559
622
  if (presentation.hasDetails && this.runtimeDetailsOptions.autoOpen) {
560
623
  this._openRuntimeDetails(node, presentation, anchor, {
@@ -564,6 +627,7 @@ export class BpmnViewer {
564
627
  },
565
628
  onTransitionRequest: (item, anchor, event) => {
566
629
  const transition = this.runtimePresentation.getTransition(item.transitionId);
630
+ this.options._onPanelSelection?.(this._resolveTraceClick({ targetType: 'transition', transition, traceItem: item, originalEvent: event }));
567
631
  if (this.runtimeDetailsOptions.autoOpen) {
568
632
  this._openRuntimeTransitionDetails(transition, anchor, {
569
633
  restoreFocusVisible: event ? event.detail === 0 : Boolean(anchor?.matches?.(':focus-visible')),
@@ -598,21 +662,27 @@ export class BpmnViewer {
598
662
  this._emitTraceClick({ targetType: 'transition', element, transition, originalEvent: event });
599
663
  if (this.runtimeDetailsOptions.autoOpen) this.openRuntimeTransitionDetails(transition, event.currentTarget);
600
664
  },
601
- onViewportChange: (viewport) => { this._disposeRuntimeDetails(); this.onViewportChange(viewport); },
665
+ onViewportChange: (viewport) => {
666
+ if (viewport) this._lastViewport = { zoom: viewport.zoom, pan: { ...viewport.pan } };
667
+ if (!this._silentViewport) this._disposeRuntimeDetails();
668
+ this.onViewportChange(viewport);
669
+ },
602
670
  });
603
671
  }
604
672
 
605
- _select(kind, element, originalEvent = undefined, { emitTrace = true } = {}) {
673
+ _select(kind, element, originalEvent = undefined, { emitTrace = true, traceItem = null } = {}) {
606
674
  this.selection = { kind, id: element.id };
607
675
  this.renderer.setSelection(this.selection);
608
676
  const presentation = kind === 'node' ? this.runtimePresentation.getNode(element.id) : null;
609
677
  this.onElementClick({ kind, element, runtime: this.runtime, presentation });
610
678
  if (emitTrace) this._emitTraceClick({ targetType: kind, element, presentation, originalEvent });
679
+ else this.options._onPanelSelection?.(this._resolveTraceClick({ targetType: traceItem ? 'visit' : kind, element, presentation, traceItem, originalEvent }));
611
680
  }
612
681
 
613
682
  clearSelection() {
614
683
  this.selection = null;
615
684
  this.renderer.setSelection(null);
685
+ this.options._onPanelSelection?.(null);
616
686
  this.onElementClick(null);
617
687
  }
618
688
 
@@ -918,46 +988,144 @@ export class BpmnViewer {
918
988
  });
919
989
  }
920
990
 
921
- refresh() {
991
+ _rendererKind() {
992
+ if (!this.renderer) return null;
993
+ return this.renderer instanceof RuntimeTimelineHost ? 'timeline' : 'diagram';
994
+ }
995
+
996
+ _containerHasSize() {
997
+ if (typeof document !== 'undefined' && document.hidden) return false;
998
+ const width = this.container.clientWidth || this.container.getBoundingClientRect?.().width || 0;
999
+ const height = this.container.clientHeight || this.container.getBoundingClientRect?.().height || 0;
1000
+ return width >= 1 && height >= 1;
1001
+ }
1002
+
1003
+ _capturePresentationState() {
1004
+ const kind = this._rendererKind();
1005
+ if (kind === 'diagram') {
1006
+ const viewport = this.renderer?.getViewportState?.();
1007
+ if (viewport) this._lastViewport = viewport;
1008
+ } else if (kind === 'timeline') {
1009
+ this._timelineScroll = this.container.scrollTop || this._timelineScroll || 0;
1010
+ }
1011
+ return {
1012
+ kind,
1013
+ details: this._detailsTarget ? { ...this._detailsTarget, anchor: this._detailsAnchor } : null,
1014
+ };
1015
+ }
1016
+
1017
+ _syncDiagramRenderer() {
1018
+ this.renderer.options.visualModel = this.model;
1019
+ this.renderer.options.runtimePresenter = () => this.runtimePresentation;
1020
+ this.renderer.options.runtimeAppearance = this.runtimeAppearance;
1021
+ this.renderer.mode = this.mode;
1022
+ this.renderer.model = this.projectedModel;
1023
+ this.renderer.runtime = this.projectedRuntime;
1024
+ this.renderer.render();
1025
+ }
1026
+
1027
+ _syncTimelineRenderer() {
1028
+ Object.assign(this.renderer.options, {
1029
+ model: this.projectedModel,
1030
+ runtime: this.projectedRuntime,
1031
+ presentation: this.runtimePresentation,
1032
+ projection: this.traceProjection,
1033
+ ...this._resolveTimelineOptions(),
1034
+ appearance: this.runtimeAppearance,
1035
+ themeState: this.themeController.getState(),
1036
+ });
1037
+ this.renderer.render();
1038
+ }
1039
+
1040
+ _pruneSelection() {
1041
+ if (!this.selection) return;
1042
+ const collection = this.selection.kind === 'edge' ? this.projectedModel?.edges : this.projectedModel?.nodes;
1043
+ if (!collection?.some((item) => item.id === this.selection.id)) this.selection = null;
1044
+ }
1045
+
1046
+ _restoreRuntimeDetails(target) {
1047
+ if (!target) return;
1048
+ if (target.kind === 'node') {
1049
+ const node = this.model.nodes.find((item) => item.id === target.id);
1050
+ const presentation = node ? this.runtimePresentation.getNode(node.id) : null;
1051
+ if (node && presentation?.hasDetails) this.openRuntimeDetails(node, presentation, target.anchor || null);
1052
+ return;
1053
+ }
1054
+ if (target.kind === 'transition') {
1055
+ const transition = this.runtimePresentation.getTransition(target.id);
1056
+ if (transition) this._openRuntimeTransitionDetails(transition, target.anchor || null);
1057
+ }
1058
+ }
1059
+
1060
+ refresh(options = {}) {
1061
+ const preserveViewport = options.preserveViewport === true;
1062
+ const previous = this._capturePresentationState();
922
1063
  this._disposeRuntimeDetails();
923
1064
  this._rebuildProjection();
924
- this._mountRenderer();
1065
+ const kind = this.runtime && this.activeProjection === 'compact' && this.traceProjection ? 'timeline' : 'diagram';
1066
+ const sameKind = previous.kind === kind && this.renderer;
1067
+ if (sameKind && kind === 'diagram') this._syncDiagramRenderer();
1068
+ else if (sameKind && kind === 'timeline') this._syncTimelineRenderer();
1069
+ else this._mountRenderer();
1070
+ this._pruneSelection();
925
1071
  if (this.selection) this.renderer.setSelection?.(this.selection);
1072
+ else this.renderer.setSelection?.(null);
1073
+ this.options._onPanelSelection?.();
1074
+ this._silentViewport = true;
1075
+ try {
1076
+ if (kind === 'diagram' && preserveViewport && this._lastViewport) this.renderer.setViewportState?.(this._lastViewport);
1077
+ if (kind === 'timeline' && preserveViewport) this.container.scrollTop = this._timelineScroll || 0;
1078
+ } finally {
1079
+ this._silentViewport = false;
1080
+ }
1081
+ this._restoreRuntimeDetails(previous.details);
926
1082
  }
927
1083
 
928
- setModel(model) { this.model = model; this._svgExportAssetCache.clear(); this.refresh(); }
1084
+ setModel(model) {
1085
+ const same = this.model === model || Boolean(this.model?.id && model?.id && this.model.id === model.id);
1086
+ this.model = model;
1087
+ this._svgExportAssetCache.clear();
1088
+ this.refresh({ preserveViewport: same });
1089
+ }
929
1090
  setRuntime(runtime) {
930
- this.runtime = runtime ? normalizeRuntime(runtime) : null;
1091
+ const next = runtime ? normalizeRuntime(runtime) : null;
1092
+ const sameInstance = Boolean(this.runtime) && Boolean(next) && (this.runtime.processInstanceId || '') === (next.processInstanceId || '');
1093
+ this.runtime = next;
931
1094
  this.mode = this.options.mode === 'instance' || this.runtime ? 'instance' : 'viewer';
932
1095
  this._svgExportAssetCache.clear();
933
- this.refresh();
1096
+ this.refresh({ preserveViewport: sameInstance });
934
1097
  }
935
1098
  setProjection(projection) {
936
1099
  if (!['auto', 'standard', 'approval', 'compact'].includes(projection)) return;
937
1100
  this.projection = projection;
938
- this.refresh();
939
- requestAnimationFrame(() => this.renderer.fitView());
1101
+ this.refresh({ preserveViewport: false });
1102
+ requestAnimationFrame(() => {
1103
+ if (this._containerHasSize()) this.renderer?.fitView?.();
1104
+ else this._pendingFit = true;
1105
+ });
940
1106
  }
941
- setDisplayOptions({ timeline, runtimeDetails, runtimeTraceOptions, runtimeAssetResolver } = {}) {
1107
+ setDisplayOptions({ timeline, runtimeDetails, runtimeTraceOptions, runtimeAssetResolver, replace = false } = {}) {
942
1108
  let changed = false;
943
1109
  if (runtimeAssetResolver !== undefined) {
944
1110
  this.runtimeAssetResolver = runtimeAssetResolver || null;
945
1111
  this._svgExportAssetCache.clear();
946
1112
  changed = true;
947
1113
  }
948
- if (runtimeTraceOptions !== undefined) {
949
- this.runtimeTraceOptions = { ...this.runtimeTraceOptions, ...(runtimeTraceOptions || {}) };
1114
+ if (replace || runtimeTraceOptions !== undefined) {
1115
+ this.runtimeTraceOptions = replace
1116
+ ? { ...(runtimeTraceOptions || {}) }
1117
+ : { ...this.runtimeTraceOptions, ...(runtimeTraceOptions || {}) };
950
1118
  changed = true;
951
1119
  }
952
- if (timeline !== undefined) {
953
- this.timelineOptions = mergeTimelineOptions(this.timelineOptions, timeline || {});
1120
+ if (replace || timeline !== undefined) {
1121
+ this.timelineOptions = mergeTimelineOptions(replace ? {} : this.timelineOptions, timeline || {});
954
1122
  changed = true;
955
1123
  }
956
- if (runtimeDetails !== undefined) {
957
- this.runtimeDetailsOptions = mergeRuntimeDetailsOptions(this.runtimeDetailsOptions, runtimeDetails || {});
1124
+ if (replace || runtimeDetails !== undefined) {
1125
+ this.runtimeDetailsOptions = mergeRuntimeDetailsOptions(replace ? {} : this.runtimeDetailsOptions, runtimeDetails || {});
958
1126
  changed = true;
959
1127
  }
960
- if (changed) this.refresh();
1128
+ if (changed) this.refresh({ preserveViewport: true });
961
1129
  }
962
1130
  setTheme(theme) { return this.themeController.setTheme(theme); }
963
1131
  setThemeMode(mode) { return this.themeController.setMode(mode); }
@@ -965,7 +1133,7 @@ export class BpmnViewer {
965
1133
  setRuntimeAppearance(runtimeAppearance) {
966
1134
  this.runtimeAppearanceOptions = runtimeAppearance || {};
967
1135
  this.runtimeAppearance = createRuntimeAppearance(this.runtimeAppearanceOptions);
968
- this.refresh();
1136
+ this.refresh({ preserveViewport: true });
969
1137
  }
970
1138
  refreshPresentation() {
971
1139
  if (this.mode !== 'viewer' || (this.runtime && this.activeProjection === 'compact')) return;
@@ -1013,6 +1181,8 @@ export class BpmnViewer {
1013
1181
  fitView() { this.renderer.fitView(); }
1014
1182
  zoomBy(delta) { this.renderer.zoomBy(delta); }
1015
1183
  destroy() {
1184
+ this._sidebarTransitionActive = false;
1185
+ this._sidebarViewport = null;
1016
1186
  this._svgExportPreview?.close?.({ immediate: true });
1017
1187
  this._svgExportPreview = null;
1018
1188
  this._svgExportAssetCache.clear();