flexdesk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/css/base.css +1484 -181
- package/css/flexdesk.css +1311 -18
- package/css/overrides.css +44 -0
- package/css/tokens.css +45 -0
- package/dist/charts.js +5 -3
- package/dist/charts.js.map +1 -1
- package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
- package/dist/chunk-ELXVW542.js.map +7 -0
- package/dist/chunk-LH5TSOZW.js +1237 -0
- package/dist/chunk-LH5TSOZW.js.map +7 -0
- package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
- package/dist/chunk-O5OHMWBB.js.map +7 -0
- package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
- package/dist/chunk-QIU5S2RU.js.map +7 -0
- package/dist/chunk-QNQHQ24V.js +408 -0
- package/dist/chunk-QNQHQ24V.js.map +7 -0
- package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
- package/dist/chunk-XKDTIT4Q.js.map +7 -0
- package/dist/editor.js +3 -380
- package/dist/editor.js.map +3 -3
- package/dist/flexdesk.css +1311 -18
- package/dist/tiles.js +168 -41
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +45 -0
- package/dist/widgets.js +44 -14
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +2983 -142
- package/dist/wm.js.map +4 -4
- package/package.json +3 -2
- package/src/charts/chart_types.js +167 -0
- package/src/charts/plotly_wrapper.js +178 -10
- package/src/editor/notebook_tab_bar.js +39 -3
- package/src/tiles/tile_base.js +143 -35
- package/src/tiles/tile_grid.js +52 -1
- package/src/tiling/command_palette.js +71 -18
- package/src/tiling/desktops.js +36 -12
- package/src/tiling/keymap.js +24 -4
- package/src/tiling/shell.js +135 -24
- package/src/tiling/tab_strip.js +184 -0
- package/src/tiling/tile_breadcrumb.js +34 -2
- package/src/tiling/tile_renderer.js +1386 -21
- package/src/tiling/tile_tab_menu.js +101 -0
- package/src/tiling/tile_tree.js +82 -0
- package/src/tiling/wm.js +2352 -74
- package/src/ui/components/action_dropdown.js +34 -3
- package/src/ui/components/autocomplete_field.js +65 -13
- package/src/ui/components/context_menu.js +79 -8
- package/src/ui/components/data_table.js +508 -84
- package/src/ui/components/managed_window.js +928 -36
- package/src/ui/components/modal.js +214 -8
- package/dist/chunk-CT4YXXLP.js.map +0 -7
- package/dist/chunk-DRYCDMEG.js.map +0 -7
- package/dist/chunk-DVU44T77.js.map +0 -7
- package/dist/chunk-TLZUUFOE.js.map +0 -7
- package/dist/chunk-UCJ2WD4D.js +0 -625
- package/dist/chunk-UCJ2WD4D.js.map +0 -7
package/src/tiles/tile_base.js
CHANGED
|
@@ -42,10 +42,25 @@ export class TileBase {
|
|
|
42
42
|
* @param {Object} [options.eventBus] - Event bus for cross-component communication
|
|
43
43
|
* @param {Object} [options.config] - Widget-specific configuration
|
|
44
44
|
* @param {boolean} [options.headless] - If true, mount without tile chrome (header, controls)
|
|
45
|
+
* @param {{resolve: Function}} [options.dataSource] - Where this tile asks for its
|
|
46
|
+
* own rows. Handed down by `TileGrid`; absent for a grid whose data is
|
|
47
|
+
* broadcast with `setData()`, and then nothing ever calls `loadData()`.
|
|
45
48
|
*/
|
|
46
49
|
constructor({ id, grid, eventBus = null, config = {}, readonly = false,
|
|
47
|
-
headless = false, host = null, stateGuard = null
|
|
50
|
+
headless = false, host = null, stateGuard = null,
|
|
51
|
+
dataSource = null }) {
|
|
48
52
|
this.host = host;
|
|
53
|
+
// THE TILE PULLS; THE GRID DOES NOT PUSH.
|
|
54
|
+
//
|
|
55
|
+
// `TileGrid.setData()` broadcasts one dataset to every tile, which is the
|
|
56
|
+
// right shape when every tile reads the same simulation run and the wrong
|
|
57
|
+
// one when each tile is bound to a different query — the second tile would
|
|
58
|
+
// then have to find its own rows inside a payload it never asked for. A
|
|
59
|
+
// data source is anything answering `resolve(tileId, binding)`; the base
|
|
60
|
+
// class only holds it and hands it to `loadData()`, so a grid constructed
|
|
61
|
+
// without one behaves exactly as it did before this line existed, and the
|
|
62
|
+
// broadcast path is untouched.
|
|
63
|
+
this.dataSource = dataSource;
|
|
49
64
|
// `window.__ECOSIM_JS_NEW__?.stateGuard` — an application's global, reached for
|
|
50
65
|
// by a tile. The StateGuard is a FRAMEWORK service (@flexdesk/core); the INSTANCE is
|
|
51
66
|
// the application's, and it is handed down through the grid. No guard => the
|
|
@@ -64,6 +79,12 @@ export class TileBase {
|
|
|
64
79
|
/** @type {Object|null} Unfiltered analytics data for cross-namespace variable resolution */
|
|
65
80
|
this.fullData = null;
|
|
66
81
|
this._disposed = false;
|
|
82
|
+
// Written by whatever implements `loadData()`. `_loadSeq` is the guard
|
|
83
|
+
// against a slow first request landing after a fast second one and
|
|
84
|
+
// painting stale rows over fresh ones — a tile is re-resolved on every
|
|
85
|
+
// filter change, so that race is the normal case rather than the edge.
|
|
86
|
+
this._lastLoadedAt = null;
|
|
87
|
+
this._loadSeq = 0;
|
|
67
88
|
/** @type {Map<string, {name: string, analytics: Object, color: string}>|null} */
|
|
68
89
|
this.comparisonData = null;
|
|
69
90
|
}
|
|
@@ -308,6 +329,33 @@ export class TileBase {
|
|
|
308
329
|
}
|
|
309
330
|
}
|
|
310
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Load this tile's own data from `this.dataSource` and render it.
|
|
334
|
+
*
|
|
335
|
+
* A NO-OP HERE ON PURPOSE. The base class knows nothing about what a binding
|
|
336
|
+
* is or what a dataset looks like — those belong to the consumer, and the
|
|
337
|
+
* consumer that has them (Tables' `TablesTile`) implements this in ~30 lines
|
|
338
|
+
* over `showLoading()` / `render()` / `showError()`. What upstream owns is the
|
|
339
|
+
* CALL SITES: `TileGrid` invokes this after mount, after a resize settles and
|
|
340
|
+
* after a config save, but only when a `dataSource` was injected. Making it a
|
|
341
|
+
* hook rather than an implementation is what keeps EcoAgent's broadcast path
|
|
342
|
+
* (`setData` -> `update`) bit-identical: with no data source nothing here ever
|
|
343
|
+
* runs.
|
|
344
|
+
*
|
|
345
|
+
* @param {{force?: boolean}} [_opts] - `force` bypasses the source's cache.
|
|
346
|
+
* @returns {Promise<void>}
|
|
347
|
+
*/
|
|
348
|
+
async loadData(_opts = {}) {}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Called once after a drag-resize settles, for widgets whose content does not
|
|
352
|
+
* reflow on its own — a Plotly figure sizes itself at draw time and stays that
|
|
353
|
+
* size until something calls `Plotly.Plots.resize`. Deliberately NOT wired to a
|
|
354
|
+
* ResizeObserver: the grid already knows when a resize ended, and an observer
|
|
355
|
+
* per tile fires during the drag, which is a relayout per pointer-move.
|
|
356
|
+
*/
|
|
357
|
+
onResize() {}
|
|
358
|
+
|
|
311
359
|
/** Add, update, or remove the info (ⓘ) button in the header to match current docs state. */
|
|
312
360
|
_syncInfoButton() {
|
|
313
361
|
const header = this.element?.querySelector('.tile-header');
|
|
@@ -367,6 +415,60 @@ export class TileBase {
|
|
|
367
415
|
this.fullData = null;
|
|
368
416
|
}
|
|
369
417
|
|
|
418
|
+
/**
|
|
419
|
+
* The entries in the tile's ⋯ menu, as `{action, label, icon, hidden}`.
|
|
420
|
+
*
|
|
421
|
+
* This existed as a LITERAL inside `_buildChrome`, and the literal was
|
|
422
|
+
* EcoAgent's: "Add to Documentation" and "Show in Documentation". Both are
|
|
423
|
+
* meaningful only for a tile whose `config.sourceCellId` names a notebook
|
|
424
|
+
* cell, and the first one's hide condition is `sourceCellId && already-added` —
|
|
425
|
+
* so on a tile that has no `sourceCellId` at all it renders VISIBLE, and
|
|
426
|
+
* choosing it emits `tile:add-to-documentation` at an application that has no
|
|
427
|
+
* documentation. Every consumer other than EcoAgent therefore shipped a menu
|
|
428
|
+
* item that did nothing, and could not remove it without editing this file.
|
|
429
|
+
*
|
|
430
|
+
* Returning `[]` drops the whole wrapper — button, dropdown and all — because
|
|
431
|
+
* a ⋯ button that opens an empty menu reads as a broken feature rather than
|
|
432
|
+
* an absent one. The default is the two entries above, so EcoAgent's chrome
|
|
433
|
+
* is unchanged.
|
|
434
|
+
*
|
|
435
|
+
* Selection is routed through `_onMenuAction()`; override both together.
|
|
436
|
+
* @returns {Array<{action: string, label: string, icon?: string, hidden?: boolean}>}
|
|
437
|
+
*/
|
|
438
|
+
menuItems() {
|
|
439
|
+
const linked = !!(this.config?.sourceCellId
|
|
440
|
+
&& this.grid?.documentationCellIds?.has(this.config.sourceCellId));
|
|
441
|
+
return [
|
|
442
|
+
{ action: 'add-to-documentation',
|
|
443
|
+
label: 'Add to Documentation', icon: 'post_add', hidden: linked },
|
|
444
|
+
{ action: 'show-in-documentation',
|
|
445
|
+
label: 'Show in Documentation', icon: 'description', hidden: !linked },
|
|
446
|
+
];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Act on a ⋯ menu selection. One dispatch point for every entry, so an
|
|
451
|
+
* override of `menuItems()` cannot add a row that has nothing behind it, and
|
|
452
|
+
* an unrecognised action is silently ignored rather than throwing into a
|
|
453
|
+
* click handler.
|
|
454
|
+
* @param {string} action
|
|
455
|
+
* @protected
|
|
456
|
+
*/
|
|
457
|
+
_onMenuAction(action) {
|
|
458
|
+
if (action === 'add-to-documentation') {
|
|
459
|
+
this.eventBus?.emit?.('tile:add-to-documentation', {
|
|
460
|
+
tileType: this.constructor.TYPE,
|
|
461
|
+
config: { ...this.config },
|
|
462
|
+
});
|
|
463
|
+
} else if (action === 'show-in-documentation') {
|
|
464
|
+
if (this.config?.sourceCellId) {
|
|
465
|
+
this.eventBus?.emit?.('tile:show-in-documentation', {
|
|
466
|
+
sourceCellId: this.config.sourceCellId,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
370
472
|
/**
|
|
371
473
|
* Build the tile chrome (header, content area, resize handles).
|
|
372
474
|
* @private
|
|
@@ -397,6 +499,24 @@ export class TileBase {
|
|
|
397
499
|
</button>`
|
|
398
500
|
: '';
|
|
399
501
|
|
|
502
|
+
// The ⋯ menu, from `menuItems()` rather than from a literal. Read-only
|
|
503
|
+
// chrome never had one, so the hook is not even asked in that mode.
|
|
504
|
+
const menuEntries = (this.readonly ? [] : this.menuItems()) ?? [];
|
|
505
|
+
const menuHtml = menuEntries.length
|
|
506
|
+
? `<div class="tile-menu-wrapper">
|
|
507
|
+
<button class="tile-menu-btn twm-has-tooltip" data-tooltip="More actions">
|
|
508
|
+
<span class="material-symbols-outlined">more_vert</span>
|
|
509
|
+
</button>
|
|
510
|
+
<div class="tile-menu-dropdown" hidden>
|
|
511
|
+
${menuEntries.map((item) => `
|
|
512
|
+
<button class="tile-menu-item" data-action="${this.#escapeAttr(item.action)}"${item.hidden ? ' style="display:none"' : ''}>
|
|
513
|
+
${item.icon ? `<span class="material-symbols-outlined">${this.#escapeAttr(item.icon)}</span>` : ''}
|
|
514
|
+
<span>${this.#escapeAttr(item.label ?? '')}</span>
|
|
515
|
+
</button>`).join('')}
|
|
516
|
+
</div>
|
|
517
|
+
</div>`
|
|
518
|
+
: '';
|
|
519
|
+
|
|
400
520
|
if (this.readonly) {
|
|
401
521
|
// Read-only mode: no drag handle, no menu, no remove button
|
|
402
522
|
header.innerHTML = `
|
|
@@ -416,21 +536,7 @@ export class TileBase {
|
|
|
416
536
|
<button class="tile-config-btn twm-has-tooltip" data-tooltip="Configure">
|
|
417
537
|
<span class="material-symbols-outlined">settings</span>
|
|
418
538
|
</button>
|
|
419
|
-
|
|
420
|
-
<button class="tile-menu-btn twm-has-tooltip" data-tooltip="More actions">
|
|
421
|
-
<span class="material-symbols-outlined">more_vert</span>
|
|
422
|
-
</button>
|
|
423
|
-
<div class="tile-menu-dropdown" hidden>
|
|
424
|
-
<button class="tile-menu-item" data-action="add-to-documentation" style="${this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId) ? 'display:none' : ''}">
|
|
425
|
-
<span class="material-symbols-outlined">post_add</span>
|
|
426
|
-
<span>Add to Documentation</span>
|
|
427
|
-
</button>
|
|
428
|
-
<button class="tile-menu-item" data-action="show-in-documentation" style="${this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId) ? '' : 'display:none'}">
|
|
429
|
-
<span class="material-symbols-outlined">description</span>
|
|
430
|
-
<span>Show in Documentation</span>
|
|
431
|
-
</button>
|
|
432
|
-
</div>
|
|
433
|
-
</div>
|
|
539
|
+
${menuHtml}
|
|
434
540
|
${infoBtnHtml}
|
|
435
541
|
${expandBtnHtml}
|
|
436
542
|
<button class="tile-remove-btn twm-has-tooltip" data-tooltip="Remove widget">
|
|
@@ -485,23 +591,17 @@ export class TileBase {
|
|
|
485
591
|
});
|
|
486
592
|
}
|
|
487
593
|
});
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
594
|
+
// One listener per rendered entry, dispatching by `data-action`
|
|
595
|
+
// into `_onMenuAction`. Wiring each action by name here is what
|
|
596
|
+
// made the menu un-overridable: a subclass could add a row and
|
|
597
|
+
// could not add the handler behind it.
|
|
598
|
+
menuDropdown.querySelectorAll('.tile-menu-item').forEach((itemEl) => {
|
|
599
|
+
itemEl.addEventListener('click', (e) => {
|
|
600
|
+
e.stopPropagation();
|
|
601
|
+
menuDropdown.hidden = true;
|
|
602
|
+
this._onMenuAction(itemEl.dataset.action);
|
|
494
603
|
});
|
|
495
604
|
});
|
|
496
|
-
menuDropdown.querySelector('[data-action="show-in-documentation"]')?.addEventListener('click', (e) => {
|
|
497
|
-
e.stopPropagation();
|
|
498
|
-
menuDropdown.hidden = true;
|
|
499
|
-
if (this.config?.sourceCellId) {
|
|
500
|
-
this.eventBus?.emit?.('tile:show-in-documentation', {
|
|
501
|
-
sourceCellId: this.config.sourceCellId,
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
});
|
|
505
605
|
}
|
|
506
606
|
|
|
507
607
|
const removeBtn = header.querySelector('.tile-remove-btn');
|
|
@@ -619,6 +719,13 @@ export class TileBase {
|
|
|
619
719
|
|
|
620
720
|
/**
|
|
621
721
|
* Show empty state in content area.
|
|
722
|
+
*
|
|
723
|
+
* The message is ESCAPED. Both this and `showError` interpolated straight
|
|
724
|
+
* into `innerHTML`, and the string reaching `showError` is the one place a
|
|
725
|
+
* caller has least control over — a server's error text, echoed back with a
|
|
726
|
+
* column name or a filter value in it. Callers pass plain text; this decides
|
|
727
|
+
* what that means in HTML.
|
|
728
|
+
*
|
|
622
729
|
* @param {string} [message] - Custom message
|
|
623
730
|
*/
|
|
624
731
|
showEmpty(message = 'No data available') {
|
|
@@ -626,14 +733,14 @@ export class TileBase {
|
|
|
626
733
|
this.contentElement.innerHTML = `
|
|
627
734
|
<div class="tile-empty">
|
|
628
735
|
<span class="material-symbols-outlined">inbox</span>
|
|
629
|
-
<span>${message}</span>
|
|
736
|
+
<span>${this.#escapeAttr(message)}</span>
|
|
630
737
|
</div>
|
|
631
738
|
`;
|
|
632
739
|
}
|
|
633
740
|
}
|
|
634
741
|
|
|
635
742
|
/**
|
|
636
|
-
* Show error state in content area.
|
|
743
|
+
* Show error state in content area. The message is escaped — see `showEmpty`.
|
|
637
744
|
* @param {string} [message] - Error message
|
|
638
745
|
*/
|
|
639
746
|
showError(message = 'Failed to load data') {
|
|
@@ -641,7 +748,7 @@ export class TileBase {
|
|
|
641
748
|
this.contentElement.innerHTML = `
|
|
642
749
|
<div class="twm-tile-error">
|
|
643
750
|
<span class="material-symbols-outlined">error</span>
|
|
644
|
-
<span>${message}</span>
|
|
751
|
+
<span>${this.#escapeAttr(message)}</span>
|
|
645
752
|
</div>
|
|
646
753
|
`;
|
|
647
754
|
}
|
|
@@ -760,7 +867,8 @@ export class TileBase {
|
|
|
760
867
|
}
|
|
761
868
|
|
|
762
869
|
/**
|
|
763
|
-
* Escape a string for safe insertion into an
|
|
870
|
+
* Escape a string for safe insertion into HTML — an attribute value or a text
|
|
871
|
+
* node, since the five characters that matter are the same five in both.
|
|
764
872
|
* @private
|
|
765
873
|
*/
|
|
766
874
|
#escapeAttr(s) {
|
package/src/tiles/tile_grid.js
CHANGED
|
@@ -21,6 +21,9 @@ export class TileGrid {
|
|
|
21
21
|
* @param {Function} [options.onAddWidget] - Callback for add widget action
|
|
22
22
|
* @param {Function} [options.onResetLayout] - Callback for reset layout action
|
|
23
23
|
* @param {Function} [options.onExport] - Callback for export action
|
|
24
|
+
* @param {{resolve: Function}} [options.dataSource] - Per-tile data provider,
|
|
25
|
+
* handed to every tile. With none, the grid is the broadcast-only grid
|
|
26
|
+
* it has always been and no tile is ever asked to load anything.
|
|
24
27
|
*/
|
|
25
28
|
constructor({ container, columns = 12, rowHeight = 80, gap = 16, eventBus = null,
|
|
26
29
|
showToolbar = true, readonly = false, onLayoutChange, onBeforeLayoutChange,
|
|
@@ -29,9 +32,13 @@ export class TileGrid {
|
|
|
29
32
|
// ui/js/ecoagent/ to back Expand -> Export CSV/PNG; a grid that has
|
|
30
33
|
// no host simply gets the Blob-download fallback the host contract
|
|
31
34
|
// promises. It is not an error to have none.
|
|
32
|
-
host = null, stateGuard = null
|
|
35
|
+
host = null, stateGuard = null,
|
|
36
|
+
// Injected, never constructed here: the grid does not know what
|
|
37
|
+
// a binding is, only that a tile may have somewhere to ask.
|
|
38
|
+
dataSource = null }) {
|
|
33
39
|
this.host = host;
|
|
34
40
|
this.stateGuard = stateGuard;
|
|
41
|
+
this.dataSource = dataSource;
|
|
35
42
|
this.container = container;
|
|
36
43
|
this.columns = columns;
|
|
37
44
|
this.rowHeight = rowHeight;
|
|
@@ -181,6 +188,7 @@ export class TileGrid {
|
|
|
181
188
|
readonly: this.readonly,
|
|
182
189
|
host: this.host,
|
|
183
190
|
stateGuard: this.stateGuard,
|
|
191
|
+
dataSource: this.dataSource,
|
|
184
192
|
});
|
|
185
193
|
|
|
186
194
|
if (!tile) return null;
|
|
@@ -211,6 +219,14 @@ export class TileGrid {
|
|
|
211
219
|
if (this.data) {
|
|
212
220
|
tile.fullData = this.fullData;
|
|
213
221
|
tile.update(this.data);
|
|
222
|
+
} else if (this.dataSource) {
|
|
223
|
+
// A tile that fetches its own rows is never handed any, so the branch
|
|
224
|
+
// above cannot fire for it and it would mount empty and stay empty
|
|
225
|
+
// until something else happened to it. NOT awaited: `setLayout` calls
|
|
226
|
+
// `addTile` in a loop, and awaiting here would serialise a board into
|
|
227
|
+
// one request per tile in sequence — the opposite of what a batching
|
|
228
|
+
// data source exists to do.
|
|
229
|
+
tile.loadData?.();
|
|
214
230
|
}
|
|
215
231
|
|
|
216
232
|
// Emit layout changed
|
|
@@ -248,6 +264,31 @@ export class TileGrid {
|
|
|
248
264
|
});
|
|
249
265
|
}
|
|
250
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Ask every self-fetching tile to load again, bypassing whatever the data
|
|
269
|
+
* source has cached.
|
|
270
|
+
*
|
|
271
|
+
* The counterpart of `setData` for the pull model: `setData` is how a caller
|
|
272
|
+
* with one dataset for the whole board pushes it, and this is how a caller
|
|
273
|
+
* whose tiles each own a query says "that underlying data changed". Passing
|
|
274
|
+
* `ids` narrows it to the tiles a change actually touched, which is what an
|
|
275
|
+
* invalidation carrying a table id can work out and a broadcast cannot.
|
|
276
|
+
*
|
|
277
|
+
* Not awaited and returns nothing: the tiles render themselves as their own
|
|
278
|
+
* requests land, and a caller that waited for all of them would be waiting on
|
|
279
|
+
* the slowest tile to show the fastest one.
|
|
280
|
+
*
|
|
281
|
+
* @param {string[]|Set<string>|null} [ids] - Tile ids, or null for all.
|
|
282
|
+
*/
|
|
283
|
+
reloadAll(ids = null) {
|
|
284
|
+
if (!this.dataSource) return;
|
|
285
|
+
const wanted = ids ? new Set(ids) : null;
|
|
286
|
+
this.tiles.forEach((tile, tileId) => {
|
|
287
|
+
if (wanted && !wanted.has(tileId)) return;
|
|
288
|
+
tile.loadData?.({ force: true });
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
251
292
|
/**
|
|
252
293
|
* Get current layout.
|
|
253
294
|
* @returns {Array} Layout array
|
|
@@ -560,6 +601,12 @@ export class TileGrid {
|
|
|
560
601
|
if (this.data) {
|
|
561
602
|
state.tile.fullData = this.fullData;
|
|
562
603
|
state.tile.update(this.data);
|
|
604
|
+
} else if (this.dataSource) {
|
|
605
|
+
// Re-rendering from a dataset already in hand is free; asking
|
|
606
|
+
// the server again because a tile got 40 px wider is not. A
|
|
607
|
+
// self-fetching tile is told the geometry settled and decides
|
|
608
|
+
// what that costs it — for a chart, one `Plotly.Plots.resize`.
|
|
609
|
+
state.tile.onResize?.();
|
|
563
610
|
}
|
|
564
611
|
}
|
|
565
612
|
}
|
|
@@ -771,6 +818,10 @@ export class TileGrid {
|
|
|
771
818
|
if (tile) {
|
|
772
819
|
tile.fullData = this.fullData;
|
|
773
820
|
tile.update(this.data, newConfig);
|
|
821
|
+
// A saved config is usually a changed BINDING, and the data
|
|
822
|
+
// source's cache is keyed by the old one — so this is the one
|
|
823
|
+
// reload that must not be served from it.
|
|
824
|
+
if (this.dataSource) tile.loadData?.({ force: true });
|
|
774
825
|
// Update layout config
|
|
775
826
|
const layoutItem = this.layout.find(l => l.id === tileId);
|
|
776
827
|
if (layoutItem) {
|
|
@@ -33,8 +33,13 @@ const STATIC_COMMANDS = [
|
|
|
33
33
|
/** @param taxonomy injected ontology — supplies the chip strip + row icons
|
|
34
34
|
* @param catalog injected entity catalog — supplies the searchable rows
|
|
35
35
|
* @param placeholder input placeholder. It names the embedder's entity types
|
|
36
|
-
* ("try foo:bar"), so the embedder owns the string.
|
|
37
|
-
|
|
36
|
+
* ("try foo:bar"), so the embedder owns the string.
|
|
37
|
+
* @param onPick C30. `(pick) => truthy` CLAIMS the open, exactly the way
|
|
38
|
+
* `ManagedWindow.onMaximize` claims the maximise gesture.
|
|
39
|
+
* See `commit` for what it is for; omitted, the palette
|
|
40
|
+
* behaves as it always has. */
|
|
41
|
+
export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = 'Search…',
|
|
42
|
+
onPick = null }) {
|
|
38
43
|
if (!taxonomy) throw new Error('createCommandPalette: a taxonomy is required');
|
|
39
44
|
if (!catalog) throw new Error('createCommandPalette: an entity catalog is required');
|
|
40
45
|
let overlay = null;
|
|
@@ -53,7 +58,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
53
58
|
overlay = document.createElement('div');
|
|
54
59
|
overlay.id = ROOT_ID;
|
|
55
60
|
overlay.className = 'twm-cmdpal-overlay';
|
|
56
|
-
overlay.innerHTML = _markup(taxonomy, placeholder);
|
|
61
|
+
overlay.innerHTML = _markup(taxonomy, placeholder, wm);
|
|
57
62
|
document.body.appendChild(overlay);
|
|
58
63
|
overlay.addEventListener('click', (e) => {
|
|
59
64
|
if (e.target === overlay) close();
|
|
@@ -143,11 +148,50 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
143
148
|
el.addEventListener('click', () => commit(Number(el.dataset.idx)));
|
|
144
149
|
});
|
|
145
150
|
};
|
|
151
|
+
/**
|
|
152
|
+
* ══ C30. THE PALETTE IS A DOOR, NOT A PLACEMENT POLICY ══════════
|
|
153
|
+
*
|
|
154
|
+
* `openInPrimary` is the wrong verb for a picked ENTITY and it was the
|
|
155
|
+
* only one here. It RESETS the primary leaf to a single fresh tab —
|
|
156
|
+
* its own doc says so, and says why: navigation from outside the tile
|
|
157
|
+
* is a page change, and a page change replaces the page. So picking a
|
|
158
|
+
* table from Ctrl+K threw away every other tab in that leaf, ignored
|
|
159
|
+
* whatever rule the embedder has about where its entities open (a tab
|
|
160
|
+
* beside its siblings, a floating window on a canvas pane, a split),
|
|
161
|
+
* and bypassed any per-mount-site bookkeeping the embedder keeps —
|
|
162
|
+
* for Tables, the grid registry keyed on `(mount site, table)`, which
|
|
163
|
+
* is what holds staged edits across a re-render.
|
|
164
|
+
*
|
|
165
|
+
* The result was a door that did something different from every other
|
|
166
|
+
* door onto the same object: the rail opened a table one way, the
|
|
167
|
+
* palette another, and only the palette destroyed work. Reported as
|
|
168
|
+
* "open table via Ctrl+K does not work" — which is exactly what it
|
|
169
|
+
* looks like from the outside when the tab you were in is replaced.
|
|
170
|
+
*
|
|
171
|
+
* The library cannot decide this. It does not know what a `table` is,
|
|
172
|
+
* which is the whole point of the injected taxonomy and catalogue. So
|
|
173
|
+
* the embedder is offered the pick and may CLAIM it, on the same
|
|
174
|
+
* contract as `ManagedWindow.onMaximize` (`managed_window.js:230`):
|
|
175
|
+
* return truthy and nothing else happens. Guarded, because a throwing
|
|
176
|
+
* embedder must not leave the palette closed over a half-done open —
|
|
177
|
+
* and unclaimed picks still land in the primary tile, so no existing
|
|
178
|
+
* embedder changes behaviour by upgrading.
|
|
179
|
+
*
|
|
180
|
+
* `pick` is the catalogue row verbatim (`{kind, id, label, hint,
|
|
181
|
+
* props?}`) rather than a re-shaped argument: the embedder wrote the
|
|
182
|
+
* `shape` that produced it, so it is the one party that can read it.
|
|
183
|
+
*/
|
|
146
184
|
const commit = (i) => {
|
|
147
185
|
const pick = filtered[i];
|
|
148
186
|
if (!pick) return;
|
|
149
187
|
close();
|
|
150
188
|
if (pick.action) { try { pick.action(); } catch (err) { console.error(err); } return; }
|
|
189
|
+
if (onPick) {
|
|
190
|
+
let handled = false;
|
|
191
|
+
try { handled = onPick(pick) ?? false; }
|
|
192
|
+
catch (err) { console.error('[cmdpal] onPick threw', err); }
|
|
193
|
+
if (handled) return;
|
|
194
|
+
}
|
|
151
195
|
wm.openInPrimary(pick.kind, pick.props || { id: pick.id, label: pick.label });
|
|
152
196
|
};
|
|
153
197
|
|
|
@@ -240,7 +284,29 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
|
|
|
240
284
|
return { open, close, toggle, isOpen };
|
|
241
285
|
}
|
|
242
286
|
|
|
243
|
-
function _markup(taxonomy, placeholder) {
|
|
287
|
+
function _markup(taxonomy, placeholder, wm) {
|
|
288
|
+
// ONLY THE PANELS THIS SHELL CAN ACTUALLY SHOW.
|
|
289
|
+
//
|
|
290
|
+
// The three toggles were hardcoded, so the palette offered `panel:left` to
|
|
291
|
+
// an embedder that has no factory for it — and `togglePanel` then
|
|
292
|
+
// canonicalized a leaf whose only possible rendering is "no factory
|
|
293
|
+
// registered yet", permanently, because the renderer caches tile DOM per
|
|
294
|
+
// (kind, props). C14 closed that path at boot; this closes the other end of
|
|
295
|
+
// it. A panel with no factory is not a panel this shell can show, and the
|
|
296
|
+
// registry is the thing that knows.
|
|
297
|
+
const PANEL_CHIPS = [
|
|
298
|
+
{ side: 'left', icon: 'menu', label: 'Left nav' },
|
|
299
|
+
{ side: 'right', icon: 'dock_to_left', label: 'Right panel' },
|
|
300
|
+
{ side: 'bottom', icon: 'dock_to_bottom', label: 'Bottom panel' },
|
|
301
|
+
];
|
|
302
|
+
const panelToggles = PANEL_CHIPS
|
|
303
|
+
.filter((p) => wm.content?.has?.(`panel:${p.side}`) !== false)
|
|
304
|
+
.map((p) => `
|
|
305
|
+
<button class="twm-chip" data-toggle="${p.side}">
|
|
306
|
+
<span class="material-symbols-outlined">${p.icon}</span>
|
|
307
|
+
${p.label}
|
|
308
|
+
</button>`).join('');
|
|
309
|
+
|
|
244
310
|
const chips = taxonomy.topNavEntries().map((k) => `
|
|
245
311
|
<button class="twm-chip" data-shortcut="${k.kind}">
|
|
246
312
|
<span class="material-symbols-outlined">${k.icon}</span>
|
|
@@ -256,20 +322,7 @@ function _markup(taxonomy, placeholder) {
|
|
|
256
322
|
autocomplete="off" />
|
|
257
323
|
</div>
|
|
258
324
|
<div class="twm-cmdpal__chips">${chips}</div>
|
|
259
|
-
<div class="twm-cmdpal__toggles">
|
|
260
|
-
<button class="twm-chip" data-toggle="left">
|
|
261
|
-
<span class="material-symbols-outlined">menu</span>
|
|
262
|
-
Left nav
|
|
263
|
-
</button>
|
|
264
|
-
<button class="twm-chip" data-toggle="right">
|
|
265
|
-
<span class="material-symbols-outlined">dock_to_left</span>
|
|
266
|
-
Right panel
|
|
267
|
-
</button>
|
|
268
|
-
<button class="twm-chip" data-toggle="bottom">
|
|
269
|
-
<span class="material-symbols-outlined">dock_to_bottom</span>
|
|
270
|
-
Bottom panel
|
|
271
|
-
</button>
|
|
272
|
-
</div>
|
|
325
|
+
<div class="twm-cmdpal__toggles">${panelToggles}</div>
|
|
273
326
|
<div class="twm-cmdpal__list" data-role="list"></div>
|
|
274
327
|
<div class="twm-cmdpal__footer">
|
|
275
328
|
<span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
|
package/src/tiling/desktops.js
CHANGED
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
|
|
11
11
|
import { TileTree, makeLeaf } from './tile_tree.js';
|
|
12
12
|
|
|
13
|
+
/** What a FRESH desktop's panel tiles start as, when the embedder says nothing.
|
|
14
|
+
*
|
|
15
|
+
* C14. It is a DEFAULT, not a constant. An embedder whose navigator and
|
|
16
|
+
* inspector are its own chrome OUTSIDE the WM root — an icon rail, say — has
|
|
17
|
+
* no `panel:left` factory to mount, and every desktop it creates would open
|
|
18
|
+
* with two "no factory registered yet" placeholders wedged either side of its
|
|
19
|
+
* content. It cannot fix that after the fact: `wm.load()` canonicalizes the
|
|
20
|
+
* panels in before it returns, and the renderer caches tile DOM per
|
|
21
|
+
* (kind, props). The only place the answer can be given is here, before the
|
|
22
|
+
* first desktop exists — which is what `panelDefaults` is for. */
|
|
13
23
|
const DEFAULT_PANEL_STATE = {
|
|
14
24
|
left: true,
|
|
15
25
|
right: true,
|
|
@@ -25,7 +35,7 @@ const DEFAULT_PANEL_STATE = {
|
|
|
25
35
|
* deliberately NO default: a silent 'home' fallback is the exact bug this
|
|
26
36
|
* parameter exists to remove.
|
|
27
37
|
*/
|
|
28
|
-
function _makeDesktop(label, seed) {
|
|
38
|
+
function _makeDesktop(label, seed, panelDefaults = DEFAULT_PANEL_STATE) {
|
|
29
39
|
const tree = new TileTree();
|
|
30
40
|
tree.setRoot(makeLeaf(seed()));
|
|
31
41
|
return {
|
|
@@ -33,20 +43,28 @@ function _makeDesktop(label, seed) {
|
|
|
33
43
|
label,
|
|
34
44
|
tree,
|
|
35
45
|
windows: [],
|
|
36
|
-
//
|
|
37
|
-
// assigned by wm._canonicalize via
|
|
38
|
-
|
|
46
|
+
// Boot default: whatever the embedder asked for, left + right + bottom
|
|
47
|
+
// when it asked for nothing. Names are assigned by wm._canonicalize via
|
|
48
|
+
// PANEL_TITLES.
|
|
49
|
+
panels: { ...panelDefaults },
|
|
39
50
|
};
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
export class DesktopManager {
|
|
43
|
-
/**
|
|
44
|
-
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} opts
|
|
56
|
+
* @param {function} opts.seed builds the root leaf. Required.
|
|
57
|
+
* @param {object} [opts.panelDefaults] C14. Which panel tiles a fresh
|
|
58
|
+
* desktop opens with, merged over `DEFAULT_PANEL_STATE`. Omitted, every
|
|
59
|
+
* desktop opens with all three — today's behaviour, unchanged.
|
|
60
|
+
*/
|
|
61
|
+
constructor({ seed, panelDefaults = null } = {}) {
|
|
45
62
|
if (typeof seed !== 'function') {
|
|
46
63
|
throw new Error('DesktopManager: a `seed` function is required (the taxonomy root leaf)');
|
|
47
64
|
}
|
|
48
65
|
this.seed = seed;
|
|
49
|
-
this.
|
|
66
|
+
this.panelDefaults = { ...DEFAULT_PANEL_STATE, ...(panelDefaults || {}) };
|
|
67
|
+
this.desktops = [_makeDesktop('1', seed, this.panelDefaults)];
|
|
50
68
|
this.activeIdx = 0;
|
|
51
69
|
}
|
|
52
70
|
|
|
@@ -61,12 +79,14 @@ export class DesktopManager {
|
|
|
61
79
|
|
|
62
80
|
ensureCount(n) {
|
|
63
81
|
while (this.desktops.length < n) {
|
|
64
|
-
this.desktops.push(_makeDesktop(
|
|
82
|
+
this.desktops.push(_makeDesktop(
|
|
83
|
+
String(this.desktops.length + 1), this.seed, this.panelDefaults));
|
|
65
84
|
}
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
addDesktop(label = null) {
|
|
69
|
-
const d = _makeDesktop(
|
|
88
|
+
const d = _makeDesktop(
|
|
89
|
+
label || String(this.desktops.length + 1), this.seed, this.panelDefaults);
|
|
70
90
|
this.desktops.push(d);
|
|
71
91
|
return d;
|
|
72
92
|
}
|
|
@@ -84,15 +104,19 @@ export class DesktopManager {
|
|
|
84
104
|
};
|
|
85
105
|
}
|
|
86
106
|
|
|
87
|
-
static deserialize(blob, { seed } = {}) {
|
|
88
|
-
const m = new DesktopManager({ seed });
|
|
107
|
+
static deserialize(blob, { seed, panelDefaults = null } = {}) {
|
|
108
|
+
const m = new DesktopManager({ seed, panelDefaults });
|
|
89
109
|
if (!blob || !Array.isArray(blob.desktops) || blob.desktops.length === 0) return m;
|
|
90
110
|
m.desktops = blob.desktops.map((raw) => ({
|
|
91
111
|
id: raw.id || `desk-${Math.random().toString(36).slice(2,8)}`,
|
|
92
112
|
label: raw.label || '?',
|
|
93
113
|
tree: raw.tree ? TileTree.deserialize(raw.tree) : new TileTree(),
|
|
94
114
|
windows: [],
|
|
95
|
-
|
|
115
|
+
// A RESTORED desktop's own answer wins over the default: the user
|
|
116
|
+
// closed that panel, and re-opening it on every reload is the bug
|
|
117
|
+
// this merge order avoids. The default only fills a key the stored
|
|
118
|
+
// blob predates.
|
|
119
|
+
panels: { ...m.panelDefaults, ...(raw.panels || {}) },
|
|
96
120
|
}));
|
|
97
121
|
// Empty tree → seed with the taxonomy root so the desktop is usable.
|
|
98
122
|
for (const d of m.desktops) {
|
package/src/tiling/keymap.js
CHANGED
|
@@ -28,10 +28,20 @@
|
|
|
28
28
|
*
|
|
29
29
|
* The handler refuses to act when focus is in an editable field, unless
|
|
30
30
|
* the chord uses the Alt modifier (which is never typed into a field).
|
|
31
|
+
*
|
|
32
|
+
* ── It returns a disposer, and that is not decoration ──────────────────
|
|
33
|
+
* The listener is on `document` and is bound to ONE shell's `wm` and
|
|
34
|
+
* `palette`. An embedder that can build a second shell in the same page —
|
|
35
|
+
* Tables rebuilds its whole shell when you switch project, because every open
|
|
36
|
+
* tab names a table by id — would otherwise leave the first one's handler
|
|
37
|
+
* attached for ever. Two handlers is not "twice as responsive": `Ctrl+K`
|
|
38
|
+
* toggles the dead palette open and the live one closed in the same keystroke,
|
|
39
|
+
* and `Alt+W` closes a tile in a tree nobody can see. `createShell().dispose()`
|
|
40
|
+
* calls this.
|
|
31
41
|
*/
|
|
32
42
|
|
|
33
|
-
export function installKeymap({ wm, palette }) {
|
|
34
|
-
|
|
43
|
+
export function installKeymap({ wm, palette, ...opts } = {}) {
|
|
44
|
+
const onKeyDown = (e) => {
|
|
35
45
|
const inField = e.target?.closest?.(
|
|
36
46
|
'input, textarea, select, [contenteditable="true"]');
|
|
37
47
|
|
|
@@ -66,8 +76,15 @@ export function installKeymap({ wm, palette }) {
|
|
|
66
76
|
// flows (query editor, filters) keep keyboard focus.
|
|
67
77
|
const fMatch = /^F([1-9]|1[0-2])$/.exec(e.key);
|
|
68
78
|
if (fMatch && !inField && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
79
|
+
// The selector is a CONFIG option, not a constant. An embedder that
|
|
80
|
+
// moves its sections out of the top bar — into an icon rail, say —
|
|
81
|
+
// would otherwise find F1..F8 silently doing nothing: the query
|
|
82
|
+
// returns an empty NodeList, `idx < btns.length` is false, and the
|
|
83
|
+
// handler falls through without a hint that anything is bound.
|
|
84
|
+
// Passing `navSelector` is how such an embedder keeps the keys.
|
|
69
85
|
const btns = document.querySelectorAll(
|
|
70
|
-
|
|
86
|
+
opts.navSelector
|
|
87
|
+
|| '.twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn');
|
|
71
88
|
const idx = Number(fMatch[1]) - 1;
|
|
72
89
|
if (idx < btns.length) {
|
|
73
90
|
e.preventDefault();
|
|
@@ -152,5 +169,8 @@ export function installKeymap({ wm, palette }) {
|
|
|
152
169
|
if (key.length === 1 && /[a-z]/.test(key) && !inField) {
|
|
153
170
|
e.preventDefault();
|
|
154
171
|
}
|
|
155
|
-
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
document.addEventListener('keydown', onKeyDown);
|
|
175
|
+
return () => document.removeEventListener('keydown', onKeyDown);
|
|
156
176
|
}
|