dsh-mobile-flow 0.4.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +183 -8
- package/README.zh.md +144 -7
- package/lib/client.js +1803 -15
- package/package.json +11 -3
package/lib/client.js
CHANGED
|
@@ -4,6 +4,9 @@ window.__ModuleLoader__.load({
|
|
|
4
4
|
var module = { exports: {} };
|
|
5
5
|
var exports = module.exports;
|
|
6
6
|
|
|
7
|
+
var React = require("react");
|
|
8
|
+
var h = React.createElement;
|
|
9
|
+
|
|
7
10
|
/* Mobile in-flow composer: on narrow viewports the composer seat (input
|
|
8
11
|
bar + AI confirmation takeovers) joins the document flow, so swiping
|
|
9
12
|
up scrolls it out of view and the transcript gets the full screen.
|
|
@@ -14,8 +17,9 @@ window.__ModuleLoader__.load({
|
|
|
14
17
|
|
|
15
18
|
Selectors target the product's stable data-* attributes
|
|
16
19
|
(data-composer-seat / data-conversation-scroll / data-phase /
|
|
17
|
-
data-slot
|
|
18
|
-
|
|
20
|
+
data-slot / data-composer-card / data-input-scroll), never
|
|
21
|
+
CSS-Modules-hashed class names; all hooks verified against the
|
|
22
|
+
0.1.2-rc.1 shipped bundles (re-verified against 0.1.5-rc.1). */
|
|
19
23
|
var CSS = [
|
|
20
24
|
"/* ── dsh-mobile-flow: in-flow composer (≤720px) ── */",
|
|
21
25
|
"@media (max-width: 720px) {",
|
|
@@ -83,25 +87,188 @@ window.__ModuleLoader__.load({
|
|
|
83
87
|
" [class*='_sessionRow'] [class*='_time'] {",
|
|
84
88
|
" display: none !important;",
|
|
85
89
|
" }",
|
|
86
|
-
"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
90
|
+
"}",
|
|
91
|
+
].join("\n");
|
|
92
|
+
|
|
93
|
+
/* Native-input takeover styles. These ride OUTSIDE the media query on
|
|
94
|
+
purpose: the JS half decides when the takeover is live (narrow viewport
|
|
95
|
+
by default, or an explicit override) and marks the card with
|
|
96
|
+
data-mobile-input-active, so a forced takeover also works on a desktop
|
|
97
|
+
viewport. Every rule below is inert until that marker is present. */
|
|
98
|
+
var INPUT_CSS = [
|
|
99
|
+
"/* ── dsh-mobile-flow: native textarea takeover ── */",
|
|
100
|
+
"/* The stock draft surface keeps its box (it is what sizes the card) but",
|
|
101
|
+
" stops painting: the native textarea draws the draft instead. */",
|
|
102
|
+
"[data-composer-card][data-mobile-input-active] [data-input-scroll] > div > * {",
|
|
103
|
+
" visibility: hidden !important;",
|
|
104
|
+
" pointer-events: none !important;",
|
|
105
|
+
"}",
|
|
106
|
+
"/* Our seat: geometry published by the takeover (left/top/width/height",
|
|
107
|
+
" copied from the stock scrollport), never by this sheet.",
|
|
108
|
+
" z-index is load-bearing: the stock row lives in .grow, which is",
|
|
109
|
+
" position:relative and comes LATER in DOM order, so at equal stacking",
|
|
110
|
+
" level it paints — and hit-tests — above this seat; taps in the middle",
|
|
111
|
+
" of the input box then never reach the textarea (measured 2026-09-11).",
|
|
112
|
+
" The opaque background keeps the (hidden) stock surface from bleeding",
|
|
113
|
+
" through on engines that treat visibility:hidden differently. */",
|
|
114
|
+
"[data-mobile-input-wrap] {",
|
|
115
|
+
" position: absolute;",
|
|
116
|
+
" z-index: 5;",
|
|
117
|
+
" pointer-events: auto;",
|
|
118
|
+
" border-radius: 22px;",
|
|
119
|
+
" background: var(--dsw-specific-input-major);",
|
|
120
|
+
"}",
|
|
121
|
+
"/* Mirrors .input's metrics exactly (same paddings; font and line-height",
|
|
122
|
+
" inherited from the card) so the takeover is visually the stock bar. */",
|
|
123
|
+
"[data-mobile-input] {",
|
|
124
|
+
" display: block;",
|
|
125
|
+
" box-sizing: border-box;",
|
|
126
|
+
" width: 100%;",
|
|
127
|
+
" height: 100%;",
|
|
128
|
+
" margin: 0;",
|
|
129
|
+
" padding: 4px 8px 0 14px;",
|
|
130
|
+
" font-family: inherit;",
|
|
131
|
+
" font-size: inherit;",
|
|
132
|
+
" line-height: inherit;",
|
|
133
|
+
" color: var(--dsw-alias-label-primary);",
|
|
134
|
+
" caret-color: var(--dsw-alias-state-business-primary);",
|
|
135
|
+
" background: transparent;",
|
|
136
|
+
" border: 0;",
|
|
137
|
+
" outline: none;",
|
|
138
|
+
" resize: none;",
|
|
139
|
+
" overflow-y: auto;",
|
|
140
|
+
" white-space: pre-wrap;",
|
|
141
|
+
" overflow-wrap: anywhere;",
|
|
142
|
+
" word-break: break-word;",
|
|
143
|
+
" pointer-events: auto;",
|
|
144
|
+
" /* ArkWeb/WebKit-class engines have shipped keyboard-opens-but-no-input",
|
|
145
|
+
" bugs when an inherited user-select:none lands on the field. */",
|
|
146
|
+
" -webkit-user-select: text;",
|
|
147
|
+
" user-select: text;",
|
|
148
|
+
" touch-action: manipulation;",
|
|
149
|
+
" -webkit-touch-callout: default;",
|
|
150
|
+
"}",
|
|
151
|
+
"[data-mobile-input]::placeholder {",
|
|
152
|
+
" color: var(--dsw-alias-label-caption);",
|
|
153
|
+
" opacity: 1;",
|
|
154
|
+
"}",
|
|
155
|
+
"[data-mobile-input][readonly] {",
|
|
156
|
+
" color: var(--dsw-alias-label-tertiary);",
|
|
157
|
+
" cursor: not-allowed;",
|
|
158
|
+
"}",
|
|
159
|
+
"/* Escape hatch in the composer tool row (narrow viewports only): one tap",
|
|
160
|
+
" returns the stock input box when the native one misbehaves on a device",
|
|
161
|
+
" we cannot test on. Its neighbour switches the diagnostics surfaces on",
|
|
162
|
+
" and off (a tokenized URL is rewritten before plugins load, so a phone",
|
|
163
|
+
" cannot always pass ?dsh-mobile-input=... from the address bar). */",
|
|
164
|
+
"[data-mobile-input-toggle],",
|
|
165
|
+
"[data-mobile-input-copy],",
|
|
166
|
+
"[data-mobile-input-diagnostics] {",
|
|
167
|
+
" display: inline-flex;",
|
|
168
|
+
" align-items: center;",
|
|
169
|
+
" gap: 4px;",
|
|
170
|
+
" height: 24px;",
|
|
171
|
+
" padding: 0 8px;",
|
|
172
|
+
" border: 1px solid var(--dsw-alias-border-l2);",
|
|
173
|
+
" border-radius: 999px;",
|
|
174
|
+
" background: transparent;",
|
|
175
|
+
" color: var(--dsw-alias-label-secondary);",
|
|
176
|
+
" font-size: 11px;",
|
|
177
|
+
" line-height: 1;",
|
|
178
|
+
" white-space: nowrap;",
|
|
179
|
+
"}",
|
|
180
|
+
"[data-mobile-input-toggle][data-state='on'],",
|
|
181
|
+
"[data-mobile-input-diagnostics][data-state='on'] {",
|
|
182
|
+
" border-color: var(--dsw-alias-state-business-primary);",
|
|
183
|
+
" color: var(--dsw-alias-state-business-primary);",
|
|
184
|
+
"}",
|
|
185
|
+
"/* Debug panel (?dsh-mobile-input=debug): the only way to read a phone's",
|
|
186
|
+
" input events without a console. */",
|
|
187
|
+
"[data-mobile-input-debug] {",
|
|
188
|
+
" position: fixed;",
|
|
189
|
+
" top: 0;",
|
|
190
|
+
" left: 0;",
|
|
191
|
+
" right: 0;",
|
|
192
|
+
" z-index: 2147483000;",
|
|
193
|
+
" max-height: 40vh;",
|
|
194
|
+
" overflow: auto;",
|
|
195
|
+
" margin: 0;",
|
|
196
|
+
" padding: 6px 8px;",
|
|
197
|
+
" font: 11px/1.4 ui-monospace, monospace;",
|
|
198
|
+
" white-space: pre-wrap;",
|
|
199
|
+
" color: #0f0;",
|
|
200
|
+
" background: rgba(0, 0, 0, 0.82);",
|
|
201
|
+
"}",
|
|
202
|
+
"[data-mobile-input-debug-body] {",
|
|
203
|
+
" margin: 0;",
|
|
204
|
+
" white-space: pre-wrap;",
|
|
205
|
+
"}",
|
|
206
|
+
"[data-mobile-input-debug-button],",
|
|
207
|
+
"[data-mobile-input-bench] button {",
|
|
208
|
+
" padding: 3px 8px;",
|
|
209
|
+
" border: 1px solid currentColor;",
|
|
210
|
+
" border-radius: 999px;",
|
|
211
|
+
" background: transparent;",
|
|
212
|
+
" color: inherit;",
|
|
213
|
+
" font: 11px/1.4 ui-monospace, monospace;",
|
|
214
|
+
"}",
|
|
215
|
+
"/* Device test bench (?dsh-mobile-input=bench): ONE fixed panel anchored to",
|
|
216
|
+
" the TOP, so the composer at the bottom of the screen stays visible and",
|
|
217
|
+
" the field under test is the REAL one. The log lives inside that panel and",
|
|
218
|
+
" is painted only when no field has focus (the diary defers it), so a log",
|
|
219
|
+
" repaint cannot reflow anything under a live IME. */",
|
|
220
|
+
"[data-mobile-input-bench] {",
|
|
221
|
+
" position: fixed;",
|
|
222
|
+
" top: 0;",
|
|
223
|
+
" left: 0;",
|
|
224
|
+
" right: 0;",
|
|
225
|
+
" z-index: 2147483000;",
|
|
226
|
+
" max-height: 42vh;",
|
|
227
|
+
" overflow: auto;",
|
|
228
|
+
" padding: 6px 8px;",
|
|
229
|
+
" font: 12px/1.4 ui-monospace, monospace;",
|
|
230
|
+
" color: var(--dsw-alias-label-primary, #111);",
|
|
231
|
+
" background: var(--dsw-alias-bg-base, #fff);",
|
|
232
|
+
" border-bottom: 1px solid var(--dsw-alias-border-l2, #ccc);",
|
|
233
|
+
"}",
|
|
234
|
+
"[data-mobile-input-log] {",
|
|
235
|
+
" max-height: 22vh;",
|
|
236
|
+
" overflow: auto;",
|
|
237
|
+
" margin: 6px 0 0;",
|
|
238
|
+
" padding: 6px 8px;",
|
|
239
|
+
" white-space: pre-wrap;",
|
|
240
|
+
" color: #0f0;",
|
|
241
|
+
" background: rgba(0, 0, 0, 0.86);",
|
|
242
|
+
" border-radius: 6px;",
|
|
243
|
+
"}",
|
|
244
|
+
"[data-mobile-input-bench] label {",
|
|
245
|
+
" display: block;",
|
|
246
|
+
" margin-top: 6px;",
|
|
247
|
+
" font-size: 11px;",
|
|
248
|
+
" opacity: 0.75;",
|
|
249
|
+
"}",
|
|
250
|
+
"[data-mobile-input-bench-field] {",
|
|
251
|
+
" display: block;",
|
|
252
|
+
" box-sizing: border-box;",
|
|
253
|
+
" width: 100%;",
|
|
254
|
+
" height: 36px;",
|
|
255
|
+
" margin: 0;",
|
|
256
|
+
" padding: 6px 8px;",
|
|
257
|
+
" border: 1px solid var(--dsw-alias-border-l2, #ccc);",
|
|
258
|
+
" border-radius: 6px;",
|
|
259
|
+
" background: var(--dsw-alias-bg-base, #fff);",
|
|
260
|
+
" color: var(--dsw-alias-label-primary, #111);",
|
|
261
|
+
" font-family: inherit;",
|
|
262
|
+
" font-size: 13px;",
|
|
263
|
+
" line-height: 20px;",
|
|
264
|
+
" resize: none;",
|
|
98
265
|
"}",
|
|
99
266
|
].join("\n");
|
|
100
267
|
|
|
101
268
|
function apply(ctx) {
|
|
102
269
|
var tag = document.createElement("style");
|
|
103
270
|
tag.dataset.plugin = "dsh-mobile-flow";
|
|
104
|
-
tag.textContent = CSS;
|
|
271
|
+
tag.textContent = CSS + "\n" + INPUT_CSS;
|
|
105
272
|
document.head.append(tag);
|
|
106
273
|
|
|
107
274
|
ctx.effect(function () {
|
|
@@ -113,6 +280,60 @@ window.__ModuleLoader__.load({
|
|
|
113
280
|
ctx.effect(function () {
|
|
114
281
|
return installNoAutoFocus();
|
|
115
282
|
});
|
|
283
|
+
|
|
284
|
+
/* Diagnostics are opt-in per page load (they cost a ring buffer and a
|
|
285
|
+
couple of listeners, and nothing else). Both instrument the real
|
|
286
|
+
composer field, so a device report can always be read against the
|
|
287
|
+
production code path. */
|
|
288
|
+
var diagnostics = debugRequested() || benchRequested() ? createDiary() : null;
|
|
289
|
+
if (diagnostics !== null) {
|
|
290
|
+
try {
|
|
291
|
+
diagnostics.add("start ua=" + String(navigator.userAgent).slice(0, 96));
|
|
292
|
+
diagnostics.add("h=" + window.innerHeight
|
|
293
|
+
+ " growth=" + readGrowthMode()
|
|
294
|
+
+ " input=" + String(readOverride())
|
|
295
|
+
+ " narrow=" + String(window.matchMedia !== undefined && window.matchMedia(NARROW_QUERY).matches));
|
|
296
|
+
ctx.effect(function () {
|
|
297
|
+
return watchKeyboard(diagnostics);
|
|
298
|
+
});
|
|
299
|
+
if (benchRequested()) {
|
|
300
|
+
/* Guarded: diagnostics are a debugging aid, and a broken aid must
|
|
301
|
+
never take the takeover (or the plugin) down with it. */
|
|
302
|
+
ctx.effect(function () {
|
|
303
|
+
try {
|
|
304
|
+
return installBench(diagnostics);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
diagnostics.add("bench failed: " + String(error && error.message), true);
|
|
307
|
+
return function () {};
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
} catch (error) { /* diagnostics are optional; the takeover is not */ }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/* The native-input takeover occupies the `conversation.input.overlay`
|
|
315
|
+
seat: a session-scope list slot rendered inside the resident composer
|
|
316
|
+
card, whose standard kit carries `useInput` and `inputActions`. That is
|
|
317
|
+
exactly what the takeover needs — the official input machine, never
|
|
318
|
+
the Lexical DOM. Resolved defensively: a shell without the seat (or
|
|
319
|
+
without the slot service) keeps the stock composer. */
|
|
320
|
+
var slots = typeof ctx.get === "function" ? ctx.get("slots") : undefined;
|
|
321
|
+
if (slots === undefined) slots = ctx.slots;
|
|
322
|
+
if (slots === undefined || typeof slots.inject !== "function") return;
|
|
323
|
+
slots.inject("conversation.input.overlay", () => slots.register({
|
|
324
|
+
name: "conversation.input.overlay",
|
|
325
|
+
id: "native-input",
|
|
326
|
+
order: 40,
|
|
327
|
+
}, createNativeInput(ctx, diagnostics)));
|
|
328
|
+
|
|
329
|
+
/* Escape hatch: a compact toggle in the composer tool row (narrow
|
|
330
|
+
viewports only) that returns the stock input box for one device
|
|
331
|
+
without touching a console. */
|
|
332
|
+
slots.inject("conversation.input.left", () => slots.register({
|
|
333
|
+
name: "conversation.input.left",
|
|
334
|
+
id: "native-input-toggle",
|
|
335
|
+
order: 60,
|
|
336
|
+
}, createNativeInputToggle(diagnostics)));
|
|
116
337
|
}
|
|
117
338
|
|
|
118
339
|
/* 5) No auto-focus on session switch (narrow viewports): InputBar's
|
|
@@ -165,7 +386,1574 @@ window.__ModuleLoader__.load({
|
|
|
165
386
|
};
|
|
166
387
|
}
|
|
167
388
|
|
|
389
|
+
/* ─────────────────── native input takeover ───────────────────
|
|
390
|
+
|
|
391
|
+
ArkWeb lesson #1 (HarmonyOS 7, 2026-09-11): mirroring on EVERY keystroke
|
|
392
|
+
made the on-screen keyboard close after each character. Each mirror
|
|
393
|
+
republished the machine draft, which re-rendered the composer card and
|
|
394
|
+
made Lexical rewrite the (hidden) stock editor's DOM; a strict engine
|
|
395
|
+
drops the IME when the editing surface churns under its feet. So the
|
|
396
|
+
field now owns its text while the user types: the machine is updated at
|
|
397
|
+
COMMIT POINTS only (Enter, blur, any toolbar tap, page hide, unmount,
|
|
398
|
+
and immediately when a `/` or `@` trigger character is typed, because
|
|
399
|
+
the trigger menu needs the machine).
|
|
400
|
+
|
|
401
|
+
ArkWeb lesson #2 (2026-09-14): pulling the mirror off the typing path
|
|
402
|
+
was not enough — the keyboard still closed after each character. The
|
|
403
|
+
remaining per-keystroke work was the field's own geometry: `autosize`
|
|
404
|
+
probed `style.height = 0px` (read scrollHeight, write the height back)
|
|
405
|
+
and the seat re-aligned itself whenever the card's box moved, so the
|
|
406
|
+
focused editable collapsed to zero height and changed size on every key.
|
|
407
|
+
The rule is therefore absolute: WHILE THE FIELD HAS FOCUS THE PLUGIN
|
|
408
|
+
PERFORMS ZERO DOM WRITES. Resizing, re-alignment and chrome syncing (a
|
|
409
|
+
placeholder or read-only flip) all happen at commit points, when focus
|
|
410
|
+
has already left — the field keeps one fixed height (the product's own
|
|
411
|
+
floor, 36px docked / 52px hero) and scrolls internally while typing.
|
|
412
|
+
|
|
413
|
+
Why: the stock composer's text surface is a Lexical contenteditable.
|
|
414
|
+
Android IMEs — voice keyboards above all — drive composition through
|
|
415
|
+
Chrome's InputConnection -> beforeinput("insertCompositionText")
|
|
416
|
+
recomposition flow. A framework editor answers those events by
|
|
417
|
+
reconciling the DOM from its own state, so the composing text (and on
|
|
418
|
+
recomposition already-committed text) gets wiped. A native <textarea>
|
|
419
|
+
is edited by the platform itself and never enters that fight — which is
|
|
420
|
+
why the same IME behaves in ordinary textarea comment boxes.
|
|
421
|
+
|
|
422
|
+
What: while the machine is plain, the draft surface becomes a native
|
|
423
|
+
textarea laid over the stock scrollport. Keystrokes are mirrored into
|
|
424
|
+
the official input machine through the public session input face at
|
|
425
|
+
commit points, so the send button, slash adjudication, attachments,
|
|
426
|
+
busy-Enter policy and draft persistence all keep working unchanged. The
|
|
427
|
+
Lexical editor keeps its layout box (hidden, never removed) because that
|
|
428
|
+
box is what sizes the card for the mirrored draft.
|
|
429
|
+
|
|
430
|
+
Scope: the takeover owns the surface ONLY while the machine is plain.
|
|
431
|
+
A claim (a `/` command picked from the menu, adjudicating, submitting)
|
|
432
|
+
hands the surface back to the stock editor, whose token/chip/decoration
|
|
433
|
+
state a plain-text mirror cannot reproduce. */
|
|
434
|
+
|
|
435
|
+
/** Override key: "on" | "force" | "off" in localStorage (persistent) or
|
|
436
|
+
the `dsh-mobile-input` query parameter for one page load. */
|
|
437
|
+
var OVERRIDE_KEY = "dsh-mobile-flow:input";
|
|
438
|
+
var NARROW_QUERY = "(max-width: 720px)";
|
|
439
|
+
|
|
440
|
+
function readOverride() {
|
|
441
|
+
try {
|
|
442
|
+
var stored = window.localStorage.getItem(OVERRIDE_KEY);
|
|
443
|
+
if (stored === "on" || stored === "force" || stored === "off") return stored;
|
|
444
|
+
} catch (error) { /* storage unavailable: fall through to the query */ }
|
|
445
|
+
try {
|
|
446
|
+
var param = new window.URLSearchParams(window.location.search).get("dsh-mobile-input");
|
|
447
|
+
if (param === "1" || param === "on" || param === "force") return "force";
|
|
448
|
+
if (param === "0" || param === "off") return "off";
|
|
449
|
+
} catch (error) { /* no URLSearchParams: the media query decides */ }
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Raised on the window when the persisted preference changes. */
|
|
454
|
+
var PREFERENCE_EVENT = "dsh-mobile-flow:preference";
|
|
455
|
+
|
|
456
|
+
/** Persist the takeover preference; every mounted part re-reads it. */
|
|
457
|
+
function writePreference(value) {
|
|
458
|
+
try {
|
|
459
|
+
if (value === null) window.localStorage.removeItem(OVERRIDE_KEY);
|
|
460
|
+
else window.localStorage.setItem(OVERRIDE_KEY, value);
|
|
461
|
+
} catch (error) { /* storage unavailable: the in-memory event still applies */ }
|
|
462
|
+
try {
|
|
463
|
+
window.dispatchEvent(new window.Event(PREFERENCE_EVENT));
|
|
464
|
+
} catch (error) { /* no Event constructor: the next mount re-reads anyway */ }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Requested diagnostics surfaces, from `?dsh-mobile-input=debug[,bench]`. */
|
|
468
|
+
function requestedModes() {
|
|
469
|
+
try {
|
|
470
|
+
var raw = new window.URLSearchParams(window.location.search).get("dsh-mobile-input");
|
|
471
|
+
return raw === null ? [] : raw.split(",");
|
|
472
|
+
} catch (error) {
|
|
473
|
+
return [];
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Which diagnostics surface this page load should install.
|
|
479
|
+
*
|
|
480
|
+
* The URL comes first, but it is NOT reliable on the real shell: a load
|
|
481
|
+
* with `?token=` is rewritten (the shell consumes the token and replaces
|
|
482
|
+
* the whole query) before plugins apply, so a phone that bookmarks a
|
|
483
|
+
* tokenized URL can never pass a parameter. The tool-row chip therefore
|
|
484
|
+
* persists the request in localStorage, which survives that rewrite.
|
|
485
|
+
*/
|
|
486
|
+
var DIAGNOSTICS_KEY = "dsh-mobile-flow:diagnostics";
|
|
487
|
+
|
|
488
|
+
function diagnosticsModes() {
|
|
489
|
+
var requested = requestedModes();
|
|
490
|
+
if (requested.length !== 0) return requested;
|
|
491
|
+
try {
|
|
492
|
+
var stored = window.localStorage.getItem(DIAGNOSTICS_KEY);
|
|
493
|
+
if (stored === "bench") return ["bench"];
|
|
494
|
+
if (stored === "debug") return ["debug"];
|
|
495
|
+
if (stored === "both") return ["debug", "bench"];
|
|
496
|
+
} catch (error) { /* storage unavailable: no persisted diagnostics */ }
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Persist the diagnostics request: "bench" | "debug" | "both" | "off". */
|
|
501
|
+
function writeDiagnostics(mode) {
|
|
502
|
+
try {
|
|
503
|
+
if (mode === "off") window.localStorage.removeItem(DIAGNOSTICS_KEY);
|
|
504
|
+
else window.localStorage.setItem(DIAGNOSTICS_KEY, mode);
|
|
505
|
+
} catch (error) { /* storage unavailable: the reload cannot honour it */ }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Whether the debug panel is requested (`?dsh-mobile-input=debug`). */
|
|
509
|
+
function debugRequested() {
|
|
510
|
+
return diagnosticsModes().indexOf("debug") !== -1;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Whether the on-device test bench is requested (`?dsh-mobile-input=bench`). */
|
|
514
|
+
function benchRequested() {
|
|
515
|
+
return diagnosticsModes().indexOf("bench") !== -1;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* How tall the takeover field may become, and WHEN it may change size.
|
|
520
|
+
*
|
|
521
|
+
* `commit` (default) — the field only resizes while it does NOT have focus:
|
|
522
|
+
* the strict-engine-safe path (see the section comment).
|
|
523
|
+
* `live` — resize on every input event, i.e. the pre-0.6 behaviour, kept as
|
|
524
|
+
* the A/B control for a device that still misbehaves.
|
|
525
|
+
* `none` — never resize: the field keeps the product's own floor height.
|
|
526
|
+
*/
|
|
527
|
+
var GROWTH_KEY = "dsh-mobile-flow:growth";
|
|
528
|
+
var GROWTH_EVENT = "dsh-mobile-flow:growth-change";
|
|
529
|
+
var GROWTH_MODES = ["commit", "live", "none"];
|
|
530
|
+
|
|
531
|
+
function readGrowthMode() {
|
|
532
|
+
try {
|
|
533
|
+
var stored = window.localStorage.getItem(GROWTH_KEY);
|
|
534
|
+
if (GROWTH_MODES.indexOf(stored) !== -1) return stored;
|
|
535
|
+
} catch (error) { /* storage unavailable: fall through to the query */ }
|
|
536
|
+
try {
|
|
537
|
+
var param = new window.URLSearchParams(window.location.search).get("dsh-mobile-growth");
|
|
538
|
+
if (GROWTH_MODES.indexOf(param) !== -1) return param;
|
|
539
|
+
} catch (error) { /* no URLSearchParams: the default decides */ }
|
|
540
|
+
return "commit";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function writeGrowthMode(value) {
|
|
544
|
+
try { window.localStorage.setItem(GROWTH_KEY, value); } catch (error) { /* ignore */ }
|
|
545
|
+
try { window.dispatchEvent(new window.Event(GROWTH_EVENT)); } catch (error) { /* ignore */ }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* How the `/` and `@` trigger characters reach the machine.
|
|
550
|
+
*
|
|
551
|
+
* `changes` (default) — mirror when a trigger character is ADDED or REMOVED,
|
|
552
|
+
* so the command menu opens (and closes) at the trigger, but the characters
|
|
553
|
+
* typed after it stay in the field. This matters more than it looks: a
|
|
554
|
+
* per-keystroke mirror republishes the machine draft, which re-renders the
|
|
555
|
+
* composer card, rewrites the hidden Lexical editor's DOM and re-renders the
|
|
556
|
+
* command menu — every keystroke. On ArkWeb (HarmonyOS 7, measured
|
|
557
|
+
* 2026-09-14) that is exactly the "keyboard closes after each character"
|
|
558
|
+
* report, and it only shows up in slash-prefixed drafts, because any text
|
|
559
|
+
* containing `/` used to be mirrored on every keystroke.
|
|
560
|
+
* `resync` (default, 2026-09-14) — the mirror itself is what makes ArkWeb
|
|
561
|
+
* drop the keyboard: republishing the draft makes the app rewrite the
|
|
562
|
+
* (hidden, inert) stock editor's DOM asynchronously, i.e. NOT inside the
|
|
563
|
+
* keystroke's gesture, and the engine answers that by closing the IME. The
|
|
564
|
+
* stock path never does that (its own editing IS the draft change), which is
|
|
565
|
+
* why typing `/` in the stock editor keeps the keyboard (measured on the
|
|
566
|
+
* device). So: drop focus, land the write, take focus back — all inside the
|
|
567
|
+
* same task as the keystroke, so the churn lands with no IME attached and
|
|
568
|
+
* the re-focus is still part of a user gesture.
|
|
569
|
+
* `changes` — v0.6.1 behaviour (plain in-task mirror), kept as the control.
|
|
570
|
+
* `live` — the pre-0.6 behaviour (mirror every keystroke).
|
|
571
|
+
* `off` — never mirror a trigger mid-typing (commit points only).
|
|
572
|
+
*/
|
|
573
|
+
var TRIGGER_KEY = "dsh-mobile-flow:trigger";
|
|
574
|
+
var TRIGGER_EVENT = "dsh-mobile-flow:trigger-change";
|
|
575
|
+
var TRIGGER_MODES = ["resync", "changes", "live", "off"];
|
|
576
|
+
|
|
577
|
+
function readTriggerMode() {
|
|
578
|
+
try {
|
|
579
|
+
var stored = window.localStorage.getItem(TRIGGER_KEY);
|
|
580
|
+
if (TRIGGER_MODES.indexOf(stored) !== -1) return stored;
|
|
581
|
+
} catch (error) { /* storage unavailable: fall through to the query */ }
|
|
582
|
+
try {
|
|
583
|
+
var param = new window.URLSearchParams(window.location.search).get("dsh-mobile-trigger");
|
|
584
|
+
if (TRIGGER_MODES.indexOf(param) !== -1) return param;
|
|
585
|
+
} catch (error) { /* no URLSearchParams: the default decides */ }
|
|
586
|
+
return "resync";
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function writeTriggerMode(value) {
|
|
590
|
+
try { window.localStorage.setItem(TRIGGER_KEY, value); } catch (error) { /* ignore */ }
|
|
591
|
+
try { window.dispatchEvent(new window.Event(TRIGGER_EVENT)); } catch (error) { /* ignore */ }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Whether the diagnostics CONTROLS are un-hidden in the tool row. They are
|
|
596
|
+
* hidden by default (the everyday composer keeps one chip: the escape
|
|
597
|
+
* hatch); a long press on that chip calls them out, and the choice sticks
|
|
598
|
+
* so a device that needed them keeps them without hunting for the gesture.
|
|
599
|
+
*/
|
|
600
|
+
var REVEAL_KEY = "dsh-mobile-flow:reveal";
|
|
601
|
+
var REVEAL_EVENT = "dsh-mobile-flow:reveal-change";
|
|
602
|
+
|
|
603
|
+
function readReveal() {
|
|
604
|
+
try {
|
|
605
|
+
return window.localStorage.getItem(REVEAL_KEY) === "1";
|
|
606
|
+
} catch (error) {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function writeReveal(value) {
|
|
612
|
+
try {
|
|
613
|
+
if (value === true) window.localStorage.setItem(REVEAL_KEY, "1");
|
|
614
|
+
else window.localStorage.removeItem(REVEAL_KEY);
|
|
615
|
+
} catch (error) { /* storage unavailable: the event below still applies */ }
|
|
616
|
+
try { window.dispatchEvent(new window.Event(REVEAL_EVENT)); } catch (error) { /* ignore */ }
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Whether the focused element is a text field (the diary's hold rule). */
|
|
620
|
+
function typingSomewhere() {
|
|
621
|
+
var el = document.activeElement;
|
|
622
|
+
if (el === null || el === undefined) return false;
|
|
623
|
+
if (el.tagName === "TEXTAREA" || el.tagName === "INPUT") return true;
|
|
624
|
+
return el.isContentEditable === true;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Page-local diagnostics. A phone has no console and Chromium cannot
|
|
629
|
+
* reproduce ArkWeb's IME, so the plugin keeps its own event ring buffer and
|
|
630
|
+
* paints it into panels ON DEMAND — never on the typing path: a panel write
|
|
631
|
+
* is DOM churn itself, and an instrument that perturbs what it measures is
|
|
632
|
+
* worse than no instrument (2026-09-14 lesson: the first debug panel wrote
|
|
633
|
+
* on every event and could have caused the very keyboard drop it reported).
|
|
634
|
+
*
|
|
635
|
+
* Lines carry a millisecond offset from the first line so a keyboard
|
|
636
|
+
* transition can be read against the events that preceded it.
|
|
637
|
+
*/
|
|
638
|
+
function createDiary() {
|
|
639
|
+
var lines = [];
|
|
640
|
+
var panels = [];
|
|
641
|
+
var base = 0;
|
|
642
|
+
var paint = function (force) {
|
|
643
|
+
if (panels.length === 0) return;
|
|
644
|
+
/* While a field has focus the paint is deferred: the caller either
|
|
645
|
+
forces it (a keyboard transition, a blur) or the next line paints. */
|
|
646
|
+
if (force !== true && typingSomewhere()) return;
|
|
647
|
+
var tail = lines.slice(-14).join("\n");
|
|
648
|
+
for (var i = 0; i < panels.length; i += 1) panels[i].textContent = tail;
|
|
649
|
+
};
|
|
650
|
+
return {
|
|
651
|
+
add: function (line, force) {
|
|
652
|
+
if (base === 0) base = Date.now();
|
|
653
|
+
lines.push("+" + (Date.now() - base) + "ms " + line);
|
|
654
|
+
if (lines.length > 400) lines.shift();
|
|
655
|
+
paint(force === true);
|
|
656
|
+
},
|
|
657
|
+
paint: function (force) { paint(force === true); },
|
|
658
|
+
text: function () { return lines.join("\n"); },
|
|
659
|
+
watch: function (el) { panels.push(el); paint(true); },
|
|
660
|
+
unwatch: function (el) {
|
|
661
|
+
panels = panels.filter(function (panel) { return panel !== el; });
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Keyboard visibility, as a timestamped fact. ArkWeb reports the on-screen
|
|
668
|
+
* keyboard through a window/viewport height change, so every transition is
|
|
669
|
+
* recorded next to the input events — that is what turns "the keyboard
|
|
670
|
+
* closed after one character" into something readable without a console.
|
|
671
|
+
*/
|
|
672
|
+
function watchKeyboard(diary) {
|
|
673
|
+
var last = window.innerHeight;
|
|
674
|
+
var onResize = function () {
|
|
675
|
+
var height = window.innerHeight;
|
|
676
|
+
if (height === last) return;
|
|
677
|
+
var viewport = window.visualViewport;
|
|
678
|
+
var visual = viewport !== undefined && viewport !== null
|
|
679
|
+
? Math.round(viewport.height)
|
|
680
|
+
: null;
|
|
681
|
+
diary.add("KEYBOARD " + last + "->" + height + "px"
|
|
682
|
+
+ (visual === null ? "" : " vv=" + visual), true);
|
|
683
|
+
last = height;
|
|
684
|
+
};
|
|
685
|
+
window.addEventListener("resize", onResize);
|
|
686
|
+
var viewport = window.visualViewport;
|
|
687
|
+
if (viewport !== undefined && viewport !== null && typeof viewport.addEventListener === "function") {
|
|
688
|
+
viewport.addEventListener("resize", onResize);
|
|
689
|
+
}
|
|
690
|
+
return function () {
|
|
691
|
+
window.removeEventListener("resize", onResize);
|
|
692
|
+
if (viewport !== undefined && viewport !== null && typeof viewport.removeEventListener === "function") {
|
|
693
|
+
viewport.removeEventListener("resize", onResize);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Put the diary on the clipboard (a phone cannot attach a log file). */
|
|
699
|
+
function copyDiary(diary, button) {
|
|
700
|
+
var text = diary.text();
|
|
701
|
+
var done = function (ok) {
|
|
702
|
+
button.textContent = ok ? "已复制" : "复制失败";
|
|
703
|
+
window.setTimeout(function () { button.textContent = "复制日志"; }, 2500);
|
|
704
|
+
};
|
|
705
|
+
try {
|
|
706
|
+
var clipboard = navigator.clipboard;
|
|
707
|
+
if (clipboard !== undefined && clipboard !== null && typeof clipboard.writeText === "function") {
|
|
708
|
+
clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
} catch (error) { /* fall through to the legacy path */ }
|
|
712
|
+
try {
|
|
713
|
+
var scratch = document.createElement("textarea");
|
|
714
|
+
scratch.value = text;
|
|
715
|
+
scratch.style.cssText = "position:fixed;top:-1000px;left:0";
|
|
716
|
+
document.body.append(scratch);
|
|
717
|
+
scratch.select();
|
|
718
|
+
var ok = typeof document.execCommand === "function" && document.execCommand("copy");
|
|
719
|
+
scratch.remove();
|
|
720
|
+
done(ok === true);
|
|
721
|
+
} catch (error) {
|
|
722
|
+
done(false);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Whether the takeover should own the surface on this viewport. */
|
|
727
|
+
function takeoverLive(media) {
|
|
728
|
+
var override = readOverride();
|
|
729
|
+
if (override === "on" || override === "force") return true;
|
|
730
|
+
if (override === "off") return false;
|
|
731
|
+
return media !== null && media.matches === true;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Narrow viewport only (no preference): the toggle's own visibility gate. */
|
|
735
|
+
function useNarrowViewport() {
|
|
736
|
+
var media = React.useMemo(function () {
|
|
737
|
+
try {
|
|
738
|
+
return window.matchMedia(NARROW_QUERY);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
}, []);
|
|
743
|
+
var state = React.useState(function () { return media !== null && media.matches === true; });
|
|
744
|
+
var narrow = state[0];
|
|
745
|
+
var setNarrow = state[1];
|
|
746
|
+
React.useEffect(function () {
|
|
747
|
+
if (media === null) return undefined;
|
|
748
|
+
var update = function () { setNarrow(media.matches === true); };
|
|
749
|
+
update();
|
|
750
|
+
if (typeof media.addEventListener === "function") media.addEventListener("change", update);
|
|
751
|
+
else if (typeof media.addListener === "function") media.addListener(update);
|
|
752
|
+
return function () {
|
|
753
|
+
if (typeof media.removeEventListener === "function") media.removeEventListener("change", update);
|
|
754
|
+
else if (typeof media.removeListener === "function") media.removeListener(update);
|
|
755
|
+
};
|
|
756
|
+
}, [media]);
|
|
757
|
+
return narrow;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Narrow-viewport flag, live across rotation, resize and override edits. */
|
|
761
|
+
function useTakeoverViewport() {
|
|
762
|
+
var media = React.useMemo(function () {
|
|
763
|
+
try {
|
|
764
|
+
return window.matchMedia(NARROW_QUERY);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
}, []);
|
|
769
|
+
var state = React.useState(function () { return takeoverLive(media); });
|
|
770
|
+
var live = state[0];
|
|
771
|
+
var setLive = state[1];
|
|
772
|
+
React.useEffect(function () {
|
|
773
|
+
if (media === null) return undefined;
|
|
774
|
+
var update = function () { setLive(takeoverLive(media)); };
|
|
775
|
+
update();
|
|
776
|
+
if (typeof media.addEventListener === "function") media.addEventListener("change", update);
|
|
777
|
+
else if (typeof media.addListener === "function") media.addListener(update);
|
|
778
|
+
var onStorage = function () { update(); };
|
|
779
|
+
window.addEventListener(PREFERENCE_EVENT, onStorage);
|
|
780
|
+
window.addEventListener("storage", onStorage);
|
|
781
|
+
return function () {
|
|
782
|
+
if (typeof media.removeEventListener === "function") media.removeEventListener("change", update);
|
|
783
|
+
else if (typeof media.removeListener === "function") media.removeListener(update);
|
|
784
|
+
window.removeEventListener(PREFERENCE_EVENT, onStorage);
|
|
785
|
+
window.removeEventListener("storage", onStorage);
|
|
786
|
+
};
|
|
787
|
+
}, [media]);
|
|
788
|
+
return live;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Resolve the session's public input face — the same machine the composer
|
|
793
|
+
* bar drives. Its `state` store is what lets the mirror read the LIVE
|
|
794
|
+
* draft (a render-time value could be stale and clobber fast typing).
|
|
795
|
+
* `sessions.scope(id).get('conversation')` is the documented third-party
|
|
796
|
+
* route (dsh-genui inserts composer templates through it).
|
|
797
|
+
*/
|
|
798
|
+
function resolveInputFace(ctx, sessionId) {
|
|
799
|
+
if (sessionId === undefined || sessionId === null) return undefined;
|
|
800
|
+
try {
|
|
801
|
+
/* ctx.get, never ctx.sessions: a Cordis context throws on an
|
|
802
|
+
undeclared service property, and this plugin only injects `slots`. */
|
|
803
|
+
var sessions = typeof ctx.get === "function" ? ctx.get("sessions") : undefined;
|
|
804
|
+
if (sessions === undefined || sessions === null || typeof sessions.scope !== "function") return undefined;
|
|
805
|
+
var scoped = sessions.scope(sessionId);
|
|
806
|
+
if (scoped === undefined || scoped === null) return undefined;
|
|
807
|
+
var conversation = scoped.get("conversation");
|
|
808
|
+
if (conversation === undefined || conversation === null) return undefined;
|
|
809
|
+
var resolver = conversation.input;
|
|
810
|
+
if (resolver === undefined || typeof resolver.for !== "function") return undefined;
|
|
811
|
+
var face = resolver.for(scoped);
|
|
812
|
+
return face === undefined || face === null ? undefined : face;
|
|
813
|
+
} catch (error) {
|
|
814
|
+
return undefined;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Bind the takeover component to the client context. The component runs in
|
|
820
|
+
* the browser plugin's fiber, so the session input face is resolved through
|
|
821
|
+
* the context captured here (a module-level component has no `ctx`).
|
|
822
|
+
* @param ctx - the client root context that applied this plugin.
|
|
823
|
+
* @param diary - the diagnostics ring buffer, or null when no diagnostics
|
|
824
|
+
* surface was requested for this page load.
|
|
825
|
+
* @returns the slot entry component.
|
|
826
|
+
*/
|
|
827
|
+
function createNativeInput(ctx, diary) {
|
|
828
|
+
return function NativeInput(props) {
|
|
829
|
+
var live = useTakeoverViewport();
|
|
830
|
+
var useInput = props.useInput;
|
|
831
|
+
var input = typeof useInput === "function" ? useInput(function (s) { return s; }) : undefined;
|
|
832
|
+
var sessionId = props.sessionId;
|
|
833
|
+
var actions = props.inputActions;
|
|
834
|
+
var phase = input === undefined ? "plain" : input.phase;
|
|
835
|
+
/* Ownership is surface-level only: claims fall back to the stock editor
|
|
836
|
+
(see the section comment). */
|
|
837
|
+
var active = live && phase === "plain" && sessionId !== undefined;
|
|
838
|
+
|
|
839
|
+
var wrapRef = React.useRef(null);
|
|
840
|
+
var areaRef = React.useRef(null);
|
|
841
|
+
var composingRef = React.useRef(false);
|
|
842
|
+
/* Trigger characters present when the current IME composition started. */
|
|
843
|
+
var compositionTriggersRef = React.useRef(0);
|
|
844
|
+
/* True while the takeover itself is dropping/re-taking focus (see
|
|
845
|
+
`resync`): the blur in between is plumbing, not a commit point. */
|
|
846
|
+
var resyncingRef = React.useRef(false);
|
|
847
|
+
var draftRef = React.useRef("");
|
|
848
|
+
var faceRef = React.useRef(undefined);
|
|
849
|
+
/* The last value this component itself pushed into the machine: an
|
|
850
|
+
incoming draft equal to it is our own echo, never an external write. */
|
|
851
|
+
var pushedRef = React.useRef(null);
|
|
852
|
+
/* Latest field value not yet mirrored into the machine (see the section
|
|
853
|
+
comment: the mirror is deferred to commit points). */
|
|
854
|
+
var pendingRef = React.useRef(null);
|
|
855
|
+
/* Whether the takeover currently owns the surface (read by handlers that
|
|
856
|
+
outlive a render). */
|
|
857
|
+
var activeRef = React.useRef(false);
|
|
858
|
+
/* The stock editor's own editability, captured before the takeover forces
|
|
859
|
+
contenteditable="false" (the field mirrors THIS, not our own override). */
|
|
860
|
+
var stockEditableRef = React.useRef(true);
|
|
861
|
+
/* Last geometry applied: style writes are skipped when nothing moved, so
|
|
862
|
+
a strict engine's IME is not disturbed by pointless relayouts. */
|
|
863
|
+
var geometryRef = React.useRef("");
|
|
864
|
+
|
|
865
|
+
draftRef.current = input === undefined ? "" : input.draft;
|
|
866
|
+
|
|
867
|
+
var face = React.useMemo(
|
|
868
|
+
function () { return resolveInputFace(ctx, sessionId); },
|
|
869
|
+
[sessionId],
|
|
870
|
+
);
|
|
871
|
+
faceRef.current = face;
|
|
872
|
+
|
|
873
|
+
var cardOf = function () {
|
|
874
|
+
var wrap = wrapRef.current;
|
|
875
|
+
return wrap === null ? null : wrap.closest("[data-composer-card]");
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
/* The one invariant this whole file is built around (see the section
|
|
879
|
+
comment): while the field has focus the plugin writes NOTHING to the
|
|
880
|
+
DOM. Every measurement, resize and chrome sync below starts with this
|
|
881
|
+
guard, so no reachable path — input event, ResizeObserver, rAF pass,
|
|
882
|
+
MutationObserver — can churn the DOM under a live IME. */
|
|
883
|
+
var hasFocus = function () {
|
|
884
|
+
return document.activeElement === areaRef.current;
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
/** Growth policy for this mount (re-read on every preference change). */
|
|
888
|
+
var growthRef = React.useRef(readGrowthMode());
|
|
889
|
+
/** Trigger-sync policy for this mount (see readTriggerMode). */
|
|
890
|
+
var triggerRef = React.useRef(readTriggerMode());
|
|
891
|
+
/** Last value seen by onInput: the trigger delta is what decides a mirror. */
|
|
892
|
+
var lastValueRef = React.useRef("");
|
|
893
|
+
React.useEffect(function () {
|
|
894
|
+
var update = function () {
|
|
895
|
+
growthRef.current = readGrowthMode();
|
|
896
|
+
triggerRef.current = readTriggerMode();
|
|
897
|
+
};
|
|
898
|
+
window.addEventListener(GROWTH_EVENT, update);
|
|
899
|
+
window.addEventListener(TRIGGER_EVENT, update);
|
|
900
|
+
window.addEventListener("storage", update);
|
|
901
|
+
return function () {
|
|
902
|
+
window.removeEventListener(GROWTH_EVENT, update);
|
|
903
|
+
window.removeEventListener(TRIGGER_EVENT, update);
|
|
904
|
+
window.removeEventListener("storage", update);
|
|
905
|
+
};
|
|
906
|
+
}, []);
|
|
907
|
+
|
|
908
|
+
/** Diagnostics, prefixed so a log mixing bench variants with the real
|
|
909
|
+
composer field stays readable. */
|
|
910
|
+
var debug = function (line, force) {
|
|
911
|
+
if (diary === null) return;
|
|
912
|
+
diary.add("composer " + line, force === true);
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
/** Publish the stock scrollport's box onto our seat (idempotent).
|
|
916
|
+
Refuses to run while the field has focus: moving or resizing the
|
|
917
|
+
focused editable is what closes the keyboard on ArkWeb. */
|
|
918
|
+
var measure = function (force) {
|
|
919
|
+
var wrap = wrapRef.current;
|
|
920
|
+
if (wrap === null) return;
|
|
921
|
+
if (hasFocus() && force !== true) return;
|
|
922
|
+
var card = cardOf();
|
|
923
|
+
var scroll = card === null ? null : card.querySelector("[data-input-scroll]");
|
|
924
|
+
if (card === null || scroll === null) return;
|
|
925
|
+
var cardRect = card.getBoundingClientRect();
|
|
926
|
+
var scrollRect = scroll.getBoundingClientRect();
|
|
927
|
+
/* Whole pixels: sub-pixel jitter would otherwise re-write styles (and
|
|
928
|
+
invalidate layout) on every measurement. */
|
|
929
|
+
var left = Math.round(scrollRect.left - cardRect.left - card.clientLeft);
|
|
930
|
+
var top = Math.round(scrollRect.top - cardRect.top - card.clientTop);
|
|
931
|
+
var width = Math.round(scroll.clientWidth);
|
|
932
|
+
var height = Math.round(scroll.clientHeight);
|
|
933
|
+
var next = [left, top, width, height].join(":");
|
|
934
|
+
if (next === geometryRef.current) return;
|
|
935
|
+
geometryRef.current = next;
|
|
936
|
+
wrap.style.left = left + "px";
|
|
937
|
+
wrap.style.top = top + "px";
|
|
938
|
+
wrap.style.width = width + "px";
|
|
939
|
+
wrap.style.height = height + "px";
|
|
940
|
+
if (scroll.scrollTop !== 0) scroll.scrollTop = 0;
|
|
941
|
+
debug("measure " + next);
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
/** Grow the field to its own content and keep the card tall enough.
|
|
945
|
+
Runs at commit points only (see the section comment); the measurement
|
|
946
|
+
is non-destructive — the old probe collapsed the field to
|
|
947
|
+
`height: 0px` and back, which on ArkWeb is indistinguishable from the
|
|
948
|
+
editable disappearing. */
|
|
949
|
+
var autosize = function (force) {
|
|
950
|
+
var area = areaRef.current;
|
|
951
|
+
if (area === null) return;
|
|
952
|
+
if (hasFocus() && force !== true && growthRef.current !== "live") return;
|
|
953
|
+
var card = cardOf();
|
|
954
|
+
var scroll = card === null ? null : card.querySelector("[data-input-scroll]");
|
|
955
|
+
var grow = scroll === null ? null : scroll.firstElementChild;
|
|
956
|
+
var cap = 0;
|
|
957
|
+
if (scroll !== null) {
|
|
958
|
+
var raw = window.getComputedStyle(scroll).maxHeight;
|
|
959
|
+
var parsed = parseFloat(raw);
|
|
960
|
+
if (isFinite(parsed) && parsed > 0) cap = parsed;
|
|
961
|
+
}
|
|
962
|
+
/* The product's own floor for this surface (36px docked, 52px in the
|
|
963
|
+
blank-session hero) — read it off the stock editor rather than
|
|
964
|
+
guessing. */
|
|
965
|
+
var floor = 36;
|
|
966
|
+
var stock = scroll === null ? null : card.querySelector("[data-composer-input]");
|
|
967
|
+
if (stock !== null) {
|
|
968
|
+
var rawFloor = parseFloat(window.getComputedStyle(stock).minHeight);
|
|
969
|
+
if (isFinite(rawFloor) && rawFloor > 0) floor = rawFloor;
|
|
970
|
+
}
|
|
971
|
+
var previous = area.style.height;
|
|
972
|
+
area.style.height = "auto";
|
|
973
|
+
var needed = area.scrollHeight;
|
|
974
|
+
area.style.height = previous;
|
|
975
|
+
if (needed < floor) needed = floor;
|
|
976
|
+
if (cap > 0 && needed > cap) needed = cap;
|
|
977
|
+
/* `none`: the field is pinned to the product's own floor — it never
|
|
978
|
+
grows, but it still shrinks back after a send. */
|
|
979
|
+
if (growthRef.current === "none") needed = floor;
|
|
980
|
+
var box = Math.round(needed) + "px";
|
|
981
|
+
if (area.style.height !== box) area.style.height = box;
|
|
982
|
+
/* Floor on the stock row's box: the absolutely positioned seat cannot
|
|
983
|
+
make the card grow by itself. */
|
|
984
|
+
if (grow !== null && grow.style.minHeight !== box) grow.style.minHeight = box;
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
/** Mirror the stock surface's chrome: placeholder text + editability.
|
|
988
|
+
Also deferred while the field has focus (a read-only flip is a DOM
|
|
989
|
+
write like any other). */
|
|
990
|
+
var refreshChrome = function (force) {
|
|
991
|
+
var area = areaRef.current;
|
|
992
|
+
if (area === null) return;
|
|
993
|
+
if (hasFocus() && force !== true) return;
|
|
994
|
+
var card = cardOf();
|
|
995
|
+
var editor = card === null ? null : card.querySelector("[data-composer-input]");
|
|
996
|
+
if (editor === null) return;
|
|
997
|
+
var placeholder = editor.getAttribute("data-placeholder");
|
|
998
|
+
if (placeholder !== null && area.placeholder !== placeholder) area.placeholder = placeholder;
|
|
999
|
+
/* The product's own gate (locked / inert / takeover states) rides this
|
|
1000
|
+
attribute; mirror it instead of re-deriving the policy. */
|
|
1001
|
+
var editable = activeRef.current
|
|
1002
|
+
? stockEditableRef.current
|
|
1003
|
+
: editor.getAttribute("contenteditable") !== "false";
|
|
1004
|
+
/* Keep the stock editor out of the browser's editable set for as long
|
|
1005
|
+
as the takeover owns the surface (React re-applies its own value only
|
|
1006
|
+
when the prop changes, so ours sticks until then). */
|
|
1007
|
+
if (activeRef.current && editor.getAttribute("contenteditable") !== "false") {
|
|
1008
|
+
editor.setAttribute("contenteditable", "false");
|
|
1009
|
+
debug("re-forced contenteditable=false");
|
|
1010
|
+
}
|
|
1011
|
+
if (area.readOnly === editable) area.readOnly = !editable;
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
/** One settle pass, for the moments focus has already left the field.
|
|
1015
|
+
`force` overrides the focus guard for the single case that has to win
|
|
1016
|
+
over it: a committed send clearing the draft (the card has just
|
|
1017
|
+
shrunk, so a grown field would hang out of it). */
|
|
1018
|
+
var settle = function (force) {
|
|
1019
|
+
if (hasFocus() && force !== true) return;
|
|
1020
|
+
measure(force);
|
|
1021
|
+
autosize(force);
|
|
1022
|
+
refreshChrome(force);
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
/* Mark the card while the takeover owns the surface (the CSS that hides
|
|
1026
|
+
the stock draft surface keys off this attribute), take the stock editor
|
|
1027
|
+
out of the focus/IME tree, and keep our empty seat out of hit-testing
|
|
1028
|
+
when the stock editor owns the surface (claims). */
|
|
1029
|
+
React.useLayoutEffect(function () {
|
|
1030
|
+
var card = cardOf();
|
|
1031
|
+
if (card === null) return undefined;
|
|
1032
|
+
var wrap = wrapRef.current;
|
|
1033
|
+
var editor = card.querySelector("[data-composer-input]");
|
|
1034
|
+
var restore = null;
|
|
1035
|
+
activeRef.current = active;
|
|
1036
|
+
if (active) {
|
|
1037
|
+
card.setAttribute("data-mobile-input-active", "");
|
|
1038
|
+
if (wrap !== null) wrap.style.display = "";
|
|
1039
|
+
if (editor !== null) {
|
|
1040
|
+
/* On Chromium-class engines an inert editable is out of both the
|
|
1041
|
+
focus tree and the IME's editable candidates: nothing but the
|
|
1042
|
+
textarea can own the input connection. */
|
|
1043
|
+
var hadInert = editor.hasAttribute("inert");
|
|
1044
|
+
var hadAria = editor.getAttribute("aria-hidden");
|
|
1045
|
+
var hadEditable = editor.getAttribute("contenteditable");
|
|
1046
|
+
stockEditableRef.current = hadEditable !== "false";
|
|
1047
|
+
if (typeof editor.inert === "boolean") editor.inert = true;
|
|
1048
|
+
else editor.setAttribute("inert", "");
|
|
1049
|
+
editor.setAttribute("aria-hidden", "true");
|
|
1050
|
+
editor.setAttribute("contenteditable", "false");
|
|
1051
|
+
restore = function () {
|
|
1052
|
+
if (!hadInert) {
|
|
1053
|
+
if (typeof editor.inert === "boolean") editor.inert = false;
|
|
1054
|
+
editor.removeAttribute("inert");
|
|
1055
|
+
}
|
|
1056
|
+
if (hadAria === null) editor.removeAttribute("aria-hidden");
|
|
1057
|
+
else editor.setAttribute("aria-hidden", hadAria);
|
|
1058
|
+
if (hadEditable === null) editor.removeAttribute("contenteditable");
|
|
1059
|
+
else editor.setAttribute("contenteditable", hadEditable);
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
} else {
|
|
1063
|
+
card.removeAttribute("data-mobile-input-active");
|
|
1064
|
+
if (wrap !== null) wrap.style.display = "none";
|
|
1065
|
+
}
|
|
1066
|
+
return function () {
|
|
1067
|
+
card.removeAttribute("data-mobile-input-active");
|
|
1068
|
+
var grow = card.querySelector("[data-input-scroll]");
|
|
1069
|
+
if (grow !== null && grow.firstElementChild !== null) grow.firstElementChild.style.minHeight = "";
|
|
1070
|
+
if (restore !== null) restore();
|
|
1071
|
+
};
|
|
1072
|
+
}, [active]);
|
|
1073
|
+
|
|
1074
|
+
/* Geometry tracking: the stock box moves whenever the card relayouts
|
|
1075
|
+
(taller draft, attachment rail, rotation), so follow it. */
|
|
1076
|
+
React.useLayoutEffect(function () {
|
|
1077
|
+
if (!active) return undefined;
|
|
1078
|
+
var card = cardOf();
|
|
1079
|
+
var scroll = card === null ? null : card.querySelector("[data-input-scroll]");
|
|
1080
|
+
var frame = 0;
|
|
1081
|
+
var run = function () {
|
|
1082
|
+
frame = 0;
|
|
1083
|
+
settle();
|
|
1084
|
+
};
|
|
1085
|
+
var schedule = function () {
|
|
1086
|
+
if (frame !== 0) return;
|
|
1087
|
+
frame = window.requestAnimationFrame(run);
|
|
1088
|
+
};
|
|
1089
|
+
settle();
|
|
1090
|
+
var observer = typeof window.ResizeObserver === "function" ? new window.ResizeObserver(schedule) : null;
|
|
1091
|
+
if (observer !== null) {
|
|
1092
|
+
if (card !== null) observer.observe(card);
|
|
1093
|
+
if (scroll !== null) observer.observe(scroll);
|
|
1094
|
+
}
|
|
1095
|
+
/* Chrome (placeholder / editability) changes ride attributes only. */
|
|
1096
|
+
var mutations = typeof window.MutationObserver === "function" && card !== null
|
|
1097
|
+
? new window.MutationObserver(schedule)
|
|
1098
|
+
: null;
|
|
1099
|
+
if (mutations !== null) {
|
|
1100
|
+
mutations.observe(card, {
|
|
1101
|
+
attributes: true,
|
|
1102
|
+
attributeFilter: ["data-placeholder", "contenteditable"],
|
|
1103
|
+
subtree: true,
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
window.addEventListener("resize", schedule);
|
|
1107
|
+
window.addEventListener("orientationchange", schedule);
|
|
1108
|
+
var fonts = document.fonts;
|
|
1109
|
+
if (fonts !== undefined && typeof fonts.ready === "object" && typeof fonts.ready.then === "function") {
|
|
1110
|
+
fonts.ready.then(schedule, schedule);
|
|
1111
|
+
}
|
|
1112
|
+
return function () {
|
|
1113
|
+
if (frame !== 0) window.cancelAnimationFrame(frame);
|
|
1114
|
+
if (observer !== null) observer.disconnect();
|
|
1115
|
+
if (mutations !== null) mutations.disconnect();
|
|
1116
|
+
window.removeEventListener("resize", schedule);
|
|
1117
|
+
window.removeEventListener("orientationchange", schedule);
|
|
1118
|
+
};
|
|
1119
|
+
}, [active]);
|
|
1120
|
+
|
|
1121
|
+
/* Machine -> textarea. The live store subscription (not a render-time
|
|
1122
|
+
value) makes the echo of our own mirror a no-op while an external
|
|
1123
|
+
change — send committed, failed-send restore, another plugin's
|
|
1124
|
+
insert — still lands, with no stale-render race to clobber typing. */
|
|
1125
|
+
React.useEffect(function () {
|
|
1126
|
+
if (!active) return undefined;
|
|
1127
|
+
var area = areaRef.current;
|
|
1128
|
+
if (area === null) return undefined;
|
|
1129
|
+
var sync = function () {
|
|
1130
|
+
var next = face === undefined ? draftRef.current : face.state.getSnapshot().draft;
|
|
1131
|
+
var focused = document.activeElement === area;
|
|
1132
|
+
/* While the field has focus IT is the source of truth: a write here
|
|
1133
|
+
(mid-composition on engines whose composition events differ, or
|
|
1134
|
+
between two fast keystrokes) resets an IME session — the classic
|
|
1135
|
+
"keyboard up, one character in, nothing after" failure. The one
|
|
1136
|
+
exception is a clear, which is a committed send, never local
|
|
1137
|
+
typing. */
|
|
1138
|
+
if (composingRef.current && next !== "") return;
|
|
1139
|
+
/* Text the user typed but has not committed yet outranks anything the
|
|
1140
|
+
machine publishes meanwhile — including an EMPTY publish: the
|
|
1141
|
+
machine draft is stale by design while typing, so "" is not "the
|
|
1142
|
+
user cleared the field", it is just the machine never having heard
|
|
1143
|
+
about this text. Only our own flush (which nulls the pending value
|
|
1144
|
+
first) may clear the field. */
|
|
1145
|
+
if (pendingRef.current !== null) {
|
|
1146
|
+
if (next !== "" || area.value !== "") {
|
|
1147
|
+
debug("keep " + pendingRef.current.length + " uncommitted chars");
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
/* While uncommitted text is still ours, an external draft must not
|
|
1152
|
+
clobber it. Once it is flushed (pending null), the app is the
|
|
1153
|
+
authority again — that is how a command picked from the menu lands
|
|
1154
|
+
in the field. */
|
|
1155
|
+
if (focused && next !== "" && next !== pushedRef.current && pendingRef.current !== null) {
|
|
1156
|
+
debug("skip external write while focused: " + next.length + " chars");
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (area.value !== next) {
|
|
1160
|
+
area.value = next;
|
|
1161
|
+
debug("write " + next.length + " chars (focused=" + focused + ")");
|
|
1162
|
+
/* A committed send is the one write the plugin performs on a
|
|
1163
|
+
FOCUSED field, and it has to resize too: the card has just
|
|
1164
|
+
shrunk back to its floor, so a grown field would hang out of
|
|
1165
|
+
it. It follows an explicit user action, never typing. */
|
|
1166
|
+
if (next === "" && focused) {
|
|
1167
|
+
settle(true);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
settle();
|
|
1172
|
+
};
|
|
1173
|
+
sync();
|
|
1174
|
+
if (face === undefined) return undefined; /* no live store: render-driven */
|
|
1175
|
+
var off = face.state.subscribe(sync);
|
|
1176
|
+
return function () { off(); };
|
|
1177
|
+
}, [active, face]);
|
|
1178
|
+
|
|
1179
|
+
/* Fallback mirror when the live store could not be resolved (a scope
|
|
1180
|
+
that is not queryable yet): best effort off the rendered draft. */
|
|
1181
|
+
React.useEffect(function () {
|
|
1182
|
+
if (!active || face !== undefined) return;
|
|
1183
|
+
var area = areaRef.current;
|
|
1184
|
+
if (area === null) return;
|
|
1185
|
+
if (composingRef.current && draftRef.current !== "") return;
|
|
1186
|
+
if (document.activeElement === area && draftRef.current !== "") return;
|
|
1187
|
+
if (area.value !== draftRef.current) area.value = draftRef.current;
|
|
1188
|
+
}, [active, face, input]);
|
|
1189
|
+
|
|
1190
|
+
/* Diagnostics only: report APP-side DOM churn while the field has focus.
|
|
1191
|
+
This is what separates "our writes" from "the app re-rendered" (the
|
|
1192
|
+
command menu, Lexical rewriting the hidden editor) when a device report
|
|
1193
|
+
says the keyboard dropped — the two look identical from the outside. */
|
|
1194
|
+
React.useEffect(function () {
|
|
1195
|
+
if (diary === null || !active) return undefined;
|
|
1196
|
+
var card = cardOf();
|
|
1197
|
+
if (card === null || typeof window.MutationObserver !== "function") return undefined;
|
|
1198
|
+
var observer = new window.MutationObserver(function (records) {
|
|
1199
|
+
if (records.length === 0 || !hasFocus()) return;
|
|
1200
|
+
var kinds = [];
|
|
1201
|
+
for (var i = 0; i < records.length && i < 4; i += 1) {
|
|
1202
|
+
var record = records[i];
|
|
1203
|
+
kinds.push(record.type + ":"
|
|
1204
|
+
+ (record.attributeName !== null && record.attributeName !== undefined
|
|
1205
|
+
? record.attributeName
|
|
1206
|
+
: record.target.tagName));
|
|
1207
|
+
}
|
|
1208
|
+
debug("CHURN while focused: " + records.length + " [" + kinds.join(",") + "]");
|
|
1209
|
+
});
|
|
1210
|
+
observer.observe(card, { attributes: true, childList: true, characterData: true, subtree: true });
|
|
1211
|
+
return function () { observer.disconnect(); };
|
|
1212
|
+
}, [active]);
|
|
1213
|
+
|
|
1214
|
+
/* Textarea -> machine. Mirrored on every input event: the send button,
|
|
1215
|
+
the placeholder and the `/` trigger pipeline all read the machine, so
|
|
1216
|
+
a debounce would let the user send a draft the machine never saw. */
|
|
1217
|
+
var push = function (value) {
|
|
1218
|
+
pushedRef.current = value;
|
|
1219
|
+
try {
|
|
1220
|
+
if (actions !== undefined && typeof actions.setDraft === "function") {
|
|
1221
|
+
actions.setDraft(value);
|
|
1222
|
+
return true;
|
|
1223
|
+
}
|
|
1224
|
+
var face = faceRef.current;
|
|
1225
|
+
if (face !== undefined && typeof face.setDraft === "function") {
|
|
1226
|
+
face.setDraft(value);
|
|
1227
|
+
return true;
|
|
1228
|
+
}
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
/* A refused machine write must never break typing: the field keeps
|
|
1231
|
+
its own text and the next publish re-syncs. */
|
|
1232
|
+
debug("push failed: " + String(error && error.message));
|
|
1233
|
+
}
|
|
1234
|
+
return false;
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
/** Defer a field value until the next commit point (per-keystroke DOM
|
|
1238
|
+
churn is what drops the IME on strict engines). */
|
|
1239
|
+
var queueMirror = function (value, urgent) {
|
|
1240
|
+
pendingRef.current = value;
|
|
1241
|
+
if (urgent === true) return mirrorNow();
|
|
1242
|
+
return false;
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
/** Mirror a trigger change immediately (the menus read the machine). */
|
|
1246
|
+
var chattyMirror = function (value) {
|
|
1247
|
+
debug("trigger mirror " + value.length + " chars");
|
|
1248
|
+
return queueMirror(value, true);
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
/** Flush pending React work now, when react-dom is reachable. */
|
|
1252
|
+
var flushNow = function (fn) {
|
|
1253
|
+
try {
|
|
1254
|
+
var dom = require("react-dom");
|
|
1255
|
+
if (dom !== null && dom !== undefined && typeof dom.flushSync === "function") {
|
|
1256
|
+
dom.flushSync(fn);
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
} catch (error) { /* no react-dom: the update stays async */ }
|
|
1260
|
+
fn();
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Trigger mirror for strict engines. The write makes the app rewrite the
|
|
1265
|
+
* hidden stock editor's DOM; doing that while the IME is attached is what
|
|
1266
|
+
* closes the keyboard (measured 2026-09-14: churn lands ~30ms after the
|
|
1267
|
+
* write, the keyboard drops ~100ms later, and typing the same `/` in the
|
|
1268
|
+
* stock editor — where the write IS the user's own editing — keeps it).
|
|
1269
|
+
* So the whole thing happens while focus is deliberately elsewhere, and
|
|
1270
|
+
* focus comes back inside the same gesture.
|
|
1271
|
+
*/
|
|
1272
|
+
var resyncTrigger = function (value) {
|
|
1273
|
+
var area = areaRef.current;
|
|
1274
|
+
pendingRef.current = value;
|
|
1275
|
+
if (area === null || document.activeElement !== area) {
|
|
1276
|
+
return mirrorNow();
|
|
1277
|
+
}
|
|
1278
|
+
resyncingRef.current = true;
|
|
1279
|
+
try {
|
|
1280
|
+
area.blur();
|
|
1281
|
+
flushNow(function () { mirrorNow(); });
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
debug("resync failed: " + String(error && error.message));
|
|
1284
|
+
} finally {
|
|
1285
|
+
resyncingRef.current = false;
|
|
1286
|
+
}
|
|
1287
|
+
try {
|
|
1288
|
+
area.focus({ preventScroll: true });
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
try { area.focus(); } catch (again) { /* the field keeps its text anyway */ }
|
|
1291
|
+
}
|
|
1292
|
+
debug("resync " + value.length + " chars");
|
|
1293
|
+
return true;
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
/** Flush the pending field value into the machine, now. */
|
|
1297
|
+
var mirrorNow = function () {
|
|
1298
|
+
var value = pendingRef.current;
|
|
1299
|
+
if (value === null) return false;
|
|
1300
|
+
pendingRef.current = null;
|
|
1301
|
+
debug("commit " + value.length + " chars");
|
|
1302
|
+
return push(value);
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
/** Enter = the stock send gesture, replayed on the hidden editor so the
|
|
1306
|
+
product's own keymap decides (menu arbitration, busy-Enter policy). */
|
|
1307
|
+
var sendGesture = function () {
|
|
1308
|
+
var card = cardOf();
|
|
1309
|
+
var editor = card === null ? null : card.querySelector("[data-composer-input]");
|
|
1310
|
+
if (editor !== null && typeof window.KeyboardEvent === "function") {
|
|
1311
|
+
var replay = new window.KeyboardEvent("keydown", {
|
|
1312
|
+
key: "Enter",
|
|
1313
|
+
code: "Enter",
|
|
1314
|
+
bubbles: true,
|
|
1315
|
+
cancelable: true,
|
|
1316
|
+
composed: true,
|
|
1317
|
+
});
|
|
1318
|
+
editor.dispatchEvent(replay);
|
|
1319
|
+
if (replay.defaultPrevented === true) return true; /* stock handler ran */
|
|
1320
|
+
}
|
|
1321
|
+
if (actions !== undefined && typeof actions.submit === "function") {
|
|
1322
|
+
actions.submit();
|
|
1323
|
+
return true;
|
|
1324
|
+
}
|
|
1325
|
+
var face = faceRef.current;
|
|
1326
|
+
if (face !== undefined && typeof face.submit === "function") {
|
|
1327
|
+
face.submit();
|
|
1328
|
+
return true;
|
|
1329
|
+
}
|
|
1330
|
+
return false;
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
/** The `/` and `@` menus are driven by the machine's draft, not by the
|
|
1334
|
+
field, so a trigger character has to reach the machine — but ONLY the
|
|
1335
|
+
trigger character, never every character typed after it (see
|
|
1336
|
+
readTriggerMode: a per-keystroke mirror is what drops the keyboard on
|
|
1337
|
+
ArkWeb, and any draft containing `/` used to take that path). */
|
|
1338
|
+
var countTriggers = function (text) {
|
|
1339
|
+
var total = 0;
|
|
1340
|
+
for (var i = 0; i < text.length; i += 1) {
|
|
1341
|
+
var ch = text.charAt(i);
|
|
1342
|
+
if (ch === "/" || ch === "@") total += 1;
|
|
1343
|
+
}
|
|
1344
|
+
return total;
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
/* The typing path. It touches NO DOM: no resize, no re-align, no chrome
|
|
1348
|
+
sync, no panel paint — only refs and (for a trigger character) the
|
|
1349
|
+
machine write the menus are driven by. Everything else waits for a
|
|
1350
|
+
commit point (see the section comment). */
|
|
1351
|
+
var onInput = function (event) {
|
|
1352
|
+
var value = event.currentTarget.value;
|
|
1353
|
+
var previous = lastValueRef.current;
|
|
1354
|
+
lastValueRef.current = value;
|
|
1355
|
+
var mode = triggerRef.current;
|
|
1356
|
+
var triggerChanged = countTriggers(value) !== countTriggers(previous);
|
|
1357
|
+
/* Mid-composition the text is provisional: mirroring it would churn the
|
|
1358
|
+
card AND reset the IME session on a strict engine, so the composition
|
|
1359
|
+
path always defers (compositionend decides, against the count captured
|
|
1360
|
+
when the composition started). */
|
|
1361
|
+
var composing = composingRef.current === true;
|
|
1362
|
+
debug("input " + value.length + " chars"
|
|
1363
|
+
+ (triggerChanged && !composing ? " (trigger changed)" : " (deferred)"));
|
|
1364
|
+
if (!composing && triggerChanged && mode === "resync") {
|
|
1365
|
+
resyncTrigger(value);
|
|
1366
|
+
} else if (!composing && (mode === "live" || (mode === "changes" && triggerChanged))) {
|
|
1367
|
+
/* Urgent: the menu needs the machine draft. Everything else waits for
|
|
1368
|
+
a commit point — that is the whole ArkWeb fix. */
|
|
1369
|
+
chattyMirror(value);
|
|
1370
|
+
} else {
|
|
1371
|
+
queueMirror(value, false);
|
|
1372
|
+
}
|
|
1373
|
+
if (growthRef.current === "live") autosize();
|
|
1374
|
+
};
|
|
1375
|
+
var onCompositionStart = function () {
|
|
1376
|
+
composingRef.current = true;
|
|
1377
|
+
compositionTriggersRef.current = countTriggers(areaRef.current === null ? "" : areaRef.current.value);
|
|
1378
|
+
debug("compositionstart");
|
|
1379
|
+
};
|
|
1380
|
+
var onCompositionEnd = function (event) {
|
|
1381
|
+
composingRef.current = false;
|
|
1382
|
+
var value = event.currentTarget.value;
|
|
1383
|
+
/* Compared against the count captured at compositionstart: the per-input
|
|
1384
|
+
events during the composition already moved lastValueRef forward. */
|
|
1385
|
+
var triggerChanged = countTriggers(value) !== compositionTriggersRef.current;
|
|
1386
|
+
lastValueRef.current = value;
|
|
1387
|
+
debug("compositionend " + value.length);
|
|
1388
|
+
if (triggerChanged && triggerRef.current === "resync") resyncTrigger(value);
|
|
1389
|
+
else if (triggerChanged) chattyMirror(value);
|
|
1390
|
+
else queueMirror(value, false);
|
|
1391
|
+
if (growthRef.current === "live") autosize();
|
|
1392
|
+
};
|
|
1393
|
+
var onFocus = function () {
|
|
1394
|
+
if (resyncingRef.current) return;
|
|
1395
|
+
debug("focus");
|
|
1396
|
+
};
|
|
1397
|
+
/** Commit point: publish the field, then settle the surface (focus is on
|
|
1398
|
+
its way out, so the write is safe again). */
|
|
1399
|
+
var onBlur = function () {
|
|
1400
|
+
if (resyncingRef.current) {
|
|
1401
|
+
debug("blur (resync)", true);
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
debug("blur -> commit", true);
|
|
1405
|
+
mirrorNow();
|
|
1406
|
+
settle();
|
|
1407
|
+
/* Some engines report the old activeElement while the blur handler
|
|
1408
|
+
runs; the deferred pass covers that, and stays a no-op when focus is
|
|
1409
|
+
already back in the field. */
|
|
1410
|
+
window.setTimeout(function () { settle(); }, 0);
|
|
1411
|
+
};
|
|
1412
|
+
var onKeyDown = function (event) {
|
|
1413
|
+
if (event.key !== "Enter" || event.shiftKey === true) return;
|
|
1414
|
+
if (event.nativeEvent !== undefined && event.nativeEvent.isComposing === true) return;
|
|
1415
|
+
if (composingRef.current) return;
|
|
1416
|
+
/* An empty draft is only sendable when it carries attachments (the
|
|
1417
|
+
machine's attachment-only send); otherwise keep the newline. */
|
|
1418
|
+
var attachments = input !== undefined && Array.isArray(input.attachmentIds)
|
|
1419
|
+
? input.attachmentIds.length
|
|
1420
|
+
: 0;
|
|
1421
|
+
if (event.currentTarget.value.trim() === "" && attachments === 0) return;
|
|
1422
|
+
queueMirror(event.currentTarget.value, false);
|
|
1423
|
+
mirrorNow();
|
|
1424
|
+
if (sendGesture()) {
|
|
1425
|
+
pendingRef.current = null;
|
|
1426
|
+
event.preventDefault();
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
/* Attachments pasted into a textarea have nowhere to land, so forward a
|
|
1430
|
+
file-bearing paste to the stock surface, whose keymap owns intake. */
|
|
1431
|
+
var onPaste = function (event) {
|
|
1432
|
+
var data = event.clipboardData;
|
|
1433
|
+
if (data === undefined || data === null || data.files === undefined || data.files.length === 0) return;
|
|
1434
|
+
var card = cardOf();
|
|
1435
|
+
var editor = card === null ? null : card.querySelector("[data-composer-input]");
|
|
1436
|
+
if (editor === null) return;
|
|
1437
|
+
try {
|
|
1438
|
+
var forwarded = new window.ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data });
|
|
1439
|
+
editor.dispatchEvent(forwarded);
|
|
1440
|
+
event.preventDefault();
|
|
1441
|
+
} catch (error) { /* no ClipboardEvent constructor: the picker still works */ }
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1444
|
+
/* Taps anywhere in the stock input region must land on the textarea,
|
|
1445
|
+
whatever the engine's hit-testing does with the layers above it: a
|
|
1446
|
+
capture listener on the card focuses the field when the tap point is
|
|
1447
|
+
inside our box but the target is not the field itself. */
|
|
1448
|
+
React.useEffect(function () {
|
|
1449
|
+
if (!active) return undefined;
|
|
1450
|
+
var card = cardOf();
|
|
1451
|
+
if (card === null) return undefined;
|
|
1452
|
+
/** The button whose box contains a point (a disabled button is not
|
|
1453
|
+
returned by elementFromPoint, so hit it by rect). */
|
|
1454
|
+
var buttonAt = function (x, y) {
|
|
1455
|
+
var buttons = card.querySelectorAll("button");
|
|
1456
|
+
for (var i = 0; i < buttons.length; i += 1) {
|
|
1457
|
+
var rect = buttons[i].getBoundingClientRect();
|
|
1458
|
+
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return buttons[i];
|
|
1459
|
+
}
|
|
1460
|
+
return null;
|
|
1461
|
+
};
|
|
1462
|
+
var onPointerDown = function (event) {
|
|
1463
|
+
var area = areaRef.current;
|
|
1464
|
+
var wrap = wrapRef.current;
|
|
1465
|
+
if (area === null || wrap === null || event.target === area) return;
|
|
1466
|
+
var x = event.clientX;
|
|
1467
|
+
var y = event.clientY;
|
|
1468
|
+
debug("tap " + Math.round(x) + "," + Math.round(y) + " target=" + (event.target && event.target.tagName));
|
|
1469
|
+
var rect = wrap.getBoundingClientRect();
|
|
1470
|
+
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
|
|
1471
|
+
/* Inside the input box: the field must get the tap, whatever the
|
|
1472
|
+
engine did with the layers above it. */
|
|
1473
|
+
event.preventDefault();
|
|
1474
|
+
try {
|
|
1475
|
+
area.focus({ preventScroll: true });
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
area.focus();
|
|
1478
|
+
}
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
/* Outside the field: a toolbar action whose gate may depend on the
|
|
1482
|
+
draft (the send button is disabled while the machine draft is
|
|
1483
|
+
empty). Commit first, then replay the tap on a button our commit
|
|
1484
|
+
just enabled. */
|
|
1485
|
+
var button = buttonAt(x, y);
|
|
1486
|
+
var wasDisabled = button !== null && button.disabled === true;
|
|
1487
|
+
mirrorNow();
|
|
1488
|
+
if (button !== null && wasDisabled && button.disabled === false) {
|
|
1489
|
+
event.preventDefault();
|
|
1490
|
+
window.setTimeout(function () { button.click(); }, 0);
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
/* Engines without a working inert: if anything hands focus back to the
|
|
1494
|
+
stock editor, take it straight back to the field. */
|
|
1495
|
+
var onFocusIn = function (event) {
|
|
1496
|
+
var area = areaRef.current;
|
|
1497
|
+
if (area === null || event.target === area) return;
|
|
1498
|
+
var editor = card.querySelector("[data-composer-input]");
|
|
1499
|
+
if (editor === null || event.target !== editor) return;
|
|
1500
|
+
debug("stock editor stole focus -> refocus field");
|
|
1501
|
+
try {
|
|
1502
|
+
area.focus({ preventScroll: true });
|
|
1503
|
+
} catch (error) { /* ignore */ }
|
|
1504
|
+
};
|
|
1505
|
+
var onHide = function () {
|
|
1506
|
+
if (document.visibilityState === "hidden") mirrorNow();
|
|
1507
|
+
};
|
|
1508
|
+
card.addEventListener("pointerdown", onPointerDown, true);
|
|
1509
|
+
document.addEventListener("focusin", onFocusIn, true);
|
|
1510
|
+
window.addEventListener("pagehide", onHide);
|
|
1511
|
+
document.addEventListener("visibilitychange", onHide);
|
|
1512
|
+
return function () {
|
|
1513
|
+
card.removeEventListener("pointerdown", onPointerDown, true);
|
|
1514
|
+
document.removeEventListener("focusin", onFocusIn, true);
|
|
1515
|
+
window.removeEventListener("pagehide", onHide);
|
|
1516
|
+
document.removeEventListener("visibilitychange", onHide);
|
|
1517
|
+
/* Leaving the surface (phase flip / session switch / unmount) must
|
|
1518
|
+
not lose what the user typed. */
|
|
1519
|
+
mirrorNow();
|
|
1520
|
+
};
|
|
1521
|
+
}, [active]);
|
|
1522
|
+
|
|
1523
|
+
/* Debug panel (?dsh-mobile-input=debug): the only read-out available on a
|
|
1524
|
+
phone with no console. The panel is a VIEWER of the diary — it never
|
|
1525
|
+
writes while a field has focus (the diary defers the paint), and it
|
|
1526
|
+
re-paints on demand, on the way out of the field, or when the keyboard
|
|
1527
|
+
state changes (the moment a report is actually about). */
|
|
1528
|
+
React.useEffect(function () {
|
|
1529
|
+
if (diary === null || !debugRequested() || benchRequested()) return undefined;
|
|
1530
|
+
var panel = document.createElement("div");
|
|
1531
|
+
panel.setAttribute("data-mobile-input-debug", "");
|
|
1532
|
+
var head = document.createElement("div");
|
|
1533
|
+
head.style.cssText = "display:flex;gap:6px;align-items:center;margin-bottom:4px";
|
|
1534
|
+
var stage = document.createElement("span");
|
|
1535
|
+
stage.textContent = "诊断日志";
|
|
1536
|
+
var refresh = document.createElement("button");
|
|
1537
|
+
refresh.type = "button";
|
|
1538
|
+
refresh.setAttribute("data-mobile-input-debug-button", "");
|
|
1539
|
+
refresh.textContent = "刷新";
|
|
1540
|
+
refresh.addEventListener("click", function () { diary.paint(true); });
|
|
1541
|
+
var copy = document.createElement("button");
|
|
1542
|
+
copy.type = "button";
|
|
1543
|
+
copy.setAttribute("data-mobile-input-debug-button", "");
|
|
1544
|
+
copy.textContent = "复制日志";
|
|
1545
|
+
copy.addEventListener("click", function () { copyDiary(diary, copy); });
|
|
1546
|
+
head.append(stage, refresh, copy);
|
|
1547
|
+
var body = document.createElement("pre");
|
|
1548
|
+
body.setAttribute("data-mobile-input-debug-body", "");
|
|
1549
|
+
panel.append(head, body);
|
|
1550
|
+
document.body.append(panel);
|
|
1551
|
+
diary.watch(body);
|
|
1552
|
+
debug("panel on; active=" + active, true);
|
|
1553
|
+
return function () {
|
|
1554
|
+
diary.unwatch(body);
|
|
1555
|
+
panel.remove();
|
|
1556
|
+
};
|
|
1557
|
+
}, []);
|
|
1558
|
+
|
|
1559
|
+
/* The seat stays mounted even while the stock editor owns the surface:
|
|
1560
|
+
it is how this component finds its card, and it keeps the takeover
|
|
1561
|
+
from remounting the subtree on every phase flip. */
|
|
1562
|
+
/* A tap on the seat itself (padding area) focuses the field too. */
|
|
1563
|
+
var onSeatPointerDown = function (event) {
|
|
1564
|
+
var area = areaRef.current;
|
|
1565
|
+
if (area === null || event.target === area) return;
|
|
1566
|
+
event.preventDefault();
|
|
1567
|
+
try {
|
|
1568
|
+
area.focus({ preventScroll: true });
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
area.focus();
|
|
1571
|
+
}
|
|
1572
|
+
};
|
|
1573
|
+
|
|
1574
|
+
return h(
|
|
1575
|
+
"div",
|
|
1576
|
+
{ ref: wrapRef, "data-mobile-input-wrap": "", onPointerDown: onSeatPointerDown },
|
|
1577
|
+
active
|
|
1578
|
+
? h("textarea", {
|
|
1579
|
+
ref: areaRef,
|
|
1580
|
+
"data-mobile-input": "",
|
|
1581
|
+
rows: 1,
|
|
1582
|
+
enterKeyHint: "send",
|
|
1583
|
+
autoCapitalize: "sentences",
|
|
1584
|
+
onInput: onInput,
|
|
1585
|
+
onFocus: onFocus,
|
|
1586
|
+
onBlur: onBlur,
|
|
1587
|
+
onKeyDown: onKeyDown,
|
|
1588
|
+
onPaste: onPaste,
|
|
1589
|
+
onCompositionStart: onCompositionStart,
|
|
1590
|
+
onCompositionEnd: onCompositionEnd,
|
|
1591
|
+
})
|
|
1592
|
+
: null,
|
|
1593
|
+
);
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Escape hatch in the composer tool row (narrow viewports only): one tap
|
|
1599
|
+
* swaps the native input back to the stock editor and remembers it, so a
|
|
1600
|
+
* device the takeover misbehaves on is never a dead end.
|
|
1601
|
+
* @returns the slot entry component.
|
|
1602
|
+
*/
|
|
1603
|
+
function createNativeInputToggle(diagnostics) {
|
|
1604
|
+
return function NativeInputToggle() {
|
|
1605
|
+
var narrow = useNarrowViewport();
|
|
1606
|
+
var state = React.useState(function () { return readOverride(); });
|
|
1607
|
+
var preference = state[0];
|
|
1608
|
+
var setPreference = state[1];
|
|
1609
|
+
var revealState = React.useState(function () { return readReveal(); });
|
|
1610
|
+
var revealed = revealState[0];
|
|
1611
|
+
var setRevealed = revealState[1];
|
|
1612
|
+
/* Long press on the escape hatch calls the diagnostics controls out (or
|
|
1613
|
+
puts them away); the click that ends the press must not also flip the
|
|
1614
|
+
input preference. */
|
|
1615
|
+
var pressTimer = React.useRef(0);
|
|
1616
|
+
var longPressed = React.useRef(false);
|
|
1617
|
+
var startPress = function () {
|
|
1618
|
+
longPressed.current = false;
|
|
1619
|
+
if (pressTimer.current !== 0) window.clearTimeout(pressTimer.current);
|
|
1620
|
+
pressTimer.current = window.setTimeout(function () {
|
|
1621
|
+
pressTimer.current = 0;
|
|
1622
|
+
longPressed.current = true;
|
|
1623
|
+
writeReveal(!readReveal());
|
|
1624
|
+
}, 600);
|
|
1625
|
+
};
|
|
1626
|
+
var endPress = function () {
|
|
1627
|
+
if (pressTimer.current !== 0) {
|
|
1628
|
+
window.clearTimeout(pressTimer.current);
|
|
1629
|
+
pressTimer.current = 0;
|
|
1630
|
+
}
|
|
1631
|
+
};
|
|
1632
|
+
React.useEffect(function () {
|
|
1633
|
+
var update = function () {
|
|
1634
|
+
setPreference(readOverride());
|
|
1635
|
+
setRevealed(readReveal());
|
|
1636
|
+
};
|
|
1637
|
+
window.addEventListener(PREFERENCE_EVENT, update);
|
|
1638
|
+
window.addEventListener(REVEAL_EVENT, update);
|
|
1639
|
+
window.addEventListener("storage", update);
|
|
1640
|
+
return function () {
|
|
1641
|
+
window.removeEventListener(PREFERENCE_EVENT, update);
|
|
1642
|
+
window.removeEventListener(REVEAL_EVENT, update);
|
|
1643
|
+
window.removeEventListener("storage", update);
|
|
1644
|
+
endPress();
|
|
1645
|
+
};
|
|
1646
|
+
}, []);
|
|
1647
|
+
if (!narrow) return null;
|
|
1648
|
+
var on = preference !== "off";
|
|
1649
|
+
var label = (on ? "原生输入框:已开启(点击改用官方输入框)" : "原生输入框:已关闭(点击启用)")
|
|
1650
|
+
+ ";长按显示/隐藏诊断按钮";
|
|
1651
|
+
var diagnosing = diagnosticsModes().length !== 0;
|
|
1652
|
+
var diagnosisLabel = diagnosing
|
|
1653
|
+
? "诊断面板:已开启(点击关闭并刷新)"
|
|
1654
|
+
: "诊断面板:点击开启(测试台 + 事件日志,页面会刷新)";
|
|
1655
|
+
/* Two chips: the escape hatch, and the diagnostics switch. The latter
|
|
1656
|
+
is here because a tokenized URL is rewritten by the shell before
|
|
1657
|
+
plugins load, so a phone cannot always pass ?dsh-mobile-input=... */
|
|
1658
|
+
return h(
|
|
1659
|
+
React.Fragment,
|
|
1660
|
+
null,
|
|
1661
|
+
h(
|
|
1662
|
+
"button",
|
|
1663
|
+
{
|
|
1664
|
+
type: "button",
|
|
1665
|
+
"data-mobile-input-toggle": "",
|
|
1666
|
+
"data-state": on ? "on" : "off",
|
|
1667
|
+
title: label,
|
|
1668
|
+
"aria-label": label,
|
|
1669
|
+
onMouseDown: function (event) { event.preventDefault(); },
|
|
1670
|
+
onPointerDown: startPress,
|
|
1671
|
+
onPointerUp: endPress,
|
|
1672
|
+
onPointerCancel: endPress,
|
|
1673
|
+
onPointerLeave: endPress,
|
|
1674
|
+
onClick: function () {
|
|
1675
|
+
/* A long press already did its job: swallow the click. */
|
|
1676
|
+
if (longPressed.current === true) {
|
|
1677
|
+
longPressed.current = false;
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
writePreference(on ? "off" : "on");
|
|
1681
|
+
},
|
|
1682
|
+
},
|
|
1683
|
+
on ? "输入法✓" : "输入法✗",
|
|
1684
|
+
),
|
|
1685
|
+
diagnostics !== null
|
|
1686
|
+
? h(
|
|
1687
|
+
"button",
|
|
1688
|
+
{
|
|
1689
|
+
type: "button",
|
|
1690
|
+
"data-mobile-input-copy": "",
|
|
1691
|
+
title: "复制诊断日志(与面板里的同一份)",
|
|
1692
|
+
"aria-label": "复制诊断日志",
|
|
1693
|
+
onMouseDown: function (event) { event.preventDefault(); },
|
|
1694
|
+
onClick: function (event) { copyDiary(diagnostics, event.currentTarget); },
|
|
1695
|
+
},
|
|
1696
|
+
"复制日志",
|
|
1697
|
+
)
|
|
1698
|
+
: null,
|
|
1699
|
+
revealed || diagnosing
|
|
1700
|
+
? h(
|
|
1701
|
+
"button",
|
|
1702
|
+
{
|
|
1703
|
+
type: "button",
|
|
1704
|
+
"data-mobile-input-diagnostics": "",
|
|
1705
|
+
"data-state": diagnosing ? "on" : "off",
|
|
1706
|
+
title: diagnosisLabel,
|
|
1707
|
+
"aria-label": diagnosisLabel,
|
|
1708
|
+
onMouseDown: function (event) { event.preventDefault(); },
|
|
1709
|
+
onClick: function () {
|
|
1710
|
+
writeDiagnostics(diagnosing ? "off" : "bench");
|
|
1711
|
+
try {
|
|
1712
|
+
window.location.reload();
|
|
1713
|
+
} catch (error) { /* the next load picks the switch up anyway */ }
|
|
1714
|
+
},
|
|
1715
|
+
},
|
|
1716
|
+
diagnosing ? "诊断✓" : "诊断",
|
|
1717
|
+
)
|
|
1718
|
+
: null,
|
|
1719
|
+
);
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* Device test bench (?dsh-mobile-input=bench).
|
|
1725
|
+
*
|
|
1726
|
+
* Chromium cannot reproduce ArkWeb's IME and a phone has no console, so
|
|
1727
|
+
* when the keyboard still drops while typing, the only way to tell WHICH
|
|
1728
|
+
* ingredient does it is to run the candidates side by side on the device
|
|
1729
|
+
* itself. Each variant is a real field the user types into:
|
|
1730
|
+
*
|
|
1731
|
+
* A bare textarea (normal flow, no JS writes) — baseline
|
|
1732
|
+
* B textarea inside a height:0 absolute container — the seat shape
|
|
1733
|
+
* C B + a style write on every keystroke — the pre-0.6 autosize
|
|
1734
|
+
* D B + logging only, nothing written — the 0.6 behaviour
|
|
1735
|
+
* E bare textarea inside an iframe — a clean document
|
|
1736
|
+
*
|
|
1737
|
+
* The diary records focus/blur/input per field AND every keyboard (window
|
|
1738
|
+
* height) transition, so the answer reads off as a timeline: whichever
|
|
1739
|
+
* variant is followed by a KEYBOARD line is the guilty one. The log lives
|
|
1740
|
+
* in its OWN fixed panel, so repainting it can never reflow a field under
|
|
1741
|
+
* test (fixed boxes are out of flow).
|
|
1742
|
+
* @param diary - the diagnostics ring buffer created by apply().
|
|
1743
|
+
* @returns a disposer for ctx.effect.
|
|
1744
|
+
*/
|
|
1745
|
+
function installBench(diary) {
|
|
1746
|
+
var panel = document.createElement("div");
|
|
1747
|
+
panel.setAttribute("data-mobile-input-bench", "");
|
|
1748
|
+
var log = document.createElement("pre");
|
|
1749
|
+
log.setAttribute("data-mobile-input-log", "");
|
|
1750
|
+
|
|
1751
|
+
var title = document.createElement("div");
|
|
1752
|
+
title.style.cssText = "font-size:11px;line-height:1.35";
|
|
1753
|
+
title.textContent = "诊断中:本面板只占屏幕顶部,下方真实输入框可直接打字。日志在打字期间不刷新,"
|
|
1754
|
+
+ "失焦或键盘开合时更新;收起来时点「复制日志」发给 AI。";
|
|
1755
|
+
|
|
1756
|
+
var row = document.createElement("div");
|
|
1757
|
+
row.style.cssText = "display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin:4px 0";
|
|
1758
|
+
|
|
1759
|
+
var copy = document.createElement("button");
|
|
1760
|
+
copy.type = "button";
|
|
1761
|
+
copy.textContent = "复制日志";
|
|
1762
|
+
copy.addEventListener("click", function () { copyDiary(diary, copy); });
|
|
1763
|
+
row.append(copy);
|
|
1764
|
+
|
|
1765
|
+
/* Growth-policy switches: A/B the PRODUCTION field (below in the real
|
|
1766
|
+
composer) without editing the URL on a phone keyboard. */
|
|
1767
|
+
var modes = [
|
|
1768
|
+
["commit", "生产:提交点增高"],
|
|
1769
|
+
["live", "生产:逐键增高"],
|
|
1770
|
+
["none", "生产:固定高度"],
|
|
1771
|
+
];
|
|
1772
|
+
var modeButtons = [];
|
|
1773
|
+
var paintModes = function () {
|
|
1774
|
+
var current = readGrowthMode();
|
|
1775
|
+
for (var i = 0; i < modeButtons.length; i += 1) {
|
|
1776
|
+
modeButtons[i].el.textContent = modeButtons[i].label + (current === modeButtons[i].mode ? " ✓" : "");
|
|
1777
|
+
}
|
|
1778
|
+
};
|
|
1779
|
+
for (var i = 0; i < modes.length; i += 1) {
|
|
1780
|
+
var makeMode = function (mode, label) {
|
|
1781
|
+
var button = document.createElement("button");
|
|
1782
|
+
button.type = "button";
|
|
1783
|
+
button.textContent = label;
|
|
1784
|
+
button.setAttribute("data-mobile-input-growth", mode);
|
|
1785
|
+
button.addEventListener("click", function () {
|
|
1786
|
+
writeGrowthMode(mode);
|
|
1787
|
+
paintModes();
|
|
1788
|
+
diary.add("growth=" + mode);
|
|
1789
|
+
});
|
|
1790
|
+
modeButtons.push({ el: button, mode: mode, label: label });
|
|
1791
|
+
return button;
|
|
1792
|
+
};
|
|
1793
|
+
row.append(makeMode(modes[i][0], modes[i][1]));
|
|
1794
|
+
}
|
|
1795
|
+
paintModes();
|
|
1796
|
+
|
|
1797
|
+
/* The variant fields are a SECOND step: the decisive test is the real
|
|
1798
|
+
composer field below this panel, so the variants start collapsed. */
|
|
1799
|
+
var variantsBox = document.createElement("div");
|
|
1800
|
+
variantsBox.setAttribute("data-mobile-input-bench-variants", "");
|
|
1801
|
+
variantsBox.hidden = true;
|
|
1802
|
+
var variantsToggle = document.createElement("button");
|
|
1803
|
+
variantsToggle.type = "button";
|
|
1804
|
+
variantsToggle.setAttribute("data-mobile-input-bench-variants-toggle", "");
|
|
1805
|
+
variantsToggle.textContent = "展开变体 A-E";
|
|
1806
|
+
variantsToggle.addEventListener("click", function () {
|
|
1807
|
+
variantsBox.hidden = !variantsBox.hidden;
|
|
1808
|
+
variantsToggle.textContent = variantsBox.hidden ? "展开变体 A-E" : "收起变体";
|
|
1809
|
+
});
|
|
1810
|
+
row.append(variantsToggle);
|
|
1811
|
+
|
|
1812
|
+
/* Trigger-sync switches: the A/B for the slash-command keyboard drop. */
|
|
1813
|
+
var triggerModes = [
|
|
1814
|
+
["resync", "触发:重聚焦"],
|
|
1815
|
+
["changes", "触发:仅触发"],
|
|
1816
|
+
["live", "触发:逐键"],
|
|
1817
|
+
["off", "触发:关"],
|
|
1818
|
+
];
|
|
1819
|
+
var triggerButtons = [];
|
|
1820
|
+
var paintTriggers = function () {
|
|
1821
|
+
var current = readTriggerMode();
|
|
1822
|
+
for (var i = 0; i < triggerButtons.length; i += 1) {
|
|
1823
|
+
triggerButtons[i].el.textContent = triggerButtons[i].label
|
|
1824
|
+
+ (current === triggerButtons[i].mode ? " ✓" : "");
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
for (var j = 0; j < triggerModes.length; j += 1) {
|
|
1828
|
+
var makeTrigger = function (mode, label) {
|
|
1829
|
+
var button = document.createElement("button");
|
|
1830
|
+
button.type = "button";
|
|
1831
|
+
button.textContent = label;
|
|
1832
|
+
button.setAttribute("data-mobile-input-trigger", mode);
|
|
1833
|
+
button.addEventListener("click", function () {
|
|
1834
|
+
writeTriggerMode(mode);
|
|
1835
|
+
paintTriggers();
|
|
1836
|
+
diary.add("trigger=" + mode);
|
|
1837
|
+
});
|
|
1838
|
+
triggerButtons.push({ el: button, mode: mode, label: label });
|
|
1839
|
+
return button;
|
|
1840
|
+
};
|
|
1841
|
+
row.append(makeTrigger(triggerModes[j][0], triggerModes[j][1]));
|
|
1842
|
+
}
|
|
1843
|
+
paintTriggers();
|
|
1844
|
+
|
|
1845
|
+
var close = document.createElement("button");
|
|
1846
|
+
close.type = "button";
|
|
1847
|
+
close.textContent = "关闭测试台";
|
|
1848
|
+
close.addEventListener("click", function () {
|
|
1849
|
+
diary.unwatch(log);
|
|
1850
|
+
panel.remove();
|
|
1851
|
+
log.remove();
|
|
1852
|
+
/* The request is persisted, so turning the panels off has to clear it
|
|
1853
|
+
too — otherwise the next load brings them straight back. */
|
|
1854
|
+
writeDiagnostics("off");
|
|
1855
|
+
try { window.location.reload(); } catch (error) { /* panels already gone */ }
|
|
1856
|
+
});
|
|
1857
|
+
row.append(close);
|
|
1858
|
+
|
|
1859
|
+
panel.append(title, row);
|
|
1860
|
+
|
|
1861
|
+
var instrument = function (area, id, live) {
|
|
1862
|
+
area.addEventListener("focus", function () { diary.add(id + " focus"); });
|
|
1863
|
+
area.addEventListener("blur", function () { diary.add(id + " blur", true); });
|
|
1864
|
+
area.addEventListener("input", function (event) {
|
|
1865
|
+
var el = event.currentTarget;
|
|
1866
|
+
diary.add(id + " input " + el.value.length + " chars");
|
|
1867
|
+
if (live === true) {
|
|
1868
|
+
/* The pre-0.6 autosize probe, verbatim: collapse to zero, read the
|
|
1869
|
+
content height, write the height back. */
|
|
1870
|
+
var previous = el.style.height;
|
|
1871
|
+
el.style.height = "0px";
|
|
1872
|
+
void el.scrollHeight;
|
|
1873
|
+
el.style.height = previous;
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
area.addEventListener("compositionstart", function () { diary.add(id + " compositionstart"); });
|
|
1877
|
+
area.addEventListener("compositionend", function () { diary.add(id + " compositionend", true); });
|
|
1878
|
+
};
|
|
1879
|
+
|
|
1880
|
+
var caption = function (text) {
|
|
1881
|
+
var label = document.createElement("label");
|
|
1882
|
+
label.textContent = text;
|
|
1883
|
+
variantsBox.append(label);
|
|
1884
|
+
};
|
|
1885
|
+
|
|
1886
|
+
var field = function (id, text, zero, live) {
|
|
1887
|
+
caption(id + " " + text);
|
|
1888
|
+
var holder = document.createElement("div");
|
|
1889
|
+
holder.setAttribute("data-mobile-input-bench-variant", "");
|
|
1890
|
+
var area = document.createElement("textarea");
|
|
1891
|
+
if (zero === true) {
|
|
1892
|
+
/* The real seat's shape, replicated: card(position:relative) >
|
|
1893
|
+
anchor(position:absolute, height:0) > seat(absolute, explicit box)
|
|
1894
|
+
> textarea. */
|
|
1895
|
+
holder.style.cssText = "position:relative;height:38px";
|
|
1896
|
+
var anchor = document.createElement("div");
|
|
1897
|
+
anchor.style.cssText = "position:absolute;left:0;top:0;right:0;height:0";
|
|
1898
|
+
area.style.cssText = "position:absolute;left:0;top:0;width:100%;height:36px";
|
|
1899
|
+
anchor.append(area);
|
|
1900
|
+
holder.append(anchor);
|
|
1901
|
+
} else {
|
|
1902
|
+
holder.append(area);
|
|
1903
|
+
}
|
|
1904
|
+
area.setAttribute("data-mobile-input-bench-field", "");
|
|
1905
|
+
area.setAttribute("data-mobile-input-bench-id", id);
|
|
1906
|
+
area.setAttribute("rows", "1");
|
|
1907
|
+
area.setAttribute("placeholder", text);
|
|
1908
|
+
variantsBox.append(holder);
|
|
1909
|
+
instrument(area, id, live === true);
|
|
1910
|
+
return area;
|
|
1911
|
+
};
|
|
1912
|
+
|
|
1913
|
+
field("A", "裸 textarea(普通流,零 JS 写入)", false, false);
|
|
1914
|
+
field("B", "零高容器内的 textarea(复刻插件结构,零 JS 写入)", true, false);
|
|
1915
|
+
field("C", "同 B + 每次按键都写高度(v0.5.2 的逐键 autosize)", true, true);
|
|
1916
|
+
field("D", "同 B + 只记录、不写任何 DOM(v0.6 行为)", true, false);
|
|
1917
|
+
|
|
1918
|
+
var frame = document.createElement("iframe");
|
|
1919
|
+
frame.setAttribute("data-mobile-input-bench-frame", "");
|
|
1920
|
+
frame.style.cssText = "display:block;box-sizing:border-box;width:100%;height:56px;margin-top:6px;border:1px solid #ccc;border-radius:6px;background:#fff";
|
|
1921
|
+
caption("E 隔离文档(iframe)里的裸 textarea");
|
|
1922
|
+
/* Same-origin (srcdoc inherits the parent origin), so the parent wires the
|
|
1923
|
+
instrument to the inner field directly. */
|
|
1924
|
+
frame.setAttribute("srcdoc", "<!doctype html><html><head>"
|
|
1925
|
+
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
|
|
1926
|
+
+ "<style>body{margin:4px}textarea{display:block;box-sizing:border-box;width:100%;height:36px;"
|
|
1927
|
+
+ "font:13px/20px sans-serif;padding:6px 8px;border:1px solid #ccc;border-radius:6px}</style></head>"
|
|
1928
|
+
+ "<body><textarea rows=\"1\" placeholder=\"E 隔离文档里的裸 textarea\"></textarea></body></html>");
|
|
1929
|
+
frame.addEventListener("load", function () {
|
|
1930
|
+
var doc = frame.contentDocument;
|
|
1931
|
+
if (doc === null || doc === undefined) return;
|
|
1932
|
+
var area = doc.querySelector("textarea");
|
|
1933
|
+
if (area !== null && area !== undefined) instrument(area, "E", false);
|
|
1934
|
+
});
|
|
1935
|
+
variantsBox.append(frame);
|
|
1936
|
+
panel.append(variantsBox);
|
|
1937
|
+
|
|
1938
|
+
document.body.append(panel, log);
|
|
1939
|
+
/* A single panel: instructions + buttons + log + (collapsed) variants. */
|
|
1940
|
+
panel.append(log);
|
|
1941
|
+
diary.watch(log);
|
|
1942
|
+
diary.add("bench on; 直接在下方真实输入框里打字即可(变体在「展开变体 A-E」里)", true);
|
|
1943
|
+
return function () {
|
|
1944
|
+
diary.unwatch(log);
|
|
1945
|
+
panel.remove();
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
|
|
168
1949
|
exports.apply = apply;
|
|
1950
|
+
/* The takeover registers slot entries, so the plugin waits for the slot
|
|
1951
|
+
service (Cordis re-applies it once the service appears) — the same
|
|
1952
|
+
declaration shipped composer-contributing plugins use. Everything else
|
|
1953
|
+
(the session face, the seat) is resolved defensively at runtime, so a
|
|
1954
|
+
shell missing them keeps the stock composer instead of failing the
|
|
1955
|
+
plugin. */
|
|
1956
|
+
exports.inject = ["slots"];
|
|
169
1957
|
return module.exports;
|
|
170
1958
|
}
|
|
171
1959
|
});
|