dsh-mobile-flow 0.4.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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) {",
@@ -86,10 +90,185 @@ window.__ModuleLoader__.load({
86
90
  "}",
87
91
  ].join("\n");
88
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;",
265
+ "}",
266
+ ].join("\n");
267
+
89
268
  function apply(ctx) {
90
269
  var tag = document.createElement("style");
91
270
  tag.dataset.plugin = "dsh-mobile-flow";
92
- tag.textContent = CSS;
271
+ tag.textContent = CSS + "\n" + INPUT_CSS;
93
272
  document.head.append(tag);
94
273
 
95
274
  ctx.effect(function () {
@@ -101,6 +280,60 @@ window.__ModuleLoader__.load({
101
280
  ctx.effect(function () {
102
281
  return installNoAutoFocus();
103
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)));
104
337
  }
105
338
 
106
339
  /* 5) No auto-focus on session switch (narrow viewports): InputBar's
@@ -153,7 +386,1675 @@ window.__ModuleLoader__.load({
153
386
  };
154
387
  }
155
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
+ /**
727
+ * Whether the product's composer on this card is a LIVE TEXT INPUT.
728
+ *
729
+ * The resident composer card is reused for states that are not inputs at
730
+ * all: a blank-session hero whose workspace is not resolved yet renders the
731
+ * same surface as a workspace-PICKER trigger (tapping it opens the picker,
732
+ * `editor={null}`, `contenteditable="false"`), and a blocked/removed/offline
733
+ * session renders it disabled. The takeover must not own those surfaces: a
734
+ * read-only field that swallows the card's own tap is worse than no
735
+ * takeover — the user cannot type AND cannot open the picker.
736
+ *
737
+ * Two product-owned attributes carry that fact and the takeover never
738
+ * writes either (see the "no workspace yet" note on the editability refs):
739
+ * - `aria-haspopup="menu"` — the workspace-picker trigger state
740
+ * - `aria-disabled="true"` — the locked states (blocked composer, removed
741
+ * session, offline parent)
742
+ * `contenteditable` is deliberately NOT consulted here: the takeover forces
743
+ * it to "false" while it owns the surface, so reading it once at mount and
744
+ * latching the answer is what left a phone with a permanently read-only
745
+ * field (v0.7.2 phone report: "new session, tapping the input box does
746
+ * nothing").
747
+ *
748
+ * @param card - the resident composer card, or null.
749
+ * @returns true when the card currently hosts a real text input.
750
+ */
751
+ function composerIsTextInput(card) {
752
+ if (card === null || typeof card.querySelector !== "function") return false;
753
+ var editor = card.querySelector("[data-composer-input]");
754
+ if (editor === null) return false;
755
+ if (editor.getAttribute("aria-haspopup") === "menu") return false;
756
+ if (editor.getAttribute("aria-disabled") === "true") return false;
757
+ return true;
758
+ }
759
+
760
+ /** Whether the takeover should own the surface on this viewport. */
761
+ function takeoverLive(media) {
762
+ var override = readOverride();
763
+ if (override === "on" || override === "force") return true;
764
+ if (override === "off") return false;
765
+ return media !== null && media.matches === true;
766
+ }
767
+
768
+ /** Narrow viewport only (no preference): the toggle's own visibility gate. */
769
+ function useNarrowViewport() {
770
+ var media = React.useMemo(function () {
771
+ try {
772
+ return window.matchMedia(NARROW_QUERY);
773
+ } catch (error) {
774
+ return null;
775
+ }
776
+ }, []);
777
+ var state = React.useState(function () { return media !== null && media.matches === true; });
778
+ var narrow = state[0];
779
+ var setNarrow = state[1];
780
+ React.useEffect(function () {
781
+ if (media === null) return undefined;
782
+ var update = function () { setNarrow(media.matches === true); };
783
+ update();
784
+ if (typeof media.addEventListener === "function") media.addEventListener("change", update);
785
+ else if (typeof media.addListener === "function") media.addListener(update);
786
+ return function () {
787
+ if (typeof media.removeEventListener === "function") media.removeEventListener("change", update);
788
+ else if (typeof media.removeListener === "function") media.removeListener(update);
789
+ };
790
+ }, [media]);
791
+ return narrow;
792
+ }
793
+
794
+ /** Narrow-viewport flag, live across rotation, resize and override edits. */
795
+ function useTakeoverViewport() {
796
+ var media = React.useMemo(function () {
797
+ try {
798
+ return window.matchMedia(NARROW_QUERY);
799
+ } catch (error) {
800
+ return null;
801
+ }
802
+ }, []);
803
+ var state = React.useState(function () { return takeoverLive(media); });
804
+ var live = state[0];
805
+ var setLive = state[1];
806
+ React.useEffect(function () {
807
+ if (media === null) return undefined;
808
+ var update = function () { setLive(takeoverLive(media)); };
809
+ update();
810
+ if (typeof media.addEventListener === "function") media.addEventListener("change", update);
811
+ else if (typeof media.addListener === "function") media.addListener(update);
812
+ var onStorage = function () { update(); };
813
+ window.addEventListener(PREFERENCE_EVENT, onStorage);
814
+ window.addEventListener("storage", onStorage);
815
+ return function () {
816
+ if (typeof media.removeEventListener === "function") media.removeEventListener("change", update);
817
+ else if (typeof media.removeListener === "function") media.removeListener(update);
818
+ window.removeEventListener(PREFERENCE_EVENT, onStorage);
819
+ window.removeEventListener("storage", onStorage);
820
+ };
821
+ }, [media]);
822
+ return live;
823
+ }
824
+
825
+ /**
826
+ * Resolve the session's public input face — the same machine the composer
827
+ * bar drives. Its `state` store is what lets the mirror read the LIVE
828
+ * draft (a render-time value could be stale and clobber fast typing).
829
+ * `sessions.scope(id).get('conversation')` is the documented third-party
830
+ * route (dsh-genui inserts composer templates through it).
831
+ */
832
+ function resolveInputFace(ctx, sessionId) {
833
+ if (sessionId === undefined || sessionId === null) return undefined;
834
+ try {
835
+ /* ctx.get, never ctx.sessions: a Cordis context throws on an
836
+ undeclared service property, and this plugin only injects `slots`. */
837
+ var sessions = typeof ctx.get === "function" ? ctx.get("sessions") : undefined;
838
+ if (sessions === undefined || sessions === null || typeof sessions.scope !== "function") return undefined;
839
+ var scoped = sessions.scope(sessionId);
840
+ if (scoped === undefined || scoped === null) return undefined;
841
+ var conversation = scoped.get("conversation");
842
+ if (conversation === undefined || conversation === null) return undefined;
843
+ var resolver = conversation.input;
844
+ if (resolver === undefined || typeof resolver.for !== "function") return undefined;
845
+ var face = resolver.for(scoped);
846
+ return face === undefined || face === null ? undefined : face;
847
+ } catch (error) {
848
+ return undefined;
849
+ }
850
+ }
851
+
852
+ /**
853
+ * Bind the takeover component to the client context. The component runs in
854
+ * the browser plugin's fiber, so the session input face is resolved through
855
+ * the context captured here (a module-level component has no `ctx`).
856
+ * @param ctx - the client root context that applied this plugin.
857
+ * @param diary - the diagnostics ring buffer, or null when no diagnostics
858
+ * surface was requested for this page load.
859
+ * @returns the slot entry component.
860
+ */
861
+ function createNativeInput(ctx, diary) {
862
+ return function NativeInput(props) {
863
+ var live = useTakeoverViewport();
864
+ var useInput = props.useInput;
865
+ var input = typeof useInput === "function" ? useInput(function (s) { return s; }) : undefined;
866
+ var sessionId = props.sessionId;
867
+ var actions = props.inputActions;
868
+ var phase = input === undefined ? "plain" : input.phase;
869
+ /* Whether the product's composer is a real text input right now. Tracked
870
+ live (see the mount effect below), never latched: a new session is
871
+ routinely created before its workspace resolves, and that hero composer
872
+ is a workspace-picker trigger, not an input. */
873
+ var readyPair = React.useState(false);
874
+ var composerReady = readyPair[0];
875
+ var setComposerReady = readyPair[1];
876
+ /* Ownership is surface-level only: claims fall back to the stock editor
877
+ (see the section comment). */
878
+ var active = live && composerReady && phase === "plain" && sessionId !== undefined;
879
+
880
+ var wrapRef = React.useRef(null);
881
+ var areaRef = React.useRef(null);
882
+ var composingRef = React.useRef(false);
883
+ /* Trigger characters present when the current IME composition started. */
884
+ var compositionTriggersRef = React.useRef(0);
885
+ /* True while the takeover itself is dropping/re-taking focus (see
886
+ `resync`): the blur in between is plumbing, not a commit point. */
887
+ var resyncingRef = React.useRef(false);
888
+ var draftRef = React.useRef("");
889
+ var faceRef = React.useRef(undefined);
890
+ /* The last value this component itself pushed into the machine: an
891
+ incoming draft equal to it is our own echo, never an external write. */
892
+ var pushedRef = React.useRef(null);
893
+ /* Latest field value not yet mirrored into the machine (see the section
894
+ comment: the mirror is deferred to commit points). */
895
+ var pendingRef = React.useRef(null);
896
+ /* Whether the takeover currently owns the surface (read by handlers that
897
+ outlive a render). */
898
+ var activeRef = React.useRef(false);
899
+ /* The product's editability intent for the composer, kept across takeover
900
+ releases. The field mirrors THIS, never our own override: the takeover
901
+ forces contenteditable="false" while it owns the surface, so the
902
+ attribute cannot be read back as the product's answer. `forcedRef` marks
903
+ a "false" that is ours. (A latched read-only field used to survive the
904
+ composer becoming editable again — one phone report: a new session whose
905
+ workspace was not resolved yet left the input dead until the next
906
+ session switch.) */
907
+ var appEditableRef = React.useRef(true);
908
+ /* True while the "false" standing on the product's editor is OUR force. */
909
+ var forcedRef = React.useRef(false);
910
+ /* Last geometry applied: style writes are skipped when nothing moved, so
911
+ a strict engine's IME is not disturbed by pointless relayouts. */
912
+ var geometryRef = React.useRef("");
913
+
914
+ draftRef.current = input === undefined ? "" : input.draft;
915
+
916
+ var face = React.useMemo(
917
+ function () { return resolveInputFace(ctx, sessionId); },
918
+ [sessionId],
919
+ );
920
+ faceRef.current = face;
921
+
922
+ var cardOf = function () {
923
+ var wrap = wrapRef.current;
924
+ return wrap === null ? null : wrap.closest("[data-composer-card]");
925
+ };
926
+
927
+ /* Is the product's composer a text input right now? Re-answered on every
928
+ product-side change of the two attributes that decide it, whether the
929
+ takeover is active or not — that is what lets the surface be handed
930
+ back when a new session opens without a resolved workspace, and taken
931
+ over again the moment the composer becomes an input. */
932
+ React.useEffect(function () {
933
+ var card = cardOf();
934
+ var update = function () {
935
+ var next = composerIsTextInput(cardOf());
936
+ setComposerReady(function (previous) {
937
+ if (previous !== next) {
938
+ debug("composer " + (next ? "is a text input -> takeover may own it"
939
+ : "is NOT a text input (picker/locked) -> stock surface stays"));
940
+ }
941
+ return next;
942
+ });
943
+ };
944
+ update();
945
+ if (card === null || typeof window.MutationObserver !== "function") return undefined;
946
+ var observer = new window.MutationObserver(update);
947
+ observer.observe(card, {
948
+ attributes: true,
949
+ attributeFilter: ["aria-haspopup", "aria-disabled"],
950
+ subtree: true,
951
+ });
952
+ return function () { observer.disconnect(); };
953
+ }, []);
954
+
955
+ /* The one invariant this whole file is built around (see the section
956
+ comment): while the field has focus the plugin writes NOTHING to the
957
+ DOM. Every measurement, resize and chrome sync below starts with this
958
+ guard, so no reachable path — input event, ResizeObserver, rAF pass,
959
+ MutationObserver — can churn the DOM under a live IME. */
960
+ var hasFocus = function () {
961
+ return document.activeElement === areaRef.current;
962
+ };
963
+
964
+ /** Growth policy for this mount (re-read on every preference change). */
965
+ var growthRef = React.useRef(readGrowthMode());
966
+ /** Trigger-sync policy for this mount (see readTriggerMode). */
967
+ var triggerRef = React.useRef(readTriggerMode());
968
+ /** Last value seen by onInput: the trigger delta is what decides a mirror. */
969
+ var lastValueRef = React.useRef("");
970
+ React.useEffect(function () {
971
+ var update = function () {
972
+ growthRef.current = readGrowthMode();
973
+ triggerRef.current = readTriggerMode();
974
+ };
975
+ window.addEventListener(GROWTH_EVENT, update);
976
+ window.addEventListener(TRIGGER_EVENT, update);
977
+ window.addEventListener("storage", update);
978
+ return function () {
979
+ window.removeEventListener(GROWTH_EVENT, update);
980
+ window.removeEventListener(TRIGGER_EVENT, update);
981
+ window.removeEventListener("storage", update);
982
+ };
983
+ }, []);
984
+
985
+ /** Diagnostics, prefixed so a log mixing bench variants with the real
986
+ composer field stays readable. */
987
+ var debug = function (line, force) {
988
+ if (diary === null) return;
989
+ diary.add("composer " + line, force === true);
990
+ };
991
+
992
+ /** Publish the stock scrollport's box onto our seat (idempotent).
993
+ Refuses to run while the field has focus: moving or resizing the
994
+ focused editable is what closes the keyboard on ArkWeb. */
995
+ var measure = function (force) {
996
+ var wrap = wrapRef.current;
997
+ if (wrap === null) return;
998
+ if (hasFocus() && force !== true) return;
999
+ var card = cardOf();
1000
+ var scroll = card === null ? null : card.querySelector("[data-input-scroll]");
1001
+ if (card === null || scroll === null) return;
1002
+ var cardRect = card.getBoundingClientRect();
1003
+ var scrollRect = scroll.getBoundingClientRect();
1004
+ /* Whole pixels: sub-pixel jitter would otherwise re-write styles (and
1005
+ invalidate layout) on every measurement. */
1006
+ var left = Math.round(scrollRect.left - cardRect.left - card.clientLeft);
1007
+ var top = Math.round(scrollRect.top - cardRect.top - card.clientTop);
1008
+ var width = Math.round(scroll.clientWidth);
1009
+ var height = Math.round(scroll.clientHeight);
1010
+ var next = [left, top, width, height].join(":");
1011
+ if (next === geometryRef.current) return;
1012
+ geometryRef.current = next;
1013
+ wrap.style.left = left + "px";
1014
+ wrap.style.top = top + "px";
1015
+ wrap.style.width = width + "px";
1016
+ wrap.style.height = height + "px";
1017
+ if (scroll.scrollTop !== 0) scroll.scrollTop = 0;
1018
+ debug("measure " + next);
1019
+ };
1020
+
1021
+ /** Grow the field to its own content and keep the card tall enough.
1022
+ Runs at commit points only (see the section comment); the measurement
1023
+ is non-destructive — the old probe collapsed the field to
1024
+ `height: 0px` and back, which on ArkWeb is indistinguishable from the
1025
+ editable disappearing. */
1026
+ var autosize = function (force) {
1027
+ var area = areaRef.current;
1028
+ if (area === null) return;
1029
+ if (hasFocus() && force !== true && growthRef.current !== "live") return;
1030
+ var card = cardOf();
1031
+ var scroll = card === null ? null : card.querySelector("[data-input-scroll]");
1032
+ var grow = scroll === null ? null : scroll.firstElementChild;
1033
+ var cap = 0;
1034
+ if (scroll !== null) {
1035
+ var raw = window.getComputedStyle(scroll).maxHeight;
1036
+ var parsed = parseFloat(raw);
1037
+ if (isFinite(parsed) && parsed > 0) cap = parsed;
1038
+ }
1039
+ /* The product's own floor for this surface (36px docked, 52px in the
1040
+ blank-session hero) — read it off the stock editor rather than
1041
+ guessing. */
1042
+ var floor = 36;
1043
+ var stock = scroll === null ? null : card.querySelector("[data-composer-input]");
1044
+ if (stock !== null) {
1045
+ var rawFloor = parseFloat(window.getComputedStyle(stock).minHeight);
1046
+ if (isFinite(rawFloor) && rawFloor > 0) floor = rawFloor;
1047
+ }
1048
+ var previous = area.style.height;
1049
+ area.style.height = "auto";
1050
+ var needed = area.scrollHeight;
1051
+ area.style.height = previous;
1052
+ if (needed < floor) needed = floor;
1053
+ if (cap > 0 && needed > cap) needed = cap;
1054
+ /* `none`: the field is pinned to the product's own floor — it never
1055
+ grows, but it still shrinks back after a send. */
1056
+ if (growthRef.current === "none") needed = floor;
1057
+ var box = Math.round(needed) + "px";
1058
+ if (area.style.height !== box) area.style.height = box;
1059
+ /* Floor on the stock row's box: the absolutely positioned seat cannot
1060
+ make the card grow by itself. */
1061
+ if (grow !== null && grow.style.minHeight !== box) grow.style.minHeight = box;
1062
+ };
1063
+
1064
+ /** Mirror the stock surface's chrome: placeholder text + editability.
1065
+ Also deferred while the field has focus (a read-only flip is a DOM
1066
+ write like any other). */
1067
+ var refreshChrome = function (force) {
1068
+ var area = areaRef.current;
1069
+ if (area === null) return;
1070
+ if (hasFocus() && force !== true) return;
1071
+ var card = cardOf();
1072
+ var editor = card === null ? null : card.querySelector("[data-composer-input]");
1073
+ if (editor === null) return;
1074
+ var placeholder = editor.getAttribute("data-placeholder");
1075
+ if (placeholder !== null && area.placeholder !== placeholder) area.placeholder = placeholder;
1076
+ /* The product's own gate (locked / inert / takeover states) rides this
1077
+ attribute; mirror it instead of re-deriving the policy. The takeover
1078
+ only ever writes "false" AND marks those writes, so an unmarked value
1079
+ is the product speaking: "true" means it made its composer editable
1080
+ again, which must clear any latched read-only state (see
1081
+ `composerIsTextInput`). */
1082
+ if (!forcedRef.current && editor.getAttribute("contenteditable") === "false") {
1083
+ appEditableRef.current = false;
1084
+ } else if (editor.getAttribute("contenteditable") !== "false") {
1085
+ if (appEditableRef.current === false) debug("stock editor editable again");
1086
+ appEditableRef.current = true;
1087
+ forcedRef.current = false;
1088
+ }
1089
+ var editable = appEditableRef.current;
1090
+ /* Keep the stock editor out of the browser's editable set for as long
1091
+ as the takeover owns the surface (React re-applies its own value only
1092
+ when the prop changes, so ours sticks until then). */
1093
+ if (activeRef.current && editor.getAttribute("contenteditable") !== "false") {
1094
+ editor.setAttribute("contenteditable", "false");
1095
+ forcedRef.current = true;
1096
+ debug("re-forced contenteditable=false");
1097
+ }
1098
+ if (area.readOnly === editable) area.readOnly = !editable;
1099
+ };
1100
+
1101
+ /** One settle pass, for the moments focus has already left the field.
1102
+ `force` overrides the focus guard for the single case that has to win
1103
+ over it: a committed send clearing the draft (the card has just
1104
+ shrunk, so a grown field would hang out of it). */
1105
+ var settle = function (force) {
1106
+ if (hasFocus() && force !== true) return;
1107
+ measure(force);
1108
+ autosize(force);
1109
+ refreshChrome(force);
1110
+ };
1111
+
1112
+ /* Mark the card while the takeover owns the surface (the CSS that hides
1113
+ the stock draft surface keys off this attribute), take the stock editor
1114
+ out of the focus/IME tree, and keep our empty seat out of hit-testing
1115
+ when the stock editor owns the surface (claims). */
1116
+ React.useLayoutEffect(function () {
1117
+ var card = cardOf();
1118
+ if (card === null) return undefined;
1119
+ var wrap = wrapRef.current;
1120
+ var editor = card.querySelector("[data-composer-input]");
1121
+ var restore = null;
1122
+ activeRef.current = active;
1123
+ if (active) {
1124
+ card.setAttribute("data-mobile-input-active", "");
1125
+ if (wrap !== null) wrap.style.display = "";
1126
+ if (editor !== null) {
1127
+ /* On Chromium-class engines an inert editable is out of both the
1128
+ focus tree and the IME's editable candidates: nothing but the
1129
+ textarea can own the input connection. */
1130
+ var hadInert = editor.hasAttribute("inert");
1131
+ var hadAria = editor.getAttribute("aria-hidden");
1132
+ var hadEditable = editor.getAttribute("contenteditable");
1133
+ /* Trust the product's value unless the "false" standing there is
1134
+ the one we wrote ourselves. */
1135
+ if (!forcedRef.current || hadEditable !== "false") {
1136
+ appEditableRef.current = hadEditable !== "false";
1137
+ }
1138
+ if (typeof editor.inert === "boolean") editor.inert = true;
1139
+ else editor.setAttribute("inert", "");
1140
+ editor.setAttribute("aria-hidden", "true");
1141
+ editor.setAttribute("contenteditable", "false");
1142
+ forcedRef.current = true;
1143
+ restore = function () {
1144
+ if (!hadInert) {
1145
+ if (typeof editor.inert === "boolean") editor.inert = false;
1146
+ editor.removeAttribute("inert");
1147
+ }
1148
+ if (hadAria === null) editor.removeAttribute("aria-hidden");
1149
+ else editor.setAttribute("aria-hidden", hadAria);
1150
+ /* Hand editability back to the product — but only while it still
1151
+ wants an input. When the takeover is released because the
1152
+ product locked the composer (picker trigger / blocked session),
1153
+ our own "false" IS the product's answer and must stand: writing
1154
+ the pre-takeover "true" back would re-enable a surface the
1155
+ product has just disabled (and hand the IME a hidden editable
1156
+ again — the exact bug the inert/aria-hidden pair exists for). */
1157
+ if (composerIsTextInput(card)) {
1158
+ editor.setAttribute("contenteditable", appEditableRef.current ? "true" : "false");
1159
+ forcedRef.current = false;
1160
+ }
1161
+ };
1162
+ }
1163
+ } else {
1164
+ card.removeAttribute("data-mobile-input-active");
1165
+ if (wrap !== null) wrap.style.display = "none";
1166
+ }
1167
+ return function () {
1168
+ card.removeAttribute("data-mobile-input-active");
1169
+ var grow = card.querySelector("[data-input-scroll]");
1170
+ if (grow !== null && grow.firstElementChild !== null) grow.firstElementChild.style.minHeight = "";
1171
+ if (restore !== null) restore();
1172
+ };
1173
+ }, [active]);
1174
+
1175
+ /* Geometry tracking: the stock box moves whenever the card relayouts
1176
+ (taller draft, attachment rail, rotation), so follow it. */
1177
+ React.useLayoutEffect(function () {
1178
+ if (!active) return undefined;
1179
+ var card = cardOf();
1180
+ var scroll = card === null ? null : card.querySelector("[data-input-scroll]");
1181
+ var frame = 0;
1182
+ var run = function () {
1183
+ frame = 0;
1184
+ settle();
1185
+ };
1186
+ var schedule = function () {
1187
+ if (frame !== 0) return;
1188
+ frame = window.requestAnimationFrame(run);
1189
+ };
1190
+ settle();
1191
+ var observer = typeof window.ResizeObserver === "function" ? new window.ResizeObserver(schedule) : null;
1192
+ if (observer !== null) {
1193
+ if (card !== null) observer.observe(card);
1194
+ if (scroll !== null) observer.observe(scroll);
1195
+ }
1196
+ /* Chrome (placeholder / editability) changes ride attributes only. */
1197
+ var mutations = typeof window.MutationObserver === "function" && card !== null
1198
+ ? new window.MutationObserver(schedule)
1199
+ : null;
1200
+ if (mutations !== null) {
1201
+ mutations.observe(card, {
1202
+ attributes: true,
1203
+ attributeFilter: ["data-placeholder", "contenteditable"],
1204
+ subtree: true,
1205
+ });
1206
+ }
1207
+ window.addEventListener("resize", schedule);
1208
+ window.addEventListener("orientationchange", schedule);
1209
+ var fonts = document.fonts;
1210
+ if (fonts !== undefined && typeof fonts.ready === "object" && typeof fonts.ready.then === "function") {
1211
+ fonts.ready.then(schedule, schedule);
1212
+ }
1213
+ return function () {
1214
+ if (frame !== 0) window.cancelAnimationFrame(frame);
1215
+ if (observer !== null) observer.disconnect();
1216
+ if (mutations !== null) mutations.disconnect();
1217
+ window.removeEventListener("resize", schedule);
1218
+ window.removeEventListener("orientationchange", schedule);
1219
+ };
1220
+ }, [active]);
1221
+
1222
+ /* Machine -> textarea. The live store subscription (not a render-time
1223
+ value) makes the echo of our own mirror a no-op while an external
1224
+ change — send committed, failed-send restore, another plugin's
1225
+ insert — still lands, with no stale-render race to clobber typing. */
1226
+ React.useEffect(function () {
1227
+ if (!active) return undefined;
1228
+ var area = areaRef.current;
1229
+ if (area === null) return undefined;
1230
+ var sync = function () {
1231
+ var next = face === undefined ? draftRef.current : face.state.getSnapshot().draft;
1232
+ var focused = document.activeElement === area;
1233
+ /* While the field has focus IT is the source of truth: a write here
1234
+ (mid-composition on engines whose composition events differ, or
1235
+ between two fast keystrokes) resets an IME session — the classic
1236
+ "keyboard up, one character in, nothing after" failure. The one
1237
+ exception is a clear, which is a committed send, never local
1238
+ typing. */
1239
+ if (composingRef.current && next !== "") return;
1240
+ /* Text the user typed but has not committed yet outranks anything the
1241
+ machine publishes meanwhile — including an EMPTY publish: the
1242
+ machine draft is stale by design while typing, so "" is not "the
1243
+ user cleared the field", it is just the machine never having heard
1244
+ about this text. Only our own flush (which nulls the pending value
1245
+ first) may clear the field. */
1246
+ if (pendingRef.current !== null) {
1247
+ if (next !== "" || area.value !== "") {
1248
+ debug("keep " + pendingRef.current.length + " uncommitted chars");
1249
+ return;
1250
+ }
1251
+ }
1252
+ /* While uncommitted text is still ours, an external draft must not
1253
+ clobber it. Once it is flushed (pending null), the app is the
1254
+ authority again — that is how a command picked from the menu lands
1255
+ in the field. */
1256
+ if (focused && next !== "" && next !== pushedRef.current && pendingRef.current !== null) {
1257
+ debug("skip external write while focused: " + next.length + " chars");
1258
+ return;
1259
+ }
1260
+ if (area.value !== next) {
1261
+ area.value = next;
1262
+ debug("write " + next.length + " chars (focused=" + focused + ")");
1263
+ /* A committed send is the one write the plugin performs on a
1264
+ FOCUSED field, and it has to resize too: the card has just
1265
+ shrunk back to its floor, so a grown field would hang out of
1266
+ it. It follows an explicit user action, never typing. */
1267
+ if (next === "" && focused) {
1268
+ settle(true);
1269
+ return;
1270
+ }
1271
+ }
1272
+ settle();
1273
+ };
1274
+ sync();
1275
+ if (face === undefined) return undefined; /* no live store: render-driven */
1276
+ var off = face.state.subscribe(sync);
1277
+ return function () { off(); };
1278
+ }, [active, face]);
1279
+
1280
+ /* Fallback mirror when the live store could not be resolved (a scope
1281
+ that is not queryable yet): best effort off the rendered draft. */
1282
+ React.useEffect(function () {
1283
+ if (!active || face !== undefined) return;
1284
+ var area = areaRef.current;
1285
+ if (area === null) return;
1286
+ if (composingRef.current && draftRef.current !== "") return;
1287
+ if (document.activeElement === area && draftRef.current !== "") return;
1288
+ if (area.value !== draftRef.current) area.value = draftRef.current;
1289
+ }, [active, face, input]);
1290
+
1291
+ /* Diagnostics only: report APP-side DOM churn while the field has focus.
1292
+ This is what separates "our writes" from "the app re-rendered" (the
1293
+ command menu, Lexical rewriting the hidden editor) when a device report
1294
+ says the keyboard dropped — the two look identical from the outside. */
1295
+ React.useEffect(function () {
1296
+ if (diary === null || !active) return undefined;
1297
+ var card = cardOf();
1298
+ if (card === null || typeof window.MutationObserver !== "function") return undefined;
1299
+ var observer = new window.MutationObserver(function (records) {
1300
+ if (records.length === 0 || !hasFocus()) return;
1301
+ var kinds = [];
1302
+ for (var i = 0; i < records.length && i < 4; i += 1) {
1303
+ var record = records[i];
1304
+ kinds.push(record.type + ":"
1305
+ + (record.attributeName !== null && record.attributeName !== undefined
1306
+ ? record.attributeName
1307
+ : record.target.tagName));
1308
+ }
1309
+ debug("CHURN while focused: " + records.length + " [" + kinds.join(",") + "]");
1310
+ });
1311
+ observer.observe(card, { attributes: true, childList: true, characterData: true, subtree: true });
1312
+ return function () { observer.disconnect(); };
1313
+ }, [active]);
1314
+
1315
+ /* Textarea -> machine. Mirrored on every input event: the send button,
1316
+ the placeholder and the `/` trigger pipeline all read the machine, so
1317
+ a debounce would let the user send a draft the machine never saw. */
1318
+ var push = function (value) {
1319
+ pushedRef.current = value;
1320
+ try {
1321
+ if (actions !== undefined && typeof actions.setDraft === "function") {
1322
+ actions.setDraft(value);
1323
+ return true;
1324
+ }
1325
+ var face = faceRef.current;
1326
+ if (face !== undefined && typeof face.setDraft === "function") {
1327
+ face.setDraft(value);
1328
+ return true;
1329
+ }
1330
+ } catch (error) {
1331
+ /* A refused machine write must never break typing: the field keeps
1332
+ its own text and the next publish re-syncs. */
1333
+ debug("push failed: " + String(error && error.message));
1334
+ }
1335
+ return false;
1336
+ };
1337
+
1338
+ /** Defer a field value until the next commit point (per-keystroke DOM
1339
+ churn is what drops the IME on strict engines). */
1340
+ var queueMirror = function (value, urgent) {
1341
+ pendingRef.current = value;
1342
+ if (urgent === true) return mirrorNow();
1343
+ return false;
1344
+ };
1345
+
1346
+ /** Mirror a trigger change immediately (the menus read the machine). */
1347
+ var chattyMirror = function (value) {
1348
+ debug("trigger mirror " + value.length + " chars");
1349
+ return queueMirror(value, true);
1350
+ };
1351
+
1352
+ /** Flush pending React work now, when react-dom is reachable. */
1353
+ var flushNow = function (fn) {
1354
+ try {
1355
+ var dom = require("react-dom");
1356
+ if (dom !== null && dom !== undefined && typeof dom.flushSync === "function") {
1357
+ dom.flushSync(fn);
1358
+ return;
1359
+ }
1360
+ } catch (error) { /* no react-dom: the update stays async */ }
1361
+ fn();
1362
+ };
1363
+
1364
+ /**
1365
+ * Trigger mirror for strict engines. The write makes the app rewrite the
1366
+ * hidden stock editor's DOM; doing that while the IME is attached is what
1367
+ * closes the keyboard (measured 2026-09-14: churn lands ~30ms after the
1368
+ * write, the keyboard drops ~100ms later, and typing the same `/` in the
1369
+ * stock editor — where the write IS the user's own editing — keeps it).
1370
+ * So the whole thing happens while focus is deliberately elsewhere, and
1371
+ * focus comes back inside the same gesture.
1372
+ */
1373
+ var resyncTrigger = function (value) {
1374
+ var area = areaRef.current;
1375
+ pendingRef.current = value;
1376
+ if (area === null || document.activeElement !== area) {
1377
+ return mirrorNow();
1378
+ }
1379
+ resyncingRef.current = true;
1380
+ try {
1381
+ area.blur();
1382
+ flushNow(function () { mirrorNow(); });
1383
+ } catch (error) {
1384
+ debug("resync failed: " + String(error && error.message));
1385
+ } finally {
1386
+ resyncingRef.current = false;
1387
+ }
1388
+ try {
1389
+ area.focus({ preventScroll: true });
1390
+ } catch (error) {
1391
+ try { area.focus(); } catch (again) { /* the field keeps its text anyway */ }
1392
+ }
1393
+ debug("resync " + value.length + " chars");
1394
+ return true;
1395
+ };
1396
+
1397
+ /** Flush the pending field value into the machine, now. */
1398
+ var mirrorNow = function () {
1399
+ var value = pendingRef.current;
1400
+ if (value === null) return false;
1401
+ pendingRef.current = null;
1402
+ debug("commit " + value.length + " chars");
1403
+ return push(value);
1404
+ };
1405
+
1406
+ /** Enter = the stock send gesture, replayed on the hidden editor so the
1407
+ product's own keymap decides (menu arbitration, busy-Enter policy). */
1408
+ var sendGesture = function () {
1409
+ var card = cardOf();
1410
+ var editor = card === null ? null : card.querySelector("[data-composer-input]");
1411
+ if (editor !== null && typeof window.KeyboardEvent === "function") {
1412
+ var replay = new window.KeyboardEvent("keydown", {
1413
+ key: "Enter",
1414
+ code: "Enter",
1415
+ bubbles: true,
1416
+ cancelable: true,
1417
+ composed: true,
1418
+ });
1419
+ editor.dispatchEvent(replay);
1420
+ if (replay.defaultPrevented === true) return true; /* stock handler ran */
1421
+ }
1422
+ if (actions !== undefined && typeof actions.submit === "function") {
1423
+ actions.submit();
1424
+ return true;
1425
+ }
1426
+ var face = faceRef.current;
1427
+ if (face !== undefined && typeof face.submit === "function") {
1428
+ face.submit();
1429
+ return true;
1430
+ }
1431
+ return false;
1432
+ };
1433
+
1434
+ /** The `/` and `@` menus are driven by the machine's draft, not by the
1435
+ field, so a trigger character has to reach the machine — but ONLY the
1436
+ trigger character, never every character typed after it (see
1437
+ readTriggerMode: a per-keystroke mirror is what drops the keyboard on
1438
+ ArkWeb, and any draft containing `/` used to take that path). */
1439
+ var countTriggers = function (text) {
1440
+ var total = 0;
1441
+ for (var i = 0; i < text.length; i += 1) {
1442
+ var ch = text.charAt(i);
1443
+ if (ch === "/" || ch === "@") total += 1;
1444
+ }
1445
+ return total;
1446
+ };
1447
+
1448
+ /* The typing path. It touches NO DOM: no resize, no re-align, no chrome
1449
+ sync, no panel paint — only refs and (for a trigger character) the
1450
+ machine write the menus are driven by. Everything else waits for a
1451
+ commit point (see the section comment). */
1452
+ var onInput = function (event) {
1453
+ var value = event.currentTarget.value;
1454
+ var previous = lastValueRef.current;
1455
+ lastValueRef.current = value;
1456
+ var mode = triggerRef.current;
1457
+ var triggerChanged = countTriggers(value) !== countTriggers(previous);
1458
+ /* Mid-composition the text is provisional: mirroring it would churn the
1459
+ card AND reset the IME session on a strict engine, so the composition
1460
+ path always defers (compositionend decides, against the count captured
1461
+ when the composition started). */
1462
+ var composing = composingRef.current === true;
1463
+ debug("input " + value.length + " chars"
1464
+ + (triggerChanged && !composing ? " (trigger changed)" : " (deferred)"));
1465
+ if (!composing && triggerChanged && mode === "resync") {
1466
+ resyncTrigger(value);
1467
+ } else if (!composing && (mode === "live" || (mode === "changes" && triggerChanged))) {
1468
+ /* Urgent: the menu needs the machine draft. Everything else waits for
1469
+ a commit point — that is the whole ArkWeb fix. */
1470
+ chattyMirror(value);
1471
+ } else {
1472
+ queueMirror(value, false);
1473
+ }
1474
+ if (growthRef.current === "live") autosize();
1475
+ };
1476
+ var onCompositionStart = function () {
1477
+ composingRef.current = true;
1478
+ compositionTriggersRef.current = countTriggers(areaRef.current === null ? "" : areaRef.current.value);
1479
+ debug("compositionstart");
1480
+ };
1481
+ var onCompositionEnd = function (event) {
1482
+ composingRef.current = false;
1483
+ var value = event.currentTarget.value;
1484
+ /* Compared against the count captured at compositionstart: the per-input
1485
+ events during the composition already moved lastValueRef forward. */
1486
+ var triggerChanged = countTriggers(value) !== compositionTriggersRef.current;
1487
+ lastValueRef.current = value;
1488
+ debug("compositionend " + value.length);
1489
+ if (triggerChanged && triggerRef.current === "resync") resyncTrigger(value);
1490
+ else if (triggerChanged) chattyMirror(value);
1491
+ else queueMirror(value, false);
1492
+ if (growthRef.current === "live") autosize();
1493
+ };
1494
+ var onFocus = function () {
1495
+ if (resyncingRef.current) return;
1496
+ debug("focus");
1497
+ };
1498
+ /** Commit point: publish the field, then settle the surface (focus is on
1499
+ its way out, so the write is safe again). */
1500
+ var onBlur = function () {
1501
+ if (resyncingRef.current) {
1502
+ debug("blur (resync)", true);
1503
+ return;
1504
+ }
1505
+ debug("blur -> commit", true);
1506
+ mirrorNow();
1507
+ settle();
1508
+ /* Some engines report the old activeElement while the blur handler
1509
+ runs; the deferred pass covers that, and stays a no-op when focus is
1510
+ already back in the field. */
1511
+ window.setTimeout(function () { settle(); }, 0);
1512
+ };
1513
+ var onKeyDown = function (event) {
1514
+ if (event.key !== "Enter" || event.shiftKey === true) return;
1515
+ if (event.nativeEvent !== undefined && event.nativeEvent.isComposing === true) return;
1516
+ if (composingRef.current) return;
1517
+ /* An empty draft is only sendable when it carries attachments (the
1518
+ machine's attachment-only send); otherwise keep the newline. */
1519
+ var attachments = input !== undefined && Array.isArray(input.attachmentIds)
1520
+ ? input.attachmentIds.length
1521
+ : 0;
1522
+ if (event.currentTarget.value.trim() === "" && attachments === 0) return;
1523
+ queueMirror(event.currentTarget.value, false);
1524
+ mirrorNow();
1525
+ if (sendGesture()) {
1526
+ pendingRef.current = null;
1527
+ event.preventDefault();
1528
+ }
1529
+ };
1530
+ /* Attachments pasted into a textarea have nowhere to land, so forward a
1531
+ file-bearing paste to the stock surface, whose keymap owns intake. */
1532
+ var onPaste = function (event) {
1533
+ var data = event.clipboardData;
1534
+ if (data === undefined || data === null || data.files === undefined || data.files.length === 0) return;
1535
+ var card = cardOf();
1536
+ var editor = card === null ? null : card.querySelector("[data-composer-input]");
1537
+ if (editor === null) return;
1538
+ try {
1539
+ var forwarded = new window.ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data });
1540
+ editor.dispatchEvent(forwarded);
1541
+ event.preventDefault();
1542
+ } catch (error) { /* no ClipboardEvent constructor: the picker still works */ }
1543
+ };
1544
+
1545
+ /* Taps anywhere in the stock input region must land on the textarea,
1546
+ whatever the engine's hit-testing does with the layers above it: a
1547
+ capture listener on the card focuses the field when the tap point is
1548
+ inside our box but the target is not the field itself. */
1549
+ React.useEffect(function () {
1550
+ if (!active) return undefined;
1551
+ var card = cardOf();
1552
+ if (card === null) return undefined;
1553
+ /** The button whose box contains a point (a disabled button is not
1554
+ returned by elementFromPoint, so hit it by rect). */
1555
+ var buttonAt = function (x, y) {
1556
+ var buttons = card.querySelectorAll("button");
1557
+ for (var i = 0; i < buttons.length; i += 1) {
1558
+ var rect = buttons[i].getBoundingClientRect();
1559
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return buttons[i];
1560
+ }
1561
+ return null;
1562
+ };
1563
+ var onPointerDown = function (event) {
1564
+ var area = areaRef.current;
1565
+ var wrap = wrapRef.current;
1566
+ if (area === null || wrap === null || event.target === area) return;
1567
+ var x = event.clientX;
1568
+ var y = event.clientY;
1569
+ debug("tap " + Math.round(x) + "," + Math.round(y) + " target=" + (event.target && event.target.tagName));
1570
+ var rect = wrap.getBoundingClientRect();
1571
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
1572
+ /* Inside the input box: the field must get the tap, whatever the
1573
+ engine did with the layers above it. */
1574
+ event.preventDefault();
1575
+ try {
1576
+ area.focus({ preventScroll: true });
1577
+ } catch (error) {
1578
+ area.focus();
1579
+ }
1580
+ return;
1581
+ }
1582
+ /* Outside the field: a toolbar action whose gate may depend on the
1583
+ draft (the send button is disabled while the machine draft is
1584
+ empty). Commit first, then replay the tap on a button our commit
1585
+ just enabled. */
1586
+ var button = buttonAt(x, y);
1587
+ var wasDisabled = button !== null && button.disabled === true;
1588
+ mirrorNow();
1589
+ if (button !== null && wasDisabled && button.disabled === false) {
1590
+ event.preventDefault();
1591
+ window.setTimeout(function () { button.click(); }, 0);
1592
+ }
1593
+ };
1594
+ /* Engines without a working inert: if anything hands focus back to the
1595
+ stock editor, take it straight back to the field. */
1596
+ var onFocusIn = function (event) {
1597
+ var area = areaRef.current;
1598
+ if (area === null || event.target === area) return;
1599
+ var editor = card.querySelector("[data-composer-input]");
1600
+ if (editor === null || event.target !== editor) return;
1601
+ debug("stock editor stole focus -> refocus field");
1602
+ try {
1603
+ area.focus({ preventScroll: true });
1604
+ } catch (error) { /* ignore */ }
1605
+ };
1606
+ var onHide = function () {
1607
+ if (document.visibilityState === "hidden") mirrorNow();
1608
+ };
1609
+ card.addEventListener("pointerdown", onPointerDown, true);
1610
+ document.addEventListener("focusin", onFocusIn, true);
1611
+ window.addEventListener("pagehide", onHide);
1612
+ document.addEventListener("visibilitychange", onHide);
1613
+ return function () {
1614
+ card.removeEventListener("pointerdown", onPointerDown, true);
1615
+ document.removeEventListener("focusin", onFocusIn, true);
1616
+ window.removeEventListener("pagehide", onHide);
1617
+ document.removeEventListener("visibilitychange", onHide);
1618
+ /* Leaving the surface (phase flip / session switch / unmount) must
1619
+ not lose what the user typed. */
1620
+ mirrorNow();
1621
+ };
1622
+ }, [active]);
1623
+
1624
+ /* Debug panel (?dsh-mobile-input=debug): the only read-out available on a
1625
+ phone with no console. The panel is a VIEWER of the diary — it never
1626
+ writes while a field has focus (the diary defers the paint), and it
1627
+ re-paints on demand, on the way out of the field, or when the keyboard
1628
+ state changes (the moment a report is actually about). */
1629
+ React.useEffect(function () {
1630
+ if (diary === null || !debugRequested() || benchRequested()) return undefined;
1631
+ var panel = document.createElement("div");
1632
+ panel.setAttribute("data-mobile-input-debug", "");
1633
+ var head = document.createElement("div");
1634
+ head.style.cssText = "display:flex;gap:6px;align-items:center;margin-bottom:4px";
1635
+ var stage = document.createElement("span");
1636
+ stage.textContent = "诊断日志";
1637
+ var refresh = document.createElement("button");
1638
+ refresh.type = "button";
1639
+ refresh.setAttribute("data-mobile-input-debug-button", "");
1640
+ refresh.textContent = "刷新";
1641
+ refresh.addEventListener("click", function () { diary.paint(true); });
1642
+ var copy = document.createElement("button");
1643
+ copy.type = "button";
1644
+ copy.setAttribute("data-mobile-input-debug-button", "");
1645
+ copy.textContent = "复制日志";
1646
+ copy.addEventListener("click", function () { copyDiary(diary, copy); });
1647
+ head.append(stage, refresh, copy);
1648
+ var body = document.createElement("pre");
1649
+ body.setAttribute("data-mobile-input-debug-body", "");
1650
+ panel.append(head, body);
1651
+ document.body.append(panel);
1652
+ diary.watch(body);
1653
+ debug("panel on; active=" + active, true);
1654
+ return function () {
1655
+ diary.unwatch(body);
1656
+ panel.remove();
1657
+ };
1658
+ }, []);
1659
+
1660
+ /* The seat stays mounted even while the stock editor owns the surface:
1661
+ it is how this component finds its card, and it keeps the takeover
1662
+ from remounting the subtree on every phase flip. */
1663
+ /* A tap on the seat itself (padding area) focuses the field too. */
1664
+ var onSeatPointerDown = function (event) {
1665
+ var area = areaRef.current;
1666
+ if (area === null || event.target === area) return;
1667
+ event.preventDefault();
1668
+ try {
1669
+ area.focus({ preventScroll: true });
1670
+ } catch (error) {
1671
+ area.focus();
1672
+ }
1673
+ };
1674
+
1675
+ return h(
1676
+ "div",
1677
+ { ref: wrapRef, "data-mobile-input-wrap": "", onPointerDown: onSeatPointerDown },
1678
+ active
1679
+ ? h("textarea", {
1680
+ ref: areaRef,
1681
+ "data-mobile-input": "",
1682
+ rows: 1,
1683
+ enterKeyHint: "send",
1684
+ autoCapitalize: "sentences",
1685
+ onInput: onInput,
1686
+ onFocus: onFocus,
1687
+ onBlur: onBlur,
1688
+ onKeyDown: onKeyDown,
1689
+ onPaste: onPaste,
1690
+ onCompositionStart: onCompositionStart,
1691
+ onCompositionEnd: onCompositionEnd,
1692
+ })
1693
+ : null,
1694
+ );
1695
+ };
1696
+ }
1697
+
1698
+ /**
1699
+ * Escape hatch in the composer tool row (narrow viewports only): one tap
1700
+ * swaps the native input back to the stock editor and remembers it, so a
1701
+ * device the takeover misbehaves on is never a dead end.
1702
+ * @returns the slot entry component.
1703
+ */
1704
+ function createNativeInputToggle(diagnostics) {
1705
+ return function NativeInputToggle() {
1706
+ var narrow = useNarrowViewport();
1707
+ var state = React.useState(function () { return readOverride(); });
1708
+ var preference = state[0];
1709
+ var setPreference = state[1];
1710
+ var revealState = React.useState(function () { return readReveal(); });
1711
+ var revealed = revealState[0];
1712
+ var setRevealed = revealState[1];
1713
+ /* Long press on the escape hatch calls the diagnostics controls out (or
1714
+ puts them away); the click that ends the press must not also flip the
1715
+ input preference. */
1716
+ var pressTimer = React.useRef(0);
1717
+ var longPressed = React.useRef(false);
1718
+ var startPress = function () {
1719
+ longPressed.current = false;
1720
+ if (pressTimer.current !== 0) window.clearTimeout(pressTimer.current);
1721
+ pressTimer.current = window.setTimeout(function () {
1722
+ pressTimer.current = 0;
1723
+ longPressed.current = true;
1724
+ writeReveal(!readReveal());
1725
+ }, 600);
1726
+ };
1727
+ var endPress = function () {
1728
+ if (pressTimer.current !== 0) {
1729
+ window.clearTimeout(pressTimer.current);
1730
+ pressTimer.current = 0;
1731
+ }
1732
+ };
1733
+ React.useEffect(function () {
1734
+ var update = function () {
1735
+ setPreference(readOverride());
1736
+ setRevealed(readReveal());
1737
+ };
1738
+ window.addEventListener(PREFERENCE_EVENT, update);
1739
+ window.addEventListener(REVEAL_EVENT, update);
1740
+ window.addEventListener("storage", update);
1741
+ return function () {
1742
+ window.removeEventListener(PREFERENCE_EVENT, update);
1743
+ window.removeEventListener(REVEAL_EVENT, update);
1744
+ window.removeEventListener("storage", update);
1745
+ endPress();
1746
+ };
1747
+ }, []);
1748
+ if (!narrow) return null;
1749
+ var on = preference !== "off";
1750
+ var label = (on ? "原生输入框:已开启(点击改用官方输入框)" : "原生输入框:已关闭(点击启用)")
1751
+ + ";长按显示/隐藏诊断按钮";
1752
+ var diagnosing = diagnosticsModes().length !== 0;
1753
+ var diagnosisLabel = diagnosing
1754
+ ? "诊断面板:已开启(点击关闭并刷新)"
1755
+ : "诊断面板:点击开启(测试台 + 事件日志,页面会刷新)";
1756
+ /* Two chips: the escape hatch, and the diagnostics switch. The latter
1757
+ is here because a tokenized URL is rewritten by the shell before
1758
+ plugins load, so a phone cannot always pass ?dsh-mobile-input=... */
1759
+ return h(
1760
+ React.Fragment,
1761
+ null,
1762
+ h(
1763
+ "button",
1764
+ {
1765
+ type: "button",
1766
+ "data-mobile-input-toggle": "",
1767
+ "data-state": on ? "on" : "off",
1768
+ title: label,
1769
+ "aria-label": label,
1770
+ onMouseDown: function (event) { event.preventDefault(); },
1771
+ onPointerDown: startPress,
1772
+ onPointerUp: endPress,
1773
+ onPointerCancel: endPress,
1774
+ onPointerLeave: endPress,
1775
+ onClick: function () {
1776
+ /* A long press already did its job: swallow the click. */
1777
+ if (longPressed.current === true) {
1778
+ longPressed.current = false;
1779
+ return;
1780
+ }
1781
+ writePreference(on ? "off" : "on");
1782
+ },
1783
+ },
1784
+ on ? "输入法✓" : "输入法✗",
1785
+ ),
1786
+ diagnostics !== null
1787
+ ? h(
1788
+ "button",
1789
+ {
1790
+ type: "button",
1791
+ "data-mobile-input-copy": "",
1792
+ title: "复制诊断日志(与面板里的同一份)",
1793
+ "aria-label": "复制诊断日志",
1794
+ onMouseDown: function (event) { event.preventDefault(); },
1795
+ onClick: function (event) { copyDiary(diagnostics, event.currentTarget); },
1796
+ },
1797
+ "复制日志",
1798
+ )
1799
+ : null,
1800
+ revealed || diagnosing
1801
+ ? h(
1802
+ "button",
1803
+ {
1804
+ type: "button",
1805
+ "data-mobile-input-diagnostics": "",
1806
+ "data-state": diagnosing ? "on" : "off",
1807
+ title: diagnosisLabel,
1808
+ "aria-label": diagnosisLabel,
1809
+ onMouseDown: function (event) { event.preventDefault(); },
1810
+ onClick: function () {
1811
+ writeDiagnostics(diagnosing ? "off" : "bench");
1812
+ try {
1813
+ window.location.reload();
1814
+ } catch (error) { /* the next load picks the switch up anyway */ }
1815
+ },
1816
+ },
1817
+ diagnosing ? "诊断✓" : "诊断",
1818
+ )
1819
+ : null,
1820
+ );
1821
+ };
1822
+ }
1823
+
1824
+ /**
1825
+ * Device test bench (?dsh-mobile-input=bench).
1826
+ *
1827
+ * Chromium cannot reproduce ArkWeb's IME and a phone has no console, so
1828
+ * when the keyboard still drops while typing, the only way to tell WHICH
1829
+ * ingredient does it is to run the candidates side by side on the device
1830
+ * itself. Each variant is a real field the user types into:
1831
+ *
1832
+ * A bare textarea (normal flow, no JS writes) — baseline
1833
+ * B textarea inside a height:0 absolute container — the seat shape
1834
+ * C B + a style write on every keystroke — the pre-0.6 autosize
1835
+ * D B + logging only, nothing written — the 0.6 behaviour
1836
+ * E bare textarea inside an iframe — a clean document
1837
+ *
1838
+ * The diary records focus/blur/input per field AND every keyboard (window
1839
+ * height) transition, so the answer reads off as a timeline: whichever
1840
+ * variant is followed by a KEYBOARD line is the guilty one. The log lives
1841
+ * in its OWN fixed panel, so repainting it can never reflow a field under
1842
+ * test (fixed boxes are out of flow).
1843
+ * @param diary - the diagnostics ring buffer created by apply().
1844
+ * @returns a disposer for ctx.effect.
1845
+ */
1846
+ function installBench(diary) {
1847
+ var panel = document.createElement("div");
1848
+ panel.setAttribute("data-mobile-input-bench", "");
1849
+ var log = document.createElement("pre");
1850
+ log.setAttribute("data-mobile-input-log", "");
1851
+
1852
+ var title = document.createElement("div");
1853
+ title.style.cssText = "font-size:11px;line-height:1.35";
1854
+ title.textContent = "诊断中:本面板只占屏幕顶部,下方真实输入框可直接打字。日志在打字期间不刷新,"
1855
+ + "失焦或键盘开合时更新;收起来时点「复制日志」发给 AI。";
1856
+
1857
+ var row = document.createElement("div");
1858
+ row.style.cssText = "display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin:4px 0";
1859
+
1860
+ var copy = document.createElement("button");
1861
+ copy.type = "button";
1862
+ copy.textContent = "复制日志";
1863
+ copy.addEventListener("click", function () { copyDiary(diary, copy); });
1864
+ row.append(copy);
1865
+
1866
+ /* Growth-policy switches: A/B the PRODUCTION field (below in the real
1867
+ composer) without editing the URL on a phone keyboard. */
1868
+ var modes = [
1869
+ ["commit", "生产:提交点增高"],
1870
+ ["live", "生产:逐键增高"],
1871
+ ["none", "生产:固定高度"],
1872
+ ];
1873
+ var modeButtons = [];
1874
+ var paintModes = function () {
1875
+ var current = readGrowthMode();
1876
+ for (var i = 0; i < modeButtons.length; i += 1) {
1877
+ modeButtons[i].el.textContent = modeButtons[i].label + (current === modeButtons[i].mode ? " ✓" : "");
1878
+ }
1879
+ };
1880
+ for (var i = 0; i < modes.length; i += 1) {
1881
+ var makeMode = function (mode, label) {
1882
+ var button = document.createElement("button");
1883
+ button.type = "button";
1884
+ button.textContent = label;
1885
+ button.setAttribute("data-mobile-input-growth", mode);
1886
+ button.addEventListener("click", function () {
1887
+ writeGrowthMode(mode);
1888
+ paintModes();
1889
+ diary.add("growth=" + mode);
1890
+ });
1891
+ modeButtons.push({ el: button, mode: mode, label: label });
1892
+ return button;
1893
+ };
1894
+ row.append(makeMode(modes[i][0], modes[i][1]));
1895
+ }
1896
+ paintModes();
1897
+
1898
+ /* The variant fields are a SECOND step: the decisive test is the real
1899
+ composer field below this panel, so the variants start collapsed. */
1900
+ var variantsBox = document.createElement("div");
1901
+ variantsBox.setAttribute("data-mobile-input-bench-variants", "");
1902
+ variantsBox.hidden = true;
1903
+ var variantsToggle = document.createElement("button");
1904
+ variantsToggle.type = "button";
1905
+ variantsToggle.setAttribute("data-mobile-input-bench-variants-toggle", "");
1906
+ variantsToggle.textContent = "展开变体 A-E";
1907
+ variantsToggle.addEventListener("click", function () {
1908
+ variantsBox.hidden = !variantsBox.hidden;
1909
+ variantsToggle.textContent = variantsBox.hidden ? "展开变体 A-E" : "收起变体";
1910
+ });
1911
+ row.append(variantsToggle);
1912
+
1913
+ /* Trigger-sync switches: the A/B for the slash-command keyboard drop. */
1914
+ var triggerModes = [
1915
+ ["resync", "触发:重聚焦"],
1916
+ ["changes", "触发:仅触发"],
1917
+ ["live", "触发:逐键"],
1918
+ ["off", "触发:关"],
1919
+ ];
1920
+ var triggerButtons = [];
1921
+ var paintTriggers = function () {
1922
+ var current = readTriggerMode();
1923
+ for (var i = 0; i < triggerButtons.length; i += 1) {
1924
+ triggerButtons[i].el.textContent = triggerButtons[i].label
1925
+ + (current === triggerButtons[i].mode ? " ✓" : "");
1926
+ }
1927
+ };
1928
+ for (var j = 0; j < triggerModes.length; j += 1) {
1929
+ var makeTrigger = function (mode, label) {
1930
+ var button = document.createElement("button");
1931
+ button.type = "button";
1932
+ button.textContent = label;
1933
+ button.setAttribute("data-mobile-input-trigger", mode);
1934
+ button.addEventListener("click", function () {
1935
+ writeTriggerMode(mode);
1936
+ paintTriggers();
1937
+ diary.add("trigger=" + mode);
1938
+ });
1939
+ triggerButtons.push({ el: button, mode: mode, label: label });
1940
+ return button;
1941
+ };
1942
+ row.append(makeTrigger(triggerModes[j][0], triggerModes[j][1]));
1943
+ }
1944
+ paintTriggers();
1945
+
1946
+ var close = document.createElement("button");
1947
+ close.type = "button";
1948
+ close.textContent = "关闭测试台";
1949
+ close.addEventListener("click", function () {
1950
+ diary.unwatch(log);
1951
+ panel.remove();
1952
+ log.remove();
1953
+ /* The request is persisted, so turning the panels off has to clear it
1954
+ too — otherwise the next load brings them straight back. */
1955
+ writeDiagnostics("off");
1956
+ try { window.location.reload(); } catch (error) { /* panels already gone */ }
1957
+ });
1958
+ row.append(close);
1959
+
1960
+ panel.append(title, row);
1961
+
1962
+ var instrument = function (area, id, live) {
1963
+ area.addEventListener("focus", function () { diary.add(id + " focus"); });
1964
+ area.addEventListener("blur", function () { diary.add(id + " blur", true); });
1965
+ area.addEventListener("input", function (event) {
1966
+ var el = event.currentTarget;
1967
+ diary.add(id + " input " + el.value.length + " chars");
1968
+ if (live === true) {
1969
+ /* The pre-0.6 autosize probe, verbatim: collapse to zero, read the
1970
+ content height, write the height back. */
1971
+ var previous = el.style.height;
1972
+ el.style.height = "0px";
1973
+ void el.scrollHeight;
1974
+ el.style.height = previous;
1975
+ }
1976
+ });
1977
+ area.addEventListener("compositionstart", function () { diary.add(id + " compositionstart"); });
1978
+ area.addEventListener("compositionend", function () { diary.add(id + " compositionend", true); });
1979
+ };
1980
+
1981
+ var caption = function (text) {
1982
+ var label = document.createElement("label");
1983
+ label.textContent = text;
1984
+ variantsBox.append(label);
1985
+ };
1986
+
1987
+ var field = function (id, text, zero, live) {
1988
+ caption(id + " " + text);
1989
+ var holder = document.createElement("div");
1990
+ holder.setAttribute("data-mobile-input-bench-variant", "");
1991
+ var area = document.createElement("textarea");
1992
+ if (zero === true) {
1993
+ /* The real seat's shape, replicated: card(position:relative) >
1994
+ anchor(position:absolute, height:0) > seat(absolute, explicit box)
1995
+ > textarea. */
1996
+ holder.style.cssText = "position:relative;height:38px";
1997
+ var anchor = document.createElement("div");
1998
+ anchor.style.cssText = "position:absolute;left:0;top:0;right:0;height:0";
1999
+ area.style.cssText = "position:absolute;left:0;top:0;width:100%;height:36px";
2000
+ anchor.append(area);
2001
+ holder.append(anchor);
2002
+ } else {
2003
+ holder.append(area);
2004
+ }
2005
+ area.setAttribute("data-mobile-input-bench-field", "");
2006
+ area.setAttribute("data-mobile-input-bench-id", id);
2007
+ area.setAttribute("rows", "1");
2008
+ area.setAttribute("placeholder", text);
2009
+ variantsBox.append(holder);
2010
+ instrument(area, id, live === true);
2011
+ return area;
2012
+ };
2013
+
2014
+ field("A", "裸 textarea(普通流,零 JS 写入)", false, false);
2015
+ field("B", "零高容器内的 textarea(复刻插件结构,零 JS 写入)", true, false);
2016
+ field("C", "同 B + 每次按键都写高度(v0.5.2 的逐键 autosize)", true, true);
2017
+ field("D", "同 B + 只记录、不写任何 DOM(v0.6 行为)", true, false);
2018
+
2019
+ var frame = document.createElement("iframe");
2020
+ frame.setAttribute("data-mobile-input-bench-frame", "");
2021
+ 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";
2022
+ caption("E 隔离文档(iframe)里的裸 textarea");
2023
+ /* Same-origin (srcdoc inherits the parent origin), so the parent wires the
2024
+ instrument to the inner field directly. */
2025
+ frame.setAttribute("srcdoc", "<!doctype html><html><head>"
2026
+ + "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
2027
+ + "<style>body{margin:4px}textarea{display:block;box-sizing:border-box;width:100%;height:36px;"
2028
+ + "font:13px/20px sans-serif;padding:6px 8px;border:1px solid #ccc;border-radius:6px}</style></head>"
2029
+ + "<body><textarea rows=\"1\" placeholder=\"E 隔离文档里的裸 textarea\"></textarea></body></html>");
2030
+ frame.addEventListener("load", function () {
2031
+ var doc = frame.contentDocument;
2032
+ if (doc === null || doc === undefined) return;
2033
+ var area = doc.querySelector("textarea");
2034
+ if (area !== null && area !== undefined) instrument(area, "E", false);
2035
+ });
2036
+ variantsBox.append(frame);
2037
+ panel.append(variantsBox);
2038
+
2039
+ document.body.append(panel, log);
2040
+ /* A single panel: instructions + buttons + log + (collapsed) variants. */
2041
+ panel.append(log);
2042
+ diary.watch(log);
2043
+ diary.add("bench on; 直接在下方真实输入框里打字即可(变体在「展开变体 A-E」里)", true);
2044
+ return function () {
2045
+ diary.unwatch(log);
2046
+ panel.remove();
2047
+ };
2048
+ }
2049
+
156
2050
  exports.apply = apply;
2051
+ /* The takeover registers slot entries, so the plugin waits for the slot
2052
+ service (Cordis re-applies it once the service appears) — the same
2053
+ declaration shipped composer-contributing plugins use. Everything else
2054
+ (the session face, the seat) is resolved defensively at runtime, so a
2055
+ shell missing them keeps the stock composer instead of failing the
2056
+ plugin. */
2057
+ exports.inject = ["slots"];
157
2058
  return module.exports;
158
2059
  }
159
2060
  });