@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.
- package/README.md +219 -213
- package/REFERENCE.md +272 -6
- package/dist/renderengine-workspace.umd.js +11980 -7421
- package/dist/renderengine.umd.js +8991 -5784
- package/examples/archimate.html +68 -66
- package/examples/data-model.html +534 -0
- package/package.json +70 -68
- package/src/bian.js +182 -0
- package/src/charting/CheckboxTreeSelector.js +373 -304
- package/src/charting/DiagramEditor.js +53 -32
- package/src/charting/DiagramTypeRegistry.js +13 -3
- package/src/charting/ModelCheckDialog.js +63 -0
- package/src/charting/ModelChecker.js +26 -0
- package/src/charting/ViewTabStrip.js +94 -81
- package/src/charting/archimate/ArchimateCanvasEditor.js +825 -815
- package/src/charting/archimate/ArchimateElementTemplate.js +208 -195
- package/src/charting/datamodel/BianLandscapeReader.js +185 -0
- package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
- package/src/charting/datamodel/DataModel.js +645 -0
- package/src/charting/datamodel/DataModelChecker.js +92 -0
- package/src/charting/datamodel/DataModelCompared.js +127 -0
- package/src/charting/datamodel/DataModelContext.js +108 -0
- package/src/charting/datamodel/DataModelFileFormat.js +33 -0
- package/src/charting/datamodel/DataModelPerspective.js +129 -0
- package/src/charting/datamodel/DataType.js +108 -0
- package/src/charting/datamodel/EnumerationType.js +51 -0
- package/src/charting/datamodel/LevelMapping.js +38 -0
- package/src/charting/datamodel/LevelSchema.js +41 -0
- package/src/charting/datamodel/ServiceDomain.js +177 -0
- package/src/charting/datamodel/ServiceLandscape.js +104 -0
- package/src/charting/datamodel/StructuredType.js +63 -0
- package/src/charting/datamodel/index.js +15 -0
- package/src/charting/diagramModel/MergeModelCommand.js +43 -37
- package/src/charting/diagramModel/OpenViewList.js +75 -0
- package/src/charting/diagramModel/ViewSetChecker.js +22 -34
- package/src/charting/diagrams/DataModelDiagram.js +160 -86
- package/src/charting/diagrams/DataModelRelationshipStrategy.js +94 -38
- package/src/charting/diagrams/EdgeGeometry.js +578 -553
- package/src/charting/diagrams/GraphDiagram.js +11 -1
- package/src/charting/diagrams/NodeGeometry.js +169 -158
- package/src/charting/diagrams/nesting.js +31 -0
- package/src/charting/glyphs/PersonGlyph.js +24 -23
- package/src/charting/modelTree/ModelTree.js +202 -164
- package/src/charting/modelTree/ModelTreePanel.js +36 -3
- package/src/charting/modelTree/TreeViewMode.js +131 -110
- package/src/charting/modelTree/modes/FlatTreeMode.js +32 -46
- package/src/charting/modelTree/modes/LayerGroupedTreeMode.js +47 -62
- package/src/charting/modelTree/modes/TypeGroupedTreeMode.js +46 -60
- package/src/datamodel.js +132 -122
- package/src/diagramCatalog.js +35 -30
- package/src/index.js +2666 -2659
- package/src/workspace/EditorShell.js +166 -67
- package/src/workspace/PanelLayout.js +56 -0
- package/src/workspace/index.js +1 -0
- package/src/workspace/layout/TabsNode.js +205 -191
- package/src/workspace/parts/ArchimateCanvasPart.js +10 -9
- package/src/workspace/parts/ChartPart.js +6 -1
- package/src/workspace/parts/DataModelPart.js +544 -0
- package/src/workspace/parts/DiagramEditorPart.js +2 -8
- package/src/workspace/parts/EditorPart.js +461 -407
- package/src/workspace/parts/ModelFile.js +216 -187
- package/src/workspace/parts/ModelsPart.js +483 -192
- package/src/workspace/storage/BackupFolder.js +145 -0
- package/src/workspace/storage/KeyValueStore.js +92 -0
- package/src/workspace/storage/ModelLibrary.js +65 -0
- package/src/workspace/workArea.js +417 -413
- 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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
|
3271
|
-
*
|
|
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
|
|
3287
|
-
|
|
3288
|
-
|
|
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
|
|
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('
|
|
228
|
-
'
|
|
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,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* @param {
|
|
13
|
-
* @param {
|
|
14
|
-
* @param {(id: string) => void}
|
|
15
|
-
* @param {(id: string) =>
|
|
16
|
-
* @param {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
this.
|
|
23
|
-
this.
|
|
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
|
-
}
|
|
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
|
+
}
|