@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
@@ -1,413 +1,417 @@
1
- /**
2
- * WorkArea — the IDE-style editor area: several views open at once, in tab
3
- * groups that can sit side by side.
4
- *
5
- * The whole point is the separation the plain `tabs`/`split` layout does not
6
- * make on its own: a view being OPEN (it has a tab) is not the same as it being
7
- * VISIBLE (its tab is selected in a group that is on screen). A shell that
8
- * rebuilds a fixed layout per view can only ever show one; a work area lets you
9
- * put the data model next to the notebook that queries it.
10
- *
11
- * Structure lives in `workAreaModel.js` as pure functions over plain JSON, and
12
- * this class is the thin rendering shell over them:
13
- *
14
- * model (JSON) --createLayout--> live layout tree --serialize--> model
15
- *
16
- * The live tree is authoritative for the things the user adjusts directly —
17
- * splitter positions, which tab is selected — so every structural change starts
18
- * by reading them back out of it (`_syncFromTree`). Rebuilding the subtree
19
- * afterwards is cheap and non-destructive: `PartNode.dispose()` only detaches a
20
- * part and `Part.mount()` re-parents it, so a part keeps its DOM, its state and
21
- * its bus subscriptions across the rebuild.
22
- *
23
- * Selecting a tab does NOT rebuild, though — the pane elements are what scroll,
24
- * and replacing them would throw away scroll position on every tab click.
25
- */
26
- import { createLayout } from './layout/index.js';
27
- import { el } from './dom.js';
28
- import {
29
- emptyWorkArea, listGroups, listOpenParts, findPart,
30
- openPart, closePart, focusPart,
31
- } from './workAreaModel.js';
32
-
33
- export class WorkArea {
34
- /**
35
- * @param {object} [options]
36
- * @param {object} [options.area] serialized area to restore
37
- * @param {string} [options.activeGroupId] group new views open into
38
- * @param {Function} [options.onChange] ({ area, activeGroupId, reason }) — persist here
39
- * @param {*} [options.placeholder] el() spec shown when nothing is open
40
- * @param {Element} [options.actionsHost] filled with the focused view's `actions`
41
- * @param {Element} [options.outlineHost] filled with the focused view's outline part
42
- * @param {Function} [options.outlineFor] (partId) => outline partId | null
43
- * @param {*} [options.outlinePlaceholder] el() spec when a view has no outline
44
- * @param {Function} [options.beforeClose] (partId) => false to keep a tab its close button was
45
- * pressed on (unsaved work, say); the handler may close it later
46
- */
47
- constructor({
48
- area, activeGroupId, onChange, placeholder,
49
- actionsHost, outlineHost, outlineFor, outlinePlaceholder, beforeClose,
50
- } = {}) {
51
- this.area = area ?? emptyWorkArea();
52
- this.activeGroupId = activeGroupId ?? listGroups(this.area)[0]?.id ?? null;
53
- this.onChange = onChange ?? null;
54
- this.beforeClose = beforeClose ?? null;
55
- this.placeholder = placeholder ?? { cls: 'ws-workarea-empty', text: 'Nothing open' };
56
- this.host = null;
57
- this.ctx = null;
58
- this._tree = null;
59
- this._outerCtx = null;
60
-
61
- // Frame slots. Both are filled from whichever view has FOCUS, which is
62
- // the whole reason they live here: the work area is the only thing that
63
- // knows what that is.
64
- this.actionsHost = actionsHost ?? null;
65
- this.outlineHost = outlineHost ?? null;
66
- this.outlineFor = outlineFor ?? null;
67
- this.outlinePlaceholder = outlinePlaceholder ?? null;
68
- this._outlinePart = null;
69
- this._slotPartId = null;
70
- }
71
-
72
- /* ── Queries ────────────────────────────────────────────────────────── */
73
-
74
- /** Every open part id, in group order. */
75
- openParts() { return listOpenParts(this.area); }
76
-
77
- isOpen(partId) { return !!findPart(this.area, partId); }
78
-
79
- /** The part id selected in each group — i.e. what is actually on screen. */
80
- visibleParts() {
81
- return listGroups(this.area)
82
- .map(g => g.children?.[g.active ?? 0]?.partId)
83
- .filter(Boolean);
84
- }
85
-
86
- /**
87
- * The view with focus: the selected tab of the active group. This is what a
88
- * shell keys its chrome off — window title, breadcrumb, which toolbar
89
- * belongs to what — and it is not the same as "the last thing opened",
90
- * because clicking into another group changes it.
91
- */
92
- activePart() {
93
- const groups = listGroups(this.area);
94
- const group = groups.find(g => g.id === this.activeGroupId) ?? groups[0];
95
- return group?.children?.[group.active ?? 0]?.partId ?? null;
96
- }
97
-
98
- groupCount() { return listGroups(this.area).length; }
99
-
100
- /** `{ area, activeGroupId }` — hand straight to a store, pass back to the ctor. */
101
- serialize() {
102
- this._syncFromTree();
103
- return { area: this.area, activeGroupId: this.activeGroupId };
104
- }
105
-
106
- /* ── Rendering ──────────────────────────────────────────────────────── */
107
-
108
- render(host, ctx = {}) {
109
- this.host = host;
110
- this._outerCtx = ctx;
111
- this.ctx = {
112
- ...ctx,
113
- // Closing and group focus are decisions about the AREA, not about
114
- // one tab strip, so they are intercepted here rather than left to
115
- // TabsNode's own handling.
116
- onTabClose: ({ partId }) => { if (partId && this.beforeClose?.(partId) !== false) this.close(partId); },
117
- onGroupFocus: (id) => this._setActiveGroup(id),
118
- onLayoutChange: () => this._changed('layout'),
119
- };
120
- this._build();
121
- // Both slots explicitly: nothing has focus yet as far as they are
122
- // concerned, so a restored area would otherwise come back with an empty
123
- // action bar and an empty rail.
124
- this._syncSlots({ actions: true, outline: true });
125
- return this;
126
- }
127
-
128
- _build() {
129
- if (!this.host) return;
130
- this._tree?.dispose();
131
- this._tree = null;
132
- this.host.textContent = '';
133
-
134
- if (!listOpenParts(this.area).length) {
135
- if (this.placeholder) this.host.appendChild(el(this.placeholder));
136
- return;
137
- }
138
-
139
- this._tree = createLayout(this.area);
140
- this._tree.render(this.host, this.ctx);
141
- this._tree.activate();
142
- this._markActiveGroup();
143
- }
144
-
145
- /* ── Frame slots ────────────────────────────────────────────────────── */
146
-
147
- /**
148
- * Point the action bar and outline rail at whatever now has focus.
149
- *
150
- * A slot is re-read when focus MOVED, or when the caller asks for that slot
151
- * specifically. The two are asked for separately because their costs
152
- * differ: re-reading actions is a handful of buttons, whereas re-showing an
153
- * outline re-parents an element, which resets its scroll position — so a
154
- * `refreshActions()` must not quietly scroll a long tree back to the top.
155
- */
156
- _syncSlots({ actions = false, outline = false } = {}) {
157
- const partId = this.activePart();
158
- const moved = partId !== this._slotPartId;
159
- if (!moved && !actions && !outline) return;
160
- this._slotPartId = partId;
161
- if (moved || actions) this._renderActions(partId);
162
- if (moved || outline) this._showOutline(partId);
163
- }
164
-
165
- _renderActions(partId) {
166
- if (!this.actionsHost) return;
167
- this.actionsHost.textContent = '';
168
- const actions = partId ? (this.ctx?.registry?.get(partId)?.actions ?? []) : [];
169
- this.actionsHost.classList.toggle('ws-actionbar-empty', !actions.length);
170
- for (const action of actions) {
171
- if (!action) continue;
172
- // An already-built element is passed straight through. This is the
173
- // AdapterPart bargain again: a panel that already has a working
174
- // toolbar — with selects, labels and its own rebuild logic — joins
175
- // the frame's action bar by handing it over, not by being rewritten
176
- // into a button array that could not express it anyway.
177
- if (typeof action.nodeType === 'number' || typeof action.appendChild === 'function') {
178
- this.actionsHost.appendChild(action);
179
- continue;
180
- }
181
- if (action.separator) { this.actionsHost.appendChild(el({ cls: 'ws-action-sep' })); continue; }
182
- this.actionsHost.appendChild(el({
183
- tag: 'button',
184
- cls: ['ws-action', action.cls],
185
- text: action.label ?? '',
186
- attrs: {
187
- type: 'button',
188
- title: action.title ?? action.label ?? null,
189
- disabled: action.disabled || null,
190
- },
191
- on: { click: (e) => action.onClick?.(e, this) },
192
- }));
193
- }
194
- }
195
-
196
- /**
197
- * Show the focused view's outline.
198
- *
199
- * An outline is an ordinary Part, resolved through the registry — not a new
200
- * kind of thing. That is what gets it pooling, mount-once-then-re-parent and
201
- * lifecycle for free: a tree keeps its expanded nodes and scroll position
202
- * while you work in another view and come back.
203
- */
204
- _showOutline(partId) {
205
- if (!this.outlineHost) return;
206
- const outlineId = partId && this.outlineFor ? this.outlineFor(partId) : null;
207
- const next = outlineId ? this.ctx?.registry?.get(outlineId) : null;
208
-
209
- // Detached by name rather than by clearing the host: the outgoing
210
- // outline is a pooled part, and `detach()` is the call that says so.
211
- this._outlinePart?.detach();
212
- this._outlinePart = null;
213
- this._placeholderEl?.remove();
214
- this._placeholderEl = null;
215
- this.outlineHost.classList.toggle('ws-outline-empty', !next);
216
-
217
- if (!next) {
218
- if (this.outlinePlaceholder) {
219
- this._placeholderEl = this.outlineHost.appendChild(el(this.outlinePlaceholder));
220
- }
221
- return;
222
- }
223
- try {
224
- next.mount(this.outlineHost, this.ctx);
225
- this._outlinePart = next;
226
- } catch (err) {
227
- console.error(`WorkArea: outline part "${outlineId}" failed to mount`, err);
228
- this.outlineHost.classList.add('ws-outline-empty');
229
- }
230
- }
231
-
232
- /** Re-read the focused view's actions — call when a panel's state changes. */
233
- refreshActions() {
234
- this._syncSlots({ actions: true });
235
- return this;
236
- }
237
-
238
- /**
239
- * Re-read both slots. For the case a lazily-mounted view creates the very
240
- * things the slots are meant to show *after* it took focus — the action bar
241
- * and outline were resolved against a panel that did not exist yet.
242
- */
243
- refreshSlots() {
244
- this._syncSlots({ actions: true, outline: true });
245
- return this;
246
- }
247
-
248
- /** The outline part currently on show, if any. */
249
- get outlinePart() { return this._outlinePart; }
250
-
251
- /**
252
- * Read the live tree back into the model. Splitter drags and tab selections
253
- * only ever happened to the DOM nodes; skipping this would silently undo
254
- * them the next time anything opens or closes.
255
- */
256
- _syncFromTree() {
257
- if (this._tree) this.area = this._tree.serialize();
258
- return this.area;
259
- }
260
-
261
- /** Show every tab's label as its part's title is now (a file name that changed, say). */
262
- refreshLabels() {
263
- const walk = (node) => {
264
- if (node?.constructor?.type === 'tabs') node.refreshLabels?.();
265
- for (const child of node?.children ?? []) walk(child);
266
- };
267
- if (this._tree) walk(this._tree);
268
- return this;
269
- }
270
-
271
- /** Live TabsNode for a group id, or null when nothing is rendered. */
272
- _liveGroup(groupId) {
273
- const walk = (node) => {
274
- if (node?.id === groupId && node.constructor?.type === 'tabs') return node;
275
- for (const child of node?.children ?? []) {
276
- const hit = walk(child);
277
- if (hit) return hit;
278
- }
279
- return null;
280
- };
281
- return this._tree ? walk(this._tree) : null;
282
- }
283
-
284
- /**
285
- * Tint the active group's tab strip, but only once there is more than one
286
- * group — with a single group "where the next view opens" is not a question
287
- * anyone is asking, and the accent would just be noise.
288
- */
289
- _markActiveGroup() {
290
- const many = this.groupCount() > 1;
291
- for (const group of listGroups(this.area)) {
292
- const live = this._liveGroup(group.id);
293
- live?.el?.classList.toggle('ws-group-active', many && group.id === this.activeGroupId);
294
- }
295
- }
296
-
297
- _setActiveGroup(id) {
298
- if (!id || id === this.activeGroupId) return;
299
- this.activeGroupId = id;
300
- this._markActiveGroup();
301
- this._changed('focus-group');
302
- }
303
-
304
- _changed(reason) {
305
- this._syncFromTree();
306
- // Before onChange, so a consumer reacting to the change (updating its
307
- // own chrome, persisting) sees a frame that already agrees with focus.
308
- this._syncSlots();
309
- this.onChange?.({ area: this.area, activeGroupId: this.activeGroupId, reason });
310
- this._outerCtx?.onLayoutChange?.();
311
- }
312
-
313
- /* ── Commands ───────────────────────────────────────────────────────── */
314
-
315
- /**
316
- * Open a view.
317
- *
318
- * `where`: `'tab'` (default) adds it to the active group; `'right'`,
319
- * `'left'`, `'below'` or `'above'` opens a new group beside it — which is
320
- * what actually gets two views on screen together.
321
- *
322
- * Already open anywhere -> focused, not duplicated: a part instance owns one
323
- * DOM element, so a second tab for it would necessarily be empty.
324
- */
325
- open(partId, { where = 'tab' } = {}) {
326
- if (!partId) return this;
327
- if (this.isOpen(partId)) return this.focus(partId);
328
- this._syncFromTree();
329
- const next = openPart(this.area, partId, { where, activeGroupId: this.activeGroupId });
330
- this.area = next.area;
331
- this.activeGroupId = next.activeGroupId;
332
- this._build();
333
- this._changed('open');
334
- return this;
335
- }
336
-
337
- /** Open beside the active group — the "split editor" gesture. */
338
- openToSide(partId, side = 'right') { return this.open(partId, { where: side }); }
339
-
340
- /**
341
- * Select an already-open view. Deliberately does not rebuild: it drives the
342
- * live TabsNode, so pane scroll positions and the DOM survive a tab click.
343
- */
344
- focus(partId) {
345
- const found = findPart(this._syncFromTree(), partId);
346
- if (!found) return this;
347
- const live = this._liveGroup(found.group.id);
348
- if (live) {
349
- live.select(found.index, true);
350
- this.activeGroupId = found.group.id;
351
- this._markActiveGroup();
352
- this._changed('focus');
353
- return this;
354
- }
355
- const next = focusPart(this.area, partId, { activeGroupId: this.activeGroupId });
356
- this.area = next.area;
357
- this.activeGroupId = next.activeGroupId;
358
- this._build();
359
- this._changed('focus');
360
- return this;
361
- }
362
-
363
- /**
364
- * Close a view. Its group disappears when it held the last tab, and a split
365
- * left with one child collapses into it — so closing back down to one view
366
- * leaves a plain tab strip rather than a split with an empty half.
367
- */
368
- close(partId) {
369
- this._syncFromTree();
370
- const next = closePart(this.area, partId, { activeGroupId: this.activeGroupId });
371
- if (!next.closed) return this;
372
- this.area = next.area;
373
- this.activeGroupId = next.activeGroupId;
374
- this._build();
375
- this._changed('close');
376
- return this;
377
- }
378
-
379
- /** Close everything, leaving one empty group. */
380
- closeAll() {
381
- for (const partId of this.openParts()) this.close(partId);
382
- return this;
383
- }
384
-
385
- /**
386
- * Replace the whole area — restoring a saved one, or switching perspective.
387
- * Parts the new area does not reference are left in the registry pool, so
388
- * switching back finds them as they were.
389
- */
390
- setArea(area, activeGroupId) {
391
- this.area = area ?? emptyWorkArea();
392
- this.activeGroupId = activeGroupId ?? listGroups(this.area)[0]?.id ?? null;
393
- this._build();
394
- this._changed('set-area');
395
- return this;
396
- }
397
-
398
- dispose() {
399
- this._tree?.dispose();
400
- this._tree = null;
401
- // Detached, not disposed — the outline is a pooled part like any other,
402
- // and destroying it here would take it out of the registry for good.
403
- this._outlinePart?.detach();
404
- this._outlinePart = null;
405
- this._placeholderEl = null;
406
- this._slotPartId = null;
407
- if (this.host) this.host.textContent = '';
408
- if (this.actionsHost) this.actionsHost.textContent = '';
409
- if (this.outlineHost) this.outlineHost.textContent = '';
410
- this.host = null;
411
- this.ctx = null;
412
- }
413
- }
1
+ /**
2
+ * WorkArea — the IDE-style editor area: several views open at once, in tab
3
+ * groups that can sit side by side.
4
+ *
5
+ * The whole point is the separation the plain `tabs`/`split` layout does not
6
+ * make on its own: a view being OPEN (it has a tab) is not the same as it being
7
+ * VISIBLE (its tab is selected in a group that is on screen). A shell that
8
+ * rebuilds a fixed layout per view can only ever show one; a work area lets you
9
+ * put the data model next to the notebook that queries it.
10
+ *
11
+ * Structure lives in `workAreaModel.js` as pure functions over plain JSON, and
12
+ * this class is the thin rendering shell over them:
13
+ *
14
+ * model (JSON) --createLayout--> live layout tree --serialize--> model
15
+ *
16
+ * The live tree is authoritative for the things the user adjusts directly —
17
+ * splitter positions, which tab is selected — so every structural change starts
18
+ * by reading them back out of it (`_syncFromTree`). Rebuilding the subtree
19
+ * afterwards is cheap and non-destructive: `PartNode.dispose()` only detaches a
20
+ * part and `Part.mount()` re-parents it, so a part keeps its DOM, its state and
21
+ * its bus subscriptions across the rebuild.
22
+ *
23
+ * Selecting a tab does NOT rebuild, though — the pane elements are what scroll,
24
+ * and replacing them would throw away scroll position on every tab click.
25
+ */
26
+ import { createLayout } from './layout/index.js';
27
+ import { el } from './dom.js';
28
+ import {
29
+ emptyWorkArea, listGroups, listOpenParts, findPart,
30
+ openPart, closePart, focusPart,
31
+ } from './workAreaModel.js';
32
+
33
+ export class WorkArea {
34
+ /**
35
+ * @param {object} [options]
36
+ * @param {object} [options.area] serialized area to restore
37
+ * @param {string} [options.activeGroupId] group new views open into
38
+ * @param {Function} [options.onChange] ({ area, activeGroupId, reason }) — persist here
39
+ * @param {*} [options.placeholder] el() spec shown when nothing is open
40
+ * @param {Element} [options.actionsHost] filled with the focused view's `actions`
41
+ * @param {Element} [options.outlineHost] filled with the focused view's outline part
42
+ * @param {Function} [options.outlineFor] (partId) => outline partId | null
43
+ * @param {*} [options.outlinePlaceholder] el() spec when a view has no outline
44
+ * @param {Function} [options.beforeClose] (partId) => false to keep a tab its close button was
45
+ * pressed on (unsaved work, say); the handler may close it later
46
+ * @param {Function} [options.tabMenu] (partId, event) => true when it showed a menu for that
47
+ * part's tab (right-clicked); the browser's own shows otherwise
48
+ */
49
+ constructor({
50
+ area, activeGroupId, onChange, placeholder,
51
+ actionsHost, outlineHost, outlineFor, outlinePlaceholder, beforeClose, tabMenu,
52
+ } = {}) {
53
+ this.area = area ?? emptyWorkArea();
54
+ this.activeGroupId = activeGroupId ?? listGroups(this.area)[0]?.id ?? null;
55
+ this.onChange = onChange ?? null;
56
+ this.beforeClose = beforeClose ?? null;
57
+ this.tabMenu = tabMenu ?? null;
58
+ this.placeholder = placeholder ?? { cls: 'ws-workarea-empty', text: 'Nothing open' };
59
+ this.host = null;
60
+ this.ctx = null;
61
+ this._tree = null;
62
+ this._outerCtx = null;
63
+
64
+ // Frame slots. Both are filled from whichever view has FOCUS, which is
65
+ // the whole reason they live here: the work area is the only thing that
66
+ // knows what that is.
67
+ this.actionsHost = actionsHost ?? null;
68
+ this.outlineHost = outlineHost ?? null;
69
+ this.outlineFor = outlineFor ?? null;
70
+ this.outlinePlaceholder = outlinePlaceholder ?? null;
71
+ this._outlinePart = null;
72
+ this._slotPartId = null;
73
+ }
74
+
75
+ /* ── Queries ────────────────────────────────────────────────────────── */
76
+
77
+ /** Every open part id, in group order. */
78
+ openParts() { return listOpenParts(this.area); }
79
+
80
+ isOpen(partId) { return !!findPart(this.area, partId); }
81
+
82
+ /** The part id selected in each group — i.e. what is actually on screen. */
83
+ visibleParts() {
84
+ return listGroups(this.area)
85
+ .map(g => g.children?.[g.active ?? 0]?.partId)
86
+ .filter(Boolean);
87
+ }
88
+
89
+ /**
90
+ * The view with focus: the selected tab of the active group. This is what a
91
+ * shell keys its chrome off — window title, breadcrumb, which toolbar
92
+ * belongs to what — and it is not the same as "the last thing opened",
93
+ * because clicking into another group changes it.
94
+ */
95
+ activePart() {
96
+ const groups = listGroups(this.area);
97
+ const group = groups.find(g => g.id === this.activeGroupId) ?? groups[0];
98
+ return group?.children?.[group.active ?? 0]?.partId ?? null;
99
+ }
100
+
101
+ groupCount() { return listGroups(this.area).length; }
102
+
103
+ /** `{ area, activeGroupId }` — hand straight to a store, pass back to the ctor. */
104
+ serialize() {
105
+ this._syncFromTree();
106
+ return { area: this.area, activeGroupId: this.activeGroupId };
107
+ }
108
+
109
+ /* ── Rendering ──────────────────────────────────────────────────────── */
110
+
111
+ render(host, ctx = {}) {
112
+ this.host = host;
113
+ this._outerCtx = ctx;
114
+ this.ctx = {
115
+ ...ctx,
116
+ // Closing and group focus are decisions about the AREA, not about
117
+ // one tab strip, so they are intercepted here rather than left to
118
+ // TabsNode's own handling.
119
+ onTabClose: ({ partId }) => { if (partId && this.beforeClose?.(partId) !== false) this.close(partId); },
120
+ onTabMenu: ({ partId, event }) => !!(partId && this.tabMenu?.(partId, event)),
121
+ onGroupFocus: (id) => this._setActiveGroup(id),
122
+ onLayoutChange: () => this._changed('layout'),
123
+ };
124
+ this._build();
125
+ // Both slots explicitly: nothing has focus yet as far as they are
126
+ // concerned, so a restored area would otherwise come back with an empty
127
+ // action bar and an empty rail.
128
+ this._syncSlots({ actions: true, outline: true });
129
+ return this;
130
+ }
131
+
132
+ _build() {
133
+ if (!this.host) return;
134
+ this._tree?.dispose();
135
+ this._tree = null;
136
+ this.host.textContent = '';
137
+
138
+ if (!listOpenParts(this.area).length) {
139
+ if (this.placeholder) this.host.appendChild(el(this.placeholder));
140
+ return;
141
+ }
142
+
143
+ this._tree = createLayout(this.area);
144
+ this._tree.render(this.host, this.ctx);
145
+ this._tree.activate();
146
+ this._markActiveGroup();
147
+ }
148
+
149
+ /* ── Frame slots ────────────────────────────────────────────────────── */
150
+
151
+ /**
152
+ * Point the action bar and outline rail at whatever now has focus.
153
+ *
154
+ * A slot is re-read when focus MOVED, or when the caller asks for that slot
155
+ * specifically. The two are asked for separately because their costs
156
+ * differ: re-reading actions is a handful of buttons, whereas re-showing an
157
+ * outline re-parents an element, which resets its scroll position — so a
158
+ * `refreshActions()` must not quietly scroll a long tree back to the top.
159
+ */
160
+ _syncSlots({ actions = false, outline = false } = {}) {
161
+ const partId = this.activePart();
162
+ const moved = partId !== this._slotPartId;
163
+ if (!moved && !actions && !outline) return;
164
+ this._slotPartId = partId;
165
+ if (moved || actions) this._renderActions(partId);
166
+ if (moved || outline) this._showOutline(partId);
167
+ }
168
+
169
+ _renderActions(partId) {
170
+ if (!this.actionsHost) return;
171
+ this.actionsHost.textContent = '';
172
+ const actions = partId ? (this.ctx?.registry?.get(partId)?.actions ?? []) : [];
173
+ this.actionsHost.classList.toggle('ws-actionbar-empty', !actions.length);
174
+ for (const action of actions) {
175
+ if (!action) continue;
176
+ // An already-built element is passed straight through. This is the
177
+ // AdapterPart bargain again: a panel that already has a working
178
+ // toolbar — with selects, labels and its own rebuild logic — joins
179
+ // the frame's action bar by handing it over, not by being rewritten
180
+ // into a button array that could not express it anyway.
181
+ if (typeof action.nodeType === 'number' || typeof action.appendChild === 'function') {
182
+ this.actionsHost.appendChild(action);
183
+ continue;
184
+ }
185
+ if (action.separator) { this.actionsHost.appendChild(el({ cls: 'ws-action-sep' })); continue; }
186
+ this.actionsHost.appendChild(el({
187
+ tag: 'button',
188
+ cls: ['ws-action', action.cls],
189
+ text: action.label ?? '',
190
+ attrs: {
191
+ type: 'button',
192
+ title: action.title ?? action.label ?? null,
193
+ disabled: action.disabled || null,
194
+ },
195
+ on: { click: (e) => action.onClick?.(e, this) },
196
+ }));
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Show the focused view's outline.
202
+ *
203
+ * An outline is an ordinary Part, resolved through the registry — not a new
204
+ * kind of thing. That is what gets it pooling, mount-once-then-re-parent and
205
+ * lifecycle for free: a tree keeps its expanded nodes and scroll position
206
+ * while you work in another view and come back.
207
+ */
208
+ _showOutline(partId) {
209
+ if (!this.outlineHost) return;
210
+ const outlineId = partId && this.outlineFor ? this.outlineFor(partId) : null;
211
+ const next = outlineId ? this.ctx?.registry?.get(outlineId) : null;
212
+
213
+ // Detached by name rather than by clearing the host: the outgoing
214
+ // outline is a pooled part, and `detach()` is the call that says so.
215
+ this._outlinePart?.detach();
216
+ this._outlinePart = null;
217
+ this._placeholderEl?.remove();
218
+ this._placeholderEl = null;
219
+ this.outlineHost.classList.toggle('ws-outline-empty', !next);
220
+
221
+ if (!next) {
222
+ if (this.outlinePlaceholder) {
223
+ this._placeholderEl = this.outlineHost.appendChild(el(this.outlinePlaceholder));
224
+ }
225
+ return;
226
+ }
227
+ try {
228
+ next.mount(this.outlineHost, this.ctx);
229
+ this._outlinePart = next;
230
+ } catch (err) {
231
+ console.error(`WorkArea: outline part "${outlineId}" failed to mount`, err);
232
+ this.outlineHost.classList.add('ws-outline-empty');
233
+ }
234
+ }
235
+
236
+ /** Re-read the focused view's actions — call when a panel's state changes. */
237
+ refreshActions() {
238
+ this._syncSlots({ actions: true });
239
+ return this;
240
+ }
241
+
242
+ /**
243
+ * Re-read both slots. For the case a lazily-mounted view creates the very
244
+ * things the slots are meant to show *after* it took focus — the action bar
245
+ * and outline were resolved against a panel that did not exist yet.
246
+ */
247
+ refreshSlots() {
248
+ this._syncSlots({ actions: true, outline: true });
249
+ return this;
250
+ }
251
+
252
+ /** The outline part currently on show, if any. */
253
+ get outlinePart() { return this._outlinePart; }
254
+
255
+ /**
256
+ * Read the live tree back into the model. Splitter drags and tab selections
257
+ * only ever happened to the DOM nodes; skipping this would silently undo
258
+ * them the next time anything opens or closes.
259
+ */
260
+ _syncFromTree() {
261
+ if (this._tree) this.area = this._tree.serialize();
262
+ return this.area;
263
+ }
264
+
265
+ /** Show every tab's label as its part's title is now (a file name that changed, say). */
266
+ refreshLabels() {
267
+ const walk = (node) => {
268
+ if (node?.constructor?.type === 'tabs') node.refreshLabels?.();
269
+ for (const child of node?.children ?? []) walk(child);
270
+ };
271
+ if (this._tree) walk(this._tree);
272
+ return this;
273
+ }
274
+
275
+ /** Live TabsNode for a group id, or null when nothing is rendered. */
276
+ _liveGroup(groupId) {
277
+ const walk = (node) => {
278
+ if (node?.id === groupId && node.constructor?.type === 'tabs') return node;
279
+ for (const child of node?.children ?? []) {
280
+ const hit = walk(child);
281
+ if (hit) return hit;
282
+ }
283
+ return null;
284
+ };
285
+ return this._tree ? walk(this._tree) : null;
286
+ }
287
+
288
+ /**
289
+ * Tint the active group's tab strip, but only once there is more than one
290
+ * group — with a single group "where the next view opens" is not a question
291
+ * anyone is asking, and the accent would just be noise.
292
+ */
293
+ _markActiveGroup() {
294
+ const many = this.groupCount() > 1;
295
+ for (const group of listGroups(this.area)) {
296
+ const live = this._liveGroup(group.id);
297
+ live?.el?.classList.toggle('ws-group-active', many && group.id === this.activeGroupId);
298
+ }
299
+ }
300
+
301
+ _setActiveGroup(id) {
302
+ if (!id || id === this.activeGroupId) return;
303
+ this.activeGroupId = id;
304
+ this._markActiveGroup();
305
+ this._changed('focus-group');
306
+ }
307
+
308
+ _changed(reason) {
309
+ this._syncFromTree();
310
+ // Before onChange, so a consumer reacting to the change (updating its
311
+ // own chrome, persisting) sees a frame that already agrees with focus.
312
+ this._syncSlots();
313
+ this.onChange?.({ area: this.area, activeGroupId: this.activeGroupId, reason });
314
+ this._outerCtx?.onLayoutChange?.();
315
+ }
316
+
317
+ /* ── Commands ───────────────────────────────────────────────────────── */
318
+
319
+ /**
320
+ * Open a view.
321
+ *
322
+ * `where`: `'tab'` (default) adds it to the active group; `'right'`,
323
+ * `'left'`, `'below'` or `'above'` opens a new group beside it — which is
324
+ * what actually gets two views on screen together.
325
+ *
326
+ * Already open anywhere -> focused, not duplicated: a part instance owns one
327
+ * DOM element, so a second tab for it would necessarily be empty.
328
+ */
329
+ open(partId, { where = 'tab' } = {}) {
330
+ if (!partId) return this;
331
+ if (this.isOpen(partId)) return this.focus(partId);
332
+ this._syncFromTree();
333
+ const next = openPart(this.area, partId, { where, activeGroupId: this.activeGroupId });
334
+ this.area = next.area;
335
+ this.activeGroupId = next.activeGroupId;
336
+ this._build();
337
+ this._changed('open');
338
+ return this;
339
+ }
340
+
341
+ /** Open beside the active group — the "split editor" gesture. */
342
+ openToSide(partId, side = 'right') { return this.open(partId, { where: side }); }
343
+
344
+ /**
345
+ * Select an already-open view. Deliberately does not rebuild: it drives the
346
+ * live TabsNode, so pane scroll positions and the DOM survive a tab click.
347
+ */
348
+ focus(partId) {
349
+ const found = findPart(this._syncFromTree(), partId);
350
+ if (!found) return this;
351
+ const live = this._liveGroup(found.group.id);
352
+ if (live) {
353
+ live.select(found.index, true);
354
+ this.activeGroupId = found.group.id;
355
+ this._markActiveGroup();
356
+ this._changed('focus');
357
+ return this;
358
+ }
359
+ const next = focusPart(this.area, partId, { activeGroupId: this.activeGroupId });
360
+ this.area = next.area;
361
+ this.activeGroupId = next.activeGroupId;
362
+ this._build();
363
+ this._changed('focus');
364
+ return this;
365
+ }
366
+
367
+ /**
368
+ * Close a view. Its group disappears when it held the last tab, and a split
369
+ * left with one child collapses into it — so closing back down to one view
370
+ * leaves a plain tab strip rather than a split with an empty half.
371
+ */
372
+ close(partId) {
373
+ this._syncFromTree();
374
+ const next = closePart(this.area, partId, { activeGroupId: this.activeGroupId });
375
+ if (!next.closed) return this;
376
+ this.area = next.area;
377
+ this.activeGroupId = next.activeGroupId;
378
+ this._build();
379
+ this._changed('close');
380
+ return this;
381
+ }
382
+
383
+ /** Close everything, leaving one empty group. */
384
+ closeAll() {
385
+ for (const partId of this.openParts()) this.close(partId);
386
+ return this;
387
+ }
388
+
389
+ /**
390
+ * Replace the whole area — restoring a saved one, or switching perspective.
391
+ * Parts the new area does not reference are left in the registry pool, so
392
+ * switching back finds them as they were.
393
+ */
394
+ setArea(area, activeGroupId) {
395
+ this.area = area ?? emptyWorkArea();
396
+ this.activeGroupId = activeGroupId ?? listGroups(this.area)[0]?.id ?? null;
397
+ this._build();
398
+ this._changed('set-area');
399
+ return this;
400
+ }
401
+
402
+ dispose() {
403
+ this._tree?.dispose();
404
+ this._tree = null;
405
+ // Detached, not disposed — the outline is a pooled part like any other,
406
+ // and destroying it here would take it out of the registry for good.
407
+ this._outlinePart?.detach();
408
+ this._outlinePart = null;
409
+ this._placeholderEl = null;
410
+ this._slotPartId = null;
411
+ if (this.host) this.host.textContent = '';
412
+ if (this.actionsHost) this.actionsHost.textContent = '';
413
+ if (this.outlineHost) this.outlineHost.textContent = '';
414
+ this.host = null;
415
+ this.ctx = null;
416
+ }
417
+ }