flexdesk 0.17.16 → 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 +19 -0
- package/css/base.css +12 -1
- package/css/flexdesk.css +12 -1
- package/css/tokens.css +3 -0
- package/dist/flexdesk.css +12 -1
- package/dist/tokens.css +3 -0
- package/dist/widgets.js +4113 -4088
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +114 -30
- package/dist/wm.js.map +2 -2
- package/docs/components/notifications.md +23 -1
- package/docs/components/panels-and-desktops.md +22 -0
- package/docs/components/shell.md +54 -0
- package/package.json +1 -1
- package/src/tiling/kind_taxonomy.js +21 -13
- package/src/tiling/shell.js +4 -1
- package/src/tiling/wm.js +123 -28
- package/src/ui/components/notification_history.js +11 -1
- package/src/ui/notification_center.js +47 -1
|
@@ -120,7 +120,8 @@ background, a left border in the severity's colour (`--color-success`,
|
|
|
120
120
|
`--color-warning`, `--color-danger`; info keeps the blue) and the entry
|
|
121
121
|
animation `twm-notification-in`, which the sheet now defines. The card sets
|
|
122
122
|
`pointer-events: auto` over the root's `none`, so its buttons and its
|
|
123
|
-
click-to-close work.
|
|
123
|
+
click-to-close work. Since [Copy message](#copy-message), click-to-close takes
|
|
124
|
+
only a click on the card outside its text.
|
|
124
125
|
|
|
125
126
|
Until 0.17.2 the card carried the bare `notification` / `notification--<severity>`
|
|
126
127
|
classes, which no FlexDesk sheet styled: the card had no background and no
|
|
@@ -156,6 +157,27 @@ Until 0.17.3 the bell counted only on bus events, so a toast raised through
|
|
|
156
157
|
was pushed as a second entry: a toast that timed out was in `history` twice
|
|
157
158
|
and counted twice. `tests/notification_history_follows_center.test.mjs` holds it.
|
|
158
159
|
|
|
160
|
+
### Copy message
|
|
161
|
+
|
|
162
|
+
Right-clicking a toast, or its entry in the `NotificationHistory` popover, opens
|
|
163
|
+
the context menu (`showContextMenu`) with **Copy message**. It copies the
|
|
164
|
+
notification's title, message and description, one per line and skipping any
|
|
165
|
+
that is empty, as plain text through `navigator.clipboard.writeText`. The menu
|
|
166
|
+
replaces the browser's native one there. The history popover stays open while
|
|
167
|
+
its menu is used. Every context menu draws on `--z-context-menu` (tokens.css),
|
|
168
|
+
over toasts (`--z-toast`) and managed windows, so the menu opens over the toast
|
|
169
|
+
that was right-clicked.
|
|
170
|
+
|
|
171
|
+
The text of a toast and of a history entry (title, message, description,
|
|
172
|
+
status) is selectable: the sheets give it `user-select: text` over the body's
|
|
173
|
+
`none`. A click or double-click on a toast's text (title, message,
|
|
174
|
+
description, status) selects it and does not close the toast, nor does a click
|
|
175
|
+
that ends a selection inside it. Only a click on the card outside the text (the
|
|
176
|
+
header's empty space, the padding, the timestamp) closes it, besides the ×
|
|
177
|
+
button. `notificationText(notification)` returns the copied
|
|
178
|
+
text, and `showNotificationMenu(event, notification, logger)` opens the menu.
|
|
179
|
+
Both are exported from `widgets`. `tests/notification_copy.test.mjs` covers it.
|
|
180
|
+
|
|
159
181
|
### Alerts with history: a log, a pane, a tab host and a bell (0.14.0)
|
|
160
182
|
|
|
161
183
|
A toast is the moment. The **alert log** keeps what came in until it is
|
|
@@ -10,6 +10,7 @@ The shell's left, right and bottom panels, and its virtual desktops. A kind says
|
|
|
10
10
|
- [Panels keep the size you dragged them to (0.17.0)](#panels-keep-the-size-you-dragged-them-to-0170)
|
|
11
11
|
- [A navigation panel you drive yourself: `mountNavPanel` (0.17.0)](#a-navigation-panel-you-drive-yourself-mountnavpanel-0170)
|
|
12
12
|
- [Your desktops beside the app's (0.10.0)](#your-desktops-beside-the-apps-0100)
|
|
13
|
+
- [A page that opens on the desktop you are on (0.17.17)](#a-page-that-opens-on-the-desktop-you-are-on-01717)
|
|
13
14
|
- [A saved layout that cannot be read (0.17.14)](#a-saved-layout-that-cannot-be-read-01714)
|
|
14
15
|
|
|
15
16
|
## Panels a page does not offer (0.17.0)
|
|
@@ -127,6 +128,27 @@ main pane; it used to replace that pane with the active tab alone. A target stil
|
|
|
127
128
|
holding only its seed is replaced, as before. `dispose()` removes the bar, so a
|
|
128
129
|
rebuilt shell paints a fresh one.
|
|
129
130
|
|
|
131
|
+
## A page that opens on the desktop you are on (0.17.17)
|
|
132
|
+
|
|
133
|
+
A desktop is the tiles and panels arranged on it. A kind marked `single: true`
|
|
134
|
+
exists once in the app, so opening it from another desktop switches to the
|
|
135
|
+
desktop that has it. A kind marked `single: 'desktop'` exists once per desktop
|
|
136
|
+
instead: the top-nav button, `navigate` and `openInPrimary` reveal the
|
|
137
|
+
instance on the active desktop, or open one there when that desktop has none,
|
|
138
|
+
and never switch desktop.
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
kinds: {
|
|
142
|
+
model: { label: 'Model', isTopNav: true, single: 'desktop' },
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
On desktop 2, clicking *Model* opens the model on desktop 2 even while
|
|
147
|
+
desktop 1 shows its own; clicking it again reveals desktop 2's. Each desktop's
|
|
148
|
+
instance is saved with its layout and restored there. The details, and what
|
|
149
|
+
counts as a window on a desktop, are in
|
|
150
|
+
[Shell and navigation](shell.md#a-kind-that-exists-once-per-desktop-single-desktop-01717).
|
|
151
|
+
|
|
130
152
|
## A saved layout that cannot be read (0.17.14)
|
|
131
153
|
|
|
132
154
|
The shell saves its layout through the host's `state` under the key `desktops`
|
package/docs/components/shell.md
CHANGED
|
@@ -10,6 +10,7 @@ A complete first boot is in the [quickstart](../quickstart.md#hello-shell). This
|
|
|
10
10
|
|
|
11
11
|
- [Opt-in shell features](#opt-in-shell-features)
|
|
12
12
|
- [A kind that exists once: `single` (0.17.0)](#a-kind-that-exists-once-single-0170)
|
|
13
|
+
- [A kind that exists once per desktop: `single: 'desktop'` (0.17.17)](#a-kind-that-exists-once-per-desktop-single-desktop-01717)
|
|
13
14
|
- [An open single instance takes new props: `navigate(props)` (0.17.2)](#an-open-single-instance-takes-new-props-navigateprops-0172)
|
|
14
15
|
- [A link to a kind nobody registered (0.17.2)](#a-link-to-a-kind-nobody-registered-0172)
|
|
15
16
|
- [A pane that names what it shows: `ctx.setTitle` (0.12.1)](#a-pane-that-names-what-it-shows-ctxsettitle-0121)
|
|
@@ -74,6 +75,59 @@ with it. The taxonomy answers `isSingle(kind)`, `instanceKey(kind, props)` and
|
|
|
74
75
|
`singleReason(kind)`; the window manager answers `isSingleKind(kind)`,
|
|
75
76
|
`findInstance(kind, props)` and `revealInstance(kind, props)`.
|
|
76
77
|
|
|
78
|
+
## A kind that exists once per desktop: `single: 'desktop'` (0.17.17)
|
|
79
|
+
|
|
80
|
+
A virtual desktop is the tiles and panels the user arranged on it, so a page
|
|
81
|
+
the user opens from the top nav belongs on the desktop they are looking at,
|
|
82
|
+
not on another desktop that already shows it. Mark such a kind
|
|
83
|
+
`single: 'desktop'`: at most one instance per desktop.
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
createTaxonomy({
|
|
87
|
+
root: 'cosmos',
|
|
88
|
+
kinds: {
|
|
89
|
+
cosmos: { label: 'Cosmos', isTopNav: true },
|
|
90
|
+
chart: { label: 'Star chart', isTopNav: true, single: 'desktop' }, // one per desktop
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- **Every open path looks only at the active desktop** — `navigate`, the top
|
|
96
|
+
nav, `openInPrimary`, `openInTab*`, `splitLeafWith`, Back. When the desktop
|
|
97
|
+
has the instance (in a tile, in a top-nav page a tile switched away from, or
|
|
98
|
+
in a window on it) it is revealed there, as for `single: true`; when it has
|
|
99
|
+
none, one is opened there. The open never switches desktop, and the
|
|
100
|
+
instances on other desktops are left as they are.
|
|
101
|
+
- **A window is on the desktop it was opened on** or moved to. A window
|
|
102
|
+
contained to a pane (`promoteInPlace`) is shown on that desktop only; a
|
|
103
|
+
free-floating one (`navigate(kind, props, { dest: 'window' })`) is drawn over
|
|
104
|
+
every desktop but is still the instance of the one it was opened on, never
|
|
105
|
+
of another: on desktop 1, *Model* reveals desktop 1's own model even while
|
|
106
|
+
desktop 2's floats in front of it.
|
|
107
|
+
- **The copy verbs refuse** within the desktop, as for `single: true`.
|
|
108
|
+
- **The move verbs refuse** to carry the kind onto a desktop that already holds
|
|
109
|
+
it: *Move to desktop N* (Alt+Shift+N and the tile and window menus, whose row
|
|
110
|
+
is greyed with the reason), and dropping a floating window onto another
|
|
111
|
+
desktop's tile. `wm.desktopMoveRefusal({ leafId } | { windowId }, idx)`
|
|
112
|
+
answers that reason, or null. Removing a desktop brings its windows across
|
|
113
|
+
to the active desktop and never closes one, so that desktop can then hold
|
|
114
|
+
the kind twice, until a reload keeps the first.
|
|
115
|
+
- **Navigating inside a window looks on the window's own desktop**
|
|
116
|
+
(`openInWindow`, and the in-window navigation that goes through it): a
|
|
117
|
+
floating window of desktop 1 navigated to the kind while desktop 2 is on
|
|
118
|
+
screen reveals desktop 1's instance, and shows the kind itself when desktop
|
|
119
|
+
1 has none. The window stays on its desktop.
|
|
120
|
+
- **A page restore and a restored layout dedupe per desktop**: a tile's archived
|
|
121
|
+
page brings its instance back unless that desktop has it live elsewhere, and
|
|
122
|
+
a reload keeps the first instance on each desktop. Each desktop's instance
|
|
123
|
+
survives, live or archived.
|
|
124
|
+
|
|
125
|
+
`single: true` keeps its meaning: one instance in the app, and opening it
|
|
126
|
+
switches to the desktop it is on. Because `'desktop'` names this mode, it is
|
|
127
|
+
not read as a prop name. `findInstance(kind)` answers the active desktop's
|
|
128
|
+
instance; the taxonomy answers `isSinglePerDesktop(kind)`, and
|
|
129
|
+
`instanceKey(kind, props)` is the same for every props.
|
|
130
|
+
|
|
77
131
|
## An open single instance takes new props: `navigate(props)` (0.17.2)
|
|
78
132
|
|
|
79
133
|
Revealing a `single` instance used to drop the props the open asked for: the
|
package/package.json
CHANGED
|
@@ -53,15 +53,17 @@
|
|
|
53
53
|
* not inserted and their toggles are disabled, showing
|
|
54
54
|
* `reason`.
|
|
55
55
|
*
|
|
56
|
-
* single? `true`, or the name of a prop. The kind exists
|
|
57
|
-
* that holds heavy app-wide state and must not be
|
|
58
|
-
* twice. With `true` there is one instance of the kind;
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
56
|
+
* single? `true`, `'desktop'`, or the name of a prop. The kind exists
|
|
57
|
+
* ONCE: content that holds heavy app-wide state and must not be
|
|
58
|
+
* mounted twice. With `true` there is one instance of the kind;
|
|
59
|
+
* with `'desktop'` there is one per virtual desktop, found and
|
|
60
|
+
* opened on the active one (so `'desktop'` is not a prop name
|
|
61
|
+
* here); with a prop name there is one per value of that prop
|
|
62
|
+
* (`'id'`: one tile per record). The window manager makes every
|
|
63
|
+
* open path reveal the instance that exists, refuses the copy
|
|
64
|
+
* verbs with a reason, and keeps the first of any duplicates a
|
|
65
|
+
* restored layout holds. The root kind cannot be single: every
|
|
66
|
+
* new pane, tab and desktop is seeded with it.
|
|
65
67
|
*
|
|
66
68
|
* window? `{ width?, height? }` — the initial size, in px, of a managed
|
|
67
69
|
* window this kind opens in (`navigate(…, { dest: 'window' })`,
|
|
@@ -249,14 +251,19 @@ export function createTaxonomy({ kinds, root } = {}) {
|
|
|
249
251
|
return s === true || (typeof s === 'string' && s !== '');
|
|
250
252
|
};
|
|
251
253
|
|
|
254
|
+
/** Whether `kind` exists once per virtual desktop (`single: 'desktop'`)
|
|
255
|
+
* rather than once in the app. */
|
|
256
|
+
const isSinglePerDesktop = (kind) => _kinds[kind]?.single === 'desktop';
|
|
257
|
+
|
|
252
258
|
/** The identity of one instance of a single kind: two tab specs with the
|
|
253
|
-
* same key are the same instance
|
|
254
|
-
*
|
|
255
|
-
*
|
|
259
|
+
* same key are the same instance (on one desktop, for
|
|
260
|
+
* `single: 'desktop'`). `null` for a kind that is not single, which is
|
|
261
|
+
* how callers ask both questions at once. Keyed by the kind and, for
|
|
262
|
+
* `single: '<prop>'`, that prop's value as a string. */
|
|
256
263
|
const instanceKey = (kind, props) => {
|
|
257
264
|
if (!isSingle(kind)) return null;
|
|
258
265
|
const by = _kinds[kind].single;
|
|
259
|
-
if (by === true) return String(kind);
|
|
266
|
+
if (by === true || by === 'desktop') return String(kind);
|
|
260
267
|
const v = props?.[by];
|
|
261
268
|
return `${kind}\u0000${v == null ? '' : String(v)}`;
|
|
262
269
|
};
|
|
@@ -287,6 +294,7 @@ export function createTaxonomy({ kinds, root } = {}) {
|
|
|
287
294
|
panelOffer,
|
|
288
295
|
topNavEntries,
|
|
289
296
|
isSingle,
|
|
297
|
+
isSinglePerDesktop,
|
|
290
298
|
instanceKey,
|
|
291
299
|
singleReason,
|
|
292
300
|
isWindowOnly,
|
package/src/tiling/shell.js
CHANGED
|
@@ -712,8 +712,11 @@ function _tileContextMenu(wm, leafId, x, y) {
|
|
|
712
712
|
// `isPanel` above) never a panel: `moveFocusedToDesktop` refuses all
|
|
713
713
|
// three too, because the keyboard reaches it without this menu.
|
|
714
714
|
if (i === wm.desktops.activeIdx || d.fixed) continue;
|
|
715
|
+
// A desktop holds a `single: 'desktop'` kind once: greyed, saying so.
|
|
716
|
+
const refusal = wm.desktopMoveRefusal({ leafId }, i);
|
|
715
717
|
items.push({ label: `Move to desktop ${d.label}`, icon: 'sweep',
|
|
716
|
-
action: `move:${i}
|
|
718
|
+
action: `move:${i}`, disabled: !!refusal,
|
|
719
|
+
title: refusal || undefined });
|
|
717
720
|
}
|
|
718
721
|
}
|
|
719
722
|
items.push({ separator: true });
|
package/src/tiling/wm.js
CHANGED
|
@@ -344,6 +344,29 @@ export class WindowManager {
|
|
|
344
344
|
//
|
|
345
345
|
// MOVE verbs (float, dock, drag a tab, move to desktop) carry the one
|
|
346
346
|
// instance somewhere else and are untouched.
|
|
347
|
+
//
|
|
348
|
+
// `single: 'desktop'` keeps the same promises per virtual desktop: a
|
|
349
|
+
// desktop is the tiles and panels the user arranged on it, so opening the
|
|
350
|
+
// kind looks only at the ACTIVE desktop — its tiles, their archived pages
|
|
351
|
+
// and the windows on it — and opens it there when that desktop has none.
|
|
352
|
+
// A window is on the desktop it was opened on or moved to (`desktopIdx`):
|
|
353
|
+
// a free-floating one is drawn over every desktop, but it is that one
|
|
354
|
+
// desktop's instance, so it never stands in for another desktop's. The
|
|
355
|
+
// move verbs refuse to carry the kind onto a desktop that already holds
|
|
356
|
+
// it (`desktopMoveRefusal`). Navigating inside a window looks on the
|
|
357
|
+
// window's own desktop (`openInWindow`). Removing a desktop brings its
|
|
358
|
+
// windows across, a window never being destroyed by a tile operation, so
|
|
359
|
+
// the active desktop can then hold the kind twice until the next reload
|
|
360
|
+
// keeps the first.
|
|
361
|
+
|
|
362
|
+
/** The key a tab's instance is unique under on desktop `desktopIdx`: its
|
|
363
|
+
* instance key, scoped to that desktop for a `single: 'desktop'` kind;
|
|
364
|
+
* null for a tab that is not single. */
|
|
365
|
+
_scopedKey(t, desktopIdx) {
|
|
366
|
+
const k = t ? this._instanceKey(t.kind, t.props) : null;
|
|
367
|
+
if (k == null || !this.taxonomy.isSinglePerDesktop(t.kind)) return k;
|
|
368
|
+
return `${desktopIdx}\u0000${k}`;
|
|
369
|
+
}
|
|
347
370
|
|
|
348
371
|
/** The instance key of a tab spec, or null when its kind is not single. */
|
|
349
372
|
_instanceKey(kind, props) {
|
|
@@ -356,27 +379,38 @@ export class WindowManager {
|
|
|
356
379
|
}
|
|
357
380
|
|
|
358
381
|
/** Where the one instance of a single kind is open, or null — also null
|
|
359
|
-
* for a kind that is not single.
|
|
382
|
+
* for a kind that is not single. A `single: 'desktop'` kind is looked for
|
|
383
|
+
* on the active desktop only. One of:
|
|
360
384
|
* `{ where: 'window', windowId, idx, desktopIdx }`
|
|
361
385
|
* `{ where: 'tab', desktopIdx, leafId, idx }`
|
|
362
386
|
* `{ where: 'archive', desktopIdx, leafId, page, idx }` — a tab of a
|
|
363
387
|
* top-nav page the tile has switched away from (`pageTabs`). */
|
|
364
388
|
findInstance(kind, props = {}) {
|
|
389
|
+
const on = this.taxonomy.isSinglePerDesktop(kind) ? this.desktops.activeIdx : null;
|
|
390
|
+
return this._findInstance(kind, props, on);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** `findInstance`, looking only at desktop `on` when it is a number, and
|
|
394
|
+
* past the window `exceptWindowId`. */
|
|
395
|
+
_findInstance(kind, props, on, exceptWindowId = null) {
|
|
365
396
|
const key = this._instanceKey(kind, props);
|
|
366
397
|
if (key == null) return null;
|
|
367
398
|
const same = (t) => !!t && this._instanceKey(t.kind, t.props) === key;
|
|
399
|
+
const desktops = [...this.desktops.desktops.entries()]
|
|
400
|
+
.filter(([desktopIdx]) => on == null || desktopIdx === on);
|
|
368
401
|
for (const [windowId, rec] of this._windowToLeaf) {
|
|
402
|
+
if (windowId === exceptWindowId) continue;
|
|
403
|
+
if (on != null && rec.desktopIdx !== on) continue;
|
|
369
404
|
const idx = _windowTabSpecs(rec).findIndex(same);
|
|
370
405
|
if (idx >= 0) return { where: 'window', windowId, idx, desktopIdx: rec.desktopIdx };
|
|
371
406
|
}
|
|
372
|
-
const
|
|
373
|
-
for (const [desktopIdx, d] of desktops.entries()) {
|
|
407
|
+
for (const [desktopIdx, d] of desktops) {
|
|
374
408
|
for (const leaf of d.tree.leaves()) {
|
|
375
409
|
const idx = (leaf.tabs || []).findIndex(same);
|
|
376
410
|
if (idx >= 0) return { where: 'tab', desktopIdx, leafId: leaf.id, idx };
|
|
377
411
|
}
|
|
378
412
|
}
|
|
379
|
-
for (const [desktopIdx, d] of desktops
|
|
413
|
+
for (const [desktopIdx, d] of desktops) {
|
|
380
414
|
for (const leaf of d.tree.leaves()) {
|
|
381
415
|
for (const [page, arc] of this._archivedPages(leaf)) {
|
|
382
416
|
const idx = arc.tabs.findIndex(same);
|
|
@@ -387,6 +421,31 @@ export class WindowManager {
|
|
|
387
421
|
return null;
|
|
388
422
|
}
|
|
389
423
|
|
|
424
|
+
/** Why moving a pane's or a window's tabs onto desktop `idx` is refused,
|
|
425
|
+
* or null: that desktop already holds one of their `single: 'desktop'`
|
|
426
|
+
* kinds (in a tile, an archived page or a window), and a desktop holds
|
|
427
|
+
* such a kind once. `source` is `{ leafId }` (a tile of the active
|
|
428
|
+
* desktop) or `{ windowId }`. The move verbs ask it, and their menu rows
|
|
429
|
+
* are greyed with its sentence. */
|
|
430
|
+
desktopMoveRefusal(source, idx) {
|
|
431
|
+
let specs = [];
|
|
432
|
+
if (source?.windowId) {
|
|
433
|
+
const rec = this._windowToLeaf.get(source.windowId);
|
|
434
|
+
specs = rec ? _windowTabSpecs(rec) : [];
|
|
435
|
+
} else {
|
|
436
|
+
const leaf = this._tree().get(source?.leafId);
|
|
437
|
+
specs = leaf?.tabs?.length ? leaf.tabs : (leaf?.content ? [leaf.content] : []);
|
|
438
|
+
}
|
|
439
|
+
const d = this.desktops.desktops[idx];
|
|
440
|
+
if (!d) return null;
|
|
441
|
+
for (const t of specs) {
|
|
442
|
+
if (!t || !this.taxonomy.isSinglePerDesktop(t.kind)) continue;
|
|
443
|
+
if (!this._findInstance(t.kind, t.props, idx, source.windowId ?? null)) continue;
|
|
444
|
+
return `${this.taxonomy.kinds[t.kind]?.label || t.kind} is open on desktop ${d.label} already`;
|
|
445
|
+
}
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
|
|
390
449
|
/** Reveal the instance of a single kind that is open: answers whether
|
|
391
450
|
* there was one. Every open path asks this first.
|
|
392
451
|
*
|
|
@@ -400,7 +459,12 @@ export class WindowManager {
|
|
|
400
459
|
* open names no props at all — a top-nav button, `openInPrimary(kind)` —
|
|
401
460
|
* which asks to SEE the instance, not to empty it. */
|
|
402
461
|
revealInstance(kind, props = {}) {
|
|
403
|
-
|
|
462
|
+
return this._revealFound(this.findInstance(kind, props), kind, props);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Reveal the instance at `loc` (a `findInstance` result, or null) and
|
|
466
|
+
* hand it `props`. Answers whether there was one to reveal. */
|
|
467
|
+
_revealFound(loc, kind, props) {
|
|
404
468
|
if (!loc || !this._revealAt(loc)) return false;
|
|
405
469
|
if (props && Object.keys(props).length) this._deliverProps(kind, props);
|
|
406
470
|
return true;
|
|
@@ -516,7 +580,8 @@ export class WindowManager {
|
|
|
516
580
|
* soon as the tile leaves the page by a path other than `swapToPage`
|
|
517
581
|
* (`navigateActiveTab`). So a restored tab whose instance is live
|
|
518
582
|
* anywhere else is dropped from the tile and from the restored archive;
|
|
519
|
-
* the instance stays where it is.
|
|
583
|
+
* the instance stays where it is. For a `single: 'desktop'` kind,
|
|
584
|
+
* anywhere else on the tile's desktop. */
|
|
520
585
|
_swapToPage(tree, leafId, target, currentTopNav, targetTopNav, opts) {
|
|
521
586
|
const done = tree.swapToPage(leafId, target, currentTopNav, targetTopNav, opts);
|
|
522
587
|
if (done) this._dropRestoredCopies(tree, leafId, targetTopNav, target);
|
|
@@ -527,11 +592,12 @@ export class WindowManager {
|
|
|
527
592
|
const leaf = tree.get(leafId);
|
|
528
593
|
const tabs = Array.isArray(leaf?.tabs) ? leaf.tabs : null;
|
|
529
594
|
if (!tabs) return;
|
|
595
|
+
const desktopIdx = this.desktops.desktops.findIndex((d) => d.tree === tree);
|
|
530
596
|
const elsewhere = this._liveInstanceKeys(tree, leafId);
|
|
531
597
|
const keeper = () => {
|
|
532
598
|
const seen = new Set();
|
|
533
599
|
return (t) => {
|
|
534
|
-
const k =
|
|
600
|
+
const k = this._scopedKey(t, desktopIdx);
|
|
535
601
|
if (k == null) return true;
|
|
536
602
|
if (elsewhere.has(k) || seen.has(k)) return false;
|
|
537
603
|
seen.add(k);
|
|
@@ -569,16 +635,23 @@ export class WindowManager {
|
|
|
569
635
|
tree.setActiveLeafTab(leafId, Math.max(0, Math.min(tabs.length - 1, active)));
|
|
570
636
|
}
|
|
571
637
|
|
|
572
|
-
/** The
|
|
573
|
-
* desktop but `leafId` of `tree
|
|
638
|
+
/** The scoped keys (`_scopedKey`, for `tree`'s desktop) of every LIVE
|
|
639
|
+
* tab — windows, and every leaf of every desktop but `leafId` of `tree`;
|
|
640
|
+
* a `single: 'desktop'` tab counts only when it is on `tree`'s desktop.
|
|
641
|
+
* Archived pages are not live. */
|
|
574
642
|
_liveInstanceKeys(tree, leafId) {
|
|
643
|
+
const here = this.desktops.desktops.findIndex((d) => d.tree === tree);
|
|
575
644
|
const keys = new Set();
|
|
576
|
-
const
|
|
577
|
-
|
|
645
|
+
const adder = (onHere) => (t) => {
|
|
646
|
+
if (!onHere && t && this.taxonomy.isSinglePerDesktop(t.kind)) return;
|
|
647
|
+
const k = this._scopedKey(t, here);
|
|
578
648
|
if (k != null) keys.add(k);
|
|
579
649
|
};
|
|
580
|
-
for (const [, rec] of this._windowToLeaf)
|
|
650
|
+
for (const [, rec] of this._windowToLeaf) {
|
|
651
|
+
_windowTabSpecs(rec).forEach(adder(rec.desktopIdx === here));
|
|
652
|
+
}
|
|
581
653
|
for (const d of this.desktops.desktops) {
|
|
654
|
+
const add = adder(d.tree === tree);
|
|
582
655
|
for (const leaf of d.tree.leaves()) {
|
|
583
656
|
if (d.tree === tree && leaf.id === leafId) continue;
|
|
584
657
|
(leaf.tabs || []).forEach(add);
|
|
@@ -618,14 +691,15 @@ export class WindowManager {
|
|
|
618
691
|
return dropped;
|
|
619
692
|
}
|
|
620
693
|
|
|
621
|
-
/** A restored layout keeps the FIRST instance of each single kind
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
694
|
+
/** A restored layout keeps the FIRST instance of each single kind (of a
|
|
695
|
+
* `single: 'desktop'` kind, on each desktop): later live tabs are
|
|
696
|
+
* dropped (the tile shows the tab that took the dropped one's place, or
|
|
697
|
+
* closes when it held nothing else), and so are later archived ones.
|
|
698
|
+
* Answers whether anything was dropped. */
|
|
625
699
|
_dropDuplicateSingles() {
|
|
626
700
|
const seen = new Set();
|
|
627
|
-
return this._pruneRestoredTabs((t) => {
|
|
628
|
-
const k =
|
|
701
|
+
return this._pruneRestoredTabs((t, desktopIdx) => {
|
|
702
|
+
const k = this._scopedKey(t, desktopIdx);
|
|
629
703
|
if (k == null) return true;
|
|
630
704
|
if (seen.has(k)) return false;
|
|
631
705
|
seen.add(k);
|
|
@@ -633,19 +707,20 @@ export class WindowManager {
|
|
|
633
707
|
});
|
|
634
708
|
}
|
|
635
709
|
|
|
636
|
-
/** Drop every tab of the restored layout `keep(tab)` refuses,
|
|
637
|
-
* tiles and in their archived pages; a tile left empty closes
|
|
638
|
-
* content tile if none is left). Returns whether anything
|
|
710
|
+
/** Drop every tab of the restored layout `keep(tab, desktopIdx)` refuses,
|
|
711
|
+
* in the tiles and in their archived pages; a tile left empty closes
|
|
712
|
+
* (seeding a content tile if none is left). Returns whether anything
|
|
713
|
+
* went. */
|
|
639
714
|
_pruneRestoredTabs(keep) {
|
|
640
715
|
let dropped = false;
|
|
641
|
-
for (const d of this.desktops.desktops) {
|
|
716
|
+
for (const [desktopIdx, d] of this.desktops.desktops.entries()) {
|
|
642
717
|
const tree = d.tree;
|
|
643
718
|
for (const leaf of tree.leaves()) {
|
|
644
719
|
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
645
720
|
let active = leaf.activeTabIdx || 0;
|
|
646
721
|
let changed = false;
|
|
647
722
|
for (let i = 0; i < tabs.length;) {
|
|
648
|
-
if (keep(tabs[i])) { i++; continue; }
|
|
723
|
+
if (keep(tabs[i], desktopIdx)) { i++; continue; }
|
|
649
724
|
tabs.splice(i, 1);
|
|
650
725
|
if (i < active) active -= 1;
|
|
651
726
|
changed = true;
|
|
@@ -664,10 +739,10 @@ export class WindowManager {
|
|
|
664
739
|
}
|
|
665
740
|
}
|
|
666
741
|
}
|
|
667
|
-
for (const d of this.desktops.desktops) {
|
|
742
|
+
for (const [desktopIdx, d] of this.desktops.desktops.entries()) {
|
|
668
743
|
for (const leaf of d.tree.leaves()) {
|
|
669
744
|
for (const [page, arc] of this._archivedPages(leaf)) {
|
|
670
|
-
const kept = arc.tabs.filter(keep);
|
|
745
|
+
const kept = arc.tabs.filter((t) => keep(t, desktopIdx));
|
|
671
746
|
if (kept.length === arc.tabs.length) continue;
|
|
672
747
|
dropped = true;
|
|
673
748
|
if (!kept.length) { delete leaf.pageTabs[page]; continue; }
|
|
@@ -1096,9 +1171,15 @@ export class WindowManager {
|
|
|
1096
1171
|
* and updates the window's title. */
|
|
1097
1172
|
openInWindow(winId, kind, props = {}) {
|
|
1098
1173
|
if (this._openWindowOnly(kind, props)) return;
|
|
1099
|
-
if (this.revealInstance(kind, props)) return;
|
|
1100
1174
|
const rec = this._windowToLeaf.get(winId);
|
|
1101
1175
|
if (!rec) { this.openInPrimary(kind, props); return; }
|
|
1176
|
+
// A window is on its own desktop, even a free-floating one drawn over
|
|
1177
|
+
// every desktop: a `single: 'desktop'` kind navigated to in it is
|
|
1178
|
+
// looked for on the window's desktop, not the one on screen.
|
|
1179
|
+
const loc = this.taxonomy.isSinglePerDesktop(kind)
|
|
1180
|
+
? this._findInstance(kind, props, rec.desktopIdx)
|
|
1181
|
+
: this.findInstance(kind, props);
|
|
1182
|
+
if (this._revealFound(loc, kind, props)) return;
|
|
1102
1183
|
try { rec.mountInfo?.destroy?.(); } catch {}
|
|
1103
1184
|
// R8. THE BODY, NOT THE CONTENT ELEMENT. A floated pane's window holds
|
|
1104
1185
|
// a tab strip above its body, and both are children of `contentEl` —
|
|
@@ -3149,6 +3230,11 @@ export class WindowManager {
|
|
|
3149
3230
|
// The window's home follows the drop. Without this the dock is applied
|
|
3150
3231
|
// to the tree of the desktop the window was PROMOTED from, and the tile
|
|
3151
3232
|
// the user aimed at is on the one they are looking at.
|
|
3233
|
+
// A free-floating window is drawn over every desktop, so a drop can
|
|
3234
|
+
// carry it to another one: refused, as the move verbs are, when that
|
|
3235
|
+
// desktop holds its `single: 'desktop'` kind already.
|
|
3236
|
+
if (typeof desktopIdx === 'number' && desktopIdx !== record.desktopIdx
|
|
3237
|
+
&& this.desktopMoveRefusal({ windowId: winId }, desktopIdx)) return false;
|
|
3152
3238
|
if (typeof desktopIdx === 'number') record.desktopIdx = desktopIdx;
|
|
3153
3239
|
|
|
3154
3240
|
// THE DRAG ALREADY ANSWERED, so there is nothing to ask — the probe
|
|
@@ -3236,6 +3322,8 @@ export class WindowManager {
|
|
|
3236
3322
|
if (rec.desktopIdx === targetIdx) return;
|
|
3237
3323
|
// C40. A fixed desktop is the embedder's, and nothing is moved onto it.
|
|
3238
3324
|
if (this.desktops.desktops[targetIdx]?.fixed) return;
|
|
3325
|
+
// A desktop holds a `single: 'desktop'` kind once.
|
|
3326
|
+
if (this.desktopMoveRefusal({ windowId }, targetIdx)) return;
|
|
3239
3327
|
this.desktops.ensureCount(targetIdx + 1);
|
|
3240
3328
|
rec.desktopIdx = targetIdx;
|
|
3241
3329
|
if (!rec.homeContainer && rec.homeLeafId) {
|
|
@@ -3411,9 +3499,11 @@ export class WindowManager {
|
|
|
3411
3499
|
if (targets.length) {
|
|
3412
3500
|
items.push({ separator: true });
|
|
3413
3501
|
for (const [i, d] of targets) {
|
|
3502
|
+
const refusal = this.desktopMoveRefusal({ windowId: winId }, i);
|
|
3414
3503
|
items.push({
|
|
3415
3504
|
label: `Move to desktop ${d.label}`, icon: 'sweep',
|
|
3416
|
-
action: `move:${i}`,
|
|
3505
|
+
action: `move:${i}`, disabled: !!refusal,
|
|
3506
|
+
title: refusal || undefined,
|
|
3417
3507
|
});
|
|
3418
3508
|
}
|
|
3419
3509
|
}
|
|
@@ -3988,7 +4078,9 @@ export class WindowManager {
|
|
|
3988
4078
|
* ruling is that a tile operation may move a window and never destroy it,
|
|
3989
4079
|
* and removing a desktop is the largest tile operation there is: they come
|
|
3990
4080
|
* across to the desktop that ends up active, re-homed onto its ground by
|
|
3991
|
-
* `_rehomeContainedWindows` on the render below.
|
|
4081
|
+
* `_rehomeContainedWindows` on the render below. One holding a
|
|
4082
|
+
* `single: 'desktop'` kind the active desktop already has makes it hold
|
|
4083
|
+
* the kind twice, until a reload keeps the first.
|
|
3992
4084
|
*/
|
|
3993
4085
|
removeDesktop(idx) {
|
|
3994
4086
|
const m = this.desktops;
|
|
@@ -4066,6 +4158,9 @@ export class WindowManager {
|
|
|
4066
4158
|
// Already there. Without this, `tree.close` below removes the tile from
|
|
4067
4159
|
// the same tree its tabs were just appended to, and it is gone.
|
|
4068
4160
|
if (idx === this.desktops.activeIdx) return;
|
|
4161
|
+
// A desktop holds a `single: 'desktop'` kind once: a pane carrying one
|
|
4162
|
+
// the target already has stays where it is.
|
|
4163
|
+
if (this.desktopMoveRefusal({ leafId: focused.id }, idx)) return;
|
|
4069
4164
|
this.desktops.ensureCount(idx + 1);
|
|
4070
4165
|
const target = this.desktops.desktops[idx];
|
|
4071
4166
|
// C40. THE PANE'S TABS JOIN THE TARGET'S PRIMARY LEAF, ALL OF THEM. It
|
|
@@ -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
|
}
|
|
@@ -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;
|