@peter.prib/renderengine 0.4.0 → 0.7.0

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 (78) hide show
  1. package/README.md +4 -0
  2. package/REFERENCE.md +267 -17
  3. package/dist/renderengine-workspace.umd.js +5289 -2895
  4. package/dist/renderengine.umd.js +4243 -2120
  5. package/examples/archimate.html +8 -28
  6. package/examples/data-model.html +45 -556
  7. package/package.json +70 -70
  8. package/src/archimateArchiFormat.js +11 -3
  9. package/src/archimateExchangeFormat.js +17 -9
  10. package/src/charting/ArchimateDiagramEditor.js +1 -1
  11. package/src/charting/Axis.js +20 -0
  12. package/src/charting/DiagramEditor.js +35 -3
  13. package/src/charting/Dialog.js +195 -0
  14. package/src/charting/DuplicatesDialog.js +108 -0
  15. package/src/charting/FormDialog.js +28 -97
  16. package/src/charting/InteractionManager.js +3 -0
  17. package/src/charting/Legend.js +45 -2
  18. package/src/charting/MergeCompareDialog.js +81 -0
  19. package/src/charting/PopupMenu.js +12 -3
  20. package/src/charting/SettingsDialog.js +6 -2
  21. package/src/charting/SettingsManager.js +14 -1
  22. package/src/charting/archimate/ArchimateCanvasEditor.js +190 -15
  23. package/src/charting/archimate/ArchimatePalette.js +1 -1
  24. package/src/charting/charts/AreaChart.js +5 -2
  25. package/src/charting/charts/BarChart.js +5 -3
  26. package/src/charting/charts/BarChart3DOrbit.js +5 -7
  27. package/src/charting/charts/BubbleChart.js +5 -7
  28. package/src/charting/charts/BubbleChart3D.js +5 -7
  29. package/src/charting/charts/CandlestickChart.js +3 -1
  30. package/src/charting/charts/DensityChart.js +4 -6
  31. package/src/charting/charts/GroupedBarChart.js +2 -2
  32. package/src/charting/charts/LineChart.js +5 -2
  33. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  34. package/src/charting/charts/PipeChart.js +4 -6
  35. package/src/charting/charts/RadarChart.js +5 -6
  36. package/src/charting/charts/ScatterChart.js +5 -7
  37. package/src/charting/charts/StackedBarChart.js +4 -2
  38. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -7
  39. package/src/charting/charts/StreamGraphChart.js +5 -2
  40. package/src/charting/charts/WaterfallChart.js +2 -1
  41. package/src/charting/content/ContentSource.js +129 -0
  42. package/src/charting/content/ContentSources.js +77 -0
  43. package/src/charting/datamodel/DataModel.js +116 -0
  44. package/src/charting/diagrams/ArchimateModel.js +38 -3
  45. package/src/charting/diagrams/ArchimateModelChecker.js +13 -4
  46. package/src/charting/diagrams/MergeElementsCommand.js +124 -101
  47. package/src/charting/diagrams/SvgZoom.js +20 -1
  48. package/src/charting/diagrams/duplicates.js +95 -13
  49. package/src/charting/duplicateSearchForm.js +58 -0
  50. package/src/charting/modelTree/ModelTree.js +239 -202
  51. package/src/charting/modelTree/ModelTreePanel.js +955 -919
  52. package/src/charting/modelTree/TreeBuilder.js +93 -45
  53. package/src/charting/modelTree/TreeViewMode.js +9 -6
  54. package/src/charting/modelTree/viewFolders.js +15 -6
  55. package/src/charting/requirements/AzureDevOpsApi.js +55 -0
  56. package/src/charting/requirements/GitLabApi.js +43 -0
  57. package/src/charting/requirements/JiraApi.js +69 -0
  58. package/src/charting/requirements/TrackerApi.js +142 -0
  59. package/src/charting/requirements/TrackerExport.js +3 -2
  60. package/src/charting/requirements/index.js +5 -0
  61. package/src/charting/requirements/trackerApis.js +21 -0
  62. package/src/icons/IconSet.js +86 -0
  63. package/src/icons/index.js +59 -0
  64. package/src/index.js +67 -10
  65. package/src/workspace/dom.js +56 -42
  66. package/src/workspace/index.js +1 -1
  67. package/src/workspace/nav/NavEntry.js +42 -0
  68. package/src/workspace/nav/NavGroup.js +43 -0
  69. package/src/workspace/nav/NavItem.js +53 -0
  70. package/src/workspace/nav/NavMenu.js +143 -0
  71. package/src/workspace/nav/NavSection.js +17 -0
  72. package/src/workspace/nav/index.js +5 -0
  73. package/src/workspace/parts/ArchimateCanvasPart.js +8 -3
  74. package/src/workspace/parts/DataModelPart.js +91 -0
  75. package/src/workspace/parts/EditorPart.js +8 -0
  76. package/src/workspace/parts/ModelHosted.js +1 -0
  77. package/src/workspace/parts/ModelsPart.js +765 -622
  78. package/src/workspace/styles.js +28 -1
@@ -0,0 +1,108 @@
1
+ import { el } from '../workspace/dom.js';
2
+ import { Dialog } from './Dialog.js';
3
+
4
+ /**
5
+ * DuplicatesDialog — the groups Find likely duplicates… found, a row each:
6
+ * a box to tick it, its message (choosing it shows it: `onReveal`), and
7
+ * Merge…. It stays open while they are merged: **Merge selected** merges the
8
+ * ticked groups one after another, and after each merge the list is found
9
+ * again (`refind`), so what was merged leaves it. Any modeller lists its
10
+ * duplicates this way.
11
+ *
12
+ * `merge(group)` does one group's merge (asking what it must: MergeCompareDialog)
13
+ * and resolves to whether it merged.
14
+ */
15
+ export class DuplicatesDialog extends Dialog {
16
+ /**
17
+ * @param {Object} options
18
+ * @param {Array<{ids: string[], message: string}>} options.groups - What was found (model-check findings)
19
+ * @param {(group) => (boolean|Promise<boolean>)} options.merge
20
+ * @param {() => Array<Object>} [options.refind] - The groups found again, after a merge (else those left)
21
+ * @param {(group) => void} [options.onReveal]
22
+ * @param {string} [options.empty] - What an empty list says
23
+ * @param {Document} [options.document]
24
+ */
25
+ constructor({ groups, merge, refind = null, onReveal = () => {}, empty = 'No likely duplicates.', document: doc = globalThis.document }) {
26
+ super({
27
+ title: DuplicatesDialog.titleFor(groups),
28
+ closeButton: true,
29
+ document: doc,
30
+ buttons: [
31
+ { label: 'Merge selected', primary: true, action: () => this.mergeSelected() },
32
+ { label: 'Close', action: (dialog) => dialog.close() },
33
+ ],
34
+ });
35
+ Object.assign(this, { groups, mergeGroup: merge, refind, onReveal, empty });
36
+ /** The groups ticked, by key. */
37
+ this.ticked = new Set();
38
+ }
39
+
40
+ /** "Likely duplicates: n groups". */
41
+ static titleFor(groups) {
42
+ return `Likely duplicates: ${groups.length} group${groups.length === 1 ? '' : 's'}`;
43
+ }
44
+
45
+ /** A group's key: its members. */
46
+ static keyOf(group) {
47
+ return group.ids.join('\u0000');
48
+ }
49
+
50
+ content() {
51
+ return [this._list()];
52
+ }
53
+
54
+ /** The rows. @private */
55
+ _list() {
56
+ return {
57
+ cls: 're-duplicates', style: { display: 'flex', flexDirection: 'column', gap: '4px', minWidth: '440px' },
58
+ children: this.groups.length ? this.groups.map(group => {
59
+ const key = DuplicatesDialog.keyOf(group);
60
+ return {
61
+ attrs: { 'data-group': key }, style: { display: 'flex', gap: '6px', alignItems: 'center' },
62
+ children: [
63
+ {
64
+ tag: 'input', attrs: { type: 'checkbox', 'aria-label': 'Merge this group', checked: this.ticked.has(key) || null },
65
+ on: { change: (e) => { if (e.target.checked) this.ticked.add(key); else this.ticked.delete(key); } },
66
+ },
67
+ {
68
+ tag: 'button', attrs: { type: 'button', title: 'Show it' }, text: `⚠ ${group.message}`,
69
+ style: { flex: '1', textAlign: 'left', padding: '4px 6px', border: '1px solid #ddd', borderRadius: '3px', background: '#fff6e5', cursor: 'pointer' },
70
+ on: { click: () => this.onReveal(group) },
71
+ },
72
+ { tag: 'button', attrs: { type: 'button', 'data-action': 'Merge…' }, text: 'Merge…', style: { padding: '4px 8px', cursor: 'pointer' }, on: { click: () => this.mergeOne(group) } },
73
+ ],
74
+ };
75
+ }) : [{ text: this.empty }],
76
+ };
77
+ }
78
+
79
+ /** Merge one group (asking what it must); the list is found again. Resolves to whether it merged. */
80
+ async mergeOne(group) {
81
+ const merged = await this.mergeGroup(group);
82
+ if (merged) this.refresh([group]);
83
+ return !!merged;
84
+ }
85
+
86
+ /** Merge the ticked groups, one after another; the list is found again. Resolves to how many merged. */
87
+ async mergeSelected() {
88
+ const chosen = this.groups.filter(g => this.ticked.has(DuplicatesDialog.keyOf(g)));
89
+ const done = [];
90
+ for (const group of chosen) {
91
+ if (await this.mergeGroup(group)) done.push(group);
92
+ }
93
+ if (done.length) this.refresh(done);
94
+ return done.length;
95
+ }
96
+
97
+ /** Show the groups as they are now: found again, or without those merged. */
98
+ refresh(merged = []) {
99
+ const gone = new Set(merged.map(DuplicatesDialog.keyOf));
100
+ this.groups = this.refind ? this.refind() : this.groups.filter(g => !gone.has(DuplicatesDialog.keyOf(g)));
101
+ const still = new Set(this.groups.map(DuplicatesDialog.keyOf));
102
+ this.ticked = new Set([...this.ticked].filter(k => still.has(k)));
103
+ this.title = DuplicatesDialog.titleFor(this.groups);
104
+ const heading = this.titleBar?.querySelector?.('h3') ?? this.titleBar?.children?.[0];
105
+ if (heading) heading.textContent = this.title;
106
+ this.body?.replaceChildren(el(this._list(), this.doc));
107
+ }
108
+ }
@@ -1,4 +1,5 @@
1
1
  import { el } from '../workspace/dom.js';
2
+ import { Dialog } from './Dialog.js';
2
3
  import { ColorField } from './ColorField.js';
3
4
  import { RowsField } from './RowsField.js';
4
5
 
@@ -34,15 +35,16 @@ const INHERIT = '__inherit__';
34
35
  * inherited field reads as `undefined`. `FormDialog.many` uses it for a form
35
36
  * that edits several items at once.
36
37
  *
37
- * The dialog fits the window: its fields scroll between the title and the
38
- * buttons, which stay in view, and it moves by dragging its title. A textarea
39
- * scrolls inside its own box and can be resized taller.
38
+ * A Dialog (its frame: it fits the window, its fields scroll between the
39
+ * title and the buttons, which stay in view, and it moves by dragging its
40
+ * title), built as a <form>. A textarea scrolls inside its own box and can be
41
+ * resized taller.
40
42
  *
41
43
  * `validate(values)` may return a message to show inline instead of closing.
42
44
  * After open(), `controls[key]` is each field's control, for wiring fields that
43
45
  * depend on one another.
44
46
  */
45
- export class FormDialog {
47
+ export class FormDialog extends Dialog {
46
48
  /**
47
49
  * @param {Object} options
48
50
  * @param {string} options.title
@@ -58,7 +60,13 @@ export class FormDialog {
58
60
  * applied (false for Cancel, Escape or a click outside)
59
61
  */
60
62
  constructor({ title, description, fields, onApply, validate, submitLabel = 'Apply', cancelLabel = 'Cancel', idPrefix = 'form-dialog-', document: doc = document, onClose = null }) {
61
- this.title = title;
63
+ super({
64
+ title, document: doc, onClose,
65
+ buttons: [
66
+ { label: cancelLabel, action: (dialog) => dialog.close() },
67
+ { label: submitLabel, primary: true, submit: true },
68
+ ],
69
+ });
62
70
  this.description = description;
63
71
  this.fields = fields;
64
72
  this.onApply = onApply;
@@ -66,107 +74,30 @@ export class FormDialog {
66
74
  this.submitLabel = submitLabel;
67
75
  this.cancelLabel = cancelLabel;
68
76
  this.idPrefix = idPrefix;
69
- this.doc = doc;
70
- this.onClose = onClose;
71
- this._applied = false;
72
- this.overlay = null;
73
77
  /** @type {Object<string, HTMLElement>} */
74
78
  this.controls = {};
75
79
  this._readers = [];
76
80
  this._error = null;
77
81
  }
78
82
 
79
- open() {
80
- this._readers = [];
81
- this.controls = {};
82
- const rows = this.fields.map(f => this._row(f));
83
- this.overlay = el({
84
- style: {
85
- position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.25)', zIndex: '10001',
86
- display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'sans-serif',
87
- },
88
- on: { mousedown: (e) => { if (e.target === this.overlay) this.close(); } },
89
- children: [{
90
- tag: 'form',
91
- attrs: { role: 'dialog', 'aria-label': this.title },
92
- style: {
93
- background: '#fff', color: '#222', border: '1px solid #bbb', borderRadius: '6px', padding: '16px 18px',
94
- minWidth: '360px', maxWidth: '90vw', maxHeight: '90vh', boxSizing: 'border-box',
95
- display: 'flex', flexDirection: 'column',
96
- boxShadow: '0 6px 24px rgba(0,0,0,0.2)', fontSize: '12px',
97
- },
98
- ref: (n) => { this.form = n; },
99
- on: {
100
- submit: (e) => { e.preventDefault(); this._apply(); },
101
- keydown: (e) => { if (e.key === 'Escape') this.close(); },
102
- },
103
- children: [
104
- {
105
- // The title is the handle that moves the dialog.
106
- tag: 'h3', text: this.title, attrs: { title: 'Drag to move' },
107
- style: { margin: '0 0 8px', fontSize: '14px', cursor: 'move', userSelect: 'none', flex: '0 0 auto' },
108
- on: { mousedown: (e) => this._startMove(e) },
109
- },
110
- {
111
- // The fields scroll; the title and the buttons stay in view.
112
- style: { overflowY: 'auto', flex: '1 1 auto', minHeight: '0', paddingRight: '4px' },
113
- ref: (n) => { this.body = n; },
114
- children: [
115
- this.description && { text: this.description, style: { fontSize: '11px', color: '#666', margin: '0 0 10px', whiteSpace: 'pre-line' } },
116
- { style: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 12px', alignItems: 'center' }, children: rows.flat() },
117
- { attrs: { role: 'alert' }, style: { color: '#c0392b', fontSize: '12px', marginTop: '8px', display: 'none' }, ref: (n) => { this._error = n; } },
118
- ].filter(Boolean),
119
- },
120
- {
121
- style: { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '16px', flex: '0 0 auto' },
122
- children: [
123
- { tag: 'button', attrs: { type: 'button' }, text: this.cancelLabel, on: { click: () => this.close() } },
124
- {
125
- tag: 'button', attrs: { type: 'submit' }, text: this.submitLabel,
126
- style: { background: '#0066cc', color: '#fff', border: '1px solid #0066cc', borderRadius: '3px', padding: '4px 14px' },
127
- },
128
- ],
129
- },
130
- ].filter(Boolean),
131
- }],
132
- }, this.doc);
133
- this.doc.body.appendChild(this.overlay);
134
- this.overlay.querySelector('input:not([type=checkbox]), select, textarea')?.focus();
135
- return this;
136
- }
83
+ get frameTag() { return 'form'; }
137
84
 
138
- /**
139
- * Move the dialog by its title: from then on it sits where it was dropped
140
- * (fixed position), kept inside the window.
141
- * @protected
142
- */
143
- _startMove(e) {
144
- if (e.button !== 0 || !this.form) return;
145
- e.preventDefault();
146
- const view = this.doc.defaultView;
147
- const box = this.form.getBoundingClientRect();
148
- const grab = { x: e.clientX - box.left, y: e.clientY - box.top };
149
- Object.assign(this.form.style, { position: 'fixed', margin: '0', left: `${box.left}px`, top: `${box.top}px` });
150
- const move = (ev) => {
151
- const width = view?.innerWidth ?? Infinity, height = view?.innerHeight ?? Infinity;
152
- const left = Math.min(Math.max(0, ev.clientX - grab.x), Math.max(0, width - box.width));
153
- const top = Math.min(Math.max(0, ev.clientY - grab.y), Math.max(0, height - 40));
154
- this.form.style.left = `${left}px`;
155
- this.form.style.top = `${top}px`;
156
- };
157
- const end = () => {
158
- this.doc.removeEventListener('mousemove', move);
159
- this.doc.removeEventListener('mouseup', end);
160
- };
161
- this.doc.addEventListener('mousemove', move);
162
- this.doc.addEventListener('mouseup', end);
85
+ /** The dialog's <form>: its frame. */
86
+ get form() { return this.frame; }
87
+
88
+ frameEvents() {
89
+ return { submit: (e) => { e.preventDefault(); this._apply(); } };
163
90
  }
164
91
 
165
- close() {
166
- if (!this.overlay) return;
167
- this.overlay.remove();
168
- this.overlay = null;
169
- this.onClose?.(this._applied);
92
+ content() {
93
+ this._readers = [];
94
+ this.controls = {};
95
+ const rows = this.fields.map(f => this._row(f));
96
+ return [
97
+ this.description && { text: this.description, style: { fontSize: '11px', color: '#666', margin: '0 0 10px', whiteSpace: 'pre-line' } },
98
+ { style: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 12px', alignItems: 'center' }, children: rows.flat() },
99
+ { attrs: { role: 'alert' }, style: { color: '#c0392b', fontSize: '12px', marginTop: '8px', display: 'none' }, ref: (n) => { this._error = n; } },
100
+ ].filter(Boolean);
170
101
  }
171
102
 
172
103
  values() {
@@ -624,8 +624,11 @@ export class InteractionManager {
624
624
  const engine = this._engine;
625
625
  if (!engine.renderer || !engine.renderer.svg) return;
626
626
  if (!engine.renderer.supportsSceneGraph) return;
627
+ const zoom = engine.options?.zoom;
628
+ if (zoom === false) return;
627
629
 
628
630
  engine._svgZoom = new SvgZoom(engine.container, engine.renderer.svg, {
631
+ wheelModifier: zoom?.wheelModifier ?? null,
629
632
  clientToSvgPoint: (clientX, clientY) => engine._clientToSvgPoint(clientX, clientY),
630
633
  hitTest: (clientX, clientY) => engine.hitTest(clientX, clientY),
631
634
  is3D: () => engine._chart3D,
@@ -13,14 +13,49 @@ export class Legend {
13
13
  * @param {number} [options.itemHeight=20]
14
14
  * @param {number} [options.markerSize=10]
15
15
  * @param {string} [options.fontSize='12px']
16
+ * @param {'square'|'circle'} [options.marker='square'] - A circle for a chart of points (scatter, bubble)
17
+ * @param {boolean} [options.inset=false] - Drawn over the plot: on a backing, so it reads over the data
16
18
  */
17
- constructor({ items, x, y, itemHeight = 20, markerSize = 10, fontSize = '12px' }) {
19
+ constructor({ items, x, y, itemHeight = 20, markerSize = 10, fontSize = '12px', marker = 'square', inset = false }) {
18
20
  this.items = items;
19
21
  this.x = x;
20
22
  this.y = y;
21
23
  this.itemHeight = itemHeight;
22
24
  this.markerSize = markerSize;
23
25
  this.fontSize = fontSize;
26
+ this.marker = marker;
27
+ this.inset = inset;
28
+ }
29
+
30
+ /**
31
+ * A legend for a plot `box` on a drawing `drawingWidth` wide: to the right
32
+ * of the plot when it fits there, otherwise inside the plot's top-right
33
+ * corner (inset, on a backing). A chart's plot usually fills its drawing,
34
+ * so "to the right" alone would put the legend off the edge.
35
+ * @param {{x:number, y:number, width:number}} box - The plot area
36
+ * @param {number} drawingWidth - The renderer's width
37
+ * @param {object} options - As the constructor's, without x and y
38
+ */
39
+ static beside(box, drawingWidth, options) {
40
+ const legend = new Legend({ ...options, x: 0, y: 0 });
41
+ const { width } = legend.size();
42
+ const outside = box.x + box.width + 20;
43
+ if (!(drawingWidth > 0) || outside + width <= drawingWidth) {
44
+ legend.x = outside;
45
+ legend.y = box.y;
46
+ } else {
47
+ legend.inset = true;
48
+ legend.x = box.x + box.width - width - 10;
49
+ legend.y = box.y + 10;
50
+ }
51
+ return legend;
52
+ }
53
+
54
+ /** Its size: the marker and the widest label (a character is about 0.6 em), a row per item. */
55
+ size() {
56
+ const px = parseFloat(this.fontSize) || 12;
57
+ const longest = Math.max(0, ...this.items.map(item => String(this._label(item)).length));
58
+ return { width: this.markerSize + 5 + longest * px * 0.6, height: this.items.length * this.itemHeight };
24
59
  }
25
60
 
26
61
  /** Label text for an item — uses `label` or `name`. */
@@ -33,6 +68,10 @@ export class Legend {
33
68
  * @param {*} renderer a renderer implementing .rect() and .text()
34
69
  */
35
70
  draw(renderer) {
71
+ if (this.inset) {
72
+ const { width, height } = this.size();
73
+ renderer.rect(this.x - 6, this.y - 6, width + 12, height + 2, 'rgba(255,255,255,0.85)', '#ddd');
74
+ }
36
75
  let currentY = this.y;
37
76
  for (const item of this.items) {
38
77
  const hidden = !!item.hidden;
@@ -44,7 +83,11 @@ export class Legend {
44
83
  attrs.dataIndex = item.dataIndex;
45
84
  }
46
85
 
47
- renderer.rect(this.x, currentY, this.markerSize, this.markerSize, color, null, attrs);
86
+ if (this.marker === 'circle') {
87
+ renderer.circle(this.x + this.markerSize / 2, currentY + this.markerSize / 2, this.markerSize / 2, color, null, attrs);
88
+ } else {
89
+ renderer.rect(this.x, currentY, this.markerSize, this.markerSize, color, null, attrs);
90
+ }
48
91
  renderer.text(this._label(item), this.x + this.markerSize + 5, currentY + this.markerSize / 2, {
49
92
  textAnchor: 'start',
50
93
  verticalAlign: 'middle',
@@ -0,0 +1,81 @@
1
+ import { el } from '../workspace/dom.js';
2
+ import { FormDialog } from './FormDialog.js';
3
+
4
+ /**
5
+ * MergeCompareDialog — things about to be merged, side by side: a column
6
+ * each, a row for each thing they have (type, documentation, each property,
7
+ * relationships …), the rows where they differ marked; under it, the choices
8
+ * a merge needs (which one is kept, which documentation, which value of a
9
+ * property). OK merges; Cancel leaves them be. Any modeller's merge asks this
10
+ * way (the ArchiMate canvas's elements, a data model's entities).
11
+ *
12
+ * `ask()` resolves to the choices' values, or null when cancelled.
13
+ */
14
+ export class MergeCompareDialog {
15
+ /**
16
+ * @param {Object} options
17
+ * @param {string} options.title
18
+ * @param {string[]} options.columns - Each thing's heading
19
+ * @param {Array<{label: string, values: string[]}>} options.rows - What each has, in column order ('' for nothing)
20
+ * @param {Array<Object>} [options.fields] - The choices (FormDialog fields)
21
+ * @param {string} [options.description]
22
+ * @param {Document} [options.document]
23
+ */
24
+ constructor({ title, columns, rows, fields = [], description = '', document: doc = globalThis.document }) {
25
+ Object.assign(this, { title, columns, rows, fields, description, document: doc });
26
+ }
27
+
28
+ /** Whether a row's values are not all the same. */
29
+ static differs(row) {
30
+ return new Set(row.values.map(v => String(v ?? '').trim())).size > 1;
31
+ }
32
+
33
+ /** Whether any row differs: if none does, there is nothing to look at before merging. */
34
+ get hasDifferences() {
35
+ return this.rows.some(row => MergeCompareDialog.differs(row));
36
+ }
37
+
38
+ /** The side-by-side table. */
39
+ table() {
40
+ const cell = (text, extra = {}) => ({
41
+ tag: 'td', text: text || '—',
42
+ style: { padding: '3px 6px', borderBottom: '1px solid #eee', verticalAlign: 'top', whiteSpace: 'pre-wrap', color: text ? null : '#999', ...extra },
43
+ });
44
+ return el({
45
+ style: { gridColumn: '1 / -1', overflowX: 'auto', maxWidth: '80vw' },
46
+ children: [{
47
+ tag: 'table', attrs: { 'data-merge-compare': '' }, style: { borderCollapse: 'collapse', fontSize: '12px', minWidth: '420px' },
48
+ children: [
49
+ { tag: 'thead', children: [{ tag: 'tr', children: [{ tag: 'th', text: '' }, ...this.columns.map(c => ({ tag: 'th', text: c, style: { textAlign: 'left', padding: '3px 6px', borderBottom: '2px solid #ccc' } }))] }] },
50
+ {
51
+ tag: 'tbody',
52
+ children: this.rows.map(row => {
53
+ const differs = MergeCompareDialog.differs(row);
54
+ return {
55
+ tag: 'tr', attrs: { 'data-differs': differs ? '' : null }, style: { background: differs ? '#fff3cd' : null },
56
+ children: [cell(`${differs ? '≠ ' : ''}${row.label}`, { fontWeight: 'bold', color: '#333' }), ...row.values.map(v => cell(v))],
57
+ };
58
+ }),
59
+ },
60
+ ],
61
+ }],
62
+ }, this.document);
63
+ }
64
+
65
+ /** Show it (over the page, or in `host`); resolves to the choices' values on OK, null on Cancel. */
66
+ ask(host = null) {
67
+ return new Promise((resolve) => {
68
+ new FormDialog({
69
+ title: this.title,
70
+ description: this.description,
71
+ fields: [{ key: 'compare', label: '', type: 'custom', element: this.table(), read: () => null }, ...this.fields],
72
+ submitLabel: 'OK, merge',
73
+ idPrefix: 'merge-compare-',
74
+ document: this.document,
75
+ onApply: ({ compare, ...choices }) => resolve(choices),
76
+ // A form closes before it applies: closed unapplied (Cancel, Escape, a click outside) is no.
77
+ onClose: (applied) => { if (!applied) resolve(null); },
78
+ }).open(host);
79
+ });
80
+ }
81
+ }
@@ -2,7 +2,8 @@ import { el } from '../workspace/dom.js';
2
2
 
3
3
  /**
4
4
  * PopupMenu — the one popup/context menu: a small menu at a screen position,
5
- * closed by choosing an item or clicking elsewhere. One is open at a time.
5
+ * closed by choosing an item, clicking elsewhere, Escape, or scrolling (it
6
+ * would no longer be beside what it was opened on). One is open at a time.
6
7
  * Editor menus, the graph engine's ContextMenu and the model tree's menus all
7
8
  * render through it.
8
9
  *
@@ -29,8 +30,14 @@ export class PopupMenu {
29
30
  }
30
31
  // Registered after the opening click has finished propagating.
31
32
  const dismiss = () => PopupMenu.close();
32
- setTimeout(() => doc.addEventListener('click', dismiss, { once: true }), 0);
33
- PopupMenu._open = { menu, dismiss, doc };
33
+ const onKey = (e) => { if (e.key === 'Escape') PopupMenu.close(); };
34
+ setTimeout(() => {
35
+ if (PopupMenu._open?.menu !== menu) return; // closed meanwhile
36
+ doc.addEventListener('click', dismiss, { once: true });
37
+ doc.addEventListener('scroll', dismiss, { once: true, capture: true });
38
+ doc.addEventListener('keydown', onKey);
39
+ }, 0);
40
+ PopupMenu._open = { menu, dismiss, onKey, doc };
34
41
  return menu;
35
42
  }
36
43
 
@@ -68,6 +75,8 @@ export class PopupMenu {
68
75
  if (!open) return;
69
76
  open.menu.remove();
70
77
  open.doc.removeEventListener('click', open.dismiss);
78
+ open.doc.removeEventListener('scroll', open.dismiss, { capture: true });
79
+ open.doc.removeEventListener('keydown', open.onKey);
71
80
  PopupMenu._open = null;
72
81
  }
73
82
 
@@ -1,5 +1,5 @@
1
1
  import { FormDialog } from './FormDialog.js';
2
- import { MODEL_SETTINGS_PROPERTIES } from './SettingsManager.js';
2
+ import { MODEL_SETTINGS_PROPERTIES, TREE_SETTINGS_PROPERTIES } from './SettingsManager.js';
3
3
  import { inheritableFields, readInheritableFields } from './SettingsCascade.js';
4
4
 
5
5
  /** Settings a model sets or inherits from the tool, and the tool sets for every model. */
@@ -33,6 +33,7 @@ export function showGlobalSettingsDialog(doc, settingsManager, modelId = 'defaul
33
33
  { type: 'heading', label: 'Tool' },
34
34
  { key: 'theme', label: 'Theme', type: 'select', value: tool.theme, choices: [['light', 'light'], ['dark', 'dark']] },
35
35
  { key: 'toolInvalidRelationships', label: checking.label, type: 'select', value: tool.invalidRelationships ?? checking.default, choices: checking.choices },
36
+ ...TREE_SETTINGS_PROPERTIES.map(({ key, label, type, min }) => ({ key, label, type, min, value: tool[key] })),
36
37
  { type: 'heading', label: 'Model' },
37
38
  ...inheritableFields(CHECKING, modelOwn, fromTool),
38
39
  { key: 'edgeRouting', label: 'Default edge routing', type: 'select', value: model.edgeRouting, choices: ROUTING_CHOICES },
@@ -43,7 +44,10 @@ export function showGlobalSettingsDialog(doc, settingsManager, modelId = 'defaul
43
44
  idPrefix: 'global-settings-',
44
45
  document: doc,
45
46
  onApply: ({ theme, toolInvalidRelationships, edgeRouting, gridSize, showGrid, ...rest }) => {
46
- settingsManager.saveToolSettings({ theme, invalidRelationships: toolInvalidRelationships });
47
+ // A tree setting left empty keeps its value.
48
+ const tree = Object.fromEntries(TREE_SETTINGS_PROPERTIES.map(({ key }) => [key, rest[key]])
49
+ .filter(([, value]) => Number.isFinite(value)).map(([key, value]) => [key, Math.max(1, Math.trunc(value))]));
50
+ settingsManager.saveToolSettings({ theme, invalidRelationships: toolInvalidRelationships, ...tree });
47
51
  // The model's own choice, or undefined to inherit the tool's.
48
52
  settingsManager.saveModelSettings(modelId, readInheritableFields(CHECKING, rest));
49
53
  // Observers (e.g. DiagramTabs) re-render on these notifications.
@@ -1,12 +1,13 @@
1
1
  import { SettingsCascade } from './SettingsCascade.js';
2
2
  import { LocalJsonStore } from './storage/LocalJsonStore.js';
3
+ import { ModelTree } from './modelTree/ModelTree.js';
3
4
 
4
5
  /**
5
6
  * SettingsManager — editor settings across tool, model and view levels.
6
7
  *
7
8
  * Hierarchy: Tool (global) ← Model (per model) ← View (per tab, overrides)
8
9
  *
9
- * Tool settings (localStorage): theme, zoom
10
+ * Tool settings (localStorage): theme, zoom, how the model tree searches and shows
10
11
  * Model settings (localStorage, per model id): edge routing, grid, watermark
11
12
  * View settings (on the view object): overrides of model settings, plus title/background
12
13
  *
@@ -43,9 +44,16 @@ export const VIEW_SETTINGS_DEFAULTS = Object.freeze({
43
44
  backgroundImage: '',
44
45
  });
45
46
 
47
+ /** How the model tree searches and shows, for every model (the tool level); `option` is ModelTree's name for it. */
48
+ export const TREE_SETTINGS_PROPERTIES = Object.freeze([
49
+ { key: 'treeLeafLimit', option: 'leafLimit', label: 'Tree: leaves shown per folder', type: 'number', default: ModelTree.LEAF_LIMIT, min: 1 },
50
+ { key: 'treeSearchMinChars', option: 'minFilterChars', label: 'Tree: search from (characters)', type: 'number', default: ModelTree.MIN_FILTER_CHARS, min: 1 },
51
+ ]);
52
+
46
53
  export const TOOL_SETTINGS_DEFAULTS = Object.freeze({
47
54
  theme: 'light',
48
55
  defaultZoom: 1,
56
+ ...Object.fromEntries(TREE_SETTINGS_PROPERTIES.map(p => [p.key, p.default])),
49
57
  });
50
58
 
51
59
  export class SettingsManager {
@@ -93,6 +101,11 @@ export class SettingsManager {
93
101
  return { ...this._toolSettings };
94
102
  }
95
103
 
104
+ /** The model tree's options from the tool settings ({leafLimit, minFilterChars}), for ModelTree. */
105
+ treeOptions() {
106
+ return Object.fromEntries(TREE_SETTINGS_PROPERTIES.map(p => [p.option, this._toolSettings[p.key] ?? p.default]));
107
+ }
108
+
96
109
  // ── Model Settings (per-model defaults) ──
97
110
 
98
111
  /** The model level holds only what is set on it; unset settings come from the tool level, then defaults. */