flexdesk 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/dist/wm.js +153 -3
- package/dist/wm.js.map +2 -2
- package/package.json +1 -1
- package/src/tiling/shell.js +15 -1
- package/src/tiling/tile_renderer.js +1 -1
- package/src/tiling/wm.js +171 -2
package/package.json
CHANGED
package/src/tiling/shell.js
CHANGED
|
@@ -86,6 +86,15 @@ import { openForm } from '../ui/components/modal.js';
|
|
|
86
86
|
* window onto a tile puts it back in the
|
|
87
87
|
* tree. Default off — it changes what a
|
|
88
88
|
* drag to an edge does.
|
|
89
|
+
* @param {boolean} [cfg.backToOpenList] C32. Whether Backspace on a record
|
|
90
|
+
* tab closes it and returns to a list of
|
|
91
|
+
* its section that is already open, rather
|
|
92
|
+
* than rewriting it into a second copy of
|
|
93
|
+
* that list. Default off.
|
|
94
|
+
* @param {boolean} [cfg.floatActiveTab] C34. Whether floating a tile takes
|
|
95
|
+
* only the tab on screen, leaving its
|
|
96
|
+
* siblings in the tile. Default off: the
|
|
97
|
+
* whole pane floats, strip and all (R8).
|
|
89
98
|
* @param {'top'|'bottom'} [cfg.tabLayout] C22. Where a multi-tab leaf draws its
|
|
90
99
|
* tabs. `'bottom'` is the framework's own
|
|
91
100
|
* spreadsheet strip under the tile body
|
|
@@ -122,6 +131,8 @@ export async function createShell({
|
|
|
122
131
|
panels = null,
|
|
123
132
|
snapPromotion = false,
|
|
124
133
|
promoteInPlace = false,
|
|
134
|
+
backToOpenList = false,
|
|
135
|
+
floatActiveTab = false,
|
|
125
136
|
tabLayout = null,
|
|
126
137
|
chrome = {},
|
|
127
138
|
// An embedder that moved its sections out of the top bar — into an icon
|
|
@@ -163,6 +174,8 @@ export async function createShell({
|
|
|
163
174
|
panelDefaults: panels,
|
|
164
175
|
snapPromotion,
|
|
165
176
|
promoteInPlace,
|
|
177
|
+
backToOpenList,
|
|
178
|
+
floatActiveTab,
|
|
166
179
|
tabLayout,
|
|
167
180
|
// `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
|
|
168
181
|
// spreads it into every content factory, which is how the breadcrumb
|
|
@@ -521,7 +534,8 @@ function _tileContextMenu(wm, leafId, x, y) {
|
|
|
521
534
|
// most of them — a menu disagreeing with its verb in the generous
|
|
522
535
|
// direction is a dead control; in the mean direction it is a missing
|
|
523
536
|
// feature, and this file has shipped one of each.
|
|
524
|
-
{ label: 'Float this
|
|
537
|
+
{ label: wm.floatActiveTab ? 'Float this tab as a window' : 'Float this pane as a window',
|
|
538
|
+
icon: 'web_asset',
|
|
525
539
|
action: 'promote',
|
|
526
540
|
disabled: isPanel || !leaf.content
|
|
527
541
|
|| wm.renderer?.leafChrome?.(leafId)?.promote === false },
|
|
@@ -561,7 +561,7 @@ export class TileRenderer {
|
|
|
561
561
|
<button class="twm-leaf__btn" data-action="split-v" title="Split vertically (Alt+V)">
|
|
562
562
|
<span class="material-symbols-outlined">splitscreen_add</span>
|
|
563
563
|
</button>
|
|
564
|
-
<button class="twm-leaf__btn" data-action="promote" title="Float this pane as a window (Alt+F)">
|
|
564
|
+
<button class="twm-leaf__btn" data-action="promote" title="Float this ${this.ctx.wm?.floatActiveTab ? 'tab' : 'pane'} as a window (Alt+F)">
|
|
565
565
|
<span class="material-symbols-outlined">web_asset</span>
|
|
566
566
|
</button>`}
|
|
567
567
|
<button class="twm-leaf__btn" data-action="close" title="Close (Alt+W)">
|
package/src/tiling/wm.js
CHANGED
|
@@ -34,7 +34,8 @@ const PANEL_TITLES = {
|
|
|
34
34
|
export class WindowManager {
|
|
35
35
|
constructor({ rootEl, api, ctx, onChange, eventBus, host, taxonomy, events, content,
|
|
36
36
|
panelDefaults = null, snapPromotion = false,
|
|
37
|
-
promoteInPlace = false, tabLayout = null
|
|
37
|
+
promoteInPlace = false, tabLayout = null, backToOpenList = false,
|
|
38
|
+
floatActiveTab = false }) {
|
|
38
39
|
if (!taxonomy) throw new Error('WindowManager: a taxonomy is required');
|
|
39
40
|
if (!content || typeof content.mount !== 'function') {
|
|
40
41
|
throw new Error('WindowManager: a content registry is required '
|
|
@@ -57,6 +58,8 @@ export class WindowManager {
|
|
|
57
58
|
this.ctx = ctx || {};
|
|
58
59
|
this.eventBus = eventBus || null;
|
|
59
60
|
this.onChange = onChange || (() => {});
|
|
61
|
+
// C34. Whether the tile's float gesture takes only the tab on screen.
|
|
62
|
+
this.floatActiveTab = !!floatActiveTab;
|
|
60
63
|
|
|
61
64
|
// The leaf a fresh or emptied desktop starts with. Comes from the
|
|
62
65
|
// taxonomy's root kind — NOT a hardcoded 'home'. An embedder with a
|
|
@@ -83,6 +86,26 @@ export class WindowManager {
|
|
|
83
86
|
* changes where a promoted window can be dragged, and an existing
|
|
84
87
|
* embedder upgrading must not find its windows suddenly clipped. */
|
|
85
88
|
this.promoteInPlace = !!promoteInPlace;
|
|
89
|
+
/** C32. Whether Backspace on an ENTITY tab closes it and returns to a list
|
|
90
|
+
* that is already open, instead of turning the tab into a second copy of
|
|
91
|
+
* that list. See `_backToOpenList`. Default off: it changes what the key
|
|
92
|
+
* does, and an existing embedder upgrading must not find tabs closing
|
|
93
|
+
* where they used to navigate. */
|
|
94
|
+
this.backToOpenList = !!backToOpenList;
|
|
95
|
+
|
|
96
|
+
// C34. BACK BELONGS TO WHAT YOU ARE LOOKING AT. A floating window is not a
|
|
97
|
+
// tile, so the tree's focus never moves to it, and Backspace pressed while
|
|
98
|
+
// reading a window used to walk the tile BEHIND it. The last pointerdown
|
|
99
|
+
// says which one the user is in: inside a window it names that window,
|
|
100
|
+
// anywhere else it clears. Capture phase, so content that stops the event
|
|
101
|
+
// cannot hide it.
|
|
102
|
+
this._backWindowId = null;
|
|
103
|
+
const doc = rootEl?.ownerDocument || globalThis.document;
|
|
104
|
+
doc?.addEventListener?.('pointerdown', (e) => {
|
|
105
|
+
const el = e.target?.closest?.('[data-window-id]');
|
|
106
|
+
this._backWindowId = el && this._windowToLeaf.has(el.getAttribute('data-window-id'))
|
|
107
|
+
? el.getAttribute('data-window-id') : null;
|
|
108
|
+
}, true);
|
|
86
109
|
this._snapCtl = null;
|
|
87
110
|
this.desktops = new DesktopManager({
|
|
88
111
|
seed: this._rootLeaf, panelDefaults: this.panelDefaults });
|
|
@@ -238,6 +261,8 @@ export class WindowManager {
|
|
|
238
261
|
* methods (openFromContext / openInLeaf / navigateActiveTab) —
|
|
239
262
|
* they all record history by default. */
|
|
240
263
|
navigateBack() {
|
|
264
|
+
// C34. A window the user is reading gets the Back, not the tile under it.
|
|
265
|
+
if (this._navigateBackInWindow()) return;
|
|
241
266
|
const tree = this._tree();
|
|
242
267
|
// Prefer the FOCUSED leaf — that's where the user was just
|
|
243
268
|
// interacting (Open click, breadcrumb step, etc.). Falls back
|
|
@@ -285,7 +310,10 @@ export class WindowManager {
|
|
|
285
310
|
this._notifyChange('navigate-back');
|
|
286
311
|
return;
|
|
287
312
|
}
|
|
288
|
-
// (2)
|
|
313
|
+
// (2) C32. An entity tab with nowhere left to go back to, beside a list
|
|
314
|
+
// of its own section that is already open: close it and land there.
|
|
315
|
+
if (this.backToOpenList && this._backToOpenList(id)) return;
|
|
316
|
+
// (3) Taxonomy fallback — the user's path is gone, so walk the
|
|
289
317
|
// canonical parent instead. An ENTITY-level parent wins (the
|
|
290
318
|
// instance one level up, e.g. the parent an id names); failing
|
|
291
319
|
// that, the KIND-level parent (this page's section).
|
|
@@ -347,6 +375,134 @@ export class WindowManager {
|
|
|
347
375
|
/** Navigate inside the current tab — preserves every other tab in
|
|
348
376
|
* the leaf. Used by Backspace + breadcrumb segments + anything
|
|
349
377
|
* else that should walk WITHIN the tile rather than reset it. */
|
|
378
|
+
/**
|
|
379
|
+
* C32. BACK FROM AN OPENED RECORD RETURNS TO THE LIST, IT DOES NOT MAKE ONE.
|
|
380
|
+
*
|
|
381
|
+
* The gesture this is for: a list tab, a record opened from it into a NEW
|
|
382
|
+
* tab, and Backspace in that record. With nothing left in the record tab's
|
|
383
|
+
* own history, step (3) of `navigateBack` walks the taxonomy up and REWRITES
|
|
384
|
+
* the record tab into its parent — so the tile ends up showing the list
|
|
385
|
+
* twice, once in the tab you started from and once in the tab you just left.
|
|
386
|
+
* The user's model is a browser's: you came from the list, the list is still
|
|
387
|
+
* open, so going back means closing this and being on the list again.
|
|
388
|
+
*
|
|
389
|
+
* So: when the active tab is an ENTITY — its props carry an `id`, the same
|
|
390
|
+
* test `swapToPage` uses for "the caller asked for a specific entity" — look
|
|
391
|
+
* for an open tab of the SAME section that is NOT one. That is the list or
|
|
392
|
+
* landing page the record belongs to. Close the record and activate it.
|
|
393
|
+
*
|
|
394
|
+
* NEAREST TO THE LEFT FIRST. New tabs are appended at the end, so the tab a
|
|
395
|
+
* record was opened from sits to its left; when several lists of the section
|
|
396
|
+
* are open, the nearest one on that side is the one it most plausibly came
|
|
397
|
+
* from. The right side is searched only after the left has nothing.
|
|
398
|
+
*
|
|
399
|
+
* DECLINES, and leaves step (3) to walk up in place, whenever there is no
|
|
400
|
+
* such tab: a record that is the tile's only tab, or one whose section has no
|
|
401
|
+
* list open. That keeps the one guarantee the whole rule exists for — Back
|
|
402
|
+
* never leaves two copies of a list — without ever closing a record into a
|
|
403
|
+
* tile that has nothing of its own to show.
|
|
404
|
+
*
|
|
405
|
+
* Matching is by SECTION (`topNavFor`) and not by kind, because the list a
|
|
406
|
+
* record was opened from is often not the record's taxonomy parent: a root
|
|
407
|
+
* landing page that renders the section, or a list that a flattened taxonomy
|
|
408
|
+
* files beside the record rather than above it.
|
|
409
|
+
*
|
|
410
|
+
* @returns {boolean} whether it closed a tab
|
|
411
|
+
*/
|
|
412
|
+
_backToOpenList(leafId) {
|
|
413
|
+
const tree = this._tree();
|
|
414
|
+
const leaf = tree.get(leafId);
|
|
415
|
+
if (!leaf || leaf.kind !== 'leaf') return false;
|
|
416
|
+
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
417
|
+
if (tabs.length < 2) return false;
|
|
418
|
+
const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
419
|
+
const active = tabs[activeIdx];
|
|
420
|
+
if (!_isEntityTab(active)) return false;
|
|
421
|
+
const section = this.taxonomy.topNavFor(active.kind);
|
|
422
|
+
if (!section) return false;
|
|
423
|
+
|
|
424
|
+
const isList = (t) => t && !_isEntityTab(t) && this.taxonomy.topNavFor(t.kind) === section;
|
|
425
|
+
let target = -1;
|
|
426
|
+
for (let i = activeIdx - 1; i >= 0 && target < 0; i--) if (isList(tabs[i])) target = i;
|
|
427
|
+
for (let i = activeIdx + 1; i < tabs.length && target < 0; i++) if (isList(tabs[i])) target = i;
|
|
428
|
+
if (target < 0) return false;
|
|
429
|
+
|
|
430
|
+
tree.removeLeafTab(leafId, activeIdx);
|
|
431
|
+
// Removing the active tab shifts everything after it one to the left.
|
|
432
|
+
tree.setActiveLeafTab(leafId, target > activeIdx ? target - 1 : target);
|
|
433
|
+
tree.focus(leafId);
|
|
434
|
+
this.renderer.render();
|
|
435
|
+
this._persist();
|
|
436
|
+
this._notifyChange('navigate-back-close');
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* C34. Back while a floating window is the thing being read. Returns true
|
|
442
|
+
* when it handled the press, false to let the tile path run.
|
|
443
|
+
*
|
|
444
|
+
* Only the window's ACTIVE tab is considered, in this order:
|
|
445
|
+
* (1) with `backToOpenList`, a record closes onto an open list of its
|
|
446
|
+
* section: first among the window's own tabs, then any tile of the
|
|
447
|
+
* window's desktop, primary tile first. Closing the last tab closes
|
|
448
|
+
* the window, which discards it (a plain close never docks back).
|
|
449
|
+
* (2) otherwise it walks up IN the window, exactly as a tile does.
|
|
450
|
+
* A window whose content has nowhere to go still counts as handled: the
|
|
451
|
+
* press was aimed at it, and moving the tile behind it would be the bug.
|
|
452
|
+
*/
|
|
453
|
+
_navigateBackInWindow() {
|
|
454
|
+
const winId = this._backWindowId;
|
|
455
|
+
const rec = winId ? this._windowToLeaf.get(winId) : null;
|
|
456
|
+
if (!rec) { this._backWindowId = null; return false; }
|
|
457
|
+
const tabs = Array.isArray(rec.tabs) && rec.tabs.length ? rec.tabs : [rec.original].filter(Boolean);
|
|
458
|
+
const activeIdx = Math.max(0, Math.min(tabs.length - 1, rec.activeTabIdx || 0));
|
|
459
|
+
const active = tabs[activeIdx];
|
|
460
|
+
if (!active) return true;
|
|
461
|
+
const section = this.backToOpenList && _isEntityTab(active)
|
|
462
|
+
? this.taxonomy.topNavFor(active.kind) : null;
|
|
463
|
+
const isList = (t) => !!t && !_isEntityTab(t) && this.taxonomy.topNavFor(t.kind) === section;
|
|
464
|
+
|
|
465
|
+
if (section) {
|
|
466
|
+
// (1a) a list among the window's own tabs, nearest to the left first.
|
|
467
|
+
let own = -1;
|
|
468
|
+
for (let i = activeIdx - 1; i >= 0 && own < 0; i--) if (isList(tabs[i])) own = i;
|
|
469
|
+
for (let i = activeIdx + 1; i < tabs.length && own < 0; i++) if (isList(tabs[i])) own = i;
|
|
470
|
+
if (own >= 0 && Array.isArray(rec.tabs) && rec.tabs.length > 1) {
|
|
471
|
+
this._windowTabAction(winId, 'close', { idx: activeIdx });
|
|
472
|
+
this.showWindowTab(winId, own > activeIdx ? own - 1 : own);
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
// (1b) a list in a tile of the window's desktop.
|
|
476
|
+
const desk = this.desktops.desktops[rec.desktopIdx] || this.desktops.active();
|
|
477
|
+
const tree = desk.tree;
|
|
478
|
+
const leaves = tree.leaves?.() || [];
|
|
479
|
+
const primaryId = tree.primaryLeafId();
|
|
480
|
+
const ordered = [...leaves.filter((l) => l.id === primaryId), ...leaves.filter((l) => l.id !== primaryId)];
|
|
481
|
+
for (const leaf of ordered) {
|
|
482
|
+
const ltabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
483
|
+
const cur = Math.max(0, Math.min(ltabs.length - 1, leaf.activeTabIdx || 0));
|
|
484
|
+
const hit = isList(ltabs[cur]) ? cur : ltabs.findIndex(isList);
|
|
485
|
+
if (hit < 0) continue;
|
|
486
|
+
this._backWindowId = null;
|
|
487
|
+
if (Array.isArray(rec.tabs) && rec.tabs.length > 1) this._windowTabAction(winId, 'close', { idx: activeIdx });
|
|
488
|
+
else { try { rec.window.close({ force: true }); } catch { /* already gone */ } }
|
|
489
|
+
if (this.desktops.active().tree === tree) {
|
|
490
|
+
tree.setActiveLeafTab(leaf.id, hit);
|
|
491
|
+
tree.focus(leaf.id);
|
|
492
|
+
this.renderer.render();
|
|
493
|
+
}
|
|
494
|
+
this._persist();
|
|
495
|
+
this._notifyChange('navigate-back-close');
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// (2) walk up inside the window.
|
|
500
|
+
const up = this.taxonomy.parentOf?.(active.kind, active.props || {});
|
|
501
|
+
const parentKind = up ? up.kind : this.taxonomy.parentKindFor(active.kind);
|
|
502
|
+
if (parentKind) this.openInWindow(winId, parentKind, up ? (up.props || {}) : {});
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
|
|
350
506
|
navigateActiveTab(leafId, kind, props = {}) {
|
|
351
507
|
const tree = this._tree();
|
|
352
508
|
const leaf = tree.get(leafId);
|
|
@@ -757,6 +913,14 @@ export class WindowManager {
|
|
|
757
913
|
const tree = this._tree();
|
|
758
914
|
const focused = tree.focused();
|
|
759
915
|
if (!focused) return null;
|
|
916
|
+
// C34. An embedder whose tabs are separate records, not one pane's
|
|
917
|
+
// views, floats the record on screen and leaves its siblings in place.
|
|
918
|
+
// A window then holds one tab, so it draws no strip at all.
|
|
919
|
+
const tabs = Array.isArray(focused.tabs) ? focused.tabs : [];
|
|
920
|
+
if (this.floatActiveTab && tabs.length > 1) {
|
|
921
|
+
const idx = Math.max(0, Math.min(tabs.length - 1, focused.activeTabIdx || 0));
|
|
922
|
+
return this.floatTabAsWindow(focused.id, idx);
|
|
923
|
+
}
|
|
760
924
|
return this.floatPane(focused.id);
|
|
761
925
|
}
|
|
762
926
|
|
|
@@ -3641,3 +3805,8 @@ function _swapSiblings(tree, aId, bId) {
|
|
|
3641
3805
|
return true;
|
|
3642
3806
|
}
|
|
3643
3807
|
|
|
3808
|
+
/** A tab showing ONE record rather than a list: its props name an `id`. The same
|
|
3809
|
+
* test `swapToPage` applies to "the caller asked for a specific entity". */
|
|
3810
|
+
function _isEntityTab(tab) {
|
|
3811
|
+
return !!tab && tab.props != null && tab.props.id != null && tab.props.id !== '';
|
|
3812
|
+
}
|