@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.
Files changed (71) hide show
  1. package/README.md +4 -0
  2. package/REFERENCE.md +252 -17
  3. package/dist/renderengine-workspace.umd.js +4995 -2932
  4. package/dist/renderengine.umd.js +3985 -2193
  5. package/examples/archimate.html +8 -28
  6. package/examples/data-model.html +45 -556
  7. package/package.json +70 -70
  8. package/src/archimateArchiFormat.js +11 -3
  9. package/src/archimateExchangeFormat.js +17 -9
  10. package/src/charting/ArchimateDiagramEditor.js +1 -1
  11. package/src/charting/Axis.js +20 -0
  12. package/src/charting/DiagramEditor.js +35 -3
  13. package/src/charting/Dialog.js +195 -0
  14. package/src/charting/DuplicatesDialog.js +108 -0
  15. package/src/charting/FormDialog.js +28 -97
  16. package/src/charting/InteractionManager.js +3 -0
  17. package/src/charting/Legend.js +45 -2
  18. package/src/charting/MergeCompareDialog.js +81 -0
  19. package/src/charting/PopupMenu.js +12 -3
  20. package/src/charting/SettingsDialog.js +6 -2
  21. package/src/charting/SettingsManager.js +14 -1
  22. package/src/charting/archimate/ArchimateCanvasEditor.js +190 -15
  23. package/src/charting/archimate/ArchimatePalette.js +1 -1
  24. package/src/charting/charts/AreaChart.js +5 -2
  25. package/src/charting/charts/BarChart.js +5 -3
  26. package/src/charting/charts/BarChart3DOrbit.js +5 -7
  27. package/src/charting/charts/BubbleChart.js +5 -7
  28. package/src/charting/charts/BubbleChart3D.js +5 -7
  29. package/src/charting/charts/CandlestickChart.js +3 -1
  30. package/src/charting/charts/DensityChart.js +4 -6
  31. package/src/charting/charts/GroupedBarChart.js +2 -2
  32. package/src/charting/charts/LineChart.js +5 -2
  33. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  34. package/src/charting/charts/PipeChart.js +4 -6
  35. package/src/charting/charts/RadarChart.js +5 -6
  36. package/src/charting/charts/ScatterChart.js +5 -7
  37. package/src/charting/charts/StackedBarChart.js +4 -2
  38. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -7
  39. package/src/charting/charts/StreamGraphChart.js +5 -2
  40. package/src/charting/charts/WaterfallChart.js +2 -1
  41. package/src/charting/content/ContentSource.js +129 -0
  42. package/src/charting/content/ContentSources.js +77 -0
  43. package/src/charting/datamodel/DataModel.js +116 -0
  44. package/src/charting/diagrams/ArchimateModel.js +38 -3
  45. package/src/charting/diagrams/ArchimateModelChecker.js +13 -4
  46. package/src/charting/diagrams/MergeElementsCommand.js +124 -101
  47. package/src/charting/diagrams/SvgZoom.js +20 -1
  48. package/src/charting/diagrams/duplicates.js +95 -13
  49. package/src/charting/duplicateSearchForm.js +58 -0
  50. package/src/charting/modelTree/ModelTree.js +239 -202
  51. package/src/charting/modelTree/ModelTreePanel.js +955 -919
  52. package/src/charting/modelTree/TreeBuilder.js +93 -45
  53. package/src/charting/modelTree/TreeViewMode.js +9 -6
  54. package/src/charting/modelTree/viewFolders.js +15 -6
  55. package/src/icons/IconSet.js +86 -0
  56. package/src/icons/index.js +59 -0
  57. package/src/index.js +65 -9
  58. package/src/workspace/dom.js +56 -42
  59. package/src/workspace/index.js +1 -1
  60. package/src/workspace/nav/NavEntry.js +42 -0
  61. package/src/workspace/nav/NavGroup.js +43 -0
  62. package/src/workspace/nav/NavItem.js +53 -0
  63. package/src/workspace/nav/NavMenu.js +143 -0
  64. package/src/workspace/nav/NavSection.js +17 -0
  65. package/src/workspace/nav/index.js +5 -0
  66. package/src/workspace/parts/ArchimateCanvasPart.js +8 -3
  67. package/src/workspace/parts/DataModelPart.js +91 -0
  68. package/src/workspace/parts/EditorPart.js +8 -0
  69. package/src/workspace/parts/ModelHosted.js +1 -0
  70. package/src/workspace/parts/ModelsPart.js +765 -622
  71. package/src/workspace/styles.js +28 -1
package/package.json CHANGED
@@ -1,70 +1,70 @@
1
- {
2
- "name": "@peter.prib/renderengine",
3
- "version": "0.5.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`). @private */
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
- const direct = items.filter(item => !item.archiFolder?.length);
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
- body.push(..._inSubFolders(viewArray, (view, indent) => _serializeView(view).map(line => indent.slice(4) + line)));
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
- walk(child, [...path, step], false);
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 organizationLines = _organizationLines({ elements, relationships, views: diagrams, ids });
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
- for (const item of items.filter(i => !i.archiFolder?.length)) lines.push(`${indent}<item identifierRef="${ids.ref(item.id)}"/>`);
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());
@@ -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
- const findings = checker.run();
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: 'none', stroke: '#0066cc', 'stroke-width': 1.5, 'stroke-dasharray': '4,3',
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
+ }