@cplieger/web-terminal-ui 1.0.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/LICENSE +692 -0
- package/README.md +76 -0
- package/css/00-tokens.css +33 -0
- package/css/01-reset.css +73 -0
- package/css/02-app.css +730 -0
- package/css/MANIFEST +5 -0
- package/package.json +58 -0
- package/scaffold/index.html +52 -0
- package/src/composition.ts +171 -0
- package/src/index.ts +10 -0
- package/src/mount.ts +798 -0
- package/src/predict.ts +183 -0
- package/src/status.ts +173 -0
- package/src/viewport.ts +98 -0
package/css/MANIFEST
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cplieger/web-terminal-ui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
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
|
+
"license": "GPL-3.0-or-later",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"typecheck": "tsgo -p tsconfig.json",
|
|
13
|
+
"typecheck:tests": "tsgo -p tsconfig.test.json",
|
|
14
|
+
"test": "vitest --run",
|
|
15
|
+
"test:watch": "vitest",
|
|
16
|
+
"test:coverage": "vitest --run --coverage",
|
|
17
|
+
"lint:eslint": "eslint .",
|
|
18
|
+
"lint:prettier": "prettier --check .",
|
|
19
|
+
"lint:fix": "eslint . --fix && prettier --write .",
|
|
20
|
+
"lint:knip": "knip",
|
|
21
|
+
"verify": "bash scripts/verify.sh"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@cplieger/web-terminal-engine": ">=1.2.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@eslint/js": "10.0.1",
|
|
28
|
+
"@types/node": "25.9.4",
|
|
29
|
+
"@vitest/coverage-v8": "4.1.9",
|
|
30
|
+
"eslint": "10.5.0",
|
|
31
|
+
"fast-check": "4.8.0",
|
|
32
|
+
"happy-dom": "20.10.6",
|
|
33
|
+
"html-validate": "11.5.3",
|
|
34
|
+
"knip": "6.20.0",
|
|
35
|
+
"prettier": "3.8.4",
|
|
36
|
+
"stylelint": "17.13.0",
|
|
37
|
+
"stylelint-config-standard": "40.0.0",
|
|
38
|
+
"typescript": "6.0.3",
|
|
39
|
+
"typescript-eslint": "8.62.0",
|
|
40
|
+
"vitest": "4.1.9"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/cplieger/web-terminal-ui.git"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"src/",
|
|
51
|
+
"css/",
|
|
52
|
+
"scaffold/",
|
|
53
|
+
"LICENSE",
|
|
54
|
+
"README.md",
|
|
55
|
+
"!src/**/*.test.ts",
|
|
56
|
+
"!src/**/fc-strict-setup.ts"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover,interactive-widget=resizes-content">
|
|
6
|
+
<meta name="color-scheme" content="dark">
|
|
7
|
+
<title>Web Terminal</title>
|
|
8
|
+
<!-- App icons and web-app manifest are deployment-specific; add your own. -->
|
|
9
|
+
<link rel="stylesheet" href="/style.css">
|
|
10
|
+
<!-- [html-validate-disable-next no-style-tag -- critical CSS for instant loading indicator] -->
|
|
11
|
+
<style>
|
|
12
|
+
#loading{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;z-index:200;background:#000;font-family:system-ui,-apple-system,sans-serif;color:#b48eff;font-size:18px;transition:opacity .3s ease}
|
|
13
|
+
#loading.fade{opacity:0}
|
|
14
|
+
#loading .spin{width:32px;height:32px;border:3px solid #b48eff;border-right-color:transparent;border-radius:50%;animation:sp .6s linear infinite}
|
|
15
|
+
@keyframes sp{to{transform:rotate(360deg)}}
|
|
16
|
+
</style>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<div id="term" class="term">
|
|
20
|
+
<div id="term-output" class="term-output" role="log" aria-live="off" aria-roledescription="Terminal" aria-label="Terminal"></div>
|
|
21
|
+
<textarea id="term-input" class="term-input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Terminal input" tabindex="-1"></textarea>
|
|
22
|
+
<div id="composition-view" class="composition-view" aria-hidden="true"></div>
|
|
23
|
+
<div id="pred-cursor" class="pred-cursor" aria-hidden="true"></div>
|
|
24
|
+
</div>
|
|
25
|
+
<div id="loading"><div class="spin"></div>Loading…</div>
|
|
26
|
+
<div id="conn-banner" class="conn-banner" role="status" aria-live="polite"></div>
|
|
27
|
+
<div id="key-toolbar" class="key-toolbar collapsed no-transition" aria-label="Navigation keys" role="toolbar">
|
|
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. -->
|
|
43
|
+
<script type="importmap">
|
|
44
|
+
{
|
|
45
|
+
"imports": {
|
|
46
|
+
"@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
<script type="module" src="/app.js"></script>
|
|
51
|
+
</body>
|
|
52
|
+
</html>
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// IME / composition support.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors xterm.js's CompositionHelper. The browser fires
|
|
4
|
+
// compositionstart → compositionupdate(s) → compositionend when an IME
|
|
5
|
+
// (Japanese / Korean / Chinese / Vietnamese / dictation / autocorrect
|
|
6
|
+
// suggestion bar) is active. Without a handler, the user sees nothing
|
|
7
|
+
// while typing because keydown/input fire only when the composed
|
|
8
|
+
// phrase is finalised.
|
|
9
|
+
//
|
|
10
|
+
// What this module does:
|
|
11
|
+
// 1. On compositionstart, mark composing=true and capture textarea
|
|
12
|
+
// selection bounds. Show the floating compositionView at the
|
|
13
|
+
// cursor with the in-progress text.
|
|
14
|
+
// 2. On compositionupdate, mirror the in-progress text into
|
|
15
|
+
// compositionView (\u200E LTR markers, RTL CSS direction so long
|
|
16
|
+
// compositions track their tail).
|
|
17
|
+
// 3. On compositionend, set a setTimeout(0) finaliser. The
|
|
18
|
+
// compositionend.data property is unreliable on Chromium (Korean
|
|
19
|
+
// ending consonant moves to next char on vowel input, etc.); the
|
|
20
|
+
// textarea value at next tick has the truth.
|
|
21
|
+
// 4. While composing, callers' keydown handler must early-return
|
|
22
|
+
// (handled via isComposing()). xterm.js takes the same approach.
|
|
23
|
+
//
|
|
24
|
+
// Positioning: the compositionView is absolutely positioned inside
|
|
25
|
+
// `termWrap`. The IME UI (browser-rendered popup with candidate words)
|
|
26
|
+
// uses the textarea as its anchor; we move the textarea to the cursor
|
|
27
|
+
// for the same reason.
|
|
28
|
+
|
|
29
|
+
import { keyboard } from "@cplieger/web-terminal-engine";
|
|
30
|
+
const { bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
31
|
+
|
|
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
|
+
let textarea: HTMLTextAreaElement;
|
|
39
|
+
let compositionView: HTMLElement;
|
|
40
|
+
let getCursorPx: () => { left: number; top: number; cellH: number };
|
|
41
|
+
let send: (bytes: string) => void;
|
|
42
|
+
|
|
43
|
+
let composing = false;
|
|
44
|
+
let sendingComposition = false;
|
|
45
|
+
let compositionStart = 0;
|
|
46
|
+
let compositionEnd = 0;
|
|
47
|
+
let compositionSuffix = "";
|
|
48
|
+
|
|
49
|
+
export function init(opts: {
|
|
50
|
+
textarea: HTMLTextAreaElement;
|
|
51
|
+
compositionView: HTMLElement;
|
|
52
|
+
getCursorPx: () => { left: number; top: number; cellH: number };
|
|
53
|
+
send: (bytes: string) => void;
|
|
54
|
+
}): void {
|
|
55
|
+
textarea = opts.textarea;
|
|
56
|
+
compositionView = opts.compositionView;
|
|
57
|
+
getCursorPx = opts.getCursorPx;
|
|
58
|
+
send = opts.send;
|
|
59
|
+
|
|
60
|
+
textarea.addEventListener("compositionstart", onStart);
|
|
61
|
+
textarea.addEventListener("compositionupdate", onUpdate);
|
|
62
|
+
textarea.addEventListener("compositionend", onEnd);
|
|
63
|
+
textarea.addEventListener("paste", onPaste);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True while an IME composition is in progress. Caller's keydown
|
|
67
|
+
* listener must early-return on true to avoid sending raw key bytes
|
|
68
|
+
* during composition. */
|
|
69
|
+
export function isComposing(): boolean {
|
|
70
|
+
return composing || sendingComposition;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function onStart(): void {
|
|
74
|
+
composing = true;
|
|
75
|
+
const start = textarea.selectionStart;
|
|
76
|
+
const end = textarea.selectionEnd;
|
|
77
|
+
compositionStart = Math.min(start, end);
|
|
78
|
+
compositionEnd = Math.max(start, end);
|
|
79
|
+
compositionSuffix = textarea.value.substring(compositionEnd);
|
|
80
|
+
compositionView.textContent = "";
|
|
81
|
+
compositionView.classList.add("active");
|
|
82
|
+
positionCompositionView();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function onUpdate(ev: CompositionEvent): void {
|
|
86
|
+
// \u200E (LTR mark) wrappers + CSS direction:rtl on the view make
|
|
87
|
+
// long compositions show their trailing edge instead of being
|
|
88
|
+
// clipped at the start. Pattern from xterm.js.
|
|
89
|
+
compositionView.textContent = `\u200E${ev.data}\u200E`;
|
|
90
|
+
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
|
+
}
|
|
98
|
+
|
|
99
|
+
function onEnd(): void {
|
|
100
|
+
compositionView.classList.remove("active");
|
|
101
|
+
composing = false;
|
|
102
|
+
// The compositionend event fires before the textarea reflects the
|
|
103
|
+
// final value on most browsers (Chromium especially). Defer one tick
|
|
104
|
+
// and read the textarea then; xterm.js's pattern.
|
|
105
|
+
sendingComposition = true;
|
|
106
|
+
const startSnapshot = compositionStart;
|
|
107
|
+
const suffixSnapshot = compositionSuffix;
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
if (!sendingComposition) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
sendingComposition = false;
|
|
113
|
+
const value = textarea.value;
|
|
114
|
+
const valueEnd =
|
|
115
|
+
suffixSnapshot.length > 0 && value.endsWith(suffixSnapshot)
|
|
116
|
+
? value.length - suffixSnapshot.length
|
|
117
|
+
: value.length;
|
|
118
|
+
const composed = value.substring(startSnapshot, Math.max(startSnapshot, valueEnd));
|
|
119
|
+
if (composed.length > 0) {
|
|
120
|
+
send(composed);
|
|
121
|
+
}
|
|
122
|
+
resetTextareaToPlaceholder();
|
|
123
|
+
}, 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function onPaste(ev: ClipboardEvent): void {
|
|
127
|
+
// Native `paste` event handler. Required for iOS where Ctrl+Shift+V
|
|
128
|
+
// is unavailable; users invoke paste from the iOS callout menu and
|
|
129
|
+
// it fires this event on the focused textarea.
|
|
130
|
+
if (!ev.clipboardData) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const raw = ev.clipboardData.getData("text/plain");
|
|
134
|
+
if (raw === "") {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
ev.preventDefault();
|
|
138
|
+
ev.stopPropagation();
|
|
139
|
+
send(bracketTextForPaste(prepareTextForTerminal(raw)));
|
|
140
|
+
// Restore the placeholder so the subsequent `input` event (some
|
|
141
|
+
// browsers fire it after paste) and held-Backspace auto-repeat keep
|
|
142
|
+
// working.
|
|
143
|
+
resetTextareaToPlaceholder();
|
|
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
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Position the composition view (and the helper textarea, which
|
|
157
|
+
* anchors the IME popup) at the current terminal cursor pixel
|
|
158
|
+
* position. iOS keyboards open without scrolling the layout when
|
|
159
|
+
* the focused textarea is on screen at a sane location. */
|
|
160
|
+
export function positionCompositionView(): void {
|
|
161
|
+
const { left, top, cellH } = getCursorPx();
|
|
162
|
+
compositionView.style.left = `${left}px`;
|
|
163
|
+
compositionView.style.top = `${top}px`;
|
|
164
|
+
compositionView.style.height = `${cellH}px`;
|
|
165
|
+
compositionView.style.lineHeight = `${cellH}px`;
|
|
166
|
+
// Textarea piggy-backs on the cursor position so iOS scroll-into-
|
|
167
|
+
// view targets the cursor area instead of jumping to top-left.
|
|
168
|
+
textarea.style.left = `${left}px`;
|
|
169
|
+
textarea.style.top = `${top}px`;
|
|
170
|
+
textarea.style.height = `${cellH}px`;
|
|
171
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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 mount()
|
|
3
|
+
// call against the shipped scaffold (scaffold/index.html) + CSS (css/).
|
|
4
|
+
//
|
|
5
|
+
// Consumers who want a different UI should depend on @cplieger/web-terminal-engine
|
|
6
|
+
// directly and wire the engine's render/scroll/connection/keyboard modules
|
|
7
|
+
// to their own DOM.
|
|
8
|
+
|
|
9
|
+
export { mount } from "./mount.js";
|
|
10
|
+
export type { MountOptions, TerminalUI } from "./mount.js";
|