@cplieger/web-terminal-ui 1.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -28
- package/css/00-tokens.css +1 -0
- package/css/02-app.css +34 -10
- package/package.json +5 -5
- package/scaffold/index.html +12 -25
- package/src/composition.ts +4 -26
- package/src/index.ts +3 -2
- package/src/input-placeholder.ts +29 -0
- package/src/mount.ts +303 -222
- package/src/predict.ts +13 -15
- package/src/status.ts +78 -23
package/README.md
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
# @cplieger/web-terminal-ui
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@cplieger/web-terminal-ui)
|
|
4
|
+
[](https://jsr.io/@cplieger/web-terminal-ui)
|
|
5
|
+
[](https://github.com/cplieger/web-terminal-ui/actions/workflows/coverage.yml)
|
|
6
|
+
[](https://www.bestpractices.dev/projects/13431)
|
|
7
|
+
[](https://scorecard.dev/viewer/?uri=github.com/cplieger/web-terminal-ui)
|
|
8
|
+
|
|
3
9
|
The reference touch-first browser UI for
|
|
4
10
|
[`@cplieger/web-terminal-engine`](https://github.com/cplieger/web-terminal-engine) — the part
|
|
5
11
|
that turns the engine's render/scroll/connection/keyboard modules into a
|
|
6
12
|
usable terminal on a phone as well as a desktop.
|
|
7
13
|
|
|
8
|
-
One `mount()` call
|
|
9
|
-
everything above the raw terminal:
|
|
14
|
+
One `mount(root)` call builds the entire terminal UI inside a single container
|
|
15
|
+
element you provide and owns everything above the raw terminal:
|
|
10
16
|
|
|
11
|
-
- a **display-only**
|
|
17
|
+
- a **display-only** terminal output (native text selection survives redraws)
|
|
12
18
|
- a hidden `<textarea>` that owns the keyboard, the local typing buffer, and IME
|
|
13
19
|
- a **mobile key toolbar** (Tab / Esc / arrows / Enter / sticky-Ctrl) and a
|
|
14
20
|
scroll-to-bottom control
|
|
@@ -20,7 +26,7 @@ everything above the raw terminal:
|
|
|
20
26
|
- a connection-status banner and a copy toast
|
|
21
27
|
|
|
22
28
|
It is published as TypeScript source (no build step) to npm and JSR, alongside
|
|
23
|
-
the CSS bundle and
|
|
29
|
+
the CSS bundle and a reference HTML page. Consumers who want a different UI should
|
|
24
30
|
depend on the engine directly and skip this package.
|
|
25
31
|
|
|
26
32
|
## Install
|
|
@@ -35,41 +41,73 @@ pins the engine version explicitly.
|
|
|
35
41
|
|
|
36
42
|
## Usage
|
|
37
43
|
|
|
38
|
-
Serve the
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
Serve the bundled CSS (`css/` concatenated per `css/MANIFEST` into the
|
|
45
|
+
`style.css` your page links) plus a minimal HTML page that has one empty
|
|
46
|
+
container element for the terminal, then call `mount(root)` from your entry
|
|
47
|
+
module:
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<div id="terminal"></div>
|
|
51
|
+
<div id="loading">Loading…</div>
|
|
52
|
+
<script type="importmap">
|
|
53
|
+
{
|
|
54
|
+
"imports": {
|
|
55
|
+
"@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js",
|
|
56
|
+
"@cplieger/web-terminal-ui": "/vendor/cplieger-web-terminal-ui/index.js"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
<script type="module">
|
|
61
|
+
import { mount } from "@cplieger/web-terminal-ui";
|
|
62
|
+
mount(document.getElementById("terminal"), {
|
|
63
|
+
loading: document.getElementById("loading"),
|
|
64
|
+
});
|
|
65
|
+
// or, for a server that exposes the WebSocket elsewhere / a custom font:
|
|
66
|
+
// mount(root, { wsPath: "/api/shell/ws", fontReady: '14px "MyMono"' });
|
|
67
|
+
</script>
|
|
48
68
|
```
|
|
49
69
|
|
|
50
|
-
`mount()`
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
`mount(root, opts?)` builds the entire terminal subtree (the display output,
|
|
71
|
+
the hidden keyboard textarea, the mobile key toolbar, the status banner, and
|
|
72
|
+
the context menu) inside `root` itself — there is no element-id contract for
|
|
73
|
+
the host page to reproduce. Call it exactly once; the module state is
|
|
74
|
+
single-instance per page. `scaffold/index.html` is a complete reference page to
|
|
75
|
+
copy and adapt.
|
|
55
76
|
|
|
56
77
|
### Options
|
|
57
78
|
|
|
58
|
-
| Option | Default | Purpose
|
|
59
|
-
| ----------- | -------------------------- |
|
|
60
|
-
| `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to.
|
|
61
|
-
| `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback.
|
|
79
|
+
| Option | Default | Purpose |
|
|
80
|
+
| ----------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
81
|
+
| `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
|
|
82
|
+
| `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
|
|
83
|
+
| `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); mount fades it out and removes it once the first frame renders. |
|
|
62
84
|
|
|
63
85
|
`mount()` returns a small handle: `{ focus() }` re-focuses the terminal input
|
|
64
86
|
(and opens the soft keyboard on touch).
|
|
65
87
|
|
|
66
88
|
## What ships
|
|
67
89
|
|
|
68
|
-
| Path | Purpose
|
|
69
|
-
| ------------------------ |
|
|
70
|
-
| `src/*.ts` | The UI modules (`mount`, IME, predictive echo, viewport, status).
|
|
71
|
-
| `css/*.css` + `MANIFEST` | The default theme + layout; concatenate in MANIFEST order.
|
|
72
|
-
| `scaffold/index.html` | A reference HTML
|
|
90
|
+
| Path | Purpose |
|
|
91
|
+
| ------------------------ | --------------------------------------------------------------------- |
|
|
92
|
+
| `src/*.ts` | The UI modules (`mount`, IME, predictive echo, viewport, status). |
|
|
93
|
+
| `css/*.css` + `MANIFEST` | The default theme + layout; concatenate in MANIFEST order. |
|
|
94
|
+
| `scaffold/index.html` | A reference HTML page: `<head>` + one empty root element + importmap. |
|
|
95
|
+
|
|
96
|
+
## Related projects
|
|
97
|
+
|
|
98
|
+
The web-terminal family:
|
|
99
|
+
|
|
100
|
+
- [`web-terminal-engine`](https://github.com/cplieger/web-terminal-engine) — the
|
|
101
|
+
Go session engine + TypeScript browser renderer this UI is built on (peer
|
|
102
|
+
dependency).
|
|
103
|
+
- [`web-terminal-server`](https://github.com/cplieger/web-terminal-server) — a
|
|
104
|
+
ready-to-run container that serves this UI over HTTP + WebSocket for any
|
|
105
|
+
command.
|
|
106
|
+
|
|
107
|
+
Consumers that ship this UI:
|
|
108
|
+
|
|
109
|
+
- [`vibekit`](https://github.com/cplieger/vibekit)
|
|
110
|
+
- [`vibecli`](https://github.com/cplieger/vibecli)
|
|
73
111
|
|
|
74
112
|
## License
|
|
75
113
|
|
package/css/00-tokens.css
CHANGED
package/css/02-app.css
CHANGED
|
@@ -48,11 +48,10 @@
|
|
|
48
48
|
client-side cap or a server-reported resume gap). A quiet, centered,
|
|
49
49
|
non-selectable divider so the user knows scrollback doesn't go further up. */
|
|
50
50
|
.term-trim-marker {
|
|
51
|
-
color: color-mix(in oklch, var(--text)
|
|
51
|
+
color: color-mix(in oklch, var(--text) 60%, transparent);
|
|
52
52
|
font-size: 0.6875rem;
|
|
53
53
|
text-align: center;
|
|
54
54
|
padding: 2px 0;
|
|
55
|
-
opacity: 0.7;
|
|
56
55
|
user-select: none;
|
|
57
56
|
font-family: var(--font-ui);
|
|
58
57
|
}
|
|
@@ -141,10 +140,11 @@
|
|
|
141
140
|
display: block;
|
|
142
141
|
}
|
|
143
142
|
|
|
144
|
-
/* Connection-state banner:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
/* Connection-state banner: bottom-center on touch (lifted above the
|
|
144
|
+
virtual keyboard via --kb-inset), top-center on desktop. Subtle
|
|
145
|
+
backdrop blur. Hidden until status.ts sets `.visible`. Distinct from
|
|
146
|
+
the `.pill` family because it wants to span the full width centrally
|
|
147
|
+
rather than dock to a corner. */
|
|
148
148
|
.conn-banner {
|
|
149
149
|
position: fixed;
|
|
150
150
|
bottom: var(--sp-3);
|
|
@@ -208,6 +208,18 @@
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/* Desktop (pointer: fine): move the banner to top-center so it no longer
|
|
212
|
+
shares the bottom-center anchor with the scroll-to-bottom FAB
|
|
213
|
+
(.kb-scroll-bottom). Sharing it let the banner (pointer-events:auto,
|
|
214
|
+
z-index:20) render over the FAB and intercept its click. Touch keeps
|
|
215
|
+
the bottom placement and the --kb-inset keyboard lift above. */
|
|
216
|
+
@media (pointer: fine) {
|
|
217
|
+
.conn-banner {
|
|
218
|
+
top: var(--sp-3);
|
|
219
|
+
bottom: auto;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
211
223
|
/* Clickable URLs */
|
|
212
224
|
.term-link {
|
|
213
225
|
color: inherit;
|
|
@@ -430,7 +442,7 @@
|
|
|
430
442
|
z-index: 10;
|
|
431
443
|
transition:
|
|
432
444
|
opacity var(--dur-standard) var(--ease-standard),
|
|
433
|
-
background-color
|
|
445
|
+
background-color var(--dur-micro) var(--ease-standard),
|
|
434
446
|
display var(--dur-standard) allow-discrete;
|
|
435
447
|
}
|
|
436
448
|
|
|
@@ -696,9 +708,9 @@
|
|
|
696
708
|
opacity: 0;
|
|
697
709
|
scale: 0.95;
|
|
698
710
|
transition:
|
|
699
|
-
opacity
|
|
700
|
-
scale
|
|
701
|
-
display
|
|
711
|
+
opacity var(--dur-exit) var(--ease-standard),
|
|
712
|
+
scale var(--dur-exit) var(--ease-standard),
|
|
713
|
+
display var(--dur-exit) allow-discrete;
|
|
702
714
|
}
|
|
703
715
|
|
|
704
716
|
.ctx-menu.visible {
|
|
@@ -728,3 +740,15 @@
|
|
|
728
740
|
.ctx-menu button:active {
|
|
729
741
|
background: var(--text-active);
|
|
730
742
|
}
|
|
743
|
+
|
|
744
|
+
/* Touch (long-press) menu: bump the rows to the 44px --touch-target
|
|
745
|
+
standard the rest of the touch UI uses (the key-toolbar grid is
|
|
746
|
+
repeat(4, var(--touch-target))). Desktop right-click keeps the
|
|
747
|
+
compact 24px menu. padding-block re-centres the label in the taller box. */
|
|
748
|
+
@media (pointer: coarse) {
|
|
749
|
+
.ctx-menu button {
|
|
750
|
+
min-height: var(--touch-target);
|
|
751
|
+
padding-block: var(--sp-2);
|
|
752
|
+
touch-action: manipulation;
|
|
753
|
+
}
|
|
754
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: textarea input model, mobile key toolbar, context menu, IME, predictive echo, viewport/keyboard-inset handling, and CSS. One mount() call.",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
"@eslint/js": "10.0.1",
|
|
28
28
|
"@types/node": "25.9.4",
|
|
29
29
|
"@vitest/coverage-v8": "4.1.9",
|
|
30
|
-
"eslint": "10.
|
|
30
|
+
"eslint": "10.6.0",
|
|
31
31
|
"fast-check": "4.8.0",
|
|
32
32
|
"happy-dom": "20.10.6",
|
|
33
33
|
"html-validate": "11.5.3",
|
|
34
|
-
"knip": "6.
|
|
35
|
-
"prettier": "3.
|
|
36
|
-
"stylelint": "17.
|
|
34
|
+
"knip": "6.22.0",
|
|
35
|
+
"prettier": "3.9.0",
|
|
36
|
+
"stylelint": "17.14.0",
|
|
37
37
|
"stylelint-config-standard": "40.0.0",
|
|
38
38
|
"typescript": "6.0.3",
|
|
39
39
|
"typescript-eslint": "8.62.0",
|
package/scaffold/index.html
CHANGED
|
@@ -16,37 +16,24 @@
|
|
|
16
16
|
</style>
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
<div id="pred-cursor" class="pred-cursor" aria-hidden="true"></div>
|
|
24
|
-
</div>
|
|
19
|
+
<!-- mount() builds the entire terminal UI inside this single root element.
|
|
20
|
+
The loading overlay is a sibling (not a child of the root) so mount never
|
|
21
|
+
clears it; mount fades it out once the first frame renders. -->
|
|
22
|
+
<div id="terminal"></div>
|
|
25
23
|
<div id="loading"><div class="spin"></div>Loading…</div>
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<button type="button" id="kb-toggle" class="kb-toggle" aria-label="Toggle key toolbar"><svg class="icon-hamburger" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg><svg class="icon-close" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>
|
|
29
|
-
<button type="button" id="kb-tab" class="kb-key kb-r1c1" aria-label="Tab">TAB</button>
|
|
30
|
-
<button type="button" id="kb-esc" class="kb-key kb-r1c2" aria-label="Escape">ESC</button>
|
|
31
|
-
<button type="button" id="kb-up" class="kb-key kb-r1c3" aria-label="Up"><svg viewBox="0 0 24 24"><polyline points="6 15 12 9 18 15"/></svg></button>
|
|
32
|
-
<button type="button" id="kb-enter" class="kb-key kb-r1c4" aria-label="Enter"><svg viewBox="0 0 24 24"><polyline points="9 10 4 15 9 20"/><polyline points="20 4 20 15 4 15"/></svg></button>
|
|
33
|
-
<button type="button" id="scroll-bottom" class="kb-scroll-bottom" aria-label="Scroll to bottom"><svg viewBox="0 0 24 24"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg></button>
|
|
34
|
-
<button type="button" id="kb-ctrl" class="kb-key kb-r2c1" aria-label="Sticky Ctrl modifier" aria-pressed="false">CTRL</button>
|
|
35
|
-
<button type="button" id="kb-left" class="kb-key kb-r2c2" aria-label="Left"><svg viewBox="0 0 24 24"><polyline points="15 6 9 12 15 18"/></svg></button>
|
|
36
|
-
<button type="button" id="kb-down" class="kb-key kb-r2c3" aria-label="Down"><svg viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg></button>
|
|
37
|
-
<button type="button" id="kb-right" class="kb-key kb-r2c4" aria-label="Right"><svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg></button>
|
|
38
|
-
</div>
|
|
39
|
-
<div id="ctx-menu" class="ctx-menu"></div>
|
|
40
|
-
<!-- Deployment-specific: the importmap resolves the bare engine specifier to
|
|
41
|
-
the engine's compiled JS, and /app.js is your bundle that calls
|
|
42
|
-
`mount()` from @cplieger/web-terminal-ui. Adjust paths to your build. -->
|
|
24
|
+
<!-- Deployment-specific: the importmap resolves the bare package specifiers to
|
|
25
|
+
the compiled JS your build emits. Adjust the paths to your build layout. -->
|
|
43
26
|
<script type="importmap">
|
|
44
27
|
{
|
|
45
28
|
"imports": {
|
|
46
|
-
"@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js"
|
|
29
|
+
"@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js",
|
|
30
|
+
"@cplieger/web-terminal-ui": "/vendor/cplieger-web-terminal-ui/index.js"
|
|
47
31
|
}
|
|
48
32
|
}
|
|
49
33
|
</script>
|
|
50
|
-
<script type="module"
|
|
34
|
+
<script type="module">
|
|
35
|
+
import { mount } from "@cplieger/web-terminal-ui";
|
|
36
|
+
mount(document.getElementById("terminal"), { loading: document.getElementById("loading") });
|
|
37
|
+
</script>
|
|
51
38
|
</body>
|
|
52
39
|
</html>
|
package/src/composition.ts
CHANGED
|
@@ -27,14 +27,9 @@
|
|
|
27
27
|
// for the same reason.
|
|
28
28
|
|
|
29
29
|
import { keyboard } from "@cplieger/web-terminal-engine";
|
|
30
|
+
import { resetToPlaceholder } from "./input-placeholder.js";
|
|
30
31
|
const { bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
31
32
|
|
|
32
|
-
// Single-character invisible placeholder used by app.ts to keep the
|
|
33
|
-
// iOS soft keyboard's held-Backspace auto-repeat firing. Composition
|
|
34
|
-
// finalisation must restore the placeholder (and not empty the
|
|
35
|
-
// textarea) for the same reason.
|
|
36
|
-
const INPUT_PLACEHOLDER = "\u00A0";
|
|
37
|
-
|
|
38
33
|
let textarea: HTMLTextAreaElement;
|
|
39
34
|
let compositionView: HTMLElement;
|
|
40
35
|
let getCursorPx: () => { left: number; top: number; cellH: number };
|
|
@@ -43,7 +38,6 @@ let send: (bytes: string) => void;
|
|
|
43
38
|
let composing = false;
|
|
44
39
|
let sendingComposition = false;
|
|
45
40
|
let compositionStart = 0;
|
|
46
|
-
let compositionEnd = 0;
|
|
47
41
|
let compositionSuffix = "";
|
|
48
42
|
|
|
49
43
|
export function init(opts: {
|
|
@@ -75,7 +69,7 @@ function onStart(): void {
|
|
|
75
69
|
const start = textarea.selectionStart;
|
|
76
70
|
const end = textarea.selectionEnd;
|
|
77
71
|
compositionStart = Math.min(start, end);
|
|
78
|
-
compositionEnd = Math.max(start, end);
|
|
72
|
+
const compositionEnd = Math.max(start, end);
|
|
79
73
|
compositionSuffix = textarea.value.substring(compositionEnd);
|
|
80
74
|
compositionView.textContent = "";
|
|
81
75
|
compositionView.classList.add("active");
|
|
@@ -88,12 +82,6 @@ function onUpdate(ev: CompositionEvent): void {
|
|
|
88
82
|
// clipped at the start. Pattern from xterm.js.
|
|
89
83
|
compositionView.textContent = `\u200E${ev.data}\u200E`;
|
|
90
84
|
positionCompositionView();
|
|
91
|
-
// Schedule a microtask to refresh the end position after the
|
|
92
|
-
// textarea selection settles.
|
|
93
|
-
setTimeout(() => {
|
|
94
|
-
const end = textarea.selectionEnd;
|
|
95
|
-
compositionEnd = Math.max(compositionStart, end);
|
|
96
|
-
}, 0);
|
|
97
85
|
}
|
|
98
86
|
|
|
99
87
|
function onEnd(): void {
|
|
@@ -119,7 +107,7 @@ function onEnd(): void {
|
|
|
119
107
|
if (composed.length > 0) {
|
|
120
108
|
send(composed);
|
|
121
109
|
}
|
|
122
|
-
|
|
110
|
+
resetToPlaceholder(textarea);
|
|
123
111
|
}, 0);
|
|
124
112
|
}
|
|
125
113
|
|
|
@@ -140,17 +128,7 @@ function onPaste(ev: ClipboardEvent): void {
|
|
|
140
128
|
// Restore the placeholder so the subsequent `input` event (some
|
|
141
129
|
// browsers fire it after paste) and held-Backspace auto-repeat keep
|
|
142
130
|
// working.
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function resetTextareaToPlaceholder(): void {
|
|
147
|
-
textarea.value = INPUT_PLACEHOLDER;
|
|
148
|
-
try {
|
|
149
|
-
textarea.setSelectionRange(INPUT_PLACEHOLDER.length, INPUT_PLACEHOLDER.length);
|
|
150
|
-
} catch {
|
|
151
|
-
// Some older WebKit builds throw on setSelectionRange against a
|
|
152
|
-
// visually-hidden textarea; ignore.
|
|
153
|
-
}
|
|
131
|
+
resetToPlaceholder(textarea);
|
|
154
132
|
}
|
|
155
133
|
|
|
156
134
|
/** Position the composition view (and the helper textarea, which
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @cplieger/web-terminal-ui — the reference touch-first browser UI built on
|
|
2
|
-
// the @cplieger/web-terminal-engine engine. The public surface is a single
|
|
3
|
-
// call
|
|
2
|
+
// the @cplieger/web-terminal-engine engine. The public surface is a single
|
|
3
|
+
// mount(root) call that builds the whole terminal subtree inside a
|
|
4
|
+
// host-provided element; scaffold/index.html + css/ are a reference page to copy.
|
|
4
5
|
//
|
|
5
6
|
// Consumers who want a different UI should depend on @cplieger/web-terminal-engine
|
|
6
7
|
// directly and wire the engine's render/scroll/connection/keyboard modules
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Single source of truth for the hidden-textarea placeholder shared by
|
|
2
|
+
// mount.ts and composition.ts.
|
|
3
|
+
//
|
|
4
|
+
// A single-character placeholder is kept in the hidden textarea so iOS soft
|
|
5
|
+
// keyboards have something to "delete" when the user holds Backspace. iOS only
|
|
6
|
+
// fires repeating `input` events with inputType="deleteContentBackward" when
|
|
7
|
+
// the textarea has content to delete; with a perpetually empty textarea,
|
|
8
|
+
// holding Backspace deletes one char and stops because iOS sees nothing more to
|
|
9
|
+
// remove. The placeholder itself is invisible (textarea has opacity:0) and is
|
|
10
|
+
// stripped out of every send. NBSP is chosen specifically so screen-reader
|
|
11
|
+
// announcement of the input state stays empty-ish rather than "space".
|
|
12
|
+
//
|
|
13
|
+
// The constant AND the reset routine must stay in lockstep across the two
|
|
14
|
+
// modules (mount.ts seeds + re-pads it; composition.ts restores it on
|
|
15
|
+
// compositionend / paste) or the iOS held-Backspace key-repeat silently breaks
|
|
16
|
+
// with no compile error — which is why they live here in one place.
|
|
17
|
+
export const INPUT_PLACEHOLDER = "\u00A0";
|
|
18
|
+
|
|
19
|
+
// Restore the placeholder and put the cursor at the end, so the next typed
|
|
20
|
+
// character appends after it rather than before.
|
|
21
|
+
export function resetToPlaceholder(textarea: HTMLTextAreaElement): void {
|
|
22
|
+
textarea.value = INPUT_PLACEHOLDER;
|
|
23
|
+
try {
|
|
24
|
+
textarea.setSelectionRange(INPUT_PLACEHOLDER.length, INPUT_PLACEHOLDER.length);
|
|
25
|
+
} catch {
|
|
26
|
+
// Some older WebKit builds throw on setSelectionRange against a
|
|
27
|
+
// visually-hidden textarea; ignore.
|
|
28
|
+
}
|
|
29
|
+
}
|