flexdesk 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/css/base.css +68 -0
- package/css/flexdesk.css +68 -0
- package/dist/flexdesk.css +68 -0
- package/dist/wm.js +157 -15
- package/dist/wm.js.map +3 -3
- package/package.json +1 -1
- package/src/tiling/shell.js +21 -1
- package/src/tiling/tile_tree.js +33 -11
- package/src/tiling/wm.js +23 -10
- 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
|
|
|
@@ -97,7 +98,12 @@ import { openForm } from '../ui/components/modal.js';
|
|
|
97
98
|
* embedder whose settings pane holds only
|
|
98
99
|
* the root element can change it live.
|
|
99
100
|
* @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
|
|
100
|
-
* panelToggles?: { left?, right?, bottom? }
|
|
101
|
+
* panelToggles?: { left?, right?, bottom? },
|
|
102
|
+
* zoom? }
|
|
103
|
+
* `zoom` is C31: the element the content
|
|
104
|
+
* zoom control is painted into — see
|
|
105
|
+
* zoom.js. Absent, there is no control and
|
|
106
|
+
* nothing is ever scaled.
|
|
101
107
|
* @returns {Promise<object>} the frozen shell
|
|
102
108
|
*/
|
|
103
109
|
export async function createShell({
|
|
@@ -185,6 +191,13 @@ export async function createShell({
|
|
|
185
191
|
const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
|
|
186
192
|
topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
|
|
187
193
|
desktopsEl = mountDesktopBar(chrome.desktops, wm);
|
|
194
|
+
// C31. The content zoom. Painted with the rest of the chrome and, unlike the
|
|
195
|
+
// rest, AWAITED before the first mount: its saved value is applied to `root`
|
|
196
|
+
// as a CSS variable, and a shell that mounted first would paint every tile at
|
|
197
|
+
// 100% and then visibly jump to the user's zoom a moment later. A host with no
|
|
198
|
+
// saved zoom — or no `state` at all — resolves immediately to the default.
|
|
199
|
+
const zoom = mountZoomControl(chrome.zoom, { root, host });
|
|
200
|
+
if (zoom) await zoom.ready;
|
|
188
201
|
// NOTE: bindPanelToggles CLONES the buttons (to strip whatever state the
|
|
189
202
|
// embedder's own machinery left on them) and returns the FRESH nodes. The
|
|
190
203
|
// originals are detached from here on — sync against the returned map, not
|
|
@@ -211,6 +224,9 @@ export async function createShell({
|
|
|
211
224
|
topNavEl,
|
|
212
225
|
desktopsEl,
|
|
213
226
|
panelToggles: toggles,
|
|
227
|
+
// `get`/`set` so an embedder can drive the zoom from its own settings
|
|
228
|
+
// pane, or read it, without reaching into the control's DOM.
|
|
229
|
+
zoom,
|
|
214
230
|
}),
|
|
215
231
|
// A shell that can be built can be built TWICE — an embedder that
|
|
216
232
|
// rebuilds on a context change (a different project, a different
|
|
@@ -229,6 +245,10 @@ export async function createShell({
|
|
|
229
245
|
// captured before a rebuild, a promise that has not settled —
|
|
230
246
|
// cannot repaint a dead tree into a root the live shell now owns.
|
|
231
247
|
try { wm.renderer.destroy(); } catch (err) { log.warn?.('renderer teardown', err); }
|
|
248
|
+
// The zoom lives on `root` as a variable and a class. A rebuilt shell on
|
|
249
|
+
// the same root must not inherit the old one's scale with no control on
|
|
250
|
+
// screen to change it, so dispose puts the root back to 100%.
|
|
251
|
+
try { zoom?.dispose(); } catch { /* ignore */ }
|
|
232
252
|
},
|
|
233
253
|
});
|
|
234
254
|
}
|
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
|
@@ -3279,6 +3279,11 @@ export class WindowManager {
|
|
|
3279
3279
|
* `opts.transient` — the appended tab is not persisted/restored
|
|
3280
3280
|
* (e.g. an add-row form). Only meaningful with
|
|
3281
3281
|
* `newTab:true`.
|
|
3282
|
+
* `opts.background` — with `newTab`, append the tab WITHOUT switching to
|
|
3283
|
+
* it or focusing its tile. "Open in a background tab"
|
|
3284
|
+
* means the page you are reading stays in front;
|
|
3285
|
+
* without it the tab arrives and takes the screen,
|
|
3286
|
+
* which is what an ordinary click already does.
|
|
3282
3287
|
*
|
|
3283
3288
|
* Back-compat: the legacy `opts.target` enum still works and maps
|
|
3284
3289
|
* onto the axes — 'auto'→origin, 'tab'→origin+newTab,
|
|
@@ -3289,6 +3294,8 @@ export class WindowManager {
|
|
|
3289
3294
|
* `openInWindow`) stay internal; callers prefer `wm.navigate(...)`. */
|
|
3290
3295
|
navigate(kind, props = {}, opts = {}) {
|
|
3291
3296
|
const { ctx = null, transient = false } = opts;
|
|
3297
|
+
// Append the tab but stay where you are. Only meaningful with `newTab`.
|
|
3298
|
+
const background = !!opts.background;
|
|
3292
3299
|
// Resolve the two axes, honoring the legacy `target` alias.
|
|
3293
3300
|
let { dest = 'main', newTab = false } = opts;
|
|
3294
3301
|
if (opts.target != null) {
|
|
@@ -3314,12 +3321,12 @@ export class WindowManager {
|
|
|
3314
3321
|
if (dest === 'window') return this._navigateWindow(kind, props);
|
|
3315
3322
|
if (dest === 'main') {
|
|
3316
3323
|
return newTab
|
|
3317
|
-
? this.openInTabInPrimary(kind, props, transient)
|
|
3324
|
+
? this.openInTabInPrimary(kind, props, transient, background)
|
|
3318
3325
|
: this.openInPrimary(kind, props);
|
|
3319
3326
|
}
|
|
3320
3327
|
// dest === 'origin'
|
|
3321
3328
|
return newTab
|
|
3322
|
-
? this._navigateTab(ctx, kind, props, transient)
|
|
3329
|
+
? this._navigateTab(ctx, kind, props, transient, background)
|
|
3323
3330
|
: this._navigateAuto(ctx, kind, props);
|
|
3324
3331
|
}
|
|
3325
3332
|
|
|
@@ -3346,14 +3353,14 @@ export class WindowManager {
|
|
|
3346
3353
|
this.openInPrimary(kind, props);
|
|
3347
3354
|
}
|
|
3348
3355
|
|
|
3349
|
-
_navigateTab(ctx, kind, props, transient = false) {
|
|
3356
|
+
_navigateTab(ctx, kind, props, transient = false, background = false) {
|
|
3350
3357
|
// Windows aren't tabbed — "open in tab" inside a window just
|
|
3351
3358
|
// replaces the window's content.
|
|
3352
3359
|
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
3353
3360
|
this.openInWindow(ctx.windowId, kind, props);
|
|
3354
3361
|
return;
|
|
3355
3362
|
}
|
|
3356
|
-
this.openInTabFromContext(ctx || {}, kind, props, transient);
|
|
3363
|
+
this.openInTabFromContext(ctx || {}, kind, props, transient, background);
|
|
3357
3364
|
}
|
|
3358
3365
|
|
|
3359
3366
|
/** Spawn a fresh ManagedWindow with the requested content. No
|
|
@@ -3486,7 +3493,7 @@ export class WindowManager {
|
|
|
3486
3493
|
* Mirrors `openFromContext` (windowed / split-leaf / primary
|
|
3487
3494
|
* routing) but uses `appendLeafTab` so the existing content
|
|
3488
3495
|
* stays in place as a tab. */
|
|
3489
|
-
openInTabFromContext(ctx, kind, props = {}, transient = false) {
|
|
3496
|
+
openInTabFromContext(ctx, kind, props = {}, transient = false, background = false) {
|
|
3490
3497
|
// Managed-window content: just open in the window — managed
|
|
3491
3498
|
// windows aren't tabbed (one window = one content).
|
|
3492
3499
|
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
@@ -3504,8 +3511,11 @@ export class WindowManager {
|
|
|
3504
3511
|
this.openInPrimary(kind, props);
|
|
3505
3512
|
return;
|
|
3506
3513
|
}
|
|
3507
|
-
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
|
|
3508
|
-
|
|
3514
|
+
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
|
|
3515
|
+
{ transient, background });
|
|
3516
|
+
// A BACKGROUND tab must not steal the tile's focus either — the point
|
|
3517
|
+
// is that the user stays exactly where they were.
|
|
3518
|
+
if (!background) tree.focus(leafId);
|
|
3509
3519
|
this.renderer.render();
|
|
3510
3520
|
this._persist();
|
|
3511
3521
|
this._notifyChange('tab-open');
|
|
@@ -3518,7 +3528,7 @@ export class WindowManager {
|
|
|
3518
3528
|
* click from outside the tile system (e.g. the bottom-panel
|
|
3519
3529
|
* "Add row" button, which passes no ctx) reliably lands as a sibling
|
|
3520
3530
|
* tab in the main tile rather than swapping its content. */
|
|
3521
|
-
openInTabInPrimary(kind, props = {}, transient = false) {
|
|
3531
|
+
openInTabInPrimary(kind, props = {}, transient = false, background = false) {
|
|
3522
3532
|
const tree = this._tree();
|
|
3523
3533
|
const leafId = tree.primaryLeafId();
|
|
3524
3534
|
// No content tile on this desktop (e.g. a panels-only layout) —
|
|
@@ -3526,8 +3536,11 @@ export class WindowManager {
|
|
|
3526
3536
|
// caller asked for "a tab in the main tile"; with no main tile to
|
|
3527
3537
|
// tab into, a floating window is the least-surprising fallback.
|
|
3528
3538
|
if (!leafId) { this._navigateWindow(kind, props); return; }
|
|
3529
|
-
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
|
|
3530
|
-
|
|
3539
|
+
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
|
|
3540
|
+
{ transient, background });
|
|
3541
|
+
// A BACKGROUND tab must not steal the tile's focus either — the point
|
|
3542
|
+
// is that the user stays exactly where they were.
|
|
3543
|
+
if (!background) tree.focus(leafId);
|
|
3531
3544
|
this.renderer.render();
|
|
3532
3545
|
this._persist();
|
|
3533
3546
|
this._notifyChange('tab-open');
|
|
@@ -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
|
+
}
|