@peter.prib/renderengine 0.2.6 → 0.2.10
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 +70 -9
- package/REFERENCE.md +303 -2
- package/dist/renderengine-workspace.umd.js +8851 -3305
- package/dist/renderengine.umd.js +5621 -1382
- package/examples/archimate.html +68 -66
- package/examples/data-model.html +251 -19
- package/package.json +1 -1
- package/src/bian.js +182 -0
- package/src/charting/DiagramEditor.js +5 -26
- package/src/charting/DiagramTypeRegistry.js +8 -2
- package/src/charting/FormDialog.js +398 -385
- package/src/charting/ModelCheckDialog.js +63 -0
- package/src/charting/ModelChecker.js +26 -0
- package/src/charting/RowsField.js +127 -0
- package/src/charting/ViewTabStrip.js +94 -81
- package/src/charting/datamodel/BianLandscapeReader.js +185 -0
- package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
- package/src/charting/datamodel/ClassificationLens.js +33 -0
- package/src/charting/datamodel/ClassificationScheme.js +98 -0
- package/src/charting/datamodel/DataModel.js +574 -12
- package/src/charting/datamodel/DataModelChecker.js +338 -0
- package/src/charting/datamodel/DataModelCompared.js +127 -0
- package/src/charting/datamodel/DataModelContext.js +40 -10
- package/src/charting/datamodel/DataModelEditCommand.js +47 -0
- package/src/charting/datamodel/DataModelFileFormat.js +33 -0
- package/src/charting/datamodel/DataModelPerspective.js +55 -0
- package/src/charting/datamodel/DataType.js +108 -0
- package/src/charting/datamodel/EnumerationType.js +64 -0
- package/src/charting/datamodel/ServiceDomain.js +186 -0
- package/src/charting/datamodel/ServiceLandscape.js +104 -0
- package/src/charting/datamodel/SqlDdl.js +80 -0
- package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
- package/src/charting/datamodel/StructuredType.js +63 -0
- package/src/charting/datamodel/index.js +21 -5
- package/src/charting/datamodel/naming/CamelCaseStyle.js +13 -0
- package/src/charting/datamodel/naming/LowerWordsStyle.js +13 -0
- package/src/charting/datamodel/naming/NameStyle.js +57 -0
- package/src/charting/datamodel/naming/NamingStandard.js +132 -0
- package/src/charting/datamodel/naming/PascalCaseStyle.js +13 -0
- package/src/charting/datamodel/naming/SnakeCaseStyle.js +13 -0
- package/src/charting/datamodel/naming/TitleCaseStyle.js +25 -0
- package/src/charting/datamodel/naming/UpperSnakeStyle.js +13 -0
- package/src/charting/datamodel/naming/index.js +8 -0
- package/src/charting/datamodel/scorecard/CompletenessCategory.js +30 -0
- package/src/charting/datamodel/scorecard/ConsistencyCategory.js +33 -0
- package/src/charting/datamodel/scorecard/DefinitionsCategory.js +33 -0
- package/src/charting/datamodel/scorecard/ModelScorecard.js +113 -0
- package/src/charting/datamodel/scorecard/ReadabilityCategory.js +37 -0
- package/src/charting/datamodel/scorecard/ScorecardCategory.js +46 -0
- package/src/charting/datamodel/scorecard/StandardsCategory.js +29 -0
- package/src/charting/datamodel/scorecard/StructureCategory.js +32 -0
- package/src/charting/datamodel/scorecard/index.js +8 -0
- package/src/charting/diagramModel/MergeModelCommand.js +43 -37
- package/src/charting/diagramModel/OpenViewList.js +75 -0
- package/src/charting/diagramModel/ViewSetChecker.js +22 -34
- package/src/charting/diagrams/DataModelDiagram.js +4 -11
- package/src/charting/diagrams/DataModelRelationshipStrategy.js +19 -5
- package/src/charting/diagrams/EdgeGeometry.js +578 -553
- package/src/charting/diagrams/GraphDiagram.js +11 -1
- package/src/charting/diagrams/NodeGeometry.js +173 -158
- package/src/charting/glyphs/PersonGlyph.js +24 -23
- package/src/charting/toolbar/ToolbarSelect.js +6 -0
- package/src/datamodel.js +402 -4
- package/src/index.js +6 -4
- package/src/workspace/EditorShell.js +166 -67
- package/src/workspace/PanelLayout.js +56 -0
- package/src/workspace/layout/TabsNode.js +205 -191
- package/src/workspace/parts/ArchimateCanvasPart.js +5 -7
- package/src/workspace/parts/DataModelPart.js +534 -33
- package/src/workspace/parts/DiagramEditorPart.js +2 -8
- package/src/workspace/parts/EditorPart.js +461 -407
- package/src/workspace/parts/ModelFile.js +216 -187
- package/src/workspace/parts/ModelsPart.js +483 -192
- package/src/workspace/storage/BackupFolder.js +145 -0
- package/src/workspace/storage/KeyValueStore.js +92 -0
- package/src/workspace/storage/ModelLibrary.js +65 -0
- package/src/workspace/workArea.js +417 -413
- package/src/yaml/YamlReader.js +372 -0
|
@@ -1,192 +1,483 @@
|
|
|
1
|
-
import { Part } from '../Part.js';
|
|
2
|
-
import { WorkArea } from '../workArea.js';
|
|
3
|
-
import { el } from '../dom.js';
|
|
4
|
-
import { ModelCompareDialog, ModelMergeDialog } from '../../charting/ModelComparisonDialog.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* a
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
this.
|
|
76
|
-
this.work
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
this.work.focus(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
-
return
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
1
|
+
import { Part } from '../Part.js';
|
|
2
|
+
import { WorkArea } from '../workArea.js';
|
|
3
|
+
import { el } from '../dom.js';
|
|
4
|
+
import { ModelCompareDialog, ModelMergeDialog } from '../../charting/ModelComparisonDialog.js';
|
|
5
|
+
import { FormDialog } from '../../charting/FormDialog.js';
|
|
6
|
+
import { PopupMenu } from '../../charting/PopupMenu.js';
|
|
7
|
+
import { downloadBlob } from '../../exportImage.js';
|
|
8
|
+
import { IndexedDbStore } from '../storage/KeyValueStore.js';
|
|
9
|
+
import { ModelLibrary } from '../storage/ModelLibrary.js';
|
|
10
|
+
import { BackupFolder } from '../storage/BackupFolder.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ModelsPart — several models open at once (as Archi's model tree holds
|
|
14
|
+
* several): one editor part per model (any EditorPart type, `editorType`),
|
|
15
|
+
* each a tab in a work area, so each keeps its own file, unsaved state and
|
|
16
|
+
* undo history, and two can sit side by side. Copy and paste go between
|
|
17
|
+
* them as between any two editors.
|
|
18
|
+
*
|
|
19
|
+
* The editor parts it hosts find it as `ctx.models` and offer, in their File
|
|
20
|
+
* menu, New model, Open… in a new tab and Close model; a model imported from
|
|
21
|
+
* a file opens in a tab of its own. Their Tools menu works across the open
|
|
22
|
+
* models (toolItems): compare one with another, and merge from another into
|
|
23
|
+
* it (ModelComparison). A tab shows its file's
|
|
24
|
+
* name and • while it has unsaved changes, and right-clicked, the menu of
|
|
25
|
+
* what can be done with that model (tabMenuItems); closing one with unsaved changes
|
|
26
|
+
* asks first; the page title follows the model in front. The last model
|
|
27
|
+
* closed leaves a new, empty one.
|
|
28
|
+
*
|
|
29
|
+
* Spec: `{ id, type: 'models', editorType, models?: [editor part spec], newModel?: editor part spec, bases?: [editor part spec], keep? }`:
|
|
30
|
+
* `models` are opened at the start (one empty one without), `newModel` is what New model makes.
|
|
31
|
+
*
|
|
32
|
+
* `bases` are reference models kept as given (never opened, edited or kept):
|
|
33
|
+
* File ▸ New ▸ From base makes a model as a copy of one, and Tools ▸ Compare
|
|
34
|
+
* with and Merge from offer them beside the other open models. File ▸
|
|
35
|
+
* Duplicate model opens a copy of the model in front, read back through its
|
|
36
|
+
* own file format.
|
|
37
|
+
*
|
|
38
|
+
* With `keep`, every model is kept in this browser (ModelLibrary over an
|
|
39
|
+
* IndexedDB store) a moment after each edit, in its editor's file format, so
|
|
40
|
+
* work outlives the page without saving a file; those open when the page
|
|
41
|
+
* closed open again next time. File ▸ This browser opens a kept model, stops
|
|
42
|
+
* keeping one, backs them all up (BackupFolder: a folder chosen once, such as
|
|
43
|
+
* one OneDrive or Google Drive syncs, else downloads) and chooses that folder.
|
|
44
|
+
*/
|
|
45
|
+
export class ModelsPart extends Part {
|
|
46
|
+
static type = 'models';
|
|
47
|
+
|
|
48
|
+
constructor(spec = {}) {
|
|
49
|
+
super(spec);
|
|
50
|
+
if (!spec.editorType) throw new TypeError('ModelsPart needs an editorType (the editor part type, e.g. "archimate-canvas")');
|
|
51
|
+
this.editorType = spec.editorType;
|
|
52
|
+
/** The ids of the editor parts open here, in the order opened. */
|
|
53
|
+
this.modelIds = [];
|
|
54
|
+
this.work = null;
|
|
55
|
+
this._next = 1;
|
|
56
|
+
/** With `keep`: the kept models and where backups go (set once the store is open). */
|
|
57
|
+
this.library = null;
|
|
58
|
+
this.backups = null;
|
|
59
|
+
/** Edits waiting to be kept, by part id. @private */
|
|
60
|
+
this._pending = new Map();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What a copy's name ends with (copyName). */
|
|
64
|
+
static COPY_SUFFIX = ' (copy)';
|
|
65
|
+
|
|
66
|
+
/** How long after an edit a model is kept (an edit's bursts make one write). */
|
|
67
|
+
static KEEP_DELAY = 800;
|
|
68
|
+
|
|
69
|
+
get registry() {
|
|
70
|
+
return this.ctx?.registry ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
render() {
|
|
74
|
+
Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', height: '100%' });
|
|
75
|
+
const host = this.container.appendChild(el({ style: { flex: '1 1 auto', minHeight: '0' } }));
|
|
76
|
+
this.work = new WorkArea({
|
|
77
|
+
placeholder: { cls: 'ws-workarea-empty', text: 'No model open' },
|
|
78
|
+
// A tab's × asks first when its model has unsaved changes (closeModel closes it).
|
|
79
|
+
beforeClose: (id) => { this.closeModel(id); return false; },
|
|
80
|
+
// A tab right-clicked: what can be done with its model.
|
|
81
|
+
tabMenu: (id, event) => this.showTabMenu(id, event),
|
|
82
|
+
onChange: () => this.activeModel()?.file?.showState?.(),
|
|
83
|
+
});
|
|
84
|
+
this.work.render(host, { ...this.ctx, models: this });
|
|
85
|
+
this.bases(); // copied now, before an open model (which may share the spec's data) is edited
|
|
86
|
+
const specs = this.spec.models?.length ? this.spec.models : [this.spec.newModel ?? {}];
|
|
87
|
+
const parts = specs.map(spec => this.newModel(spec));
|
|
88
|
+
if (parts[0]) this.work.focus(parts[0].id);
|
|
89
|
+
/** Resolves once kept models are reopened (with `keep`). */
|
|
90
|
+
this.ready = this.spec.keep ? this.startKeeping(parts) : Promise.resolve();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ===== Kept in this browser =====
|
|
94
|
+
|
|
95
|
+
/** The store models and the backup folder are kept in: the browser's IndexedDB. A test gives its own. */
|
|
96
|
+
openStore() {
|
|
97
|
+
return IndexedDbStore.open('renderengine-models');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Open the store, reopen the models open when the page last closed (in
|
|
102
|
+
* place of the starting ones, unless those were changed meanwhile), and keep
|
|
103
|
+
* what is open now.
|
|
104
|
+
* @param {Array<EditorPart>} starting - The models the spec opened
|
|
105
|
+
*/
|
|
106
|
+
async startKeeping(starting = []) {
|
|
107
|
+
const store = await this.openStore();
|
|
108
|
+
this.library = new ModelLibrary(store);
|
|
109
|
+
const win = this.container?.ownerDocument?.defaultView ?? globalThis;
|
|
110
|
+
this.backups = new BackupFolder({
|
|
111
|
+
store, window: win,
|
|
112
|
+
download: (name, data, mimeType) => downloadBlob(new (win.Blob ?? Blob)([data], { type: mimeType }), name),
|
|
113
|
+
});
|
|
114
|
+
await this.backups.restore();
|
|
115
|
+
const kept = this.ownKept(await this.library.openOnes());
|
|
116
|
+
for (const { id } of kept) {
|
|
117
|
+
const entry = await this.library.load(id); // the list has no data; the entry has
|
|
118
|
+
if (entry) this.openKept(entry);
|
|
119
|
+
}
|
|
120
|
+
if (kept.length) for (const part of starting) if (!part.model?.isDirty?.()) this.closeModel(part.id);
|
|
121
|
+
for (const part of this.models()) if (part.keepId) await this.keep(part);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Keep a model in this browser now (its editor's keepEntry), as open. */
|
|
125
|
+
async keep(part) {
|
|
126
|
+
if (!this.library || part.keepOff) return null;
|
|
127
|
+
const entry = part.keepEntry?.();
|
|
128
|
+
if (!entry) return null;
|
|
129
|
+
part.keepId ??= ModelLibrary.newId();
|
|
130
|
+
return this.library.keep({ ...entry, id: part.keepId, editorType: this.editorType, open: this.modelIds.includes(part.id) });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A model was edited: keep it a moment later (a burst of edits, one write). */
|
|
134
|
+
modelEdited(part) {
|
|
135
|
+
if (!this.library) return;
|
|
136
|
+
clearTimeout(this._pending.get(part.id));
|
|
137
|
+
this._pending.set(part.id, setTimeout(() => { this._pending.delete(part.id); this.keep(part); }, ModelsPart.KEEP_DELAY));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The kept models that are this workbench's: kept by its kind of editor
|
|
142
|
+
* (`editorType`), or (kept before that was recorded) in one of its formats.
|
|
143
|
+
* Pages of other editors keep theirs in the same browser; those are not read here.
|
|
144
|
+
*/
|
|
145
|
+
ownKept(entries) {
|
|
146
|
+
const labels = new Set(this.formatLabels());
|
|
147
|
+
return entries.filter(e => (e.editorType ? e.editorType === this.editorType : labels.has(e.format)));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The labels of this workbench's editors' file formats. */
|
|
151
|
+
formatLabels() {
|
|
152
|
+
const sample = this.models()[0] ?? this.bases()[0];
|
|
153
|
+
return (sample?.fileFormats?.() ?? []).map(f => f.label);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Open a kept model in a new tab, read in its format. Returns the part, or null when it is not one this editor reads. */
|
|
157
|
+
openKept(entry) {
|
|
158
|
+
if (!this.ownKept([entry]).length) {
|
|
159
|
+
this.tell(`"${entry.name}" was kept by another editor (${entry.editorType ?? entry.format}); open it there.`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const part = this.newModel();
|
|
163
|
+
const format = part.fileFormats().find(f => f.label === entry.format);
|
|
164
|
+
try {
|
|
165
|
+
part.replaceModel(format.import(entry.data));
|
|
166
|
+
} catch (err) {
|
|
167
|
+
this.closeModel(part.id);
|
|
168
|
+
this.tell(`"${entry.name}" could not be read back: ${err.message}`);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
part.keepId = entry.id;
|
|
172
|
+
part.file.name = entry.fileName ?? null;
|
|
173
|
+
part.file.showState();
|
|
174
|
+
this.work.focus(part.id);
|
|
175
|
+
return part;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* File ▸ This browser, with `keep`: open a kept model, stop keeping this
|
|
180
|
+
* one, back up every kept model, choose where backups go.
|
|
181
|
+
*/
|
|
182
|
+
keepItems(part) {
|
|
183
|
+
if (!this.library) return [];
|
|
184
|
+
return [
|
|
185
|
+
{ label: 'Open a kept model…', action: () => this.askOpenKept() },
|
|
186
|
+
{ label: 'Stop keeping this model', disabled: !part.keepId || undefined, action: () => this.stopKeeping(part) },
|
|
187
|
+
{ separator: true },
|
|
188
|
+
{ label: 'Back up kept models now', action: () => this.backUpAndTell() },
|
|
189
|
+
{ label: `Backup folder: ${this.backups.location}…`, disabled: !this.backups.supported || undefined, action: () => this.chooseBackupFolder() },
|
|
190
|
+
...(this.backups.dir ? [{ label: 'Back up to downloads instead', action: () => this.backups.forget() }] : []),
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Choose a kept model not open now, and open it. */
|
|
195
|
+
async askOpenKept() {
|
|
196
|
+
const open = new Set(this.models().map(p => p.keepId));
|
|
197
|
+
const entries = this.ownKept(await this.library.list()).filter(e => !open.has(e.id));
|
|
198
|
+
if (!entries.length) { this.tell('Every model kept in this browser is open.'); return null; }
|
|
199
|
+
return new FormDialog({
|
|
200
|
+
title: 'Open a kept model',
|
|
201
|
+
fields: [{
|
|
202
|
+
key: 'id', label: 'Model', type: 'select', value: entries[0].id,
|
|
203
|
+
choices: entries.map(e => [e.id, `${e.name} (kept ${new Date(e.keptAt).toLocaleString()})`]),
|
|
204
|
+
}],
|
|
205
|
+
submitLabel: 'Open', idPrefix: 'kept-',
|
|
206
|
+
document: this.container?.ownerDocument ?? globalThis.document,
|
|
207
|
+
onApply: async ({ id }) => { const entry = await this.library.load(id); if (entry) this.openKept(entry); },
|
|
208
|
+
}).open();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Forget a model in this browser, and keep it no more. */
|
|
212
|
+
async stopKeeping(part) {
|
|
213
|
+
if (part.keepId) await this.library.remove(part.keepId);
|
|
214
|
+
part.keepId = null;
|
|
215
|
+
part.keepOff = true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Every kept model as a backup file: its name, in its format's extension. */
|
|
219
|
+
async backupFiles() {
|
|
220
|
+
const sample = this.models()[0];
|
|
221
|
+
const formats = sample?.fileFormats?.() ?? [];
|
|
222
|
+
const files = [];
|
|
223
|
+
for (const meta of this.ownKept(await this.library.list())) {
|
|
224
|
+
const entry = await this.library.load(meta.id);
|
|
225
|
+
const format = formats.find(f => f.label === entry.format);
|
|
226
|
+
const ext = (format?.accept ?? '').split(',')[0].trim() || '';
|
|
227
|
+
const base = String(entry.fileName ?? entry.name ?? 'model').replace(/\.[^.]+$/, '').replace(/[^A-Za-z0-9._-]+/g, '-') || 'model';
|
|
228
|
+
files.push({ name: `${base}${ext}`, data: entry.data, mimeType: 'application/octet-stream' });
|
|
229
|
+
}
|
|
230
|
+
return files;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Back up every kept model (what is open is kept first), and say where. */
|
|
234
|
+
async backUpAndTell() {
|
|
235
|
+
for (const part of this.models()) await this.keep(part);
|
|
236
|
+
const files = await this.backupFiles();
|
|
237
|
+
if (!files.length) { this.tell('Nothing is kept in this browser yet.'); return null; }
|
|
238
|
+
const { location, names } = await this.backups.backUp(files);
|
|
239
|
+
this.tell(`Backed up ${names.length} model${names.length === 1 ? '' : 's'} to the ${location}:\n\n${names.join('\n')}`);
|
|
240
|
+
return { location, names };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Choose the backup folder (a click); one OneDrive, Google Drive or Dropbox syncs takes the backups to the cloud. */
|
|
244
|
+
async chooseBackupFolder() {
|
|
245
|
+
const dir = await this.backups.choose();
|
|
246
|
+
if (dir) this.tell(`Backups go into "${dir.name}" from now on, each named with the date and time.\n\nA folder that OneDrive, Google Drive or Dropbox syncs takes them to the cloud.`);
|
|
247
|
+
return dir;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Tell the user (the page's alert). @protected */
|
|
251
|
+
tell(message) {
|
|
252
|
+
const win = this.container?.ownerDocument?.defaultView ?? globalThis;
|
|
253
|
+
win.alert?.(message);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The editor parts open here, in order. */
|
|
257
|
+
models() {
|
|
258
|
+
return this.modelIds.map(id => this.registry?.get(id)).filter(Boolean);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The editor part of the model in front (the selected tab of the active group). */
|
|
262
|
+
activeModel() {
|
|
263
|
+
const id = this.work?.activePart();
|
|
264
|
+
return id ? this.registry?.get(id) ?? null : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Open a new model in a tab: an editor part of `editorType` made from `spec`
|
|
269
|
+
* (default the spec's `newModel`, an empty model). Returns the part.
|
|
270
|
+
*/
|
|
271
|
+
newModel(spec = this.spec.newModel ?? {}) {
|
|
272
|
+
const id = spec.id && !this.registry.has(spec.id) ? spec.id : this._freshId();
|
|
273
|
+
const part = this.registry.add({ ...spec, id, type: this.editorType });
|
|
274
|
+
this.modelIds.push(id);
|
|
275
|
+
this.work.open(id);
|
|
276
|
+
this.fileStateChanged(part);
|
|
277
|
+
return part;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Open a model made elsewhere (one imported from a file) in a new tab,
|
|
282
|
+
* as it is: nothing to undo, not yet saved to a file. Returns the part.
|
|
283
|
+
*/
|
|
284
|
+
openModel(model) {
|
|
285
|
+
const part = this.newModel();
|
|
286
|
+
part.replaceModel(model);
|
|
287
|
+
this.work.focus(part.id);
|
|
288
|
+
this.fileStateChanged(part);
|
|
289
|
+
this.keep(part); // an import is kept straight away
|
|
290
|
+
return part;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ===== Bases, and copies =====
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The base models (spec `bases`): a part each, made from a copy of its spec
|
|
297
|
+
* and never added to the workbench, so nothing done to an open model reaches it.
|
|
298
|
+
*/
|
|
299
|
+
bases() {
|
|
300
|
+
return (this._bases ??= (this.spec.bases ?? []).map((spec, i) =>
|
|
301
|
+
this.registry.create({ ...ModelsPart.copySpec(spec), id: `${this.id}-base-${i + 1}`, type: this.editorType })));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** What a copy of a model is called: " (copy)" after its name, once (a copy of a copy is not "… (copy) (copy)"). */
|
|
305
|
+
static copyName(name) {
|
|
306
|
+
const plain = String(name || 'Untitled');
|
|
307
|
+
return plain.endsWith(ModelsPart.COPY_SUFFIX) ? plain : `${plain}${ModelsPart.COPY_SUFFIX}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A spec's own copy: its data apart from the spec's (a spec is JSON). */
|
|
311
|
+
static copySpec(spec) {
|
|
312
|
+
return JSON.parse(JSON.stringify(spec ?? {}));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** What a base is called in menus. */
|
|
316
|
+
baseLabel(base, i) {
|
|
317
|
+
return base.model?.name || base.spec.name || `Base ${i + 1}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Open a new model as a copy of a base (by its place in `bases`), named as a copy (copyName). Returns the part. */
|
|
321
|
+
newFromBase(index) {
|
|
322
|
+
const spec = this.bases()[index]?.spec; // the base's own copy, as given
|
|
323
|
+
if (!spec) return null;
|
|
324
|
+
const { id, keepId, ...copy } = ModelsPart.copySpec(spec);
|
|
325
|
+
const part = this.newModel({ ...copy, name: ModelsPart.copyName(copy.name ?? this.baseLabel(this.bases()[index], index)) });
|
|
326
|
+
this.work.focus(part.id);
|
|
327
|
+
return part;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** File ▸ New ▸'s items from bases: From base: <name>, one a base. */
|
|
331
|
+
baseItems() {
|
|
332
|
+
return this.bases().map((base, i) => ({ label: `From base: ${this.baseLabel(base, i)}`, action: () => this.newFromBase(i) }));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Open a copy of a model in a new tab: written and read back in its own
|
|
337
|
+
* file format (keepEntry), named as a copy (copyName). Null when it has no format.
|
|
338
|
+
*/
|
|
339
|
+
duplicate(part) {
|
|
340
|
+
const entry = part.keepEntry?.();
|
|
341
|
+
if (!entry) return null;
|
|
342
|
+
const format = part.fileFormats().find(f => f.label === entry.format);
|
|
343
|
+
const model = format.import(entry.data);
|
|
344
|
+
model.name = ModelsPart.copyName(model.name || entry.name);
|
|
345
|
+
return this.openModel(model);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The Tools menu's items for one open model (`part`): Compare with and
|
|
350
|
+
* Merge from, each a submenu of the other open models, then the bases.
|
|
351
|
+
*/
|
|
352
|
+
toolItems(part) {
|
|
353
|
+
const others = this.models().filter(m => m !== part);
|
|
354
|
+
const bases = this.bases().map((base, i) => ({ base, label: `${this.baseLabel(base, i)} (base)` }));
|
|
355
|
+
const each = (act) => (others.length || bases.length
|
|
356
|
+
? [
|
|
357
|
+
...others.map(other => ({ label: this.labelOf(other), action: () => act(part, other) })),
|
|
358
|
+
...(others.length && bases.length ? [{ separator: true }] : []),
|
|
359
|
+
...bases.map(({ base, label }) => ({ label, action: () => act(part, base) })),
|
|
360
|
+
]
|
|
361
|
+
: [{ label: 'Open another model first', disabled: true }]);
|
|
362
|
+
return [
|
|
363
|
+
{ label: 'Compare with', items: each((a, b) => this.compare(a, b)) },
|
|
364
|
+
{ label: 'Merge from', items: each((a, b) => this.merge(a, b)) },
|
|
365
|
+
];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** How `part`'s model differs from `other`'s, shown (ModelCompareDialog); its Merge… goes on to merge. */
|
|
369
|
+
compare(part, other) {
|
|
370
|
+
return new ModelCompareDialog({
|
|
371
|
+
...this._comparing(part, other),
|
|
372
|
+
onMerge: () => this.merge(part, other),
|
|
373
|
+
}).open();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Choose what to bring from `other`'s model into `part`'s, then bring it: one undo step there. */
|
|
377
|
+
merge(part, other) {
|
|
378
|
+
return new ModelMergeDialog({
|
|
379
|
+
...this._comparing(part, other),
|
|
380
|
+
onMerge: (command) => part.runModelEdit(command),
|
|
381
|
+
}).open();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** @private */
|
|
385
|
+
_comparing(part, other) {
|
|
386
|
+
return {
|
|
387
|
+
comparison: part.model.compareWith(other.model),
|
|
388
|
+
mineName: part.model.name || this.labelOf(part),
|
|
389
|
+
theirsName: other.model.name || this.labelOf(other),
|
|
390
|
+
document: this.container?.ownerDocument ?? globalThis.document,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Ask for a file (as `from`'s Open… does) and open it in a new tab,
|
|
396
|
+
* leaving `from`'s model as it is. Returns the new part, or null.
|
|
397
|
+
*/
|
|
398
|
+
async openInNewTab(from) {
|
|
399
|
+
const chosen = await from.file.choose();
|
|
400
|
+
if (!chosen) return null;
|
|
401
|
+
const part = this.newModel();
|
|
402
|
+
part.file.adopt(chosen);
|
|
403
|
+
return part;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Close a model's tab; with unsaved changes, only once confirmed. The last
|
|
408
|
+
* one closed leaves a new, empty model. Returns whether it closed.
|
|
409
|
+
*/
|
|
410
|
+
closeModel(id) {
|
|
411
|
+
const part = this.registry?.get(id);
|
|
412
|
+
if (!part || !this.modelIds.includes(id)) return false;
|
|
413
|
+
if (part.model?.isDirty?.() && !this.confirm(`"${this.labelOf(part)}" has unsaved changes. Close it anyway?`)) return false;
|
|
414
|
+
this.work.close(id);
|
|
415
|
+
this.modelIds = this.modelIds.filter(m => m !== id);
|
|
416
|
+
if (part.keepId) this.library?.setOpen(part.keepId, false); // kept, not reopened next time
|
|
417
|
+
this.registry.remove(id);
|
|
418
|
+
if (!this.modelIds.length) this.newModel();
|
|
419
|
+
else this.activeModel()?.file?.showState?.();
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** A model's tab label: what its toolbar and the page title call it (ModelFile#label). */
|
|
424
|
+
labelOf(part) {
|
|
425
|
+
return part.fileLabel;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** An editor part's file or saved state changed: its tab says so. */
|
|
429
|
+
fileStateChanged(part) {
|
|
430
|
+
part.title = this.labelOf(part);
|
|
431
|
+
this.work?.refreshLabels();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* What can be done with one open model, for its tab's right-click menu:
|
|
436
|
+
* Save and Save As… (when it saves files), Duplicate model, Compare with ▸
|
|
437
|
+
* and Merge from ▸ (the other open models, then the bases), This browser ▸
|
|
438
|
+
* (with `keep`), Close model and Close other models.
|
|
439
|
+
*/
|
|
440
|
+
tabMenuItems(part) {
|
|
441
|
+
const saves = !!part.fileFormats?.().length;
|
|
442
|
+
const [, save, saveAs] = saves ? part.file.menuItems() : [];
|
|
443
|
+
const [compare, merge] = part.model?.compareWith ? this.toolItems(part) : [];
|
|
444
|
+
const keeping = this.keepItems(part);
|
|
445
|
+
const others = this.modelIds.filter(id => id !== part.id);
|
|
446
|
+
return [
|
|
447
|
+
...(saves ? [save, saveAs, { separator: true }, { label: 'Duplicate model', action: () => this.duplicate(part) }] : []),
|
|
448
|
+
...(compare ? [compare, merge] : []),
|
|
449
|
+
...(keeping.length ? [{ label: 'This browser', items: keeping }] : []),
|
|
450
|
+
{ separator: true },
|
|
451
|
+
{ label: 'Close model', action: () => this.closeModel(part.id) },
|
|
452
|
+
{ label: 'Close other models', disabled: !others.length || undefined, action: () => { for (const id of others) this.closeModel(id); } },
|
|
453
|
+
];
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Show a model's tab menu at a right-click; true when there was a model to show it for. */
|
|
457
|
+
showTabMenu(id, event) {
|
|
458
|
+
const part = this.modelIds.includes(id) ? this.registry?.get(id) : null;
|
|
459
|
+
if (!part) return false;
|
|
460
|
+
PopupMenu.show(event?.clientX ?? 0, event?.clientY ?? 0, this.tabMenuItems(part), this.container?.ownerDocument ?? globalThis.document);
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Ask the user (the page's confirm). @protected */
|
|
465
|
+
confirm(message) {
|
|
466
|
+
const win = this.container?.ownerDocument?.defaultView ?? globalThis;
|
|
467
|
+
return typeof win.confirm === 'function' ? win.confirm(message) : true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** @private */
|
|
471
|
+
_freshId() {
|
|
472
|
+
let id;
|
|
473
|
+
do { id = `${this.id}-model-${this._next++}`; } while (this.registry.has(id));
|
|
474
|
+
return id;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
dispose() {
|
|
478
|
+
this.work?.dispose();
|
|
479
|
+
for (const id of this.modelIds) this.registry?.remove(id);
|
|
480
|
+
this.modelIds = [];
|
|
481
|
+
super.dispose();
|
|
482
|
+
}
|
|
483
|
+
}
|