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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
  }
@@ -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) OR for a
302
- // page kind that ISN'T the nav category itself (e.g. `settings`
303
- // lives under the `home` topNav). In both cases we must surface
304
- // the requested target rather than silently showing whatever
305
- // the restored page happened to hold. A bare top-nav click
306
- // (target.kind === targetTopNav, no id) falls through and just
307
- // restores the saved tabs as-is.
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
- n.activeTabIdx = n.tabs.length - 1;
493
- _syncActiveTab(n);
494
- return n.activeTabIdx;
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) Taxonomy fallback — the user's path is gone, so walk the
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), { transient });
3508
- tree.focus(leafId);
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), { transient });
3530
- tree.focus(leafId);
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
+ }