flexdesk 0.3.0 → 0.4.1
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 +35 -0
- package/css/base.css +68 -0
- package/css/flexdesk.css +68 -0
- package/dist/flexdesk.css +68 -0
- package/dist/wm.js +224 -16
- package/dist/wm.js.map +3 -3
- package/package.json +1 -1
- package/src/tiling/shell.js +28 -1
- package/src/tiling/tile_tree.js +33 -11
- package/src/tiling/wm.js +101 -12
- package/src/tiling/zoom.js +248 -0
package/package.json
CHANGED
package/src/tiling/shell.js
CHANGED
|
@@ -44,6 +44,7 @@ import { createContentRegistry } from './content_registry.js';
|
|
|
44
44
|
import { createCommandPalette } from './command_palette.js';
|
|
45
45
|
import { installKeymap } from './keymap.js';
|
|
46
46
|
import { openTileTabSwitcher } from './tile_tab_menu.js';
|
|
47
|
+
import { mountZoomControl } from './zoom.js';
|
|
47
48
|
import { showContextMenu } from '../ui/components/context_menu.js';
|
|
48
49
|
import { openForm } from '../ui/components/modal.js';
|
|
49
50
|
|
|
@@ -85,6 +86,11 @@ import { openForm } from '../ui/components/modal.js';
|
|
|
85
86
|
* window onto a tile puts it back in the
|
|
86
87
|
* tree. Default off — it changes what a
|
|
87
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.
|
|
88
94
|
* @param {'top'|'bottom'} [cfg.tabLayout] C22. Where a multi-tab leaf draws its
|
|
89
95
|
* tabs. `'bottom'` is the framework's own
|
|
90
96
|
* spreadsheet strip under the tile body
|
|
@@ -97,7 +103,12 @@ import { openForm } from '../ui/components/modal.js';
|
|
|
97
103
|
* embedder whose settings pane holds only
|
|
98
104
|
* the root element can change it live.
|
|
99
105
|
* @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
|
|
100
|
-
* panelToggles?: { left?, right?, bottom? }
|
|
106
|
+
* panelToggles?: { left?, right?, bottom? },
|
|
107
|
+
* zoom? }
|
|
108
|
+
* `zoom` is C31: the element the content
|
|
109
|
+
* zoom control is painted into — see
|
|
110
|
+
* zoom.js. Absent, there is no control and
|
|
111
|
+
* nothing is ever scaled.
|
|
101
112
|
* @returns {Promise<object>} the frozen shell
|
|
102
113
|
*/
|
|
103
114
|
export async function createShell({
|
|
@@ -116,6 +127,7 @@ export async function createShell({
|
|
|
116
127
|
panels = null,
|
|
117
128
|
snapPromotion = false,
|
|
118
129
|
promoteInPlace = false,
|
|
130
|
+
backToOpenList = false,
|
|
119
131
|
tabLayout = null,
|
|
120
132
|
chrome = {},
|
|
121
133
|
// An embedder that moved its sections out of the top bar — into an icon
|
|
@@ -157,6 +169,7 @@ export async function createShell({
|
|
|
157
169
|
panelDefaults: panels,
|
|
158
170
|
snapPromotion,
|
|
159
171
|
promoteInPlace,
|
|
172
|
+
backToOpenList,
|
|
160
173
|
tabLayout,
|
|
161
174
|
// `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
|
|
162
175
|
// spreads it into every content factory, which is how the breadcrumb
|
|
@@ -185,6 +198,13 @@ export async function createShell({
|
|
|
185
198
|
const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
|
|
186
199
|
topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
|
|
187
200
|
desktopsEl = mountDesktopBar(chrome.desktops, wm);
|
|
201
|
+
// C31. The content zoom. Painted with the rest of the chrome and, unlike the
|
|
202
|
+
// rest, AWAITED before the first mount: its saved value is applied to `root`
|
|
203
|
+
// as a CSS variable, and a shell that mounted first would paint every tile at
|
|
204
|
+
// 100% and then visibly jump to the user's zoom a moment later. A host with no
|
|
205
|
+
// saved zoom — or no `state` at all — resolves immediately to the default.
|
|
206
|
+
const zoom = mountZoomControl(chrome.zoom, { root, host });
|
|
207
|
+
if (zoom) await zoom.ready;
|
|
188
208
|
// NOTE: bindPanelToggles CLONES the buttons (to strip whatever state the
|
|
189
209
|
// embedder's own machinery left on them) and returns the FRESH nodes. The
|
|
190
210
|
// originals are detached from here on — sync against the returned map, not
|
|
@@ -211,6 +231,9 @@ export async function createShell({
|
|
|
211
231
|
topNavEl,
|
|
212
232
|
desktopsEl,
|
|
213
233
|
panelToggles: toggles,
|
|
234
|
+
// `get`/`set` so an embedder can drive the zoom from its own settings
|
|
235
|
+
// pane, or read it, without reaching into the control's DOM.
|
|
236
|
+
zoom,
|
|
214
237
|
}),
|
|
215
238
|
// A shell that can be built can be built TWICE — an embedder that
|
|
216
239
|
// rebuilds on a context change (a different project, a different
|
|
@@ -229,6 +252,10 @@ export async function createShell({
|
|
|
229
252
|
// captured before a rebuild, a promise that has not settled —
|
|
230
253
|
// cannot repaint a dead tree into a root the live shell now owns.
|
|
231
254
|
try { wm.renderer.destroy(); } catch (err) { log.warn?.('renderer teardown', err); }
|
|
255
|
+
// The zoom lives on `root` as a variable and a class. A rebuilt shell on
|
|
256
|
+
// the same root must not inherit the old one's scale with no control on
|
|
257
|
+
// screen to change it, so dispose puts the root back to 100%.
|
|
258
|
+
try { zoom?.dispose(); } catch { /* ignore */ }
|
|
232
259
|
},
|
|
233
260
|
});
|
|
234
261
|
}
|
package/src/tiling/tile_tree.js
CHANGED
|
@@ -298,15 +298,27 @@ export class TileTree {
|
|
|
298
298
|
}));
|
|
299
299
|
n.activeTabIdx = Math.max(0,
|
|
300
300
|
Math.min(n.tabs.length - 1, saved.activeTabIdx || 0));
|
|
301
|
-
// The caller asked for a specific entity (props.id)
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
301
|
+
// The caller asked for a specific entity (props.id), for a page
|
|
302
|
+
// kind that ISN'T the nav category itself (e.g. `settings` lives
|
|
303
|
+
// under the `home` topNav), or for this page WITH PARTICULAR PROPS
|
|
304
|
+
// (a filter, an ad-hoc expression). In all three we must surface the
|
|
305
|
+
// requested target rather than silently showing whatever the
|
|
306
|
+
// restored page happened to hold.
|
|
307
|
+
//
|
|
308
|
+
// THE THIRD CASE WAS MISSING, and it is not a corner: "show me
|
|
309
|
+
// everything open on bo" is `kind: 'backlog'` with an `expr` and no
|
|
310
|
+
// id — no entity, and `backlog` IS its own nav category — so it fell
|
|
311
|
+
// through to the bare-restore branch and put back whatever the page
|
|
312
|
+
// last held. If that was a record you had been reading, the tile did
|
|
313
|
+
// not visibly change at all, and the click looked broken. (Found and
|
|
314
|
+
// fixed in BugDesk's fork first; this is that fix, upstream.)
|
|
315
|
+
//
|
|
316
|
+
// A BARE page switch still restores as-is, which is the whole point
|
|
317
|
+
// of archiving tabs: clicking a top-nav chip carries no props and
|
|
318
|
+
// must put back the tab you left open.
|
|
308
319
|
const wantsTarget = target.props?.id != null
|
|
309
|
-
|| target.kind !== targetTopNav
|
|
320
|
+
|| target.kind !== targetTopNav
|
|
321
|
+
|| Object.keys(target.props || {}).length > 0;
|
|
310
322
|
if (wantsTarget) {
|
|
311
323
|
const wantId = target.props?.id != null;
|
|
312
324
|
const matchIdx = n.tabs.findIndex((t) =>
|
|
@@ -489,9 +501,19 @@ export class TileTree {
|
|
|
489
501
|
};
|
|
490
502
|
n.tabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
491
503
|
n.tabs.push(tab);
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
504
|
+
// `background` appends WITHOUT switching to it — the caller asked for a
|
|
505
|
+
// tab to come back to, not for the page to change under them. Without
|
|
506
|
+
// this, "open in a background tab" was indistinguishable from an
|
|
507
|
+
// ordinary click: the tab arrived and took the screen with it.
|
|
508
|
+
//
|
|
509
|
+
// Returns the new tab's index either way, NOT `activeTabIdx`: for a
|
|
510
|
+
// background tab those differ, and a caller that wants to address the
|
|
511
|
+
// tab it just made needs the index of that tab.
|
|
512
|
+
if (!opts.background) {
|
|
513
|
+
n.activeTabIdx = n.tabs.length - 1;
|
|
514
|
+
_syncActiveTab(n);
|
|
515
|
+
}
|
|
516
|
+
return n.tabs.length - 1;
|
|
495
517
|
}
|
|
496
518
|
|
|
497
519
|
/** Switch the active tab on a leaf. No-op if `idx` is out of range. */
|
package/src/tiling/wm.js
CHANGED
|
@@ -34,7 +34,7 @@ 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
38
|
if (!taxonomy) throw new Error('WindowManager: a taxonomy is required');
|
|
39
39
|
if (!content || typeof content.mount !== 'function') {
|
|
40
40
|
throw new Error('WindowManager: a content registry is required '
|
|
@@ -83,6 +83,12 @@ export class WindowManager {
|
|
|
83
83
|
* changes where a promoted window can be dragged, and an existing
|
|
84
84
|
* embedder upgrading must not find its windows suddenly clipped. */
|
|
85
85
|
this.promoteInPlace = !!promoteInPlace;
|
|
86
|
+
/** C32. Whether Backspace on an ENTITY tab closes it and returns to a list
|
|
87
|
+
* that is already open, instead of turning the tab into a second copy of
|
|
88
|
+
* that list. See `_backToOpenList`. Default off: it changes what the key
|
|
89
|
+
* does, and an existing embedder upgrading must not find tabs closing
|
|
90
|
+
* where they used to navigate. */
|
|
91
|
+
this.backToOpenList = !!backToOpenList;
|
|
86
92
|
this._snapCtl = null;
|
|
87
93
|
this.desktops = new DesktopManager({
|
|
88
94
|
seed: this._rootLeaf, panelDefaults: this.panelDefaults });
|
|
@@ -285,7 +291,10 @@ export class WindowManager {
|
|
|
285
291
|
this._notifyChange('navigate-back');
|
|
286
292
|
return;
|
|
287
293
|
}
|
|
288
|
-
// (2)
|
|
294
|
+
// (2) C32. An entity tab with nowhere left to go back to, beside a list
|
|
295
|
+
// of its own section that is already open: close it and land there.
|
|
296
|
+
if (this.backToOpenList && this._backToOpenList(id)) return;
|
|
297
|
+
// (3) Taxonomy fallback — the user's path is gone, so walk the
|
|
289
298
|
// canonical parent instead. An ENTITY-level parent wins (the
|
|
290
299
|
// instance one level up, e.g. the parent an id names); failing
|
|
291
300
|
// that, the KIND-level parent (this page's section).
|
|
@@ -347,6 +356,68 @@ export class WindowManager {
|
|
|
347
356
|
/** Navigate inside the current tab — preserves every other tab in
|
|
348
357
|
* the leaf. Used by Backspace + breadcrumb segments + anything
|
|
349
358
|
* else that should walk WITHIN the tile rather than reset it. */
|
|
359
|
+
/**
|
|
360
|
+
* C32. BACK FROM AN OPENED RECORD RETURNS TO THE LIST, IT DOES NOT MAKE ONE.
|
|
361
|
+
*
|
|
362
|
+
* The gesture this is for: a list tab, a record opened from it into a NEW
|
|
363
|
+
* tab, and Backspace in that record. With nothing left in the record tab's
|
|
364
|
+
* own history, step (3) of `navigateBack` walks the taxonomy up and REWRITES
|
|
365
|
+
* the record tab into its parent — so the tile ends up showing the list
|
|
366
|
+
* twice, once in the tab you started from and once in the tab you just left.
|
|
367
|
+
* The user's model is a browser's: you came from the list, the list is still
|
|
368
|
+
* open, so going back means closing this and being on the list again.
|
|
369
|
+
*
|
|
370
|
+
* So: when the active tab is an ENTITY — its props carry an `id`, the same
|
|
371
|
+
* test `swapToPage` uses for "the caller asked for a specific entity" — look
|
|
372
|
+
* for an open tab of the SAME section that is NOT one. That is the list or
|
|
373
|
+
* landing page the record belongs to. Close the record and activate it.
|
|
374
|
+
*
|
|
375
|
+
* NEAREST TO THE LEFT FIRST. New tabs are appended at the end, so the tab a
|
|
376
|
+
* record was opened from sits to its left; when several lists of the section
|
|
377
|
+
* are open, the nearest one on that side is the one it most plausibly came
|
|
378
|
+
* from. The right side is searched only after the left has nothing.
|
|
379
|
+
*
|
|
380
|
+
* DECLINES, and leaves step (3) to walk up in place, whenever there is no
|
|
381
|
+
* such tab: a record that is the tile's only tab, or one whose section has no
|
|
382
|
+
* list open. That keeps the one guarantee the whole rule exists for — Back
|
|
383
|
+
* never leaves two copies of a list — without ever closing a record into a
|
|
384
|
+
* tile that has nothing of its own to show.
|
|
385
|
+
*
|
|
386
|
+
* Matching is by SECTION (`topNavFor`) and not by kind, because the list a
|
|
387
|
+
* record was opened from is often not the record's taxonomy parent: a root
|
|
388
|
+
* landing page that renders the section, or a list that a flattened taxonomy
|
|
389
|
+
* files beside the record rather than above it.
|
|
390
|
+
*
|
|
391
|
+
* @returns {boolean} whether it closed a tab
|
|
392
|
+
*/
|
|
393
|
+
_backToOpenList(leafId) {
|
|
394
|
+
const tree = this._tree();
|
|
395
|
+
const leaf = tree.get(leafId);
|
|
396
|
+
if (!leaf || leaf.kind !== 'leaf') return false;
|
|
397
|
+
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
398
|
+
if (tabs.length < 2) return false;
|
|
399
|
+
const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
400
|
+
const active = tabs[activeIdx];
|
|
401
|
+
if (!_isEntityTab(active)) return false;
|
|
402
|
+
const section = this.taxonomy.topNavFor(active.kind);
|
|
403
|
+
if (!section) return false;
|
|
404
|
+
|
|
405
|
+
const isList = (t) => t && !_isEntityTab(t) && this.taxonomy.topNavFor(t.kind) === section;
|
|
406
|
+
let target = -1;
|
|
407
|
+
for (let i = activeIdx - 1; i >= 0 && target < 0; i--) if (isList(tabs[i])) target = i;
|
|
408
|
+
for (let i = activeIdx + 1; i < tabs.length && target < 0; i++) if (isList(tabs[i])) target = i;
|
|
409
|
+
if (target < 0) return false;
|
|
410
|
+
|
|
411
|
+
tree.removeLeafTab(leafId, activeIdx);
|
|
412
|
+
// Removing the active tab shifts everything after it one to the left.
|
|
413
|
+
tree.setActiveLeafTab(leafId, target > activeIdx ? target - 1 : target);
|
|
414
|
+
tree.focus(leafId);
|
|
415
|
+
this.renderer.render();
|
|
416
|
+
this._persist();
|
|
417
|
+
this._notifyChange('navigate-back-close');
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
350
421
|
navigateActiveTab(leafId, kind, props = {}) {
|
|
351
422
|
const tree = this._tree();
|
|
352
423
|
const leaf = tree.get(leafId);
|
|
@@ -3279,6 +3350,11 @@ export class WindowManager {
|
|
|
3279
3350
|
* `opts.transient` — the appended tab is not persisted/restored
|
|
3280
3351
|
* (e.g. an add-row form). Only meaningful with
|
|
3281
3352
|
* `newTab:true`.
|
|
3353
|
+
* `opts.background` — with `newTab`, append the tab WITHOUT switching to
|
|
3354
|
+
* it or focusing its tile. "Open in a background tab"
|
|
3355
|
+
* means the page you are reading stays in front;
|
|
3356
|
+
* without it the tab arrives and takes the screen,
|
|
3357
|
+
* which is what an ordinary click already does.
|
|
3282
3358
|
*
|
|
3283
3359
|
* Back-compat: the legacy `opts.target` enum still works and maps
|
|
3284
3360
|
* onto the axes — 'auto'→origin, 'tab'→origin+newTab,
|
|
@@ -3289,6 +3365,8 @@ export class WindowManager {
|
|
|
3289
3365
|
* `openInWindow`) stay internal; callers prefer `wm.navigate(...)`. */
|
|
3290
3366
|
navigate(kind, props = {}, opts = {}) {
|
|
3291
3367
|
const { ctx = null, transient = false } = opts;
|
|
3368
|
+
// Append the tab but stay where you are. Only meaningful with `newTab`.
|
|
3369
|
+
const background = !!opts.background;
|
|
3292
3370
|
// Resolve the two axes, honoring the legacy `target` alias.
|
|
3293
3371
|
let { dest = 'main', newTab = false } = opts;
|
|
3294
3372
|
if (opts.target != null) {
|
|
@@ -3314,12 +3392,12 @@ export class WindowManager {
|
|
|
3314
3392
|
if (dest === 'window') return this._navigateWindow(kind, props);
|
|
3315
3393
|
if (dest === 'main') {
|
|
3316
3394
|
return newTab
|
|
3317
|
-
? this.openInTabInPrimary(kind, props, transient)
|
|
3395
|
+
? this.openInTabInPrimary(kind, props, transient, background)
|
|
3318
3396
|
: this.openInPrimary(kind, props);
|
|
3319
3397
|
}
|
|
3320
3398
|
// dest === 'origin'
|
|
3321
3399
|
return newTab
|
|
3322
|
-
? this._navigateTab(ctx, kind, props, transient)
|
|
3400
|
+
? this._navigateTab(ctx, kind, props, transient, background)
|
|
3323
3401
|
: this._navigateAuto(ctx, kind, props);
|
|
3324
3402
|
}
|
|
3325
3403
|
|
|
@@ -3346,14 +3424,14 @@ export class WindowManager {
|
|
|
3346
3424
|
this.openInPrimary(kind, props);
|
|
3347
3425
|
}
|
|
3348
3426
|
|
|
3349
|
-
_navigateTab(ctx, kind, props, transient = false) {
|
|
3427
|
+
_navigateTab(ctx, kind, props, transient = false, background = false) {
|
|
3350
3428
|
// Windows aren't tabbed — "open in tab" inside a window just
|
|
3351
3429
|
// replaces the window's content.
|
|
3352
3430
|
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
3353
3431
|
this.openInWindow(ctx.windowId, kind, props);
|
|
3354
3432
|
return;
|
|
3355
3433
|
}
|
|
3356
|
-
this.openInTabFromContext(ctx || {}, kind, props, transient);
|
|
3434
|
+
this.openInTabFromContext(ctx || {}, kind, props, transient, background);
|
|
3357
3435
|
}
|
|
3358
3436
|
|
|
3359
3437
|
/** Spawn a fresh ManagedWindow with the requested content. No
|
|
@@ -3486,7 +3564,7 @@ export class WindowManager {
|
|
|
3486
3564
|
* Mirrors `openFromContext` (windowed / split-leaf / primary
|
|
3487
3565
|
* routing) but uses `appendLeafTab` so the existing content
|
|
3488
3566
|
* stays in place as a tab. */
|
|
3489
|
-
openInTabFromContext(ctx, kind, props = {}, transient = false) {
|
|
3567
|
+
openInTabFromContext(ctx, kind, props = {}, transient = false, background = false) {
|
|
3490
3568
|
// Managed-window content: just open in the window — managed
|
|
3491
3569
|
// windows aren't tabbed (one window = one content).
|
|
3492
3570
|
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
@@ -3504,8 +3582,11 @@ export class WindowManager {
|
|
|
3504
3582
|
this.openInPrimary(kind, props);
|
|
3505
3583
|
return;
|
|
3506
3584
|
}
|
|
3507
|
-
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
|
|
3508
|
-
|
|
3585
|
+
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
|
|
3586
|
+
{ transient, background });
|
|
3587
|
+
// A BACKGROUND tab must not steal the tile's focus either — the point
|
|
3588
|
+
// is that the user stays exactly where they were.
|
|
3589
|
+
if (!background) tree.focus(leafId);
|
|
3509
3590
|
this.renderer.render();
|
|
3510
3591
|
this._persist();
|
|
3511
3592
|
this._notifyChange('tab-open');
|
|
@@ -3518,7 +3599,7 @@ export class WindowManager {
|
|
|
3518
3599
|
* click from outside the tile system (e.g. the bottom-panel
|
|
3519
3600
|
* "Add row" button, which passes no ctx) reliably lands as a sibling
|
|
3520
3601
|
* tab in the main tile rather than swapping its content. */
|
|
3521
|
-
openInTabInPrimary(kind, props = {}, transient = false) {
|
|
3602
|
+
openInTabInPrimary(kind, props = {}, transient = false, background = false) {
|
|
3522
3603
|
const tree = this._tree();
|
|
3523
3604
|
const leafId = tree.primaryLeafId();
|
|
3524
3605
|
// No content tile on this desktop (e.g. a panels-only layout) —
|
|
@@ -3526,8 +3607,11 @@ export class WindowManager {
|
|
|
3526
3607
|
// caller asked for "a tab in the main tile"; with no main tile to
|
|
3527
3608
|
// tab into, a floating window is the least-surprising fallback.
|
|
3528
3609
|
if (!leafId) { this._navigateWindow(kind, props); return; }
|
|
3529
|
-
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
|
|
3530
|
-
|
|
3610
|
+
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
|
|
3611
|
+
{ transient, background });
|
|
3612
|
+
// A BACKGROUND tab must not steal the tile's focus either — the point
|
|
3613
|
+
// is that the user stays exactly where they were.
|
|
3614
|
+
if (!background) tree.focus(leafId);
|
|
3531
3615
|
this.renderer.render();
|
|
3532
3616
|
this._persist();
|
|
3533
3617
|
this._notifyChange('tab-open');
|
|
@@ -3628,3 +3712,8 @@ function _swapSiblings(tree, aId, bId) {
|
|
|
3628
3712
|
return true;
|
|
3629
3713
|
}
|
|
3630
3714
|
|
|
3715
|
+
/** A tab showing ONE record rather than a list: its props name an `id`. The same
|
|
3716
|
+
* test `swapToPage` applies to "the caller asked for a specific entity". */
|
|
3717
|
+
function _isEntityTab(tab) {
|
|
3718
|
+
return !!tab && tab.props != null && tab.props.id != null && tab.props.id !== '';
|
|
3719
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zoom.js — the shell's content zoom: a − / track / + / readout control, and
|
|
3
|
+
* the one place that decides what "zoom the workspace" is allowed to touch.
|
|
4
|
+
*
|
|
5
|
+
* Opt-in, like every shell feature that changes what the user sees: an embedder
|
|
6
|
+
* that passes no `chrome.zoom` element gets no control and no scaling, so no
|
|
7
|
+
* existing consumer changes by upgrading. The control is Excel's — a continuous
|
|
8
|
+
* track with a detent-free middle, buttons either side that move in tens, and a
|
|
9
|
+
* readout that is itself the reset — because that is the shape people already
|
|
10
|
+
* know, and it is the one Tables shipped in its own status bar before this was
|
|
11
|
+
* lifted into the framework.
|
|
12
|
+
*
|
|
13
|
+
* ── WHAT IT SCALES, AND WHAT IT MUST NOT ───────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* It scales CONTENT SURFACES and nothing a window is dragged across:
|
|
16
|
+
*
|
|
17
|
+
* .twm-leaf__body what a tile's content factory mounted into
|
|
18
|
+
* .twm-window-content what a promoted window's content mounted into
|
|
19
|
+
*
|
|
20
|
+
* and it does NOT scale the root, a leaf wrap, tile chrome, tab bars, or a
|
|
21
|
+
* window frame. That line is load-bearing rather than a matter of taste. CSS
|
|
22
|
+
* `zoom` establishes a scaled coordinate space, and FlexDesk's window drag,
|
|
23
|
+
* resize and snap all do arithmetic between the pointer (viewport pixels) and a
|
|
24
|
+
* window's `left`/`top` (the pixels of whatever contains it). A contained window
|
|
25
|
+
* (C21) lives in a LEAF WRAP and is re-parented to the ROOT for the length of a
|
|
26
|
+
* drag (R1); zoom either of those and every drag drifts by the zoom factor, and
|
|
27
|
+
* every C15 snap probe measures a tile in units the pointer is not in. Tile
|
|
28
|
+
* bodies and window content sit BELOW all of that geometry, so scaling them
|
|
29
|
+
* changes the text and none of the maths.
|
|
30
|
+
*
|
|
31
|
+
* ── HOW IT IS APPLIED ─────────────────────────────────────────────────────
|
|
32
|
+
*
|
|
33
|
+
* A CSS variable and a class, both on `root` — the element the embedder handed
|
|
34
|
+
* the shell. Never `document.body`, never a selector the framework did not
|
|
35
|
+
* author (the doctrine at the top of shell.js). Tiles and windows are created
|
|
36
|
+
* and destroyed long after any given change, so anything written element by
|
|
37
|
+
* element would have to be re-applied on every mount; a variable on the root is
|
|
38
|
+
* read by whatever exists at the time.
|
|
39
|
+
*
|
|
40
|
+
* The class is what keeps 100% free of any declaration. A rule that always said
|
|
41
|
+
* `zoom: var(--twm-zoom, 1)` would establish a scaled coordinate space even at
|
|
42
|
+
* 1, so at the default the class comes off and no `zoom` applies anywhere — an
|
|
43
|
+
* unzoomed shell lays out byte-for-byte as it did before this existed.
|
|
44
|
+
*
|
|
45
|
+
* A window an embedder mounts on `document.body` itself is outside the shell's
|
|
46
|
+
* root and therefore outside this: it is not the shell's to scale. Every window
|
|
47
|
+
* the WM promotes under `promoteInPlace` (C21) is inside the root, and so is
|
|
48
|
+
* every window for the duration of a drag.
|
|
49
|
+
*
|
|
50
|
+
* ── `zoom`, NOT `transform: scale()` ──────────────────────────────────────
|
|
51
|
+
*
|
|
52
|
+
* `zoom` reflows: text stays on the pixel grid, and a scroll container still
|
|
53
|
+
* measures the content it is scrolling. A transform would blur the text and
|
|
54
|
+
* leave the layout box at its old size, so a zoomed-in table would overflow a
|
|
55
|
+
* pane that did not know it had grown.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/** The range. Below 50% a data row stops being readable; above 200% a typical
|
|
59
|
+
* row no longer fits its own columns. */
|
|
60
|
+
export const ZOOM_MIN = 50;
|
|
61
|
+
export const ZOOM_MAX = 200;
|
|
62
|
+
|
|
63
|
+
/** The track's granularity — fine, because a control you have to aim is one
|
|
64
|
+
* people stop using. */
|
|
65
|
+
export const ZOOM_STEP = 5;
|
|
66
|
+
|
|
67
|
+
/** The BUTTONS' step: Excel's split, where the track is continuous and the − / +
|
|
68
|
+
* cover ground. Commensurate with ZOOM_STEP by construction, so a button press
|
|
69
|
+
* always lands on a notch and repeated presses cannot drift. */
|
|
70
|
+
export const ZOOM_NUDGE = 10;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where the shell opens, and where a reset returns. Load-bearing: `applyZoom`
|
|
74
|
+
* removes the class at exactly this value, and that is what makes "unzoomed"
|
|
75
|
+
* mean "no declaration at all". Only true while the default IS 100.
|
|
76
|
+
*/
|
|
77
|
+
export const ZOOM_DEFAULT = 100;
|
|
78
|
+
|
|
79
|
+
/** The logical key under the host's `state` capability. A per-person display
|
|
80
|
+
* preference, persisted the same way the desktops are. */
|
|
81
|
+
export const ZOOM_STATE_KEY = 'zoom';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A number from anywhere — a slider, a stored preference, a caller — as a zoom
|
|
85
|
+
* the shell will accept.
|
|
86
|
+
*
|
|
87
|
+
* `null`, `undefined` and `''` are "no value" and read as the default. They are
|
|
88
|
+
* checked BEFORE the cast because `Number()` turns both `null` and `''` into 0 —
|
|
89
|
+
* a perfectly finite number that would then clamp to the floor, so a host with
|
|
90
|
+
* no saved zoom, or a read that failed, would silently open the shell at 50%.
|
|
91
|
+
*
|
|
92
|
+
* It QUANTISES, which is what makes 100% reachable: the track is stepped, so a
|
|
93
|
+
* stored 97 has to land on a notch rather than sit between two where neither the
|
|
94
|
+
* track nor the buttons can leave it.
|
|
95
|
+
*/
|
|
96
|
+
export function clampZoom(value) {
|
|
97
|
+
if (value == null || value === '') return ZOOM_DEFAULT;
|
|
98
|
+
const n = Number(value);
|
|
99
|
+
if (!Number.isFinite(n)) return ZOOM_DEFAULT;
|
|
100
|
+
const stepped = Math.round(n / ZOOM_STEP) * ZOOM_STEP;
|
|
101
|
+
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, stepped));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Apply one zoom to a shell root. Idempotent. Takes the ELEMENT, never finds it.
|
|
106
|
+
*
|
|
107
|
+
* @param {Element} root the shell's root
|
|
108
|
+
* @param {number} percent already clamped
|
|
109
|
+
*/
|
|
110
|
+
export function applyZoom(root, percent) {
|
|
111
|
+
if (!root?.style) return;
|
|
112
|
+
root.style.setProperty('--twm-zoom', String(percent / 100));
|
|
113
|
+
root.classList.toggle('twm-zoomed', percent !== ZOOM_DEFAULT);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Paint the control into the element the embedder handed over, restore the saved
|
|
118
|
+
* zoom, and keep the root in step.
|
|
119
|
+
*
|
|
120
|
+
* @param {Element|null} hostEl where the control goes; absent → no control
|
|
121
|
+
* @param {object} opts
|
|
122
|
+
* @param {Element} opts.root the shell root the zoom applies to
|
|
123
|
+
* @param {object} [opts.host] the host port; its `state` persists the zoom
|
|
124
|
+
* @param {string} [opts.stateKey]
|
|
125
|
+
* @param {Function} [opts.onChange] `(percent) => void`, after every change
|
|
126
|
+
* @returns {{el: Element, get: () => number, set: (percent: number) => void,
|
|
127
|
+
* ready: Promise<number>, dispose: () => void} | null}
|
|
128
|
+
*/
|
|
129
|
+
export function mountZoomControl(hostEl, { root, host = null, stateKey = ZOOM_STATE_KEY, onChange } = {}) {
|
|
130
|
+
if (!hostEl || !root) return null;
|
|
131
|
+
|
|
132
|
+
const doc = hostEl.ownerDocument;
|
|
133
|
+
let current = ZOOM_DEFAULT;
|
|
134
|
+
let disposed = false;
|
|
135
|
+
|
|
136
|
+
const el = doc.createElement('div');
|
|
137
|
+
el.className = 'twm-zoom';
|
|
138
|
+
|
|
139
|
+
const stepButton = (label, title, delta) => {
|
|
140
|
+
const b = doc.createElement('button');
|
|
141
|
+
b.type = 'button';
|
|
142
|
+
b.className = 'twm-zoom__step';
|
|
143
|
+
b.textContent = label;
|
|
144
|
+
b.title = title;
|
|
145
|
+
b.setAttribute('aria-label', title);
|
|
146
|
+
b.addEventListener('click', () => commit(clampZoom(current + delta)));
|
|
147
|
+
return b;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const slider = doc.createElement('input');
|
|
151
|
+
slider.type = 'range';
|
|
152
|
+
slider.className = 'twm-zoom__slider';
|
|
153
|
+
slider.min = String(ZOOM_MIN);
|
|
154
|
+
slider.max = String(ZOOM_MAX);
|
|
155
|
+
slider.step = String(ZOOM_STEP);
|
|
156
|
+
slider.value = String(ZOOM_DEFAULT);
|
|
157
|
+
slider.title = `Zoom the workspace, ${ZOOM_MIN}–${ZOOM_MAX}% — double-click to reset.`;
|
|
158
|
+
slider.setAttribute('aria-label', 'Zoom the workspace');
|
|
159
|
+
// `input`, NOT `change`: the readout has to follow the thumb while it is
|
|
160
|
+
// being dragged, or the number under the mouse is the number you left.
|
|
161
|
+
slider.addEventListener('input', () => commit(clampZoom(slider.value)));
|
|
162
|
+
// A double-click on the track resets. It arrives after two mousedowns that
|
|
163
|
+
// each set the value and fire `input`, so the honest description is "one real
|
|
164
|
+
// write, then the reset", and a brief jump to wherever you clicked is visible
|
|
165
|
+
// before it snaps back. Excel's track does exactly that. It must not be
|
|
166
|
+
// "fixed" by swallowing `input` — that is the event the drag is made of.
|
|
167
|
+
slider.addEventListener('dblclick', () => commit(ZOOM_DEFAULT));
|
|
168
|
+
|
|
169
|
+
// The readout IS the reset, where Excel puts it and where a hand already is.
|
|
170
|
+
// A separate "100%" button would be a fourth control in a bar 20px tall, and a
|
|
171
|
+
// percentage nobody can click answers the question while refusing the obvious
|
|
172
|
+
// next request.
|
|
173
|
+
const readout = doc.createElement('button');
|
|
174
|
+
readout.type = 'button';
|
|
175
|
+
readout.className = 'twm-zoom__value';
|
|
176
|
+
readout.title = `Back to ${ZOOM_DEFAULT}%`;
|
|
177
|
+
readout.addEventListener('click', () => commit(ZOOM_DEFAULT));
|
|
178
|
+
|
|
179
|
+
el.append(
|
|
180
|
+
stepButton('−', `Zoom out ${ZOOM_NUDGE}%`, -ZOOM_NUDGE),
|
|
181
|
+
slider,
|
|
182
|
+
stepButton('+', `Zoom in ${ZOOM_NUDGE}%`, ZOOM_NUDGE),
|
|
183
|
+
readout,
|
|
184
|
+
);
|
|
185
|
+
hostEl.appendChild(el);
|
|
186
|
+
|
|
187
|
+
/** Paint the control and the root. No persistence — used by the restore,
|
|
188
|
+
* where writing back what was just read would be a pointless round trip. */
|
|
189
|
+
const paint = (percent) => {
|
|
190
|
+
current = percent;
|
|
191
|
+
slider.value = String(percent);
|
|
192
|
+
readout.textContent = `${percent}%`;
|
|
193
|
+
applyZoom(root, percent);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Debounced, because a drag fires `input` per pixel and each one would
|
|
197
|
+
// otherwise be a write through the host. A host without `state` is legal,
|
|
198
|
+
// exactly as it is for the desktops: the control still works, the zoom just
|
|
199
|
+
// does not survive a reload.
|
|
200
|
+
let saveTimer = 0;
|
|
201
|
+
const persist = (percent) => {
|
|
202
|
+
const state = host?.state;
|
|
203
|
+
if (!state) return;
|
|
204
|
+
clearTimeout(saveTimer);
|
|
205
|
+
saveTimer = setTimeout(() => {
|
|
206
|
+
Promise.resolve()
|
|
207
|
+
.then(() => state.write(stateKey, percent))
|
|
208
|
+
.catch((err) => console.warn('[zoom] save failed', err));
|
|
209
|
+
}, 400);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const commit = (percent) => {
|
|
213
|
+
if (disposed || percent === current) return;
|
|
214
|
+
paint(percent);
|
|
215
|
+
persist(percent);
|
|
216
|
+
try { onChange?.(percent); } catch (err) { console.warn('[zoom] onChange threw', err); }
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
paint(ZOOM_DEFAULT);
|
|
220
|
+
|
|
221
|
+
// Restored after the control is already on screen and usable: a zoom is not
|
|
222
|
+
// worth blocking a first paint on, and a failed read costs the default. The
|
|
223
|
+
// promise is returned so an embedder that DOES want to wait before mounting —
|
|
224
|
+
// to avoid content painting at 100% and then jumping — can.
|
|
225
|
+
const ready = Promise.resolve()
|
|
226
|
+
.then(() => host?.state?.read?.(stateKey))
|
|
227
|
+
.then((saved) => {
|
|
228
|
+
if (!disposed && saved != null) paint(clampZoom(saved));
|
|
229
|
+
return current;
|
|
230
|
+
})
|
|
231
|
+
.catch((err) => {
|
|
232
|
+
console.warn('[zoom] load failed', err);
|
|
233
|
+
return current;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
el,
|
|
238
|
+
get: () => current,
|
|
239
|
+
set: (percent) => commit(clampZoom(percent)),
|
|
240
|
+
ready,
|
|
241
|
+
dispose: () => {
|
|
242
|
+
disposed = true;
|
|
243
|
+
clearTimeout(saveTimer);
|
|
244
|
+
el.remove();
|
|
245
|
+
applyZoom(root, ZOOM_DEFAULT);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|