@bpmn-nova/studio 0.3.0-preview → 0.3.2-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.
Files changed (63) hide show
  1. package/README.md +245 -20
  2. package/dist/canvas.js +7 -4
  3. package/dist/context-menu.js +2 -2
  4. package/dist/controller.js +84 -6
  5. package/dist/index.d.ts +138 -38
  6. package/dist/index.js +12 -11
  7. package/dist/interactions.js +1 -1
  8. package/dist/modules/bpmn-model/index.d.ts +11 -0
  9. package/dist/modules/bpmn-model/index.js +703 -0
  10. package/dist/modules/core/containment.js +590 -0
  11. package/dist/modules/core/gateway.js +72 -0
  12. package/dist/modules/core/history.js +32 -0
  13. package/dist/modules/core/index.d.ts +268 -0
  14. package/dist/modules/core/index.js +7 -0
  15. package/dist/modules/core/layout.js +644 -0
  16. package/dist/modules/core/model.js +287 -0
  17. package/dist/modules/core/runtime-transition-route.js +360 -0
  18. package/dist/modules/core/scope.js +99 -0
  19. package/dist/modules/designer/index.d.ts +79 -0
  20. package/dist/modules/designer/index.js +607 -0
  21. package/dist/modules/engine-activiti/index.d.ts +19 -0
  22. package/dist/modules/engine-activiti/index.js +160 -0
  23. package/dist/modules/engine-flowable/index.d.ts +19 -0
  24. package/dist/modules/engine-flowable/index.js +160 -0
  25. package/dist/modules/export-svg/index.d.ts +112 -0
  26. package/dist/modules/export-svg/index.js +2 -0
  27. package/dist/modules/export-svg/preview.js +327 -0
  28. package/dist/modules/export-svg/render.js +718 -0
  29. package/dist/modules/icons/index.d.ts +24 -0
  30. package/dist/modules/icons/index.js +264 -0
  31. package/dist/modules/palette/index.d.ts +74 -0
  32. package/dist/modules/palette/index.js +99 -0
  33. package/dist/modules/palette/panel.js +99 -0
  34. package/dist/modules/properties/index.d.ts +20 -0
  35. package/dist/modules/properties/index.js +19 -0
  36. package/dist/modules/properties-activiti/index.d.ts +3 -0
  37. package/dist/modules/properties-activiti/index.js +97 -0
  38. package/dist/modules/properties-bpmn/index.d.ts +3 -0
  39. package/dist/modules/properties-bpmn/index.js +518 -0
  40. package/dist/modules/properties-core/index.d.ts +124 -0
  41. package/dist/modules/properties-core/index.js +312 -0
  42. package/dist/modules/properties-flowable/index.d.ts +3 -0
  43. package/dist/modules/properties-flowable/index.js +114 -0
  44. package/dist/modules/properties-renderer/index.d.ts +25 -0
  45. package/dist/modules/properties-renderer/index.js +491 -0
  46. package/dist/modules/renderer-svg/index.d.ts +118 -0
  47. package/dist/modules/renderer-svg/index.js +1460 -0
  48. package/dist/modules/runtime/index.d.ts +169 -0
  49. package/dist/modules/runtime/index.js +535 -0
  50. package/dist/modules/theme/index.d.ts +95 -0
  51. package/dist/modules/theme/index.js +368 -0
  52. package/dist/modules/viewer/index.d.ts +265 -0
  53. package/dist/modules/viewer/index.js +1011 -0
  54. package/dist/modules/viewer/runtime-content.js +123 -0
  55. package/dist/modules/viewer/runtime-details-motion.js +228 -0
  56. package/dist/modules/viewer/runtime-trace.js +574 -0
  57. package/dist/modules/viewer/timeline.js +276 -0
  58. package/dist/selection-layout.js +1 -1
  59. package/dist/shell.js +210 -26
  60. package/dist/styles.css +116 -7
  61. package/llms-full.txt +3082 -0
  62. package/llms.txt +225 -0
  63. package/package.json +39 -16
@@ -0,0 +1,491 @@
1
+ import { NODE_DEFINITIONS } from '../core/index.js';
2
+ import { normalizeOptions } from '../properties-core/index.js';
3
+ import { createDefaultIconRegistry, hydrateIcons, iconIdForNode } from '../icons/index.js';
4
+ import { ThemeController } from '../theme/index.js';
5
+
6
+ const escapeHtml = (value = '') => String(value)
7
+ .replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
8
+ .replaceAll('"', '&quot;').replaceAll("'", '&#039;');
9
+
10
+ function valueText(value) {
11
+ if (Array.isArray(value)) return value.join(', ');
12
+ if (value === true) return 'true';
13
+ if (value === false) return 'false';
14
+ return value ?? '';
15
+ }
16
+
17
+ function optionHtml(options, value) {
18
+ return normalizeOptions(options).map((option) => `<option value="${escapeHtml(option.value)}" ${String(option.value) === String(value ?? '') ? 'selected' : ''}>${escapeHtml(option.label)}</option>`).join('');
19
+ }
20
+
21
+ let selectSeed = 0;
22
+ function customSelectHtml(options, value, nativeAttrs, { label = '选择', compact = false, disabled = false } = {}) {
23
+ const normalized = normalizeOptions(options);
24
+ const selectedValue = String(value ?? '');
25
+ const selected = normalized.find((option) => String(option.value) === selectedValue);
26
+ const selectedLabel = selected?.label ?? normalized[0]?.label ?? '请选择';
27
+ const listboxId = `nova-property-select-${++selectSeed}`;
28
+ const optionList = normalized.map((option, index) => {
29
+ const isSelected = String(option.value) === selectedValue;
30
+ return `<button type="button" class="property-select-option ${isSelected ? 'is-selected' : ''}" role="option" aria-selected="${isSelected ? 'true' : 'false'}" tabindex="-1" data-property-select-option data-option-index="${index}" data-value="${escapeHtml(option.value)}"><span>${escapeHtml(option.label)}</span><span class="nova-icon nova-icon-sm property-select-check" data-icon="ui.check"></span></button>`;
31
+ }).join('');
32
+ return `<div class="property-select ${compact ? 'property-select-compact' : ''}" data-property-select>
33
+ <button type="button" class="property-select-trigger" data-property-select-trigger aria-label="${escapeHtml(`${label}:${selectedLabel}`)}" aria-haspopup="listbox" aria-expanded="false" aria-controls="${listboxId}" ${disabled ? 'disabled' : ''}><span class="property-select-value">${escapeHtml(selectedLabel)}</span><span class="nova-icon nova-icon-sm property-select-chevron" data-icon="ui.chevron"></span></button>
34
+ <div class="property-select-popover" id="${listboxId}" role="listbox" aria-label="${escapeHtml(label)}" hidden>${optionList || '<div class="property-select-empty">暂无选项</div>'}</div>
35
+ <select class="property-select-native" data-property-select-native tabindex="-1" aria-hidden="true" hidden ${nativeAttrs}>${optionHtml(normalized, value)}</select>
36
+ </div>`;
37
+ }
38
+
39
+ function resolveMaybe(value, context) {
40
+ return typeof value === 'function' ? value(context) : value;
41
+ }
42
+
43
+ function fieldShell(entry, control, error = null) {
44
+ const required = entry.required ? '<span class="property-required">*</span>' : '';
45
+ const note = entry.note ? `<div class="property-note">${escapeHtml(entry.note)}</div>` : '';
46
+ const validation = error ? `<div class="property-error">${escapeHtml(error)}</div>` : '';
47
+ const label = entry.label ? `<label class="property-label">${escapeHtml(entry.label)}${required}</label>` : '';
48
+ const variant = entry.variant ? ` property-variant-${escapeHtml(entry.variant)}` : '';
49
+ return `<div class="property-field property-type-${escapeHtml(entry.type || 'text')}${variant}" data-entry-id="${escapeHtml(entry.id)}">${label}${control}${note}${validation}</div>`;
50
+ }
51
+
52
+ function renderBasicEntry(entry, value, context) {
53
+ const attrs = `data-property-input="${escapeHtml(entry.id)}" ${entry.readonly ? 'disabled' : ''}`;
54
+ const placeholder = entry.placeholder ? `placeholder="${escapeHtml(entry.placeholder)}"` : '';
55
+ if (entry.type === 'readonly') return fieldShell(entry, `<div class="property-readonly">${escapeHtml(resolveMaybe(entry.getValue ? value : (entry.value ?? value), context) || '—')}</div>`);
56
+ if (entry.type === 'textarea' || entry.type === 'code' || entry.type === 'expression') {
57
+ const cls = entry.type === 'code' ? ' property-code' : entry.type === 'expression' ? ' property-expression' : '';
58
+ return fieldShell(entry, `<textarea class="property-control${cls}" rows="${entry.rows || (entry.type === 'code' ? 8 : 4)}" ${attrs} ${placeholder}>${escapeHtml(valueText(value))}</textarea>`);
59
+ }
60
+ if (entry.type === 'select') {
61
+ const options = resolveMaybe(entry.options, context) || [];
62
+ return fieldShell(entry, customSelectHtml(options, value, attrs, { label: entry.label, disabled: entry.readonly }));
63
+ }
64
+ if (entry.type === 'switch') {
65
+ return fieldShell(entry, `<button type="button" class="property-switch ${value ? 'is-on' : ''}" data-property-switch="${escapeHtml(entry.id)}" aria-pressed="${value ? 'true' : 'false'}"><span></span><strong>${value ? '开启' : '关闭'}</strong></button>`);
66
+ }
67
+ if (entry.type === 'range') {
68
+ return fieldShell(entry, `<div class="property-range"><input type="range" min="${entry.min ?? 0}" max="${entry.max ?? 100}" step="${entry.step ?? 1}" value="${Number(value ?? entry.min ?? 0)}" ${attrs}/><span data-range-value="${escapeHtml(entry.id)}">${escapeHtml(valueText(value))}${escapeHtml(entry.unit || '')}</span></div>`);
69
+ }
70
+ if (entry.type === 'action') {
71
+ const actionIcon = entry.iconId ? `<span class="nova-icon nova-icon-sm" data-icon="${escapeHtml(entry.iconId)}"></span>` : '';
72
+ return fieldShell(entry, `<button type="button" class="property-action ${entry.variant === 'danger' ? 'is-danger' : ''}" data-property-action="${escapeHtml(entry.id)}">${actionIcon}<span>${escapeHtml(entry.actionLabel || entry.label || '执行')}</span></button>`);
73
+ }
74
+ if (entry.type === 'tags') {
75
+ return fieldShell(entry, `<input class="property-control" type="text" value="${escapeHtml(valueText(value))}" ${attrs} ${placeholder}/><div class="property-inline-hint">使用逗号分隔多个值;也可填写表达式。</div>`);
76
+ }
77
+ if (entry.type === 'number') {
78
+ return fieldShell(entry, `<input class="property-control" type="number" value="${escapeHtml(valueText(value))}" min="${entry.min ?? ''}" max="${entry.max ?? ''}" step="${entry.step ?? 1}" ${attrs} ${placeholder}/>`);
79
+ }
80
+ return fieldShell(entry, `<input class="property-control" type="text" value="${escapeHtml(valueText(value))}" ${attrs} ${placeholder}/>`);
81
+ }
82
+
83
+ function renderSmartSelect(entry, value, context, panelId) {
84
+ const options = smartOptions(entry, context);
85
+ const listId = `${panelId}-${entry.id}-list`;
86
+ const optionList = Array.isArray(options) ? options : [];
87
+ const datalist = `<datalist id="${escapeHtml(listId)}">${normalizeOptions(optionList).map((option) => `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`).join('')}</datalist>`;
88
+ return fieldShell(entry, `<div class="property-smart-select"><input class="property-control" list="${escapeHtml(listId)}" type="text" value="${escapeHtml(valueText(value))}" data-property-input="${escapeHtml(entry.id)}" placeholder="${escapeHtml(entry.placeholder || '选择或输入值')}"/><span class="nova-icon nova-icon-sm property-smart-icon" data-icon="ui.chevron"></span></div>${datalist}`);
89
+ }
90
+
91
+ function smartOptions(entry, context) {
92
+ if (entry.type === 'resource-select') return resolveMaybe(entry.options, context) || [];
93
+ if (entry.type === 'node-select') {
94
+ return context.model.nodes.filter((node) => !entry.nodeFilter || entry.nodeFilter(node, context)).map((node) => ({ value: node.id, label: `${node.name} · ${node.id}` }));
95
+ }
96
+ const provider = entry.dataProvider ? context.data(entry.dataProvider) : null;
97
+ if (!provider) return [];
98
+ if (entry.type === 'user-select') {
99
+ const method = provider.searchUsers || provider.listUsers || provider.list;
100
+ return typeof method === 'function' ? method.call(provider, '') : [];
101
+ }
102
+ if (entry.type === 'form-select') {
103
+ const method = provider.listForms || provider.list;
104
+ return typeof method === 'function' ? method.call(provider) : [];
105
+ }
106
+ if (entry.type === 'process-select') {
107
+ const method = provider.listProcesses || provider.list;
108
+ return typeof method === 'function' ? method.call(provider) : [];
109
+ }
110
+ return [];
111
+ }
112
+
113
+ function renderNodeList(entry, value, context) {
114
+ const rows = (value || []).map((id) => context.model.nodes.find((node) => node.id === id)).filter(Boolean);
115
+ const content = rows.length ? rows.map((node) => {
116
+ const definition = NODE_DEFINITIONS[node.type] || NODE_DEFINITIONS.generic;
117
+ const label = node.name || definition?.label || node.id;
118
+ const typeLabel = definition?.label || node.type || '流程节点';
119
+ return `<button type="button" class="property-node-ref" data-node-ref="${escapeHtml(node.id)}" aria-label="${escapeHtml(`定位到节点:${label}`)}">
120
+ <span class="property-node-ref-icon"><span class="nova-icon nova-icon-sm" data-icon="${escapeHtml(iconIdForNode(node.type, definition))}"></span></span>
121
+ <span class="property-node-ref-copy"><strong title="${escapeHtml(label)}">${escapeHtml(label)}</strong><small title="${escapeHtml(`${typeLabel} · ${node.id}`)}">${escapeHtml(typeLabel)} · ${escapeHtml(node.id)}</small></span>
122
+ <span class="property-node-ref-locate nova-icon nova-icon-xs" data-icon="ui.locate"></span>
123
+ </button>`;
124
+ }).join('') : '<div class="property-empty-row">暂无节点引用</div>';
125
+ return fieldShell(entry, `<div class="property-node-list">${content}</div>`);
126
+ }
127
+
128
+ function renderFlowList(entry, value, context) {
129
+ const rows = Array.isArray(value) ? value : [];
130
+ const content = rows.length ? rows.map((edge, index) => {
131
+ const relation = entry.relation === 'target' ? 'target' : 'source';
132
+ const related = context.model.nodes.find((node) => node.id === edge[relation]);
133
+ return `<button type="button" class="property-flow-ref" data-flow-ref="${escapeHtml(edge.id)}">
134
+ <span class="property-flow-index">${index + 1}</span>
135
+ <span class="property-flow-copy"><strong>${escapeHtml(related?.name || edge[relation] || '未连接')}</strong><small>${escapeHtml(edge.name || edge.id)}</small></span>
136
+ <span class="nova-icon nova-icon-xs" data-icon="ui.locate"></span>
137
+ </button>`;
138
+ }).join('') : `<div class="property-empty-row">${escapeHtml(entry.emptyText || '暂无线路')}</div>`;
139
+ return fieldShell(entry, `<div class="property-flow-list">${content}</div>`);
140
+ }
141
+
142
+ function renderTable(entry, value) {
143
+ const rows = Array.isArray(value) ? value : [];
144
+ const columns = entry.columns || [];
145
+ const head = columns.map((column) => `<span>${escapeHtml(column.label)}</span>`).join('') + '<span></span>';
146
+ const rowHtml = rows.map((row, rowIndex) => {
147
+ const cells = columns.map((column) => {
148
+ const cellValue = row?.[column.key] ?? '';
149
+ if (column.type === 'select') return customSelectHtml(column.options || [], cellValue, `data-table-input data-table-entry="${escapeHtml(entry.id)}" data-row="${rowIndex}" data-key="${escapeHtml(column.key)}"`, { label: column.label, compact: true });
150
+ return `<input class="property-table-input" type="text" value="${escapeHtml(cellValue)}" data-table-entry="${escapeHtml(entry.id)}" data-row="${rowIndex}" data-key="${escapeHtml(column.key)}" placeholder="${escapeHtml(column.placeholder || '')}"/>`;
151
+ }).join('');
152
+ return `<div class="property-table-row" style="--property-columns:${Math.max(1, columns.length)}">${cells}<button type="button" class="property-row-remove" data-table-remove="${escapeHtml(entry.id)}" data-row="${rowIndex}" title="删除" aria-label="删除"><span class="nova-icon nova-icon-sm" data-icon="ui.delete"></span></button></div>`;
153
+ }).join('');
154
+ return fieldShell(entry, `<div class="property-table"><div class="property-table-head" style="--property-columns:${Math.max(1, columns.length)}">${head}</div>${rowHtml || '<div class="property-empty-row">暂无配置</div>'}<button type="button" class="property-row-add" data-table-add="${escapeHtml(entry.id)}"><span class="nova-icon nova-icon-sm" data-icon="ui.add"></span><span>添加${escapeHtml(entry.rowLabel || '一项')}</span></button></div>`);
155
+ }
156
+
157
+ function renderBranchList(entry, value, context) {
158
+ const rows = Array.isArray(value) ? value : [];
159
+ const fields = new Set(entry.fields || ['name', 'condition', 'default']);
160
+ const html = rows.length ? rows.map((row, index) => {
161
+ const target = context.model.nodes.find((node) => node.id === row.target);
162
+ return `<div class="property-branch-card ${row.isDefault ? 'is-default' : ''}" data-branch-index="${index}">
163
+ <div class="property-branch-head">
164
+ <div class="property-branch-identity"><span>${index + 1}</span><div><strong>分支 ${index + 1}</strong><small>Sequence Flow</small></div></div>
165
+ <button type="button" class="property-branch-locate" data-branch-select="${index}"><span>定位</span><span class="nova-icon nova-icon-xs" data-icon="ui.locate"></span></button>
166
+ </div>
167
+ <div class="property-branch-condition ${fields.size === 1 ? 'is-single-field' : ''}">
168
+ ${fields.has('name') ? `<label><span>出口名称</span><input class="property-control" type="text" value="${escapeHtml(row.name || '')}" data-branch-field="name" data-row="${index}" placeholder="例如:采购审批"/></label>` : ''}
169
+ ${fields.has('condition') ? `<label><span>条件表达式</span><textarea class="property-control property-expression" rows="2" data-branch-field="condition" data-row="${index}" placeholder="${escapeHtml('${condition}')}">${escapeHtml(row.condition || '')}</textarea></label>` : ''}
170
+ </div>
171
+ <div class="property-branch-foot ${fields.has('default') ? '' : 'is-target-only'}">
172
+ ${fields.has('default') ? `<label class="property-branch-default ${row.isDefault ? 'is-active' : ''}"><input type="radio" name="default-branch-${escapeHtml(context.element.id)}" data-branch-default="${index}" ${row.isDefault ? 'checked' : ''}/><i></i><span>默认线路</span></label>` : ''}
173
+ <div class="property-branch-target"><small>流向</small><strong>${escapeHtml(target?.name || row.target || '未连接')}</strong><span>→</span></div>
174
+ </div>
175
+ </div>`;
176
+ }).join('') : '<div class="property-empty-row">当前网关没有出口分支</div>';
177
+ return fieldShell(entry, `<div class="property-branch-list">${html}</div>`);
178
+ }
179
+
180
+ let panelSeed = 0;
181
+ export class PropertiesPanel {
182
+ constructor({ container, registry, studio = registry?.studio, designer = null, canvas = null, iconRegistry = createDefaultIconRegistry(), emptyText = '请选择元素查看属性', themeController = null, theme = null, onThemeChange = null } = {}) {
183
+ if (!container) throw new Error('PropertiesPanel requires a container.');
184
+ if (!registry) throw new Error('PropertiesPanel requires a PropertiesRegistry.');
185
+ this.container = container;
186
+ this.registry = registry;
187
+ this.studio = studio || designer;
188
+ this.designer = this.studio;
189
+ this.canvas = canvas;
190
+ this.iconRegistry = iconRegistry;
191
+ this.registry.setStudio(this.studio);
192
+ this.emptyText = emptyText;
193
+ this.collapseState = new Map();
194
+ this.id = `nova-properties-${++panelSeed}`;
195
+ this.container.classList.add('nova-properties');
196
+ this.themeController = themeController || new ThemeController({ root: container, theme, onChange: onThemeChange });
197
+ this._ownsThemeController = !themeController;
198
+ this._customCleanups = [];
199
+ this._offStudio = this.studio?.subscribe?.((event) => {
200
+ if (['selectionChanged', 'modelChanged', 'propertiesProfileChanged'].includes(event.type)) this.render();
201
+ });
202
+ }
203
+
204
+ destroy() {
205
+ this._customCleanups.forEach((cleanup) => cleanup?.());
206
+ this._customCleanups = [];
207
+ this._offStudio?.();
208
+ this.container.innerHTML = '';
209
+ this.container.classList.remove('nova-properties');
210
+ if (this._ownsThemeController) this.themeController.destroy();
211
+ }
212
+
213
+ setTheme(theme) { return this.themeController.setTheme(theme); }
214
+ setThemeMode(mode) { return this.themeController.setMode(mode); }
215
+ getThemeState() { return this.themeController.getState(); }
216
+
217
+ render(selection, element) {
218
+ this._customCleanups.forEach((cleanup) => cleanup?.());
219
+ this._customCleanups = [];
220
+ const { groups, context } = this.registry.getGroups(selection, element);
221
+ const header = this._header(context);
222
+ const groupHtml = groups.map((group) => this._group(group, context)).join('');
223
+ const emptyContent = context.kind === 'multi' ? this._multiSummary(context) : `<div class="properties-empty">${escapeHtml(this.emptyText)}</div>`;
224
+ this.container.innerHTML = `${header}<div class="properties-body">${groupHtml || emptyContent}</div>`;
225
+ hydrateIcons(this.container, this.iconRegistry);
226
+ this._bind(groups, context);
227
+ this._renderCustomEntries(groups, context);
228
+ return { groups, context };
229
+ }
230
+
231
+ _multiSummary(context) {
232
+ const labels = { task: '任务', event: '事件', gateway: '网关', container: '子流程', boundary: '边界事件' };
233
+ const counts = new Map();
234
+ for (const element of context.element || []) {
235
+ const definition = element?.type ? NODE_DEFINITIONS[element.type] : null;
236
+ if (!definition) continue;
237
+ const label = labels[definition.kind] || '其他';
238
+ counts.set(label, (counts.get(label) || 0) + 1);
239
+ }
240
+ const chips = [...counts].map(([label, count]) => `<span>${escapeHtml(label)} <strong>${count}</strong></span>`).join('');
241
+ return `<section class="properties-multi-summary">
242
+ <div class="properties-multi-icon"><span class="nova-icon" data-icon="ui.layoutSelection"></span></div>
243
+ <strong>批量布局选区</strong>
244
+ <p>在画布上使用悬浮工具栏进行对齐、分布和局部美化。</p>
245
+ <div class="properties-multi-counts">${chips}</div>
246
+ ${this.canvas ? '<button type="button" data-fit-selection><span class="nova-icon nova-icon-sm" data-icon="ui.fitSelection"></span><span>适应选区</span></button>' : ''}
247
+ </section>`;
248
+ }
249
+
250
+ _header(context) {
251
+ const profile = context.profile || 'business';
252
+ const profileSwitch = `<div class="properties-profile-switch" aria-label="属性视图"><button type="button" data-properties-profile="business" class="${profile === 'business' ? 'is-active' : ''}">业务</button><button type="button" data-properties-profile="developer" class="${profile === 'developer' ? 'is-active' : ''}">开发者</button></div>`;
253
+ const renderHeader = (title, className = '') => `<div class="properties-head ${className}"><div class="properties-title-row"><div class="properties-title">${escapeHtml(title)}</div>${profileSwitch}</div></div>`;
254
+ if (context.kind === 'none') return renderHeader('未选择元素');
255
+ if (context.kind === 'multi') return renderHeader(`已选择 ${context.element.length} 个元素`);
256
+ if (context.kind === 'process') return renderHeader('流程与画布', 'properties-head-process');
257
+ if (context.kind === 'edge') {
258
+ return renderHeader(context.element.name || (context.element.type === 'association' ? '关联' : '流程分支'));
259
+ }
260
+ return renderHeader(context.element.name || context.definition?.label || '未命名元素');
261
+ }
262
+
263
+ _group(group, context) {
264
+ const key = `${context.kind}:${context.element?.id || context.model.id}:${group.id}`;
265
+ const collapsed = this.collapseState.has(key) ? this.collapseState.get(key) : !!group.collapsed;
266
+ const count = this.registry.configuredCount(group, context);
267
+ const description = group.description ? `<div class="properties-group-description">${escapeHtml(group.description)}</div>` : '';
268
+ const entries = group.entries.map((entry) => this._entry(entry, context)).join('');
269
+ return `<section class="properties-group ${collapsed ? 'is-collapsed' : ''}" data-group-id="${escapeHtml(group.id)}" data-group-key="${escapeHtml(key)}">
270
+ <button type="button" class="properties-group-toggle" data-group-toggle="${escapeHtml(group.id)}" aria-expanded="${collapsed ? 'false' : 'true'}"><span class="nova-icon nova-icon-sm properties-chevron" data-icon="ui.chevron"></span><strong>${escapeHtml(group.label)}</strong>${count ? `<span class="properties-count">${count}</span>` : ''}</button>
271
+ <div class="properties-group-content">${description}${entries}</div>
272
+ </section>`;
273
+ }
274
+
275
+ _entry(entry, context) {
276
+ const value = this.registry.getValue(entry, context);
277
+ const error = this.registry.validateEntry(entry, context);
278
+ const custom = this.registry.components.get(entry.component || entry.type);
279
+ if (custom) return fieldShell(entry, `<div class="property-custom-host" data-custom-entry="${escapeHtml(entry.id)}"></div>`, error);
280
+ if (entry.type === 'table') return renderTable(entry, value);
281
+ if (entry.type === 'branchList') return renderBranchList(entry, value, context);
282
+ if (entry.type === 'flow-list') return renderFlowList(entry, value, context);
283
+ if (entry.type === 'node-list') return renderNodeList(entry, value, context);
284
+ if (['user-select', 'form-select', 'process-select', 'resource-select', 'node-select'].includes(entry.type)) return renderSmartSelect(entry, value, context, this.id);
285
+ const basic = renderBasicEntry(entry, value, context);
286
+ return error ? basic.replace(/<\/div>$/, `<div class="property-error">${escapeHtml(error)}</div></div>`) : basic;
287
+ }
288
+
289
+ _bind(groups, context) {
290
+ const entryMap = new Map(groups.flatMap((group) => group.entries.map((entry) => [entry.id, entry])));
291
+ this.container.querySelectorAll('[data-properties-profile]').forEach((button) => button.addEventListener('click', () => this.studio?.setPropertiesProfile(button.dataset.propertiesProfile)));
292
+ this.container.querySelector('[data-fit-selection]')?.addEventListener('click', () => this.canvas?.fitSelection());
293
+ this.container.querySelectorAll('[data-group-toggle]').forEach((button) => button.addEventListener('click', () => {
294
+ const section = button.closest('.properties-group');
295
+ section.classList.toggle('is-collapsed');
296
+ const collapsed = section.classList.contains('is-collapsed');
297
+ button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
298
+ this.collapseState.set(section.dataset.groupKey, collapsed);
299
+ }));
300
+
301
+ this.container.querySelectorAll('[data-property-input]').forEach((input) => {
302
+ const entry = entryMap.get(input.dataset.propertyInput);
303
+ const commit = () => {
304
+ let value = input.value;
305
+ if (entry.type === 'number' || entry.type === 'range') value = input.value === '' ? '' : Number(input.value);
306
+ this.registry.setValue(entry, value, context);
307
+ };
308
+ input.addEventListener('change', commit);
309
+ if (input.type === 'range') input.addEventListener('input', () => {
310
+ const label = this.container.querySelector(`[data-range-value="${CSS.escape(entry.id)}"]`);
311
+ if (label) label.textContent = `${input.value}${entry.unit || ''}`;
312
+ });
313
+ });
314
+
315
+ this.container.querySelectorAll('[data-property-switch]').forEach((button) => button.addEventListener('click', () => {
316
+ const entry = entryMap.get(button.dataset.propertySwitch);
317
+ const value = !button.classList.contains('is-on');
318
+ this.registry.setValue(entry, value, context);
319
+ }));
320
+
321
+ this.container.querySelectorAll('[data-property-action]').forEach((button) => button.addEventListener('click', () => {
322
+ const entry = entryMap.get(button.dataset.propertyAction);
323
+ entry?.action?.(context);
324
+ }));
325
+
326
+ this.container.querySelectorAll('[data-table-input]').forEach((input) => input.addEventListener('change', () => {
327
+ const entry = entryMap.get(input.dataset.tableEntry);
328
+ const rows = JSON.parse(JSON.stringify(this.registry.getValue(entry, context) || []));
329
+ const row = rows[Number(input.dataset.row)] || {};
330
+ row[input.dataset.key] = input.value;
331
+ rows[Number(input.dataset.row)] = row;
332
+ this.registry.setValue(entry, rows, context);
333
+ }));
334
+ this.container.querySelectorAll('[data-table-add]').forEach((button) => button.addEventListener('click', () => {
335
+ const entry = entryMap.get(button.dataset.tableAdd);
336
+ const rows = JSON.parse(JSON.stringify(this.registry.getValue(entry, context) || []));
337
+ rows.push(typeof entry.defaultRow === 'function' ? entry.defaultRow(context) : { ...(entry.defaultRow || {}) });
338
+ this.registry.setValue(entry, rows, context);
339
+ }));
340
+ this.container.querySelectorAll('[data-table-remove]').forEach((button) => button.addEventListener('click', () => {
341
+ const entry = entryMap.get(button.dataset.tableRemove);
342
+ const rows = JSON.parse(JSON.stringify(this.registry.getValue(entry, context) || []));
343
+ rows.splice(Number(button.dataset.row), 1);
344
+ this.registry.setValue(entry, rows, context);
345
+ }));
346
+
347
+ this._bindCustomSelects();
348
+
349
+ const branchEntry = groups.flatMap((group) => group.entries).find((entry) => entry.type === 'branchList');
350
+ if (branchEntry) {
351
+ const commitBranches = () => {
352
+ const rows = JSON.parse(JSON.stringify(this.registry.getValue(branchEntry, context) || []));
353
+ this.container.querySelectorAll('[data-branch-field]').forEach((input) => { rows[Number(input.dataset.row)][input.dataset.branchField] = input.value; });
354
+ if (!branchEntry.fields || branchEntry.fields.includes('default')) {
355
+ const checked = this.container.querySelector('[data-branch-default]:checked');
356
+ rows.forEach((row, index) => { row.isDefault = !!checked && Number(checked.dataset.branchDefault) === index; });
357
+ }
358
+ this.registry.setValue(branchEntry, rows, context);
359
+ };
360
+ this.container.querySelectorAll('[data-branch-field]').forEach((input) => input.addEventListener('change', commitBranches));
361
+ this.container.querySelectorAll('[data-branch-default]').forEach((input) => input.addEventListener('change', commitBranches));
362
+ this.container.querySelectorAll('[data-branch-select]').forEach((button) => button.addEventListener('click', () => {
363
+ const rows = this.registry.getValue(branchEntry, context) || [];
364
+ branchEntry.selectRow?.(context, rows[Number(button.dataset.branchSelect)]);
365
+ }));
366
+ }
367
+
368
+ this.container.querySelectorAll('[data-node-ref]').forEach((button) => button.addEventListener('click', () => context.studio.select({ kind: 'node', id: button.dataset.nodeRef })));
369
+ this.container.querySelectorAll('[data-flow-ref]').forEach((button) => button.addEventListener('click', () => context.studio.select({ kind: 'edge', id: button.dataset.flowRef })));
370
+ }
371
+
372
+ _bindCustomSelects() {
373
+ const ownerDocument = this.container.ownerDocument;
374
+ const wrappers = [...this.container.querySelectorAll('[data-property-select]')];
375
+ if (!wrappers.length) return;
376
+
377
+ const optionNodes = (wrapper) => [...wrapper.querySelectorAll('[data-property-select-option]')];
378
+ const close = (wrapper, restoreFocus = false) => {
379
+ const trigger = wrapper.querySelector('[data-property-select-trigger]');
380
+ const popover = wrapper.querySelector('.property-select-popover');
381
+ wrapper.classList.remove('is-open', 'is-above');
382
+ trigger?.setAttribute('aria-expanded', 'false');
383
+ if (popover) popover.hidden = true;
384
+ optionNodes(wrapper).forEach((option) => option.classList.remove('is-active'));
385
+ if (restoreFocus) trigger?.focus({ preventScroll: true });
386
+ };
387
+ const closeOthers = (current) => wrappers.forEach((wrapper) => {
388
+ if (wrapper !== current) close(wrapper);
389
+ });
390
+ const activate = (wrapper, index, focus = true) => {
391
+ const options = optionNodes(wrapper);
392
+ if (!options.length) return;
393
+ const nextIndex = Math.max(0, Math.min(index, options.length - 1));
394
+ options.forEach((option, optionIndex) => option.classList.toggle('is-active', optionIndex === nextIndex));
395
+ if (focus) options[nextIndex].focus({ preventScroll: true });
396
+ options[nextIndex].scrollIntoView({ block: 'nearest' });
397
+ };
398
+ const open = (wrapper, direction = 1) => {
399
+ const trigger = wrapper.querySelector('[data-property-select-trigger]');
400
+ if (trigger?.disabled) return;
401
+ closeOthers(wrapper);
402
+ const popover = wrapper.querySelector('.property-select-popover');
403
+ wrapper.classList.add('is-open');
404
+ trigger?.setAttribute('aria-expanded', 'true');
405
+ if (popover) popover.hidden = false;
406
+ const rect = popover?.getBoundingClientRect();
407
+ if (rect && rect.bottom > (ownerDocument.defaultView?.innerHeight ?? Infinity) - 8 && wrapper.getBoundingClientRect().top > rect.height + 8) wrapper.classList.add('is-above');
408
+ const options = optionNodes(wrapper);
409
+ const selectedIndex = options.findIndex((option) => option.classList.contains('is-selected'));
410
+ const startIndex = selectedIndex < 0 ? 0 : Math.max(0, Math.min(selectedIndex + (direction < 0 ? -1 : 0), options.length - 1));
411
+ activate(wrapper, startIndex);
412
+ };
413
+ const choose = (wrapper, option) => {
414
+ const nativeSelect = wrapper.querySelector('[data-property-select-native]');
415
+ if (!nativeSelect || !option) return;
416
+ nativeSelect.value = option.dataset.value;
417
+ nativeSelect.dispatchEvent(new ownerDocument.defaultView.Event('change', { bubbles: true }));
418
+ };
419
+ const move = (wrapper, delta) => {
420
+ const options = optionNodes(wrapper);
421
+ const current = options.findIndex((option) => option.classList.contains('is-active'));
422
+ activate(wrapper, current < 0 ? 0 : (current + delta + options.length) % options.length);
423
+ };
424
+
425
+ wrappers.forEach((wrapper) => {
426
+ const trigger = wrapper.querySelector('[data-property-select-trigger]');
427
+ const options = optionNodes(wrapper);
428
+ trigger?.addEventListener('click', () => wrapper.classList.contains('is-open') ? close(wrapper) : open(wrapper));
429
+ trigger?.addEventListener('keydown', (event) => {
430
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter', ' '].includes(event.key)) return;
431
+ event.preventDefault();
432
+ if (!wrapper.classList.contains('is-open')) open(wrapper, event.key === 'ArrowUp' ? -1 : 1);
433
+ else if (event.key === 'ArrowDown') move(wrapper, 1);
434
+ else if (event.key === 'ArrowUp') move(wrapper, -1);
435
+ else if (event.key === 'Home') activate(wrapper, 0);
436
+ else if (event.key === 'End') activate(wrapper, options.length - 1);
437
+ });
438
+ options.forEach((option) => {
439
+ option.addEventListener('pointermove', () => activate(wrapper, Number(option.dataset.optionIndex), false));
440
+ option.addEventListener('click', () => choose(wrapper, option));
441
+ option.addEventListener('keydown', (event) => {
442
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
443
+ event.preventDefault();
444
+ move(wrapper, event.key === 'ArrowDown' ? 1 : -1);
445
+ } else if (event.key === 'Home' || event.key === 'End') {
446
+ event.preventDefault();
447
+ activate(wrapper, event.key === 'Home' ? 0 : options.length - 1);
448
+ } else if (event.key === 'Enter' || event.key === ' ') {
449
+ event.preventDefault();
450
+ choose(wrapper, option);
451
+ } else if (event.key === 'Escape') {
452
+ event.preventDefault();
453
+ close(wrapper, true);
454
+ } else if (event.key === 'Tab') close(wrapper);
455
+ });
456
+ });
457
+ });
458
+
459
+ const onPointerDown = (event) => wrappers.forEach((wrapper) => {
460
+ if (!wrapper.contains(event.target)) close(wrapper);
461
+ });
462
+ const onKeyDown = (event) => {
463
+ if (event.key !== 'Escape') return;
464
+ const openWrapper = wrappers.find((wrapper) => wrapper.classList.contains('is-open'));
465
+ if (openWrapper) close(openWrapper, true);
466
+ };
467
+ ownerDocument.addEventListener('pointerdown', onPointerDown);
468
+ ownerDocument.addEventListener('keydown', onKeyDown);
469
+ this._customCleanups.push(() => {
470
+ ownerDocument.removeEventListener('pointerdown', onPointerDown);
471
+ ownerDocument.removeEventListener('keydown', onKeyDown);
472
+ });
473
+ }
474
+
475
+ _renderCustomEntries(groups, context) {
476
+ for (const entry of groups.flatMap((group) => group.entries)) {
477
+ const renderer = this.registry.components.get(entry.component || entry.type);
478
+ if (!renderer) continue;
479
+ const host = this.container.querySelector(`[data-custom-entry="${CSS.escape(entry.id)}"]`);
480
+ if (!host) continue;
481
+ const cleanup = renderer({
482
+ container: host,
483
+ entry,
484
+ context,
485
+ value: this.registry.getValue(entry, context),
486
+ commit: (value) => this.registry.setValue(entry, value, context),
487
+ });
488
+ if (typeof cleanup === 'function') this._customCleanups.push(cleanup);
489
+ }
490
+ }
491
+ }
@@ -0,0 +1,118 @@
1
+ import type { AlignmentGuide, BpmnEdge, BpmnNode, ElementSelection, Point, ProcessModel, SelectionRect } from '../core/index.js'
2
+ import type { IconRegistry, NodeVisualResolution } from '../icons/index.js'
3
+ import type { NodeRuntimePresentation, ProcessInstanceSnapshot, RuntimePresentation } from '../runtime/index.js'
4
+ import type { NovaThemeInput, NovaThemeMode, NovaThemeState, RuntimeAppearanceResolver, ThemeController } from '../theme/index.js'
5
+ import type { SvgExportArtifact, SvgExportOptions, SvgExportPreviewController, SvgNodeRenderer } from '../export-svg/index.js'
6
+
7
+ export interface SceneBounds {
8
+ left: number
9
+ top: number
10
+ right: number
11
+ bottom: number
12
+ width: number
13
+ height: number
14
+ }
15
+ export type ViewportBounds = Pick<SceneBounds, 'left' | 'top' | 'right' | 'bottom'>
16
+ export function computeSceneBounds(model: ProcessModel, viewportBounds?: ViewportBounds | null, margin?: number): SceneBounds
17
+ export interface EdgeLabelMetrics {
18
+ width: number
19
+ height: number
20
+ paddingX: number
21
+ paddingY: number
22
+ maxWidth: number
23
+ lineHeight: number
24
+ radius: number
25
+ lines: string[]
26
+ }
27
+ export interface EdgeLabelBounds extends SelectionRect {
28
+ left: number
29
+ top: number
30
+ right: number
31
+ bottom: number
32
+ }
33
+ export interface EdgeLabelPlacement {
34
+ x: number
35
+ y: number
36
+ bounds: EdgeLabelBounds
37
+ strategy: 'inline' | 'offset' | 'fallback'
38
+ segmentIndex: number
39
+ }
40
+ export function edgeLabelMetrics(value: string, options?: Partial<Pick<EdgeLabelMetrics, 'paddingX' | 'paddingY' | 'maxWidth' | 'lineHeight' | 'radius'>>): EdgeLabelMetrics
41
+ export function edgeLabelPlacement(points: Point[], metrics: EdgeLabelMetrics, options?: {
42
+ startClearance?: number
43
+ endClearance?: number
44
+ innerClearance?: number
45
+ obstaclePadding?: number
46
+ labelGap?: number
47
+ offsetStep?: number
48
+ maxOffset?: number
49
+ obstacles?: Array<SelectionRect | ViewportBounds>
50
+ }): EdgeLabelPlacement
51
+
52
+ export interface DiagramNodeRendererContext {
53
+ container: HTMLElement
54
+ node: BpmnNode
55
+ definition: Record<string, unknown>
56
+ runtimeState: NodeRuntimePresentation
57
+ runtimePresentation: NodeRuntimePresentation
58
+ visual: NodeVisualResolution
59
+ semanticIconId: string | null
60
+ mode: 'design' | 'viewer' | 'instance'
61
+ model: ProcessModel
62
+ themeState: NovaThemeState
63
+ runtimeAppearance: RuntimeAppearanceResolver | null
64
+ select(): void
65
+ openRuntimeDetails(anchor?: Element): void
66
+ renderSemanticIcon(target?: Element, options?: { className?: string; title?: string }): SVGSVGElement | null
67
+ }
68
+ export type DiagramNodeRenderer = (context: DiagramNodeRendererContext) => void | (() => void)
69
+ export interface DiagramRendererOptions {
70
+ model?: ProcessModel
71
+ mode?: 'design' | 'viewer' | 'instance'
72
+ runtime?: ProcessInstanceSnapshot | null
73
+ iconRegistry?: IconRegistry
74
+ nodeRenderers?: Record<string, DiagramNodeRenderer>
75
+ nodeRenderer?: DiagramNodeRenderer
76
+ runtimePresenter?: (context: { model: ProcessModel; runtime: ProcessInstanceSnapshot | null; appearance: RuntimeAppearanceResolver | null }) => RuntimePresentation
77
+ runtimeAppearance?: RuntimeAppearanceResolver | null
78
+ svgExport?: SvgExportOptions & { label?: string; nodeRenderers?: Record<string, SvgNodeRenderer> }
79
+ themeController?: ThemeController
80
+ theme?: NovaThemeInput
81
+ onThemeChange?: (state: NovaThemeState) => void
82
+ onNodeClick?: (node: BpmnNode, event: Event) => void
83
+ onNodePointerDown?: (node: BpmnNode, event: PointerEvent) => void
84
+ onEdgeClick?: (edge: BpmnEdge, event: Event) => void
85
+ onCanvasClick?: (event: Event) => void
86
+ onViewportChange?: (state: { zoom: number; pan: Point }) => void
87
+ canCreateNodeType?: (nodeType: string) => boolean
88
+ [key: string]: unknown
89
+ }
90
+ export class DiagramRenderer {
91
+ constructor(container: HTMLElement, options?: DiagramRendererOptions)
92
+ model: ProcessModel
93
+ screenToWorld(clientX: number, clientY: number): Point
94
+ openQuickMenu(nodeId: string): void
95
+ editEdgeName(edgeId: string): void
96
+ closeTransientOverlays(): void
97
+ setAlignmentGuides(guides?: AlignmentGuide[]): void
98
+ setModel(model: ProcessModel): void
99
+ setMode(mode: 'design' | 'viewer' | 'instance'): void
100
+ setRuntime(runtime: ProcessInstanceSnapshot | null): void
101
+ setSelection(selection: ElementSelection | null): void
102
+ setConnectingSource(nodeId: string | null): void
103
+ setSpacePressed(active: boolean): void
104
+ setInteractionMode(mode: string): void
105
+ getViewportState(): { zoom: number; pan: Point }
106
+ setViewportState(state: { zoom?: number; pan?: Point }): void
107
+ setTheme(theme: NovaThemeInput): NovaThemeState
108
+ setThemeMode(mode: NovaThemeMode): NovaThemeState
109
+ getThemeState(): NovaThemeState
110
+ exportSvg(options?: SvgExportOptions): Promise<SvgExportArtifact>
111
+ openSvgExportPreview(options?: SvgExportOptions & { previewTitle?: string; onDownload?: (artifact: SvgExportArtifact) => void }): SvgExportPreviewController
112
+ zoomBy(delta: number): void
113
+ fitView(padding?: number, options?: Record<string, unknown>): void
114
+ fitReadable(padding?: number): void
115
+ actualSize(padding?: number): void
116
+ render(): void
117
+ destroy(): void
118
+ }