@peter.prib/renderengine 0.2.5 → 0.2.7

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 (67) hide show
  1. package/README.md +219 -213
  2. package/REFERENCE.md +272 -6
  3. package/dist/renderengine-workspace.umd.js +11980 -7421
  4. package/dist/renderengine.umd.js +8991 -5784
  5. package/examples/archimate.html +68 -66
  6. package/examples/data-model.html +534 -0
  7. package/package.json +70 -68
  8. package/src/bian.js +182 -0
  9. package/src/charting/CheckboxTreeSelector.js +373 -304
  10. package/src/charting/DiagramEditor.js +53 -32
  11. package/src/charting/DiagramTypeRegistry.js +13 -3
  12. package/src/charting/ModelCheckDialog.js +63 -0
  13. package/src/charting/ModelChecker.js +26 -0
  14. package/src/charting/ViewTabStrip.js +94 -81
  15. package/src/charting/archimate/ArchimateCanvasEditor.js +825 -815
  16. package/src/charting/archimate/ArchimateElementTemplate.js +208 -195
  17. package/src/charting/datamodel/BianLandscapeReader.js +185 -0
  18. package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
  19. package/src/charting/datamodel/DataModel.js +645 -0
  20. package/src/charting/datamodel/DataModelChecker.js +92 -0
  21. package/src/charting/datamodel/DataModelCompared.js +127 -0
  22. package/src/charting/datamodel/DataModelContext.js +108 -0
  23. package/src/charting/datamodel/DataModelFileFormat.js +33 -0
  24. package/src/charting/datamodel/DataModelPerspective.js +129 -0
  25. package/src/charting/datamodel/DataType.js +108 -0
  26. package/src/charting/datamodel/EnumerationType.js +51 -0
  27. package/src/charting/datamodel/LevelMapping.js +38 -0
  28. package/src/charting/datamodel/LevelSchema.js +41 -0
  29. package/src/charting/datamodel/ServiceDomain.js +177 -0
  30. package/src/charting/datamodel/ServiceLandscape.js +104 -0
  31. package/src/charting/datamodel/StructuredType.js +63 -0
  32. package/src/charting/datamodel/index.js +15 -0
  33. package/src/charting/diagramModel/MergeModelCommand.js +43 -37
  34. package/src/charting/diagramModel/OpenViewList.js +75 -0
  35. package/src/charting/diagramModel/ViewSetChecker.js +22 -34
  36. package/src/charting/diagrams/DataModelDiagram.js +160 -86
  37. package/src/charting/diagrams/DataModelRelationshipStrategy.js +94 -38
  38. package/src/charting/diagrams/EdgeGeometry.js +578 -553
  39. package/src/charting/diagrams/GraphDiagram.js +11 -1
  40. package/src/charting/diagrams/NodeGeometry.js +169 -158
  41. package/src/charting/diagrams/nesting.js +31 -0
  42. package/src/charting/glyphs/PersonGlyph.js +24 -23
  43. package/src/charting/modelTree/ModelTree.js +202 -164
  44. package/src/charting/modelTree/ModelTreePanel.js +36 -3
  45. package/src/charting/modelTree/TreeViewMode.js +131 -110
  46. package/src/charting/modelTree/modes/FlatTreeMode.js +32 -46
  47. package/src/charting/modelTree/modes/LayerGroupedTreeMode.js +47 -62
  48. package/src/charting/modelTree/modes/TypeGroupedTreeMode.js +46 -60
  49. package/src/datamodel.js +132 -122
  50. package/src/diagramCatalog.js +35 -30
  51. package/src/index.js +2666 -2659
  52. package/src/workspace/EditorShell.js +166 -67
  53. package/src/workspace/PanelLayout.js +56 -0
  54. package/src/workspace/index.js +1 -0
  55. package/src/workspace/layout/TabsNode.js +205 -191
  56. package/src/workspace/parts/ArchimateCanvasPart.js +10 -9
  57. package/src/workspace/parts/ChartPart.js +6 -1
  58. package/src/workspace/parts/DataModelPart.js +544 -0
  59. package/src/workspace/parts/DiagramEditorPart.js +2 -8
  60. package/src/workspace/parts/EditorPart.js +461 -407
  61. package/src/workspace/parts/ModelFile.js +216 -187
  62. package/src/workspace/parts/ModelsPart.js +483 -192
  63. package/src/workspace/storage/BackupFolder.js +145 -0
  64. package/src/workspace/storage/KeyValueStore.js +92 -0
  65. package/src/workspace/storage/ModelLibrary.js +65 -0
  66. package/src/workspace/workArea.js +417 -413
  67. package/src/yaml/YamlReader.js +372 -0
@@ -1,407 +1,461 @@
1
- import { Part } from '../Part.js';
2
- import { ViewMenu } from '../../charting/ViewMenu.js';
3
- import { ToolbarMenu } from '../../charting/toolbar/index.js';
4
- import { modelFileMenuItems } from '../../charting/ModelFileFormat.js';
5
- import { ModelFile } from './ModelFile.js';
6
- import { reportHtml, reportFileName } from '../../charting/report/reportHtml.js';
7
- import { downloadBlob } from '../../exportImage.js';
8
- import { FindReplaceDialog } from '../../charting/FindReplaceDialog.js';
9
- import { FormDialog } from '../../charting/FormDialog.js';
10
- import { NAME_TRANSFORMS } from '../../charting/diagramModel/nameTransforms.js';
11
-
12
- /**
13
- * EditorPart — base for workbench parts that edit a model of several views
14
- * (a ViewSetModel): the template editor's DiagramEditorPart and
15
- * ArchimateEditorPart.
16
- *
17
- * Owns what they share: the model (made by the subclass from the part's JSON
18
- * spec), the view menu (new, duplicate, rename, delete) over it, a File menu
19
- * that starts with those items and offers the part's file formats (export,
20
- * and import, which replaces the model), and disposing the editor with the
21
- * part. Subclasses build their editor in render() and supply
22
- * `createModel(spec)`, and `fileFormats()` when they read and write files.
23
- *
24
- * With file formats the part also opens and saves a file (ModelFile): Open…,
25
- * Save and Save As… (Ctrl+O, Ctrl+S, Ctrl+Shift+S), writing back to the file
26
- * it opened or last saved where the browser allows; it knows when the model
27
- * has changed since (`model.isDirty()`), shows it (`fileLabel`, the page
28
- * title) and asks before the page is left with changes unsaved.
29
- *
30
- * Hosted by a ModelsPart (`ctx.models`: several models open at once, a tab
31
- * each), its File menu also offers New model, Open… in a new tab and Close
32
- * model, an import opens in a new tab rather than replacing this model, a
33
- * Tools menu compares and merges models (ModelsPart#toolItems), and its tab
34
- * follows its file's name and saved state.
35
- */
36
- export class EditorPart extends Part {
37
- /** What a view is called in this part's menus ('view', 'diagram'). */
38
- static viewNoun = 'view';
39
-
40
- constructor(spec = {}) {
41
- super(spec);
42
- if (new.target === EditorPart) throw new TypeError('EditorPart is abstract — subclass it');
43
- this._model = this.createModel(spec);
44
- /** The editor this part hosts; set by render(). */
45
- this.editor = null;
46
- /** The file the model is open from, and saved to (ModelFile). */
47
- this.file = new ModelFile(this);
48
- this._followEdits(this._model);
49
- }
50
-
51
- /** Install the file keys and the leave-page guard once mounted. */
52
- mount(host, ctx) {
53
- const first = !this.mounted;
54
- super.mount(host, ctx);
55
- if (!first || !this.fileFormats().length) return;
56
- const win = this.document.defaultView;
57
- this.listen(this.document, 'keydown', (e) => this.file.onKey(e));
58
- this.listen(win, 'beforeunload', (e) => {
59
- if (!this.model.isDirty?.()) return;
60
- e.preventDefault();
61
- e.returnValue = '';
62
- });
63
- this.file.showState();
64
- }
65
-
66
- /** What the part's title shows: the file's name, and • when there are unsaved changes. */
67
- get fileLabel() {
68
- return this.file.label;
69
- }
70
-
71
- /**
72
- * Show the saved state on every edit of `model`, from now on as saved and
73
- * with nothing to undo (making it, or opening it, is no edit). @private
74
- */
75
- _followEdits(model) {
76
- this._unsubscribeEdits?.();
77
- model.history?.clear?.();
78
- model.markSaved?.();
79
- this._unsubscribeEdits = model.onEdit?.(() => this.file.showState()) ?? null;
80
- }
81
-
82
- /** The model this part edits, built from its spec. @abstract */
83
- createModel(spec) {
84
- throw new Error(`${this.constructor.name} must implement createModel()`);
85
- }
86
-
87
- /** The model being edited (a subclass whose editor can replace it reads it from there). */
88
- get model() {
89
- return this._model;
90
- }
91
-
92
- /** The page this part is mounted in. */
93
- get document() {
94
- return this.container?.ownerDocument ?? globalThis.document;
95
- }
96
-
97
- /**
98
- * View menu items over the current model. A subclass that can show a
99
- * view's properties (`showViewProperties(view)`, the name included) gets
100
- * "Properties…" in place of "Rename…".
101
- */
102
- get viewMenu() {
103
- if (this._viewMenu?.model !== this.model) {
104
- this._viewMenu = new ViewMenu(this.model, {
105
- document: () => this.document,
106
- noun: this.constructor.viewNoun,
107
- editProperties: typeof this.showViewProperties === 'function' ? (view) => this.showViewProperties(view) : null,
108
- });
109
- }
110
- return this._viewMenu;
111
- }
112
-
113
- /** File menu (fileMenuItems). */
114
- fileMenu() {
115
- return new ToolbarMenu('File', () => this.fileMenuItems());
116
- }
117
-
118
- /** The menus beside File and Edit that work across models: Tools, with a ModelsPart. None without. */
119
- hostMenus() {
120
- return this.modelsHost ? [new ToolbarMenu('Tools', () => this.modelsHost.toolItems(this))] : [];
121
- }
122
-
123
- /** Edit menu: undo and redo (the editor's), then Find and replace… over the whole model. */
124
- editMenu() {
125
- return new ToolbarMenu('Edit', () => this.editMenuItems());
126
- }
127
-
128
- editMenuItems() {
129
- const history = this.editor?.history;
130
- return [
131
- { label: 'Undo', disabled: !history?.canUndo || undefined, action: () => this.editor.undo() },
132
- { label: 'Redo', disabled: !history?.canRedo || undefined, action: () => this.editor.redo() },
133
- ...(typeof this.model.search === 'function' ? [
134
- { separator: true },
135
- { label: 'Find and replace…', action: () => this.findAndReplace() },
136
- { label: 'Transform names…', action: () => this.askTransformNames() },
137
- ...(this.model.search().propertyNames().length ? [{ label: 'Rename property…', action: () => this.askRenameProperty() }] : []),
138
- ] : []),
139
- ];
140
- }
141
-
142
- /** Run a model-wide edit (null: nothing to do; a rename, a merge) as one undo step, then redraw. */
143
- runModelEdit(command) {
144
- if (!command) return null;
145
- (this.editor?.history ?? this.model.history).execute(command);
146
- if (this.mounted) this.render();
147
- return command;
148
- }
149
-
150
- /**
151
- * Transform names…: Title Case, Sentence case, split camelCase and the
152
- * rest (nameTransforms.js) on every name, or on those containing some
153
- * text. One undo step.
154
- */
155
- askTransformNames() {
156
- return new FormDialog({
157
- title: 'Transform names',
158
- fields: [
159
- { key: 'transform', label: 'Make them', type: 'select', value: 'title', choices: NAME_TRANSFORMS.map(([k, label]) => [k, label]) },
160
- { key: 'text', label: 'Only names containing', type: 'text', value: '', placeholder: 'every name' },
161
- ],
162
- submitLabel: 'Transform',
163
- idPrefix: 'transform-names-',
164
- document: this.document,
165
- validate: ({ transform, text }) => (this.model.search().transformCommand(transform, text) ? null : 'No name would change'),
166
- onApply: ({ transform, text }) => this.transformNames(transform, text),
167
- }).open();
168
- }
169
-
170
- /** Transform names (ModelSearch#transformCommand), one undo step. */
171
- transformNames(transform, text = '') {
172
- return this.runModelEdit(this.model.search().transformCommand(transform, text));
173
- }
174
-
175
- /** Rename property…: a property's name, everywhere in the model. One undo step. */
176
- askRenameProperty() {
177
- const names = this.model.search().propertyNames();
178
- return new FormDialog({
179
- title: 'Rename property',
180
- fields: [
181
- { key: 'from', label: 'Property', type: 'select', value: names[0], choices: names.map(n => [n, n]) },
182
- { key: 'to', label: 'New name', type: 'text', value: '' },
183
- ],
184
- submitLabel: 'Rename',
185
- idPrefix: 'rename-property-',
186
- document: this.document,
187
- validate: ({ from, to }) => (to?.trim() ? (to.trim() === from ? 'That is its name' : null) : 'Give its new name'),
188
- onApply: ({ from, to }) => this.renameProperty(from, to.trim()),
189
- }).open();
190
- }
191
-
192
- /** Rename a property everywhere (ModelSearch#renamePropertyCommand), one undo step. */
193
- renameProperty(from, to) {
194
- return this.runModelEdit(this.model.search().renamePropertyCommand(from, to));
195
- }
196
-
197
- /**
198
- * Find and replace over the model (FindReplaceDialog on `model.search()`):
199
- * a replace is one undo step on the model's history, and choosing a
200
- * match shows it (revealMatch). The last search is kept between opens.
201
- */
202
- findAndReplace() {
203
- this._findReplace ??= new FindReplaceDialog({
204
- model: () => this.model,
205
- history: () => this.editor?.history ?? this.model.currentView.history,
206
- onReveal: (match) => this.revealMatch(match),
207
- onReplaced: () => { if (this.mounted) this.render(); },
208
- document: () => this.document,
209
- });
210
- return this._findReplace.open();
211
- }
212
-
213
- /** Show a match: its view, and on it the item (the editor's revealFinding). */
214
- revealMatch(match) {
215
- if (match.viewId && match.viewId !== this.model.currentViewId) this.model.switchTo(match.viewId); // the part redraws for it
216
- if (match.kind === 'model' || match.kind === 'view') return;
217
- this.editor?.revealFinding?.({ kind: match.kind, ids: [match.id] });
218
- }
219
-
220
- /** What File offers to make: a new view here; a subclass adds its own kinds (a canvas, ...). */
221
- newItems() {
222
- return [this.viewMenu.newViewItem()];
223
- }
224
-
225
- /** The ModelsPart this part is a tab of (several models open at once), or null. */
226
- get modelsHost() {
227
- return this.ctx?.models ?? null;
228
- }
229
-
230
- /** With a ModelsPart: New model, Open… in a new tab (when it opens files) and Close model. */
231
- modelItems() {
232
- const host = this.modelsHost;
233
- if (!host) return [];
234
- return [
235
- { label: 'New model', action: () => host.newModel() },
236
- ...(this.fileFormats().length ? [{ label: 'Open… in a new tab', action: () => host.openInNewTab(this) }] : []),
237
- { label: 'Close model', action: () => host.closeModel(this.id) },
238
- ];
239
- }
240
-
241
- /** The file or its saved state changed: a ModelsPart's tab follows it. */
242
- onFileState() {
243
- this.modelsHost?.fileStateChanged?.(this);
244
- }
245
-
246
- /**
247
- * The File menu, grouped: **New** ▸ (a view, a subclass's kinds, a model),
248
- * Open…, Save, Save As… (and, with a ModelsPart, Open… in a new tab, Close
249
- * model), **Import** ▸ and **Export** ▸ (each file format, a subclass's own,
250
- * the HTML report and the pictures), a subclass's own groups, and **This
251
- * view** ▸ (its properties, duplicate, delete). A group of one is that item.
252
- */
253
- fileMenuItems() {
254
- const models = Object.fromEntries(this.modelItems().map(i => [i.label, i]));
255
- const [open, ...saving] = this.fileFormats().length ? this.file.menuItems() : [];
256
- const some = (...items) => items.filter(Boolean);
257
- return EditorPart.tidyMenu([
258
- ...EditorPart.menuGroup('New', some(...this.newItems(), models['New model'])),
259
- { separator: true },
260
- ...some(open, models['Open… in a new tab'], ...saving, models['Close model']),
261
- { separator: true },
262
- ...EditorPart.menuGroup('Import', this.importItems(), { prefix: true }),
263
- ...EditorPart.menuGroup('Export', this.exportItems(), { prefix: true }),
264
- ...this.ownFileItems(),
265
- { separator: true },
266
- ...EditorPart.menuGroup(`This ${this.constructor.viewNoun}`, this.viewMenu.items(this.model.currentViewId)),
267
- ]);
268
- }
269
-
270
- /**
271
- * File > Import's items: each format that imports, then the subclass's own
272
- * (ownImportItems). An imported model opens in a new tab with a ModelsPart
273
- * (onImported), else replaces this one.
274
- */
275
- importItems() {
276
- return EditorPart.tidyMenu([
277
- ...modelFileMenuItems(this.fileFormats(), {
278
- model: () => this.model, only: 'import',
279
- onImport: (model) => this.onImported(model),
280
- document: () => this.document,
281
- }),
282
- ...this.ownImportItems(),
283
- ]);
284
- }
285
-
286
- /** A model imported from a file: in a new tab with a ModelsPart, else in place of this one. Returns the part showing it. */
287
- onImported(model) {
288
- if (this.modelsHost) return this.modelsHost.openModel(model);
289
- this.replaceModel(model);
290
- this.file.forget();
291
- return this;
292
- }
293
-
294
- /** File > Export's items: each format, the subclass's own (ownExportItems), then the HTML report and the pictures. */
295
- exportItems() {
296
- return EditorPart.tidyMenu([
297
- ...modelFileMenuItems(this.fileFormats(), { model: () => this.model, only: 'export', document: () => this.document }),
298
- ...this.ownExportItems(),
299
- { separator: true },
300
- // In Export, "Export HTML report" is "HTML report", "Export image (PNG)" "Image (PNG)".
301
- ...[...this.reportItems(), ...(this.editor?.imageMenuItems?.() ?? [])]
302
- .map(i => ({ ...i, label: i.label.replace(/^Export (.)/, (_, c) => c.toUpperCase()) })),
303
- ]);
304
- }
305
-
306
- /** A subclass's own File > Import items. None here. */
307
- ownImportItems() {
308
- return [];
309
- }
310
-
311
- /** A subclass's own File > Export items. None here. */
312
- ownExportItems() {
313
- return [];
314
- }
315
-
316
- /**
317
- * Items as a submenu named `label`; none is nothing, and one is that item
318
- * itself (with `prefix`, named as the group says it: "Export HTML report").
319
- */
320
- static menuGroup(label, items, { prefix = false } = {}) {
321
- const real = EditorPart.tidyMenu(items);
322
- if (!real.length) return [];
323
- if (real.length > 1) return [{ label, items: real }];
324
- const [only] = real;
325
- if (!prefix) return [only];
326
- // "Image (PNG)" reads "Export image (PNG)"; an acronym (HTML, CSV) or ".archimate" stays as it is.
327
- const word = /^[A-Z][a-z]/.test(only.label) ? only.label.charAt(0).toLowerCase() + only.label.slice(1) : only.label;
328
- return [{ ...only, label: `${label} ${word}` }];
329
- }
330
-
331
- /** Separators only between items: none first, last or twice in a row. */
332
- static tidyMenu(items) {
333
- const out = [];
334
- for (const item of items) {
335
- if (item.separator && (!out.length || out.at(-1).separator)) continue;
336
- out.push(item);
337
- }
338
- while (out.at(-1)?.separator) out.pop();
339
- return out;
340
- }
341
-
342
- /** Export HTML report, when the model has a report (ViewSetModel#report). */
343
- reportItems() {
344
- return typeof this.model.report === 'function' ? [{ label: 'Export HTML report', action: () => this.exportReport() }] : [];
345
- }
346
-
347
- /**
348
- * The model's report as one HTML file (reportHtml), each view's picture
349
- * in it: downloaded as `<model>-report.html`. Returns the HTML.
350
- */
351
- exportReport() {
352
- const report = this.model.report({ pictures: this.viewPictures() });
353
- const html = reportHtml(report.data());
354
- downloadBlob(new (this.document.defaultView ?? globalThis).Blob([html], { type: 'text/html' }), reportFileName(report.title));
355
- return html;
356
- }
357
-
358
- /**
359
- * Every view's picture (DiagramEditor#viewPicture), by view id: each view
360
- * is shown in turn, then the one that was. None when the editor draws no pictures.
361
- */
362
- viewPictures() {
363
- const model = this.model;
364
- if (typeof this.editor?.viewPicture !== 'function') return {};
365
- const start = model.currentViewId;
366
- const pictures = {};
367
- try {
368
- for (const view of model.views()) {
369
- if (model.currentViewId !== view.id) model.switchTo(view.id); // the part redraws for it
370
- const picture = this.editor?.viewPicture?.();
371
- if (picture) pictures[view.id] = picture;
372
- }
373
- } finally {
374
- if (model.currentViewId !== start) model.switchTo(start);
375
- }
376
- return pictures;
377
- }
378
-
379
- /** A subclass's own File items or groups, after Import and Export (templates, ...). None here. */
380
- ownFileItems() {
381
- return [];
382
- }
383
-
384
- /** The file formats (ModelFileFormat) this part's model is exported to and imported from: none here. */
385
- fileFormats() {
386
- return [];
387
- }
388
-
389
- /** Edit another model (an imported one) in place of this one, and show it. */
390
- replaceModel(model) {
391
- this._model = model;
392
- this._followEdits(model);
393
- this.modelReplaced(model);
394
- if (this.mounted) this.render();
395
- }
396
-
397
- /** After `replaceModel`: a subclass moves what follows the model (change listeners, ...) onto the new one. */
398
- modelReplaced(model) {}
399
-
400
- dispose() {
401
- this._unsubscribeEdits?.();
402
- this.file.restoreTitle();
403
- this.editor?.dispose?.();
404
- this.editor = null;
405
- super.dispose();
406
- }
407
- }
1
+ import { Part } from '../Part.js';
2
+ import { ViewMenu } from '../../charting/ViewMenu.js';
3
+ import { ToolbarMenu } from '../../charting/toolbar/index.js';
4
+ import { modelFileMenuItems } from '../../charting/ModelFileFormat.js';
5
+ import { ModelFile } from './ModelFile.js';
6
+ import { reportHtml, reportFileName } from '../../charting/report/reportHtml.js';
7
+ import { downloadBlob } from '../../exportImage.js';
8
+ import { FindReplaceDialog } from '../../charting/FindReplaceDialog.js';
9
+ import { FormDialog } from '../../charting/FormDialog.js';
10
+ import { NAME_TRANSFORMS } from '../../charting/diagramModel/nameTransforms.js';
11
+ import { OpenViewList } from '../../charting/diagramModel/OpenViewList.js';
12
+ import { ViewTabStrip } from '../../charting/ViewTabStrip.js';
13
+ import { PopupMenu } from '../../charting/PopupMenu.js';
14
+ import { PanelLayout } from '../PanelLayout.js';
15
+
16
+ /**
17
+ * EditorPart — base for workbench parts that edit a model of several views
18
+ * (a ViewSetModel): the template editor's DiagramEditorPart and
19
+ * ArchimateEditorPart.
20
+ *
21
+ * Owns what they share: the model (made by the subclass from the part's JSON
22
+ * spec), the view menu (new, duplicate, rename, delete) over it, a File menu
23
+ * that starts with those items and offers the part's file formats (export,
24
+ * and import, which replaces the model), and disposing the editor with the
25
+ * part. Subclasses build their editor in render() and supply
26
+ * `createModel(spec)`, and `fileFormats()` when they read and write files.
27
+ *
28
+ * With file formats the part also opens and saves a file (ModelFile): Open…,
29
+ * Save and Save As… (Ctrl+O, Ctrl+S, Ctrl+Shift+S), writing back to the file
30
+ * it opened or last saved where the browser allows; it knows when the model
31
+ * has changed since (`model.isDirty()`), shows it (`fileLabel`, the page
32
+ * title) and asks before the page is left with changes unsaved.
33
+ *
34
+ * Hosted by a ModelsPart (`ctx.models`: several models open at once, a tab
35
+ * each), its File menu also offers New model, Open… in a new tab and Close
36
+ * model, an import opens in a new tab rather than replacing this model, a
37
+ * Tools menu compares and merges models (ModelsPart#toolItems), and its tab
38
+ * follows its file's name and saved state.
39
+ *
40
+ * Its tabs are the views open (OpenViewList), as a browser's are: a model
41
+ * opens (or is imported) with its first view only, a view chosen in the tree
42
+ * opens a tab, × closes the tab but keeps the view, and + makes a new one
43
+ * (viewTabs). Its side panels can be resized and folded (`layout`, a
44
+ * PanelLayout the editor shell reads), kept as they were across renders.
45
+ */
46
+ export class EditorPart extends Part {
47
+ /** What a view is called in this part's menus ('view', 'diagram'). */
48
+ static viewNoun = 'view';
49
+
50
+ constructor(spec = {}) {
51
+ super(spec);
52
+ if (new.target === EditorPart) throw new TypeError('EditorPart is abstract — subclass it');
53
+ this._model = this.createModel(spec);
54
+ /** The editor this part hosts; set by render(). */
55
+ this.editor = null;
56
+ /** The file the model is open from, and saved to (ModelFile). */
57
+ this.file = new ModelFile(this);
58
+ /** The views with a tab, in order (spec `openViews`: their ids). */
59
+ this.openViews = new OpenViewList(this._model, spec.openViews ?? null);
60
+ /** The side panels' widths, and which are folded (spec `layout`), for the editor shell. */
61
+ this.layout = new PanelLayout(spec.layout ?? {});
62
+ /** Its id among the models kept in this browser (ModelsPart with `keep`), or null. */
63
+ this.keepId = spec.keepId ?? null;
64
+ this._followEdits(this._model);
65
+ }
66
+
67
+ /**
68
+ * The model as a kept entry (ModelLibrary): in its file's format, else the
69
+ * part's save format, with its name and file name; null without a format.
70
+ */
71
+ keepEntry() {
72
+ return this.file.keepEntry();
73
+ }
74
+
75
+ /**
76
+ * The tab strip: the open views (OpenViewList), each closable while
77
+ * another is open (the view stays in the model), a right-click menu per
78
+ * tab (the view menu), and + for something new (newItems: the one there
79
+ * is, else a menu of them).
80
+ * @param {{orientation?: 'horizontal'|'vertical'}} [options]
81
+ */
82
+ viewTabs({ orientation = 'horizontal' } = {}) {
83
+ const open = this.openViews;
84
+ return new ViewTabStrip({
85
+ orientation,
86
+ document: this.document,
87
+ onSelect: (id) => open.open(id),
88
+ onClose: (id) => { if (open.close(id) && this.mounted) this.render(); },
89
+ canClose: (id) => open.canClose(id),
90
+ onAdd: (e) => {
91
+ const items = this.newItems().filter(i => !i.separator);
92
+ if (items.length === 1) items[0].action();
93
+ else PopupMenu.show(e?.clientX ?? 0, e?.clientY ?? 0, items, this.document);
94
+ },
95
+ menuItems: (id) => this.viewMenu.items(id),
96
+ }).render(open.sync(), this.model.currentViewId);
97
+ }
98
+
99
+ /** Install the file keys and the leave-page guard once mounted. */
100
+ mount(host, ctx) {
101
+ const first = !this.mounted;
102
+ super.mount(host, ctx);
103
+ if (!first || !this.fileFormats().length) return;
104
+ this.file.listen();
105
+ this.file.showState();
106
+ }
107
+
108
+ /** What the part's title shows: the file's name, and • when there are unsaved changes. */
109
+ get fileLabel() {
110
+ return this.file.label;
111
+ }
112
+
113
+ /**
114
+ * Show the saved state on every edit of `model`, from now on as saved and
115
+ * with nothing to undo (making it, or opening it, is no edit). @private
116
+ */
117
+ _followEdits(model) {
118
+ this._unsubscribeEdits?.();
119
+ model.history?.clear?.();
120
+ model.markSaved?.();
121
+ this._unsubscribeEdits = model.onEdit?.(() => {
122
+ this.file.showState();
123
+ this.modelsHost?.modelEdited?.(this); // kept in this browser, a moment after
124
+ }) ?? null;
125
+ }
126
+
127
+ /** The model this part edits, built from its spec. @abstract */
128
+ createModel(spec) {
129
+ throw new Error(`${this.constructor.name} must implement createModel()`);
130
+ }
131
+
132
+ /** The model being edited (a subclass whose editor can replace it reads it from there). */
133
+ get model() {
134
+ return this._model;
135
+ }
136
+
137
+ /** The page this part is mounted in. */
138
+ get document() {
139
+ return this.container?.ownerDocument ?? globalThis.document;
140
+ }
141
+
142
+ /**
143
+ * View menu items over the current model. A subclass that can show a
144
+ * view's properties (`showViewProperties(view)`, the name included) gets
145
+ * "Properties…" in place of "Rename…".
146
+ */
147
+ get viewMenu() {
148
+ if (this._viewMenu?.model !== this.model) {
149
+ this._viewMenu = new ViewMenu(this.model, {
150
+ document: () => this.document,
151
+ noun: this.constructor.viewNoun,
152
+ editProperties: typeof this.showViewProperties === 'function' ? (view) => this.showViewProperties(view) : null,
153
+ });
154
+ }
155
+ return this._viewMenu;
156
+ }
157
+
158
+ /** File menu (fileMenuItems). */
159
+ fileMenu() {
160
+ return new ToolbarMenu('File', () => this.fileMenuItems());
161
+ }
162
+
163
+ /** The menus beside File and Edit that work across models: Tools, with a ModelsPart. None without. */
164
+ hostMenus() {
165
+ return this.modelsHost ? [new ToolbarMenu('Tools', () => this.modelsHost.toolItems(this))] : [];
166
+ }
167
+
168
+ /** Edit menu: undo and redo (the editor's), then Find and replace… over the whole model. */
169
+ editMenu() {
170
+ return new ToolbarMenu('Edit', () => this.editMenuItems());
171
+ }
172
+
173
+ editMenuItems() {
174
+ const history = this.editor?.history;
175
+ return [
176
+ { label: 'Undo', disabled: !history?.canUndo || undefined, action: () => this.editor.undo() },
177
+ { label: 'Redo', disabled: !history?.canRedo || undefined, action: () => this.editor.redo() },
178
+ ...(typeof this.model.search === 'function' ? [
179
+ { separator: true },
180
+ { label: 'Find and replace…', action: () => this.findAndReplace() },
181
+ { label: 'Transform names…', action: () => this.askTransformNames() },
182
+ ...(this.model.search().propertyNames().length ? [{ label: 'Rename property…', action: () => this.askRenameProperty() }] : []),
183
+ ] : []),
184
+ ];
185
+ }
186
+
187
+ /** Run a model-wide edit (null: nothing to do; a rename, a merge) as one undo step, then redraw. */
188
+ runModelEdit(command) {
189
+ if (!command) return null;
190
+ (this.editor?.history ?? this.model.history).execute(command);
191
+ if (this.mounted) this.render();
192
+ return command;
193
+ }
194
+
195
+ /**
196
+ * Transform names…: Title Case, Sentence case, split camelCase and the
197
+ * rest (nameTransforms.js) on every name, or on those containing some
198
+ * text. One undo step.
199
+ */
200
+ askTransformNames() {
201
+ return new FormDialog({
202
+ title: 'Transform names',
203
+ fields: [
204
+ { key: 'transform', label: 'Make them', type: 'select', value: 'title', choices: NAME_TRANSFORMS.map(([k, label]) => [k, label]) },
205
+ { key: 'text', label: 'Only names containing', type: 'text', value: '', placeholder: 'every name' },
206
+ ],
207
+ submitLabel: 'Transform',
208
+ idPrefix: 'transform-names-',
209
+ document: this.document,
210
+ validate: ({ transform, text }) => (this.model.search().transformCommand(transform, text) ? null : 'No name would change'),
211
+ onApply: ({ transform, text }) => this.transformNames(transform, text),
212
+ }).open();
213
+ }
214
+
215
+ /** Transform names (ModelSearch#transformCommand), one undo step. */
216
+ transformNames(transform, text = '') {
217
+ return this.runModelEdit(this.model.search().transformCommand(transform, text));
218
+ }
219
+
220
+ /** Rename property…: a property's name, everywhere in the model. One undo step. */
221
+ askRenameProperty() {
222
+ const names = this.model.search().propertyNames();
223
+ return new FormDialog({
224
+ title: 'Rename property',
225
+ fields: [
226
+ { key: 'from', label: 'Property', type: 'select', value: names[0], choices: names.map(n => [n, n]) },
227
+ { key: 'to', label: 'New name', type: 'text', value: '' },
228
+ ],
229
+ submitLabel: 'Rename',
230
+ idPrefix: 'rename-property-',
231
+ document: this.document,
232
+ validate: ({ from, to }) => (to?.trim() ? (to.trim() === from ? 'That is its name' : null) : 'Give its new name'),
233
+ onApply: ({ from, to }) => this.renameProperty(from, to.trim()),
234
+ }).open();
235
+ }
236
+
237
+ /** Rename a property everywhere (ModelSearch#renamePropertyCommand), one undo step. */
238
+ renameProperty(from, to) {
239
+ return this.runModelEdit(this.model.search().renamePropertyCommand(from, to));
240
+ }
241
+
242
+ /**
243
+ * Find and replace over the model (FindReplaceDialog on `model.search()`):
244
+ * a replace is one undo step on the model's history, and choosing a
245
+ * match shows it (revealMatch). The last search is kept between opens.
246
+ */
247
+ findAndReplace() {
248
+ this._findReplace ??= new FindReplaceDialog({
249
+ model: () => this.model,
250
+ history: () => this.editor?.history ?? this.model.currentView.history,
251
+ onReveal: (match) => this.revealMatch(match),
252
+ onReplaced: () => { if (this.mounted) this.render(); },
253
+ document: () => this.document,
254
+ });
255
+ return this._findReplace.open();
256
+ }
257
+
258
+ /** Show a match: its view, and on it the item (the editor's revealFinding). */
259
+ revealMatch(match) {
260
+ if (match.viewId && match.viewId !== this.model.currentViewId) this.model.switchTo(match.viewId); // the part redraws for it
261
+ if (match.kind === 'model' || match.kind === 'view') return;
262
+ this.editor?.revealFinding?.({ kind: match.kind, ids: [match.id] });
263
+ }
264
+
265
+ /** What File offers to make: a new view here; a subclass adds its own kinds (a canvas, ...). */
266
+ newItems() {
267
+ return [this.viewMenu.newViewItem()];
268
+ }
269
+
270
+ /** The ModelsPart this part is a tab of (several models open at once), or null. */
271
+ get modelsHost() {
272
+ return this.ctx?.models ?? null;
273
+ }
274
+
275
+ /** With a ModelsPart: New model, Open… in a new tab (when it opens files) and Close model. */
276
+ modelItems() {
277
+ const host = this.modelsHost;
278
+ if (!host) return [];
279
+ return [
280
+ { label: 'New model', action: () => host.newModel() },
281
+ ...(this.fileFormats().length ? [{ label: 'Open… in a new tab', action: () => host.openInNewTab(this) }] : []),
282
+ ...(this.fileFormats().length ? [{ label: 'Duplicate model', action: () => host.duplicate(this) }] : []),
283
+ { label: 'Close model', action: () => host.closeModel(this.id) },
284
+ ];
285
+ }
286
+
287
+ /** The file or its saved state changed: a ModelsPart's tab follows it. */
288
+ onFileState() {
289
+ this.modelsHost?.fileStateChanged?.(this);
290
+ }
291
+
292
+ /**
293
+ * The File menu, grouped: **New** ▸ (a view, a subclass's kinds, a model),
294
+ * Open…, Save, Save As… (and, with a ModelsPart, Open… in a new tab, Close
295
+ * model), **Import** ▸ and **Export** ▸ (each file format, a subclass's own,
296
+ * the HTML report and the pictures), a subclass's own groups, and **This
297
+ * view** ▸ (its properties, duplicate, delete). A group of one is that item.
298
+ */
299
+ fileMenuItems() {
300
+ const models = Object.fromEntries(this.modelItems().map(i => [i.label, i]));
301
+ const [open, ...saving] = this.fileFormats().length ? this.file.menuItems() : [];
302
+ const some = (...items) => items.filter(Boolean);
303
+ return EditorPart.tidyMenu([
304
+ ...EditorPart.menuGroup('New', some(...this.newItems(), models['New model'], ...(this.modelsHost?.baseItems?.() ?? []))),
305
+ { separator: true },
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) ?? []),
308
+ { separator: true },
309
+ ...EditorPart.menuGroup('Import', this.importItems(), { prefix: true }),
310
+ ...EditorPart.menuGroup('Export', this.exportItems(), { prefix: true }),
311
+ ...this.ownFileItems(),
312
+ { separator: true },
313
+ ...EditorPart.menuGroup(`This ${this.constructor.viewNoun}`, this.viewMenu.items(this.model.currentViewId)),
314
+ ]);
315
+ }
316
+
317
+ /**
318
+ * File > Import's items: each format that imports, then the subclass's own
319
+ * (ownImportItems). An imported model opens in a new tab with a ModelsPart
320
+ * (onImported), else replaces this one.
321
+ */
322
+ importItems() {
323
+ return EditorPart.tidyMenu([
324
+ ...modelFileMenuItems(this.fileFormats(), {
325
+ model: () => this.model, only: 'import',
326
+ onImport: (model) => this.onImported(model),
327
+ document: () => this.document,
328
+ }),
329
+ ...this.ownImportItems(),
330
+ ]);
331
+ }
332
+
333
+ /** A model imported from a file: in a new tab with a ModelsPart, else in place of this one. Returns the part showing it. */
334
+ onImported(model) {
335
+ if (this.modelsHost) return this.modelsHost.openModel(model);
336
+ this.replaceModel(model);
337
+ this.file.forget();
338
+ return this;
339
+ }
340
+
341
+ /** File > Export's items: each format, the subclass's own (ownExportItems), then the HTML report and the pictures. */
342
+ exportItems() {
343
+ return EditorPart.tidyMenu([
344
+ ...modelFileMenuItems(this.fileFormats(), { model: () => this.model, only: 'export', document: () => this.document }),
345
+ ...this.ownExportItems(),
346
+ { separator: true },
347
+ // In Export, "Export HTML report" is "HTML report", "Export image (PNG)" "Image (PNG)".
348
+ ...[...this.reportItems(), ...(this.editor?.imageMenuItems?.() ?? [])]
349
+ .map(i => ({ ...i, label: i.label.replace(/^Export (.)/, (_, c) => c.toUpperCase()) })),
350
+ ]);
351
+ }
352
+
353
+ /** A subclass's own File > Import items. None here. */
354
+ ownImportItems() {
355
+ return [];
356
+ }
357
+
358
+ /** A subclass's own File > Export items. None here. */
359
+ ownExportItems() {
360
+ return [];
361
+ }
362
+
363
+ /**
364
+ * Items as a submenu named `label`; none is nothing, and one is that item
365
+ * itself (with `prefix`, named as the group says it: "Export HTML report").
366
+ */
367
+ static menuGroup(label, items, { prefix = false } = {}) {
368
+ const real = EditorPart.tidyMenu(items);
369
+ if (!real.length) return [];
370
+ if (real.length > 1) return [{ label, items: real }];
371
+ const [only] = real;
372
+ if (!prefix) return [only];
373
+ // "Image (PNG)" reads "Export image (PNG)"; an acronym (HTML, CSV) or ".archimate" stays as it is.
374
+ const word = /^[A-Z][a-z]/.test(only.label) ? only.label.charAt(0).toLowerCase() + only.label.slice(1) : only.label;
375
+ return [{ ...only, label: `${label} ${word}` }];
376
+ }
377
+
378
+ /** Separators only between items: none first, last or twice in a row. */
379
+ static tidyMenu(items) {
380
+ const out = [];
381
+ for (const item of items) {
382
+ if (item.separator && (!out.length || out.at(-1).separator)) continue;
383
+ out.push(item);
384
+ }
385
+ while (out.at(-1)?.separator) out.pop();
386
+ return out;
387
+ }
388
+
389
+ /** Export HTML report, when the model has a report (ViewSetModel#report). */
390
+ reportItems() {
391
+ return typeof this.model.report === 'function' ? [{ label: 'Export HTML report', action: () => this.exportReport() }] : [];
392
+ }
393
+
394
+ /**
395
+ * The model's report as one HTML file (reportHtml), each view's picture
396
+ * in it: downloaded as `<model>-report.html`. Returns the HTML.
397
+ */
398
+ exportReport() {
399
+ const report = this.model.report({ pictures: this.viewPictures() });
400
+ const html = reportHtml(report.data());
401
+ downloadBlob(new (this.document.defaultView ?? globalThis).Blob([html], { type: 'text/html' }), reportFileName(report.title));
402
+ return html;
403
+ }
404
+
405
+ /**
406
+ * Every view's picture (DiagramEditor#viewPicture), by view id: each view
407
+ * is shown in turn, then the one that was. None when the editor draws no pictures.
408
+ */
409
+ viewPictures() {
410
+ const model = this.model;
411
+ if (typeof this.editor?.viewPicture !== 'function') return {};
412
+ const start = model.currentViewId;
413
+ const pictures = {};
414
+ // Going through every view to draw it opens none of them as a tab.
415
+ return this.openViews.while(() => this._eachViewPicture(model, start, pictures));
416
+ }
417
+
418
+ /** @private */
419
+ _eachViewPicture(model, start, pictures) {
420
+ try {
421
+ for (const view of model.views()) {
422
+ if (model.currentViewId !== view.id) model.switchTo(view.id); // the part redraws for it
423
+ const picture = this.editor?.viewPicture?.();
424
+ if (picture) pictures[view.id] = picture;
425
+ }
426
+ } finally {
427
+ if (model.currentViewId !== start) model.switchTo(start);
428
+ }
429
+ return pictures;
430
+ }
431
+
432
+ /** A subclass's own File items or groups, after Import and Export (templates, ...). None here. */
433
+ ownFileItems() {
434
+ return [];
435
+ }
436
+
437
+ /** The file formats (ModelFileFormat) this part's model is exported to and imported from: none here. */
438
+ fileFormats() {
439
+ return [];
440
+ }
441
+
442
+ /** Edit another model (an imported one) in place of this one, and show it. */
443
+ replaceModel(model) {
444
+ this._model = model;
445
+ this.openViews = new OpenViewList(model);
446
+ this._followEdits(model);
447
+ this.modelReplaced(model);
448
+ if (this.mounted) this.render();
449
+ }
450
+
451
+ /** After `replaceModel`: a subclass moves what follows the model (change listeners, ...) onto the new one. */
452
+ modelReplaced(model) {}
453
+
454
+ dispose() {
455
+ this._unsubscribeEdits?.();
456
+ this.file.restoreTitle();
457
+ this.editor?.dispose?.();
458
+ this.editor = null;
459
+ super.dispose();
460
+ }
461
+ }