dsh-ctrl-wheel-zoom 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +146 -2
- package/client.js +485 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +6 -0
- package/index.js +9 -0
- package/locale/en.json +16 -0
- package/locale/zh.json +16 -0
- package/package.json +46 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 <copyright holder>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,147 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Ctrl + Wheel Zoom
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Hold **Ctrl** (or **⌘** on macOS) and roll the mouse wheel to zoom the whole Harness Web UI.
|
|
4
|
+
A page-zoom plugin for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web GUI, distributed as an ordinary
|
|
5
|
+
DSH **profile bundle**: no build step, no runtime dependency, plain ESM.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
| Gesture | Result |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `Ctrl` / `⌘` + wheel | Zooms the page around the pointer, 50%–200%, in 10% steps |
|
|
12
|
+
| `Ctrl` / `⌘` + `+` / `-` | Same from the keyboard |
|
|
13
|
+
| `Ctrl` / `⌘` + `0` | Back to 100% |
|
|
14
|
+
| Zoom chip (bottom-right) | Appears whenever the level is not 100%: click the percentage to reset, click **Fit to width** to fit the window |
|
|
15
|
+
| Settings → General | A **Page zoom** row with the host's stepper pattern and a reset button |
|
|
16
|
+
|
|
17
|
+
The chosen level is remembered in the browser (`localStorage`), and every listener, stylesheet and
|
|
18
|
+
the applied `zoom` property are removed when the plugin is disabled or unloaded.
|
|
19
|
+
|
|
20
|
+
A gesture that another component already handled (`preventDefault()`, e.g. an embedded code editor
|
|
21
|
+
or map with its own `Ctrl`+wheel zoom) is never stolen.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Ask the Harness agent to install it, or call the plugin manager yourself with the package located
|
|
26
|
+
by absolute path, a tarball, or a published name:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
plugin_manager action: install_bundle
|
|
30
|
+
target: <absolute path to this package / a .tgz / a published name>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`install_bundle` links the package into the active profile (`$DSH_PROFILE_DIR`), adds it to the
|
|
34
|
+
profile's bundle list, and the Harness applies it live. A page refresh picks up the browser half in
|
|
35
|
+
a page that was already open.
|
|
36
|
+
|
|
37
|
+
To install it by hand instead, add the package to the profile's dependencies and to
|
|
38
|
+
`dsh.profile.bundles` in `$DSH_PROFILE_DIR/package.json`, then restart the Harness.
|
|
39
|
+
|
|
40
|
+
## Uninstall
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
plugin_manager action: remove_bundle
|
|
44
|
+
target: dsh-ctrl-wheel-zoom
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Requirements
|
|
48
|
+
|
|
49
|
+
- A Harness Web GUI (the client half declares `platform: "web"`; it is inert without a browser).
|
|
50
|
+
- Nothing else: the package has no dependencies, no build output, and no install scripts. Built-in
|
|
51
|
+
client modules (`react`, `@deepseek-ai/dsh-client-store`) come from the shell's own module table.
|
|
52
|
+
|
|
53
|
+
## Sharing and publishing
|
|
54
|
+
|
|
55
|
+
The package is self-contained, so there are three ways to hand it to someone else.
|
|
56
|
+
|
|
57
|
+
### 1. A tarball
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pnpm pack # writes dsh-ctrl-wheel-zoom-1.0.0.tgz
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Send that `.tgz`; the receiver installs it by path:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
plugin_manager action: install_bundle
|
|
67
|
+
target: <path to dsh-ctrl-wheel-zoom-1.0.0.tgz>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
(`pnpm pack` writes the tarball beside `package.json`. DSH's own runtime ships a pnpm, so nothing
|
|
71
|
+
has to be installed first.)
|
|
72
|
+
|
|
73
|
+
### 2. The directory (zip / repository)
|
|
74
|
+
|
|
75
|
+
Zip this folder (or push it to a Git repository) and have the receiver install the **unpacked
|
|
76
|
+
directory** by path — `install_bundle` accepts a directory, and a folder is enough because there is
|
|
77
|
+
no build step.
|
|
78
|
+
|
|
79
|
+
### 3. npm (install by name for everyone)
|
|
80
|
+
|
|
81
|
+
This manifest is already publishable: `name` is the unscoped `dsh-ctrl-wheel-zoom`, the row in
|
|
82
|
+
`cordis.patch.yml` names the same specifier, and there is no `"private": true`.
|
|
83
|
+
|
|
84
|
+
npm no longer issues long-lived "classic" tokens — they were permanently revoked on 2025-12-09 —
|
|
85
|
+
and publishing requires two-factor authentication. Pick one of the two ways through:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# A. A granular access token created with "Bypass two-factor authentication" enabled,
|
|
89
|
+
# stored once as a credential:
|
|
90
|
+
# //registry.npmjs.org/:_authToken=npm_... in ~/.npmrc
|
|
91
|
+
pnpm publish --no-git-checks
|
|
92
|
+
|
|
93
|
+
# B. Any write token, with a one-time password supplied per publish:
|
|
94
|
+
pnpm publish --no-git-checks --otp=<6-digit code>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A token without bypass-2FA is refused with `403 ... Two-factor authentication or granular access
|
|
98
|
+
token with bypass 2fa enabled is required to publish packages`. Verify what a token can actually do
|
|
99
|
+
before blaming the command:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
curl -H "Authorization: Bearer $TOKEN" https://registry.npmjs.org/-/npm/v1/tokens
|
|
103
|
+
# "bypass_2fa": false → use --otp, or recreate the token with the switch on
|
|
104
|
+
# "permissions": [{"name":"package","action":"write"}] → publishing is permitted at all
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Note npm's stated direction: bypass-2FA tokens are on a path to removal (targeted for January 2027)
|
|
108
|
+
in favour of staged publishing or trusted publishing, so a long-lived automated release will need
|
|
109
|
+
one of those later.
|
|
110
|
+
|
|
111
|
+
Keep `name` in `package.json` and the `name:` line of `cordis.patch.yml` identical in every
|
|
112
|
+
release — the loader resolves the row through that specifier. Bump `version` for each release.
|
|
113
|
+
|
|
114
|
+
Afterwards anyone installs it by name:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
plugin_manager action: install_bundle
|
|
118
|
+
target: dsh-ctrl-wheel-zoom
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Files
|
|
122
|
+
|
|
123
|
+
| File | Role |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `index.js` | Host half: an empty `apply()`; the row exists so the bundle can be installed and toggled |
|
|
126
|
+
| `client.js` | Browser half: wheel/keyboard gestures, zoom state, chip and settings-row registrations, stylesheets |
|
|
127
|
+
| `cordis.patch.yml` | The loader patch that inserts the `ctrl-wheel-zoom` row |
|
|
128
|
+
| `locale/{zh,en}.json` | Plugin-card title/description text; the row's own copy lives in `client.js` |
|
|
129
|
+
| `icon.svg` | Plugin-manager card artwork |
|
|
130
|
+
|
|
131
|
+
## How the zoom works
|
|
132
|
+
|
|
133
|
+
Zoom is CSS `zoom` on the document element. It scales layout, text and fixed overlay layers
|
|
134
|
+
together exactly like the browser's own page zoom, while the app's own scroll containers keep
|
|
135
|
+
working — a `transform: scale()` wrapper cannot scroll its oversized content, and a font-size
|
|
136
|
+
change would only touch text.
|
|
137
|
+
|
|
138
|
+
The wheel listener is registered in the capture phase on `window`, so it sees the gesture before
|
|
139
|
+
the app's scroll containers and blocks them from scrolling while `Ctrl` is held. When the viewport
|
|
140
|
+
itself scrolls, the plugin re-anchors it so the content under the pointer stays under the pointer.
|
|
141
|
+
|
|
142
|
+
Both UI surfaces read one store instance shared with the gesture writer, so the chip, the settings
|
|
143
|
+
stepper and the wheel listener can never disagree about the current level.
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT — see [LICENSE](LICENSE).
|
package/client.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of the Ctrl + Wheel Zoom bundle.
|
|
3
|
+
*
|
|
4
|
+
* CTRL (or ⌘ on macOS) + mouse wheel zooms the whole Web UI around the pointer;
|
|
5
|
+
* CTRL + `+` / `-` / `0` do the same from the keyboard, and a chip pinned at the
|
|
6
|
+
* frame's bottom-right corner reports the level whenever it is not 100% and
|
|
7
|
+
* returns to 100% in one click.
|
|
8
|
+
*
|
|
9
|
+
* Why CSS `zoom` on the document element: it scales layout, text and fixed
|
|
10
|
+
* overlay layers together, exactly like the browser's own page zoom, while the
|
|
11
|
+
* app's own scroll containers keep working. A `transform: scale()` wrapper
|
|
12
|
+
* cannot scroll its oversized content and a font-size change would only touch
|
|
13
|
+
* text, so both were rejected.
|
|
14
|
+
*
|
|
15
|
+
* The wheel listener is registered in the capture phase on `window`: it sees the
|
|
16
|
+
* gesture before the app's scroll containers, and while CTRL is held it always
|
|
17
|
+
* blocks those containers from scrolling. A handler that already called
|
|
18
|
+
* `preventDefault()` (an embedded editor or map with its own CTRL+wheel zoom)
|
|
19
|
+
* wins, because we never steal an already-handled gesture.
|
|
20
|
+
*
|
|
21
|
+
* All zoom state lives in one store instance that both registered surfaces and
|
|
22
|
+
* the apply-world writer share, so the chip, the settings stepper and the wheel
|
|
23
|
+
* listener can never disagree. Everything the plugin registers lives inside
|
|
24
|
+
* `ctx.effect`, so unloading it removes both slot entries, both listeners, the
|
|
25
|
+
* stylesheets and the zoom value it applied.
|
|
26
|
+
*/
|
|
27
|
+
window.__ModuleLoader__.load({
|
|
28
|
+
id: '@local/dsh-ctrl-wheel-zoom',
|
|
29
|
+
factory(require) {
|
|
30
|
+
const React = require('react');
|
|
31
|
+
const { defineStore } = require('@deepseek-ai/dsh-client-store');
|
|
32
|
+
const h = React.createElement;
|
|
33
|
+
|
|
34
|
+
/** This package's id: the module-loader id, also the stylesheet tag owner. */
|
|
35
|
+
const PLUGIN_ID = '@local/dsh-ctrl-wheel-zoom';
|
|
36
|
+
|
|
37
|
+
/** Locale namespace and dictionaries (the zh key set is the source of truth). */
|
|
38
|
+
const NS = 'ctrl-wheel-zoom';
|
|
39
|
+
const ZH = {
|
|
40
|
+
'chip.fit': '适合宽度',
|
|
41
|
+
'chip.label': '页面缩放',
|
|
42
|
+
'chip.percent': '{percent}%',
|
|
43
|
+
'chip.reset': '恢复 100%',
|
|
44
|
+
'chip.title': '页面缩放 {percent}%',
|
|
45
|
+
'fit.title': '缩放以适合宽度',
|
|
46
|
+
'reset.title': '恢复 100%',
|
|
47
|
+
'row.decrease': '缩小页面',
|
|
48
|
+
'row.description': '按住 Ctrl 并滚动鼠标滚轮即可缩放整个页面',
|
|
49
|
+
'row.fit': '适合宽度',
|
|
50
|
+
'row.increase': '放大页面',
|
|
51
|
+
'row.reset': '恢复 100%',
|
|
52
|
+
'row.title': '页面缩放',
|
|
53
|
+
'row.unit': '%',
|
|
54
|
+
};
|
|
55
|
+
const EN = {
|
|
56
|
+
'chip.fit': 'Fit to width',
|
|
57
|
+
'chip.label': 'Page zoom',
|
|
58
|
+
'chip.percent': '{percent}%',
|
|
59
|
+
'chip.reset': 'Reset to 100%',
|
|
60
|
+
'chip.title': 'Page zoom {percent}%',
|
|
61
|
+
'fit.title': 'Zoom to fit width',
|
|
62
|
+
'reset.title': 'Reset to 100%',
|
|
63
|
+
'row.decrease': 'Zoom out',
|
|
64
|
+
'row.description': 'Hold Ctrl and roll the mouse wheel to zoom the whole page',
|
|
65
|
+
'row.fit': 'Fit to width',
|
|
66
|
+
'row.increase': 'Zoom in',
|
|
67
|
+
'row.reset': 'Reset to 100%',
|
|
68
|
+
'row.title': 'Page zoom',
|
|
69
|
+
'row.unit': '%',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Zoom bounds and step, in percent. */
|
|
73
|
+
const MIN = 50;
|
|
74
|
+
const MAX = 200;
|
|
75
|
+
const DEFAULT = 100;
|
|
76
|
+
const STEP = 10;
|
|
77
|
+
/** Wheel deltas below this accumulate instead of stepping, so trackpads feel right. */
|
|
78
|
+
const WHEEL_THRESHOLD = 45;
|
|
79
|
+
/** Browser-local zoom memory (display state, not durable profile settings). */
|
|
80
|
+
const STORAGE_KEY = 'dsh.ctrl-wheel-zoom.value';
|
|
81
|
+
|
|
82
|
+
/** The document element, or undefined outside a browser. */
|
|
83
|
+
function root() {
|
|
84
|
+
return typeof document === 'undefined' ? undefined : document.documentElement;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Clamp into the zoom range; anything unusable resolves to 100%. */
|
|
88
|
+
function clamp(value) {
|
|
89
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT;
|
|
90
|
+
return Math.min(MAX, Math.max(MIN, Math.round(value)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read the remembered zoom. An unreadable or out-of-range entry resolves to
|
|
95
|
+
* 100%, so corrupted storage can never trap the UI at an unreadable scale.
|
|
96
|
+
*/
|
|
97
|
+
function loadValue() {
|
|
98
|
+
try {
|
|
99
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
100
|
+
if (raw === null) return DEFAULT;
|
|
101
|
+
const value = Number(raw);
|
|
102
|
+
return Number.isFinite(value) ? clamp(value) : DEFAULT;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return DEFAULT;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Persist the zoom; storage failures (quota, private mode) only lose memory. */
|
|
109
|
+
function saveValue(value) {
|
|
110
|
+
try {
|
|
111
|
+
window.localStorage.setItem(STORAGE_KEY, String(value));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
/* best effort: the zoom still applies for this page */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Apply a level to the document. 100% removes the property entirely, which
|
|
119
|
+
* restores the untouched layout instead of pinning `zoom: 1`.
|
|
120
|
+
*/
|
|
121
|
+
function applyZoom(value) {
|
|
122
|
+
const element = root();
|
|
123
|
+
if (element === undefined) return;
|
|
124
|
+
if (value === DEFAULT) element.style.removeProperty('zoom');
|
|
125
|
+
else element.style.setProperty('zoom', String(value / 100));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Re-anchor the viewport so the content the pointer rested on stays under the
|
|
130
|
+
* pointer. Only an already-scrolling viewport is corrected: inside an app
|
|
131
|
+
* whose columns scroll themselves this is a no-op, so zooming never moves
|
|
132
|
+
* content the user did not point at.
|
|
133
|
+
*/
|
|
134
|
+
function keepAnchor(element, clientX, clientY, beforeZoom, afterZoom) {
|
|
135
|
+
const top = element.scrollTop;
|
|
136
|
+
const left = element.scrollLeft;
|
|
137
|
+
if (top === 0 && left === 0) return;
|
|
138
|
+
const ratio = afterZoom / beforeZoom;
|
|
139
|
+
element.scrollTop = (top + clientY) * ratio - clientY;
|
|
140
|
+
element.scrollLeft = (left + clientX) * ratio - clientX;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Chip styles: one floating pill anchored to the frame's bottom-right corner
|
|
145
|
+
* through a fixed wrapper, which stays correct whatever containing block the
|
|
146
|
+
* overlay layer establishes. Host tokens only.
|
|
147
|
+
*/
|
|
148
|
+
const CHIP_CSS = `
|
|
149
|
+
.dshZoomChip_anchor{position:fixed;right:16px;bottom:16px;z-index:4}
|
|
150
|
+
.dshZoomChip_root{box-sizing:border-box;display:inline-flex;align-items:center;gap:4px;padding:4px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-panel);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family)}
|
|
151
|
+
.dshZoomChip_button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:44px;padding:0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:inherit;font:inherit;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;cursor:pointer}
|
|
152
|
+
.dshZoomChip_button:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
153
|
+
.dshZoomChip_button:focus-visible{outline-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-width:var(--dsw-focus-ring-width)}
|
|
154
|
+
.dshZoomChip_value{font-weight:500}
|
|
155
|
+
.dshZoomChip_divider{width:.5px;height:16px;background:var(--dsw-alias-border-l3)}
|
|
156
|
+
`;
|
|
157
|
+
|
|
158
|
+
/** General-row styles: the Appearance section's row and stepper geometry. */
|
|
159
|
+
const ROW_CSS = `
|
|
160
|
+
.dshZoomRow_row{box-sizing:border-box;display:flex;align-items:center;gap:8px;padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
|
|
161
|
+
.dshZoomRow_rowText{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px}
|
|
162
|
+
.dshZoomRow_title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
|
|
163
|
+
.dshZoomRow_desc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}
|
|
164
|
+
.dshZoomRow_control{display:inline-flex;align-items:center;gap:8px}
|
|
165
|
+
.dshZoomRow_stepper{box-sizing:border-box;display:inline-flex;position:relative;align-items:center;justify-content:center;min-width:72px;height:36px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-module-platform)}
|
|
166
|
+
.dshZoomRow_value{min-width:18px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-variant-numeric:tabular-nums;text-align:center}
|
|
167
|
+
.dshZoomRow_unit{color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
|
|
168
|
+
.dshZoomRow_arrows{display:flex;position:absolute;right:8px;flex-direction:column;gap:2px;opacity:0}
|
|
169
|
+
.dshZoomRow_stepper:hover .dshZoomRow_arrows,.dshZoomRow_stepper:focus-within .dshZoomRow_arrows{opacity:1}
|
|
170
|
+
.dshZoomRow_arrow{display:inline-flex;align-items:center;justify-content:center;width:17px;height:12px;padding:0;border:none;border-radius:var(--dsw-radius-xs);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1) 75%, transparent);color:var(--dsw-alias-label-primary);cursor:pointer}
|
|
171
|
+
.dshZoomRow_arrow:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}
|
|
172
|
+
.dshZoomRow_arrow:disabled{color:var(--dsw-alias-label-caption);cursor:default}
|
|
173
|
+
.dshZoomRow_arrow:focus-visible{outline-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-width:var(--dsw-focus-ring-width)}
|
|
174
|
+
.dshZoomRow_fit{height:36px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;cursor:pointer}
|
|
175
|
+
.dshZoomRow_fit:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
176
|
+
.dshZoomRow_fit:focus-visible{outline-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-width:var(--dsw-focus-ring-width)}
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The zoom chip (`shell.overlay`). It renders only while the level is not
|
|
181
|
+
* 100%, which keeps the frame untouched in the default state; clicking the
|
|
182
|
+
* percentage returns to 100%.
|
|
183
|
+
*/
|
|
184
|
+
function ZoomChip({ actions, t, useStore }) {
|
|
185
|
+
const zoom = useStore((state) => state.zoom);
|
|
186
|
+
if (zoom === DEFAULT) return null;
|
|
187
|
+
return h(
|
|
188
|
+
'div',
|
|
189
|
+
{ className: 'dshZoomChip_anchor' },
|
|
190
|
+
h(
|
|
191
|
+
'div',
|
|
192
|
+
{ className: 'dshZoomChip_root', role: 'group', 'aria-label': t('chip.label') },
|
|
193
|
+
h(
|
|
194
|
+
'button',
|
|
195
|
+
{
|
|
196
|
+
type: 'button',
|
|
197
|
+
className: 'dshZoomChip_button',
|
|
198
|
+
title: t('chip.title', { percent: zoom }),
|
|
199
|
+
onClick: actions.reset,
|
|
200
|
+
},
|
|
201
|
+
h('span', { className: 'dshZoomChip_value' }, t('chip.percent', { percent: zoom })),
|
|
202
|
+
),
|
|
203
|
+
h('span', { className: 'dshZoomChip_divider', 'aria-hidden': true }),
|
|
204
|
+
h(
|
|
205
|
+
'button',
|
|
206
|
+
{
|
|
207
|
+
type: 'button',
|
|
208
|
+
className: 'dshZoomChip_button',
|
|
209
|
+
title: t('fit.title'),
|
|
210
|
+
onClick: actions.fit,
|
|
211
|
+
},
|
|
212
|
+
t('chip.fit'),
|
|
213
|
+
),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Stepper glyphs. The host's chevron geometry, drawn locally: a plugin must
|
|
220
|
+
* not load a Client package such as ui-primitives as a module.
|
|
221
|
+
*/
|
|
222
|
+
function Chevron({ direction }) {
|
|
223
|
+
const up = direction === 'up';
|
|
224
|
+
return h(
|
|
225
|
+
'svg',
|
|
226
|
+
{
|
|
227
|
+
width: 9,
|
|
228
|
+
height: 9,
|
|
229
|
+
viewBox: '0 0 16 16',
|
|
230
|
+
fill: 'none',
|
|
231
|
+
stroke: 'currentColor',
|
|
232
|
+
strokeWidth: 1.6,
|
|
233
|
+
strokeLinecap: 'round',
|
|
234
|
+
strokeLinejoin: 'round',
|
|
235
|
+
'aria-hidden': true,
|
|
236
|
+
},
|
|
237
|
+
h('path', { d: up ? 'M4 10L8 6L12 10' : 'M4 6L8 10L12 6' }),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The General settings row: the Appearance section's stepper pattern, for
|
|
243
|
+
* users who never discover the wheel gesture.
|
|
244
|
+
*/
|
|
245
|
+
function ZoomRow({ t, setZoom, useStore }) {
|
|
246
|
+
const zoom = useStore((state) => state.zoom);
|
|
247
|
+
return h(
|
|
248
|
+
'div',
|
|
249
|
+
{ className: 'dshZoomRow_row' },
|
|
250
|
+
h(
|
|
251
|
+
'div',
|
|
252
|
+
{ className: 'dshZoomRow_rowText' },
|
|
253
|
+
h('div', { className: 'dshZoomRow_title' }, t('row.title')),
|
|
254
|
+
h('div', { className: 'dshZoomRow_desc' }, t('row.description')),
|
|
255
|
+
),
|
|
256
|
+
h(
|
|
257
|
+
'div',
|
|
258
|
+
{ className: 'dshZoomRow_control' },
|
|
259
|
+
h(
|
|
260
|
+
'div',
|
|
261
|
+
{ className: 'dshZoomRow_stepper' },
|
|
262
|
+
h('span', { className: 'dshZoomRow_value' }, zoom),
|
|
263
|
+
h(
|
|
264
|
+
'span',
|
|
265
|
+
{ className: 'dshZoomRow_arrows' },
|
|
266
|
+
h(
|
|
267
|
+
'button',
|
|
268
|
+
{
|
|
269
|
+
type: 'button',
|
|
270
|
+
className: 'dshZoomRow_arrow',
|
|
271
|
+
'aria-label': t('row.increase'),
|
|
272
|
+
disabled: zoom >= MAX,
|
|
273
|
+
onClick: () => {
|
|
274
|
+
setZoom(zoom + STEP);
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
h(Chevron, { direction: 'up' }),
|
|
278
|
+
),
|
|
279
|
+
h(
|
|
280
|
+
'button',
|
|
281
|
+
{
|
|
282
|
+
type: 'button',
|
|
283
|
+
className: 'dshZoomRow_arrow',
|
|
284
|
+
'aria-label': t('row.decrease'),
|
|
285
|
+
disabled: zoom <= MIN,
|
|
286
|
+
onClick: () => {
|
|
287
|
+
setZoom(zoom - STEP);
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
h(Chevron, { direction: 'down' }),
|
|
291
|
+
),
|
|
292
|
+
),
|
|
293
|
+
),
|
|
294
|
+
h('span', { className: 'dshZoomRow_unit' }, t('row.unit')),
|
|
295
|
+
h(
|
|
296
|
+
'button',
|
|
297
|
+
{
|
|
298
|
+
type: 'button',
|
|
299
|
+
className: 'dshZoomRow_fit',
|
|
300
|
+
title: t('fit.title'),
|
|
301
|
+
onClick: () => {
|
|
302
|
+
setZoom(DEFAULT);
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
t('row.reset'),
|
|
306
|
+
),
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
inject: ['slots', 'locale'],
|
|
313
|
+
apply(ctx) {
|
|
314
|
+
const element = root();
|
|
315
|
+
if (element === undefined) return;
|
|
316
|
+
|
|
317
|
+
ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'ctrl-wheel-zoom: locale dictionaries');
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Mount one plugin-owned global stylesheet for exactly the owning plugin
|
|
321
|
+
* lifetime. An installed client bundle has NO `styles` builtin — that symbol
|
|
322
|
+
* belongs to the dynamic package runner — so the tag is created here and
|
|
323
|
+
* removed by the same effect's cleanup, the pattern ui-theme uses for its own
|
|
324
|
+
* sheets. Getting this wrong throws inside `apply`, which the Web boot
|
|
325
|
+
* reports as a failed entry and refuses to mount the application.
|
|
326
|
+
*
|
|
327
|
+
* @param name - stylesheet name, used for the tag's `data-plugin-css`
|
|
328
|
+
* @param css - raw CSS text
|
|
329
|
+
*/
|
|
330
|
+
function installStyle(name, css) {
|
|
331
|
+
return ctx.effect(() => {
|
|
332
|
+
const tag = document.createElement('style');
|
|
333
|
+
tag.dataset.plugin = PLUGIN_ID;
|
|
334
|
+
tag.dataset.pluginCss = `${PLUGIN_ID}/${name}`;
|
|
335
|
+
tag.textContent = css;
|
|
336
|
+
document.head.appendChild(tag);
|
|
337
|
+
return () => {
|
|
338
|
+
tag.remove();
|
|
339
|
+
};
|
|
340
|
+
}, `ctrl-wheel-zoom: ${name} stylesheet`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
installStyle('chip.css', CHIP_CSS);
|
|
344
|
+
installStyle('row.css', ROW_CSS);
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The store handle plus the one root instance every surface reads: the
|
|
348
|
+
* renderer keys resolved instances per handle and per registration, so the
|
|
349
|
+
* first registration publishes the instance this writer also mutates and
|
|
350
|
+
* both entries thereafter share it. Zoom can therefore never be reported
|
|
351
|
+
* twice with two different values.
|
|
352
|
+
*/
|
|
353
|
+
const store = defineStore({
|
|
354
|
+
init: () => ({ zoom: loadValue() }),
|
|
355
|
+
actions: {
|
|
356
|
+
set: (draft, value) => {
|
|
357
|
+
draft.zoom = clamp(value);
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
const instance = store.create();
|
|
362
|
+
|
|
363
|
+
const current = () => instance.getSnapshot().zoom;
|
|
364
|
+
|
|
365
|
+
/* Restore the remembered level before any surface mounts. */
|
|
366
|
+
applyZoom(current());
|
|
367
|
+
|
|
368
|
+
let pendingWheel = 0;
|
|
369
|
+
let lastWheelAt = 0;
|
|
370
|
+
let selfScrollAt = 0;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The single write path: re-anchor for a pointer gesture, remember the
|
|
374
|
+
* level, publish it, then apply it to the document.
|
|
375
|
+
*
|
|
376
|
+
* @param value - requested level in percent
|
|
377
|
+
* @param anchor - pointer position for a wheel gesture; omit for keyboard
|
|
378
|
+
* @returns the effective level
|
|
379
|
+
*/
|
|
380
|
+
function setZoom(value, anchor) {
|
|
381
|
+
const before = current();
|
|
382
|
+
const next = clamp(value);
|
|
383
|
+
if (next !== before) {
|
|
384
|
+
if (anchor !== undefined) keepAnchor(element, anchor.x, anchor.y, before, next);
|
|
385
|
+
saveValue(next);
|
|
386
|
+
instance.actions.set(next);
|
|
387
|
+
}
|
|
388
|
+
applyZoom(next);
|
|
389
|
+
return next;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The level at which the layout exactly fills the window width. */
|
|
393
|
+
function fitValue() {
|
|
394
|
+
const width = element.clientWidth;
|
|
395
|
+
const scrollWidth = element.scrollWidth;
|
|
396
|
+
if (!width || !scrollWidth) return DEFAULT;
|
|
397
|
+
return clamp((width / scrollWidth) * 100);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** One wheel notch, in the gesture's direction, anchored at the pointer. */
|
|
401
|
+
function onWheel(event) {
|
|
402
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
403
|
+
if (event.defaultPrevented) return;
|
|
404
|
+
if (event.timeStamp - selfScrollAt < 50) return;
|
|
405
|
+
event.preventDefault();
|
|
406
|
+
if (event.timeStamp - lastWheelAt < 20) return;
|
|
407
|
+
lastWheelAt = event.timeStamp;
|
|
408
|
+
pendingWheel += event.deltaY;
|
|
409
|
+
if (Math.abs(pendingWheel) < WHEEL_THRESHOLD) return;
|
|
410
|
+
const direction = pendingWheel < 0 ? 1 : -1;
|
|
411
|
+
pendingWheel = 0;
|
|
412
|
+
setZoom(current() + direction * STEP, { x: event.clientX, y: event.clientY });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** CTRL/⌘ + `+`, `-`, `0`: keyboard parity with the wheel gesture. */
|
|
416
|
+
function onKeydown(event) {
|
|
417
|
+
if (event.defaultPrevented || event.isComposing) return;
|
|
418
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
419
|
+
if (event.altKey) return;
|
|
420
|
+
const key = event.key;
|
|
421
|
+
let wanted;
|
|
422
|
+
if (key === '0' || key === ')') wanted = DEFAULT;
|
|
423
|
+
else if (key === '=' || key === '+') wanted = current() + STEP;
|
|
424
|
+
else if (key === '-' || key === '_') wanted = current() - STEP;
|
|
425
|
+
else return;
|
|
426
|
+
/* Swallow the chord even at a bound, so the page's own zoom never drifts from ours. */
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
if (clamp(wanted) !== current()) setZoom(wanted);
|
|
429
|
+
pendingWheel = 0;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** A scroll right after a zoom is our own correction, never a user gesture. */
|
|
433
|
+
function onScroll() {
|
|
434
|
+
selfScrollAt = performance.now();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
ctx.effect(() => {
|
|
438
|
+
window.addEventListener('wheel', onWheel, { capture: true, passive: false });
|
|
439
|
+
window.addEventListener('keydown', onKeydown, true);
|
|
440
|
+
element.addEventListener('scroll', onScroll, { passive: true });
|
|
441
|
+
applyZoom(current());
|
|
442
|
+
return () => {
|
|
443
|
+
window.removeEventListener('wheel', onWheel, { capture: true });
|
|
444
|
+
window.removeEventListener('keydown', onKeydown, true);
|
|
445
|
+
element.removeEventListener('scroll', onScroll);
|
|
446
|
+
element.style.removeProperty('zoom');
|
|
447
|
+
};
|
|
448
|
+
}, 'ctrl-wheel-zoom: gestures');
|
|
449
|
+
|
|
450
|
+
/* Service registration goes through an effect: nothing in `apply` itself may throw. */
|
|
451
|
+
ctx.effect(() => ctx.provide('ctrlWheelZoom', {
|
|
452
|
+
getZoom: current,
|
|
453
|
+
setZoom: (value) => setZoom(value),
|
|
454
|
+
stepZoom: (direction) => setZoom(current() + direction * STEP),
|
|
455
|
+
resetZoom: () => setZoom(DEFAULT),
|
|
456
|
+
fitToWidth: () => setZoom(fitValue()),
|
|
457
|
+
store,
|
|
458
|
+
}), 'ctrl-wheel-zoom: service');
|
|
459
|
+
|
|
460
|
+
ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
461
|
+
name: 'shell.overlay',
|
|
462
|
+
id: 'ctrl-wheel-zoom-chip',
|
|
463
|
+
order: 60,
|
|
464
|
+
store: instance,
|
|
465
|
+
locale: NS,
|
|
466
|
+
inject: () => ({
|
|
467
|
+
actions: {
|
|
468
|
+
reset: () => setZoom(DEFAULT),
|
|
469
|
+
fit: () => setZoom(fitValue()),
|
|
470
|
+
},
|
|
471
|
+
}),
|
|
472
|
+
}, ZoomChip)), 'ctrl-wheel-zoom: zoom chip');
|
|
473
|
+
|
|
474
|
+
ctx.effect(() => ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
|
475
|
+
name: 'settings.general.item',
|
|
476
|
+
id: 'ctrl-wheel-zoom',
|
|
477
|
+
order: 12,
|
|
478
|
+
store: instance,
|
|
479
|
+
locale: NS,
|
|
480
|
+
inject: () => ({ setZoom: (value) => setZoom(value) }),
|
|
481
|
+
}, ZoomRow)), 'ctrl-wheel-zoom: settings row');
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
},
|
|
485
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36" fill="none" role="img" aria-label="Ctrl + wheel zoom">
|
|
2
|
+
<circle cx="16" cy="16" r="9.25" stroke="#4176E6" stroke-width="2.5"/>
|
|
3
|
+
<path d="M22.9 22.9L29 29" stroke="#4176E6" stroke-width="2.5" stroke-linecap="round"/>
|
|
4
|
+
<path d="M12.5 16H19.5" stroke="#4176E6" stroke-width="2" stroke-linecap="round"/>
|
|
5
|
+
<path d="M16 12.5V19.5" stroke="#4176E6" stroke-width="2" stroke-linecap="round"/>
|
|
6
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the Ctrl + Wheel Zoom bundle.
|
|
3
|
+
*
|
|
4
|
+
* Page zoom is a browser-side concern, so the Host contributes no service and
|
|
5
|
+
* no tool: this row exists so the plugin manager can install, list, enable and
|
|
6
|
+
* disable the bundle. The Client half ({@link ./client.js}) owns the wheel
|
|
7
|
+
* listener, the zoom state and the two UI surfaces.
|
|
8
|
+
*/
|
|
9
|
+
export function apply() {}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"chip.label": "Page zoom",
|
|
3
|
+
"chip.fit": "Fit to width",
|
|
4
|
+
"chip.percent": "{percent}%",
|
|
5
|
+
"chip.reset": "Reset to 100%",
|
|
6
|
+
"chip.title": "Page zoom {percent}%",
|
|
7
|
+
"fit.title": "Zoom to fit width",
|
|
8
|
+
"reset.title": "Reset to 100%",
|
|
9
|
+
"row.title": "Page zoom",
|
|
10
|
+
"row.description": "Hold Ctrl and roll the mouse wheel to zoom the whole page",
|
|
11
|
+
"row.unit": "%",
|
|
12
|
+
"row.decrease": "Zoom out",
|
|
13
|
+
"row.increase": "Zoom in",
|
|
14
|
+
"row.reset": "Reset to 100%",
|
|
15
|
+
"row.fit": "Fit to width"
|
|
16
|
+
}
|
package/locale/zh.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"chip.label": "页面缩放",
|
|
3
|
+
"chip.fit": "适合宽度",
|
|
4
|
+
"chip.percent": "{percent}%",
|
|
5
|
+
"chip.reset": "恢复 100%",
|
|
6
|
+
"chip.title": "页面缩放 {percent}%",
|
|
7
|
+
"fit.title": "缩放以适合宽度",
|
|
8
|
+
"reset.title": "恢复 100%",
|
|
9
|
+
"row.title": "页面缩放",
|
|
10
|
+
"row.description": "按住 Ctrl 并滚动鼠标滚轮即可缩放整个页面",
|
|
11
|
+
"row.unit": "%",
|
|
12
|
+
"row.decrease": "缩小页面",
|
|
13
|
+
"row.increase": "放大页面",
|
|
14
|
+
"row.reset": "恢复 100%",
|
|
15
|
+
"row.fit": "适合宽度"
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ctrl-wheel-zoom",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Ctrl+wheel page zoom for the DeepSeek Harness Web UI, with an on-screen zoom chip and a General settings row",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"icon": "./icon.svg",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./package.json": "./package.json",
|
|
11
|
+
"./locale/*.json": "./locale/*.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"client.js",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"icon.svg",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"locale/*.json"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"dsh",
|
|
24
|
+
"deepseek-harness",
|
|
25
|
+
"dsh-plugin",
|
|
26
|
+
"dsh-bundle",
|
|
27
|
+
"zoom",
|
|
28
|
+
"page-zoom",
|
|
29
|
+
"accessibility"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22"
|
|
34
|
+
},
|
|
35
|
+
"meta": {
|
|
36
|
+
"title": "Ctrl + Wheel Zoom",
|
|
37
|
+
"description": "Hold Ctrl and roll the mouse wheel to zoom the whole page, with an on-screen zoom chip and General settings stepper."
|
|
38
|
+
},
|
|
39
|
+
"dsh": {
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"platform": "web",
|
|
45
|
+
"immediately": true,
|
|
46
|
+
"inject": []
|
|
47
|
+
}
|
|
48
|
+
}
|
|
6
49
|
}
|