@peter.prib/renderengine 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/REFERENCE.md +252 -17
- package/dist/renderengine-workspace.umd.js +4995 -2932
- package/dist/renderengine.umd.js +3985 -2193
- package/examples/archimate.html +8 -28
- package/examples/data-model.html +45 -556
- package/package.json +70 -70
- package/src/archimateArchiFormat.js +11 -3
- package/src/archimateExchangeFormat.js +17 -9
- package/src/charting/ArchimateDiagramEditor.js +1 -1
- package/src/charting/Axis.js +20 -0
- package/src/charting/DiagramEditor.js +35 -3
- package/src/charting/Dialog.js +195 -0
- package/src/charting/DuplicatesDialog.js +108 -0
- package/src/charting/FormDialog.js +28 -97
- package/src/charting/InteractionManager.js +3 -0
- package/src/charting/Legend.js +45 -2
- package/src/charting/MergeCompareDialog.js +81 -0
- package/src/charting/PopupMenu.js +12 -3
- package/src/charting/SettingsDialog.js +6 -2
- package/src/charting/SettingsManager.js +14 -1
- package/src/charting/archimate/ArchimateCanvasEditor.js +190 -15
- package/src/charting/archimate/ArchimatePalette.js +1 -1
- package/src/charting/charts/AreaChart.js +5 -2
- package/src/charting/charts/BarChart.js +5 -3
- package/src/charting/charts/BarChart3DOrbit.js +5 -7
- package/src/charting/charts/BubbleChart.js +5 -7
- package/src/charting/charts/BubbleChart3D.js +5 -7
- package/src/charting/charts/CandlestickChart.js +3 -1
- package/src/charting/charts/DensityChart.js +4 -6
- package/src/charting/charts/GroupedBarChart.js +2 -2
- package/src/charting/charts/LineChart.js +5 -2
- package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
- package/src/charting/charts/PipeChart.js +4 -6
- package/src/charting/charts/RadarChart.js +5 -6
- package/src/charting/charts/ScatterChart.js +5 -7
- package/src/charting/charts/StackedBarChart.js +4 -2
- package/src/charting/charts/StackedBarChart3DOrbit.js +5 -7
- package/src/charting/charts/StreamGraphChart.js +5 -2
- package/src/charting/charts/WaterfallChart.js +2 -1
- package/src/charting/content/ContentSource.js +129 -0
- package/src/charting/content/ContentSources.js +77 -0
- package/src/charting/datamodel/DataModel.js +116 -0
- package/src/charting/diagrams/ArchimateModel.js +38 -3
- package/src/charting/diagrams/ArchimateModelChecker.js +13 -4
- package/src/charting/diagrams/MergeElementsCommand.js +124 -101
- package/src/charting/diagrams/SvgZoom.js +20 -1
- package/src/charting/diagrams/duplicates.js +95 -13
- package/src/charting/duplicateSearchForm.js +58 -0
- package/src/charting/modelTree/ModelTree.js +239 -202
- package/src/charting/modelTree/ModelTreePanel.js +955 -919
- package/src/charting/modelTree/TreeBuilder.js +93 -45
- package/src/charting/modelTree/TreeViewMode.js +9 -6
- package/src/charting/modelTree/viewFolders.js +15 -6
- package/src/icons/IconSet.js +86 -0
- package/src/icons/index.js +59 -0
- package/src/index.js +65 -9
- package/src/workspace/dom.js +56 -42
- package/src/workspace/index.js +1 -1
- package/src/workspace/nav/NavEntry.js +42 -0
- package/src/workspace/nav/NavGroup.js +43 -0
- package/src/workspace/nav/NavItem.js +53 -0
- package/src/workspace/nav/NavMenu.js +143 -0
- package/src/workspace/nav/NavSection.js +17 -0
- package/src/workspace/nav/index.js +5 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +8 -3
- package/src/workspace/parts/DataModelPart.js +91 -0
- package/src/workspace/parts/EditorPart.js +8 -0
- package/src/workspace/parts/ModelHosted.js +1 -0
- package/src/workspace/parts/ModelsPart.js +765 -622
- package/src/workspace/styles.js +28 -1
|
@@ -1,622 +1,765 @@
|
|
|
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
|
-
import { Benchmark } from '../../charting/model/Benchmark.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
this.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
this.
|
|
130
|
-
|
|
131
|
-
this.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
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
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
this.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
return
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
* (
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
/**
|
|
596
|
-
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
+
import { Benchmark } from '../../charting/model/Benchmark.js';
|
|
12
|
+
import { ContentSource } from '../../charting/content/ContentSource.js';
|
|
13
|
+
import { ContentSources } from '../../charting/content/ContentSources.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ModelsPart — several models open at once (as Archi's model tree holds
|
|
17
|
+
* several): one editor part per model (any EditorPart type, `editorType`),
|
|
18
|
+
* each a tab in a work area, so each keeps its own file, unsaved state and
|
|
19
|
+
* undo history, and two can sit side by side. Copy and paste go between
|
|
20
|
+
* them as between any two editors.
|
|
21
|
+
*
|
|
22
|
+
* The editor parts it hosts find it as `ctx.models` and offer, in their File
|
|
23
|
+
* menu, New model, Open… in a new tab and Close model; a model imported from
|
|
24
|
+
* a file opens in a tab of its own. Their Tools menu works across the open
|
|
25
|
+
* models (toolItems): compare one with another, and merge from another into
|
|
26
|
+
* it (ModelComparison). A tab shows its file's
|
|
27
|
+
* name and • while it has unsaved changes, and right-clicked, the menu of
|
|
28
|
+
* what can be done with that model (tabMenuItems); closing one with unsaved changes
|
|
29
|
+
* asks first; the page title follows the model in front. The last model
|
|
30
|
+
* closed leaves a new, empty one.
|
|
31
|
+
*
|
|
32
|
+
* Spec: `{ id, type: 'models', editorType, models?: [editor part spec], newModel?: editor part spec, bases?: [editor part spec], keep?, content?: [{label?, url}], openContent? }`:
|
|
33
|
+
* `models` are opened at the start (one empty one without), `newModel` is what New model makes.
|
|
34
|
+
*
|
|
35
|
+
* `bases` are reference models kept as given (never opened, edited or kept):
|
|
36
|
+
* File ▸ New ▸ From base makes a model as a copy of one, and Tools ▸ Compare
|
|
37
|
+
* with and Merge from offer them beside the other open models. File ▸
|
|
38
|
+
* Duplicate model opens a copy of the model in front, read back through its
|
|
39
|
+
* own file format.
|
|
40
|
+
*
|
|
41
|
+
* With `keep`, every model is kept in this browser (ModelLibrary over an
|
|
42
|
+
* IndexedDB store) a moment after each edit, in its editor's file format, so
|
|
43
|
+
* work outlives the page without saving a file; those open when the page
|
|
44
|
+
* closed open again next time. File ▸ This browser opens a kept model, stops
|
|
45
|
+
* keeping one, backs them all up (BackupFolder: a folder chosen once, such as
|
|
46
|
+
* one OneDrive or Google Drive syncs, else downloads) and chooses that folder.
|
|
47
|
+
*
|
|
48
|
+
* Where models are kept is the host's to say: a `modelLibrary` among the
|
|
49
|
+
* workspace's services (`workspace.services`, reaching this part as
|
|
50
|
+
* `ctx.modelLibrary`) is used in place of the browser's — anything with
|
|
51
|
+
* ModelLibrary's methods (keep, load, list, openOnes, setOpen, remove) and a
|
|
52
|
+
* `label` for the menu ('The engine'). An entry is the model in its editor's
|
|
53
|
+
* file format, so a host stores it as it likes: a database row, a file, a
|
|
54
|
+
* graph built from it. The backup folder stays in the browser.
|
|
55
|
+
*
|
|
56
|
+
* Proposals: a model offered as a change to the one in front — by an agent,
|
|
57
|
+
* a colleague, an import — with why (`propose`). Tools ▸ Proposals lists them;
|
|
58
|
+
* each is reviewed as a merge (ModelMergeDialog, its rationale shown), so what
|
|
59
|
+
* is taken is chosen and is one undo step. Taking or dismissing one removes it.
|
|
60
|
+
*
|
|
61
|
+
* A host adds tools of its own for the model in front — an agent to run on
|
|
62
|
+
* it, say — as `modelTools(part, models)` among the workspace's services: its
|
|
63
|
+
* items end the Tools menu.
|
|
64
|
+
*
|
|
65
|
+
* Content: models kept apart from the tools, by domain (modelengine, or any
|
|
66
|
+
* collection with its manifest; ContentSource). The sources are the bundle
|
|
67
|
+
* the page loaded (dist/modelengine.js), the spec's `content` (`[{label?, url}]`),
|
|
68
|
+
* the host's `contentSources` service, and those added from the menu (kept
|
|
69
|
+
* in this browser). File ▸ Open from ▸ lists, by source then domain, the items
|
|
70
|
+
* this editor's file formats read, worked out from their files; one chosen
|
|
71
|
+
* opens in a tab of its own, remembering where it came from, so its tab menu
|
|
72
|
+
* and Tools offer **Update from …**: the item read again, its changes
|
|
73
|
+
* reviewed as a merge (one undo step), so what was changed here is kept.
|
|
74
|
+
* With `openContent` ('first' or an item's id) a first visit opens that item.
|
|
75
|
+
*/
|
|
76
|
+
export class ModelsPart extends Part {
|
|
77
|
+
static type = 'models';
|
|
78
|
+
|
|
79
|
+
constructor(spec = {}) {
|
|
80
|
+
super(spec);
|
|
81
|
+
if (!spec.editorType) throw new TypeError('ModelsPart needs an editorType (the editor part type, e.g. "archimate-canvas")');
|
|
82
|
+
this.editorType = spec.editorType;
|
|
83
|
+
/** The ids of the editor parts open here, in the order opened. */
|
|
84
|
+
this.modelIds = [];
|
|
85
|
+
this.work = null;
|
|
86
|
+
this._next = 1;
|
|
87
|
+
/** With `keep`: the kept models and where backups go (set once the store is open). */
|
|
88
|
+
this.library = null;
|
|
89
|
+
this.backups = null;
|
|
90
|
+
/** Edits waiting to be kept, by part id. @private */
|
|
91
|
+
this._pending = new Map();
|
|
92
|
+
/** Changes offered, not yet taken or dismissed (propose). */
|
|
93
|
+
this.proposals = [];
|
|
94
|
+
this._proposalSeq = 0;
|
|
95
|
+
/** Where content comes from (made on render, when the host's services are known). */
|
|
96
|
+
this.content = null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What a copy's name ends with (copyName). */
|
|
100
|
+
static COPY_SUFFIX = ' (copy)';
|
|
101
|
+
|
|
102
|
+
/** How long after an edit a model is kept (an edit's bursts make one write). */
|
|
103
|
+
static KEEP_DELAY = 800;
|
|
104
|
+
|
|
105
|
+
get registry() {
|
|
106
|
+
return this.ctx?.registry ?? null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
render() {
|
|
110
|
+
Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', height: '100%' });
|
|
111
|
+
const host = this.container.appendChild(el({ style: { flex: '1 1 auto', minHeight: '0' } }));
|
|
112
|
+
this.work = new WorkArea({
|
|
113
|
+
placeholder: { cls: 'ws-workarea-empty', text: 'No model open' },
|
|
114
|
+
// A tab's × asks first when its model has unsaved changes (closeModel closes it).
|
|
115
|
+
beforeClose: (id) => { this.closeModel(id); return false; },
|
|
116
|
+
// A tab right-clicked: what can be done with its model.
|
|
117
|
+
tabMenu: (id, event) => this.showTabMenu(id, event),
|
|
118
|
+
onChange: () => this.activeModel()?.file?.showState?.(),
|
|
119
|
+
});
|
|
120
|
+
this.work.render(host, { ...this.ctx, models: this });
|
|
121
|
+
this.bases(); // copied now, before an open model (which may share the spec's data) is edited
|
|
122
|
+
const specs = this.spec.models?.length ? this.spec.models : [this.spec.newModel ?? {}];
|
|
123
|
+
const parts = specs.map(spec => this.newModel(spec));
|
|
124
|
+
if (parts[0]) this.work.focus(parts[0].id);
|
|
125
|
+
/** Resolves once kept models are reopened (with `keep`). */
|
|
126
|
+
this.ready = this.spec.keep ? this.startKeeping(parts) : Promise.resolve();
|
|
127
|
+
this.content ??= new ContentSources({ sources: [...(this.spec.content ?? []), ...(this.ctx?.contentSources ?? [])] });
|
|
128
|
+
/** Resolves once the content sources' manifests are read. */
|
|
129
|
+
this.contentReady = this.content.load();
|
|
130
|
+
/** Resolves once the spec's `openContent` item is open (or there was none to open). */
|
|
131
|
+
this.started = this.openStartContent();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The spec's `openContent` ('first', or an item's id): on a first visit
|
|
136
|
+
* (only the starting, untouched model open, nothing kept reopened) that
|
|
137
|
+
* item, from the first source with one this editor reads, opens in the
|
|
138
|
+
* empty model's place. Resolves to its part, or null.
|
|
139
|
+
*/
|
|
140
|
+
async openStartContent() {
|
|
141
|
+
const wanted = this.spec.openContent;
|
|
142
|
+
if (!wanted) return null;
|
|
143
|
+
await this.ready;
|
|
144
|
+
await this.contentReady;
|
|
145
|
+
const [only, ...more] = this.models();
|
|
146
|
+
if (!only || more.length || only.keepId || only.contentOrigin || only.model?.isDirty?.()) return null;
|
|
147
|
+
const formats = this.readers();
|
|
148
|
+
for (const { source, items } of this.content.catalog) {
|
|
149
|
+
const item = items.find(i => (wanted === 'first' || i.id === wanted) && ContentSource.formatFor(i, formats));
|
|
150
|
+
if (!item) continue;
|
|
151
|
+
const part = await this.openContent(source, item);
|
|
152
|
+
if (part) this.closeModel(only.id);
|
|
153
|
+
return part;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ===== Content: models kept apart from the tools =====
|
|
159
|
+
|
|
160
|
+
/** The file formats this workbench's editors read (a model's, else a base's). */
|
|
161
|
+
readers() {
|
|
162
|
+
const sample = this.models()[0] ?? this.bases()[0];
|
|
163
|
+
return sample?.fileFormats?.() ?? [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* File ▸ Open from ▸: each source, its items this editor reads by domain
|
|
168
|
+
* (a source that could not be read says why), Read again, then Add a
|
|
169
|
+
* source… and Remove ▸ for those added here.
|
|
170
|
+
*/
|
|
171
|
+
contentItems() {
|
|
172
|
+
const formats = this.readers();
|
|
173
|
+
const sources = (this.content?.catalog ?? []).map(({ source, items, error }) => {
|
|
174
|
+
if (error) return { label: source.label, items: [{ label: `Could not read it: ${error}`, disabled: true }] };
|
|
175
|
+
const readable = items.filter(item => ContentSource.formatFor(item, formats));
|
|
176
|
+
const domains = [...new Set(readable.map(i => i.domainName))];
|
|
177
|
+
const entry = (item) => ({ label: item.name || item.id, title: item.description, action: () => this.openContent(source, item) });
|
|
178
|
+
return {
|
|
179
|
+
label: source.label,
|
|
180
|
+
items: readable.length
|
|
181
|
+
? domains.map(d => ({ label: d || 'Other', items: readable.filter(i => i.domainName === d).map(entry) }))
|
|
182
|
+
: [{ label: 'Nothing this editor reads', disabled: true }],
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
const added = this.content?.added() ?? [];
|
|
186
|
+
return [
|
|
187
|
+
...sources,
|
|
188
|
+
...(sources.length ? [{ separator: true }, { label: 'Read them again', action: () => this.content.load() }] : []),
|
|
189
|
+
{ label: 'Add a source…', action: () => this.askContentSource() },
|
|
190
|
+
...(added.length ? [{ label: 'Remove a source', items: added.map(s => ({ label: s.label || s.url, action: () => this.content.remove(s.url) })) }] : []),
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Open a content item in a tab of its own, remembering where it came from. Resolves to the part, or null. */
|
|
195
|
+
async openContent(source, item) {
|
|
196
|
+
try {
|
|
197
|
+
const { model } = await source.open(item, this.readers());
|
|
198
|
+
const part = this.openModel(model);
|
|
199
|
+
part.contentOrigin = { source, itemId: item.id, version: item.version ?? null, name: item.name || item.id };
|
|
200
|
+
return part;
|
|
201
|
+
} catch (err) {
|
|
202
|
+
this.tell(`Could not open "${item.name || item.id}" from ${source.label}: ${err.message}`);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Update from …: the item this model was opened from, read again, its
|
|
209
|
+
* changes reviewed as a merge into this one (ModelMergeDialog; one undo
|
|
210
|
+
* step), so what was changed here stays. Resolves to the dialog, or null.
|
|
211
|
+
*/
|
|
212
|
+
async updateFromContent(part) {
|
|
213
|
+
const origin = part.contentOrigin;
|
|
214
|
+
if (!origin) return null;
|
|
215
|
+
try {
|
|
216
|
+
await origin.source.manifest({ refresh: true });
|
|
217
|
+
const item = await origin.source.item(origin.itemId);
|
|
218
|
+
if (!item) throw new Error(`it no longer has "${origin.name}"`);
|
|
219
|
+
const { model } = await origin.source.open(item, this.readers());
|
|
220
|
+
const label = `${item.name || item.id} (${origin.source.label}${item.version ? `, version ${item.version}` : ''})`;
|
|
221
|
+
return this.merge(part, { model, fileLabel: label, compareLabel: label });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
this.tell(`Could not update from ${origin.source.label}: ${err.message}`);
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The model's Update from … item, when it was opened from content. */
|
|
229
|
+
contentUpdateItems(part) {
|
|
230
|
+
return part?.contentOrigin ? [{ label: `Update from ${part.contentOrigin.source.label}…`, action: () => this.updateFromContent(part) }] : [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Add a source… : a manifest's url (a folder holding manifest.json, or the file) and what to call it. */
|
|
234
|
+
askContentSource() {
|
|
235
|
+
return new FormDialog({
|
|
236
|
+
title: 'Add a content source',
|
|
237
|
+
description: 'A collection of models with a manifest.json (modelengine, or one laid out like it): a folder\'s address, or the manifest\'s.',
|
|
238
|
+
fields: [
|
|
239
|
+
{ key: 'url', label: 'Address', type: 'text', value: '', placeholder: 'https://…/modelengine/' },
|
|
240
|
+
{ key: 'label', label: 'Called', type: 'text', value: '', placeholder: 'its name, from its manifest' },
|
|
241
|
+
],
|
|
242
|
+
submitLabel: 'Add',
|
|
243
|
+
validate: ({ url }) => (String(url ?? '').trim() ? null : 'Give its address'),
|
|
244
|
+
document: this.container?.ownerDocument ?? globalThis.document,
|
|
245
|
+
onApply: ({ url, label }) => this.content.add({ url, label }),
|
|
246
|
+
}).open();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ===== Kept in this browser =====
|
|
250
|
+
|
|
251
|
+
/** The store models and the backup folder are kept in: the browser's IndexedDB. A test gives its own. */
|
|
252
|
+
openStore() {
|
|
253
|
+
return IndexedDbStore.open('renderengine-models');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Open the store, reopen the models open when the page last closed (in
|
|
258
|
+
* place of the starting ones, unless those were changed meanwhile), and keep
|
|
259
|
+
* what is open now.
|
|
260
|
+
* @param {Array<EditorPart>} starting - The models the spec opened
|
|
261
|
+
*/
|
|
262
|
+
async startKeeping(starting = []) {
|
|
263
|
+
const store = await this.openStore(); // the backup folder's, whoever keeps the models
|
|
264
|
+
this.library = this.ctx?.modelLibrary ?? new ModelLibrary(store);
|
|
265
|
+
const win = this.container?.ownerDocument?.defaultView ?? globalThis;
|
|
266
|
+
this.backups = new BackupFolder({
|
|
267
|
+
store, window: win,
|
|
268
|
+
download: (name, data, mimeType) => downloadBlob(new (win.Blob ?? Blob)([data], { type: mimeType }), name),
|
|
269
|
+
});
|
|
270
|
+
await this.backups.restore();
|
|
271
|
+
const kept = this.ownKept(await this.library.openOnes());
|
|
272
|
+
for (const { id } of kept) {
|
|
273
|
+
const entry = await this.library.load(id); // the list has no data; the entry has
|
|
274
|
+
if (entry) this.openKept(entry);
|
|
275
|
+
}
|
|
276
|
+
if (kept.length) for (const part of starting) if (!part.model?.isDirty?.()) this.closeModel(part.id);
|
|
277
|
+
for (const part of this.models()) if (part.keepId) await this.keep(part);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Keep a model in this browser now (its editor's keepEntry), as open. */
|
|
281
|
+
async keep(part) {
|
|
282
|
+
if (!this.library || part.keepOff) return null;
|
|
283
|
+
const entry = part.keepEntry?.();
|
|
284
|
+
if (!entry) return null;
|
|
285
|
+
part.keepId ??= ModelLibrary.newId();
|
|
286
|
+
return this.library.keep({ ...entry, id: part.keepId, editorType: this.editorType, open: this.modelIds.includes(part.id) });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A model was edited: keep it a moment later (a burst of edits, one write). */
|
|
290
|
+
modelEdited(part) {
|
|
291
|
+
if (!this.library) return;
|
|
292
|
+
clearTimeout(this._pending.get(part.id));
|
|
293
|
+
this._pending.set(part.id, setTimeout(() => { this._pending.delete(part.id); this.keep(part); }, ModelsPart.KEEP_DELAY));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The kept models that are this workbench's: kept by its kind of editor
|
|
298
|
+
* (`editorType`), or (kept before that was recorded) in one of its formats.
|
|
299
|
+
* Pages of other editors keep theirs in the same browser; those are not read here.
|
|
300
|
+
*/
|
|
301
|
+
ownKept(entries) {
|
|
302
|
+
const labels = new Set(this.formatLabels());
|
|
303
|
+
return entries.filter(e => (e.editorType ? e.editorType === this.editorType : labels.has(e.format)));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The labels of this workbench's editors' file formats. */
|
|
307
|
+
formatLabels() {
|
|
308
|
+
const sample = this.models()[0] ?? this.bases()[0];
|
|
309
|
+
return (sample?.fileFormats?.() ?? []).map(f => f.label);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 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. */
|
|
313
|
+
openKept(entry) {
|
|
314
|
+
if (!this.ownKept([entry]).length) {
|
|
315
|
+
this.tell(`"${entry.name}" was kept by another editor (${entry.editorType ?? entry.format}); open it there.`);
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
const part = this.newModel();
|
|
319
|
+
const format = part.fileFormats().find(f => f.label === entry.format);
|
|
320
|
+
try {
|
|
321
|
+
part.replaceModel(format.import(entry.data));
|
|
322
|
+
} catch (err) {
|
|
323
|
+
this.closeModel(part.id);
|
|
324
|
+
this.tell(`"${entry.name}" could not be read back: ${err.message}`);
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
part.keepId = entry.id;
|
|
328
|
+
part.file.name = entry.fileName ?? null;
|
|
329
|
+
part.file.showState();
|
|
330
|
+
this.work.focus(part.id);
|
|
331
|
+
return part;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* File ▸ This browser, with `keep`: open a kept model, stop keeping this
|
|
336
|
+
* one, back up every kept model, choose where backups go.
|
|
337
|
+
*/
|
|
338
|
+
keepItems(part) {
|
|
339
|
+
if (!this.library) return [];
|
|
340
|
+
return [
|
|
341
|
+
{ label: 'Open a kept model…', action: () => this.askOpenKept() },
|
|
342
|
+
{ label: 'Stop keeping this model', disabled: !part.keepId || undefined, action: () => this.stopKeeping(part) },
|
|
343
|
+
{ separator: true },
|
|
344
|
+
{ label: 'Back up kept models now', action: () => this.backUpAndTell() },
|
|
345
|
+
{ label: `Backup folder: ${this.backups.location}…`, disabled: !this.backups.supported || undefined, action: () => this.chooseBackupFolder() },
|
|
346
|
+
...(this.backups.dir ? [{ label: 'Back up to downloads instead', action: () => this.backups.forget() }] : []),
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Choose a kept model not open now, and open it. */
|
|
351
|
+
async askOpenKept() {
|
|
352
|
+
const open = new Set(this.models().map(p => p.keepId));
|
|
353
|
+
const entries = this.ownKept(await this.library.list()).filter(e => !open.has(e.id));
|
|
354
|
+
if (!entries.length) { this.tell(`Every model kept (${this.keptWhere}) is open.`); return null; }
|
|
355
|
+
return new FormDialog({
|
|
356
|
+
title: 'Open a kept model',
|
|
357
|
+
fields: [{
|
|
358
|
+
key: 'id', label: 'Model', type: 'select', value: entries[0].id,
|
|
359
|
+
choices: entries.map(e => [e.id, `${e.name} (kept ${new Date(e.keptAt).toLocaleString()})`]),
|
|
360
|
+
}],
|
|
361
|
+
submitLabel: 'Open', idPrefix: 'kept-',
|
|
362
|
+
document: this.container?.ownerDocument ?? globalThis.document,
|
|
363
|
+
onApply: async ({ id }) => { const entry = await this.library.load(id); if (entry) this.openKept(entry); },
|
|
364
|
+
}).open();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Forget a model in this browser, and keep it no more. */
|
|
368
|
+
async stopKeeping(part) {
|
|
369
|
+
if (part.keepId) await this.library.remove(part.keepId);
|
|
370
|
+
part.keepId = null;
|
|
371
|
+
part.keepOff = true;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Every kept model as a backup file: its name, in its format's extension. */
|
|
375
|
+
async backupFiles() {
|
|
376
|
+
const sample = this.models()[0];
|
|
377
|
+
const formats = sample?.fileFormats?.() ?? [];
|
|
378
|
+
const files = [];
|
|
379
|
+
for (const meta of this.ownKept(await this.library.list())) {
|
|
380
|
+
const entry = await this.library.load(meta.id);
|
|
381
|
+
const format = formats.find(f => f.label === entry.format);
|
|
382
|
+
const ext = (format?.accept ?? '').split(',')[0].trim() || '';
|
|
383
|
+
const base = String(entry.fileName ?? entry.name ?? 'model').replace(/\.[^.]+$/, '').replace(/[^A-Za-z0-9._-]+/g, '-') || 'model';
|
|
384
|
+
files.push({ name: `${base}${ext}`, data: entry.data, mimeType: 'application/octet-stream' });
|
|
385
|
+
}
|
|
386
|
+
return files;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Back up every kept model (what is open is kept first), and say where. */
|
|
390
|
+
async backUpAndTell() {
|
|
391
|
+
for (const part of this.models()) await this.keep(part);
|
|
392
|
+
const files = await this.backupFiles();
|
|
393
|
+
if (!files.length) { this.tell(`Nothing is kept (${this.keptWhere}) yet.`); return null; }
|
|
394
|
+
const { location, names } = await this.backups.backUp(files);
|
|
395
|
+
this.tell(`Backed up ${names.length} model${names.length === 1 ? '' : 's'} to the ${location}:\n\n${names.join('\n')}`);
|
|
396
|
+
return { location, names };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Choose the backup folder (a click); one OneDrive, Google Drive or Dropbox syncs takes the backups to the cloud. */
|
|
400
|
+
async chooseBackupFolder() {
|
|
401
|
+
const dir = await this.backups.choose();
|
|
402
|
+
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.`);
|
|
403
|
+
return dir;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Where models are kept, as the File menu names it: the library's label. */
|
|
407
|
+
get keptWhere() {
|
|
408
|
+
return this.library?.label ?? 'This browser';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ===== Proposals =====
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Offer a change: the model proposed — `spec`, a model as this workbench's
|
|
415
|
+
* editor takes it (as a base is given), or `entry`, a model in one of the
|
|
416
|
+
* editor's file formats (`{format, data}`, as a kept one is) — with a
|
|
417
|
+
* `title` and why (`rationale`), from `source` (who or what proposed it).
|
|
418
|
+
* `onSettle(outcome, {bench})` hears whether it was 'taken' or
|
|
419
|
+
* 'dismissed' and, taken, how what was taken moved the bench. Returns the proposal.
|
|
420
|
+
* @param {{title: string, rationale?: string, source?: string, spec?: Object, entry?: {format: string, data: *}, onSettle?: Function}} proposal
|
|
421
|
+
*/
|
|
422
|
+
propose({ title, rationale = '', source = '', spec = null, entry = null, onSettle = null }) {
|
|
423
|
+
if (!spec && !entry) throw new TypeError('A proposal needs a model spec');
|
|
424
|
+
const id = `${this.id}-proposal-${++this._proposalSeq}`;
|
|
425
|
+
const part = this.registry.create({ ...ModelsPart.copySpec(spec ?? {}), id, type: this.editorType });
|
|
426
|
+
if (entry) {
|
|
427
|
+
const format = part.fileFormats().find(f => f.label === entry.format);
|
|
428
|
+
if (!format) { part.dispose?.(); throw new Error(`No ${entry.format} format here to read the proposal in`); }
|
|
429
|
+
part.replaceModel(format.import(entry.data));
|
|
430
|
+
}
|
|
431
|
+
const proposal = { id, title: title || part.model?.name || 'Proposal', rationale, source, part, onSettle };
|
|
432
|
+
this.proposals.push(proposal);
|
|
433
|
+
return proposal;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* How the proposal would move the model's measures (Benchmark), were it
|
|
438
|
+
* taken whole: `[{label, before, after, change, …}]`.
|
|
439
|
+
*/
|
|
440
|
+
proposalBench(part, proposal) {
|
|
441
|
+
return Benchmark.compare(Benchmark.of(part.model), Benchmark.of(proposal.part.model));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Forget a proposal: not wanted (`outcome` 'dismissed'), or taken. Its proposer hears which. */
|
|
445
|
+
dismissProposal(proposal, outcome = 'dismissed', note = {}) {
|
|
446
|
+
if (!this.proposals.includes(proposal)) return;
|
|
447
|
+
this.proposals = this.proposals.filter(p => p !== proposal);
|
|
448
|
+
proposal.onSettle?.(outcome, note);
|
|
449
|
+
proposal.part?.dispose?.();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Review a proposal against `part`'s model: its differences, chosen as a
|
|
454
|
+
* merge (ModelMergeDialog), its rationale above them. Merging takes it.
|
|
455
|
+
*/
|
|
456
|
+
reviewProposal(part, proposal) {
|
|
457
|
+
const bench = this.proposalBench(part, proposal);
|
|
458
|
+
return new ModelMergeDialog({
|
|
459
|
+
...this._comparing(part, proposal.part),
|
|
460
|
+
theirsName: proposal.title,
|
|
461
|
+
note: [proposal.source && `Proposed by ${proposal.source}.`, proposal.rationale, bench.length && `Taken whole: ${Benchmark.text(bench)}.`].filter(Boolean).join(' '),
|
|
462
|
+
onMerge: (command) => {
|
|
463
|
+
// What was taken (the parts chosen), measured: the model before the merge and after.
|
|
464
|
+
const before = Benchmark.of(part.model);
|
|
465
|
+
part.runModelEdit(command);
|
|
466
|
+
this.dismissProposal(proposal, 'taken', { bench: Benchmark.text(Benchmark.compare(before, Benchmark.of(part.model))) });
|
|
467
|
+
},
|
|
468
|
+
}).open();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Tools ▸ Proposals: each, to review or dismiss. */
|
|
472
|
+
proposalItems(part) {
|
|
473
|
+
if (!this.proposals.length) return [{ label: 'None waiting', disabled: true }];
|
|
474
|
+
return this.proposals.map(p => ({
|
|
475
|
+
label: p.source ? `${p.title} — ${p.source}` : p.title,
|
|
476
|
+
items: [
|
|
477
|
+
{ label: 'Review and merge…', action: () => this.reviewProposal(part, p) },
|
|
478
|
+
{ label: 'Dismiss', action: () => this.dismissProposal(p) },
|
|
479
|
+
],
|
|
480
|
+
}));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Tell the user (the page's alert). @protected */
|
|
484
|
+
tell(message) {
|
|
485
|
+
const win = this.container?.ownerDocument?.defaultView ?? globalThis;
|
|
486
|
+
win.alert?.(message);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The editor parts open here, in order. */
|
|
490
|
+
models() {
|
|
491
|
+
return this.modelIds.map(id => this.registry?.get(id)).filter(Boolean);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** The editor part of the model in front (the selected tab of the active group). */
|
|
495
|
+
activeModel() {
|
|
496
|
+
const id = this.work?.activePart();
|
|
497
|
+
return id ? this.registry?.get(id) ?? null : null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Open a new model in a tab: an editor part of `editorType` made from `spec`
|
|
502
|
+
* (default the spec's `newModel`, an empty model). Returns the part.
|
|
503
|
+
*/
|
|
504
|
+
newModel(spec = this.spec.newModel ?? {}) {
|
|
505
|
+
const id = spec.id && !this.registry.has(spec.id) ? spec.id : this._freshId();
|
|
506
|
+
const part = this.registry.add({ ...spec, id, type: this.editorType });
|
|
507
|
+
this.modelIds.push(id);
|
|
508
|
+
this.work.open(id);
|
|
509
|
+
this.fileStateChanged(part);
|
|
510
|
+
return part;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Open a model made elsewhere (one imported from a file) in a new tab,
|
|
515
|
+
* as it is: nothing to undo, not yet saved to a file. Returns the part.
|
|
516
|
+
*/
|
|
517
|
+
openModel(model) {
|
|
518
|
+
const part = this.newModel();
|
|
519
|
+
part.replaceModel(model);
|
|
520
|
+
this.work.focus(part.id);
|
|
521
|
+
this.fileStateChanged(part);
|
|
522
|
+
this.keep(part); // an import is kept straight away
|
|
523
|
+
return part;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ===== Bases, and copies =====
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The base models (spec `bases`): a part each, made from a copy of its spec
|
|
530
|
+
* and never added to the workbench, so nothing done to an open model reaches it.
|
|
531
|
+
*/
|
|
532
|
+
bases() {
|
|
533
|
+
return (this._bases ??= (this.spec.bases ?? []).map((spec, i) =>
|
|
534
|
+
this.registry.create({ ...ModelsPart.copySpec(spec), id: `${this.id}-base-${i + 1}`, type: this.editorType })));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Replace the bases: a host whose reference models change (a live
|
|
539
|
+
* database after its schema changed) gives them afresh. The parts made
|
|
540
|
+
* for the old ones are disposed; nothing open is touched.
|
|
541
|
+
* @param {Array<Object>} specs - Editor part specs, as `bases` in the spec
|
|
542
|
+
*/
|
|
543
|
+
setBases(specs = []) {
|
|
544
|
+
for (const base of this._bases ?? []) base.dispose?.();
|
|
545
|
+
this._bases = null;
|
|
546
|
+
this.spec.bases = specs;
|
|
547
|
+
return this.bases();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** What a copy of a model is called: " (copy)" after its name, once (a copy of a copy is not "… (copy) (copy)"). */
|
|
551
|
+
static copyName(name) {
|
|
552
|
+
const plain = String(name || 'Untitled');
|
|
553
|
+
return plain.endsWith(ModelsPart.COPY_SUFFIX) ? plain : `${plain}${ModelsPart.COPY_SUFFIX}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** A spec's own copy: its data apart from the spec's (a spec is JSON). */
|
|
557
|
+
static copySpec(spec) {
|
|
558
|
+
return JSON.parse(JSON.stringify(spec ?? {}));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** What a base is called in menus. */
|
|
562
|
+
baseLabel(base, i) {
|
|
563
|
+
return base.model?.name || base.spec.name || `Base ${i + 1}`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Open a new model as a copy of a base (by its place in `bases`), named as a copy (copyName). Returns the part. */
|
|
567
|
+
newFromBase(index) {
|
|
568
|
+
const spec = this.bases()[index]?.spec; // the base's own copy, as given
|
|
569
|
+
if (!spec) return null;
|
|
570
|
+
const { id, keepId, ...copy } = ModelsPart.copySpec(spec);
|
|
571
|
+
const part = this.newModel({ ...copy, name: ModelsPart.copyName(copy.name ?? this.baseLabel(this.bases()[index], index)) });
|
|
572
|
+
this.work.focus(part.id);
|
|
573
|
+
return part;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Open a base to look at (by its place in `bases`): a tab of its own copy,
|
|
578
|
+
* named as the base is with " (view)", that can be laid out and moved
|
|
579
|
+
* about but is never kept — a live database seen as it is, without making a
|
|
580
|
+
* design of it. Returns the part.
|
|
581
|
+
*/
|
|
582
|
+
viewBase(index) {
|
|
583
|
+
const spec = this.bases()[index]?.spec;
|
|
584
|
+
if (!spec) return null;
|
|
585
|
+
const { id, keepId, ...copy } = ModelsPart.copySpec(spec);
|
|
586
|
+
const part = this.newModel({ ...copy, name: `${copy.name ?? this.baseLabel(this.bases()[index], index)}${ModelsPart.VIEW_SUFFIX}` });
|
|
587
|
+
part.keepOff = true;
|
|
588
|
+
this.work.focus(part.id);
|
|
589
|
+
return part;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** What a base viewed is called: " (view)" after its name. */
|
|
593
|
+
static VIEW_SUFFIX = ' (view)';
|
|
594
|
+
|
|
595
|
+
/** File ▸ New ▸'s items from bases: From base: <name>, a design from one; View base: <name>, one to look at. */
|
|
596
|
+
baseItems() {
|
|
597
|
+
const bases = this.bases();
|
|
598
|
+
return [
|
|
599
|
+
...bases.map((base, i) => ({ label: `From base: ${this.baseLabel(base, i)}`, action: () => this.newFromBase(i) })),
|
|
600
|
+
...(bases.length ? [{ separator: true }] : []),
|
|
601
|
+
...bases.map((base, i) => ({ label: `View base: ${this.baseLabel(base, i)}`, action: () => this.viewBase(i) })),
|
|
602
|
+
];
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Open a copy of a model in a new tab: written and read back in its own
|
|
607
|
+
* file format (keepEntry), named as a copy (copyName). Null when it has no format.
|
|
608
|
+
*/
|
|
609
|
+
duplicate(part) {
|
|
610
|
+
const entry = part.keepEntry?.();
|
|
611
|
+
if (!entry) return null;
|
|
612
|
+
const format = part.fileFormats().find(f => f.label === entry.format);
|
|
613
|
+
const model = format.import(entry.data);
|
|
614
|
+
model.name = ModelsPart.copyName(model.name || entry.name);
|
|
615
|
+
return this.openModel(model);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* The Tools menu's items for one open model (`part`): Compare with and
|
|
620
|
+
* Merge from, each a submenu of the other open models, then the bases.
|
|
621
|
+
*/
|
|
622
|
+
toolItems(part) {
|
|
623
|
+
const others = this.models().filter(m => m !== part);
|
|
624
|
+
const bases = this.bases().map((base, i) => ({ base, label: `${this.baseLabel(base, i)} (base)` }));
|
|
625
|
+
const each = (act) => (others.length || bases.length
|
|
626
|
+
? [
|
|
627
|
+
...others.map(other => ({ label: this.labelOf(other), action: () => act(part, other) })),
|
|
628
|
+
...(others.length && bases.length ? [{ separator: true }] : []),
|
|
629
|
+
...bases.map(({ base, label }) => ({ label, action: () => act(part, base) })),
|
|
630
|
+
]
|
|
631
|
+
: [{ label: 'Open another model first', disabled: true }]);
|
|
632
|
+
const own = part.modelMenuItems?.() ?? [];
|
|
633
|
+
return [
|
|
634
|
+
{ label: 'Compare with', items: each((a, b) => this.compare(a, b)) },
|
|
635
|
+
{ label: 'Merge from', items: each((a, b) => this.merge(a, b)) },
|
|
636
|
+
...this.contentUpdateItems(part),
|
|
637
|
+
// The model's own tools (Find likely duplicates…).
|
|
638
|
+
...(own.length ? [{ separator: true }, ...own] : []),
|
|
639
|
+
{ separator: true },
|
|
640
|
+
{ label: `Proposals (${this.proposals.length})`, items: this.proposalItems(part) },
|
|
641
|
+
// The host's own tools for the model in front (`services.modelTools(part, models)`: agents, say).
|
|
642
|
+
...(typeof this.ctx?.modelTools === 'function' ? [{ separator: true }, ...this.ctx.modelTools(part, this)] : []),
|
|
643
|
+
];
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** How `part`'s model differs from `other`'s, shown (ModelCompareDialog); its Merge… goes on to merge. */
|
|
647
|
+
compare(part, other) {
|
|
648
|
+
return new ModelCompareDialog({
|
|
649
|
+
...this._comparing(part, other),
|
|
650
|
+
onMerge: () => this.merge(part, other),
|
|
651
|
+
}).open();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Choose what to bring from `other`'s model into `part`'s, then bring it: one undo step there. */
|
|
655
|
+
merge(part, other) {
|
|
656
|
+
return new ModelMergeDialog({
|
|
657
|
+
...this._comparing(part, other),
|
|
658
|
+
onMerge: (command) => part.runModelEdit(command),
|
|
659
|
+
}).open();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** @private */
|
|
663
|
+
_comparing(part, other) {
|
|
664
|
+
return {
|
|
665
|
+
comparison: part.model.compareWith(other.model),
|
|
666
|
+
mineName: part.model.name || this.labelOf(part),
|
|
667
|
+
theirsName: other.compareLabel ?? (other.model.name || this.labelOf(other)),
|
|
668
|
+
document: this.container?.ownerDocument ?? globalThis.document,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Ask for a file (as `from`'s Open… does) and open it in a new tab,
|
|
674
|
+
* leaving `from`'s model as it is. Returns the new part, or null.
|
|
675
|
+
*/
|
|
676
|
+
async openInNewTab(from) {
|
|
677
|
+
const chosen = await from.file.choose();
|
|
678
|
+
if (!chosen) return null;
|
|
679
|
+
const part = this.newModel();
|
|
680
|
+
part.file.adopt(chosen);
|
|
681
|
+
return part;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Close a model's tab; with unsaved changes, only once confirmed. The last
|
|
686
|
+
* one closed leaves a new, empty model. Returns whether it closed.
|
|
687
|
+
*/
|
|
688
|
+
closeModel(id) {
|
|
689
|
+
const part = this.registry?.get(id);
|
|
690
|
+
if (!part || !this.modelIds.includes(id)) return false;
|
|
691
|
+
if (part.model?.isDirty?.() && !this.confirm(`"${this.labelOf(part)}" has unsaved changes. Close it anyway?`)) return false;
|
|
692
|
+
this.work.close(id);
|
|
693
|
+
this.modelIds = this.modelIds.filter(m => m !== id);
|
|
694
|
+
if (part.keepId) this.library?.setOpen(part.keepId, false); // kept, not reopened next time
|
|
695
|
+
this.registry.remove(id);
|
|
696
|
+
if (!this.modelIds.length) this.newModel();
|
|
697
|
+
else this.activeModel()?.file?.showState?.();
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** A model's tab label: what its toolbar and the page title call it (ModelFile#label). */
|
|
702
|
+
labelOf(part) {
|
|
703
|
+
return part.fileLabel;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** An editor part's file or saved state changed: its tab says so. */
|
|
707
|
+
fileStateChanged(part) {
|
|
708
|
+
part.title = this.labelOf(part);
|
|
709
|
+
this.work?.refreshLabels();
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* What can be done with one open model, for its tab's right-click menu:
|
|
714
|
+
* Save and Save As… (when it saves files), Duplicate model, Compare with ▸
|
|
715
|
+
* and Merge from ▸ (the other open models, then the bases), the model's own
|
|
716
|
+
* tools (Find likely duplicates…: `part.modelMenuItems()`), This browser ▸
|
|
717
|
+
* (with `keep`), Close model and Close other models.
|
|
718
|
+
*/
|
|
719
|
+
tabMenuItems(part) {
|
|
720
|
+
const saves = !!part.fileFormats?.().length;
|
|
721
|
+
const [, save, saveAs] = saves ? part.file.menuItems() : [];
|
|
722
|
+
const [compare, merge] = part.model?.compareWith ? this.toolItems(part) : [];
|
|
723
|
+
const keeping = this.keepItems(part);
|
|
724
|
+
const others = this.modelIds.filter(id => id !== part.id);
|
|
725
|
+
const own = part.modelMenuItems?.() ?? [];
|
|
726
|
+
return [
|
|
727
|
+
...(saves ? [save, saveAs, { separator: true }, { label: 'Duplicate model', action: () => this.duplicate(part) }] : []),
|
|
728
|
+
...(compare ? [compare, merge] : []),
|
|
729
|
+
...this.contentUpdateItems(part),
|
|
730
|
+
...own,
|
|
731
|
+
...(keeping.length ? [{ label: this.keptWhere, items: keeping }] : []),
|
|
732
|
+
{ separator: true },
|
|
733
|
+
{ label: 'Close model', action: () => this.closeModel(part.id) },
|
|
734
|
+
{ label: 'Close other models', disabled: !others.length || undefined, action: () => { for (const id of others) this.closeModel(id); } },
|
|
735
|
+
];
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Show a model's tab menu at a right-click; true when there was a model to show it for. */
|
|
739
|
+
showTabMenu(id, event) {
|
|
740
|
+
const part = this.modelIds.includes(id) ? this.registry?.get(id) : null;
|
|
741
|
+
if (!part) return false;
|
|
742
|
+
PopupMenu.show(event?.clientX ?? 0, event?.clientY ?? 0, this.tabMenuItems(part), this.container?.ownerDocument ?? globalThis.document);
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Ask the user (the page's confirm). @protected */
|
|
747
|
+
confirm(message) {
|
|
748
|
+
const win = this.container?.ownerDocument?.defaultView ?? globalThis;
|
|
749
|
+
return typeof win.confirm === 'function' ? win.confirm(message) : true;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** @private */
|
|
753
|
+
_freshId() {
|
|
754
|
+
let id;
|
|
755
|
+
do { id = `${this.id}-model-${this._next++}`; } while (this.registry.has(id));
|
|
756
|
+
return id;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
dispose() {
|
|
760
|
+
this.work?.dispose();
|
|
761
|
+
for (const id of this.modelIds) this.registry?.remove(id);
|
|
762
|
+
this.modelIds = [];
|
|
763
|
+
super.dispose();
|
|
764
|
+
}
|
|
765
|
+
}
|