@peter.prib/renderengine 0.2.3 → 0.2.5

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.
@@ -109,7 +109,7 @@ export class FormDialog {
109
109
  style: { overflowY: 'auto', flex: '1 1 auto', minHeight: '0', paddingRight: '4px' },
110
110
  ref: (n) => { this.body = n; },
111
111
  children: [
112
- this.description && { text: this.description, style: { fontSize: '11px', color: '#666', margin: '0 0 10px' } },
112
+ this.description && { text: this.description, style: { fontSize: '11px', color: '#666', margin: '0 0 10px', whiteSpace: 'pre-line' } },
113
113
  { style: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 12px', alignItems: 'center' }, children: rows.flat() },
114
114
  { attrs: { role: 'alert' }, style: { color: '#c0392b', fontSize: '12px', marginTop: '8px', display: 'none' }, ref: (n) => { this._error = n; } },
115
115
  ].filter(Boolean),
@@ -0,0 +1,122 @@
1
+ import { FormDialog } from './FormDialog.js';
2
+
3
+ /** What a difference is listed under, for a model called `mine` compared with `theirs`. */
4
+ const GROUP_TEXT = {
5
+ added: (kind, mine, theirs) => `${kind.label} only in "${theirs}"`,
6
+ changed: (kind) => `${kind.label} changed`,
7
+ removed: (kind, mine) => `${kind.label} only in "${mine}"`,
8
+ };
9
+
10
+ /** The order of a kind's groups. */
11
+ const CHANGE_ORDER = ['added', 'changed', 'removed'];
12
+
13
+ /** 'elementInstances' reads 'element instances'. */
14
+ const words = (field) => field.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
15
+
16
+ /**
17
+ * ModelCompareDialog — how one model differs from another (a
18
+ * ModelComparison), shown: how many things of each kind only one has or
19
+ * both have unalike, then each of them, grouped. With anything to merge, its
20
+ * button opens the merge (`onMerge`).
21
+ */
22
+ export class ModelCompareDialog {
23
+ /**
24
+ * @param {Object} options
25
+ * @param {ModelComparison} options.comparison
26
+ * @param {string} options.mineName - What the model compared is called
27
+ * @param {string} options.theirsName - What the other is called
28
+ * @param {() => void} [options.onMerge] - Merge from the other (offered when there is something to merge)
29
+ * @param {Document} [options.document]
30
+ */
31
+ constructor({ comparison, mineName, theirsName, onMerge = null, document: doc = globalThis.document }) {
32
+ this.comparison = comparison;
33
+ this.mineName = mineName;
34
+ this.theirsName = theirsName;
35
+ this.onMerge = onMerge;
36
+ this.document = doc;
37
+ }
38
+
39
+ get title() {
40
+ return `Compare "${this.mineName}" with "${this.theirsName}"`;
41
+ }
42
+
43
+ /** One line per kind that differs: how many only one has, and how many changed. */
44
+ summaryText() {
45
+ if (this.comparison.same) return 'The models are alike.';
46
+ return this.comparison.summary()
47
+ .filter(s => s.added || s.changed || s.removed)
48
+ .map(({ kind, added, changed, removed }) => `${kind.label}: ${[
49
+ added && `${added} only in "${this.theirsName}"`,
50
+ changed && `${changed} changed`,
51
+ removed && `${removed} only in "${this.mineName}"`,
52
+ ].filter(Boolean).join(', ')}`)
53
+ .join('\n');
54
+ }
55
+
56
+ /** The differences listed: [value, text, group] for a checklist, kind by kind, each kind's groups in CHANGE_ORDER. */
57
+ choices(differences) {
58
+ const kinds = this.comparison.kinds;
59
+ const rank = (d) => kinds.indexOf(d.kind) * CHANGE_ORDER.length + CHANGE_ORDER.indexOf(d.change);
60
+ return [...differences].sort((a, b) => rank(a) - rank(b)).map(d => [
61
+ d.key,
62
+ d.change === 'changed' ? `${d.name} (${d.fields.map(words).join(', ')})` : d.name,
63
+ GROUP_TEXT[d.change](d.kind, this.mineName, this.theirsName),
64
+ ]);
65
+ }
66
+
67
+ /** The dialog's fields: every difference, to read. */
68
+ fields() {
69
+ const all = this.comparison.differences();
70
+ return all.length ? [{ key: 'items', label: '', type: 'checklist', readOnly: true, value: [], choices: this.choices(all) }] : [];
71
+ }
72
+
73
+ open() {
74
+ const canMerge = !!this.onMerge && this.comparison.mergeable().length > 0;
75
+ return new FormDialog({
76
+ title: this.title,
77
+ description: this.summaryText(),
78
+ fields: this.fields(),
79
+ submitLabel: canMerge ? 'Merge…' : 'Close',
80
+ cancelLabel: 'Close',
81
+ idPrefix: 'model-compare-',
82
+ document: this.document,
83
+ onApply: () => { if (canMerge) this.onMerge(); },
84
+ }).open();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * ModelMergeDialog — choose what to bring from the other model: what only it
90
+ * has and its version of what changed, each ticked to start with; what this
91
+ * model alone has stays. Merging hands the command (ModelComparison#mergeCommand,
92
+ * with what the chosen need) to `onMerge`.
93
+ */
94
+ export class ModelMergeDialog extends ModelCompareDialog {
95
+ get title() {
96
+ return `Merge from "${this.theirsName}" into "${this.mineName}"`;
97
+ }
98
+
99
+ fields() {
100
+ const mergeable = this.comparison.mergeable();
101
+ return mergeable.length
102
+ ? [{ key: 'items', label: '', type: 'checklist', value: this.choices(mergeable).map(([key]) => key), choices: this.choices(mergeable) }]
103
+ : [];
104
+ }
105
+
106
+ open() {
107
+ const nothing = !this.comparison.mergeable().length;
108
+ return new FormDialog({
109
+ title: this.title,
110
+ description: nothing ? `"${this.mineName}" has everything "${this.theirsName}" has.` : `${this.summaryText()}\n\nWhat the chosen need (a view's elements, a relationship's ends) comes too.`,
111
+ fields: this.fields(),
112
+ submitLabel: nothing ? 'Close' : 'Merge',
113
+ idPrefix: 'model-merge-',
114
+ document: this.document,
115
+ validate: ({ items }) => (nothing || items?.length ? null : 'Choose something to merge'),
116
+ onApply: ({ items }) => {
117
+ const command = nothing ? null : this.comparison.mergeCommand(items);
118
+ if (command) this.onMerge(command);
119
+ },
120
+ }).open();
121
+ }
122
+ }
@@ -1,119 +1,119 @@
1
- import { render } from '../workspace/dom.js';
2
- import { CreateTemplateCommand } from './DiagramEditorCommands.js';
3
-
4
- /**
5
- * Palette — category picker plus a grid of draggable shapes.
6
- *
7
- * Previews are drawn by the templates themselves (Template#renderPreview), so a
8
- * palette tile always matches what lands on the canvas.
9
- */
10
- export class Palette {
11
- /**
12
- * @param {Object} options
13
- * @param {HTMLElement} options.container - Element the palette renders into (contents replaced)
14
- * @param {TemplateCatalog} options.catalog - Shapes offered in the current category
15
- * @param {DiagramEditor} options.editor - Editor receiving drops
16
- * @param {Array<{group: string, types: Array<{key: string, name: string, description?: string}>}>} [options.categories]
17
- * Grouped categories for the picker; omit to hide the picker
18
- * @param {string} [options.category] - Selected category key
19
- * @param {(key: string) => void} [options.onCategoryChange]
20
- */
21
- constructor(options = {}) {
22
- this.container = options.container;
23
- this.catalog = options.catalog;
24
- this.editor = options.editor;
25
- this.categories = options.categories ?? [];
26
- this.category = options.category ?? null;
27
- this.onCategoryChange = options.onCategoryChange ?? (() => {});
28
- }
29
-
30
- render() {
31
- if (!this.container) return;
32
- const doc = this.container.ownerDocument;
33
- render(this.container, [
34
- this.categories.length ? this._pickerSpec() : '',
35
- {
36
- style: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '6px', overflowY: 'auto', flex: '1 1 auto', alignContent: 'start' },
37
- children: this.catalog.getAll().map(entry => this._tileSpec(entry, doc)),
38
- },
39
- ], doc);
40
- Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', gap: '8px', minHeight: '0' });
41
- }
42
-
43
- _pickerSpec() {
44
- const selected = this.categories.flatMap(g => g.types).find(t => t.key === this.category);
45
- return {
46
- tag: 'select',
47
- attrs: { 'aria-label': 'Diagram type', title: selected?.description },
48
- style: { width: '100%', padding: '6px', fontSize: '12px', flex: '0 0 auto' },
49
- on: { change: (e) => this.onCategoryChange(e.target.value) },
50
- children: this.categories.map(({ group, types }) => ({
51
- tag: 'optgroup',
52
- attrs: { label: group },
53
- children: types.map(t => ({
54
- tag: 'option',
55
- attrs: { value: t.key, selected: t.key === this.category, title: t.description },
56
- text: t.name,
57
- })),
58
- })),
59
- };
60
- }
61
-
62
- _tileSpec(entry, doc) {
63
- return {
64
- attrs: { draggable: 'true', title: `Drag to add ${entry.name}` },
65
- style: {
66
- display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px',
67
- padding: '6px 4px', background: '#fff', border: '1px solid #ddd', borderRadius: '4px',
68
- cursor: 'grab', userSelect: 'none',
69
- },
70
- on: { dragstart: (e) => this._startDrag(e, entry) },
71
- children: [
72
- entry.factory('__preview', 0, 0).renderPreview(40, doc),
73
- {
74
- text: entry.name,
75
- style: { fontSize: '11px', color: '#333', textAlign: 'center', lineHeight: '1.2', overflowWrap: 'anywhere' },
76
- },
77
- ],
78
- };
79
- }
80
-
81
- _startDrag(e, entry) {
82
- if (!e.dataTransfer) return;
83
- e.dataTransfer.effectAllowed = 'copy';
84
- e.dataTransfer.setData('templateType', entry.type);
85
- }
86
-
87
- /**
88
- * Create the dragged shape at a drop point given in SVG viewBox coordinates,
89
- * snapping to the grid when it is shown.
90
- */
91
- handleDrop(e, x, y) {
92
- const templateType = e.dataTransfer.getData('templateType');
93
- if (!templateType) return false;
94
-
95
- let snapX = x, snapY = y;
96
- if (this.editor.showGrid && this.editor.gridSize) {
97
- const gridSize = this.editor.gridSize;
98
- snapX = Math.round(x / gridSize) * gridSize;
99
- snapY = Math.round(y / gridSize) * gridSize;
100
- }
101
-
102
- const template = this.catalog.create(templateType, this._generateId(templateType), snapX, snapY);
103
- if (!template) return false;
104
-
105
- if (this.editor.history) {
106
- this.editor.history.execute(new CreateTemplateCommand(this.editor, template));
107
- } else {
108
- this.editor.addTemplate(template);
109
- }
110
- this.editor.render();
111
- return true;
112
- }
113
-
114
- _generateId(templateType) {
115
- let counter = 1;
116
- while (this.editor.getTemplate(`${templateType}_${counter}`)) counter++;
117
- return `${templateType}_${counter}`;
118
- }
119
- }
1
+ import { render } from '../workspace/dom.js';
2
+ import { CreateTemplateCommand } from './DiagramEditorCommands.js';
3
+
4
+ /**
5
+ * Palette — category picker plus a grid of draggable shapes.
6
+ *
7
+ * Previews are drawn by the templates themselves (Template#renderPreview), so a
8
+ * palette tile always matches what lands on the canvas.
9
+ */
10
+ export class Palette {
11
+ /**
12
+ * @param {Object} options
13
+ * @param {HTMLElement} options.container - Element the palette renders into (contents replaced)
14
+ * @param {TemplateCatalog} options.catalog - Shapes offered in the current category
15
+ * @param {DiagramEditor} options.editor - Editor receiving drops
16
+ * @param {Array<{group: string, types: Array<{key: string, name: string, description?: string}>}>} [options.categories]
17
+ * Grouped categories for the picker; omit to hide the picker
18
+ * @param {string} [options.category] - Selected category key
19
+ * @param {(key: string) => void} [options.onCategoryChange]
20
+ */
21
+ constructor(options = {}) {
22
+ this.container = options.container;
23
+ this.catalog = options.catalog;
24
+ this.editor = options.editor;
25
+ this.categories = options.categories ?? [];
26
+ this.category = options.category ?? null;
27
+ this.onCategoryChange = options.onCategoryChange ?? (() => {});
28
+ }
29
+
30
+ render() {
31
+ if (!this.container) return;
32
+ const doc = this.container.ownerDocument;
33
+ render(this.container, [
34
+ this.categories.length ? this._pickerSpec() : '',
35
+ {
36
+ style: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '6px', overflowY: 'auto', flex: '1 1 auto', alignContent: 'start' },
37
+ children: this.catalog.getAll().map(entry => this._tileSpec(entry, doc)),
38
+ },
39
+ ], doc);
40
+ Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', gap: '8px', minHeight: '0' });
41
+ }
42
+
43
+ _pickerSpec() {
44
+ const selected = this.categories.flatMap(g => g.types).find(t => t.key === this.category);
45
+ return {
46
+ tag: 'select',
47
+ attrs: { 'aria-label': 'Diagram type', title: selected?.description },
48
+ style: { width: '100%', padding: '6px', fontSize: '12px', flex: '0 0 auto' },
49
+ on: { change: (e) => this.onCategoryChange(e.target.value) },
50
+ children: this.categories.map(({ group, types }) => ({
51
+ tag: 'optgroup',
52
+ attrs: { label: group },
53
+ children: types.map(t => ({
54
+ tag: 'option',
55
+ attrs: { value: t.key, selected: t.key === this.category, title: t.description },
56
+ text: t.name,
57
+ })),
58
+ })),
59
+ };
60
+ }
61
+
62
+ _tileSpec(entry, doc) {
63
+ return {
64
+ attrs: { draggable: 'true', title: entry.description ? `${entry.name}: ${entry.description}\nDrag to add` : `Drag to add ${entry.name}` },
65
+ style: {
66
+ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px',
67
+ padding: '6px 4px', background: '#fff', border: '1px solid #ddd', borderRadius: '4px',
68
+ cursor: 'grab', userSelect: 'none',
69
+ },
70
+ on: { dragstart: (e) => this._startDrag(e, entry) },
71
+ children: [
72
+ entry.factory('__preview', 0, 0).renderPreview(40, doc),
73
+ {
74
+ text: entry.name,
75
+ style: { fontSize: '11px', color: '#333', textAlign: 'center', lineHeight: '1.2', overflowWrap: 'anywhere' },
76
+ },
77
+ ],
78
+ };
79
+ }
80
+
81
+ _startDrag(e, entry) {
82
+ if (!e.dataTransfer) return;
83
+ e.dataTransfer.effectAllowed = 'copy';
84
+ e.dataTransfer.setData('templateType', entry.type);
85
+ }
86
+
87
+ /**
88
+ * Create the dragged shape at a drop point given in SVG viewBox coordinates,
89
+ * snapping to the grid when it is shown.
90
+ */
91
+ handleDrop(e, x, y) {
92
+ const templateType = e.dataTransfer.getData('templateType');
93
+ if (!templateType) return false;
94
+
95
+ let snapX = x, snapY = y;
96
+ if (this.editor.showGrid && this.editor.gridSize) {
97
+ const gridSize = this.editor.gridSize;
98
+ snapX = Math.round(x / gridSize) * gridSize;
99
+ snapY = Math.round(y / gridSize) * gridSize;
100
+ }
101
+
102
+ const template = this.catalog.create(templateType, this._generateId(templateType), snapX, snapY);
103
+ if (!template) return false;
104
+
105
+ if (this.editor.history) {
106
+ this.editor.history.execute(new CreateTemplateCommand(this.editor, template));
107
+ } else {
108
+ this.editor.addTemplate(template);
109
+ }
110
+ this.editor.render();
111
+ return true;
112
+ }
113
+
114
+ _generateId(templateType) {
115
+ let counter = 1;
116
+ while (this.editor.getTemplate(`${templateType}_${counter}`)) counter++;
117
+ return `${templateType}_${counter}`;
118
+ }
119
+ }
@@ -1,59 +1,61 @@
1
- /**
2
- * TemplateCatalog — registry of template types that can be added to a diagram.
3
- * Each entry describes a template type and how to create instances.
4
- */
5
- export class TemplateCatalog {
6
- constructor() {
7
- this.entries = new Map(); // type -> { name, icon, factory, properties }
8
- }
9
-
10
- /**
11
- * Register a template type.
12
- * @param {string} type - Unique type identifier
13
- * @param {Object} spec - Template specification
14
- * @param {string} spec.name - Display name
15
- * @param {string} spec.icon - Icon/emoji (e.g., "📦")
16
- * @param {Function} spec.factory - Factory function: (id, x, y) => Template instance
17
- * @param {Object} spec.properties - Default properties for this type
18
- */
19
- register(type, spec) {
20
- if (!spec.factory) {
21
- throw new Error(`TemplateCatalog.register: factory required for type '${type}'`);
22
- }
23
- this.entries.set(type, {
24
- type,
25
- name: spec.name || type,
26
- icon: spec.icon || '■',
27
- factory: spec.factory,
28
- properties: spec.properties || {},
29
- });
30
- }
31
-
32
- /**
33
- * Get a template type entry.
34
- */
35
- get(type) {
36
- return this.entries.get(type);
37
- }
38
-
39
- /**
40
- * Get all entries.
41
- */
42
- getAll() {
43
- return Array.from(this.entries.values());
44
- }
45
-
46
- /**
47
- * Create a template instance of given type.
48
- * @param {string} type
49
- * @param {string} id - Instance id
50
- * @param {number} x - X position
51
- * @param {number} y - Y position
52
- * @returns {Template|null}
53
- */
54
- create(type, id, x, y) {
55
- const entry = this.get(type);
56
- if (!entry) return null;
57
- return entry.factory(id, x, y);
58
- }
59
- }
1
+ /**
2
+ * TemplateCatalog — registry of template types that can be added to a diagram.
3
+ * Each entry describes a template type and how to create instances.
4
+ */
5
+ export class TemplateCatalog {
6
+ constructor() {
7
+ this.entries = new Map(); // type -> { name, icon, factory, properties }
8
+ }
9
+
10
+ /**
11
+ * Register a template type.
12
+ * @param {string} type - Unique type identifier
13
+ * @param {Object} spec - Template specification
14
+ * @param {string} spec.name - Display name
15
+ * @param {string} spec.icon - Icon/emoji (e.g., "📦")
16
+ * @param {Function} spec.factory - Factory function: (id, x, y) => Template instance
17
+ * @param {Object} spec.properties - Default properties for this type
18
+ * @param {string} [spec.description] - What it is (the palette shows it on hover)
19
+ */
20
+ register(type, spec) {
21
+ if (!spec.factory) {
22
+ throw new Error(`TemplateCatalog.register: factory required for type '${type}'`);
23
+ }
24
+ this.entries.set(type, {
25
+ type,
26
+ name: spec.name || type,
27
+ icon: spec.icon || '■',
28
+ factory: spec.factory,
29
+ properties: spec.properties || {},
30
+ ...(spec.description && { description: spec.description }),
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Get a template type entry.
36
+ */
37
+ get(type) {
38
+ return this.entries.get(type);
39
+ }
40
+
41
+ /**
42
+ * Get all entries.
43
+ */
44
+ getAll() {
45
+ return Array.from(this.entries.values());
46
+ }
47
+
48
+ /**
49
+ * Create a template instance of given type.
50
+ * @param {string} type
51
+ * @param {string} id - Instance id
52
+ * @param {number} x - X position
53
+ * @param {number} y - Y position
54
+ * @returns {Template|null}
55
+ */
56
+ create(type, id, x, y) {
57
+ const entry = this.get(type);
58
+ if (!entry) return null;
59
+ return entry.factory(id, x, y);
60
+ }
61
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ComparedKind — one kind of thing two models are compared on and merged by
3
+ * (their views; a notation's elements and relationships). A subclass per kind
4
+ * says how to list them, what of one counts when two are compared
5
+ * (`content`), what one needs to be brought into another model (the elements
6
+ * a view draws: `requires`), and how to bring it (`bring`, undoable).
7
+ * Things are matched by id, as two versions of one model (an Archi file and
8
+ * its later copy) keep them.
9
+ */
10
+ export class ComparedKind {
11
+ constructor() {
12
+ if (new.target === ComparedKind) throw new TypeError('ComparedKind is abstract — subclass it');
13
+ }
14
+
15
+ /** What a difference of this kind is keyed by ('view', 'element'). @abstract */
16
+ get key() { throw new Error(`${this.constructor.name} must define key`); }
17
+
18
+ /** What they are called in lists ('Views', 'Elements'). @abstract */
19
+ get label() { throw new Error(`${this.constructor.name} must define label`); }
20
+
21
+ /** Every one of them in a model, in order. @abstract */
22
+ items(model) {
23
+ throw new Error(`${this.constructor.name} must implement items()`);
24
+ }
25
+
26
+ /** One of them by id, or null. */
27
+ get(model, id) {
28
+ return this.items(model).find(item => this.idOf(item) === id) ?? null;
29
+ }
30
+
31
+ idOf(item) {
32
+ return item.id;
33
+ }
34
+
35
+ /** What it is called: its name, else its id. */
36
+ nameOf(item) {
37
+ return item.name || item.title || this.idOf(item);
38
+ }
39
+
40
+ /** What of it counts when two are compared: plain JSON, nothing generated anew each time a model is read. @abstract */
41
+ content(item) {
42
+ throw new Error(`${this.constructor.name} must implement content()`);
43
+ }
44
+
45
+ /** The fields of `content` that differ between two of them (none: they are the same). */
46
+ changedFields(mine, theirs) {
47
+ const a = this.content(mine);
48
+ const b = this.content(theirs);
49
+ return [...new Set([...Object.keys(a), ...Object.keys(b)])]
50
+ .filter(k => JSON.stringify(a[k]) !== JSON.stringify(b[k]));
51
+ }
52
+
53
+ /** What else it needs in a model to be brought there from `from`: [[kind key, id]] (none here). */
54
+ requires(item, from) {
55
+ return [];
56
+ }
57
+
58
+ /**
59
+ * Put a copy of `item` (from `from`) in `model`, in place of the one with
60
+ * its id if there is one. Returns what undoes it.
61
+ * @abstract
62
+ * @returns {() => void}
63
+ */
64
+ bring(model, item, from) {
65
+ throw new Error(`${this.constructor.name} must implement bring()`);
66
+ }
67
+ }
@@ -34,6 +34,17 @@ export class DiagramView extends DiagramEditorState {
34
34
  return [...this.templates.values()];
35
35
  }
36
36
 
37
+ /** The diagram as plain JSON: its shapes (by id), lines, line style and documentation. */
38
+ toJSON() {
39
+ return {
40
+ title: this.title,
41
+ documentation: this.documentation,
42
+ templates: [...this.templates].map(([id, t]) => ({ id, type: t.constructor.name, name: t.name, data: t.serialize() })),
43
+ edges: [...this.edges.values()].map(e => JSON.parse(JSON.stringify(e))),
44
+ edgeStyle: JSON.parse(JSON.stringify(this.edgeStyle ?? {})),
45
+ };
46
+ }
47
+
37
48
  /** An independent copy: shapes, lines, style and documentation are copied; history starts empty (same options). */
38
49
  copy(id, title, historyOptions = this.historyOptions) {
39
50
  return new DiagramView({
@@ -0,0 +1,37 @@
1
+ import { Command } from '../CommandHistory.js';
2
+
3
+ /**
4
+ * MergeModelCommand — things from another model brought into this one
5
+ * (ModelComparison#mergeCommand): each step a ComparedKind's `bring`, in the
6
+ * order given (what is needed first). One undo step; undo takes each back,
7
+ * last first.
8
+ */
9
+ export class MergeModelCommand extends Command {
10
+ /** A merge is no edit on one view: undoing it shows no other. */
11
+ viewId = null;
12
+
13
+ /**
14
+ * @param {ViewSetModel} model - Merged into
15
+ * @param {ViewSetModel} from - Merged from
16
+ * @param {Array<{kind: ComparedKind, item: Object}>} steps
17
+ * @param {string} [label]
18
+ */
19
+ constructor(model, from, steps, label = `Merge ${steps.length} item${steps.length === 1 ? '' : 's'} from "${from.name || 'another model'}"`) {
20
+ super(label);
21
+ this.model = model;
22
+ this.from = from;
23
+ this.steps = steps;
24
+ this._undos = [];
25
+ }
26
+
27
+ execute() {
28
+ this._undos = this.steps.map(({ kind, item }) => kind.bring(this.model, item, this.from));
29
+ this.model._notify();
30
+ }
31
+
32
+ undo() {
33
+ for (const undo of [...this._undos].reverse()) undo();
34
+ this._undos = [];
35
+ this.model._notify();
36
+ }
37
+ }