flexdesk 0.17.15 → 0.17.17
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/CHANGELOG.md +42 -0
- package/css/base.css +236 -5
- package/css/flexdesk.css +326 -13
- package/css/tokens.css +3 -0
- package/dist/charts.js +104 -34
- package/dist/charts.js.map +3 -3
- package/dist/{chunk-2PM6ZMNS.js → chunk-2Z2ENHR3.js} +2 -2
- package/dist/{chunk-AD7FIA3Z.js → chunk-7JI7HHSY.js} +2 -2
- package/dist/{chunk-TLIV6QSU.js → chunk-7OL6F2AM.js} +3 -3
- package/dist/{chunk-MADMGRX2.js → chunk-PGZ4EHSE.js} +156 -9
- package/dist/chunk-PGZ4EHSE.js.map +7 -0
- package/dist/{chunk-OEG6NPAN.js → chunk-XJNADRGR.js} +2 -2
- package/dist/flexdesk.css +326 -13
- package/dist/flow.js +3 -3
- package/dist/tiles.js +84 -9
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +3 -0
- package/dist/widgets.js +4559 -4091
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +212 -40
- package/dist/wm.js.map +2 -2
- package/docs/README.md +1 -1
- package/docs/components/charts.md +70 -3
- package/docs/components/forms-and-dialogs.md +61 -0
- package/docs/components/menus.md +85 -1
- package/docs/components/notifications.md +23 -1
- package/docs/components/panels-and-desktops.md +22 -0
- package/docs/components/shell.md +91 -0
- package/docs/components/tiles.md +37 -0
- package/package.json +1 -1
- package/src/charts/plot_config_editor.js +125 -29
- package/src/tiles/tile_base.js +48 -2
- package/src/tiles/tile_grid.js +42 -1
- package/src/tiling/kind_taxonomy.js +66 -13
- package/src/tiling/shell.js +4 -1
- package/src/tiling/wm.js +205 -35
- package/src/ui/components/category_picker.js +556 -0
- package/src/ui/components/computing_status_window.js +3 -0
- package/src/ui/components/confirm_dialog.js +3 -0
- package/src/ui/components/managed_window.js +204 -9
- package/src/ui/components/notification_history.js +11 -1
- package/src/ui/components/wizard.js +4 -1
- package/src/ui/notification_center.js +47 -1
- package/widgets.js +2 -1
- package/dist/chunk-MADMGRX2.js.map +0 -7
- /package/dist/{chunk-2PM6ZMNS.js.map → chunk-2Z2ENHR3.js.map} +0 -0
- /package/dist/{chunk-AD7FIA3Z.js.map → chunk-7JI7HHSY.js.map} +0 -0
- /package/dist/{chunk-TLIV6QSU.js.map → chunk-7OL6F2AM.js.map} +0 -0
- /package/dist/{chunk-OEG6NPAN.js.map → chunk-XJNADRGR.js.map} +0 -0
|
@@ -24,6 +24,16 @@ import { getSetting } from '../../core/settings.js';
|
|
|
24
24
|
* `ManagedWindow.configure({ storageKey })` before the first window opens. */
|
|
25
25
|
export const DEFAULT_WINDOW_STORAGE_KEY = 'flexdesk.managedWindows.v1';
|
|
26
26
|
let _storageKey = DEFAULT_WINDOW_STORAGE_KEY;
|
|
27
|
+
/** BUG-0053. How many window ids keep saved geometry. Every save stamps its
|
|
28
|
+
* entry with a use counter; past the cap the least recently saved ids are
|
|
29
|
+
* dropped, so ids minted per window (the window manager's `twm-mw-<time>-…`)
|
|
30
|
+
* no longer grow the entry for ever. */
|
|
31
|
+
export const DEFAULT_MAX_SAVED_WINDOWS = 50;
|
|
32
|
+
/** The prefix of the ids the window manager mints per window
|
|
33
|
+
* (`twm-mw-<time>-<rand>`). Such an id never opens again, so geometry saved
|
|
34
|
+
* under it can never be restored: entries under it are purged on load. */
|
|
35
|
+
const MINTED_ID_PREFIX = 'twm-mw-';
|
|
36
|
+
let _maxSavedWindows = DEFAULT_MAX_SAVED_WINDOWS;
|
|
27
37
|
const BASE_Z_INDEX = 6000;
|
|
28
38
|
const MAX_Z_INDEX = 6999;
|
|
29
39
|
|
|
@@ -55,6 +65,10 @@ const DEFAULT_ICON = 'web_asset';
|
|
|
55
65
|
// Global state for z-order management
|
|
56
66
|
let _zIndexCounter = 0;
|
|
57
67
|
let _activeWindows = new Map(); // id -> ManagedWindow instance
|
|
68
|
+
/** BUG-0053. Esc events a window has already answered. Every visible window
|
|
69
|
+
* listens on the document, and closing one can hand focus to another before
|
|
70
|
+
* the same event reaches it — one Esc must close one window. */
|
|
71
|
+
const _escAnswered = new WeakSet();
|
|
58
72
|
|
|
59
73
|
/** Collect the focusable elements inside `root`, in DOM order. Used
|
|
60
74
|
* by the modal focus trap so Tab cycles within the modal frame and
|
|
@@ -86,7 +100,14 @@ function _loadState() {
|
|
|
86
100
|
if (_stateCache) return _stateCache;
|
|
87
101
|
try {
|
|
88
102
|
const raw = localStorage.getItem(_storageKey);
|
|
89
|
-
|
|
103
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
104
|
+
_stateCache = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
105
|
+
// BUG-0053. Before 0.17.16 every window-manager window saved an entry
|
|
106
|
+
// under its minted id; they are junk, and leaving them in would let
|
|
107
|
+
// them crowd stable ids out of the cap.
|
|
108
|
+
for (const k of Object.keys(_stateCache)) {
|
|
109
|
+
if (k.startsWith(MINTED_ID_PREFIX)) delete _stateCache[k];
|
|
110
|
+
}
|
|
90
111
|
} catch {
|
|
91
112
|
_stateCache = {};
|
|
92
113
|
}
|
|
@@ -109,10 +130,68 @@ function _getWindowState(id) {
|
|
|
109
130
|
|
|
110
131
|
function _setWindowState(id, windowState) {
|
|
111
132
|
const state = _loadState();
|
|
112
|
-
|
|
133
|
+
// `u` orders the entries by last save. A plain object's key order cannot
|
|
134
|
+
// carry that (integer-like ids sort first). An entry written before the
|
|
135
|
+
// stamp existed is a stable id the user has used (minted ids are purged
|
|
136
|
+
// on load), so it is stamped here, in the order it was stored, AHEAD of
|
|
137
|
+
// nothing newer: it ranks as older than any entry saved since, and the
|
|
138
|
+
// cap reaches it only after the minted junk is gone and real ids crowd it.
|
|
139
|
+
let latest = 0;
|
|
140
|
+
for (const k of Object.keys(state)) latest = Math.max(latest, Number(state[k]?.u) || 0);
|
|
141
|
+
for (const k of Object.keys(state)) {
|
|
142
|
+
if (!(Number(state[k]?.u) > 0) && state[k] && typeof state[k] === 'object') {
|
|
143
|
+
state[k] = { ...state[k], u: ++latest };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
state[id] = { ...windowState, u: latest + 1 };
|
|
147
|
+
const ids = Object.keys(state);
|
|
148
|
+
if (ids.length > _maxSavedWindows) {
|
|
149
|
+
ids.sort((a, b) => (Number(state[a]?.u) || 0) - (Number(state[b]?.u) || 0));
|
|
150
|
+
for (const k of ids.slice(0, ids.length - _maxSavedWindows)) delete state[k];
|
|
151
|
+
}
|
|
113
152
|
_saveState(state);
|
|
114
153
|
}
|
|
115
154
|
|
|
155
|
+
/** BUG-0053. The first element of `root` that actually takes focus. Tried in
|
|
156
|
+
* DOM order rather than filtered by layout, because an element that is
|
|
157
|
+
* `display: none` or `visibility: hidden` refuses `focus()` in a browser —
|
|
158
|
+
* so asking the element is exact where measuring it would guess. */
|
|
159
|
+
function _focusFirstIn(root) {
|
|
160
|
+
if (!root) return false;
|
|
161
|
+
const sel = [
|
|
162
|
+
'a[href]',
|
|
163
|
+
'button:not([disabled])',
|
|
164
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
165
|
+
'select:not([disabled])',
|
|
166
|
+
'textarea:not([disabled])',
|
|
167
|
+
'[contenteditable="true"]',
|
|
168
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
169
|
+
].join(',');
|
|
170
|
+
for (const el of root.querySelectorAll(sel)) {
|
|
171
|
+
if (el.hidden || el.closest('[hidden]')) continue;
|
|
172
|
+
try { el.focus({ preventScroll: true }); } catch { continue; }
|
|
173
|
+
if (el.ownerDocument.activeElement === el) return true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** BUG-0053. Which window an Esc closes, or null.
|
|
179
|
+
*
|
|
180
|
+
* - A modal on top answers wherever focus is, as it always has.
|
|
181
|
+
* - Otherwise the window holding focus answers, modal or not.
|
|
182
|
+
* - Otherwise (focus in no window) the topmost modal answers.
|
|
183
|
+
* A non-modal window that does not hold focus never answers: an Esc meant
|
|
184
|
+
* for an editor beside it is not its. */
|
|
185
|
+
function _escAnswerer() {
|
|
186
|
+
const visible = Array.from(_activeWindows.values())
|
|
187
|
+
.filter((w) => w.isVisible && !w.isMinimized && w.element)
|
|
188
|
+
.sort((a, b) => b.zIndex - a.zIndex);
|
|
189
|
+
if (visible[0]?.modal) return visible[0];
|
|
190
|
+
const active = document.activeElement;
|
|
191
|
+
const holder = active ? visible.find((w) => w.element.contains(active)) : null;
|
|
192
|
+
return holder || visible.find((w) => w.modal) || null;
|
|
193
|
+
}
|
|
194
|
+
|
|
116
195
|
export class ManagedWindow {
|
|
117
196
|
/**
|
|
118
197
|
* @param {Object} options
|
|
@@ -150,6 +229,15 @@ export class ManagedWindow {
|
|
|
150
229
|
* away.
|
|
151
230
|
* @param {Array} [options.titlebarButtons] - Extra buttons left of
|
|
152
231
|
* minimize: `{icon, title, onClick}`.
|
|
232
|
+
* @param {boolean} [options.persistGeometry=true] - Save and restore this
|
|
233
|
+
* window's position and size under its id. Pass false for a window whose
|
|
234
|
+
* id is minted per opening, so it never writes an entry.
|
|
235
|
+
* @param {boolean|'window'} [options.focusOnOpen=true] - When the window
|
|
236
|
+
* opens, move focus to the first focusable element of its content (the
|
|
237
|
+
* window itself when it has none; always the window itself with
|
|
238
|
+
* `'window'`), and on close give focus back to the element that had it
|
|
239
|
+
* before, if that element is still in the document and focus is still in
|
|
240
|
+
* this window or nowhere.
|
|
153
241
|
*/
|
|
154
242
|
constructor(options) {
|
|
155
243
|
this.id = options.id;
|
|
@@ -274,6 +362,13 @@ export class ManagedWindow {
|
|
|
274
362
|
this._restoreTimer = null;
|
|
275
363
|
this.titlebarButtons = Array.isArray(options.titlebarButtons)
|
|
276
364
|
? options.titlebarButtons : [];
|
|
365
|
+
/** BUG-0053. See the constructor docs. */
|
|
366
|
+
this.persistGeometry = options.persistGeometry ?? true;
|
|
367
|
+
this.focusOnOpen = options.focusOnOpen ?? true;
|
|
368
|
+
/** The element that had focus when the window opened. */
|
|
369
|
+
this._returnFocusTo = null;
|
|
370
|
+
/** True once the window has closed: a closed window is spent. */
|
|
371
|
+
this._closed = false;
|
|
277
372
|
|
|
278
373
|
this.element = null;
|
|
279
374
|
this.backdropElement = null;
|
|
@@ -310,6 +405,12 @@ export class ManagedWindow {
|
|
|
310
405
|
* Show the window.
|
|
311
406
|
*/
|
|
312
407
|
show() {
|
|
408
|
+
if (this._closed) {
|
|
409
|
+
// BUG-0053. Close releases the content and every callback, so
|
|
410
|
+
// there is nothing left to show. Build a new window for the id.
|
|
411
|
+
console.warn(`[ManagedWindow] show() on closed window "${this.id}"; build a new ManagedWindow to reopen it`);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
313
414
|
if (this.isVisible && !this.isMinimized) {
|
|
314
415
|
// C27. `show()` IS THE REPAIR PATH, so it repairs. A window that is
|
|
315
416
|
// visible by its own flags and hidden by an inline `display: none`
|
|
@@ -325,6 +426,7 @@ export class ManagedWindow {
|
|
|
325
426
|
return;
|
|
326
427
|
}
|
|
327
428
|
|
|
429
|
+
let opened = false;
|
|
328
430
|
if (!this.element) {
|
|
329
431
|
this._build();
|
|
330
432
|
this._restoreState();
|
|
@@ -352,10 +454,38 @@ export class ManagedWindow {
|
|
|
352
454
|
}
|
|
353
455
|
this._installContainerResizeObserver();
|
|
354
456
|
this.isVisible = true;
|
|
457
|
+
opened = true;
|
|
355
458
|
}
|
|
356
459
|
|
|
357
460
|
this.bringToFront();
|
|
358
461
|
document.addEventListener('keydown', this._boundOnKeyDown);
|
|
462
|
+
if (opened && this.focusOnOpen) this._moveFocusIn();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** BUG-0053. Focus into the content on open, remembering where it was. */
|
|
466
|
+
_moveFocusIn() {
|
|
467
|
+
const active = document.activeElement;
|
|
468
|
+
this._returnFocusTo = active && active !== document.body
|
|
469
|
+
&& !this.element.contains(active) ? active : null;
|
|
470
|
+
if (this.focusOnOpen !== 'window' && _focusFirstIn(this.contentContainer)) return;
|
|
471
|
+
// Nothing focusable in the content (or the caller asked for the
|
|
472
|
+
// window): the window takes focus itself, so Esc (which a non-modal
|
|
473
|
+
// window answers only with focus inside) and Tab both start from here.
|
|
474
|
+
try { this.element.focus({ preventScroll: true }); } catch { /* detached */ }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** BUG-0053. Give focus back on close — only when it is still in this
|
|
478
|
+
* window (or nowhere): a window closed while the user works elsewhere
|
|
479
|
+
* must not pull focus away from where they are. */
|
|
480
|
+
_returnFocus() {
|
|
481
|
+
const back = this._returnFocusTo;
|
|
482
|
+
this._returnFocusTo = null;
|
|
483
|
+
if (!back || !back.isConnected) return;
|
|
484
|
+
const active = document.activeElement;
|
|
485
|
+
const held = !active || active === document.body
|
|
486
|
+
|| (this.element && this.element.contains(active));
|
|
487
|
+
if (!held) return;
|
|
488
|
+
try { back.focus({ preventScroll: true }); } catch { /* gone */ }
|
|
359
489
|
}
|
|
360
490
|
|
|
361
491
|
/**
|
|
@@ -389,6 +519,10 @@ export class ManagedWindow {
|
|
|
389
519
|
this.onClose();
|
|
390
520
|
}
|
|
391
521
|
|
|
522
|
+
// BUG-0053. Before the element leaves the document, while focus can
|
|
523
|
+
// still be seen to be inside it.
|
|
524
|
+
if (this.focusOnOpen) this._returnFocus();
|
|
525
|
+
|
|
392
526
|
if (this.element && this.element.parentNode) {
|
|
393
527
|
this.element.parentNode.removeChild(this.element);
|
|
394
528
|
}
|
|
@@ -417,8 +551,44 @@ export class ManagedWindow {
|
|
|
417
551
|
window.dispatchEvent(new CustomEvent('managed-window-closed', {
|
|
418
552
|
detail: { id: this.id, container: this.container }
|
|
419
553
|
}));
|
|
554
|
+
this._release();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** BUG-0053. A closed window leaves the registry and lets go of what it
|
|
558
|
+
* held: its content, its DOM and every consumer callback (an `onClose`
|
|
559
|
+
* typically captures its owner). Until now the registry kept all of it
|
|
560
|
+
* alive until the same id opened again, and `get(id)` answered with the
|
|
561
|
+
* dead window. */
|
|
562
|
+
_release() {
|
|
563
|
+
if (_activeWindows.get(this.id) === this) _activeWindows.delete(this.id);
|
|
564
|
+
this._closed = true;
|
|
565
|
+
// A close in the middle of a drag (a snap controller docking the
|
|
566
|
+
// window from its own `commit`) still finishes in `_onPointerUp`,
|
|
567
|
+
// which removes these; a programmatic close mid-drag has no pointer-up
|
|
568
|
+
// coming that is its business.
|
|
569
|
+
if (!this._dragState && !this._resizeState) {
|
|
570
|
+
document.removeEventListener('pointermove', this._boundOnPointerMove);
|
|
571
|
+
document.removeEventListener('pointerup', this._boundOnPointerUp);
|
|
572
|
+
document.removeEventListener('pointercancel', this._boundOnPointerUp);
|
|
573
|
+
}
|
|
574
|
+
this.content = null;
|
|
575
|
+
this.element = null;
|
|
576
|
+
this.contentContainer = null;
|
|
577
|
+
this.backdropElement = null;
|
|
578
|
+
this.onClose = null;
|
|
579
|
+
this.beforeClose = null;
|
|
580
|
+
this.onMinimize = null;
|
|
581
|
+
this.onMaximize = null;
|
|
582
|
+
this.snapController = null;
|
|
583
|
+
this.dragHost = null;
|
|
584
|
+
this.dragBounds = null;
|
|
585
|
+
this.titlebarButtons = [];
|
|
420
586
|
}
|
|
421
587
|
|
|
588
|
+
/** True once the window has closed. A closed window cannot be shown
|
|
589
|
+
* again; `ManagedWindow.get(id)` no longer returns it. */
|
|
590
|
+
get isClosed() { return this._closed; }
|
|
591
|
+
|
|
422
592
|
/**
|
|
423
593
|
* Minimize to taskbar with animation.
|
|
424
594
|
*/
|
|
@@ -794,6 +964,11 @@ export class ManagedWindow {
|
|
|
794
964
|
this.element.classList.add('twm-managed-window--modal');
|
|
795
965
|
}
|
|
796
966
|
this.element.setAttribute('data-window-id', this.id);
|
|
967
|
+
// BUG-0053. Focusable by script and by a press, never by Tab (the
|
|
968
|
+
// modal trap's `_collectFocusable` skips tabindex -1): a press on the
|
|
969
|
+
// title bar or on empty content keeps focus in the window, where a
|
|
970
|
+
// non-modal window's Esc looks for it.
|
|
971
|
+
this.element.tabIndex = -1;
|
|
797
972
|
|
|
798
973
|
// Top bar
|
|
799
974
|
const topbar = document.createElement('div');
|
|
@@ -933,7 +1108,16 @@ export class ManagedWindow {
|
|
|
933
1108
|
// something, which is the whole complaint it was reported under.
|
|
934
1109
|
topbar.addEventListener('dblclick', () => this.toggleMaximize());
|
|
935
1110
|
}
|
|
936
|
-
this.element.addEventListener('pointerdown', () =>
|
|
1111
|
+
this.element.addEventListener('pointerdown', () => {
|
|
1112
|
+
this.bringToFront();
|
|
1113
|
+
// The title bar's pointerdown is default-prevented (it starts a
|
|
1114
|
+
// drag), which also stops the browser moving focus. A press inside
|
|
1115
|
+
// a window that does not hold focus gives it focus; a press on a
|
|
1116
|
+
// field then moves it on to the field as usual.
|
|
1117
|
+
if (this.element && !this.element.contains(document.activeElement)) {
|
|
1118
|
+
try { this.element.focus({ preventScroll: true }); } catch { /* detached */ }
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
937
1121
|
}
|
|
938
1122
|
|
|
939
1123
|
_addResizeHandles() {
|
|
@@ -1263,7 +1447,7 @@ export class ManagedWindow {
|
|
|
1263
1447
|
const maxHeight = bounds.height;
|
|
1264
1448
|
|
|
1265
1449
|
// Modal windows always center on screen - never restore saved position
|
|
1266
|
-
const saved = this.modal ? null : _getWindowState(this.id);
|
|
1450
|
+
const saved = this.modal || !this.persistGeometry ? null : _getWindowState(this.id);
|
|
1267
1451
|
if (saved) {
|
|
1268
1452
|
this.x = saved.x ?? this.x;
|
|
1269
1453
|
this.y = saved.y ?? this.y;
|
|
@@ -1289,7 +1473,7 @@ export class ManagedWindow {
|
|
|
1289
1473
|
|
|
1290
1474
|
_saveCurrentState() {
|
|
1291
1475
|
// Modal windows always center - no need to persist position
|
|
1292
|
-
if (this.modal) return;
|
|
1476
|
+
if (this.modal || !this.persistGeometry || this._closed) return;
|
|
1293
1477
|
|
|
1294
1478
|
_setWindowState(this.id, {
|
|
1295
1479
|
x: this._preMaximizeState?.x ?? this.x,
|
|
@@ -1647,12 +1831,19 @@ export class ManagedWindow {
|
|
|
1647
1831
|
const topWindow = Array.from(_activeWindows.values())
|
|
1648
1832
|
.filter(w => w.isVisible && !w.isMinimized)
|
|
1649
1833
|
.sort((a, b) => b.zIndex - a.zIndex)[0];
|
|
1650
|
-
if (topWindow !== this) return;
|
|
1651
|
-
|
|
1652
1834
|
if (e.key === 'Escape') {
|
|
1653
|
-
|
|
1835
|
+
// BUG-0053. An Esc something inside already answered (an
|
|
1836
|
+
// autocomplete closing its list) or one that ends an IME
|
|
1837
|
+
// composition is not the window's.
|
|
1838
|
+
if (e.defaultPrevented || e.isComposing || _escAnswered.has(e)) return;
|
|
1839
|
+
if (_escAnswerer() === this) {
|
|
1840
|
+
_escAnswered.add(e);
|
|
1841
|
+
this.close();
|
|
1842
|
+
}
|
|
1654
1843
|
return;
|
|
1655
1844
|
}
|
|
1845
|
+
if (topWindow !== this) return;
|
|
1846
|
+
|
|
1656
1847
|
// Focus trap for modals: Tab and Shift+Tab cycle through the
|
|
1657
1848
|
// focusable elements inside the modal's frame instead of
|
|
1658
1849
|
// escaping to underlying tiles. Without this, Tab moves the
|
|
@@ -1691,9 +1882,13 @@ export class ManagedWindow {
|
|
|
1691
1882
|
*
|
|
1692
1883
|
* @param {object} [opts]
|
|
1693
1884
|
* @param {string} [opts.storageKey=DEFAULT_WINDOW_STORAGE_KEY]
|
|
1885
|
+
* @param {number} [opts.maxSavedWindows=DEFAULT_MAX_SAVED_WINDOWS] How many
|
|
1886
|
+
* ids keep saved geometry; the least recently saved go first.
|
|
1694
1887
|
*/
|
|
1695
|
-
static configure({ storageKey = DEFAULT_WINDOW_STORAGE_KEY
|
|
1888
|
+
static configure({ storageKey = DEFAULT_WINDOW_STORAGE_KEY,
|
|
1889
|
+
maxSavedWindows = DEFAULT_MAX_SAVED_WINDOWS } = {}) {
|
|
1696
1890
|
_storageKey = storageKey;
|
|
1891
|
+
_maxSavedWindows = Math.max(1, Math.floor(maxSavedWindows) || DEFAULT_MAX_SAVED_WINDOWS);
|
|
1697
1892
|
_stateCache = null;
|
|
1698
1893
|
}
|
|
1699
1894
|
|
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
* alertsTab the tab a click asks for, `bottompanel:show { tab }`; 'alerts'.
|
|
22
22
|
* onOpen a click, instead of the bus event — for an embedder with no bus,
|
|
23
23
|
* or whose panel must be opened before a tab can be chosen.
|
|
24
|
+
*
|
|
25
|
+
* A right-click on an entry opens the context menu with "Copy message"
|
|
26
|
+
* (STORY-0131), the same as on its toast; the popover stays open meanwhile.
|
|
24
27
|
*/
|
|
28
|
+
import { showNotificationMenu } from '../notification_center.js';
|
|
25
29
|
|
|
26
30
|
export class NotificationHistory {
|
|
27
31
|
constructor({ notificationCenter, eventBus, logger, mountPoint = null, alertLog = null,
|
|
@@ -209,10 +213,15 @@ export class NotificationHistory {
|
|
|
209
213
|
};
|
|
210
214
|
this._popoverCleanup = cleanup;
|
|
211
215
|
|
|
216
|
+
// The entries' context menu is outside the panel, and using it is not leaving it.
|
|
212
217
|
const onDocDown = (ev) => {
|
|
218
|
+
if (ev.target.closest?.('.twm-context-menu')) return;
|
|
213
219
|
if (!panel.contains(ev.target) && !this.triggerEl.contains(ev.target)) cleanup();
|
|
214
220
|
};
|
|
215
|
-
|
|
221
|
+
// Escape closes the innermost layer: an open entry menu closes itself.
|
|
222
|
+
const onKey = (ev) => {
|
|
223
|
+
if (ev.key === 'Escape' && !document.querySelector('.twm-context-menu')) cleanup();
|
|
224
|
+
};
|
|
216
225
|
const onResize = () => cleanup();
|
|
217
226
|
|
|
218
227
|
document.addEventListener('mousedown', onDocDown, true);
|
|
@@ -261,6 +270,7 @@ export class NotificationHistory {
|
|
|
261
270
|
body.className = 'twm-notification-history__item-body';
|
|
262
271
|
body.textContent = entry.message || '';
|
|
263
272
|
item.appendChild(body);
|
|
273
|
+
item.addEventListener('contextmenu', (event) => showNotificationMenu(event, entry, this.logger));
|
|
264
274
|
|
|
265
275
|
fragment.appendChild(item);
|
|
266
276
|
}
|
|
@@ -233,9 +233,12 @@ export class Wizard extends ComponentBase {
|
|
|
233
233
|
return false;
|
|
234
234
|
},
|
|
235
235
|
});
|
|
236
|
-
|
|
236
|
+
// Rendered and painted BEFORE show, so the window's focus-on-open
|
|
237
|
+
// (BUG-0053) lands on the step's first field — the stepper's buttons
|
|
238
|
+
// are disabled by then — and not on the first step button.
|
|
237
239
|
this.#renderBody();
|
|
238
240
|
this.#paint();
|
|
241
|
+
this.#window.show();
|
|
239
242
|
return this.#window;
|
|
240
243
|
}
|
|
241
244
|
|
|
@@ -35,11 +35,20 @@
|
|
|
35
35
|
* `log:entry`). A toast that leaves marks its own entry `dismissed` with a
|
|
36
36
|
* `reason`; it adds none. `subscribe(fn)` calls `fn(entry, center)` for each
|
|
37
37
|
* entry added, so a history view follows the centre rather than the bus.
|
|
38
|
+
*
|
|
39
|
+
* Copy message (STORY-0131)
|
|
40
|
+
* -------------------------
|
|
41
|
+
* A right-click on a toast, or on its entry in the history, opens the context
|
|
42
|
+
* menu with "Copy message": the title, the message and the description, one
|
|
43
|
+
* per line, go to the clipboard as plain text. The text is also selectable:
|
|
44
|
+
* a click on the title, message, description or status, or one that ends a
|
|
45
|
+
* selection, does not close the toast; only a click on its chrome does.
|
|
38
46
|
*/
|
|
39
47
|
import { ComponentBase } from './base/component_base.js';
|
|
40
48
|
import { getSetting } from '../core/settings.js';
|
|
41
49
|
import { setToastBus, getToastBus } from './components/toast.js';
|
|
42
50
|
import { ProgressBar } from './components/progress.js';
|
|
51
|
+
import { showContextMenu } from './components/context_menu.js';
|
|
43
52
|
|
|
44
53
|
const SEVERITIES = ['info', 'success', 'warn', 'error', 'fatal'];
|
|
45
54
|
|
|
@@ -60,6 +69,39 @@ const DEFAULT_LOG_LEVELS = new Set(['warn', 'error', 'fatal']);
|
|
|
60
69
|
// Messages from these namespaces are self-descriptive and need no category header.
|
|
61
70
|
const SUPPRESSED_NAMESPACE_TITLES = new Set(['bootstrap', 'app']);
|
|
62
71
|
|
|
72
|
+
/** A notification as plain text: its title, message and description, one per line. */
|
|
73
|
+
export function notificationText(notification) {
|
|
74
|
+
return [notification.title, notification.message, notification.description]
|
|
75
|
+
.filter((part) => part != null && String(part) !== '')
|
|
76
|
+
.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The right-click menu of a toast or a history entry: "Copy message". It takes
|
|
81
|
+
* the right-click, so the browser's own menu does not open.
|
|
82
|
+
*/
|
|
83
|
+
export function showNotificationMenu(event, notification, logger) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
event.stopPropagation();
|
|
86
|
+
showContextMenu(event.clientX, event.clientY,
|
|
87
|
+
[{ label: 'Copy message', icon: 'content_copy', action: 'copy' }],
|
|
88
|
+
() => {
|
|
89
|
+
Promise.resolve()
|
|
90
|
+
.then(() => navigator.clipboard.writeText(notificationText(notification)))
|
|
91
|
+
.catch((err) => logger?.warn?.('twm-notification-center', 'Copy message failed', { err }));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The toast's text: clicking it selects, it does not dismiss. */
|
|
96
|
+
const TOAST_TEXT = '.twm-notification__title, .twm-notification__body, '
|
|
97
|
+
+ '.twm-notification__description, .twm-notification__status';
|
|
98
|
+
|
|
99
|
+
/** A non-empty text selection inside `el`: a click that ends one is not a click on it. */
|
|
100
|
+
function selectsTextIn(el) {
|
|
101
|
+
const sel = el.ownerDocument.getSelection?.();
|
|
102
|
+
return Boolean(sel && !sel.isCollapsed && sel.rangeCount && el.contains(sel.getRangeAt(0).commonAncestorContainer));
|
|
103
|
+
}
|
|
104
|
+
|
|
63
105
|
export class NotificationCenter extends ComponentBase {
|
|
64
106
|
#items;
|
|
65
107
|
#summaryEl;
|
|
@@ -752,12 +794,16 @@ export class NotificationCenter extends ComponentBase {
|
|
|
752
794
|
item.appendChild(actionsEl);
|
|
753
795
|
}
|
|
754
796
|
|
|
797
|
+
// A click on the text is the start of selecting it (a double-click's
|
|
798
|
+
// first click leaves only a caret), so only the toast's chrome closes it.
|
|
755
799
|
item.addEventListener('click', (event) => {
|
|
756
|
-
if (event.target.closest(
|
|
800
|
+
if (event.target.closest(`button, ${TOAST_TEXT}`) || NotificationCenter.#isRunning(notification)
|
|
801
|
+
|| selectsTextIn(item)) {
|
|
757
802
|
return;
|
|
758
803
|
}
|
|
759
804
|
this.dismiss(notification.id, 'click');
|
|
760
805
|
});
|
|
806
|
+
item.addEventListener('contextmenu', (event) => showNotificationMenu(event, notification, this.logger));
|
|
761
807
|
|
|
762
808
|
this.#patchItem(entry, notification);
|
|
763
809
|
return entry;
|
package/widgets.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* GENERATED by scripts/gen_barrels.py. Do not hand-edit.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* 60 modules.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export * from './src/ui/base/component_base.js';
|
|
@@ -16,6 +16,7 @@ export * from './src/ui/components/alerts_pane.js';
|
|
|
16
16
|
export * from './src/ui/components/action_dropdown.js';
|
|
17
17
|
export * from './src/ui/components/attribute_list_editor.js';
|
|
18
18
|
export * from './src/ui/components/autocomplete_field.js';
|
|
19
|
+
export * from './src/ui/components/category_picker.js';
|
|
19
20
|
export * from './src/ui/components/cell_editor_host.js';
|
|
20
21
|
export * from './src/ui/components/cell_editors.js';
|
|
21
22
|
export * from './src/ui/components/cell_selection.js';
|