dsh-zen-remote 1.1.0 → 1.1.2
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 +14 -2
- package/docs/interface.md +1 -1
- package/lib/client.js +457 -26
- package/lib/index.js +20 -0
- package/lib/index.js.map +1 -1
- package/lib/types/client/effects/keyboard-avoid.d.ts +92 -0
- package/lib/types/client/effects/keyboard-avoid.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +3 -1
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/turn-fold.css.d.ts +1 -1
- package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/scripts/check-keyboard-avoid.mjs +91 -0
- package/src/client/effects/keyboard-avoid.ts +351 -0
- package/src/client/effects/turn-fold.ts +59 -2
- package/src/client/index.tsx +6 -0
- package/src/client/styles/composer.css.ts +15 -0
- package/src/client/styles/turn-fold.css.ts +35 -24
- package/src/index.ts +26 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
<p align="center">
|
|
5
5
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-0B7285?style=flat-square" alt="MIT"></a>
|
|
6
|
-
<img src="https://img.shields.io/badge/release-v1.1.
|
|
6
|
+
<img src="https://img.shields.io/badge/release-v1.1.2-5B4CF0?style=flat-square" alt="v1.1.2">
|
|
7
7
|
<img src="https://img.shields.io/badge/DSH-Web%20Profile-5B4CF0?style=flat-square" alt="DSH Web Profile">
|
|
8
8
|
</p>
|
|
9
9
|
|
|
@@ -39,7 +39,7 @@ dsh plugin add dsh-zen-remote
|
|
|
39
39
|
```jsonc
|
|
40
40
|
{
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"dsh-zen-remote": "^1.1.
|
|
42
|
+
"dsh-zen-remote": "^1.1.2" // 本地开发换成 "link:/path/to/dsh-zen-remote"
|
|
43
43
|
},
|
|
44
44
|
"dsh": { "profile": { "bundles": [
|
|
45
45
|
"@deepseek-ai/dsh-base",
|
|
@@ -191,6 +191,16 @@ open http://127.0.0.1:3088/lan-gate/admin
|
|
|
191
191
|
|
|
192
192
|
上传大小上限(默认 20MB)在插件行的 `config.maxUploadBytes` 里改。
|
|
193
193
|
|
|
194
|
+
想在电脑端也启用回合过程折叠(默认只在手机宽度生效),在插件行的 `config.turnFoldDesktop` 里设 `true`——即在 profile 的 `cordis.patch.yml` 加一条:
|
|
195
|
+
|
|
196
|
+
```yaml
|
|
197
|
+
- id: dsh-zen-remote
|
|
198
|
+
config:
|
|
199
|
+
turnFoldDesktop: true
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
改完重启 `dsh web`。不改服务端配置的话,单个浏览器也可以访问一次 `?mobile-nav-turn-fold=1` 自己开启(`=0` 关闭,按浏览器记忆)。
|
|
203
|
+
|
|
194
204
|
---
|
|
195
205
|
|
|
196
206
|
## 通知什么时候会响
|
|
@@ -272,6 +282,8 @@ open http://127.0.0.1:3088/lan-gate/admin
|
|
|
272
282
|
|
|
273
283
|
**iOS 26.x 独立 PWA 视口缩水**:加到主屏后视口底部会少掉一条状态栏高度,普通 Safari 标签页正常。这是 iOS 系统缺陷,缺掉的区域在文档之外,CSS 够不着;本插件做了三层缓解(浅色 manifest 背景 + 安全区补偿 + 强制重排),能减轻但不保证复原。彻底恢复只能整个 App 退出重开。
|
|
274
284
|
|
|
285
|
+
**个别环境软键盘对浏览器完全不可见,输入框抬升靠估算兜底**:部分组合(实测过:某些第三方输入法 + Chrome;小米浏览器安装的 PWA 壳)里,键盘弹出/收起时系统不把键盘高度告知页面——视口不变、无任何事件(visualViewport、VirtualKeyboard API 一并失效,均已实测排除)。插件的兜底是:聚焦后探测约 1.2 秒,判定「键盘不可见」就按估计高度抬升输入框(判定按浏览器记忆,之后聚焦即时抬升)。代价有两条:抬升高度是估算的,可能与实际键盘有几十像素出入;键盘收起同样无信号,输入框要等你点击或滑动输入框以外的区域才回落。正常环境完全不走这条路径,不受影响。
|
|
286
|
+
|
|
275
287
|
**经反代访问时设置页打不开(插件配置列表空白、模型卡片报「settings are unavailable in this browser」)**:直连 `127.0.0.1:3080/3088` 正常。
|
|
276
288
|
|
|
277
289
|
根因是 DSH 官方的设计,不在网关:设置类 RPC **只对回环连接开放**。客户端按 `location.hostname` 判定(`dsh-client-connection` 的 `isLoopback`),非回环时 `dsh-client-ui-settings` 把持久化降级为 `memory`,设置镜像初始状态就是 `unavailable`——官方源码注释原话是「remote browsers remain process-local because settings RPCs are loopback-only」。所有依赖这个镜像的卡片(模型、插件配置)因此一起空白,与本插件、与 service worker 缓存都无关(2026-08-20 真机 USB 调试 + 本机对照实测)。
|
package/docs/interface.md
CHANGED
|
@@ -52,7 +52,7 @@ v1.0.0(fork 自 [mexiaosqwq/dsh-web-mobile](https://github.com/mexiaosqwq/dsh-
|
|
|
52
52
|
|
|
53
53
|
### 回合过程折叠
|
|
54
54
|
|
|
55
|
-
Chat 视图里,同一回合内的推理块、工具调用块默认折叠成一条「过程 · N
|
|
55
|
+
Chat 视图里,同一回合内的推理块、工具调用块默认折叠成一条「过程 · N 步」摘要行,点开才展开;最终回复文本永远直接可见。运行中的回合摘要行会跟着步数实时更新。默认仅手机断点生效;插件行配置 `config.turnFoldDesktop: true` 可让所有客户端在任意宽度下启用折叠(见 README 配置一节),单个浏览器也可以访问一次 `?mobile-nav-turn-fold=1` 自行开启(按浏览器记忆,`=0` 关闭)。
|
|
56
56
|
|
|
57
57
|
### 手势
|
|
58
58
|
|
package/lib/client.js
CHANGED
|
@@ -4868,6 +4868,21 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
|
|
|
4868
4868
|
[data-slot="conversation.composer.bar"] > [class$="_root"] {
|
|
4869
4869
|
padding-bottom: clamp(8px, var(--mnav-sab), 16px) !important;
|
|
4870
4870
|
}
|
|
4871
|
+
|
|
4872
|
+
/* S10 keyboard avoid: effects/keyboard-avoid.ts mirrors the band of the
|
|
4873
|
+
layout viewport hidden behind the software keyboard into --mnav-kb-lift
|
|
4874
|
+
and stamps the html attribute while (and only while) it is non-zero —
|
|
4875
|
+
a resting transform, even translateY(0), would silently become the
|
|
4876
|
+
containing block for any fixed-position descendant of the bar. In every
|
|
4877
|
+
environment where the browser's own focus-reveal works the attribute is
|
|
4878
|
+
simply never set.
|
|
4879
|
+
The target is the slot's _root CHILD, not the slot wrapper: the wrapper
|
|
4880
|
+
is display:contents — it generates no box, so a transform on it is a
|
|
4881
|
+
silent no-op (found live on-device via CDP, 2026-08-21; the variable and
|
|
4882
|
+
attribute were set and nothing moved). */
|
|
4883
|
+
html[data-mnav-kb] [data-slot="conversation.composer.bar"] > [class$="_root"] {
|
|
4884
|
+
transform: translateY(calc(-1 * var(--mnav-kb-lift, 0px)));
|
|
4885
|
+
}
|
|
4871
4886
|
}
|
|
4872
4887
|
|
|
4873
4888
|
/* The attachment button and its preview row only exist for the phone shell.
|
|
@@ -5128,29 +5143,25 @@ exports.INFO_CSS = `/* ---------- session-info sheet (< 768px) ---------- */
|
|
|
5128
5143
|
__modules["styles/turn-fold.css.js"] = function (require, module, exports) {
|
|
5129
5144
|
"use strict";
|
|
5130
5145
|
// turn-fold — the folded turn process and its summary row (S8, 2026-08-17).
|
|
5131
|
-
// Everything that can hide content lives inside (max-width: 767px)
|
|
5132
|
-
//
|
|
5146
|
+
// Everything that can hide content lives inside (max-width: 767px) OR behind
|
|
5147
|
+
// html[data-mnav-desktop-fold] (the per-browser desktop opt-in, issue #2);
|
|
5148
|
+
// the two markers this file reads (data-mnav-fold on a process element,
|
|
5133
5149
|
// data-mobile-nav="turn-fold" on the injected summary row) are only ever
|
|
5134
|
-
// written by effects/turn-fold.ts, which detaches and wipes them
|
|
5135
|
-
// — so
|
|
5136
|
-
//
|
|
5137
|
-
//
|
|
5150
|
+
// written by effects/turn-fold.ts, which detaches and wipes them when it is
|
|
5151
|
+
// not active — so a non-opted-in tablet/desktop is a no-op twice over, by
|
|
5152
|
+
// attribute and by scope. The rule block is authored once and emitted twice
|
|
5153
|
+
// (phone media query / desktop attribute scope) so the two can never drift.
|
|
5154
|
+
// Appended last in styles/index.ts; it shares no selector with any other
|
|
5155
|
+
// file, the position just keeps the "phone files come after the shared
|
|
5138
5156
|
// <=1023px block" ordering intact.
|
|
5139
5157
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5140
5158
|
exports.TURN_FOLD_CSS = void 0;
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
/* The summary row never paints outside the phone breakpoint, even if a
|
|
5144
|
-
media-query change raced the effect's own cleanup. */
|
|
5145
|
-
[data-mobile-nav="turn-fold"] {
|
|
5146
|
-
display: none;
|
|
5147
|
-
}
|
|
5148
|
-
|
|
5149
|
-
@media (max-width: 767px) {
|
|
5159
|
+
/** The fold + chip rules, with every selector prefixed by `scope`. */
|
|
5160
|
+
const rules = (scope) => `
|
|
5150
5161
|
/* Folded process: tool-call / context / command rows and the Think
|
|
5151
5162
|
disclosures inside an assistant step. !important because the official
|
|
5152
5163
|
flow item and ReasoningRow both set their own display. */
|
|
5153
|
-
[data-mnav-fold]:not([data-mnav-fold-open]) {
|
|
5164
|
+
${scope} [data-mnav-fold]:not([data-mnav-fold-open]) {
|
|
5154
5165
|
display: none !important;
|
|
5155
5166
|
}
|
|
5156
5167
|
|
|
@@ -5160,7 +5171,7 @@ exports.TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) ---------- */
|
|
|
5160
5171
|
column with gap: 16px (ChatView.module.css) — align-self keeps the chip
|
|
5161
5172
|
from stretching, and the negative block margin trims that generous gap
|
|
5162
5173
|
back to something a 26px row can live in. */
|
|
5163
|
-
[data-chat-flow] > [data-mobile-nav="turn-fold"] {
|
|
5174
|
+
${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"] {
|
|
5164
5175
|
display: inline-flex;
|
|
5165
5176
|
align-self: flex-start;
|
|
5166
5177
|
align-items: center;
|
|
@@ -5179,14 +5190,14 @@ exports.TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) ---------- */
|
|
|
5179
5190
|
cursor: pointer;
|
|
5180
5191
|
-webkit-tap-highlight-color: transparent;
|
|
5181
5192
|
}
|
|
5182
|
-
[data-chat-flow] > [data-mobile-nav="turn-fold"]:active {
|
|
5193
|
+
${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]:active {
|
|
5183
5194
|
background: var(--dsw-alias-interactive-bg-pressed, rgba(0, 0, 0, .1));
|
|
5184
5195
|
}
|
|
5185
5196
|
|
|
5186
5197
|
/* Leading dot: idle turns get a quiet mark, a running turn gets the same
|
|
5187
5198
|
business-primary colour the session header's own status dot uses
|
|
5188
5199
|
(styles/header.css.ts) plus a breathing pulse. */
|
|
5189
|
-
[data-chat-flow] > [data-mobile-nav="turn-fold"]::before {
|
|
5200
|
+
${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]::before {
|
|
5190
5201
|
content: '';
|
|
5191
5202
|
flex: none;
|
|
5192
5203
|
width: 6px;
|
|
@@ -5195,7 +5206,7 @@ exports.TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) ---------- */
|
|
|
5195
5206
|
background: currentColor;
|
|
5196
5207
|
opacity: .45;
|
|
5197
5208
|
}
|
|
5198
|
-
[data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
|
|
5209
|
+
${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
|
|
5199
5210
|
background: var(--dsw-alias-state-business-primary, #4f6ef7);
|
|
5200
5211
|
opacity: 1;
|
|
5201
5212
|
animation: dsh-mobile-nav-breathe 1.4s var(--ds-ease-in-out, ease-in-out) infinite;
|
|
@@ -5203,7 +5214,7 @@ exports.TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) ---------- */
|
|
|
5203
5214
|
|
|
5204
5215
|
/* Chevron drawn from two borders — no icon injection, no mask image, and
|
|
5205
5216
|
it rotates to point up once the turn is open. */
|
|
5206
|
-
[data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
|
|
5217
|
+
${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
|
|
5207
5218
|
content: '';
|
|
5208
5219
|
flex: none;
|
|
5209
5220
|
width: 5px;
|
|
@@ -5214,21 +5225,35 @@ exports.TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) ---------- */
|
|
|
5214
5225
|
transform: rotate(45deg);
|
|
5215
5226
|
transition: transform .16s var(--ds-ease-out, ease-in-out);
|
|
5216
5227
|
}
|
|
5217
|
-
[data-chat-flow] > [data-mobile-nav="turn-fold"][data-open]::after {
|
|
5228
|
+
${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"][data-open]::after {
|
|
5218
5229
|
margin-top: 3px;
|
|
5219
5230
|
transform: rotate(-135deg);
|
|
5220
5231
|
}
|
|
5221
5232
|
|
|
5222
5233
|
@media (prefers-reduced-motion: reduce) {
|
|
5223
|
-
[data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
|
|
5234
|
+
${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
|
|
5224
5235
|
animation: none;
|
|
5225
5236
|
}
|
|
5226
|
-
[data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
|
|
5237
|
+
${scope} [data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
|
|
5227
5238
|
transition: none;
|
|
5228
5239
|
}
|
|
5229
5240
|
}
|
|
5241
|
+
`;
|
|
5242
|
+
exports.TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px, or desktop opt-in) ---------- */
|
|
5243
|
+
|
|
5244
|
+
/* The summary row never paints outside its active scope, even if a
|
|
5245
|
+
media-query change raced the effect's own cleanup. */
|
|
5246
|
+
[data-mobile-nav="turn-fold"] {
|
|
5247
|
+
display: none;
|
|
5230
5248
|
}
|
|
5231
5249
|
|
|
5250
|
+
@media (max-width: 767px) {${rules('')}}
|
|
5251
|
+
|
|
5252
|
+
/* Desktop opt-in: the exact same block, keyed on the root attribute
|
|
5253
|
+
effects/turn-fold.ts sets for a browser opted in via
|
|
5254
|
+
?mobile-nav-turn-fold=1 — active at every width. */
|
|
5255
|
+
${rules('html[data-mnav-desktop-fold]')}
|
|
5256
|
+
|
|
5232
5257
|
@keyframes dsh-mobile-nav-breathe {
|
|
5233
5258
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
5234
5259
|
50% { opacity: .4; transform: scale(.72); }
|
|
@@ -6774,6 +6799,42 @@ exports.installTurnFold = installTurnFold;
|
|
|
6774
6799
|
const locales_ts_1 = require("./locales.js");
|
|
6775
6800
|
/** Phone breakpoint — same query every phone-only effect in this plugin uses. */
|
|
6776
6801
|
const PHONE_QUERY = '(max-width: 767px)';
|
|
6802
|
+
/** Desktop fold (issue #2) is switched on either way:
|
|
6803
|
+
* - plugin row `config.turnFoldDesktop: true` — the host republishes it at
|
|
6804
|
+
* {@link CLIENT_CONFIG_ROUTE} because the client bundle ships statically
|
|
6805
|
+
* and never sees the row config;
|
|
6806
|
+
* - per-browser override, same convention as the debug flag: a URL param
|
|
6807
|
+
* writes localStorage (`?mobile-nav-turn-fold=1` opts this browser in,
|
|
6808
|
+
* `=0` opts back out). */
|
|
6809
|
+
const DESKTOP_KEY = 'dsh-mobile-nav.turn-fold-desktop';
|
|
6810
|
+
const DESKTOP_PARAM = 'mobile-nav-turn-fold';
|
|
6811
|
+
/** Root attribute the stylesheet keys the width-independent rules on. */
|
|
6812
|
+
const DESKTOP_ATTR = 'data-mnav-desktop-fold';
|
|
6813
|
+
/** Mirror of src/index.ts CLIENT_CONFIG_ROUTE (client tsconfig cannot reach it). */
|
|
6814
|
+
const CLIENT_CONFIG_ROUTE = '/_dsh/mobile-nav/client-config';
|
|
6815
|
+
/** Read (and, when the URL param is present, persist) the per-browser opt-in. */
|
|
6816
|
+
function desktopOptIn() {
|
|
6817
|
+
const param = new URLSearchParams(location.search).get(DESKTOP_PARAM);
|
|
6818
|
+
if (param === '1')
|
|
6819
|
+
localStorage.setItem(DESKTOP_KEY, '1');
|
|
6820
|
+
else if (param === '0')
|
|
6821
|
+
localStorage.removeItem(DESKTOP_KEY);
|
|
6822
|
+
return localStorage.getItem(DESKTOP_KEY) === '1';
|
|
6823
|
+
}
|
|
6824
|
+
/** True when the plugin row asks for desktop fold; false on any failure. */
|
|
6825
|
+
async function desktopConfigured() {
|
|
6826
|
+
try {
|
|
6827
|
+
const res = await fetch(CLIENT_CONFIG_ROUTE, { cache: 'no-store' });
|
|
6828
|
+
if (!res.ok)
|
|
6829
|
+
return false;
|
|
6830
|
+
const body = await res.json();
|
|
6831
|
+
return typeof body === 'object' && body !== null
|
|
6832
|
+
&& body.turnFoldDesktop === true;
|
|
6833
|
+
}
|
|
6834
|
+
catch {
|
|
6835
|
+
return false;
|
|
6836
|
+
}
|
|
6837
|
+
}
|
|
6777
6838
|
/** ChatView's flow column (dsh-client-ui-conversation lib/client.js:5512).
|
|
6778
6839
|
* Trajectory and every other view render their own tree and never carry this
|
|
6779
6840
|
* marker, so scoping here is what keeps this effect Chat-only. */
|
|
@@ -6879,7 +6940,9 @@ function groupsOf(flow) {
|
|
|
6879
6940
|
return groups;
|
|
6880
6941
|
}
|
|
6881
6942
|
/**
|
|
6882
|
-
* S8 — collapse a turn's process into one summary row (< 768px
|
|
6943
|
+
* S8 — collapse a turn's process into one summary row (< 768px by default;
|
|
6944
|
+
* every width when the plugin row sets `config.turnFoldDesktop` or a browser
|
|
6945
|
+
* opts itself in via `?mobile-nav-turn-fold=1`).
|
|
6883
6946
|
*
|
|
6884
6947
|
* Route taken: DOM marking, not the `conversation.chat.node` keyed slot.
|
|
6885
6948
|
* That slot dispatches on node kind and a second registration at a key
|
|
@@ -7033,13 +7096,35 @@ function installTurnFold(ctx) {
|
|
|
7033
7096
|
expanded.clear();
|
|
7034
7097
|
clear();
|
|
7035
7098
|
};
|
|
7099
|
+
const onChange = (event) => (event.matches ? attach() : detach());
|
|
7100
|
+
let disposed = false;
|
|
7101
|
+
let desktop = false;
|
|
7102
|
+
/** Attach at every width and drop the breakpoint listener — the
|
|
7103
|
+
* stylesheet's html[DESKTOP_ATTR] rules take over from the phone media
|
|
7104
|
+
* query. Idempotent: the config answer and the local opt-in may both
|
|
7105
|
+
* ask for it. */
|
|
7106
|
+
const enableDesktop = () => {
|
|
7107
|
+
if (desktop || disposed)
|
|
7108
|
+
return;
|
|
7109
|
+
desktop = true;
|
|
7110
|
+
document.documentElement.setAttribute(DESKTOP_ATTR, '');
|
|
7111
|
+
narrow.removeEventListener('change', onChange);
|
|
7112
|
+
attach();
|
|
7113
|
+
};
|
|
7036
7114
|
if (narrow.matches)
|
|
7037
7115
|
attach();
|
|
7038
|
-
const onChange = (event) => (event.matches ? attach() : detach());
|
|
7039
7116
|
narrow.addEventListener('change', onChange);
|
|
7117
|
+
if (desktopOptIn())
|
|
7118
|
+
enableDesktop();
|
|
7119
|
+
// The row config arrives async; a late enable just attaches then. Not
|
|
7120
|
+
// awaited before phone attach — folding must not wait on a round-trip.
|
|
7121
|
+
else
|
|
7122
|
+
void desktopConfigured().then((on) => (on ? enableDesktop() : undefined));
|
|
7040
7123
|
// A language switch changes the chip copy of every already-rendered turn.
|
|
7041
7124
|
const stopLocale = ctx.locale.subscribe(schedule);
|
|
7042
7125
|
return () => {
|
|
7126
|
+
disposed = true;
|
|
7127
|
+
document.documentElement.removeAttribute(DESKTOP_ATTR);
|
|
7043
7128
|
narrow.removeEventListener('change', onChange);
|
|
7044
7129
|
stopLocale();
|
|
7045
7130
|
detach();
|
|
@@ -7340,6 +7425,347 @@ function installKeyboardGuard(ctx) {
|
|
|
7340
7425
|
}, 'dsh-mobile-nav: composer keyboard guard');
|
|
7341
7426
|
}
|
|
7342
7427
|
};
|
|
7428
|
+
__modules["effects/keyboard-avoid.js"] = function (require, module, exports) {
|
|
7429
|
+
"use strict";
|
|
7430
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7431
|
+
exports.safetyPad = safetyPad;
|
|
7432
|
+
exports.estimatedLift = estimatedLift;
|
|
7433
|
+
exports.keyboardLift = keyboardLift;
|
|
7434
|
+
exports.composerLift = composerLift;
|
|
7435
|
+
exports.installKeyboardAvoid = installKeyboardAvoid;
|
|
7436
|
+
/** Phone breakpoint — same query every phone-only effect in this plugin uses. */
|
|
7437
|
+
const PHONE_QUERY = '(max-width: 767px)';
|
|
7438
|
+
/** Root CSS variable composer.css.ts turns into a composer lift. */
|
|
7439
|
+
const LIFT_VAR = '--mnav-kb-lift';
|
|
7440
|
+
/** Root attribute gating the transform rule. Present only while lifting:
|
|
7441
|
+
* a transform — even translateY(0) — makes the bar the containing block for
|
|
7442
|
+
* any fixed-position descendant, so the rule must not exist at rest. */
|
|
7443
|
+
const LIFT_ATTR = 'data-mnav-kb';
|
|
7444
|
+
/** Ignore sub-pixel / rounding noise so the composer never jitters. */
|
|
7445
|
+
const MIN_LIFT_PX = 2;
|
|
7446
|
+
/** Above this visualViewport scale the geometry is pinch-zoom, not keyboard. */
|
|
7447
|
+
const MAX_SCALE = 1.01;
|
|
7448
|
+
/** Extra clearance whenever the keyboard is up AND the browser reacted to it.
|
|
7449
|
+
* Third-party IMEs routinely under-report their height — the toolbar strip
|
|
7450
|
+
* above the keys is left out of what they hand the system, so the viewport
|
|
7451
|
+
* shrinks by less than the keyboard actually covers and the composer lands
|
|
7452
|
+
* slightly under it (搜狗 ~30px is a documented case; one reporter saw the
|
|
7453
|
+
* same with 微信输入法 after the viewport DID shrink). Deliberately small:
|
|
7454
|
+
* it only has to clear a toolbar strip, not a keyboard.
|
|
7455
|
+
*
|
|
7456
|
+
* ANDROID ONLY ({@link safetyPad}). The under-reporting is an Android IME
|
|
7457
|
+
* behaviour; iOS has one system keyboard whose height WebKit reports exactly,
|
|
7458
|
+
* so padding there would be a visible gap bought for nothing. */
|
|
7459
|
+
const SAFETY_PAD_PX = 15;
|
|
7460
|
+
/** The safety pad this platform gets: the clearance above, or nothing off
|
|
7461
|
+
* Android. UA sniffing is the right tool here — the target is a platform
|
|
7462
|
+
* defect, not a feature that could be detected. */
|
|
7463
|
+
function safetyPad(userAgent) {
|
|
7464
|
+
return /Android/iu.test(userAgent) ? SAFETY_PAD_PX : 0;
|
|
7465
|
+
}
|
|
7466
|
+
/** A viewport that lost at least this much against its no-keyboard baseline
|
|
7467
|
+
* has a keyboard up. Comfortably above the ~56px a collapsing URL bar moves,
|
|
7468
|
+
* comfortably below any real keyboard. */
|
|
7469
|
+
const KEYBOARD_MIN_SHRINK_PX = 100;
|
|
7470
|
+
/** The official composer slot wrapper (same marker keyboard-guard reads). */
|
|
7471
|
+
const COMPOSER = '[data-slot="conversation.composer.bar"]';
|
|
7472
|
+
/** Dumb-keyboard probe: after a touch-granted focus, the viewport gets this
|
|
7473
|
+
* long to move before the keyboard is declared invisible to the browser. */
|
|
7474
|
+
const PROBE_TOTAL_MS = 1200;
|
|
7475
|
+
const PROBE_INTERVAL_MS = 120;
|
|
7476
|
+
/** Per-browser cache of the probe's verdict. Once a keyboard has proven
|
|
7477
|
+
* invisible, later focuses lift IMMEDIATELY instead of sitting through the
|
|
7478
|
+
* probe again — the probe still runs in the background on every focus, and
|
|
7479
|
+
* any viewport movement it sees revokes the cache (IME switched, engine
|
|
7480
|
+
* fixed), returning the browser to the pure geometric path. */
|
|
7481
|
+
const DUMB_KEY = 'dsh-mobile-nav.kb-dumb';
|
|
7482
|
+
/** Any viewport movement beyond this during the probe means the browser can
|
|
7483
|
+
* see the keyboard (or is panning) — the geometric path owns the job then. */
|
|
7484
|
+
const PROBE_EPSILON_PX = 8;
|
|
7485
|
+
/**
|
|
7486
|
+
* Fallback lift for a keyboard the browser cannot see (issue #1 确诊根因:
|
|
7487
|
+
* 微信输入法在该设备上不向系统 insets 上报键盘高度, Chrome 键盘高度恒 0).
|
|
7488
|
+
* There is no signal to measure, so this is an estimate: the reporter's
|
|
7489
|
+
* measured WeType is ~315 CSS px on a 858px viewport (~37%); 42% capped at
|
|
7490
|
+
* 400px covers taller IME toolbars without stranding the composer mid-screen.
|
|
7491
|
+
* ponytail: single heuristic constant; per-IME calibration only if reports
|
|
7492
|
+
* show it misses.
|
|
7493
|
+
*/
|
|
7494
|
+
function estimatedLift(innerHeight) {
|
|
7495
|
+
return Math.min(Math.round(innerHeight * 0.42), 400);
|
|
7496
|
+
}
|
|
7497
|
+
/**
|
|
7498
|
+
* How far the composer must rise so its bottom edge sits on the visual
|
|
7499
|
+
* viewport's bottom edge (issue #1, 方案 2 of
|
|
7500
|
+
* docs/research-ime-keyboard-occlusion.md).
|
|
7501
|
+
*
|
|
7502
|
+
* The composer is sticky at the LAYOUT viewport's bottom; the keyboard
|
|
7503
|
+
* shrinks only the VISUAL viewport (Chrome 108+ resizes-visual). When the
|
|
7504
|
+
* browser also pans the visual viewport down to reveal the focused field —
|
|
7505
|
+
* iOS, and Android when its auto-scroll works — the occluded band is zero
|
|
7506
|
+
* and this stays a no-op. When it shrinks without panning (the reported
|
|
7507
|
+
* class of bug), the difference is exactly the hidden band.
|
|
7508
|
+
*
|
|
7509
|
+
* Pinch-zoom shrinks vvHeight too; the scale guard keeps zooming from
|
|
7510
|
+
* flinging the composer around.
|
|
7511
|
+
*/
|
|
7512
|
+
function keyboardLift(reading) {
|
|
7513
|
+
if (reading.scale > MAX_SCALE)
|
|
7514
|
+
return 0;
|
|
7515
|
+
const occluded = reading.innerHeight - reading.vvHeight - reading.offsetTop;
|
|
7516
|
+
return occluded >= MIN_LIFT_PX ? Math.round(occluded) : 0;
|
|
7517
|
+
}
|
|
7518
|
+
/**
|
|
7519
|
+
* The lift the composer actually gets, from the three sources in priority
|
|
7520
|
+
* order.
|
|
7521
|
+
* @param geometric - {@link keyboardLift} of the current reading.
|
|
7522
|
+
* @param estimate - the dumb-keyboard estimate, 0 when not in that mode.
|
|
7523
|
+
* @param keyboardShrunk - the viewport lost {@link KEYBOARD_MIN_SHRINK_PX}
|
|
7524
|
+
* or more against its no-keyboard baseline, i.e. the browser reacted.
|
|
7525
|
+
* @param pad - this platform's safety clearance, from {@link safetyPad}.
|
|
7526
|
+
* @returns pixels to translate the composer up by.
|
|
7527
|
+
*/
|
|
7528
|
+
function composerLift(geometric, estimate, keyboardShrunk, pad) {
|
|
7529
|
+
// Measured occlusion, plus clearance for the strip the IME did not declare.
|
|
7530
|
+
if (geometric > 0)
|
|
7531
|
+
return geometric + pad;
|
|
7532
|
+
// The estimate is a generous fraction already; padding it would strand the
|
|
7533
|
+
// composer mid-screen.
|
|
7534
|
+
if (estimate > 0)
|
|
7535
|
+
return estimate;
|
|
7536
|
+
// Nothing occluded on paper, but a keyboard IS up and the browser handled
|
|
7537
|
+
// it: the only thing that can still cover the composer is an under-reported
|
|
7538
|
+
// toolbar, so clear exactly that.
|
|
7539
|
+
return keyboardShrunk ? pad : 0;
|
|
7540
|
+
}
|
|
7541
|
+
/**
|
|
7542
|
+
* S10 — keep the composer above the software keyboard (< 768px).
|
|
7543
|
+
*
|
|
7544
|
+
* The shell deliberately relies on the browser's own focus-reveal behaviour
|
|
7545
|
+
* (home.css.ts: plain overflow:hidden so iOS pans the visual viewport, no
|
|
7546
|
+
* visualViewport JS). Issue #1 (小米 + 微信输入法) showed one environment
|
|
7547
|
+
* where that chain can break while the viewport still shrinks. This effect
|
|
7548
|
+
* is the increment that covers it: mirror the occluded band into a root CSS
|
|
7549
|
+
* variable, and let the stylesheet translate the composer up by it. In every
|
|
7550
|
+
* environment where the browser already handles the keyboard the band
|
|
7551
|
+
* computes to zero and nothing changes.
|
|
7552
|
+
*
|
|
7553
|
+
* `scroll` is listened to as well as `resize`: panning the visual viewport
|
|
7554
|
+
* changes offsetTop without a resize (CSSOM View §13.2), and both sides of
|
|
7555
|
+
* the subtraction must stay fresh.
|
|
7556
|
+
*
|
|
7557
|
+
* Second layer (2026-08-21, after on-device confirmation): the reporter's
|
|
7558
|
+
* 小米 + 微信输入法 keyboard is INVISIBLE to Chrome — vv.height stayed 859/858
|
|
7559
|
+
* with the keyboard open, so no event ever fires and the geometry above is
|
|
7560
|
+
* honestly zero. For that class only, a touch-granted composer focus starts a
|
|
7561
|
+
* short probe; if the viewport has not moved at all by the end, the composer
|
|
7562
|
+
* gets an ESTIMATED lift until blur. Guards against false positives:
|
|
7563
|
+
* - probe only after a recent touch pointerdown (a hardware-keyboard focus
|
|
7564
|
+
* never lifts anything);
|
|
7565
|
+
* - any viewport movement ≥ {@link PROBE_EPSILON_PX} cancels the probe — a
|
|
7566
|
+
* browser that shows any reaction owns the reveal itself (iOS pans,
|
|
7567
|
+
* working Android resizes);
|
|
7568
|
+
* - a non-zero geometric lift always wins over the estimate.
|
|
7569
|
+
*
|
|
7570
|
+
* Third layer: an IME that under-reports its height (toolbar strip left out
|
|
7571
|
+
* of what it declares) makes the browser shrink the viewport by less than the
|
|
7572
|
+
* keyboard covers — every number the page can read is self-consistent, so no
|
|
7573
|
+
* occlusion is computable. Whenever a keyboard is up and the browser DID
|
|
7574
|
+
* react, the composer therefore also gets {@link safetyPad} of clearance —
|
|
7575
|
+
* Android only, where the under-reporting happens.
|
|
7576
|
+
* See {@link composerLift} for how the three sources compose.
|
|
7577
|
+
*/
|
|
7578
|
+
function installKeyboardAvoid(ctx) {
|
|
7579
|
+
ctx.effect(() => {
|
|
7580
|
+
const vv = window.visualViewport;
|
|
7581
|
+
if (vv === null || vv === undefined)
|
|
7582
|
+
return () => { };
|
|
7583
|
+
const viewport = vv;
|
|
7584
|
+
const narrow = window.matchMedia(PHONE_QUERY);
|
|
7585
|
+
const root = document.documentElement;
|
|
7586
|
+
let frame = 0;
|
|
7587
|
+
let applied = 0;
|
|
7588
|
+
let attached = false;
|
|
7589
|
+
/** Estimated lift while a browser-invisible keyboard is up; 0 otherwise. */
|
|
7590
|
+
let estimate = 0;
|
|
7591
|
+
let probeTimer = 0;
|
|
7592
|
+
let lastTouch = 0;
|
|
7593
|
+
/** Viewport height with no keyboard up, the shrink is measured against
|
|
7594
|
+
* it. Re-read on every sync that finds the composer unfocused, so a
|
|
7595
|
+
* rotation or a URL-bar change rebases it without extra listeners. */
|
|
7596
|
+
let baseline = 0;
|
|
7597
|
+
const pad = safetyPad(navigator.userAgent);
|
|
7598
|
+
const composerTextarea = (node) => node instanceof HTMLElement && node.tagName === 'TEXTAREA' && node.closest(COMPOSER) !== null;
|
|
7599
|
+
const sync = () => {
|
|
7600
|
+
const focused = composerTextarea(document.activeElement);
|
|
7601
|
+
if (!focused)
|
|
7602
|
+
baseline = viewport.height;
|
|
7603
|
+
const geometric = keyboardLift({
|
|
7604
|
+
innerHeight: window.innerHeight,
|
|
7605
|
+
vvHeight: viewport.height,
|
|
7606
|
+
offsetTop: viewport.offsetTop,
|
|
7607
|
+
scale: viewport.scale,
|
|
7608
|
+
});
|
|
7609
|
+
const shrunk = focused && baseline - viewport.height >= KEYBOARD_MIN_SHRINK_PX;
|
|
7610
|
+
const lift = composerLift(geometric, estimate, shrunk, pad);
|
|
7611
|
+
if (lift === applied)
|
|
7612
|
+
return;
|
|
7613
|
+
applied = lift;
|
|
7614
|
+
if (lift === 0) {
|
|
7615
|
+
root.style.removeProperty(LIFT_VAR);
|
|
7616
|
+
root.removeAttribute(LIFT_ATTR);
|
|
7617
|
+
}
|
|
7618
|
+
else {
|
|
7619
|
+
root.style.setProperty(LIFT_VAR, `${lift}px`);
|
|
7620
|
+
root.setAttribute(LIFT_ATTR, '');
|
|
7621
|
+
}
|
|
7622
|
+
};
|
|
7623
|
+
const schedule = () => {
|
|
7624
|
+
if (frame !== 0)
|
|
7625
|
+
return;
|
|
7626
|
+
frame = requestAnimationFrame(() => {
|
|
7627
|
+
frame = 0;
|
|
7628
|
+
sync();
|
|
7629
|
+
});
|
|
7630
|
+
};
|
|
7631
|
+
const stopProbe = () => {
|
|
7632
|
+
if (probeTimer !== 0)
|
|
7633
|
+
window.clearTimeout(probeTimer);
|
|
7634
|
+
probeTimer = 0;
|
|
7635
|
+
};
|
|
7636
|
+
/** End the estimated lift AND close the keyboard (blur), keeping the two
|
|
7637
|
+
* in one coherent state. Only ever called while an estimate is active, so
|
|
7638
|
+
* healthy environments never feel it. */
|
|
7639
|
+
const retract = () => {
|
|
7640
|
+
stopProbe();
|
|
7641
|
+
if (estimate !== 0) {
|
|
7642
|
+
estimate = 0;
|
|
7643
|
+
sync();
|
|
7644
|
+
}
|
|
7645
|
+
const el = document.activeElement;
|
|
7646
|
+
if (composerTextarea(el) && el instanceof HTMLElement)
|
|
7647
|
+
el.blur();
|
|
7648
|
+
};
|
|
7649
|
+
/** True when a touch outside the composer means "the reader moved on". */
|
|
7650
|
+
const outside = (target) => estimate !== 0 && target instanceof Element && target.closest(COMPOSER) === null;
|
|
7651
|
+
const onPointerDown = (event) => {
|
|
7652
|
+
if (event.pointerType === 'touch')
|
|
7653
|
+
lastTouch = Date.now();
|
|
7654
|
+
// An invisible keyboard also closes invisibly (system back / the IME's
|
|
7655
|
+
// own collapse chevron keep the textarea focused), so blur alone cannot
|
|
7656
|
+
// end the estimate. A tap OR a scroll outside the composer is the
|
|
7657
|
+
// reader moving on — retract and dismiss the keyboard together, the
|
|
7658
|
+
// same gesture mainstream chat apps use.
|
|
7659
|
+
// ponytail: collapse-then-just-read keeps the lift until the next
|
|
7660
|
+
// touch; a real close signal does not exist in this class.
|
|
7661
|
+
if (outside(event.target))
|
|
7662
|
+
retract();
|
|
7663
|
+
};
|
|
7664
|
+
const onTouchMove = (event) => {
|
|
7665
|
+
if (outside(event.target))
|
|
7666
|
+
retract();
|
|
7667
|
+
};
|
|
7668
|
+
const onFocusIn = (event) => {
|
|
7669
|
+
if (!composerTextarea(event.target))
|
|
7670
|
+
return;
|
|
7671
|
+
// Focus not born from a touch (hardware keyboard, programmatic) raises
|
|
7672
|
+
// no on-screen keyboard — never estimate for it.
|
|
7673
|
+
if (Date.now() - lastTouch > 1000)
|
|
7674
|
+
return;
|
|
7675
|
+
stopProbe();
|
|
7676
|
+
// A browser already convicted by an earlier probe lifts right away —
|
|
7677
|
+
// the probe below still runs and revokes the verdict if the viewport
|
|
7678
|
+
// turns out to react after all.
|
|
7679
|
+
if (localStorage.getItem(DUMB_KEY) === '1') {
|
|
7680
|
+
estimate = estimatedLift(window.innerHeight);
|
|
7681
|
+
sync();
|
|
7682
|
+
}
|
|
7683
|
+
const h0 = viewport.height;
|
|
7684
|
+
const t0 = viewport.offsetTop;
|
|
7685
|
+
const i0 = window.innerHeight;
|
|
7686
|
+
const deadline = Date.now() + PROBE_TOTAL_MS;
|
|
7687
|
+
const moved = () => Math.abs(viewport.height - h0) >= PROBE_EPSILON_PX
|
|
7688
|
+
|| Math.abs(viewport.offsetTop - t0) >= PROBE_EPSILON_PX
|
|
7689
|
+
|| Math.abs(window.innerHeight - i0) >= PROBE_EPSILON_PX;
|
|
7690
|
+
const step = () => {
|
|
7691
|
+
probeTimer = 0;
|
|
7692
|
+
if (moved()) {
|
|
7693
|
+
// The browser can see this keyboard: the geometric path owns the
|
|
7694
|
+
// reveal. Drop any stale verdict and estimated lift.
|
|
7695
|
+
localStorage.removeItem(DUMB_KEY);
|
|
7696
|
+
if (estimate !== 0) {
|
|
7697
|
+
estimate = 0;
|
|
7698
|
+
sync();
|
|
7699
|
+
}
|
|
7700
|
+
return;
|
|
7701
|
+
}
|
|
7702
|
+
if (Date.now() < deadline) {
|
|
7703
|
+
probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS);
|
|
7704
|
+
return;
|
|
7705
|
+
}
|
|
7706
|
+
// Keyboard is up (touch focus on a phone) yet the viewport never
|
|
7707
|
+
// reacted: the browser cannot see it. Estimate until blur, and
|
|
7708
|
+
// remember the verdict so the next focus skips the wait.
|
|
7709
|
+
if (!composerTextarea(document.activeElement))
|
|
7710
|
+
return;
|
|
7711
|
+
localStorage.setItem(DUMB_KEY, '1');
|
|
7712
|
+
estimate = estimatedLift(window.innerHeight);
|
|
7713
|
+
sync();
|
|
7714
|
+
};
|
|
7715
|
+
probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS);
|
|
7716
|
+
};
|
|
7717
|
+
const onFocusOut = (event) => {
|
|
7718
|
+
if (!composerTextarea(event.target))
|
|
7719
|
+
return;
|
|
7720
|
+
stopProbe();
|
|
7721
|
+
if (estimate === 0)
|
|
7722
|
+
return;
|
|
7723
|
+
estimate = 0;
|
|
7724
|
+
sync();
|
|
7725
|
+
};
|
|
7726
|
+
const attach = () => {
|
|
7727
|
+
if (attached)
|
|
7728
|
+
return;
|
|
7729
|
+
attached = true;
|
|
7730
|
+
viewport.addEventListener('resize', schedule);
|
|
7731
|
+
viewport.addEventListener('scroll', schedule);
|
|
7732
|
+
document.addEventListener('pointerdown', onPointerDown, { capture: true, passive: true });
|
|
7733
|
+
document.addEventListener('touchmove', onTouchMove, { capture: true, passive: true });
|
|
7734
|
+
document.addEventListener('focusin', onFocusIn, true);
|
|
7735
|
+
document.addEventListener('focusout', onFocusOut, true);
|
|
7736
|
+
sync();
|
|
7737
|
+
};
|
|
7738
|
+
const detach = () => {
|
|
7739
|
+
if (!attached)
|
|
7740
|
+
return;
|
|
7741
|
+
attached = false;
|
|
7742
|
+
viewport.removeEventListener('resize', schedule);
|
|
7743
|
+
viewport.removeEventListener('scroll', schedule);
|
|
7744
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
7745
|
+
document.removeEventListener('touchmove', onTouchMove, true);
|
|
7746
|
+
document.removeEventListener('focusin', onFocusIn, true);
|
|
7747
|
+
document.removeEventListener('focusout', onFocusOut, true);
|
|
7748
|
+
stopProbe();
|
|
7749
|
+
if (frame !== 0)
|
|
7750
|
+
cancelAnimationFrame(frame);
|
|
7751
|
+
frame = 0;
|
|
7752
|
+
applied = 0;
|
|
7753
|
+
estimate = 0;
|
|
7754
|
+
baseline = 0;
|
|
7755
|
+
root.style.removeProperty(LIFT_VAR);
|
|
7756
|
+
root.removeAttribute(LIFT_ATTR);
|
|
7757
|
+
};
|
|
7758
|
+
if (narrow.matches)
|
|
7759
|
+
attach();
|
|
7760
|
+
const onChange = (event) => (event.matches ? attach() : detach());
|
|
7761
|
+
narrow.addEventListener('change', onChange);
|
|
7762
|
+
return () => {
|
|
7763
|
+
narrow.removeEventListener('change', onChange);
|
|
7764
|
+
detach();
|
|
7765
|
+
};
|
|
7766
|
+
}, 'dsh-mobile-nav: composer keyboard avoid');
|
|
7767
|
+
}
|
|
7768
|
+
};
|
|
7343
7769
|
__modules["index.js"] = function (require, module, exports) {
|
|
7344
7770
|
"use strict";
|
|
7345
7771
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -7364,6 +7790,7 @@ const turn_fold_ts_1 = require("./effects/turn-fold.js");
|
|
|
7364
7790
|
const modal_back_ts_1 = require("./effects/modal-back.js");
|
|
7365
7791
|
const welcome_notice_ts_1 = require("./effects/welcome-notice.js");
|
|
7366
7792
|
const keyboard_guard_ts_1 = require("./effects/keyboard-guard.js");
|
|
7793
|
+
const keyboard_avoid_ts_1 = require("./effects/keyboard-avoid.js");
|
|
7367
7794
|
const locales_ts_1 = require("./locales.js");
|
|
7368
7795
|
/** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */
|
|
7369
7796
|
exports.inject = ['slots', 'layout', 'locale', 'sessionLogDownload', 'sessions', 'workspaces'];
|
|
@@ -7416,6 +7843,10 @@ function apply(ctx) {
|
|
|
7416
7843
|
// composer autofocuses on every sessionId change; keep focus only when the
|
|
7417
7844
|
// user tapped the composer (or typed) themselves.
|
|
7418
7845
|
(0, keyboard_guard_ts_1.installKeyboardGuard)(ctx);
|
|
7846
|
+
// S10: when the keyboard shrinks the visual viewport but the browser fails
|
|
7847
|
+
// to reveal the focused composer (issue #1 的「视口缩了页面没跟上」类环境),
|
|
7848
|
+
// translate the composer up by the occluded band. Inert everywhere else.
|
|
7849
|
+
(0, keyboard_avoid_ts_1.installKeyboardAvoid)(ctx);
|
|
7419
7850
|
// Page-stack store (apply world) — created before any registration so
|
|
7420
7851
|
// every slot below (the phone home screen, the session header's back
|
|
7421
7852
|
// button) shares the exact same handle/instance.
|