@peter.prib/renderengine 0.2.11 → 0.4.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.
- package/README.md +22 -6
- package/REFERENCE.md +117 -2
- package/dist/renderengine-workspace.umd.js +2973 -1203
- package/dist/renderengine.umd.js +1178 -158
- package/package.json +1 -1
- package/src/charting/ModelCheckDialog.js +10 -3
- package/src/charting/ModelComparisonDialog.js +10 -3
- package/src/charting/ModelFileFormat.js +4 -1
- package/src/charting/archimate/ArchimateCanvasEditor.js +14 -2
- package/src/charting/archimate/ArchimateElementTemplate.js +23 -0
- package/src/charting/arrangement/layouts.js +9 -4
- package/src/charting/datamodel/DataModel.js +18 -71
- package/src/charting/datamodel/DataModelCompared.js +1 -31
- package/src/charting/diagramModel/ListCompared.js +35 -0
- package/src/charting/model/Benchmark.js +59 -0
- package/src/charting/model/EditableModel.js +104 -0
- package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
- package/src/charting/requirements/RequirementsChecker.js +76 -0
- package/src/charting/requirements/RequirementsCompared.js +33 -0
- package/src/charting/requirements/RequirementsFileFormat.js +32 -0
- package/src/charting/requirements/RequirementsImportFormats.js +48 -0
- package/src/charting/requirements/RequirementsModel.js +305 -0
- package/src/charting/requirements/Storyboard.js +94 -0
- package/src/charting/requirements/TrackerExport.js +273 -0
- package/src/charting/requirements/index.js +7 -0
- package/src/index.js +6 -0
- package/src/workspace/Workspace.js +329 -321
- package/src/workspace/index.js +2 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
- package/src/workspace/parts/DataModelPart.js +668 -735
- package/src/workspace/parts/EditorPart.js +1 -1
- package/src/workspace/parts/ModelFile.js +3 -1
- package/src/workspace/parts/ModelHosted.js +176 -0
- package/src/workspace/parts/ModelsPart.js +146 -7
- package/src/workspace/parts/RequirementsPart.js +465 -0
- package/src/workspace/storage/ModelLibrary.js +8 -2
|
@@ -304,7 +304,7 @@ export class EditorPart extends Part {
|
|
|
304
304
|
...EditorPart.menuGroup('New', some(...this.newItems(), models['New model'], ...(this.modelsHost?.baseItems?.() ?? []))),
|
|
305
305
|
{ separator: true },
|
|
306
306
|
...some(open, models['Open… in a new tab'], ...saving, models['Duplicate model'], models['Close model']),
|
|
307
|
-
...EditorPart.menuGroup('This browser', this.modelsHost?.keepItems?.(this) ?? []),
|
|
307
|
+
...EditorPart.menuGroup(this.modelsHost?.keptWhere ?? 'This browser', this.modelsHost?.keepItems?.(this) ?? []),
|
|
308
308
|
{ separator: true },
|
|
309
309
|
...EditorPart.menuGroup('Import', this.importItems(), { prefix: true }),
|
|
310
310
|
...EditorPart.menuGroup('Export', this.exportItems(), { prefix: true }),
|
|
@@ -133,7 +133,9 @@ export class ModelFile {
|
|
|
133
133
|
/** Open what `choose()` read: the part edits its model, from that file. Returns the model. */
|
|
134
134
|
adopt({ model, name, format, handle = null }) {
|
|
135
135
|
this.part.replaceModel(model);
|
|
136
|
-
|
|
136
|
+
// Read from another tool's export (a format that only reads): a new model, saved in the part's own format.
|
|
137
|
+
if (format && format.exports === false) Object.assign(this, { name: null, format: null, handle: null });
|
|
138
|
+
else Object.assign(this, { name, format, handle });
|
|
137
139
|
this.showState();
|
|
138
140
|
return model;
|
|
139
141
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { ModelFile } from './ModelFile.js';
|
|
2
|
+
import { EditorPart } from './EditorPart.js';
|
|
3
|
+
import { modelFileMenuItems } from '../../charting/ModelFileFormat.js';
|
|
4
|
+
import { pickFile } from '../../charting/toolbar/pickFile.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ModelHosted — what a part that edits one model as a whole (an
|
|
8
|
+
* EditableModel: DataModelPart, RequirementsPart) needs to be one of a
|
|
9
|
+
* ModelsPart's models, as an EditorPart is: its file (ModelFile: Open…, Save,
|
|
10
|
+
* Save As…, • while changed, asking before the page is left), kept by the
|
|
11
|
+
* host (`keepId`, `keepEntry`), its File menu (New, Duplicate, Close model,
|
|
12
|
+
* where models are kept, Import of what other tools wrote), Undo and Redo,
|
|
13
|
+
* running a model-wide edit (a merge, a proposal taken) as one undo step,
|
|
14
|
+
* and reading a file into the model as one (readInto).
|
|
15
|
+
*
|
|
16
|
+
* A mixin, for parts on different bases (a ChartPart, a plain Part):
|
|
17
|
+
* `class DataModelPart extends ModelHosted(ChartPart)`. The part gives
|
|
18
|
+
* `fileFormats()` (its own first: what it saves as), and `afterModelEdit()`
|
|
19
|
+
* to show an edit (redraw).
|
|
20
|
+
*/
|
|
21
|
+
export const ModelHosted = (Base) => class extends Base {
|
|
22
|
+
constructor(spec = {}) {
|
|
23
|
+
super(spec);
|
|
24
|
+
/** The file it is open from and saved to. */
|
|
25
|
+
this.file = new ModelFile(this);
|
|
26
|
+
/** Its id among the models kept by its host, or null. */
|
|
27
|
+
this.keepId = spec.keepId ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Its file formats: none here. @abstract */
|
|
31
|
+
fileFormats() {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What a new file is saved as: its first format. */
|
|
36
|
+
saveFormat() {
|
|
37
|
+
return this.fileFormats()[0];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The ModelsPart it is a tab of, or null. */
|
|
41
|
+
get modelsHost() {
|
|
42
|
+
return this.ctx?.models ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The page it is in. */
|
|
46
|
+
get document() {
|
|
47
|
+
return this.container?.ownerDocument ?? globalThis.document;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What its tab and the page title show: the file's name (or the model's), and • while changed. */
|
|
51
|
+
get fileLabel() {
|
|
52
|
+
return this.file.label;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The model as kept by its host (ModelFile#keepEntry). */
|
|
56
|
+
keepEntry() {
|
|
57
|
+
return this.file.keepEntry();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A model opened from a file: in a host's new tab, else in place of this one (as EditorPart does). */
|
|
61
|
+
onImported(model) {
|
|
62
|
+
return EditorPart.prototype.onImported.call(this, model);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The file or its saved state changed: a ModelsPart's tab follows it. */
|
|
66
|
+
onFileState() {
|
|
67
|
+
this.modelsHost?.fileStateChanged?.(this);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Edit `model`: as saved, and every change of it shows (• and the title) and is kept. @protected */
|
|
71
|
+
_adoptModel(model) {
|
|
72
|
+
this._unsubscribeEdits?.();
|
|
73
|
+
this.model = model;
|
|
74
|
+
model.markSaved();
|
|
75
|
+
this._unsubscribeEdits = model.onEdit(() => {
|
|
76
|
+
this.file.showState();
|
|
77
|
+
this.modelsHost?.modelEdited?.(this);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
mount(host, ctx) {
|
|
82
|
+
const first = !this.mounted;
|
|
83
|
+
super.mount(host, ctx);
|
|
84
|
+
if (first) {
|
|
85
|
+
this.file.listen();
|
|
86
|
+
this.file.showState();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Show an edit of the model (a redraw): a subclass's. */
|
|
91
|
+
afterModelEdit() {}
|
|
92
|
+
|
|
93
|
+
/** Run a model-wide edit (a merge) as one undo step, then show it. */
|
|
94
|
+
runModelEdit(command) {
|
|
95
|
+
if (!command) return null;
|
|
96
|
+
this.model.history.execute(command);
|
|
97
|
+
this.afterModelEdit();
|
|
98
|
+
return command;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* File ▸: Open…, Save, Save As…, Import of what other tools wrote (its
|
|
103
|
+
* formats that only read), then with a ModelsPart New model, Duplicate,
|
|
104
|
+
* Close model, and where models are kept ▸.
|
|
105
|
+
*/
|
|
106
|
+
fileItems() {
|
|
107
|
+
const host = this.modelsHost;
|
|
108
|
+
return EditorPart.tidyMenu([
|
|
109
|
+
...this.file.menuItems(),
|
|
110
|
+
...EditorPart.menuGroup('Import', this.importItems(), { prefix: true }),
|
|
111
|
+
...(host ? [
|
|
112
|
+
{ separator: true },
|
|
113
|
+
...EditorPart.menuGroup('New', [{ label: 'New model', action: () => host.newModel() }, ...(host.baseItems?.() ?? [])]),
|
|
114
|
+
{ label: 'Duplicate model', action: () => host.duplicate(this) },
|
|
115
|
+
{ label: 'Close model', action: () => host.closeModel(this.id) },
|
|
116
|
+
...EditorPart.menuGroup(host.keptWhere ?? 'This browser', host.keepItems?.(this) ?? []),
|
|
117
|
+
] : []),
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Import ▸: each format that only reads (another tool's export), opened as a new model. */
|
|
122
|
+
importItems() {
|
|
123
|
+
return modelFileMenuItems(this.fileFormats().filter(f => f.exports === false), {
|
|
124
|
+
model: () => this.model, only: 'import', onImport: (model) => this.onImported(model), document: () => this.document,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Ask for a file, make a command from its bytes, and run it as one undo
|
|
130
|
+
* step; a file that cannot be read, or brings nothing, is reported.
|
|
131
|
+
* @param {Object} how
|
|
132
|
+
* @param {string} how.accept - The file chooser's filter
|
|
133
|
+
* @param {string} how.what - What is read, for the report ('the landscape')
|
|
134
|
+
* @param {(bytes: Uint8Array, name: string) => Command|null} how.command
|
|
135
|
+
* @param {(name: string) => string} how.nothing - The report when the command is null
|
|
136
|
+
* @param {() => void} [how.then] - What to show once it is run (before the redraw)
|
|
137
|
+
* @param {(accept: string) => Promise<File|null>} [how.chooseFile]
|
|
138
|
+
* @param {(message: string) => void} [how.report]
|
|
139
|
+
* @returns {Promise<Command|null>}
|
|
140
|
+
*/
|
|
141
|
+
async readInto({ accept, what, command: make, nothing, then = () => {}, chooseFile = null, report = null }) {
|
|
142
|
+
const doc = this.container?.ownerDocument ?? globalThis.document;
|
|
143
|
+
const tell = report ?? ((message) => (doc.defaultView ?? globalThis).alert(message));
|
|
144
|
+
const file = await (chooseFile ?? ((filter) => pickFile(filter, doc)))(accept);
|
|
145
|
+
if (!file) return null;
|
|
146
|
+
let command;
|
|
147
|
+
try {
|
|
148
|
+
command = make(new Uint8Array(await file.arrayBuffer()), file.name);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
tell(`Could not read ${what}: ${err.message}`);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
if (!command) {
|
|
154
|
+
tell(nothing(file.name));
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
then();
|
|
158
|
+
this.runModelEdit(command);
|
|
159
|
+
return command;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Undo and Redo, each named by what it undoes (a merge is one step). */
|
|
163
|
+
undoItems() {
|
|
164
|
+
const history = this.model.history;
|
|
165
|
+
return [
|
|
166
|
+
{ label: history.canUndo ? `Undo ${history.undoLabel}` : 'Undo', disabled: !history.canUndo || undefined, action: () => { history.undo(); this.afterModelEdit(); } },
|
|
167
|
+
{ label: history.canRedo ? `Redo ${history.redoLabel}` : 'Redo', disabled: !history.canRedo || undefined, action: () => { history.redo(); this.afterModelEdit(); } },
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
dispose() {
|
|
172
|
+
this._unsubscribeEdits?.();
|
|
173
|
+
this.file.restoreTitle();
|
|
174
|
+
super.dispose();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
@@ -8,6 +8,7 @@ import { downloadBlob } from '../../exportImage.js';
|
|
|
8
8
|
import { IndexedDbStore } from '../storage/KeyValueStore.js';
|
|
9
9
|
import { ModelLibrary } from '../storage/ModelLibrary.js';
|
|
10
10
|
import { BackupFolder } from '../storage/BackupFolder.js';
|
|
11
|
+
import { Benchmark } from '../../charting/model/Benchmark.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* ModelsPart — several models open at once (as Archi's model tree holds
|
|
@@ -41,6 +42,23 @@ import { BackupFolder } from '../storage/BackupFolder.js';
|
|
|
41
42
|
* closed open again next time. File ▸ This browser opens a kept model, stops
|
|
42
43
|
* keeping one, backs them all up (BackupFolder: a folder chosen once, such as
|
|
43
44
|
* one OneDrive or Google Drive syncs, else downloads) and chooses that folder.
|
|
45
|
+
*
|
|
46
|
+
* Where models are kept is the host's to say: a `modelLibrary` among the
|
|
47
|
+
* workspace's services (`workspace.services`, reaching this part as
|
|
48
|
+
* `ctx.modelLibrary`) is used in place of the browser's — anything with
|
|
49
|
+
* ModelLibrary's methods (keep, load, list, openOnes, setOpen, remove) and a
|
|
50
|
+
* `label` for the menu ('The engine'). An entry is the model in its editor's
|
|
51
|
+
* file format, so a host stores it as it likes: a database row, a file, a
|
|
52
|
+
* graph built from it. The backup folder stays in the browser.
|
|
53
|
+
*
|
|
54
|
+
* Proposals: a model offered as a change to the one in front — by an agent,
|
|
55
|
+
* a colleague, an import — with why (`propose`). Tools ▸ Proposals lists them;
|
|
56
|
+
* each is reviewed as a merge (ModelMergeDialog, its rationale shown), so what
|
|
57
|
+
* is taken is chosen and is one undo step. Taking or dismissing one removes it.
|
|
58
|
+
*
|
|
59
|
+
* A host adds tools of its own for the model in front — an agent to run on
|
|
60
|
+
* it, say — as `modelTools(part, models)` among the workspace's services: its
|
|
61
|
+
* items end the Tools menu.
|
|
44
62
|
*/
|
|
45
63
|
export class ModelsPart extends Part {
|
|
46
64
|
static type = 'models';
|
|
@@ -58,6 +76,9 @@ export class ModelsPart extends Part {
|
|
|
58
76
|
this.backups = null;
|
|
59
77
|
/** Edits waiting to be kept, by part id. @private */
|
|
60
78
|
this._pending = new Map();
|
|
79
|
+
/** Changes offered, not yet taken or dismissed (propose). */
|
|
80
|
+
this.proposals = [];
|
|
81
|
+
this._proposalSeq = 0;
|
|
61
82
|
}
|
|
62
83
|
|
|
63
84
|
/** What a copy's name ends with (copyName). */
|
|
@@ -104,8 +125,8 @@ export class ModelsPart extends Part {
|
|
|
104
125
|
* @param {Array<EditorPart>} starting - The models the spec opened
|
|
105
126
|
*/
|
|
106
127
|
async startKeeping(starting = []) {
|
|
107
|
-
const store = await this.openStore();
|
|
108
|
-
this.library = new ModelLibrary(store);
|
|
128
|
+
const store = await this.openStore(); // the backup folder's, whoever keeps the models
|
|
129
|
+
this.library = this.ctx?.modelLibrary ?? new ModelLibrary(store);
|
|
109
130
|
const win = this.container?.ownerDocument?.defaultView ?? globalThis;
|
|
110
131
|
this.backups = new BackupFolder({
|
|
111
132
|
store, window: win,
|
|
@@ -195,7 +216,7 @@ export class ModelsPart extends Part {
|
|
|
195
216
|
async askOpenKept() {
|
|
196
217
|
const open = new Set(this.models().map(p => p.keepId));
|
|
197
218
|
const entries = this.ownKept(await this.library.list()).filter(e => !open.has(e.id));
|
|
198
|
-
if (!entries.length) { this.tell(
|
|
219
|
+
if (!entries.length) { this.tell(`Every model kept (${this.keptWhere}) is open.`); return null; }
|
|
199
220
|
return new FormDialog({
|
|
200
221
|
title: 'Open a kept model',
|
|
201
222
|
fields: [{
|
|
@@ -234,7 +255,7 @@ export class ModelsPart extends Part {
|
|
|
234
255
|
async backUpAndTell() {
|
|
235
256
|
for (const part of this.models()) await this.keep(part);
|
|
236
257
|
const files = await this.backupFiles();
|
|
237
|
-
if (!files.length) { this.tell(
|
|
258
|
+
if (!files.length) { this.tell(`Nothing is kept (${this.keptWhere}) yet.`); return null; }
|
|
238
259
|
const { location, names } = await this.backups.backUp(files);
|
|
239
260
|
this.tell(`Backed up ${names.length} model${names.length === 1 ? '' : 's'} to the ${location}:\n\n${names.join('\n')}`);
|
|
240
261
|
return { location, names };
|
|
@@ -247,6 +268,83 @@ export class ModelsPart extends Part {
|
|
|
247
268
|
return dir;
|
|
248
269
|
}
|
|
249
270
|
|
|
271
|
+
/** Where models are kept, as the File menu names it: the library's label. */
|
|
272
|
+
get keptWhere() {
|
|
273
|
+
return this.library?.label ?? 'This browser';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ===== Proposals =====
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Offer a change: the model proposed — `spec`, a model as this workbench's
|
|
280
|
+
* editor takes it (as a base is given), or `entry`, a model in one of the
|
|
281
|
+
* editor's file formats (`{format, data}`, as a kept one is) — with a
|
|
282
|
+
* `title` and why (`rationale`), from `source` (who or what proposed it).
|
|
283
|
+
* `onSettle(outcome, {bench})` hears whether it was 'taken' or
|
|
284
|
+
* 'dismissed' and, taken, how what was taken moved the bench. Returns the proposal.
|
|
285
|
+
* @param {{title: string, rationale?: string, source?: string, spec?: Object, entry?: {format: string, data: *}, onSettle?: Function}} proposal
|
|
286
|
+
*/
|
|
287
|
+
propose({ title, rationale = '', source = '', spec = null, entry = null, onSettle = null }) {
|
|
288
|
+
if (!spec && !entry) throw new TypeError('A proposal needs a model spec');
|
|
289
|
+
const id = `${this.id}-proposal-${++this._proposalSeq}`;
|
|
290
|
+
const part = this.registry.create({ ...ModelsPart.copySpec(spec ?? {}), id, type: this.editorType });
|
|
291
|
+
if (entry) {
|
|
292
|
+
const format = part.fileFormats().find(f => f.label === entry.format);
|
|
293
|
+
if (!format) { part.dispose?.(); throw new Error(`No ${entry.format} format here to read the proposal in`); }
|
|
294
|
+
part.replaceModel(format.import(entry.data));
|
|
295
|
+
}
|
|
296
|
+
const proposal = { id, title: title || part.model?.name || 'Proposal', rationale, source, part, onSettle };
|
|
297
|
+
this.proposals.push(proposal);
|
|
298
|
+
return proposal;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* How the proposal would move the model's measures (Benchmark), were it
|
|
303
|
+
* taken whole: `[{label, before, after, change, …}]`.
|
|
304
|
+
*/
|
|
305
|
+
proposalBench(part, proposal) {
|
|
306
|
+
return Benchmark.compare(Benchmark.of(part.model), Benchmark.of(proposal.part.model));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Forget a proposal: not wanted (`outcome` 'dismissed'), or taken. Its proposer hears which. */
|
|
310
|
+
dismissProposal(proposal, outcome = 'dismissed', note = {}) {
|
|
311
|
+
if (!this.proposals.includes(proposal)) return;
|
|
312
|
+
this.proposals = this.proposals.filter(p => p !== proposal);
|
|
313
|
+
proposal.onSettle?.(outcome, note);
|
|
314
|
+
proposal.part?.dispose?.();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Review a proposal against `part`'s model: its differences, chosen as a
|
|
319
|
+
* merge (ModelMergeDialog), its rationale above them. Merging takes it.
|
|
320
|
+
*/
|
|
321
|
+
reviewProposal(part, proposal) {
|
|
322
|
+
const bench = this.proposalBench(part, proposal);
|
|
323
|
+
return new ModelMergeDialog({
|
|
324
|
+
...this._comparing(part, proposal.part),
|
|
325
|
+
theirsName: proposal.title,
|
|
326
|
+
note: [proposal.source && `Proposed by ${proposal.source}.`, proposal.rationale, bench.length && `Taken whole: ${Benchmark.text(bench)}.`].filter(Boolean).join(' '),
|
|
327
|
+
onMerge: (command) => {
|
|
328
|
+
// What was taken (the parts chosen), measured: the model before the merge and after.
|
|
329
|
+
const before = Benchmark.of(part.model);
|
|
330
|
+
part.runModelEdit(command);
|
|
331
|
+
this.dismissProposal(proposal, 'taken', { bench: Benchmark.text(Benchmark.compare(before, Benchmark.of(part.model))) });
|
|
332
|
+
},
|
|
333
|
+
}).open();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Tools ▸ Proposals: each, to review or dismiss. */
|
|
337
|
+
proposalItems(part) {
|
|
338
|
+
if (!this.proposals.length) return [{ label: 'None waiting', disabled: true }];
|
|
339
|
+
return this.proposals.map(p => ({
|
|
340
|
+
label: p.source ? `${p.title} — ${p.source}` : p.title,
|
|
341
|
+
items: [
|
|
342
|
+
{ label: 'Review and merge…', action: () => this.reviewProposal(part, p) },
|
|
343
|
+
{ label: 'Dismiss', action: () => this.dismissProposal(p) },
|
|
344
|
+
],
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
|
|
250
348
|
/** Tell the user (the page's alert). @protected */
|
|
251
349
|
tell(message) {
|
|
252
350
|
const win = this.container?.ownerDocument?.defaultView ?? globalThis;
|
|
@@ -301,6 +399,19 @@ export class ModelsPart extends Part {
|
|
|
301
399
|
this.registry.create({ ...ModelsPart.copySpec(spec), id: `${this.id}-base-${i + 1}`, type: this.editorType })));
|
|
302
400
|
}
|
|
303
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Replace the bases: a host whose reference models change (a live
|
|
404
|
+
* database after its schema changed) gives them afresh. The parts made
|
|
405
|
+
* for the old ones are disposed; nothing open is touched.
|
|
406
|
+
* @param {Array<Object>} specs - Editor part specs, as `bases` in the spec
|
|
407
|
+
*/
|
|
408
|
+
setBases(specs = []) {
|
|
409
|
+
for (const base of this._bases ?? []) base.dispose?.();
|
|
410
|
+
this._bases = null;
|
|
411
|
+
this.spec.bases = specs;
|
|
412
|
+
return this.bases();
|
|
413
|
+
}
|
|
414
|
+
|
|
304
415
|
/** What a copy of a model is called: " (copy)" after its name, once (a copy of a copy is not "… (copy) (copy)"). */
|
|
305
416
|
static copyName(name) {
|
|
306
417
|
const plain = String(name || 'Untitled');
|
|
@@ -327,9 +438,33 @@ export class ModelsPart extends Part {
|
|
|
327
438
|
return part;
|
|
328
439
|
}
|
|
329
440
|
|
|
330
|
-
/**
|
|
441
|
+
/**
|
|
442
|
+
* Open a base to look at (by its place in `bases`): a tab of its own copy,
|
|
443
|
+
* named as the base is with " (view)", that can be laid out and moved
|
|
444
|
+
* about but is never kept — a live database seen as it is, without making a
|
|
445
|
+
* design of it. Returns the part.
|
|
446
|
+
*/
|
|
447
|
+
viewBase(index) {
|
|
448
|
+
const spec = this.bases()[index]?.spec;
|
|
449
|
+
if (!spec) return null;
|
|
450
|
+
const { id, keepId, ...copy } = ModelsPart.copySpec(spec);
|
|
451
|
+
const part = this.newModel({ ...copy, name: `${copy.name ?? this.baseLabel(this.bases()[index], index)}${ModelsPart.VIEW_SUFFIX}` });
|
|
452
|
+
part.keepOff = true;
|
|
453
|
+
this.work.focus(part.id);
|
|
454
|
+
return part;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** What a base viewed is called: " (view)" after its name. */
|
|
458
|
+
static VIEW_SUFFIX = ' (view)';
|
|
459
|
+
|
|
460
|
+
/** File ▸ New ▸'s items from bases: From base: <name>, a design from one; View base: <name>, one to look at. */
|
|
331
461
|
baseItems() {
|
|
332
|
-
|
|
462
|
+
const bases = this.bases();
|
|
463
|
+
return [
|
|
464
|
+
...bases.map((base, i) => ({ label: `From base: ${this.baseLabel(base, i)}`, action: () => this.newFromBase(i) })),
|
|
465
|
+
...(bases.length ? [{ separator: true }] : []),
|
|
466
|
+
...bases.map((base, i) => ({ label: `View base: ${this.baseLabel(base, i)}`, action: () => this.viewBase(i) })),
|
|
467
|
+
];
|
|
333
468
|
}
|
|
334
469
|
|
|
335
470
|
/**
|
|
@@ -362,6 +497,10 @@ export class ModelsPart extends Part {
|
|
|
362
497
|
return [
|
|
363
498
|
{ label: 'Compare with', items: each((a, b) => this.compare(a, b)) },
|
|
364
499
|
{ label: 'Merge from', items: each((a, b) => this.merge(a, b)) },
|
|
500
|
+
{ separator: true },
|
|
501
|
+
{ label: `Proposals (${this.proposals.length})`, items: this.proposalItems(part) },
|
|
502
|
+
// The host's own tools for the model in front (`services.modelTools(part, models)`: agents, say).
|
|
503
|
+
...(typeof this.ctx?.modelTools === 'function' ? [{ separator: true }, ...this.ctx.modelTools(part, this)] : []),
|
|
365
504
|
];
|
|
366
505
|
}
|
|
367
506
|
|
|
@@ -446,7 +585,7 @@ export class ModelsPart extends Part {
|
|
|
446
585
|
return [
|
|
447
586
|
...(saves ? [save, saveAs, { separator: true }, { label: 'Duplicate model', action: () => this.duplicate(part) }] : []),
|
|
448
587
|
...(compare ? [compare, merge] : []),
|
|
449
|
-
...(keeping.length ? [{ label:
|
|
588
|
+
...(keeping.length ? [{ label: this.keptWhere, items: keeping }] : []),
|
|
450
589
|
{ separator: true },
|
|
451
590
|
{ label: 'Close model', action: () => this.closeModel(part.id) },
|
|
452
591
|
{ label: 'Close other models', disabled: !others.length || undefined, action: () => { for (const id of others) this.closeModel(id); } },
|