@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
package/package.json
CHANGED
|
@@ -1,70 +1,70 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@peter.prib/renderengine",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "src/index.js",
|
|
7
|
-
"module": "src/index.js",
|
|
8
|
-
"browser": "dist/renderengine.umd.js",
|
|
9
|
-
"exports": {
|
|
10
|
-
".": "./src/index.js",
|
|
11
|
-
"./workspace": "./src/workspace/index.js",
|
|
12
|
-
"./src/*": "./src/*",
|
|
13
|
-
"./dist/*": "./dist/*",
|
|
14
|
-
"./package.json": "./package.json"
|
|
15
|
-
},
|
|
16
|
-
"files": [
|
|
17
|
-
"src/",
|
|
18
|
-
"dist/*.umd.js",
|
|
19
|
-
"examples/archimate.html",
|
|
20
|
-
"examples/diagram-editor.html",
|
|
21
|
-
"examples/data-model.html",
|
|
22
|
-
"README.md",
|
|
23
|
-
"REFERENCE.md",
|
|
24
|
-
"LICENSE"
|
|
25
|
-
],
|
|
26
|
-
"scripts": {
|
|
27
|
-
"start": "node scripts/open.mjs examples/archimate.html",
|
|
28
|
-
"archimate": "node scripts/open.mjs examples/archimate.html",
|
|
29
|
-
"diagram": "node scripts/open.mjs examples/diagram-editor.html",
|
|
30
|
-
"datamodel": "node scripts/open.mjs examples/data-model.html",
|
|
31
|
-
"test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
|
|
32
|
-
"build": "rollup -c",
|
|
33
|
-
"prepublishOnly": "npm test && npm run build",
|
|
34
|
-
"screenshots": "node scripts/screenshots/screenshots.mjs"
|
|
35
|
-
},
|
|
36
|
-
"keywords": [
|
|
37
|
-
"archimate",
|
|
38
|
-
"archi",
|
|
39
|
-
"diagram",
|
|
40
|
-
"diagram-editor",
|
|
41
|
-
"modelling",
|
|
42
|
-
"charts",
|
|
43
|
-
"svg",
|
|
44
|
-
"canvas",
|
|
45
|
-
"render",
|
|
46
|
-
"engine",
|
|
47
|
-
"graphics"
|
|
48
|
-
],
|
|
49
|
-
"author": "Peter Prib <peter.prib@gmail.com>",
|
|
50
|
-
"license": "MIT",
|
|
51
|
-
"repository": {
|
|
52
|
-
"type": "git",
|
|
53
|
-
"url": "git+https://gitlab.com/peter.prib/renderengine.git"
|
|
54
|
-
},
|
|
55
|
-
"homepage": "https://gitlab.com/peter.prib/renderengine#readme",
|
|
56
|
-
"bugs": {
|
|
57
|
-
"url": "https://gitlab.com/peter.prib/renderengine/-/issues"
|
|
58
|
-
},
|
|
59
|
-
"engines": {
|
|
60
|
-
"node": ">=18"
|
|
61
|
-
},
|
|
62
|
-
"publishConfig": {
|
|
63
|
-
"access": "public"
|
|
64
|
-
},
|
|
65
|
-
"devDependencies": {
|
|
66
|
-
"@rollup/plugin-commonjs": "^25.0.8",
|
|
67
|
-
"@rollup/plugin-node-resolve": "^15.3.1",
|
|
68
|
-
"rollup": "^3.30.0"
|
|
69
|
-
}
|
|
70
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@peter.prib/renderengine",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"module": "src/index.js",
|
|
8
|
+
"browser": "dist/renderengine.umd.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./workspace": "./src/workspace/index.js",
|
|
12
|
+
"./src/*": "./src/*",
|
|
13
|
+
"./dist/*": "./dist/*",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/",
|
|
18
|
+
"dist/*.umd.js",
|
|
19
|
+
"examples/archimate.html",
|
|
20
|
+
"examples/diagram-editor.html",
|
|
21
|
+
"examples/data-model.html",
|
|
22
|
+
"README.md",
|
|
23
|
+
"REFERENCE.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"start": "node scripts/open.mjs examples/archimate.html",
|
|
28
|
+
"archimate": "node scripts/open.mjs examples/archimate.html",
|
|
29
|
+
"diagram": "node scripts/open.mjs examples/diagram-editor.html",
|
|
30
|
+
"datamodel": "node scripts/open.mjs examples/data-model.html",
|
|
31
|
+
"test": "node --test --test-timeout=10000 ./test/*.js ./test/charting/*.js ./test/charting/charts/*.js ./test/charting/diagrams/*.js ./test/charting/shapes/*.js",
|
|
32
|
+
"build": "rollup -c",
|
|
33
|
+
"prepublishOnly": "npm test && npm run build",
|
|
34
|
+
"screenshots": "node scripts/screenshots/screenshots.mjs"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"archimate",
|
|
38
|
+
"archi",
|
|
39
|
+
"diagram",
|
|
40
|
+
"diagram-editor",
|
|
41
|
+
"modelling",
|
|
42
|
+
"charts",
|
|
43
|
+
"svg",
|
|
44
|
+
"canvas",
|
|
45
|
+
"render",
|
|
46
|
+
"engine",
|
|
47
|
+
"graphics"
|
|
48
|
+
],
|
|
49
|
+
"author": "Peter Prib <peter.prib@gmail.com>",
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://gitlab.com/peter.prib/renderengine.git"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://gitlab.com/peter.prib/renderengine#readme",
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://gitlab.com/peter.prib/renderengine/-/issues"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=18"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@rollup/plugin-commonjs": "^25.0.8",
|
|
67
|
+
"@rollup/plugin-node-resolve": "^15.3.1",
|
|
68
|
+
"rollup": "^3.30.0"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -207,6 +207,8 @@ export function parseArchiModel(xml) {
|
|
|
207
207
|
elements: [],
|
|
208
208
|
relationships: [],
|
|
209
209
|
views: [],
|
|
210
|
+
// The Views sub-folders that hold no view (each a path of folder steps); a view carries its own.
|
|
211
|
+
emptyViewFolders: [],
|
|
210
212
|
// The model's own documentation (Archi's "purpose") and properties, in order (keys may repeat).
|
|
211
213
|
documentation: _childText(root, 'purpose'),
|
|
212
214
|
properties: _propertyPairs(root),
|
|
@@ -240,8 +242,10 @@ function _folderStep(folder) {
|
|
|
240
242
|
}
|
|
241
243
|
|
|
242
244
|
/** Every view in the Views folder, sub-folders included, in file order; each
|
|
243
|
-
* remembers its sub-folder path (`archiFolder`).
|
|
245
|
+
* remembers its sub-folder path (`archiFolder`). A sub-folder with nothing in
|
|
246
|
+
* it is kept as its path (`emptyViewFolders`), as Archi shows it. @private */
|
|
244
247
|
function _walkViewFolder(folder, model, path = []) {
|
|
248
|
+
if (path.length && !folder.children.some(c => c.tagName === 'element' || c.tagName === 'folder')) model.emptyViewFolders.push(path);
|
|
245
249
|
for (const child of folder.children) {
|
|
246
250
|
if (child.tagName === 'element') {
|
|
247
251
|
const view = _parseView(child);
|
|
@@ -754,6 +758,7 @@ export function toArchimateModel(archiModel) {
|
|
|
754
758
|
id: archiModel.id ?? undefined,
|
|
755
759
|
name: archiModel.name ?? '', elements, relationships, folders, elementFolders, views,
|
|
756
760
|
documentation: archiModel.documentation || undefined, properties: archiModel.properties ?? [],
|
|
761
|
+
emptyViewFolders: archiModel.emptyViewFolders ?? [],
|
|
757
762
|
// The specializations written for element types Archi lacks are not the model's own.
|
|
758
763
|
archi: archiModel.archi ? {
|
|
759
764
|
...archiModel.archi,
|
|
@@ -1372,6 +1377,7 @@ export function toArchiExport(model) {
|
|
|
1372
1377
|
documentation: model.documentation,
|
|
1373
1378
|
properties: model.properties,
|
|
1374
1379
|
archi: model.archi,
|
|
1380
|
+
emptyViewFolders: model.emptyViewFolders ?? [],
|
|
1375
1381
|
elements: model.elements(),
|
|
1376
1382
|
relationships: model.relationships(),
|
|
1377
1383
|
}, views];
|
|
@@ -1406,7 +1412,8 @@ function _propertyLines(pairs, indent) {
|
|
|
1406
1412
|
*/
|
|
1407
1413
|
function _inSubFolders(items, write, indent = ' ') {
|
|
1408
1414
|
const lines = [];
|
|
1409
|
-
|
|
1415
|
+
// An empty folder comes as {archiFolder, folderOnly}: it makes its folders and writes nothing itself.
|
|
1416
|
+
const direct = items.filter(item => !item.archiFolder?.length && !item.folderOnly);
|
|
1410
1417
|
// Sub-folders in the order they are first named, each holding the items one level deeper.
|
|
1411
1418
|
const groups = new Map();
|
|
1412
1419
|
for (const item of items.filter(item => item.archiFolder?.length)) {
|
|
@@ -1492,7 +1499,8 @@ export function toArchiXML(model, views) {
|
|
|
1492
1499
|
{ id: rel.id, ...(name && { name }), source: rel.source, target: rel.target, ...(directed && { directed: 'true' }) }, rel, indent);
|
|
1493
1500
|
}));
|
|
1494
1501
|
} else if (folder.type === 'diagrams') {
|
|
1495
|
-
|
|
1502
|
+
const emptyFolders = (model.emptyViewFolders ?? []).map(archiFolder => ({ archiFolder, folderOnly: true }));
|
|
1503
|
+
body.push(..._inSubFolders([...viewArray, ...emptyFolders], (view, indent) => _serializeView(view).map(line => indent.slice(4) + line)));
|
|
1496
1504
|
} else {
|
|
1497
1505
|
body.push(..._inSubFolders(items.map(item => ({ ...item, archiFolder: item.el.archiFolder })),
|
|
1498
1506
|
({ el, type, original, profile }, indent) => _serializeConcept(`archimate:${_toPascalType(type)}`,
|
|
@@ -80,7 +80,7 @@ export function parseExchangeModel(xml) {
|
|
|
80
80
|
key: definitions.get(p.getAttribute('propertyDefinitionRef') ?? p.getAttribute('identifierRef')) ?? '',
|
|
81
81
|
value: _text(p, 'value') ?? '',
|
|
82
82
|
}));
|
|
83
|
-
const folders = _organizationPaths(root);
|
|
83
|
+
const { paths: folders, emptyViewFolders } = _organizationPaths(root);
|
|
84
84
|
|
|
85
85
|
const model = {
|
|
86
86
|
id: root.getAttribute('identifier'),
|
|
@@ -91,6 +91,7 @@ export function parseExchangeModel(xml) {
|
|
|
91
91
|
elements: [],
|
|
92
92
|
relationships: [],
|
|
93
93
|
views: [],
|
|
94
|
+
emptyViewFolders,
|
|
94
95
|
};
|
|
95
96
|
for (const el of _kids(_kid(root, 'elements'), 'element')) {
|
|
96
97
|
model.elements.push(_inFolder(_parseElement(el, pairs(el)), folders));
|
|
@@ -116,26 +117,31 @@ function _inFolder(item, folders) {
|
|
|
116
117
|
* The folder path of every item the organizations hold, by its id: each
|
|
117
118
|
* labelled item it is in, outermost first. An outermost item named as one of
|
|
118
119
|
* Archi's top-level folders is that folder, not a sub-folder (Archi exports
|
|
119
|
-
* its folders so); any other is a sub-folder.
|
|
120
|
+
* its folders so); any other is a sub-folder. An empty sub-folder of Views
|
|
121
|
+
* is kept as its path, as parseArchiModel keeps one.
|
|
120
122
|
* @private
|
|
123
|
+
* @returns {{paths: Map<string, Array>, emptyViewFolders: Array<Array>}}
|
|
121
124
|
*/
|
|
122
125
|
function _organizationPaths(root) {
|
|
123
126
|
const topNames = new Set(ARCHI_FOLDERS.map(f => f.name));
|
|
127
|
+
const viewsName = ARCHI_FOLDERS.find(f => f.type === 'diagrams').name;
|
|
124
128
|
const paths = new Map();
|
|
129
|
+
const emptyViewFolders = [];
|
|
125
130
|
let made = 0;
|
|
126
|
-
const walk = (item, path, outermost) => {
|
|
131
|
+
const walk = (item, path, outermost, top) => {
|
|
127
132
|
for (const child of _kids(item, 'item')) {
|
|
128
133
|
const ref = child.getAttribute('identifierRef');
|
|
129
134
|
if (ref) { paths.set(ref, path); continue; }
|
|
130
135
|
const name = _text(child, 'label') ?? '';
|
|
131
|
-
if (outermost && topNames.has(name)) { walk(child, path, false); continue; }
|
|
136
|
+
if (outermost && topNames.has(name)) { walk(child, path, false, name); continue; }
|
|
132
137
|
const documentation = _text(child, 'documentation');
|
|
133
138
|
const step = { id: child.getAttribute('identifier') ?? `folder-${++made}`, name, ...(documentation && { documentation }) };
|
|
134
|
-
|
|
139
|
+
if (top === viewsName && !_kids(child, 'item').length) emptyViewFolders.push([...path, step]);
|
|
140
|
+
walk(child, [...path, step], false, top);
|
|
135
141
|
}
|
|
136
142
|
};
|
|
137
|
-
for (const organization of _kids(root, 'organizations')) walk(organization, [], true);
|
|
138
|
-
return paths;
|
|
143
|
+
for (const organization of _kids(root, 'organizations')) walk(organization, [], true, null);
|
|
144
|
+
return { paths, emptyViewFolders };
|
|
139
145
|
}
|
|
140
146
|
|
|
141
147
|
/** An element as parseArchiModel gives it (type: Archi's class name). @private */
|
|
@@ -390,7 +396,8 @@ export function toExchangeXML(model, views = []) {
|
|
|
390
396
|
});
|
|
391
397
|
|
|
392
398
|
const viewLines = diagrams.flatMap(view => _viewLines(view, { ids, known, propertyLines, textLines }));
|
|
393
|
-
const
|
|
399
|
+
const emptyFolders = (model.emptyViewFolders ?? []).map(archiFolder => ({ archiFolder, folderOnly: true }));
|
|
400
|
+
const organizationLines = _organizationLines({ elements, relationships, views: [...diagrams, ...emptyFolders], ids });
|
|
394
401
|
|
|
395
402
|
// Read once every property is defined: the model's, the concepts' and the views'.
|
|
396
403
|
const modelLines = [
|
|
@@ -534,7 +541,8 @@ function _organizationLines({ elements, relationships, views, ids }) {
|
|
|
534
541
|
if (step.documentation) lines.push(`${indent} <documentation>${escapeXml(step.documentation)}</documentation>`);
|
|
535
542
|
lines.push(...write(inner, `${indent} `), `${indent}</item>`);
|
|
536
543
|
}
|
|
537
|
-
|
|
544
|
+
// An empty folder ({archiFolder, folderOnly}) makes its folders and lists nothing itself.
|
|
545
|
+
for (const item of items.filter(i => !i.archiFolder?.length && !i.folderOnly)) lines.push(`${indent}<item identifierRef="${ids.ref(item.id)}"/>`);
|
|
538
546
|
return lines;
|
|
539
547
|
};
|
|
540
548
|
return ARCHI_FOLDERS.filter(f => inFolder.get(f.type).length).flatMap(folder => [
|
|
@@ -303,7 +303,7 @@ export class ArchimateDiagramEditor extends DiagramEditor {
|
|
|
303
303
|
library: this.templateLibrary,
|
|
304
304
|
document: () => this.document,
|
|
305
305
|
});
|
|
306
|
-
this.tree = new this.workspace.ModelTree(model, { mode: 'byLayer' });
|
|
306
|
+
this.tree = new this.workspace.ModelTree(model, { mode: 'byLayer', ...(this.settingsManager?.treeOptions?.() ?? {}) });
|
|
307
307
|
this.currentView = model.currentView ?? this.tree.getSelectedView();
|
|
308
308
|
this._wireTreeSelection();
|
|
309
309
|
this._unsubscribeModel = model.onChange(() => this._onModelChange());
|
package/src/charting/Axis.js
CHANGED
|
@@ -27,6 +27,26 @@ export class Axis {
|
|
|
27
27
|
return ticks;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How often a category label fits along an axis `width` wide: 1 (every
|
|
32
|
+
* label), 2 (every other), … — so a long series (a minute of samples, a
|
|
33
|
+
* day of timestamps) shows readable labels rather than an overprinted
|
|
34
|
+
* smear. Estimated from the longest label at `fontSize` (a character is
|
|
35
|
+
* about 0.6 em wide), with a small gap between labels.
|
|
36
|
+
* @param {Array<*>} labels
|
|
37
|
+
* @param {number} width
|
|
38
|
+
* @param {Object} [options]
|
|
39
|
+
* @param {number} [options.fontSize=12] - In px
|
|
40
|
+
* @returns {number} the step: draw label i when i % step === 0
|
|
41
|
+
*/
|
|
42
|
+
static categoryLabelStep(labels, width, { fontSize = 12 } = {}) {
|
|
43
|
+
const count = labels?.length ?? 0;
|
|
44
|
+
if (count < 2 || !(width > 0)) return 1;
|
|
45
|
+
const longest = Math.max(...labels.map(l => String(l ?? '').length));
|
|
46
|
+
const need = longest * fontSize * 0.6 + 6;
|
|
47
|
+
return Math.max(1, Math.ceil(need / (width / count)));
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
/**
|
|
31
51
|
* Format a numeric value for axis labels (k/M suffixes, fixed decimals).
|
|
32
52
|
* @param {number} v
|
|
@@ -59,6 +59,9 @@ import {
|
|
|
59
59
|
* toolbar container) to itself.
|
|
60
60
|
*/
|
|
61
61
|
export class DiagramEditor {
|
|
62
|
+
/** How a selected shape (besides the one with handles) is marked: an outline `pad` outside it, tinted. */
|
|
63
|
+
static SELECTION_OUTLINE = Object.freeze({ pad: 4, stroke: '#0066cc', strokeWidth: 2, dash: '5,3', fill: 'rgba(0, 102, 204, 0.08)' });
|
|
64
|
+
|
|
62
65
|
/**
|
|
63
66
|
* @param {Object} options
|
|
64
67
|
* @param {string} [options.diagramContainerId] - Id of the SVG (or its container) to draw into
|
|
@@ -389,6 +392,7 @@ export class DiagramEditor {
|
|
|
389
392
|
svgElement.addEventListener('contextmenu', (e) => {
|
|
390
393
|
e.stopPropagation();
|
|
391
394
|
e.preventDefault();
|
|
395
|
+
this.selectForMenu(elementId, { extend: e.ctrlKey || e.metaKey || e.shiftKey });
|
|
392
396
|
this._showOrderingMenu(elementId, e.clientX, e.clientY);
|
|
393
397
|
});
|
|
394
398
|
}
|
|
@@ -914,16 +918,31 @@ export class DiagramEditor {
|
|
|
914
918
|
showModelCheck() {
|
|
915
919
|
const checker = this.model?.checker?.(this.checkerOptions());
|
|
916
920
|
if (!checker) return [];
|
|
917
|
-
|
|
921
|
+
return this.showFindings(checker.run());
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Some findings in the model check's dialog (each shown when chosen, with
|
|
926
|
+
* its actions), under `title` (text, or a function of the findings) and
|
|
927
|
+
* saying `empty` when there are none.
|
|
928
|
+
*/
|
|
929
|
+
showFindings(findings, { title = null, empty = undefined } = {}) {
|
|
918
930
|
new ModelCheckDialog({
|
|
919
931
|
findings,
|
|
920
932
|
onReveal: (finding) => this.revealFinding(finding),
|
|
921
933
|
actionsFor: (finding) => this.findingActions(finding),
|
|
922
934
|
document: this.document ?? document,
|
|
935
|
+
title: typeof title === 'function' ? title(findings) : title,
|
|
936
|
+
...(empty !== undefined && { empty }),
|
|
923
937
|
}).open();
|
|
924
938
|
return findings;
|
|
925
939
|
}
|
|
926
940
|
|
|
941
|
+
/** Edits of the whole model this editor offers (the Edit menu shows them): none here; a subclass adds its own. */
|
|
942
|
+
modelMenuItems() {
|
|
943
|
+
return [];
|
|
944
|
+
}
|
|
945
|
+
|
|
927
946
|
/** What can be done about a finding, beside showing it (`[{label, action}]`): nothing here; a subclass offers its own (Merge…). */
|
|
928
947
|
findingActions(finding) {
|
|
929
948
|
return [];
|
|
@@ -1051,9 +1070,11 @@ export class DiagramEditor {
|
|
|
1051
1070
|
if (!t) continue;
|
|
1052
1071
|
const b = box(t);
|
|
1053
1072
|
if (id !== this.selectedTemplateId) {
|
|
1073
|
+
// Set off the shape's own border, and tinted, so it shows on any box.
|
|
1074
|
+
const { pad, stroke, strokeWidth, dash, fill } = this.constructor.SELECTION_OUTLINE;
|
|
1054
1075
|
layer.appendChild(this._svg('rect', {
|
|
1055
|
-
class: 'selection-outline', x: b.x1, y: b.y1, width: b.x2 - b.x1, height: b.y2 - b.y1,
|
|
1056
|
-
fill
|
|
1076
|
+
class: 'selection-outline', x: b.x1 - pad, y: b.y1 - pad, width: b.x2 - b.x1 + 2 * pad, height: b.y2 - b.y1 + 2 * pad,
|
|
1077
|
+
fill, stroke, 'stroke-width': strokeWidth, 'stroke-dasharray': dash, rx: 3,
|
|
1057
1078
|
}));
|
|
1058
1079
|
}
|
|
1059
1080
|
if (this.arrangement.isLocked(id)) {
|
|
@@ -3146,6 +3167,17 @@ export class DiagramEditor {
|
|
|
3146
3167
|
* @param {string} templateId
|
|
3147
3168
|
* @param {{extend?: boolean}} [options]
|
|
3148
3169
|
*/
|
|
3170
|
+
/**
|
|
3171
|
+
* Before a shape's menu: with `extend` (Ctrl, ⌘ or Shift held) the shape
|
|
3172
|
+
* joins the selection; otherwise a shape outside the selection becomes it.
|
|
3173
|
+
* A shape already selected leaves the selection as it is, so the menu acts
|
|
3174
|
+
* on all of it (Merge, Match size, Properties of N shapes).
|
|
3175
|
+
*/
|
|
3176
|
+
selectForMenu(templateId, { extend = false } = {}) {
|
|
3177
|
+
if (this.selectedIds.has(templateId)) return;
|
|
3178
|
+
this.selectTemplate(templateId, { extend });
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3149
3181
|
selectTemplate(templateId, { extend = false } = {}) {
|
|
3150
3182
|
if (extend) {
|
|
3151
3183
|
if (this.selectedIds.has(templateId)) {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { el } from '../workspace/dom.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dialog — the one dialog frame: a title (the handle that moves it), an
|
|
5
|
+
* optional ✕, a body that scrolls between the title and the buttons (which
|
|
6
|
+
* stay in view), and Escape or a click outside to close. It fits the window.
|
|
7
|
+
* A subclass (FormDialog) or a host gives it its content: `content()` and
|
|
8
|
+
* `buttons` as specs, or fill `body` after open(). `titleBar` takes more
|
|
9
|
+
* beside the title (a link); `footer`, more beside the buttons.
|
|
10
|
+
*
|
|
11
|
+
* open() shows it over the page; open(host) places it in `host` instead, with
|
|
12
|
+
* no overlay — a dialog's content shown as a pane or a tab, which has a frame
|
|
13
|
+
* and a close control of its own.
|
|
14
|
+
*/
|
|
15
|
+
export class Dialog {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} options
|
|
18
|
+
* @param {string} options.title
|
|
19
|
+
* @param {Array<{label: string, action: (dialog: Dialog) => void, primary?: boolean, submit?: boolean}>} [options.buttons]
|
|
20
|
+
* @param {boolean} [options.closeButton] - A ✕ in the title bar
|
|
21
|
+
* @param {'tall'|'wide'} [options.size] - Tall: most of the window's height; wide: most of its width
|
|
22
|
+
* @param {string|string[]} [options.cls] - Extra class names on the frame
|
|
23
|
+
* @param {Document} [options.document]
|
|
24
|
+
* @param {(applied: boolean) => void} [options.onClose] - Once, when it closes: whether it was applied
|
|
25
|
+
*/
|
|
26
|
+
constructor({ title, buttons = [], closeButton = false, size = null, cls = null, document: doc = globalThis.document, onClose = null } = {}) {
|
|
27
|
+
this.title = title;
|
|
28
|
+
this.buttons = buttons;
|
|
29
|
+
this.closeButton = closeButton;
|
|
30
|
+
this.size = size;
|
|
31
|
+
this.cls = cls;
|
|
32
|
+
this.doc = doc;
|
|
33
|
+
this.onClose = onClose;
|
|
34
|
+
this._applied = false;
|
|
35
|
+
/** The page-covering backdrop, while open over the page. */
|
|
36
|
+
this.overlay = null;
|
|
37
|
+
/** The dialog itself. */
|
|
38
|
+
this.frame = null;
|
|
39
|
+
this.titleBar = null;
|
|
40
|
+
this.body = null;
|
|
41
|
+
this.footer = null;
|
|
42
|
+
this._host = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The frame's tag: a FormDialog's is a <form>. @protected */
|
|
46
|
+
get frameTag() { return 'div'; }
|
|
47
|
+
|
|
48
|
+
/** The body's specs (empty: a host fills `body` after open()). @protected */
|
|
49
|
+
content() { return []; }
|
|
50
|
+
|
|
51
|
+
/** Events on the frame itself (a form's submit). @protected */
|
|
52
|
+
frameEvents() { return {}; }
|
|
53
|
+
|
|
54
|
+
/** Open over the page, or in `host` (no overlay, not moved, no ✕). */
|
|
55
|
+
open(host = null) {
|
|
56
|
+
this._host = host;
|
|
57
|
+
const inline = !!host;
|
|
58
|
+
const frame = {
|
|
59
|
+
tag: this.frameTag,
|
|
60
|
+
cls: ['re-dialog', this.size && `re-dialog-${this.size}`, inline && 're-dialog-inline', ...[].concat(this.cls ?? [])],
|
|
61
|
+
attrs: { role: inline ? null : 'dialog', 'aria-label': this.title },
|
|
62
|
+
style: inline ? Dialog.INLINE_STYLE : { ...Dialog.FRAME_STYLE, ...(Dialog.SIZE_STYLE[this.size] ?? {}) },
|
|
63
|
+
ref: (n) => { this.frame = n; },
|
|
64
|
+
on: {
|
|
65
|
+
...this.frameEvents(),
|
|
66
|
+
keydown: (e) => { if (e.key === 'Escape' && !inline) this.close(); },
|
|
67
|
+
},
|
|
68
|
+
children: [
|
|
69
|
+
{
|
|
70
|
+
cls: 're-dialog-title', ref: (n) => { this.titleBar = n; },
|
|
71
|
+
style: { display: 'flex', alignItems: 'center', gap: '8px', margin: '0 0 8px', flex: '0 0 auto' },
|
|
72
|
+
children: [
|
|
73
|
+
{
|
|
74
|
+
// The title is the handle that moves the dialog.
|
|
75
|
+
tag: 'h3', text: this.title, attrs: { title: inline ? null : 'Drag to move' },
|
|
76
|
+
style: { margin: '0', fontSize: '14px', cursor: inline ? 'default' : 'move', userSelect: 'none', flex: '1 1 auto' },
|
|
77
|
+
on: inline ? {} : { mousedown: (e) => this._startMove(e) },
|
|
78
|
+
},
|
|
79
|
+
this.closeButton && !inline && {
|
|
80
|
+
tag: 'button', cls: 're-dialog-close', text: '✕', attrs: { type: 'button', title: 'Close', 'aria-label': 'Close' },
|
|
81
|
+
style: { border: 'none', background: 'transparent', fontSize: '16px', cursor: 'pointer', color: '#666', padding: '0 4px' },
|
|
82
|
+
on: { click: () => this.close() },
|
|
83
|
+
},
|
|
84
|
+
].filter(Boolean),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
// The content scrolls; the title and the buttons stay in view.
|
|
88
|
+
cls: 're-dialog-body', ref: (n) => { this.body = n; },
|
|
89
|
+
style: { overflowY: 'auto', flex: '1 1 auto', minHeight: '0', paddingRight: '4px' },
|
|
90
|
+
children: this.content(),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
cls: 're-dialog-footer', ref: (n) => { this.footer = n; },
|
|
94
|
+
style: { display: this.buttons.length ? 'flex' : 'none', justifyContent: 'flex-end', alignItems: 'center', gap: '8px', marginTop: '16px', flex: '0 0 auto' },
|
|
95
|
+
children: this.buttons.map(b => ({
|
|
96
|
+
tag: 'button', text: b.label, attrs: { type: b.submit ? 'submit' : 'button' },
|
|
97
|
+
style: b.primary ? Dialog.PRIMARY_BUTTON_STYLE : null,
|
|
98
|
+
on: b.submit ? {} : { click: () => b.action(this) },
|
|
99
|
+
})),
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
if (inline) {
|
|
104
|
+
host.appendChild(el(frame, this.doc));
|
|
105
|
+
} else {
|
|
106
|
+
this.overlay = el({
|
|
107
|
+
cls: 're-dialog-overlay', style: Dialog.OVERLAY_STYLE,
|
|
108
|
+
on: { mousedown: (e) => { if (e.target === this.overlay) this.close(); } },
|
|
109
|
+
children: [frame],
|
|
110
|
+
}, this.doc);
|
|
111
|
+
this.doc.body.appendChild(this.overlay);
|
|
112
|
+
}
|
|
113
|
+
this.frame.querySelector?.('input:not([type=checkbox]), select, textarea')?.focus();
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Add to the footer (a host's own buttons, a pager), and show it. */
|
|
118
|
+
addToFooter(...nodes) {
|
|
119
|
+
this.footer.append(...nodes);
|
|
120
|
+
this.footer.style.display = 'flex';
|
|
121
|
+
return this;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Add beside the title (a link), before the ✕. */
|
|
125
|
+
addToTitle(...nodes) {
|
|
126
|
+
const close = this.titleBar.children[1] ?? null;
|
|
127
|
+
for (const node of nodes) this.titleBar.insertBefore(node, close);
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Whether it is showing. */
|
|
132
|
+
get isOpen() { return !!(this.overlay || (this._host && this.frame?.parentNode)); }
|
|
133
|
+
|
|
134
|
+
/** Close it (once), telling onClose whether it was applied. */
|
|
135
|
+
close() {
|
|
136
|
+
if (!this.isOpen) return;
|
|
137
|
+
(this.overlay ?? this.frame).remove();
|
|
138
|
+
this.overlay = null;
|
|
139
|
+
this._host = null;
|
|
140
|
+
this.onClose?.(this._applied);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Move the dialog by its title: from then on it sits where it was dropped
|
|
145
|
+
* (fixed position), kept inside the window.
|
|
146
|
+
* @protected
|
|
147
|
+
*/
|
|
148
|
+
_startMove(e) {
|
|
149
|
+
if (e.button !== 0 || !this.frame) return;
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
const view = this.doc.defaultView;
|
|
152
|
+
const box = this.frame.getBoundingClientRect();
|
|
153
|
+
const grab = { x: e.clientX - box.left, y: e.clientY - box.top };
|
|
154
|
+
Object.assign(this.frame.style, { position: 'fixed', margin: '0', left: `${box.left}px`, top: `${box.top}px` });
|
|
155
|
+
const move = (ev) => {
|
|
156
|
+
const width = view?.innerWidth ?? Infinity, height = view?.innerHeight ?? Infinity;
|
|
157
|
+
const left = Math.min(Math.max(0, ev.clientX - grab.x), Math.max(0, width - box.width));
|
|
158
|
+
const top = Math.min(Math.max(0, ev.clientY - grab.y), Math.max(0, height - 40));
|
|
159
|
+
this.frame.style.left = `${left}px`;
|
|
160
|
+
this.frame.style.top = `${top}px`;
|
|
161
|
+
};
|
|
162
|
+
const end = () => {
|
|
163
|
+
this.doc.removeEventListener('mousemove', move);
|
|
164
|
+
this.doc.removeEventListener('mouseup', end);
|
|
165
|
+
};
|
|
166
|
+
this.doc.addEventListener('mousemove', move);
|
|
167
|
+
this.doc.addEventListener('mouseup', end);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
static OVERLAY_STYLE = {
|
|
171
|
+
position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.25)', zIndex: '10001',
|
|
172
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'sans-serif',
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
static FRAME_STYLE = {
|
|
176
|
+
background: '#fff', color: '#222', border: '1px solid #bbb', borderRadius: '6px', padding: '16px 18px',
|
|
177
|
+
minWidth: '360px', maxWidth: '90vw', maxHeight: '90vh', boxSizing: 'border-box',
|
|
178
|
+
display: 'flex', flexDirection: 'column',
|
|
179
|
+
boxShadow: '0 6px 24px rgba(0,0,0,0.2)', fontSize: '12px',
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
static SIZE_STYLE = {
|
|
183
|
+
tall: { height: '85vh', width: 'min(1100px, 90vw)' },
|
|
184
|
+
wide: { width: '90vw' },
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** In a host: fills it, as content, not a floating box. */
|
|
188
|
+
static INLINE_STYLE = {
|
|
189
|
+
display: 'flex', flexDirection: 'column', height: '100%', boxSizing: 'border-box', padding: '10px 12px', fontSize: '12px',
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
static PRIMARY_BUTTON_STYLE = {
|
|
193
|
+
background: '#0066cc', color: '#fff', border: '1px solid #0066cc', borderRadius: '3px', padding: '4px 14px',
|
|
194
|
+
};
|
|
195
|
+
}
|