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/README.md
CHANGED
|
@@ -104,6 +104,32 @@ const shell = await createShell({
|
|
|
104
104
|
A working version of exactly this — planets and moons, a mock host, no backend —
|
|
105
105
|
lives in `demo/`.
|
|
106
106
|
|
|
107
|
+
### Opt-in shell features
|
|
108
|
+
|
|
109
|
+
Two features change what the user sees, so both are off until you ask:
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
await createShell({
|
|
113
|
+
// …everything above…
|
|
114
|
+
snapPromotion: true, // drag a floating window onto a tile: it snaps, and docks back in
|
|
115
|
+
promoteInPlace: true, // a window floated out of a tile stays inside that tile's pane
|
|
116
|
+
chrome: {
|
|
117
|
+
topNav: document.getElementById('nav'),
|
|
118
|
+
zoom: document.getElementById('status-bar'), // content zoom, 50–200%
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**`chrome.zoom`** paints a − / track / + / readout control into the element you
|
|
124
|
+
pass, restores the saved zoom through the host's `state` capability, and scales
|
|
125
|
+
the content of tiles and floating windows. It deliberately scales *content only*
|
|
126
|
+
— never the root, a tile's frame or a window's frame — because CSS `zoom`
|
|
127
|
+
establishes a scaled coordinate space and every window drag, resize and snap
|
|
128
|
+
measures in real pixels. `shell.chrome.zoom` exposes `get()` and `set(percent)`
|
|
129
|
+
for a settings pane. An embedder that builds its own `WindowManager` instead of
|
|
130
|
+
calling `createShell` mounts the same control with `mountZoomControl(el, { root,
|
|
131
|
+
host })`.
|
|
132
|
+
|
|
107
133
|
## The host port
|
|
108
134
|
|
|
109
135
|
FlexDesk never touches `pywebview`, `fetch`, `localStorage` or the filesystem. It
|
package/css/base.css
CHANGED
|
@@ -4740,3 +4740,71 @@ button.twm-about-dialog__btn--close:focus {
|
|
|
4740
4740
|
* `position: fixed` descendants, which would clip every dropdown the grid opens
|
|
4741
4741
|
* on purpose. */
|
|
4742
4742
|
.twm-root { position: relative; }
|
|
4743
|
+
|
|
4744
|
+
/* ── C31. Content zoom ─────────────────────────────────────────────
|
|
4745
|
+
Applied by zoom.js as `--twm-zoom` plus `.twm-zoomed` on the shell
|
|
4746
|
+
ROOT. The class is only set away from 100%, so an unzoomed shell has
|
|
4747
|
+
no `zoom` declaration anywhere: `zoom: 1` is not a no-op, it still
|
|
4748
|
+
establishes a scaled coordinate space.
|
|
4749
|
+
|
|
4750
|
+
CONTENT SURFACES ONLY. A tile's body and a promoted window's content
|
|
4751
|
+
sit below every piece of geometry the window drag, resize and C15
|
|
4752
|
+
snap probe read. The root, a leaf wrap, tile chrome, tab bars and a
|
|
4753
|
+
window frame are deliberately absent: a contained window lives in a
|
|
4754
|
+
leaf wrap and is re-parented to the root while it is dragged, so
|
|
4755
|
+
zooming either would drift every drag by the zoom factor. See zoom.js. */
|
|
4756
|
+
.twm-zoomed .twm-leaf__body,
|
|
4757
|
+
.twm-zoomed .twm-window-content { zoom: var(--twm-zoom, 1); }
|
|
4758
|
+
|
|
4759
|
+
/* The control. Sized against the bar it sits in, not the zoom — it is
|
|
4760
|
+
chrome, and it is the one thing that must stay reachable at 200%. */
|
|
4761
|
+
.twm-zoom {
|
|
4762
|
+
display: inline-flex;
|
|
4763
|
+
align-items: center;
|
|
4764
|
+
gap: 3px;
|
|
4765
|
+
height: 100%;
|
|
4766
|
+
padding: 0 8px;
|
|
4767
|
+
border-left: 1px solid var(--surface-3);
|
|
4768
|
+
user-select: none;
|
|
4769
|
+
}
|
|
4770
|
+
.twm-zoom__step {
|
|
4771
|
+
display: inline-flex;
|
|
4772
|
+
align-items: center;
|
|
4773
|
+
justify-content: center;
|
|
4774
|
+
width: 15px;
|
|
4775
|
+
height: 15px;
|
|
4776
|
+
padding: 0;
|
|
4777
|
+
border: none;
|
|
4778
|
+
border-radius: 2px;
|
|
4779
|
+
background: none;
|
|
4780
|
+
color: var(--text-muted);
|
|
4781
|
+
font: inherit;
|
|
4782
|
+
font-size: var(--font-size-sm);
|
|
4783
|
+
line-height: 1;
|
|
4784
|
+
cursor: pointer;
|
|
4785
|
+
}
|
|
4786
|
+
.twm-zoom__step:hover { background: var(--surface-3); color: var(--text-primary); }
|
|
4787
|
+
/* A short track on purpose: it sits between two buttons in a thin bar,
|
|
4788
|
+
and the readout — not the thumb position — answers "where am I". */
|
|
4789
|
+
.twm-zoom__slider {
|
|
4790
|
+
width: 78px;
|
|
4791
|
+
height: 12px;
|
|
4792
|
+
margin: 0;
|
|
4793
|
+
cursor: pointer;
|
|
4794
|
+
accent-color: var(--accent-bright);
|
|
4795
|
+
}
|
|
4796
|
+
/* Tabular numerals, so the readout does not jiggle between 95% and 100%
|
|
4797
|
+
while the thumb moves. */
|
|
4798
|
+
.twm-zoom__value {
|
|
4799
|
+
min-width: 34px;
|
|
4800
|
+
padding: 0;
|
|
4801
|
+
border: none;
|
|
4802
|
+
background: none;
|
|
4803
|
+
color: var(--text-muted);
|
|
4804
|
+
font-family: var(--font-family-mono);
|
|
4805
|
+
font-size: var(--font-size-xs);
|
|
4806
|
+
font-variant-numeric: tabular-nums;
|
|
4807
|
+
text-align: right;
|
|
4808
|
+
cursor: pointer;
|
|
4809
|
+
}
|
|
4810
|
+
.twm-zoom__value:hover { color: var(--text-primary); }
|
package/css/flexdesk.css
CHANGED
|
@@ -7088,3 +7088,71 @@ tr:hover .twm-row-action { opacity: 1; }
|
|
|
7088
7088
|
.twm-top-nav__label {
|
|
7089
7089
|
/* hidden on narrow viewports; icon alone is enough */
|
|
7090
7090
|
}
|
|
7091
|
+
|
|
7092
|
+
/* ── C31. Content zoom ─────────────────────────────────────────────
|
|
7093
|
+
Applied by zoom.js as `--twm-zoom` plus `.twm-zoomed` on the shell
|
|
7094
|
+
ROOT. The class is only set away from 100%, so an unzoomed shell has
|
|
7095
|
+
no `zoom` declaration anywhere: `zoom: 1` is not a no-op, it still
|
|
7096
|
+
establishes a scaled coordinate space.
|
|
7097
|
+
|
|
7098
|
+
CONTENT SURFACES ONLY. A tile's body and a promoted window's content
|
|
7099
|
+
sit below every piece of geometry the window drag, resize and C15
|
|
7100
|
+
snap probe read. The root, a leaf wrap, tile chrome, tab bars and a
|
|
7101
|
+
window frame are deliberately absent: a contained window lives in a
|
|
7102
|
+
leaf wrap and is re-parented to the root while it is dragged, so
|
|
7103
|
+
zooming either would drift every drag by the zoom factor. See zoom.js. */
|
|
7104
|
+
.twm-zoomed .twm-leaf__body,
|
|
7105
|
+
.twm-zoomed .twm-window-content { zoom: var(--twm-zoom, 1); }
|
|
7106
|
+
|
|
7107
|
+
/* The control. Sized against the bar it sits in, not the zoom — it is
|
|
7108
|
+
chrome, and it is the one thing that must stay reachable at 200%. */
|
|
7109
|
+
.twm-zoom {
|
|
7110
|
+
display: inline-flex;
|
|
7111
|
+
align-items: center;
|
|
7112
|
+
gap: 3px;
|
|
7113
|
+
height: 100%;
|
|
7114
|
+
padding: 0 8px;
|
|
7115
|
+
border-left: 1px solid var(--surface-3);
|
|
7116
|
+
user-select: none;
|
|
7117
|
+
}
|
|
7118
|
+
.twm-zoom__step {
|
|
7119
|
+
display: inline-flex;
|
|
7120
|
+
align-items: center;
|
|
7121
|
+
justify-content: center;
|
|
7122
|
+
width: 15px;
|
|
7123
|
+
height: 15px;
|
|
7124
|
+
padding: 0;
|
|
7125
|
+
border: none;
|
|
7126
|
+
border-radius: 2px;
|
|
7127
|
+
background: none;
|
|
7128
|
+
color: var(--text-muted);
|
|
7129
|
+
font: inherit;
|
|
7130
|
+
font-size: var(--font-size-sm);
|
|
7131
|
+
line-height: 1;
|
|
7132
|
+
cursor: pointer;
|
|
7133
|
+
}
|
|
7134
|
+
.twm-zoom__step:hover { background: var(--surface-3); color: var(--text-primary); }
|
|
7135
|
+
/* A short track on purpose: it sits between two buttons in a thin bar,
|
|
7136
|
+
and the readout — not the thumb position — answers "where am I". */
|
|
7137
|
+
.twm-zoom__slider {
|
|
7138
|
+
width: 78px;
|
|
7139
|
+
height: 12px;
|
|
7140
|
+
margin: 0;
|
|
7141
|
+
cursor: pointer;
|
|
7142
|
+
accent-color: var(--accent-bright);
|
|
7143
|
+
}
|
|
7144
|
+
/* Tabular numerals, so the readout does not jiggle between 95% and 100%
|
|
7145
|
+
while the thumb moves. */
|
|
7146
|
+
.twm-zoom__value {
|
|
7147
|
+
min-width: 34px;
|
|
7148
|
+
padding: 0;
|
|
7149
|
+
border: none;
|
|
7150
|
+
background: none;
|
|
7151
|
+
color: var(--text-muted);
|
|
7152
|
+
font-family: var(--font-family-mono);
|
|
7153
|
+
font-size: var(--font-size-xs);
|
|
7154
|
+
font-variant-numeric: tabular-nums;
|
|
7155
|
+
text-align: right;
|
|
7156
|
+
cursor: pointer;
|
|
7157
|
+
}
|
|
7158
|
+
.twm-zoom__value:hover { color: var(--text-primary); }
|
package/dist/flexdesk.css
CHANGED
|
@@ -7088,3 +7088,71 @@ tr:hover .twm-row-action { opacity: 1; }
|
|
|
7088
7088
|
.twm-top-nav__label {
|
|
7089
7089
|
/* hidden on narrow viewports; icon alone is enough */
|
|
7090
7090
|
}
|
|
7091
|
+
|
|
7092
|
+
/* ── C31. Content zoom ─────────────────────────────────────────────
|
|
7093
|
+
Applied by zoom.js as `--twm-zoom` plus `.twm-zoomed` on the shell
|
|
7094
|
+
ROOT. The class is only set away from 100%, so an unzoomed shell has
|
|
7095
|
+
no `zoom` declaration anywhere: `zoom: 1` is not a no-op, it still
|
|
7096
|
+
establishes a scaled coordinate space.
|
|
7097
|
+
|
|
7098
|
+
CONTENT SURFACES ONLY. A tile's body and a promoted window's content
|
|
7099
|
+
sit below every piece of geometry the window drag, resize and C15
|
|
7100
|
+
snap probe read. The root, a leaf wrap, tile chrome, tab bars and a
|
|
7101
|
+
window frame are deliberately absent: a contained window lives in a
|
|
7102
|
+
leaf wrap and is re-parented to the root while it is dragged, so
|
|
7103
|
+
zooming either would drift every drag by the zoom factor. See zoom.js. */
|
|
7104
|
+
.twm-zoomed .twm-leaf__body,
|
|
7105
|
+
.twm-zoomed .twm-window-content { zoom: var(--twm-zoom, 1); }
|
|
7106
|
+
|
|
7107
|
+
/* The control. Sized against the bar it sits in, not the zoom — it is
|
|
7108
|
+
chrome, and it is the one thing that must stay reachable at 200%. */
|
|
7109
|
+
.twm-zoom {
|
|
7110
|
+
display: inline-flex;
|
|
7111
|
+
align-items: center;
|
|
7112
|
+
gap: 3px;
|
|
7113
|
+
height: 100%;
|
|
7114
|
+
padding: 0 8px;
|
|
7115
|
+
border-left: 1px solid var(--surface-3);
|
|
7116
|
+
user-select: none;
|
|
7117
|
+
}
|
|
7118
|
+
.twm-zoom__step {
|
|
7119
|
+
display: inline-flex;
|
|
7120
|
+
align-items: center;
|
|
7121
|
+
justify-content: center;
|
|
7122
|
+
width: 15px;
|
|
7123
|
+
height: 15px;
|
|
7124
|
+
padding: 0;
|
|
7125
|
+
border: none;
|
|
7126
|
+
border-radius: 2px;
|
|
7127
|
+
background: none;
|
|
7128
|
+
color: var(--text-muted);
|
|
7129
|
+
font: inherit;
|
|
7130
|
+
font-size: var(--font-size-sm);
|
|
7131
|
+
line-height: 1;
|
|
7132
|
+
cursor: pointer;
|
|
7133
|
+
}
|
|
7134
|
+
.twm-zoom__step:hover { background: var(--surface-3); color: var(--text-primary); }
|
|
7135
|
+
/* A short track on purpose: it sits between two buttons in a thin bar,
|
|
7136
|
+
and the readout — not the thumb position — answers "where am I". */
|
|
7137
|
+
.twm-zoom__slider {
|
|
7138
|
+
width: 78px;
|
|
7139
|
+
height: 12px;
|
|
7140
|
+
margin: 0;
|
|
7141
|
+
cursor: pointer;
|
|
7142
|
+
accent-color: var(--accent-bright);
|
|
7143
|
+
}
|
|
7144
|
+
/* Tabular numerals, so the readout does not jiggle between 95% and 100%
|
|
7145
|
+
while the thumb moves. */
|
|
7146
|
+
.twm-zoom__value {
|
|
7147
|
+
min-width: 34px;
|
|
7148
|
+
padding: 0;
|
|
7149
|
+
border: none;
|
|
7150
|
+
background: none;
|
|
7151
|
+
color: var(--text-muted);
|
|
7152
|
+
font-family: var(--font-family-mono);
|
|
7153
|
+
font-size: var(--font-size-xs);
|
|
7154
|
+
font-variant-numeric: tabular-nums;
|
|
7155
|
+
text-align: right;
|
|
7156
|
+
cursor: pointer;
|
|
7157
|
+
}
|
|
7158
|
+
.twm-zoom__value:hover { color: var(--text-primary); }
|
package/dist/wm.js
CHANGED
|
@@ -588,7 +588,7 @@ var TileTree = class _TileTree {
|
|
|
588
588
|
0,
|
|
589
589
|
Math.min(n.tabs.length - 1, saved.activeTabIdx || 0)
|
|
590
590
|
);
|
|
591
|
-
const wantsTarget = target.props?.id != null || target.kind !== targetTopNav;
|
|
591
|
+
const wantsTarget = target.props?.id != null || target.kind !== targetTopNav || Object.keys(target.props || {}).length > 0;
|
|
592
592
|
if (wantsTarget) {
|
|
593
593
|
const wantId = target.props?.id != null;
|
|
594
594
|
const matchIdx = n.tabs.findIndex((t) => t.kind === target.kind && (!wantId || String(t.props?.id ?? "") === String(target.props.id)));
|
|
@@ -757,9 +757,11 @@ var TileTree = class _TileTree {
|
|
|
757
757
|
};
|
|
758
758
|
n.tabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
759
759
|
n.tabs.push(tab);
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
760
|
+
if (!opts.background) {
|
|
761
|
+
n.activeTabIdx = n.tabs.length - 1;
|
|
762
|
+
_syncActiveTab(n);
|
|
763
|
+
}
|
|
764
|
+
return n.tabs.length - 1;
|
|
763
765
|
}
|
|
764
766
|
/** Switch the active tab on a leaf. No-op if `idx` is out of range. */
|
|
765
767
|
setActiveLeafTab(leafId, idx) {
|
|
@@ -6528,6 +6530,11 @@ var WindowManager = class _WindowManager {
|
|
|
6528
6530
|
* `opts.transient` — the appended tab is not persisted/restored
|
|
6529
6531
|
* (e.g. an add-row form). Only meaningful with
|
|
6530
6532
|
* `newTab:true`.
|
|
6533
|
+
* `opts.background` — with `newTab`, append the tab WITHOUT switching to
|
|
6534
|
+
* it or focusing its tile. "Open in a background tab"
|
|
6535
|
+
* means the page you are reading stays in front;
|
|
6536
|
+
* without it the tab arrives and takes the screen,
|
|
6537
|
+
* which is what an ordinary click already does.
|
|
6531
6538
|
*
|
|
6532
6539
|
* Back-compat: the legacy `opts.target` enum still works and maps
|
|
6533
6540
|
* onto the axes — 'auto'→origin, 'tab'→origin+newTab,
|
|
@@ -6538,6 +6545,7 @@ var WindowManager = class _WindowManager {
|
|
|
6538
6545
|
* `openInWindow`) stay internal; callers prefer `wm.navigate(...)`. */
|
|
6539
6546
|
navigate(kind, props = {}, opts = {}) {
|
|
6540
6547
|
const { ctx = null, transient = false } = opts;
|
|
6548
|
+
const background = !!opts.background;
|
|
6541
6549
|
let { dest = "main", newTab = false } = opts;
|
|
6542
6550
|
if (opts.target != null) {
|
|
6543
6551
|
switch (opts.target) {
|
|
@@ -6573,9 +6581,9 @@ var WindowManager = class _WindowManager {
|
|
|
6573
6581
|
}
|
|
6574
6582
|
if (dest === "window") return this._navigateWindow(kind, props);
|
|
6575
6583
|
if (dest === "main") {
|
|
6576
|
-
return newTab ? this.openInTabInPrimary(kind, props, transient) : this.openInPrimary(kind, props);
|
|
6584
|
+
return newTab ? this.openInTabInPrimary(kind, props, transient, background) : this.openInPrimary(kind, props);
|
|
6577
6585
|
}
|
|
6578
|
-
return newTab ? this._navigateTab(ctx, kind, props, transient) : this._navigateAuto(ctx, kind, props);
|
|
6586
|
+
return newTab ? this._navigateTab(ctx, kind, props, transient, background) : this._navigateAuto(ctx, kind, props);
|
|
6579
6587
|
}
|
|
6580
6588
|
_navigateAuto(ctx, kind, props) {
|
|
6581
6589
|
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
@@ -6593,12 +6601,12 @@ var WindowManager = class _WindowManager {
|
|
|
6593
6601
|
}
|
|
6594
6602
|
this.openInPrimary(kind, props);
|
|
6595
6603
|
}
|
|
6596
|
-
_navigateTab(ctx, kind, props, transient = false) {
|
|
6604
|
+
_navigateTab(ctx, kind, props, transient = false, background = false) {
|
|
6597
6605
|
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
6598
6606
|
this.openInWindow(ctx.windowId, kind, props);
|
|
6599
6607
|
return;
|
|
6600
6608
|
}
|
|
6601
|
-
this.openInTabFromContext(ctx || {}, kind, props, transient);
|
|
6609
|
+
this.openInTabFromContext(ctx || {}, kind, props, transient, background);
|
|
6602
6610
|
}
|
|
6603
6611
|
/** Spawn a fresh ManagedWindow with the requested content. No
|
|
6604
6612
|
* source leaf — closing the window just disposes the content. */
|
|
@@ -6719,7 +6727,7 @@ var WindowManager = class _WindowManager {
|
|
|
6719
6727
|
* Mirrors `openFromContext` (windowed / split-leaf / primary
|
|
6720
6728
|
* routing) but uses `appendLeafTab` so the existing content
|
|
6721
6729
|
* stays in place as a tab. */
|
|
6722
|
-
openInTabFromContext(ctx, kind, props = {}, transient = false) {
|
|
6730
|
+
openInTabFromContext(ctx, kind, props = {}, transient = false, background = false) {
|
|
6723
6731
|
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
6724
6732
|
this.openInWindow(ctx.windowId, kind, props);
|
|
6725
6733
|
return;
|
|
@@ -6733,8 +6741,13 @@ var WindowManager = class _WindowManager {
|
|
|
6733
6741
|
this.openInPrimary(kind, props);
|
|
6734
6742
|
return;
|
|
6735
6743
|
}
|
|
6736
|
-
tree.appendLeafTab(
|
|
6737
|
-
|
|
6744
|
+
tree.appendLeafTab(
|
|
6745
|
+
leafId,
|
|
6746
|
+
{ kind, props },
|
|
6747
|
+
_tabTitle(kind, props),
|
|
6748
|
+
{ transient, background }
|
|
6749
|
+
);
|
|
6750
|
+
if (!background) tree.focus(leafId);
|
|
6738
6751
|
this.renderer.render();
|
|
6739
6752
|
this._persist();
|
|
6740
6753
|
this._notifyChange("tab-open");
|
|
@@ -6746,15 +6759,20 @@ var WindowManager = class _WindowManager {
|
|
|
6746
6759
|
* click from outside the tile system (e.g. the bottom-panel
|
|
6747
6760
|
* "Add row" button, which passes no ctx) reliably lands as a sibling
|
|
6748
6761
|
* tab in the main tile rather than swapping its content. */
|
|
6749
|
-
openInTabInPrimary(kind, props = {}, transient = false) {
|
|
6762
|
+
openInTabInPrimary(kind, props = {}, transient = false, background = false) {
|
|
6750
6763
|
const tree = this._tree();
|
|
6751
6764
|
const leafId = tree.primaryLeafId();
|
|
6752
6765
|
if (!leafId) {
|
|
6753
6766
|
this._navigateWindow(kind, props);
|
|
6754
6767
|
return;
|
|
6755
6768
|
}
|
|
6756
|
-
tree.appendLeafTab(
|
|
6757
|
-
|
|
6769
|
+
tree.appendLeafTab(
|
|
6770
|
+
leafId,
|
|
6771
|
+
{ kind, props },
|
|
6772
|
+
_tabTitle(kind, props),
|
|
6773
|
+
{ transient, background }
|
|
6774
|
+
);
|
|
6775
|
+
if (!background) tree.focus(leafId);
|
|
6758
6776
|
this.renderer.render();
|
|
6759
6777
|
this._persist();
|
|
6760
6778
|
this._notifyChange("tab-open");
|
|
@@ -7215,6 +7233,112 @@ function _esc7(s) {
|
|
|
7215
7233
|
})[c]);
|
|
7216
7234
|
}
|
|
7217
7235
|
|
|
7236
|
+
// src/tiling/zoom.js
|
|
7237
|
+
var ZOOM_MIN = 50;
|
|
7238
|
+
var ZOOM_MAX = 200;
|
|
7239
|
+
var ZOOM_STEP = 5;
|
|
7240
|
+
var ZOOM_NUDGE = 10;
|
|
7241
|
+
var ZOOM_DEFAULT = 100;
|
|
7242
|
+
var ZOOM_STATE_KEY = "zoom";
|
|
7243
|
+
function clampZoom(value) {
|
|
7244
|
+
if (value == null || value === "") return ZOOM_DEFAULT;
|
|
7245
|
+
const n = Number(value);
|
|
7246
|
+
if (!Number.isFinite(n)) return ZOOM_DEFAULT;
|
|
7247
|
+
const stepped = Math.round(n / ZOOM_STEP) * ZOOM_STEP;
|
|
7248
|
+
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, stepped));
|
|
7249
|
+
}
|
|
7250
|
+
function applyZoom(root, percent) {
|
|
7251
|
+
if (!root?.style) return;
|
|
7252
|
+
root.style.setProperty("--twm-zoom", String(percent / 100));
|
|
7253
|
+
root.classList.toggle("twm-zoomed", percent !== ZOOM_DEFAULT);
|
|
7254
|
+
}
|
|
7255
|
+
function mountZoomControl(hostEl, { root, host = null, stateKey = ZOOM_STATE_KEY, onChange } = {}) {
|
|
7256
|
+
if (!hostEl || !root) return null;
|
|
7257
|
+
const doc = hostEl.ownerDocument;
|
|
7258
|
+
let current = ZOOM_DEFAULT;
|
|
7259
|
+
let disposed = false;
|
|
7260
|
+
const el = doc.createElement("div");
|
|
7261
|
+
el.className = "twm-zoom";
|
|
7262
|
+
const stepButton = (label, title, delta) => {
|
|
7263
|
+
const b = doc.createElement("button");
|
|
7264
|
+
b.type = "button";
|
|
7265
|
+
b.className = "twm-zoom__step";
|
|
7266
|
+
b.textContent = label;
|
|
7267
|
+
b.title = title;
|
|
7268
|
+
b.setAttribute("aria-label", title);
|
|
7269
|
+
b.addEventListener("click", () => commit(clampZoom(current + delta)));
|
|
7270
|
+
return b;
|
|
7271
|
+
};
|
|
7272
|
+
const slider = doc.createElement("input");
|
|
7273
|
+
slider.type = "range";
|
|
7274
|
+
slider.className = "twm-zoom__slider";
|
|
7275
|
+
slider.min = String(ZOOM_MIN);
|
|
7276
|
+
slider.max = String(ZOOM_MAX);
|
|
7277
|
+
slider.step = String(ZOOM_STEP);
|
|
7278
|
+
slider.value = String(ZOOM_DEFAULT);
|
|
7279
|
+
slider.title = `Zoom the workspace, ${ZOOM_MIN}\u2013${ZOOM_MAX}% \u2014 double-click to reset.`;
|
|
7280
|
+
slider.setAttribute("aria-label", "Zoom the workspace");
|
|
7281
|
+
slider.addEventListener("input", () => commit(clampZoom(slider.value)));
|
|
7282
|
+
slider.addEventListener("dblclick", () => commit(ZOOM_DEFAULT));
|
|
7283
|
+
const readout = doc.createElement("button");
|
|
7284
|
+
readout.type = "button";
|
|
7285
|
+
readout.className = "twm-zoom__value";
|
|
7286
|
+
readout.title = `Back to ${ZOOM_DEFAULT}%`;
|
|
7287
|
+
readout.addEventListener("click", () => commit(ZOOM_DEFAULT));
|
|
7288
|
+
el.append(
|
|
7289
|
+
stepButton("\u2212", `Zoom out ${ZOOM_NUDGE}%`, -ZOOM_NUDGE),
|
|
7290
|
+
slider,
|
|
7291
|
+
stepButton("+", `Zoom in ${ZOOM_NUDGE}%`, ZOOM_NUDGE),
|
|
7292
|
+
readout
|
|
7293
|
+
);
|
|
7294
|
+
hostEl.appendChild(el);
|
|
7295
|
+
const paint = (percent) => {
|
|
7296
|
+
current = percent;
|
|
7297
|
+
slider.value = String(percent);
|
|
7298
|
+
readout.textContent = `${percent}%`;
|
|
7299
|
+
applyZoom(root, percent);
|
|
7300
|
+
};
|
|
7301
|
+
let saveTimer = 0;
|
|
7302
|
+
const persist = (percent) => {
|
|
7303
|
+
const state = host?.state;
|
|
7304
|
+
if (!state) return;
|
|
7305
|
+
clearTimeout(saveTimer);
|
|
7306
|
+
saveTimer = setTimeout(() => {
|
|
7307
|
+
Promise.resolve().then(() => state.write(stateKey, percent)).catch((err) => console.warn("[zoom] save failed", err));
|
|
7308
|
+
}, 400);
|
|
7309
|
+
};
|
|
7310
|
+
const commit = (percent) => {
|
|
7311
|
+
if (disposed || percent === current) return;
|
|
7312
|
+
paint(percent);
|
|
7313
|
+
persist(percent);
|
|
7314
|
+
try {
|
|
7315
|
+
onChange?.(percent);
|
|
7316
|
+
} catch (err) {
|
|
7317
|
+
console.warn("[zoom] onChange threw", err);
|
|
7318
|
+
}
|
|
7319
|
+
};
|
|
7320
|
+
paint(ZOOM_DEFAULT);
|
|
7321
|
+
const ready = Promise.resolve().then(() => host?.state?.read?.(stateKey)).then((saved) => {
|
|
7322
|
+
if (!disposed && saved != null) paint(clampZoom(saved));
|
|
7323
|
+
return current;
|
|
7324
|
+
}).catch((err) => {
|
|
7325
|
+
console.warn("[zoom] load failed", err);
|
|
7326
|
+
return current;
|
|
7327
|
+
});
|
|
7328
|
+
return {
|
|
7329
|
+
el,
|
|
7330
|
+
get: () => current,
|
|
7331
|
+
set: (percent) => commit(clampZoom(percent)),
|
|
7332
|
+
ready,
|
|
7333
|
+
dispose: () => {
|
|
7334
|
+
disposed = true;
|
|
7335
|
+
clearTimeout(saveTimer);
|
|
7336
|
+
el.remove();
|
|
7337
|
+
applyZoom(root, ZOOM_DEFAULT);
|
|
7338
|
+
}
|
|
7339
|
+
};
|
|
7340
|
+
}
|
|
7341
|
+
|
|
7218
7342
|
// src/tiling/shell.js
|
|
7219
7343
|
async function createShell({
|
|
7220
7344
|
root,
|
|
@@ -7302,6 +7426,8 @@ async function createShell({
|
|
|
7302
7426
|
const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
|
|
7303
7427
|
topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
|
|
7304
7428
|
desktopsEl = mountDesktopBar(chrome.desktops, wm);
|
|
7429
|
+
const zoom = mountZoomControl(chrome.zoom, { root, host });
|
|
7430
|
+
if (zoom) await zoom.ready;
|
|
7305
7431
|
toggles = bindPanelToggles(chrome.panelToggles, wm);
|
|
7306
7432
|
eventBus?.on?.("wm:changed", syncChrome);
|
|
7307
7433
|
await wm.load();
|
|
@@ -7318,7 +7444,10 @@ async function createShell({
|
|
|
7318
7444
|
paletteButtonEl: paletteBtn,
|
|
7319
7445
|
topNavEl,
|
|
7320
7446
|
desktopsEl,
|
|
7321
|
-
panelToggles: toggles
|
|
7447
|
+
panelToggles: toggles,
|
|
7448
|
+
// `get`/`set` so an embedder can drive the zoom from its own settings
|
|
7449
|
+
// pane, or read it, without reaching into the control's DOM.
|
|
7450
|
+
zoom
|
|
7322
7451
|
}),
|
|
7323
7452
|
// A shell that can be built can be built TWICE — an embedder that
|
|
7324
7453
|
// rebuilds on a context change (a different project, a different
|
|
@@ -7343,6 +7472,10 @@ async function createShell({
|
|
|
7343
7472
|
} catch (err) {
|
|
7344
7473
|
log.warn?.("renderer teardown", err);
|
|
7345
7474
|
}
|
|
7475
|
+
try {
|
|
7476
|
+
zoom?.dispose();
|
|
7477
|
+
} catch {
|
|
7478
|
+
}
|
|
7346
7479
|
}
|
|
7347
7480
|
});
|
|
7348
7481
|
}
|
|
@@ -7640,10 +7773,18 @@ export {
|
|
|
7640
7773
|
TileRenderer,
|
|
7641
7774
|
TileTree,
|
|
7642
7775
|
WindowManager,
|
|
7776
|
+
ZOOM_DEFAULT,
|
|
7777
|
+
ZOOM_MAX,
|
|
7778
|
+
ZOOM_MIN,
|
|
7779
|
+
ZOOM_NUDGE,
|
|
7780
|
+
ZOOM_STATE_KEY,
|
|
7781
|
+
ZOOM_STEP,
|
|
7643
7782
|
actionsCellRenderer,
|
|
7783
|
+
applyZoom,
|
|
7644
7784
|
attachLandingKeyboardNav,
|
|
7645
7785
|
attachLandingShell,
|
|
7646
7786
|
attachLandingTableBehavior,
|
|
7787
|
+
clampZoom,
|
|
7647
7788
|
createCommandPalette,
|
|
7648
7789
|
createContentRegistry,
|
|
7649
7790
|
createEntityCatalog,
|
|
@@ -7661,6 +7802,7 @@ export {
|
|
|
7661
7802
|
mountLandingShell,
|
|
7662
7803
|
mountNavPanel,
|
|
7663
7804
|
mountTileBreadcrumb,
|
|
7805
|
+
mountZoomControl,
|
|
7664
7806
|
openTileTabMenu,
|
|
7665
7807
|
openTileTabSwitcher,
|
|
7666
7808
|
registerPanelKeys,
|