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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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
 
@@ -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
  }
@@ -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
@@ -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), { transient });
3508
- tree.focus(leafId);
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), { transient });
3530
- tree.focus(leafId);
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
+ }