@peter.prib/renderengine 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +219 -213
  2. package/REFERENCE.md +272 -6
  3. package/dist/renderengine-workspace.umd.js +11980 -7421
  4. package/dist/renderengine.umd.js +8991 -5784
  5. package/examples/archimate.html +68 -66
  6. package/examples/data-model.html +534 -0
  7. package/package.json +70 -68
  8. package/src/bian.js +182 -0
  9. package/src/charting/CheckboxTreeSelector.js +373 -304
  10. package/src/charting/DiagramEditor.js +53 -32
  11. package/src/charting/DiagramTypeRegistry.js +13 -3
  12. package/src/charting/ModelCheckDialog.js +63 -0
  13. package/src/charting/ModelChecker.js +26 -0
  14. package/src/charting/ViewTabStrip.js +94 -81
  15. package/src/charting/archimate/ArchimateCanvasEditor.js +825 -815
  16. package/src/charting/archimate/ArchimateElementTemplate.js +208 -195
  17. package/src/charting/datamodel/BianLandscapeReader.js +185 -0
  18. package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
  19. package/src/charting/datamodel/DataModel.js +645 -0
  20. package/src/charting/datamodel/DataModelChecker.js +92 -0
  21. package/src/charting/datamodel/DataModelCompared.js +127 -0
  22. package/src/charting/datamodel/DataModelContext.js +108 -0
  23. package/src/charting/datamodel/DataModelFileFormat.js +33 -0
  24. package/src/charting/datamodel/DataModelPerspective.js +129 -0
  25. package/src/charting/datamodel/DataType.js +108 -0
  26. package/src/charting/datamodel/EnumerationType.js +51 -0
  27. package/src/charting/datamodel/LevelMapping.js +38 -0
  28. package/src/charting/datamodel/LevelSchema.js +41 -0
  29. package/src/charting/datamodel/ServiceDomain.js +177 -0
  30. package/src/charting/datamodel/ServiceLandscape.js +104 -0
  31. package/src/charting/datamodel/StructuredType.js +63 -0
  32. package/src/charting/datamodel/index.js +15 -0
  33. package/src/charting/diagramModel/MergeModelCommand.js +43 -37
  34. package/src/charting/diagramModel/OpenViewList.js +75 -0
  35. package/src/charting/diagramModel/ViewSetChecker.js +22 -34
  36. package/src/charting/diagrams/DataModelDiagram.js +160 -86
  37. package/src/charting/diagrams/DataModelRelationshipStrategy.js +94 -38
  38. package/src/charting/diagrams/EdgeGeometry.js +578 -553
  39. package/src/charting/diagrams/GraphDiagram.js +11 -1
  40. package/src/charting/diagrams/NodeGeometry.js +169 -158
  41. package/src/charting/diagrams/nesting.js +31 -0
  42. package/src/charting/glyphs/PersonGlyph.js +24 -23
  43. package/src/charting/modelTree/ModelTree.js +202 -164
  44. package/src/charting/modelTree/ModelTreePanel.js +36 -3
  45. package/src/charting/modelTree/TreeViewMode.js +131 -110
  46. package/src/charting/modelTree/modes/FlatTreeMode.js +32 -46
  47. package/src/charting/modelTree/modes/LayerGroupedTreeMode.js +47 -62
  48. package/src/charting/modelTree/modes/TypeGroupedTreeMode.js +46 -60
  49. package/src/datamodel.js +132 -122
  50. package/src/diagramCatalog.js +35 -30
  51. package/src/index.js +2666 -2659
  52. package/src/workspace/EditorShell.js +166 -67
  53. package/src/workspace/PanelLayout.js +56 -0
  54. package/src/workspace/index.js +1 -0
  55. package/src/workspace/layout/TabsNode.js +205 -191
  56. package/src/workspace/parts/ArchimateCanvasPart.js +10 -9
  57. package/src/workspace/parts/ChartPart.js +6 -1
  58. package/src/workspace/parts/DataModelPart.js +544 -0
  59. package/src/workspace/parts/DiagramEditorPart.js +2 -8
  60. package/src/workspace/parts/EditorPart.js +461 -407
  61. package/src/workspace/parts/ModelFile.js +216 -187
  62. package/src/workspace/parts/ModelsPart.js +483 -192
  63. package/src/workspace/storage/BackupFolder.js +145 -0
  64. package/src/workspace/storage/KeyValueStore.js +92 -0
  65. package/src/workspace/storage/ModelLibrary.js +65 -0
  66. package/src/workspace/workArea.js +417 -413
  67. package/src/yaml/YamlReader.js +372 -0
@@ -8,6 +8,7 @@ import { getEdgeMarker } from './edgeMarkers/index.js';
8
8
  import { PopupMenu } from './PopupMenu.js';
9
9
  import { TextStyle, TEXT_STYLE_KEYS } from './TextStyle.js';
10
10
  import { FormDialog } from './FormDialog.js';
11
+ import { ModelCheckDialog } from './ModelCheckDialog.js';
11
12
  import { NoteTemplate } from './templates/NoteTemplate.js';
12
13
  import { parseShapeText, placeShapes } from './textToShapes.js';
13
14
  import { ViewFit } from './ViewFit.js';
@@ -914,33 +915,11 @@ export class DiagramEditor {
914
915
  const checker = this.model?.checker?.(this.checkerOptions());
915
916
  if (!checker) return [];
916
917
  const findings = checker.run();
917
- let dialog = null;
918
- const list = el({
919
- style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '4px', minWidth: '420px' },
920
- children: findings.length ? findings.map(finding => ({
921
- style: { display: 'flex', gap: '4px', alignItems: 'stretch' },
922
- children: [
923
- {
924
- tag: 'button',
925
- attrs: { type: 'button', title: 'Show it', 'data-code': finding.code },
926
- style: { flex: '1', textAlign: 'left', padding: '4px 6px', border: '1px solid #ddd', borderRadius: '3px', background: finding.severity === 'warning' ? '#fff6e5' : '#f5f8fc', cursor: 'pointer' },
927
- text: `${finding.severity === 'warning' ? '⚠' : 'ℹ'} ${finding.message}`,
928
- on: { click: () => { dialog?.close(); this.revealFinding(finding); } },
929
- },
930
- ...this.findingActions(finding).map(act => ({
931
- tag: 'button', attrs: { type: 'button', 'data-action': act.label }, text: act.label,
932
- style: { padding: '4px 8px', cursor: 'pointer' },
933
- on: { click: () => { dialog?.close(); act.action(); } },
934
- })),
935
- ],
936
- })) : [{ text: 'Nothing found: the model passes every check.' }],
937
- }, this.document);
938
- dialog = new FormDialog({
939
- title: `Model check: ${findings.length} finding${findings.length === 1 ? '' : 's'}`,
940
- fields: [{ key: 'findings', label: '', type: 'custom', element: list, read: () => null }],
941
- submitLabel: 'Close',
918
+ new ModelCheckDialog({
919
+ findings,
920
+ onReveal: (finding) => this.revealFinding(finding),
921
+ actionsFor: (finding) => this.findingActions(finding),
942
922
  document: this.document ?? document,
943
- onApply: () => {},
944
923
  }).open();
945
924
  return findings;
946
925
  }
@@ -3244,8 +3223,10 @@ export class DiagramEditor {
3244
3223
  // Try template-based first (template diagram pattern)
3245
3224
  const ids = (this._dragState?.movers ?? [elementId]).filter(id => this.getTemplate(id));
3246
3225
  if (!ids.length || !this.history) return;
3247
- // Where they were dropped: into a shape that holds them, or out of their parent.
3226
+ // Where they were dropped: into a shape that holds them, or out of their parent;
3227
+ // a parent they stick out of grows to show them (worked out where they lie now).
3248
3228
  const nesting = this.nestingChanges(ids);
3229
+ const fits = this.fitNesting(nesting);
3249
3230
  // Put them back, then move them all (and nest them) as one undo step.
3250
3231
  for (const id of ids) {
3251
3232
  const t = this.getTemplate(id);
@@ -3255,6 +3236,7 @@ export class DiagramEditor {
3255
3236
  const steps = [
3256
3237
  ...ids.map(id => new MoveElementCommand(this, id, deltaX, deltaY)),
3257
3238
  ...nesting.map(({ childId, parentId }) => this.nestCommand(childId, parentId)).filter(Boolean),
3239
+ ...fits.map(({ id, bounds }) => new SetPropertiesCommand(this.getTemplate(id), bounds, 'Fit around nested')),
3258
3240
  ];
3259
3241
  this.history.execute(steps.length === 1 ? steps[0] : new CompositeCommand('Move', steps));
3260
3242
  this.render();
@@ -3264,11 +3246,15 @@ export class DiagramEditor {
3264
3246
 
3265
3247
  // ===== Nesting (a shape drawn inside another) =====
3266
3248
 
3249
+ /** The room kept around a nested shape when its parent grows to show it: the top leaves room for the parent's name. */
3250
+ static NEST_MARGIN = Object.freeze({ side: 12, top: 28 });
3251
+
3267
3252
  /**
3268
3253
  * After a move, as the shapes lie now: each moved shape that is not moving
3269
3254
  * with its parent, whose parent changes. Its new parent is the innermost
3270
- * shape not moved that wholly holds its box and `canHold` it; none (null)
3271
- * takes it out of the one it was in.
3255
+ * shape not moved that wholly holds its box and `canHold` it, else the
3256
+ * innermost one its centre was dropped on that `nestsOnDrop` it (fitNesting
3257
+ * then grows it); none (null) takes it out of the one it was in.
3272
3258
  * @param {Array<string>} movedIds
3273
3259
  * @returns {Array<{childId: string, parentId: (string|null), previousId: (string|null)}>}
3274
3260
  */
@@ -3283,15 +3269,50 @@ export class DiagramEditor {
3283
3269
  const child = this.getTemplate(childId);
3284
3270
  if (!child) continue;
3285
3271
  const inner = box(child);
3286
- const parent = [...this.templates.values()]
3287
- .filter(t => !moved.has(t.id) && t.visible !== false && this.canHold(t.id, childId) && holds(box(t), inner))
3288
- .sort((a, b) => a.width * a.height - b.width * b.height)[0];
3272
+ const candidates = [...this.templates.values()].filter(t => !moved.has(t.id) && t.visible !== false && this.canHold(t.id, childId));
3273
+ const smallest = (list) => list.sort((a, b) => a.width * a.height - b.width * b.height)[0];
3274
+ const onCentre = (t) => { const b = box(t); return b.left <= child.x && child.x <= b.right && b.top <= child.y && child.y <= b.bottom; };
3275
+ const parent = smallest(candidates.filter(t => holds(box(t), inner)))
3276
+ ?? smallest(candidates.filter(t => onCentre(t) && this.nestsOnDrop(t.id, childId)));
3289
3277
  const parentId = parent?.id ?? null;
3290
3278
  if (parentId !== previousId) changes.push({ childId, parentId, previousId });
3291
3279
  }
3292
3280
  return changes;
3293
3281
  }
3294
3282
 
3283
+ /**
3284
+ * What has to grow for some nesting changes (nestingChanges') to show each
3285
+ * child whole, as the shapes lie now: each new parent the child sticks out
3286
+ * of, then that parent's own parents in turn, each to hold the one inside
3287
+ * with NEST_MARGIN around it.
3288
+ * @returns {Array<{id: string, bounds: {x, y, width, height}}>} new centre and size, outermost last
3289
+ */
3290
+ fitNesting(changes) {
3291
+ const { side, top } = DiagramEditor.NEST_MARGIN;
3292
+ const bounds = new Map();
3293
+ const at = (id) => bounds.get(id) ?? (({ x, y, width, height }) => ({ x, y, width, height }))(this.getTemplate(id));
3294
+ for (const { childId, parentId } of changes) {
3295
+ let inner = at(childId);
3296
+ for (let outerId = parentId; outerId; outerId = this.parentOf(outerId)) {
3297
+ const outer = at(outerId);
3298
+ const left = Math.min(outer.x - outer.width / 2, inner.x - inner.width / 2 - side);
3299
+ const right = Math.max(outer.x + outer.width / 2, inner.x + inner.width / 2 + side);
3300
+ const upper = Math.min(outer.y - outer.height / 2, inner.y - inner.height / 2 - top);
3301
+ const lower = Math.max(outer.y + outer.height / 2, inner.y + inner.height / 2 + side);
3302
+ const grown = { x: (left + right) / 2, y: (upper + lower) / 2, width: right - left, height: lower - upper };
3303
+ if (grown.width === outer.width && grown.height === outer.height) break; // it already shows it
3304
+ bounds.set(outerId, grown);
3305
+ inner = grown;
3306
+ }
3307
+ }
3308
+ return [...bounds].map(([id, b]) => ({ id, bounds: b }));
3309
+ }
3310
+
3311
+ /** Whether a shape dropped on another (its centre on it, sticking out) nests there. Never here; a subclass says when. */
3312
+ nestsOnDrop(parentId, childId) {
3313
+ return false;
3314
+ }
3315
+
3295
3316
  /** The shape this one is drawn inside (nested in), or null. None here: a subclass with nesting says. */
3296
3317
  parentOf(templateId) {
3297
3318
  return null;
@@ -218,14 +218,22 @@ export const DIAGRAM_TYPES = {
218
218
  'enterprise-ontology': {
219
219
  name: 'Enterprise Ontology',
220
220
  group: 'Data',
221
- description: 'The universal concepts enterprise data models are built from: parties in roles enter arrangements governing resources; events change them, ledgers record their value; classification, place, time, measure and rule describe them',
221
+ description: 'The universal concepts enterprise data models are built from (after IBM IFW\'s data concepts): parties in roles enter arrangements for products and resources; events change them, ledgers record their value; business direction steers them; classification, place, time, measure and rule describe them',
222
222
  templates: [
223
223
  shape('party', 'Party', ActorTemplate, {},
224
224
  'A person or legal actor that can act or hold rights: person, organisation, government body. Every business process involves actors.'),
225
225
  shape('role', 'Role', StadiumTemplate, { width: 120, height: 44, fillColor: C.purple },
226
226
  'The part a party (or resource) plays in a context: customer, supplier, employee, owner, custodian. A party has many roles, and they change over time.'),
227
- shape('arrangement', 'Arrangement', DocumentTemplate, { width: 130, height: 70, fillColor: C.yellow },
228
- 'A formalised relationship between parties, or parties and resources: contract, agreement, subscription, membership, policy, lease. Business interactions are structured by agreements.'),
227
+ shape('agreement', 'Agreement', DocumentTemplate, { width: 130, height: 70, fillColor: C.yellow },
228
+ 'The contract parties sign: the promises they agree on as one unit. It governs the arrangements made under it (BIAN keeps the two apart).'),
229
+ shape('arrangement', 'Arrangement', RoundedRectangleTemplate, { width: 130, height: 60, fillColor: C.orange },
230
+ 'The terms a product or service is provided on, under an agreement: a subscription, an account, a loan, a lease, a membership.'),
231
+ shape('product', 'Product', PackageTemplate, { width: 120, height: 70, fillColor: C.green },
232
+ 'What the business offers and sells, under terms: a plan, a policy, a loan, a service bundle. Not the things it holds (resources), but the offer made of them.'),
233
+ shape('feature', 'Product Feature', ParallelogramTemplate, { width: 130, height: 50, fillColor: C.green },
234
+ 'A feature a product is made up of and can be offered with: interest, payments, an overdraft, a reward scheme.'),
235
+ shape('condition', 'Condition', TrapezoidTemplate, { width: 120, height: 55, fillColor: C.red },
236
+ 'A fee, rate, limit or eligibility rule a product or an arrangement is subject to.'),
229
237
  shape('resource', 'Resource', BoxTemplate, { width: 110, height: 80, fillColor: C.blue },
230
238
  'A physical or digital thing of value or use: product, asset, document, file, device. Businesses manage things.'),
231
239
  shape('event', 'Event', EllipseTemplate, { width: 120, height: 60, fillColor: C.green },
@@ -240,6 +248,8 @@ export const DIAGRAM_TYPES = {
240
248
  'A point or interval in time: effective date, validity period, contract term, timestamp, posting date.'),
241
249
  shape('measure', 'Measure', RectangleTemplate, { width: 110, height: 40, fillColor: C.white },
242
250
  'A quantity with its unit: weight, price, balance, temperature, risk score, count. The basis of metrics, KPIs and ledgers.'),
251
+ shape('direction', 'Business Direction', StarTemplate, { width: 100, height: 90, fillColor: C.yellow },
252
+ 'Where the business means to go: goal, objective, strategy, plan, policy, and the targets that measure them. It shapes products, arrangements and rules.'),
243
253
  shape('rule', 'Rule', DiamondTemplate, { width: 100, height: 70, fillColor: C.red },
244
254
  'A condition that governs arrangements, events or resources: business rule, policy, validation, regulatory requirement, eligibility.'),
245
255
  note,
@@ -0,0 +1,63 @@
1
+ import { el } from '../workspace/dom.js';
2
+ import { FormDialog } from './FormDialog.js';
3
+
4
+ /**
5
+ * ModelCheckDialog — a model check's findings (ModelChecker#run), listed:
6
+ * each a button (⚠ a warning, ℹ for a look) that shows what it names
7
+ * (`onReveal`), with what can be done about it beside (`actionsFor`), or
8
+ * "nothing found". Any editor or part that checks a model shows it this way.
9
+ */
10
+ export class ModelCheckDialog {
11
+ /**
12
+ * @param {Object} options
13
+ * @param {Array<Object>} options.findings
14
+ * @param {(finding: Object) => void} [options.onReveal] - Show what a finding names
15
+ * @param {(finding: Object) => Array<{label: string, action: Function}>} [options.actionsFor]
16
+ * @param {Document} [options.document]
17
+ */
18
+ constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document }) {
19
+ this.findings = findings;
20
+ this.onReveal = onReveal;
21
+ this.actionsFor = actionsFor;
22
+ this.document = doc;
23
+ }
24
+
25
+ /** The dialog's title: how many findings. */
26
+ get title() {
27
+ const n = this.findings.length;
28
+ return `Model check: ${n} finding${n === 1 ? '' : 's'}`;
29
+ }
30
+
31
+ /** Open it; returns the FormDialog. */
32
+ open() {
33
+ let dialog = null;
34
+ const list = el({
35
+ style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '4px', minWidth: '420px' },
36
+ children: this.findings.length ? this.findings.map(finding => ({
37
+ style: { display: 'flex', gap: '4px', alignItems: 'stretch' },
38
+ children: [
39
+ {
40
+ tag: 'button',
41
+ attrs: { type: 'button', title: 'Show it', 'data-code': finding.code },
42
+ style: { flex: '1', textAlign: 'left', padding: '4px 6px', border: '1px solid #ddd', borderRadius: '3px', background: finding.severity === 'warning' ? '#fff6e5' : '#f5f8fc', cursor: 'pointer' },
43
+ text: `${finding.severity === 'warning' ? '⚠' : 'ℹ'} ${finding.message}`,
44
+ on: { click: () => { dialog?.close(); this.onReveal(finding); } },
45
+ },
46
+ ...this.actionsFor(finding).map(act => ({
47
+ tag: 'button', attrs: { type: 'button', 'data-action': act.label }, text: act.label,
48
+ style: { padding: '4px 8px', cursor: 'pointer' },
49
+ on: { click: () => { dialog?.close(); act.action(); } },
50
+ })),
51
+ ],
52
+ })) : [{ text: 'Nothing found: the model passes every check.' }],
53
+ }, this.document);
54
+ dialog = new FormDialog({
55
+ title: this.title,
56
+ fields: [{ key: 'findings', label: '', type: 'custom', element: list, read: () => null }],
57
+ submitLabel: 'Close',
58
+ document: this.document,
59
+ onApply: () => {},
60
+ });
61
+ return dialog.open();
62
+ }
63
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * ModelChecker — checks over a model: what is wrong or worth a look in it.
3
+ * Each check is a method listed by `checks()`, run in that order, so a
4
+ * subclass adds, drops or reorders them; `run()` gives every finding. The
5
+ * base of ViewSetChecker (models of views) and DataModelChecker (data
6
+ * models). DOM-free and advisory: findings are shown, never enforced.
7
+ *
8
+ * Each finding: `{ code, severity: 'warning'|'info', message, kind, ids }`,
9
+ * `kind` saying what the ids name (a view, an entity, ...).
10
+ */
11
+ export class ModelChecker {
12
+ /** @param {Object} model */
13
+ constructor(model) {
14
+ this.model = model;
15
+ }
16
+
17
+ /** The checks to run, in the order their findings are listed. None here. */
18
+ checks() {
19
+ return [];
20
+ }
21
+
22
+ /** Every finding. @returns {Array<Object>} */
23
+ run() {
24
+ return this.checks().flatMap(check => check.call(this));
25
+ }
26
+ }
@@ -1,81 +1,94 @@
1
- import { el } from '../workspace/dom.js';
2
- import { PopupMenu } from './PopupMenu.js';
3
-
4
- /**
5
- * ViewTabStrip — a row (or column) of tabs, one per view, with an optional
6
- * close button and a right-click menu per tab. Stateless: render() builds the
7
- * strip from the items given.
8
- */
9
- export class ViewTabStrip {
10
- /**
11
- * @param {Object} options
12
- * @param {'horizontal'|'vertical'} [options.orientation]
13
- * @param {(id: string) => void} options.onSelect
14
- * @param {(id: string) => void} [options.onClose] - Shows a × on each tab
15
- * @param {(id: string) => Array} [options.menuItems] - PopupMenu items for a tab
16
- * @param {Document} [options.document]
17
- */
18
- constructor({ orientation = 'horizontal', onSelect, onClose = null, menuItems = () => [], document: doc = document }) {
19
- this.orientation = orientation;
20
- this.onSelect = onSelect;
21
- this.onClose = onClose;
22
- this.menuItems = menuItems;
23
- this.doc = doc;
24
- }
25
-
26
- /**
27
- * @param {Array<{id: string, title: string}>} items
28
- * @param {string} activeId
29
- * @returns {HTMLElement}
30
- */
31
- render(items, activeId) {
32
- const vertical = this.orientation === 'vertical';
33
- return el({
34
- attrs: { role: 'tablist', 'aria-orientation': this.orientation },
35
- style: {
36
- flex: vertical ? '0 0 150px' : '0 0 auto', display: 'flex', flexDirection: vertical ? 'column' : 'row',
37
- gap: '2px', background: '#f0f0f0', padding: '4px 8px', fontSize: '12px',
38
- borderRight: vertical ? '1px solid #ccc' : 'none', borderBottom: vertical ? 'none' : '1px solid #ccc',
39
- overflowX: vertical ? 'visible' : 'auto', overflowY: vertical ? 'auto' : 'visible',
40
- },
41
- children: items.map(({ id, title }) => this._tab(id, title, id === activeId, vertical)),
42
- }, this.doc);
43
- }
44
-
45
- _tab(id, title, active, vertical) {
46
- const menuItems = this.menuItems(id);
47
- return {
48
- attrs: { role: 'tab', tabindex: '0', 'aria-selected': String(active), 'data-view-id': id },
49
- style: {
50
- display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 12px', border: '1px solid #ccc',
51
- cursor: 'pointer', fontSize: '12px', whiteSpace: 'nowrap', userSelect: 'none',
52
- borderRadius: vertical ? '3px' : '3px 3px 0 0',
53
- background: active ? '#fff' : '#e8e8e8', fontWeight: active ? 'bold' : 'normal',
54
- },
55
- on: {
56
- click: () => this.onSelect(id),
57
- keydown: (e) => {
58
- if (e.key === 'Enter' || e.key === ' ') {
59
- e.preventDefault();
60
- this.onSelect(id);
61
- }
62
- },
63
- contextmenu: (e) => {
64
- if (!menuItems.length) return;
65
- e.preventDefault();
66
- PopupMenu.show(e.clientX, e.clientY, menuItems, this.doc);
67
- },
68
- },
69
- children: [
70
- { tag: 'span', text: title },
71
- this.onClose && {
72
- tag: 'button',
73
- text: '×',
74
- attrs: { type: 'button', 'aria-label': `Close ${title}`, title: 'Close tab' },
75
- style: { border: 'none', background: 'none', padding: '0', cursor: 'pointer', fontSize: '14px', lineHeight: '1', color: '#888' },
76
- on: { click: (e) => { e.stopPropagation(); this.onClose(id); } },
77
- },
78
- ].filter(Boolean),
79
- };
80
- }
81
- }
1
+ import { el } from '../workspace/dom.js';
2
+ import { PopupMenu } from './PopupMenu.js';
3
+
4
+ /**
5
+ * ViewTabStrip — a row (or column) of tabs, one per open view, as a browser
6
+ * has them: an optional × to close a tab (`onClose`; `canClose` says which may
7
+ * be), a + after them to add one (`onAdd`), and a right-click menu per tab.
8
+ * Stateless: render() builds the strip from the items given.
9
+ */
10
+ export class ViewTabStrip {
11
+ /**
12
+ * @param {Object} options
13
+ * @param {'horizontal'|'vertical'} [options.orientation]
14
+ * @param {(id: string) => void} options.onSelect
15
+ * @param {(id: string) => void} [options.onClose] - Shows a × on each tab
16
+ * @param {(id: string) => boolean} [options.canClose] - Which tabs get the × (default all)
17
+ * @param {(event: Event) => void} [options.onAdd] - Shows a + after the tabs
18
+ * @param {(id: string) => Array} [options.menuItems] - PopupMenu items for a tab
19
+ * @param {Document} [options.document]
20
+ */
21
+ constructor({ orientation = 'horizontal', onSelect, onClose = null, canClose = () => true, onAdd = null, menuItems = () => [], document: doc = document }) {
22
+ this.orientation = orientation;
23
+ this.onSelect = onSelect;
24
+ this.onClose = onClose;
25
+ this.canClose = canClose;
26
+ this.onAdd = onAdd;
27
+ this.menuItems = menuItems;
28
+ this.doc = doc;
29
+ }
30
+
31
+ /**
32
+ * @param {Array<{id: string, title: string}>} items
33
+ * @param {string} activeId
34
+ * @returns {HTMLElement}
35
+ */
36
+ render(items, activeId) {
37
+ const vertical = this.orientation === 'vertical';
38
+ return el({
39
+ attrs: { role: 'tablist', 'aria-orientation': this.orientation },
40
+ style: {
41
+ flex: vertical ? '0 0 150px' : '0 0 auto', display: 'flex', flexDirection: vertical ? 'column' : 'row',
42
+ gap: '2px', background: '#f0f0f0', padding: '4px 8px', fontSize: '12px',
43
+ borderRight: vertical ? '1px solid #ccc' : 'none', borderBottom: vertical ? 'none' : '1px solid #ccc',
44
+ overflowX: vertical ? 'visible' : 'auto', overflowY: vertical ? 'auto' : 'visible',
45
+ },
46
+ children: [
47
+ ...items.map(({ id, title }) => this._tab(id, title, id === activeId, vertical)),
48
+ this.onAdd && {
49
+ tag: 'button', text: '+',
50
+ attrs: { type: 'button', 'aria-label': 'New view', title: 'New view', 'data-tab-add': '' },
51
+ style: { border: '1px solid transparent', background: 'none', cursor: 'pointer', fontSize: '15px', lineHeight: '1', color: '#555', padding: '2px 8px' },
52
+ on: { click: (e) => this.onAdd(e) },
53
+ },
54
+ ].filter(Boolean),
55
+ }, this.doc);
56
+ }
57
+
58
+ _tab(id, title, active, vertical) {
59
+ const menuItems = this.menuItems(id);
60
+ return {
61
+ attrs: { role: 'tab', tabindex: '0', 'aria-selected': String(active), 'data-view-id': id },
62
+ style: {
63
+ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 12px', border: '1px solid #ccc',
64
+ cursor: 'pointer', fontSize: '12px', whiteSpace: 'nowrap', userSelect: 'none',
65
+ borderRadius: vertical ? '3px' : '3px 3px 0 0',
66
+ background: active ? '#fff' : '#e8e8e8', fontWeight: active ? 'bold' : 'normal',
67
+ },
68
+ on: {
69
+ click: () => this.onSelect(id),
70
+ keydown: (e) => {
71
+ if (e.key === 'Enter' || e.key === ' ') {
72
+ e.preventDefault();
73
+ this.onSelect(id);
74
+ }
75
+ },
76
+ contextmenu: (e) => {
77
+ if (!menuItems.length) return;
78
+ e.preventDefault();
79
+ PopupMenu.show(e.clientX, e.clientY, menuItems, this.doc);
80
+ },
81
+ },
82
+ children: [
83
+ { tag: 'span', text: title },
84
+ this.onClose && this.canClose(id) && {
85
+ tag: 'button',
86
+ text: '×',
87
+ attrs: { type: 'button', 'aria-label': `Close ${title}`, title: 'Close tab' },
88
+ style: { border: 'none', background: 'none', padding: '0', cursor: 'pointer', fontSize: '14px', lineHeight: '1', color: '#888' },
89
+ on: { click: (e) => { e.stopPropagation(); this.onClose(id); } },
90
+ },
91
+ ].filter(Boolean),
92
+ };
93
+ }
94
+ }