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.
Files changed (4) hide show
  1. package/README.md +183 -8
  2. package/README.zh.md +144 -7
  3. package/lib/client.js +1803 -15
  4. 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), never CSS-Modules-hashed class names; all hooks verified
18
- against the 0.1.2-rc.1 shipped bundles. */
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
- " /* 6) Drawer semantics: while the sidebar is expanded on a narrow",
87
- " viewport the stock grid keeps the center column rendered and",
88
- " interactive in a squeezed ~86px strip beside it. Taps aimed at",
89
- " long workspace titles graze that strip, land on the input box,",
90
- " pop the IME, and the viewport collapse reads as the sidebar",
91
- " vanishing. The frame drops data-sidebar-collapsed while wide, so",
92
- " :not(:has()) marks the expanded state; disable pointer events on",
93
- " the strip for real drawer behavior. _centerCol is unique to",
94
- " ui-layout (verified against shipped bundles). */",
95
- " body:not(:has([data-sidebar-collapsed])) [class*='_centerCol'] {",
96
- " pointer-events: none !important;",
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
  });