remobi 0.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/CHANGELOG.md +77 -0
- package/LICENSE +21 -0
- package/README.md +302 -0
- package/dist/build.d.mts +17 -0
- package/dist/build.d.mts.map +1 -0
- package/dist/build.mjs +115 -0
- package/dist/build.mjs.map +1 -0
- package/dist/catppuccin-mocha-CGTshAuT.mjs +48 -0
- package/dist/catppuccin-mocha-CGTshAuT.mjs.map +1 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +945 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/node-compat-BzXgbTV9.mjs +83 -0
- package/dist/node-compat-BzXgbTV9.mjs.map +1 -0
- package/dist/overlay.iife.js +1 -0
- package/dist/src/config.d.mts +22 -0
- package/dist/src/config.d.mts.map +1 -0
- package/dist/src/config.mjs +384 -0
- package/dist/src/config.mjs.map +1 -0
- package/dist/src/index.d.mts +76 -0
- package/dist/src/index.d.mts.map +1 -0
- package/dist/src/index.mjs +1637 -0
- package/dist/src/index.mjs.map +1 -0
- package/dist/src/types.d.mts +183 -0
- package/dist/src/types.d.mts.map +1 -0
- package/dist/src/types.mjs +1 -0
- package/package.json +88 -0
- package/src/pwa/icons/icon-180.png +0 -0
- package/src/pwa/icons/icon-192.png +0 -0
- package/src/pwa/icons/icon-512.png +0 -0
- package/styles/base.css +475 -0
|
@@ -0,0 +1,1637 @@
|
|
|
1
|
+
import { defaultConfig, defineConfig } from "./config.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/actions/registry.ts
|
|
4
|
+
function createActionRegistry() {
|
|
5
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
6
|
+
let sendQueue = Promise.resolve();
|
|
7
|
+
function register(type, handler) {
|
|
8
|
+
handlers.set(type, handler);
|
|
9
|
+
}
|
|
10
|
+
async function execute(action, context) {
|
|
11
|
+
const handler = handlers.get(action.type);
|
|
12
|
+
if (!handler) return false;
|
|
13
|
+
if (action.type === "send") {
|
|
14
|
+
const current = sendQueue.then(async () => {
|
|
15
|
+
await handler(action, context);
|
|
16
|
+
});
|
|
17
|
+
sendQueue = current.catch(() => {});
|
|
18
|
+
await current;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (action.type === "paste") {
|
|
22
|
+
await sendQueue;
|
|
23
|
+
await handler(action, context);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
await handler(action, context);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
register,
|
|
31
|
+
execute
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function createDefaultActionRegistry() {
|
|
35
|
+
const registry = createActionRegistry();
|
|
36
|
+
let pasteQueue = Promise.resolve();
|
|
37
|
+
registry.register("send", (action, context) => {
|
|
38
|
+
if (action.type !== "send") return;
|
|
39
|
+
return context.sendText(action.data).then(() => context.focusIfNeeded());
|
|
40
|
+
});
|
|
41
|
+
registry.register("paste", (_action, context) => {
|
|
42
|
+
if (!navigator.clipboard || typeof navigator.clipboard.readText !== "function") {
|
|
43
|
+
context.focusIfNeeded();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const runPaste = async () => {
|
|
47
|
+
try {
|
|
48
|
+
const text = await navigator.clipboard.readText();
|
|
49
|
+
if (!text) return;
|
|
50
|
+
if (context.sendRawText) {
|
|
51
|
+
await context.sendRawText(text);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await context.sendText(text);
|
|
55
|
+
} catch {} finally {
|
|
56
|
+
context.focusIfNeeded();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const current = pasteQueue.then(runPaste);
|
|
60
|
+
pasteQueue = current.catch(() => {});
|
|
61
|
+
return current;
|
|
62
|
+
});
|
|
63
|
+
registry.register("ctrl-modifier", (_action, context) => {
|
|
64
|
+
if (context.toggleCtrlModifier) context.toggleCtrlModifier();
|
|
65
|
+
else context.focusIfNeeded();
|
|
66
|
+
});
|
|
67
|
+
registry.register("drawer-toggle", (_action, context) => {
|
|
68
|
+
if (context.openDrawer) context.openDrawer();
|
|
69
|
+
else context.focusIfNeeded();
|
|
70
|
+
});
|
|
71
|
+
registry.register("combo-picker", (_action, context) => {
|
|
72
|
+
if (context.openComboPicker) context.openComboPicker({
|
|
73
|
+
sendText: async (data) => {
|
|
74
|
+
await registry.execute({
|
|
75
|
+
type: "send",
|
|
76
|
+
data
|
|
77
|
+
}, {
|
|
78
|
+
...context,
|
|
79
|
+
sendText: context.sendRawText ?? context.sendText
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
focusIfNeeded: context.focusIfNeeded
|
|
83
|
+
});
|
|
84
|
+
else context.focusIfNeeded();
|
|
85
|
+
});
|
|
86
|
+
return registry;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/util/dom.ts
|
|
91
|
+
/** Create an element with optional attributes and children */
|
|
92
|
+
function el(tag, attrs, ...children) {
|
|
93
|
+
const element = document.createElement(tag);
|
|
94
|
+
if (attrs) for (const [key, value] of Object.entries(attrs)) element.setAttribute(key, value);
|
|
95
|
+
for (const child of children) if (typeof child === "string") element.appendChild(document.createTextNode(child));
|
|
96
|
+
else element.appendChild(child);
|
|
97
|
+
return element;
|
|
98
|
+
}
|
|
99
|
+
/** Create a button with label and aria-label */
|
|
100
|
+
function btn(label, ariaLabel) {
|
|
101
|
+
const button = el("button");
|
|
102
|
+
button.textContent = label;
|
|
103
|
+
if (ariaLabel) button.setAttribute("aria-label", ariaLabel);
|
|
104
|
+
return button;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/util/haptic.ts
|
|
109
|
+
/** Short haptic vibration — no-op on devices without vibration API */
|
|
110
|
+
function haptic() {
|
|
111
|
+
if (navigator.vibrate) navigator.vibrate(10);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/controls/combo-picker.ts
|
|
116
|
+
const NAMED_KEYS = [
|
|
117
|
+
"pagedown",
|
|
118
|
+
"pageup",
|
|
119
|
+
"return",
|
|
120
|
+
"escape",
|
|
121
|
+
"backspace",
|
|
122
|
+
"delete",
|
|
123
|
+
"enter",
|
|
124
|
+
"space",
|
|
125
|
+
"tab",
|
|
126
|
+
"home",
|
|
127
|
+
"end",
|
|
128
|
+
"left",
|
|
129
|
+
"right",
|
|
130
|
+
"down",
|
|
131
|
+
"up",
|
|
132
|
+
"pgdn",
|
|
133
|
+
"pgup",
|
|
134
|
+
"del",
|
|
135
|
+
"esc",
|
|
136
|
+
"bs"
|
|
137
|
+
];
|
|
138
|
+
function parseComboTokens(value) {
|
|
139
|
+
const trimmed = value.trim();
|
|
140
|
+
if (trimmed.length === 0) return null;
|
|
141
|
+
let keyToken = null;
|
|
142
|
+
let prefix = "";
|
|
143
|
+
for (const key of NAMED_KEYS) {
|
|
144
|
+
const pattern = new RegExp(`(?:^|[+\\-\\s])(${key})$`, "i");
|
|
145
|
+
const match = trimmed.match(pattern);
|
|
146
|
+
if (!match || match.index === void 0) continue;
|
|
147
|
+
const matchedKey = match[1];
|
|
148
|
+
if (!matchedKey) continue;
|
|
149
|
+
const keyStart = match.index + match[0].length - matchedKey.length;
|
|
150
|
+
keyToken = matchedKey;
|
|
151
|
+
prefix = trimmed.slice(0, keyStart);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
if (!keyToken) {
|
|
155
|
+
keyToken = trimmed[trimmed.length - 1] ?? "";
|
|
156
|
+
prefix = trimmed.slice(0, -1);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
modifiers: prefix.split(/[+\-\s]+/).map((token) => token.trim()).filter((token) => token.length > 0),
|
|
160
|
+
key: keyToken
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function resolveBaseKey(key, keyLower) {
|
|
164
|
+
if (key.length === 1) return {
|
|
165
|
+
ok: true,
|
|
166
|
+
data: key
|
|
167
|
+
};
|
|
168
|
+
if (keyLower === "enter" || keyLower === "return") return {
|
|
169
|
+
ok: true,
|
|
170
|
+
data: "\r"
|
|
171
|
+
};
|
|
172
|
+
if (keyLower === "tab") return {
|
|
173
|
+
ok: true,
|
|
174
|
+
data: " "
|
|
175
|
+
};
|
|
176
|
+
if (keyLower === "space") return {
|
|
177
|
+
ok: true,
|
|
178
|
+
data: " "
|
|
179
|
+
};
|
|
180
|
+
if (keyLower === "esc" || keyLower === "escape") return {
|
|
181
|
+
ok: true,
|
|
182
|
+
data: "\x1B"
|
|
183
|
+
};
|
|
184
|
+
if (keyLower === "backspace" || keyLower === "bs") return {
|
|
185
|
+
ok: true,
|
|
186
|
+
data: ""
|
|
187
|
+
};
|
|
188
|
+
if (keyLower === "delete" || keyLower === "del") return {
|
|
189
|
+
ok: true,
|
|
190
|
+
data: "\x1B[3~"
|
|
191
|
+
};
|
|
192
|
+
if (keyLower === "up") return {
|
|
193
|
+
ok: true,
|
|
194
|
+
data: "\x1B[A"
|
|
195
|
+
};
|
|
196
|
+
if (keyLower === "down") return {
|
|
197
|
+
ok: true,
|
|
198
|
+
data: "\x1B[B"
|
|
199
|
+
};
|
|
200
|
+
if (keyLower === "right") return {
|
|
201
|
+
ok: true,
|
|
202
|
+
data: "\x1B[C"
|
|
203
|
+
};
|
|
204
|
+
if (keyLower === "left") return {
|
|
205
|
+
ok: true,
|
|
206
|
+
data: "\x1B[D"
|
|
207
|
+
};
|
|
208
|
+
if (keyLower === "home") return {
|
|
209
|
+
ok: true,
|
|
210
|
+
data: "\x1B[H"
|
|
211
|
+
};
|
|
212
|
+
if (keyLower === "end") return {
|
|
213
|
+
ok: true,
|
|
214
|
+
data: "\x1B[F"
|
|
215
|
+
};
|
|
216
|
+
if (keyLower === "pageup" || keyLower === "pgup") return {
|
|
217
|
+
ok: true,
|
|
218
|
+
data: "\x1B[5~"
|
|
219
|
+
};
|
|
220
|
+
if (keyLower === "pagedown" || keyLower === "pgdn") return {
|
|
221
|
+
ok: true,
|
|
222
|
+
data: "\x1B[6~"
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
error: "Unknown key. Try one character, Enter, Tab, Space, Esc, arrows, Home/End, PgUp/PgDn."
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function applyCtrl(base, key, keyLower) {
|
|
230
|
+
if (base.length !== 1) return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: "Ctrl supports single characters (for Enter use M-Enter)."
|
|
233
|
+
};
|
|
234
|
+
if (keyLower === "space") return {
|
|
235
|
+
ok: true,
|
|
236
|
+
data: "\0"
|
|
237
|
+
};
|
|
238
|
+
if (key.length !== 1) return {
|
|
239
|
+
ok: false,
|
|
240
|
+
error: "Unsupported Ctrl combo for this key."
|
|
241
|
+
};
|
|
242
|
+
if (key === "[") return {
|
|
243
|
+
ok: true,
|
|
244
|
+
data: "\x1B"
|
|
245
|
+
};
|
|
246
|
+
if (key === "\\") return {
|
|
247
|
+
ok: true,
|
|
248
|
+
data: ""
|
|
249
|
+
};
|
|
250
|
+
if (key === "]") return {
|
|
251
|
+
ok: true,
|
|
252
|
+
data: ""
|
|
253
|
+
};
|
|
254
|
+
if (key === "6") return {
|
|
255
|
+
ok: true,
|
|
256
|
+
data: ""
|
|
257
|
+
};
|
|
258
|
+
if (key === "-" || key === "/") return {
|
|
259
|
+
ok: true,
|
|
260
|
+
data: ""
|
|
261
|
+
};
|
|
262
|
+
if (key === "8") return {
|
|
263
|
+
ok: true,
|
|
264
|
+
data: ""
|
|
265
|
+
};
|
|
266
|
+
const code = key.charCodeAt(0);
|
|
267
|
+
if (code >= 65 && code <= 90 || code >= 97 && code <= 122) return {
|
|
268
|
+
ok: true,
|
|
269
|
+
data: String.fromCharCode(code & 31)
|
|
270
|
+
};
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
error: "Unsupported Ctrl combo for this key."
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function parseComboInput(value) {
|
|
277
|
+
const tokens = parseComboTokens(value);
|
|
278
|
+
if (!tokens) return {
|
|
279
|
+
ok: false,
|
|
280
|
+
error: "Type a combo like C-s, M-Enter, or C-[."
|
|
281
|
+
};
|
|
282
|
+
let ctrl = false;
|
|
283
|
+
let alt = false;
|
|
284
|
+
for (const modifier of tokens.modifiers) {
|
|
285
|
+
const token = modifier.toLowerCase();
|
|
286
|
+
if (token === "c" || token === "ctrl" || token === "control") {
|
|
287
|
+
ctrl = true;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (token === "m" || token === "meta" || token === "alt" || token === "a") {
|
|
291
|
+
alt = true;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (token === "s" || token === "shift") continue;
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
error: `Unknown modifier: ${modifier}`
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const keyToken = tokens.key;
|
|
301
|
+
if (!keyToken) return {
|
|
302
|
+
ok: false,
|
|
303
|
+
error: "Missing key in combo."
|
|
304
|
+
};
|
|
305
|
+
const keyLower = keyToken.toLowerCase();
|
|
306
|
+
const base = resolveBaseKey(keyToken, keyLower);
|
|
307
|
+
if (!base.ok) return base;
|
|
308
|
+
let data = base.data;
|
|
309
|
+
if (ctrl) {
|
|
310
|
+
const next = applyCtrl(data, keyToken, keyLower);
|
|
311
|
+
if (!next.ok) return next;
|
|
312
|
+
data = next.data;
|
|
313
|
+
}
|
|
314
|
+
if (alt) data = `\x1b${data}`;
|
|
315
|
+
return {
|
|
316
|
+
ok: true,
|
|
317
|
+
data
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function createComboPicker() {
|
|
321
|
+
const backdrop = el("div", { id: "wt-combo-backdrop" });
|
|
322
|
+
const panel = el("div", { id: "wt-combo-panel" });
|
|
323
|
+
const title = el("h3");
|
|
324
|
+
title.textContent = "Send combo";
|
|
325
|
+
const description = el("p");
|
|
326
|
+
description.textContent = "Examples: C-s, C-[, M-Enter, Alt-x";
|
|
327
|
+
const input = el("input", {
|
|
328
|
+
type: "text",
|
|
329
|
+
placeholder: "Combo",
|
|
330
|
+
"aria-label": "Combo input",
|
|
331
|
+
autocomplete: "off",
|
|
332
|
+
autocorrect: "off",
|
|
333
|
+
autocapitalize: "off",
|
|
334
|
+
spellcheck: "false"
|
|
335
|
+
});
|
|
336
|
+
const error = el("p", { class: "wt-combo-error" });
|
|
337
|
+
const actions = el("div", { class: "wt-combo-actions" });
|
|
338
|
+
const cancelButton = el("button", { type: "button" }, "Cancel");
|
|
339
|
+
const sendButton = el("button", { type: "button" }, "Send");
|
|
340
|
+
actions.appendChild(cancelButton);
|
|
341
|
+
actions.appendChild(sendButton);
|
|
342
|
+
panel.appendChild(title);
|
|
343
|
+
panel.appendChild(description);
|
|
344
|
+
panel.appendChild(input);
|
|
345
|
+
panel.appendChild(error);
|
|
346
|
+
panel.appendChild(actions);
|
|
347
|
+
backdrop.appendChild(panel);
|
|
348
|
+
let currentDispatch = null;
|
|
349
|
+
function clearError() {
|
|
350
|
+
error.textContent = "";
|
|
351
|
+
}
|
|
352
|
+
function setError(message) {
|
|
353
|
+
error.textContent = message;
|
|
354
|
+
}
|
|
355
|
+
function closeAndFocus() {
|
|
356
|
+
const dispatch = currentDispatch;
|
|
357
|
+
backdrop.style.display = "none";
|
|
358
|
+
currentDispatch = null;
|
|
359
|
+
clearError();
|
|
360
|
+
input.value = "";
|
|
361
|
+
if (dispatch) dispatch.focusIfNeeded();
|
|
362
|
+
}
|
|
363
|
+
async function submit() {
|
|
364
|
+
const dispatch = currentDispatch;
|
|
365
|
+
if (!dispatch) return;
|
|
366
|
+
const parsed = parseComboInput(input.value);
|
|
367
|
+
if (!parsed.ok) {
|
|
368
|
+
setError(parsed.error);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
backdrop.style.display = "none";
|
|
372
|
+
currentDispatch = null;
|
|
373
|
+
clearError();
|
|
374
|
+
input.value = "";
|
|
375
|
+
try {
|
|
376
|
+
await dispatch.sendText(parsed.data);
|
|
377
|
+
} catch (errorValue) {
|
|
378
|
+
console.error("remobi: combo send failed", errorValue);
|
|
379
|
+
} finally {
|
|
380
|
+
dispatch.focusIfNeeded();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function open(dispatch) {
|
|
384
|
+
currentDispatch = dispatch;
|
|
385
|
+
clearError();
|
|
386
|
+
input.value = "";
|
|
387
|
+
backdrop.style.display = "flex";
|
|
388
|
+
setTimeout(() => input.focus(), 0);
|
|
389
|
+
}
|
|
390
|
+
function close() {
|
|
391
|
+
closeAndFocus();
|
|
392
|
+
}
|
|
393
|
+
backdrop.addEventListener("click", (event) => {
|
|
394
|
+
if (event.target !== backdrop) return;
|
|
395
|
+
haptic();
|
|
396
|
+
closeAndFocus();
|
|
397
|
+
});
|
|
398
|
+
cancelButton.addEventListener("click", (event) => {
|
|
399
|
+
event.preventDefault();
|
|
400
|
+
haptic();
|
|
401
|
+
closeAndFocus();
|
|
402
|
+
});
|
|
403
|
+
sendButton.addEventListener("click", (event) => {
|
|
404
|
+
event.preventDefault();
|
|
405
|
+
haptic();
|
|
406
|
+
submit();
|
|
407
|
+
});
|
|
408
|
+
input.addEventListener("keydown", (event) => {
|
|
409
|
+
if (event.key === "Enter") {
|
|
410
|
+
event.preventDefault();
|
|
411
|
+
haptic();
|
|
412
|
+
submit();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (event.key === "Escape") {
|
|
416
|
+
event.preventDefault();
|
|
417
|
+
haptic();
|
|
418
|
+
closeAndFocus();
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
return {
|
|
422
|
+
element: backdrop,
|
|
423
|
+
open,
|
|
424
|
+
close
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
//#endregion
|
|
429
|
+
//#region src/util/keyboard.ts
|
|
430
|
+
/** Threshold in pixels — if the gap between innerHeight and viewport height exceeds this, the keyboard is open */
|
|
431
|
+
const KB_THRESHOLD = 150;
|
|
432
|
+
/** Check whether the virtual keyboard appears to be open */
|
|
433
|
+
function isKeyboardOpen() {
|
|
434
|
+
const vp = window.visualViewport;
|
|
435
|
+
if (!vp) return false;
|
|
436
|
+
return window.innerHeight - vp.height > KB_THRESHOLD;
|
|
437
|
+
}
|
|
438
|
+
/** Focus terminal only if the keyboard was already visible */
|
|
439
|
+
function conditionalFocus(term, kbWasOpen) {
|
|
440
|
+
if (kbWasOpen) term.focus();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/util/terminal.ts
|
|
445
|
+
/** Send data to the terminal as if the user typed it */
|
|
446
|
+
function sendData(term, data) {
|
|
447
|
+
term.input(data, true);
|
|
448
|
+
}
|
|
449
|
+
/** Trigger xterm resize via window resize event */
|
|
450
|
+
function resizeTerm() {
|
|
451
|
+
window.dispatchEvent(new Event("resize"));
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Wait for `window.term` to become available (ttyd sets it).
|
|
455
|
+
* Resolves with the terminal instance, rejects after maxRetries (default 100 = 10s).
|
|
456
|
+
*/
|
|
457
|
+
function waitForTerm(maxRetries = 100) {
|
|
458
|
+
return new Promise((resolve, reject) => {
|
|
459
|
+
let attempts = 0;
|
|
460
|
+
function check() {
|
|
461
|
+
if (window.term) resolve(window.term);
|
|
462
|
+
else if (attempts >= maxRetries) reject(/* @__PURE__ */ new Error(`waitForTerm: window.term not available after ${maxRetries * 100}ms`));
|
|
463
|
+
else {
|
|
464
|
+
attempts += 1;
|
|
465
|
+
setTimeout(check, 100);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
check();
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
//#endregion
|
|
473
|
+
//#region src/controls/floating-buttons.ts
|
|
474
|
+
function createGroupButton(term, def, config, hooks, actions, openDrawer, openComboPicker) {
|
|
475
|
+
const button = el("button");
|
|
476
|
+
button.textContent = def.label;
|
|
477
|
+
button.setAttribute("aria-label", def.description);
|
|
478
|
+
button.addEventListener("click", (e) => {
|
|
479
|
+
e.preventDefault();
|
|
480
|
+
const kbWasOpen = isKeyboardOpen();
|
|
481
|
+
haptic();
|
|
482
|
+
async function sendWithHooks(data) {
|
|
483
|
+
const before = await hooks.runBeforeSendData({
|
|
484
|
+
term,
|
|
485
|
+
config,
|
|
486
|
+
source: "floating-buttons",
|
|
487
|
+
actionType: def.action.type,
|
|
488
|
+
kbWasOpen,
|
|
489
|
+
data
|
|
490
|
+
});
|
|
491
|
+
if (before.blocked) return;
|
|
492
|
+
sendData(term, before.data);
|
|
493
|
+
await hooks.runAfterSendData({
|
|
494
|
+
term,
|
|
495
|
+
config,
|
|
496
|
+
source: "floating-buttons",
|
|
497
|
+
actionType: def.action.type,
|
|
498
|
+
kbWasOpen,
|
|
499
|
+
data: before.data
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
actions.execute(def.action, {
|
|
503
|
+
term,
|
|
504
|
+
kbWasOpen,
|
|
505
|
+
focusIfNeeded: () => conditionalFocus(term, kbWasOpen),
|
|
506
|
+
sendText: sendWithHooks,
|
|
507
|
+
sendRawText: sendWithHooks,
|
|
508
|
+
openDrawer,
|
|
509
|
+
openComboPicker
|
|
510
|
+
}).catch((error) => {
|
|
511
|
+
console.error("remobi: floating button action failed", error);
|
|
512
|
+
conditionalFocus(term, kbWasOpen);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
return button;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Create one container element per floating button group.
|
|
519
|
+
* Each group is positioned via CSS classes (`wt-floating-group`, `wt-floating-${position}`)
|
|
520
|
+
* and rendered as a row or column depending on `direction` (default: row).
|
|
521
|
+
*/
|
|
522
|
+
function createFloatingButtons(term, groups, config, hooks, actions, openDrawer, openComboPicker) {
|
|
523
|
+
const elements = [];
|
|
524
|
+
for (const group of groups) {
|
|
525
|
+
const container = el("div", { class: `wt-floating-group wt-floating-${group.position}${group.direction === "column" ? " wt-floating-column" : ""}` });
|
|
526
|
+
for (const def of group.buttons) container.appendChild(createGroupButton(term, def, config, hooks, actions, openDrawer, openComboPicker));
|
|
527
|
+
elements.push(container);
|
|
528
|
+
}
|
|
529
|
+
return { elements };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
//#endregion
|
|
533
|
+
//#region src/controls/font-size.ts
|
|
534
|
+
/** Change terminal font size by delta, clamped to config range */
|
|
535
|
+
function changeFontSize(term, delta, font) {
|
|
536
|
+
const current = term.options.fontSize;
|
|
537
|
+
const next = Math.max(font.sizeRange[0], Math.min(font.sizeRange[1], current + delta));
|
|
538
|
+
if (next !== current) {
|
|
539
|
+
term.options.fontSize = next;
|
|
540
|
+
resizeTerm();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/** Create the font size controls (-, +) and help button */
|
|
544
|
+
function createFontControls(term, font) {
|
|
545
|
+
const container = el("div", { id: "wt-font-controls" });
|
|
546
|
+
const btnMinus = btn("−", "Decrease font size");
|
|
547
|
+
const btnPlus = btn("+", "Increase font size");
|
|
548
|
+
const btnHelp = btn("?", "Help");
|
|
549
|
+
container.appendChild(btnMinus);
|
|
550
|
+
container.appendChild(btnPlus);
|
|
551
|
+
container.appendChild(btnHelp);
|
|
552
|
+
btnMinus.addEventListener("click", (e) => {
|
|
553
|
+
e.preventDefault();
|
|
554
|
+
haptic();
|
|
555
|
+
changeFontSize(term, -2, font);
|
|
556
|
+
});
|
|
557
|
+
btnPlus.addEventListener("click", (e) => {
|
|
558
|
+
e.preventDefault();
|
|
559
|
+
haptic();
|
|
560
|
+
changeFontSize(term, 2, font);
|
|
561
|
+
});
|
|
562
|
+
return {
|
|
563
|
+
element: container,
|
|
564
|
+
helpButton: btnHelp
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
//#endregion
|
|
569
|
+
//#region src/controls/help.ts
|
|
570
|
+
/** Create a table row with two cells — textContent auto-escapes */
|
|
571
|
+
function row(left, right) {
|
|
572
|
+
return el("tr", {}, el("td", {}, left), el("td", {}, right));
|
|
573
|
+
}
|
|
574
|
+
function renderButtonTable(title, buttons) {
|
|
575
|
+
const frag = document.createDocumentFragment();
|
|
576
|
+
frag.appendChild(el("h2", {}, title));
|
|
577
|
+
const table = el("table");
|
|
578
|
+
for (const button of buttons) table.appendChild(row(button.label || button.id || "Unnamed", button.description || "No description"));
|
|
579
|
+
frag.appendChild(table);
|
|
580
|
+
return frag;
|
|
581
|
+
}
|
|
582
|
+
function renderGestures(config) {
|
|
583
|
+
const frag = document.createDocumentFragment();
|
|
584
|
+
frag.appendChild(el("h2", {}, "Gestures"));
|
|
585
|
+
const table = el("table");
|
|
586
|
+
if (config.gestures.swipe.enabled) {
|
|
587
|
+
table.appendChild(row("Swipe right", config.gestures.swipe.rightLabel));
|
|
588
|
+
table.appendChild(row("Swipe left", config.gestures.swipe.leftLabel));
|
|
589
|
+
}
|
|
590
|
+
if (config.gestures.pinch.enabled) table.appendChild(row("Pinch in/out", "Decrease/increase font size"));
|
|
591
|
+
if (config.gestures.scroll.enabled) if (config.gestures.scroll.strategy === "wheel") {
|
|
592
|
+
table.appendChild(row("Finger drag", "Send wheel scroll events to terminal apps"));
|
|
593
|
+
table.appendChild(row("Side ▲ ▼", "Send wheel-up / wheel-down at terminal centre"));
|
|
594
|
+
} else {
|
|
595
|
+
table.appendChild(row("Finger drag", "Send PageUp / PageDown keys"));
|
|
596
|
+
table.appendChild(row("Side ▲ ▼", "Send PageUp / PageDown keys"));
|
|
597
|
+
}
|
|
598
|
+
if (table.rows.length === 0) table.appendChild(row("None", "All gestures are disabled in config"));
|
|
599
|
+
frag.appendChild(table);
|
|
600
|
+
return frag;
|
|
601
|
+
}
|
|
602
|
+
/** Build the help overlay content as a DocumentFragment — no innerHTML */
|
|
603
|
+
function buildHelpContent(config) {
|
|
604
|
+
const topRightButtons = [{
|
|
605
|
+
id: "font-size",
|
|
606
|
+
label: "− / +",
|
|
607
|
+
description: "Decrease / increase font size",
|
|
608
|
+
action: {
|
|
609
|
+
type: "send",
|
|
610
|
+
data: ""
|
|
611
|
+
}
|
|
612
|
+
}, {
|
|
613
|
+
id: "help",
|
|
614
|
+
label: "?",
|
|
615
|
+
description: "Open this help screen",
|
|
616
|
+
action: {
|
|
617
|
+
type: "send",
|
|
618
|
+
data: ""
|
|
619
|
+
}
|
|
620
|
+
}];
|
|
621
|
+
const frag = document.createDocumentFragment();
|
|
622
|
+
const closeBtn = el("button", { class: "wt-help-close" }, "×");
|
|
623
|
+
frag.appendChild(closeBtn);
|
|
624
|
+
frag.appendChild(renderButtonTable("Toolbar — Row 1", config.toolbar.row1));
|
|
625
|
+
frag.appendChild(renderButtonTable("Toolbar — Row 2", config.toolbar.row2));
|
|
626
|
+
frag.appendChild(renderButtonTable("Drawer Buttons", config.drawer.buttons));
|
|
627
|
+
frag.appendChild(renderGestures(config));
|
|
628
|
+
frag.appendChild(renderButtonTable("Top-Right Controls", topRightButtons));
|
|
629
|
+
if (config.floatingButtons.length > 0) {
|
|
630
|
+
const groups = config.floatingButtons;
|
|
631
|
+
if (groups.length === 1 && groups[0] !== void 0) frag.appendChild(renderButtonTable("Floating Buttons", groups[0].buttons));
|
|
632
|
+
else for (const group of groups) frag.appendChild(renderButtonTable(`Floating Buttons (${group.position})`, group.buttons));
|
|
633
|
+
}
|
|
634
|
+
return frag;
|
|
635
|
+
}
|
|
636
|
+
/** Create the help overlay and wire the help button */
|
|
637
|
+
function createHelpOverlay(term, helpButton, config) {
|
|
638
|
+
const overlay = el("div", { id: "wt-help" });
|
|
639
|
+
overlay.appendChild(buildHelpContent(config));
|
|
640
|
+
function open() {
|
|
641
|
+
overlay.style.display = "block";
|
|
642
|
+
}
|
|
643
|
+
function close() {
|
|
644
|
+
overlay.style.display = "none";
|
|
645
|
+
}
|
|
646
|
+
overlay.addEventListener("click", (e) => {
|
|
647
|
+
const target = e.target;
|
|
648
|
+
if (!(target instanceof HTMLElement)) return;
|
|
649
|
+
if (target === overlay || target.classList.contains("wt-help-close")) {
|
|
650
|
+
const kbWasOpen = isKeyboardOpen();
|
|
651
|
+
haptic();
|
|
652
|
+
close();
|
|
653
|
+
conditionalFocus(term, kbWasOpen);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
helpButton.addEventListener("click", (e) => {
|
|
657
|
+
e.preventDefault();
|
|
658
|
+
haptic();
|
|
659
|
+
open();
|
|
660
|
+
});
|
|
661
|
+
return {
|
|
662
|
+
element: overlay,
|
|
663
|
+
open,
|
|
664
|
+
close
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
//#endregion
|
|
669
|
+
//#region src/gestures/lock.ts
|
|
670
|
+
/** Create a gesture lock in the unclaimed state */
|
|
671
|
+
function createGestureLock() {
|
|
672
|
+
return { current: "none" };
|
|
673
|
+
}
|
|
674
|
+
/** Try to claim the lock. Succeeds only if no gesture owns it yet. */
|
|
675
|
+
function tryLock(lock, type) {
|
|
676
|
+
if (lock.current !== "none") return false;
|
|
677
|
+
lock.current = type;
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
/** Release the lock back to unclaimed */
|
|
681
|
+
function resetLock(lock) {
|
|
682
|
+
lock.current = "none";
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
//#endregion
|
|
686
|
+
//#region src/gestures/scroll.ts
|
|
687
|
+
/** SGR mouse wheel escape sequence for a given direction */
|
|
688
|
+
function scrollSeq(direction, x, y) {
|
|
689
|
+
return `\x1b[\x3c${direction === "up" ? 64 : 65};${x};${y}M`;
|
|
690
|
+
}
|
|
691
|
+
/** Page navigation key sequence for a given direction */
|
|
692
|
+
function pageSeq(direction) {
|
|
693
|
+
return direction === "up" ? "\x1B[5~" : "\x1B[6~";
|
|
694
|
+
}
|
|
695
|
+
function clamp(value, min, max) {
|
|
696
|
+
return Math.min(max, Math.max(min, value));
|
|
697
|
+
}
|
|
698
|
+
function terminalGrid(screenRect, term) {
|
|
699
|
+
const colsFromTerm = term.cols;
|
|
700
|
+
const rowsFromTerm = term.rows;
|
|
701
|
+
if (typeof colsFromTerm === "number" && typeof rowsFromTerm === "number") {
|
|
702
|
+
if (colsFromTerm > 0 && rowsFromTerm > 0) return {
|
|
703
|
+
cols: Math.round(colsFromTerm),
|
|
704
|
+
rows: Math.round(rowsFromTerm)
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
const measure = document.querySelector(".xterm-char-measure-element");
|
|
708
|
+
if (measure instanceof HTMLElement) {
|
|
709
|
+
const measureRect = measure.getBoundingClientRect();
|
|
710
|
+
if (measureRect.width > 0 && measureRect.height > 0) return {
|
|
711
|
+
cols: Math.max(1, Math.round(screenRect.width / measureRect.width)),
|
|
712
|
+
rows: Math.max(1, Math.round(screenRect.height / measureRect.height))
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
cols: 80,
|
|
717
|
+
rows: 24
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
function touchToCell(touch, screen, term) {
|
|
721
|
+
const rect = screen.getBoundingClientRect();
|
|
722
|
+
const { cols, rows } = terminalGrid(rect, term);
|
|
723
|
+
const width = Math.max(1, rect.width);
|
|
724
|
+
const height = Math.max(1, rect.height);
|
|
725
|
+
const relX = clamp(touch.clientX - rect.left, 0, width);
|
|
726
|
+
const relY = clamp(touch.clientY - rect.top, 0, height);
|
|
727
|
+
return {
|
|
728
|
+
x: clamp(Math.floor(relX / width * cols) + 1, 1, cols),
|
|
729
|
+
y: clamp(Math.floor(relY / height * rows) + 1, 1, rows)
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
/** Attach single-finger vertical scroll to the xterm screen */
|
|
733
|
+
function attachScrollGesture(term, config, lock, isDrawerOpen) {
|
|
734
|
+
let startY = 0;
|
|
735
|
+
let lastY = 0;
|
|
736
|
+
let accDelta = 0;
|
|
737
|
+
let lastWheelAt = 0;
|
|
738
|
+
let screenEl = null;
|
|
739
|
+
function onTouchStart(e) {
|
|
740
|
+
if (!(e instanceof TouchEvent)) return;
|
|
741
|
+
if (e.touches.length === 1) {
|
|
742
|
+
const t = e.touches[0];
|
|
743
|
+
if (!t) return;
|
|
744
|
+
startY = t.clientY;
|
|
745
|
+
lastY = t.clientY;
|
|
746
|
+
accDelta = 0;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function onTouchMove(e) {
|
|
750
|
+
if (!(e instanceof TouchEvent)) return;
|
|
751
|
+
if (e.touches.length !== 1 || isDrawerOpen()) return;
|
|
752
|
+
const t = e.touches[0];
|
|
753
|
+
if (!t) return;
|
|
754
|
+
const y = t.clientY;
|
|
755
|
+
const totalDy = y - startY;
|
|
756
|
+
if (lock.current === "none" && Math.abs(totalDy) > config.sensitivity) {
|
|
757
|
+
if (!tryLock(lock, "scroll")) return;
|
|
758
|
+
}
|
|
759
|
+
if (lock.current !== "scroll") return;
|
|
760
|
+
e.preventDefault();
|
|
761
|
+
const moveDy = y - lastY;
|
|
762
|
+
lastY = y;
|
|
763
|
+
accDelta += moveDy;
|
|
764
|
+
while (Math.abs(accDelta) >= config.sensitivity) {
|
|
765
|
+
const dir = accDelta < 0 ? "down" : "up";
|
|
766
|
+
if (config.strategy === "keys") sendData(term, pageSeq(dir));
|
|
767
|
+
else {
|
|
768
|
+
const now = Date.now();
|
|
769
|
+
if (now - lastWheelAt < config.wheelIntervalMs) break;
|
|
770
|
+
lastWheelAt = now;
|
|
771
|
+
const screen = screenEl;
|
|
772
|
+
if (!screen) break;
|
|
773
|
+
const { x, y: row } = touchToCell(t, screen, term);
|
|
774
|
+
sendData(term, scrollSeq(dir, x, row));
|
|
775
|
+
}
|
|
776
|
+
accDelta -= (accDelta < 0 ? -1 : 1) * config.sensitivity;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
function onTouchEnd(e) {
|
|
780
|
+
if (!(e instanceof TouchEvent)) return;
|
|
781
|
+
if (lock.current === "scroll") resetLock(lock);
|
|
782
|
+
}
|
|
783
|
+
function attach() {
|
|
784
|
+
const screen = document.querySelector(".xterm-screen");
|
|
785
|
+
if (!(screen instanceof HTMLElement)) {
|
|
786
|
+
setTimeout(attach, 200);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
screenEl = screen;
|
|
790
|
+
screen.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
791
|
+
screen.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
792
|
+
screen.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
793
|
+
screen.addEventListener("touchcancel", onTouchEnd, { passive: true });
|
|
794
|
+
}
|
|
795
|
+
attach();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
//#endregion
|
|
799
|
+
//#region src/controls/scroll-buttons.ts
|
|
800
|
+
const LONG_PRESS_DELAY = 300;
|
|
801
|
+
const REPEAT_INTERVAL = 100;
|
|
802
|
+
const FADE_TIMEOUT = 2e3;
|
|
803
|
+
/** Create floating scroll buttons (PgUp ▲ / PgDn ▼) */
|
|
804
|
+
function createScrollButtons(term, config) {
|
|
805
|
+
const container = el("div", { id: "wt-scroll-buttons" });
|
|
806
|
+
const upBtn = el("button", { "aria-label": "Page Up" }, "▲");
|
|
807
|
+
const downBtn = el("button", { "aria-label": "Page Down" }, "▼");
|
|
808
|
+
container.appendChild(upBtn);
|
|
809
|
+
container.appendChild(downBtn);
|
|
810
|
+
function targetCell() {
|
|
811
|
+
const active = term.buffer?.active;
|
|
812
|
+
if (active && typeof active.cursorX === "number" && typeof active.cursorY === "number") return {
|
|
813
|
+
x: Math.max(1, active.cursorX + 1),
|
|
814
|
+
y: Math.max(1, active.cursorY + 1)
|
|
815
|
+
};
|
|
816
|
+
const cols = typeof term.cols === "number" && term.cols > 0 ? Math.round(term.cols) : 80;
|
|
817
|
+
const rows = typeof term.rows === "number" && term.rows > 0 ? Math.round(term.rows) : 24;
|
|
818
|
+
return {
|
|
819
|
+
x: Math.max(1, Math.floor((cols + 1) / 2)),
|
|
820
|
+
y: Math.max(1, Math.floor((rows + 1) / 2))
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
function sequence(direction) {
|
|
824
|
+
if (config.strategy === "keys") return pageSeq(direction);
|
|
825
|
+
const { x, y } = targetCell();
|
|
826
|
+
return scrollSeq(direction, x, y);
|
|
827
|
+
}
|
|
828
|
+
function wireButton(button, direction) {
|
|
829
|
+
let repeatTimer;
|
|
830
|
+
let delayTimer;
|
|
831
|
+
function send() {
|
|
832
|
+
const kbWasOpen = isKeyboardOpen();
|
|
833
|
+
sendData(term, sequence(direction));
|
|
834
|
+
conditionalFocus(term, kbWasOpen);
|
|
835
|
+
}
|
|
836
|
+
function startRepeat() {
|
|
837
|
+
delayTimer = setTimeout(() => {
|
|
838
|
+
repeatTimer = setInterval(send, REPEAT_INTERVAL);
|
|
839
|
+
}, LONG_PRESS_DELAY);
|
|
840
|
+
}
|
|
841
|
+
function stopRepeat() {
|
|
842
|
+
if (delayTimer !== void 0) {
|
|
843
|
+
clearTimeout(delayTimer);
|
|
844
|
+
delayTimer = void 0;
|
|
845
|
+
}
|
|
846
|
+
if (repeatTimer !== void 0) {
|
|
847
|
+
clearInterval(repeatTimer);
|
|
848
|
+
repeatTimer = void 0;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
button.addEventListener("touchstart", (e) => {
|
|
852
|
+
e.preventDefault();
|
|
853
|
+
send();
|
|
854
|
+
startRepeat();
|
|
855
|
+
resetFade();
|
|
856
|
+
});
|
|
857
|
+
button.addEventListener("touchend", () => stopRepeat());
|
|
858
|
+
button.addEventListener("touchcancel", () => stopRepeat());
|
|
859
|
+
button.addEventListener("click", () => {
|
|
860
|
+
send();
|
|
861
|
+
resetFade();
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
wireButton(upBtn, "up");
|
|
865
|
+
wireButton(downBtn, "down");
|
|
866
|
+
let fadeTimer;
|
|
867
|
+
function resetFade() {
|
|
868
|
+
container.classList.add("wt-active");
|
|
869
|
+
if (fadeTimer !== void 0) clearTimeout(fadeTimer);
|
|
870
|
+
fadeTimer = setTimeout(() => {
|
|
871
|
+
container.classList.remove("wt-active");
|
|
872
|
+
}, FADE_TIMEOUT);
|
|
873
|
+
}
|
|
874
|
+
return { element: container };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
//#endregion
|
|
878
|
+
//#region src/drawer/drawer.ts
|
|
879
|
+
/** Create the command drawer with backdrop */
|
|
880
|
+
function createDrawer(term, buttons, config) {
|
|
881
|
+
const actionRegistry = config.actions ?? createDefaultActionRegistry();
|
|
882
|
+
const hooks = config.hooks;
|
|
883
|
+
const appConfig = config.appConfig;
|
|
884
|
+
const backdrop = el("div", { id: "wt-backdrop" });
|
|
885
|
+
const drawer = el("div", { id: "wt-drawer" });
|
|
886
|
+
const handle = el("div", { id: "wt-drawer-handle" });
|
|
887
|
+
const grid = el("div", { id: "wt-drawer-grid" });
|
|
888
|
+
drawer.appendChild(handle);
|
|
889
|
+
drawer.appendChild(grid);
|
|
890
|
+
let drawerOpen = false;
|
|
891
|
+
for (const buttonDef of buttons) {
|
|
892
|
+
const button = el("button");
|
|
893
|
+
button.textContent = buttonDef.label;
|
|
894
|
+
button.addEventListener("click", (e) => {
|
|
895
|
+
e.preventDefault();
|
|
896
|
+
const kbWasOpen = isKeyboardOpen();
|
|
897
|
+
haptic();
|
|
898
|
+
close();
|
|
899
|
+
async function sendWithHooks(data) {
|
|
900
|
+
const before = await hooks.runBeforeSendData({
|
|
901
|
+
term,
|
|
902
|
+
config: appConfig,
|
|
903
|
+
source: "drawer",
|
|
904
|
+
actionType: buttonDef.action.type,
|
|
905
|
+
kbWasOpen,
|
|
906
|
+
data
|
|
907
|
+
});
|
|
908
|
+
if (before.blocked) return;
|
|
909
|
+
sendData(term, before.data);
|
|
910
|
+
await hooks.runAfterSendData({
|
|
911
|
+
term,
|
|
912
|
+
config: appConfig,
|
|
913
|
+
source: "drawer",
|
|
914
|
+
actionType: buttonDef.action.type,
|
|
915
|
+
kbWasOpen,
|
|
916
|
+
data: before.data
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
actionRegistry.execute(buttonDef.action, {
|
|
920
|
+
term,
|
|
921
|
+
kbWasOpen,
|
|
922
|
+
focusIfNeeded: () => conditionalFocus(term, kbWasOpen),
|
|
923
|
+
sendText: sendWithHooks,
|
|
924
|
+
sendRawText: sendWithHooks,
|
|
925
|
+
openComboPicker: config.openComboPicker
|
|
926
|
+
}).catch((error) => {
|
|
927
|
+
console.error("remobi: drawer action execution failed", error);
|
|
928
|
+
conditionalFocus(term, kbWasOpen);
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
grid.appendChild(button);
|
|
932
|
+
}
|
|
933
|
+
function open() {
|
|
934
|
+
backdrop.style.display = "block";
|
|
935
|
+
drawer.classList.add("open");
|
|
936
|
+
drawerOpen = true;
|
|
937
|
+
}
|
|
938
|
+
function close() {
|
|
939
|
+
drawer.classList.remove("open");
|
|
940
|
+
backdrop.style.display = "none";
|
|
941
|
+
drawerOpen = false;
|
|
942
|
+
}
|
|
943
|
+
function isOpen() {
|
|
944
|
+
return drawerOpen;
|
|
945
|
+
}
|
|
946
|
+
backdrop.addEventListener("click", () => {
|
|
947
|
+
const kbWasOpen = isKeyboardOpen();
|
|
948
|
+
haptic();
|
|
949
|
+
close();
|
|
950
|
+
conditionalFocus(term, kbWasOpen);
|
|
951
|
+
});
|
|
952
|
+
let handleStartY = 0;
|
|
953
|
+
handle.addEventListener("touchstart", (e) => {
|
|
954
|
+
const touch = e.touches[0];
|
|
955
|
+
if (touch) handleStartY = touch.clientY;
|
|
956
|
+
}, { passive: true });
|
|
957
|
+
handle.addEventListener("touchmove", (e) => {
|
|
958
|
+
const touch = e.touches[0];
|
|
959
|
+
if (!touch) return;
|
|
960
|
+
const dy = touch.clientY - handleStartY;
|
|
961
|
+
if (dy > 0) drawer.style.transform = `translateY(${dy}px)`;
|
|
962
|
+
}, { passive: true });
|
|
963
|
+
handle.addEventListener("touchend", (e) => {
|
|
964
|
+
const touch = e.changedTouches[0];
|
|
965
|
+
if (!touch) return;
|
|
966
|
+
const kbWasOpen = isKeyboardOpen();
|
|
967
|
+
const dy = touch.clientY - handleStartY;
|
|
968
|
+
drawer.style.transform = "";
|
|
969
|
+
if (dy > 60) {
|
|
970
|
+
close();
|
|
971
|
+
conditionalFocus(term, kbWasOpen);
|
|
972
|
+
}
|
|
973
|
+
}, { passive: true });
|
|
974
|
+
return {
|
|
975
|
+
backdrop,
|
|
976
|
+
drawer,
|
|
977
|
+
open,
|
|
978
|
+
close,
|
|
979
|
+
isOpen
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
//#endregion
|
|
984
|
+
//#region src/gestures/pinch.ts
|
|
985
|
+
/** Calculate distance between two touch points */
|
|
986
|
+
function touchDistance(t1, t2) {
|
|
987
|
+
const dx = t1.clientX - t2.clientX;
|
|
988
|
+
const dy = t1.clientY - t2.clientY;
|
|
989
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
990
|
+
}
|
|
991
|
+
/** Clamp font size to configured range */
|
|
992
|
+
function clampFontSize(size, range) {
|
|
993
|
+
return Math.max(range[0], Math.min(range[1], size));
|
|
994
|
+
}
|
|
995
|
+
/** Attach pinch-to-zoom gesture to the xterm screen */
|
|
996
|
+
function attachPinchGestures(term, font, lock) {
|
|
997
|
+
let pinchStartDist = 0;
|
|
998
|
+
let pinchBaseFontSize = 0;
|
|
999
|
+
function onPinchStart(e) {
|
|
1000
|
+
if (e.touches.length === 2) {
|
|
1001
|
+
const t0 = e.touches[0];
|
|
1002
|
+
const t1 = e.touches[1];
|
|
1003
|
+
if (!t0 || !t1) return;
|
|
1004
|
+
pinchStartDist = touchDistance(t0, t1);
|
|
1005
|
+
pinchBaseFontSize = term.options.fontSize;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
function onPinchMove(e) {
|
|
1009
|
+
if (e.touches.length !== 2) return;
|
|
1010
|
+
if (lock.current === "scroll") return;
|
|
1011
|
+
if (pinchStartDist === 0) return;
|
|
1012
|
+
const t0 = e.touches[0];
|
|
1013
|
+
const t1 = e.touches[1];
|
|
1014
|
+
if (!t0 || !t1) return;
|
|
1015
|
+
const ratio = touchDistance(t0, t1) / pinchStartDist;
|
|
1016
|
+
if (lock.current === "none" && Math.abs(ratio - 1) > .05) {
|
|
1017
|
+
if (!tryLock(lock, "pinch")) return;
|
|
1018
|
+
}
|
|
1019
|
+
if (lock.current !== "pinch") return;
|
|
1020
|
+
e.preventDefault();
|
|
1021
|
+
const newSize = clampFontSize(Math.round(pinchBaseFontSize * ratio), font.sizeRange);
|
|
1022
|
+
if (newSize !== term.options.fontSize) {
|
|
1023
|
+
term.options.fontSize = newSize;
|
|
1024
|
+
resizeTerm();
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
function onTouchEnd() {
|
|
1028
|
+
if (lock.current === "pinch") resetLock(lock);
|
|
1029
|
+
}
|
|
1030
|
+
function attach() {
|
|
1031
|
+
const screen = document.querySelector(".xterm-screen");
|
|
1032
|
+
if (!screen) {
|
|
1033
|
+
setTimeout(attach, 200);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
screen.addEventListener("touchstart", (e) => onPinchStart(e), { passive: true });
|
|
1037
|
+
screen.addEventListener("touchmove", (e) => onPinchMove(e), { passive: false });
|
|
1038
|
+
screen.addEventListener("touchend", () => onTouchEnd(), { passive: true });
|
|
1039
|
+
}
|
|
1040
|
+
attach();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
//#endregion
|
|
1044
|
+
//#region src/gestures/swipe.ts
|
|
1045
|
+
/** Result of swipe validity check — pure logic, no side effects */
|
|
1046
|
+
function isValidSwipe(dx, dy, dt, config) {
|
|
1047
|
+
const absDx = Math.abs(dx);
|
|
1048
|
+
const absDy = Math.abs(dy);
|
|
1049
|
+
if (absDx > config.threshold && dt < config.maxDuration && absDx > absDy * 2) return dx > 0 ? "right" : "left";
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
/** Create the swipe indicator element */
|
|
1053
|
+
function createSwipeIndicator() {
|
|
1054
|
+
const indicator = el("div", { id: "wt-swipe-indicator" });
|
|
1055
|
+
let timer = 0;
|
|
1056
|
+
function show(arrow) {
|
|
1057
|
+
indicator.textContent = arrow;
|
|
1058
|
+
indicator.style.opacity = "1";
|
|
1059
|
+
clearTimeout(timer);
|
|
1060
|
+
timer = window.setTimeout(() => {
|
|
1061
|
+
indicator.style.opacity = "0";
|
|
1062
|
+
}, 300);
|
|
1063
|
+
}
|
|
1064
|
+
return {
|
|
1065
|
+
element: indicator,
|
|
1066
|
+
show
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
/** Attach swipe gesture detection to the xterm screen */
|
|
1070
|
+
function attachSwipeGestures(term, config, isDrawerOpen) {
|
|
1071
|
+
const { element: indicator, show } = createSwipeIndicator();
|
|
1072
|
+
let startX = 0;
|
|
1073
|
+
let startY = 0;
|
|
1074
|
+
let startTime = 0;
|
|
1075
|
+
function onTouchStart(e) {
|
|
1076
|
+
if (isDrawerOpen() || e.touches.length !== 1) return;
|
|
1077
|
+
const touch = e.touches[0];
|
|
1078
|
+
if (!touch) return;
|
|
1079
|
+
startX = touch.clientX;
|
|
1080
|
+
startY = touch.clientY;
|
|
1081
|
+
startTime = Date.now();
|
|
1082
|
+
}
|
|
1083
|
+
function onTouchEnd(e) {
|
|
1084
|
+
if (isDrawerOpen() || e.changedTouches.length !== 1) return;
|
|
1085
|
+
const touch = e.changedTouches[0];
|
|
1086
|
+
if (!touch) return;
|
|
1087
|
+
const direction = isValidSwipe(touch.clientX - startX, touch.clientY - startY, Date.now() - startTime, config);
|
|
1088
|
+
if (direction === "right") {
|
|
1089
|
+
sendData(term, config.right);
|
|
1090
|
+
show("◀");
|
|
1091
|
+
haptic();
|
|
1092
|
+
} else if (direction === "left") {
|
|
1093
|
+
sendData(term, config.left);
|
|
1094
|
+
show("▶");
|
|
1095
|
+
haptic();
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
function attach() {
|
|
1099
|
+
const screen = document.querySelector(".xterm-screen");
|
|
1100
|
+
if (!screen) {
|
|
1101
|
+
setTimeout(attach, 200);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
screen.addEventListener("touchstart", (e) => onTouchStart(e), { passive: true });
|
|
1105
|
+
screen.addEventListener("touchend", (e) => onTouchEnd(e), { passive: true });
|
|
1106
|
+
}
|
|
1107
|
+
attach();
|
|
1108
|
+
return indicator;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
//#endregion
|
|
1112
|
+
//#region src/hooks/registry.ts
|
|
1113
|
+
function logHookError(name, error) {
|
|
1114
|
+
console.error(`remobi: hook '${name}' failed`, error);
|
|
1115
|
+
}
|
|
1116
|
+
function createHookRegistry() {
|
|
1117
|
+
const hooks = {
|
|
1118
|
+
beforeSendData: [],
|
|
1119
|
+
afterSendData: [],
|
|
1120
|
+
overlayInitStart: [],
|
|
1121
|
+
overlayReady: [],
|
|
1122
|
+
toolbarCreated: [],
|
|
1123
|
+
drawerCreated: []
|
|
1124
|
+
};
|
|
1125
|
+
function on(name, hook) {
|
|
1126
|
+
hooks[name].push(hook);
|
|
1127
|
+
return { dispose() {
|
|
1128
|
+
const index = hooks[name].indexOf(hook);
|
|
1129
|
+
if (index >= 0) hooks[name].splice(index, 1);
|
|
1130
|
+
} };
|
|
1131
|
+
}
|
|
1132
|
+
async function runBeforeSendData(context) {
|
|
1133
|
+
let nextData = context.data;
|
|
1134
|
+
for (const hook of hooks.beforeSendData) try {
|
|
1135
|
+
const result = await hook({
|
|
1136
|
+
...context,
|
|
1137
|
+
data: nextData
|
|
1138
|
+
});
|
|
1139
|
+
if (result?.block) return {
|
|
1140
|
+
blocked: true,
|
|
1141
|
+
data: nextData
|
|
1142
|
+
};
|
|
1143
|
+
if (typeof result?.data === "string") nextData = result.data;
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
logHookError("beforeSendData", error);
|
|
1146
|
+
}
|
|
1147
|
+
return {
|
|
1148
|
+
blocked: false,
|
|
1149
|
+
data: nextData
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
async function runAfterSendData(context) {
|
|
1153
|
+
for (const hook of hooks.afterSendData) try {
|
|
1154
|
+
await hook(context);
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
logHookError("afterSendData", error);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
async function runOverlayInitStart(context) {
|
|
1160
|
+
for (const hook of hooks.overlayInitStart) try {
|
|
1161
|
+
await hook(context);
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
logHookError("overlayInitStart", error);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
async function runOverlayReady(context) {
|
|
1167
|
+
for (const hook of hooks.overlayReady) try {
|
|
1168
|
+
await hook(context);
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
logHookError("overlayReady", error);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
async function runToolbarCreated(context) {
|
|
1174
|
+
for (const hook of hooks.toolbarCreated) try {
|
|
1175
|
+
await hook(context);
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
logHookError("toolbarCreated", error);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
async function runDrawerCreated(context) {
|
|
1181
|
+
for (const hook of hooks.drawerCreated) try {
|
|
1182
|
+
await hook(context);
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
logHookError("drawerCreated", error);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return {
|
|
1188
|
+
on,
|
|
1189
|
+
runBeforeSendData,
|
|
1190
|
+
runAfterSendData,
|
|
1191
|
+
runOverlayInitStart,
|
|
1192
|
+
runOverlayReady,
|
|
1193
|
+
runToolbarCreated,
|
|
1194
|
+
runDrawerCreated
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
//#endregion
|
|
1199
|
+
//#region src/reconnect.ts
|
|
1200
|
+
/** Find the ttyd WebSocket from the interceptor array */
|
|
1201
|
+
function findTtydSocket() {
|
|
1202
|
+
const sockets = window.__remobiSockets;
|
|
1203
|
+
if (!sockets) return void 0;
|
|
1204
|
+
return sockets.find((ws) => ws.url.endsWith("/ws"));
|
|
1205
|
+
}
|
|
1206
|
+
/** Create the reconnect overlay DOM (hidden by default) */
|
|
1207
|
+
function createOverlay(onReconnect) {
|
|
1208
|
+
const overlay = el("div", {
|
|
1209
|
+
id: "remobi-reconnect-overlay",
|
|
1210
|
+
style: [
|
|
1211
|
+
"display:none",
|
|
1212
|
+
"position:fixed",
|
|
1213
|
+
"inset:0",
|
|
1214
|
+
"z-index:10000",
|
|
1215
|
+
"background:rgba(30,30,46,0.92)",
|
|
1216
|
+
"color:#cdd6f4",
|
|
1217
|
+
"font-family:sans-serif",
|
|
1218
|
+
"justify-content:center",
|
|
1219
|
+
"align-items:center",
|
|
1220
|
+
"flex-direction:column",
|
|
1221
|
+
"gap:16px"
|
|
1222
|
+
].join(";")
|
|
1223
|
+
});
|
|
1224
|
+
const message = el("div", { style: "font-size:1.4rem;font-weight:600" });
|
|
1225
|
+
message.textContent = "Connection lost";
|
|
1226
|
+
const button = el("button", { style: [
|
|
1227
|
+
"padding:10px 28px",
|
|
1228
|
+
"font-size:1rem",
|
|
1229
|
+
"border:none",
|
|
1230
|
+
"border-radius:8px",
|
|
1231
|
+
"background:#cba6f7",
|
|
1232
|
+
"color:#1e1e2e",
|
|
1233
|
+
"cursor:pointer",
|
|
1234
|
+
"font-weight:600"
|
|
1235
|
+
].join(";") });
|
|
1236
|
+
button.type = "button";
|
|
1237
|
+
button.textContent = "Reconnect";
|
|
1238
|
+
button.addEventListener("click", (event) => {
|
|
1239
|
+
event.stopPropagation();
|
|
1240
|
+
onReconnect();
|
|
1241
|
+
});
|
|
1242
|
+
overlay.addEventListener("click", () => {
|
|
1243
|
+
onReconnect();
|
|
1244
|
+
});
|
|
1245
|
+
overlay.appendChild(message);
|
|
1246
|
+
overlay.appendChild(button);
|
|
1247
|
+
return {
|
|
1248
|
+
element: overlay,
|
|
1249
|
+
button
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Set up reconnect detection and overlay.
|
|
1254
|
+
*
|
|
1255
|
+
* Watches the ttyd WebSocket for close/error events. Falls back to
|
|
1256
|
+
* navigator.onLine + visibilitychange if no WebSocket found.
|
|
1257
|
+
* Returns a dispose function that removes listeners and DOM.
|
|
1258
|
+
*/
|
|
1259
|
+
function setupReconnect(_term, config) {
|
|
1260
|
+
if (!config.enabled) return () => {};
|
|
1261
|
+
let disconnected = false;
|
|
1262
|
+
let reconnectTriggered = false;
|
|
1263
|
+
function triggerReconnect() {
|
|
1264
|
+
if (!disconnected || reconnectTriggered) return;
|
|
1265
|
+
reconnectTriggered = true;
|
|
1266
|
+
location.reload();
|
|
1267
|
+
}
|
|
1268
|
+
const { element: overlay, button } = createOverlay(triggerReconnect);
|
|
1269
|
+
document.body.appendChild(overlay);
|
|
1270
|
+
function onDisconnect() {
|
|
1271
|
+
if (disconnected) return;
|
|
1272
|
+
disconnected = true;
|
|
1273
|
+
overlay.style.display = "flex";
|
|
1274
|
+
button.focus();
|
|
1275
|
+
}
|
|
1276
|
+
function onOnline() {
|
|
1277
|
+
if (disconnected) triggerReconnect();
|
|
1278
|
+
}
|
|
1279
|
+
function onVisibilityChange() {
|
|
1280
|
+
if (document.visibilityState === "visible" && disconnected) triggerReconnect();
|
|
1281
|
+
}
|
|
1282
|
+
const ws = findTtydSocket();
|
|
1283
|
+
if (ws) {
|
|
1284
|
+
ws.addEventListener("close", onDisconnect);
|
|
1285
|
+
ws.addEventListener("error", onDisconnect);
|
|
1286
|
+
} else {
|
|
1287
|
+
window.addEventListener("offline", onDisconnect);
|
|
1288
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
1289
|
+
}
|
|
1290
|
+
window.addEventListener("online", onOnline);
|
|
1291
|
+
return () => {
|
|
1292
|
+
if (ws) {
|
|
1293
|
+
ws.removeEventListener("close", onDisconnect);
|
|
1294
|
+
ws.removeEventListener("error", onDisconnect);
|
|
1295
|
+
} else {
|
|
1296
|
+
window.removeEventListener("offline", onDisconnect);
|
|
1297
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
1298
|
+
}
|
|
1299
|
+
window.removeEventListener("online", onOnline);
|
|
1300
|
+
overlay.remove();
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/theme/apply.ts
|
|
1306
|
+
/** Apply a theme to the xterm.js terminal instance */
|
|
1307
|
+
function applyTheme(term, theme) {
|
|
1308
|
+
term.options.theme = { ...theme };
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
//#endregion
|
|
1312
|
+
//#region src/toolbar/toolbar.ts
|
|
1313
|
+
/** Create the ctrl modifier state manager */
|
|
1314
|
+
function createCtrlState() {
|
|
1315
|
+
return {
|
|
1316
|
+
active: false,
|
|
1317
|
+
disposer: null,
|
|
1318
|
+
buttonEl: null
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
/** Activate ctrl sticky modifier */
|
|
1322
|
+
function activateCtrl(state, term, theme) {
|
|
1323
|
+
if (!state.buttonEl) return;
|
|
1324
|
+
state.active = true;
|
|
1325
|
+
state.buttonEl.style.background = theme.blue;
|
|
1326
|
+
state.buttonEl.style.color = theme.background;
|
|
1327
|
+
if (!state.disposer) state.disposer = term.onData((data) => {
|
|
1328
|
+
if (state.active && data.length === 1) {
|
|
1329
|
+
const code = data.charCodeAt(0);
|
|
1330
|
+
deactivateCtrl(state, theme);
|
|
1331
|
+
if (code >= 65 && code <= 90 || code >= 97 && code <= 122) sendData(term, String.fromCharCode(code & 31));
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
/** Deactivate ctrl sticky modifier */
|
|
1336
|
+
function deactivateCtrl(state, theme) {
|
|
1337
|
+
if (!state.buttonEl) return;
|
|
1338
|
+
state.active = false;
|
|
1339
|
+
state.buttonEl.style.background = theme.black;
|
|
1340
|
+
state.buttonEl.style.color = theme.foreground;
|
|
1341
|
+
if (state.disposer) {
|
|
1342
|
+
state.disposer.dispose();
|
|
1343
|
+
state.disposer = null;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
/** Wire up a single button's click handler based on its action type */
|
|
1347
|
+
function wireButton(button, def, term, ctrlState, config, registry, hooks, openDrawer, openComboPicker) {
|
|
1348
|
+
button.addEventListener("click", (e) => {
|
|
1349
|
+
e.preventDefault();
|
|
1350
|
+
const kbWasOpen = isKeyboardOpen();
|
|
1351
|
+
haptic();
|
|
1352
|
+
async function sendWithCtrlAware(data) {
|
|
1353
|
+
const before = await hooks.runBeforeSendData({
|
|
1354
|
+
term,
|
|
1355
|
+
config,
|
|
1356
|
+
source: "toolbar",
|
|
1357
|
+
actionType: def.action.type,
|
|
1358
|
+
kbWasOpen,
|
|
1359
|
+
data
|
|
1360
|
+
});
|
|
1361
|
+
if (before.blocked) return;
|
|
1362
|
+
let nextData = before.data;
|
|
1363
|
+
if (ctrlState.active && ctrlState.buttonEl) {
|
|
1364
|
+
deactivateCtrl(ctrlState, config.theme);
|
|
1365
|
+
if (nextData.length === 1) {
|
|
1366
|
+
const code = nextData.charCodeAt(0);
|
|
1367
|
+
if (code >= 65 && code <= 90 || code >= 97 && code <= 122) nextData = String.fromCharCode(code & 31);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
sendData(term, nextData);
|
|
1371
|
+
await hooks.runAfterSendData({
|
|
1372
|
+
term,
|
|
1373
|
+
config,
|
|
1374
|
+
source: "toolbar",
|
|
1375
|
+
actionType: def.action.type,
|
|
1376
|
+
kbWasOpen,
|
|
1377
|
+
data: nextData
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
async function sendRaw(data) {
|
|
1381
|
+
const before = await hooks.runBeforeSendData({
|
|
1382
|
+
term,
|
|
1383
|
+
config,
|
|
1384
|
+
source: "toolbar",
|
|
1385
|
+
actionType: def.action.type,
|
|
1386
|
+
kbWasOpen,
|
|
1387
|
+
data
|
|
1388
|
+
});
|
|
1389
|
+
if (before.blocked) return;
|
|
1390
|
+
sendData(term, before.data);
|
|
1391
|
+
await hooks.runAfterSendData({
|
|
1392
|
+
term,
|
|
1393
|
+
config,
|
|
1394
|
+
source: "toolbar",
|
|
1395
|
+
actionType: def.action.type,
|
|
1396
|
+
kbWasOpen,
|
|
1397
|
+
data: before.data
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
registry.execute(def.action, {
|
|
1401
|
+
term,
|
|
1402
|
+
kbWasOpen,
|
|
1403
|
+
focusIfNeeded: () => conditionalFocus(term, kbWasOpen),
|
|
1404
|
+
sendText: sendWithCtrlAware,
|
|
1405
|
+
sendRawText: sendRaw,
|
|
1406
|
+
openDrawer,
|
|
1407
|
+
openComboPicker,
|
|
1408
|
+
toggleCtrlModifier: () => {
|
|
1409
|
+
if (ctrlState.active) deactivateCtrl(ctrlState, config.theme);
|
|
1410
|
+
else activateCtrl(ctrlState, term, config.theme);
|
|
1411
|
+
conditionalFocus(term, kbWasOpen);
|
|
1412
|
+
}
|
|
1413
|
+
}).catch((error) => {
|
|
1414
|
+
console.error("remobi: toolbar action execution failed", error);
|
|
1415
|
+
conditionalFocus(term, kbWasOpen);
|
|
1416
|
+
});
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
/** Build a row of buttons */
|
|
1420
|
+
function buildRow(buttons, term, ctrlState, config, registry, hooks, openDrawer, openComboPicker) {
|
|
1421
|
+
const row = el("div", { class: "wt-row" });
|
|
1422
|
+
for (const def of buttons) {
|
|
1423
|
+
const button = el("button");
|
|
1424
|
+
button.textContent = def.label;
|
|
1425
|
+
if (def.action.type === "ctrl-modifier") ctrlState.buttonEl = button;
|
|
1426
|
+
wireButton(button, def, term, ctrlState, config, registry, hooks, openDrawer, openComboPicker);
|
|
1427
|
+
row.appendChild(button);
|
|
1428
|
+
}
|
|
1429
|
+
return row;
|
|
1430
|
+
}
|
|
1431
|
+
/** Create the two-row toolbar */
|
|
1432
|
+
function createToolbar(term, config, openDrawer, hooks, actions = createDefaultActionRegistry(), openComboPicker) {
|
|
1433
|
+
const toolbar = el("div", { id: "wt-toolbar" });
|
|
1434
|
+
const ctrlState = createCtrlState();
|
|
1435
|
+
const row1 = buildRow(config.toolbar.row1, term, ctrlState, config, actions, hooks, openDrawer, openComboPicker);
|
|
1436
|
+
const row2 = buildRow(config.toolbar.row2, term, ctrlState, config, actions, hooks, openDrawer, openComboPicker);
|
|
1437
|
+
toolbar.appendChild(row1);
|
|
1438
|
+
toolbar.appendChild(row2);
|
|
1439
|
+
return {
|
|
1440
|
+
element: toolbar,
|
|
1441
|
+
ctrlState
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
//#endregion
|
|
1446
|
+
//#region src/viewport/landscape.ts
|
|
1447
|
+
/**
|
|
1448
|
+
* Detect landscape orientation + keyboard open state.
|
|
1449
|
+
* In landscape with keyboard, hides row 2 and shrinks buttons via CSS class.
|
|
1450
|
+
*/
|
|
1451
|
+
function checkLandscapeKeyboard(toolbar) {
|
|
1452
|
+
const vp = window.visualViewport;
|
|
1453
|
+
if (!vp) return;
|
|
1454
|
+
const kbOpen = window.innerHeight - vp.height > KB_THRESHOLD;
|
|
1455
|
+
const landscape = window.innerWidth > window.innerHeight;
|
|
1456
|
+
if (kbOpen && landscape) toolbar.classList.add("wt-kb-open");
|
|
1457
|
+
else toolbar.classList.remove("wt-kb-open");
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
//#endregion
|
|
1461
|
+
//#region src/viewport/height.ts
|
|
1462
|
+
function viewportHeight(vp, fallbackHeight, includeOffsetTop) {
|
|
1463
|
+
if (!vp) return fallbackHeight;
|
|
1464
|
+
return includeOffsetTop ? vp.height + vp.offsetTop : vp.height;
|
|
1465
|
+
}
|
|
1466
|
+
function lockDocumentHeight(height) {
|
|
1467
|
+
document.documentElement.style.setProperty("height", height, "important");
|
|
1468
|
+
document.documentElement.style.setProperty("max-height", height, "important");
|
|
1469
|
+
document.documentElement.style.setProperty("overflow", "hidden", "important");
|
|
1470
|
+
document.documentElement.style.setProperty("overscroll-behavior", "none", "important");
|
|
1471
|
+
document.body.style.setProperty("min-height", "0", "important");
|
|
1472
|
+
document.body.style.setProperty("height", height, "important");
|
|
1473
|
+
document.body.style.setProperty("max-height", height, "important");
|
|
1474
|
+
document.body.style.setProperty("overflow", "hidden", "important");
|
|
1475
|
+
document.body.style.setProperty("overscroll-behavior", "none", "important");
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Manage terminal height to account for the toolbar and virtual keyboard.
|
|
1479
|
+
* Uses visualViewport API when available for accurate keyboard detection.
|
|
1480
|
+
*/
|
|
1481
|
+
function initHeightManager(toolbar) {
|
|
1482
|
+
let pendingResize = 0;
|
|
1483
|
+
function updateHeight() {
|
|
1484
|
+
pendingResize = 0;
|
|
1485
|
+
checkLandscapeKeyboard(toolbar);
|
|
1486
|
+
const vp = window.visualViewport;
|
|
1487
|
+
const kbOpen = isKeyboardOpen();
|
|
1488
|
+
lockDocumentHeight(`${viewportHeight(vp, window.innerHeight, kbOpen) - (kbOpen ? 0 : toolbar.offsetHeight || 90)}px`);
|
|
1489
|
+
resizeTerm();
|
|
1490
|
+
}
|
|
1491
|
+
function scheduleResize() {
|
|
1492
|
+
if (!pendingResize) pendingResize = requestAnimationFrame(updateHeight);
|
|
1493
|
+
}
|
|
1494
|
+
if (window.visualViewport) {
|
|
1495
|
+
window.visualViewport.addEventListener("resize", scheduleResize);
|
|
1496
|
+
window.visualViewport.addEventListener("scroll", scheduleResize);
|
|
1497
|
+
}
|
|
1498
|
+
window.addEventListener("resize", scheduleResize);
|
|
1499
|
+
window.addEventListener("orientationchange", () => {
|
|
1500
|
+
setTimeout(scheduleResize, 200);
|
|
1501
|
+
});
|
|
1502
|
+
scheduleResize();
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
//#endregion
|
|
1506
|
+
//#region src/index.ts
|
|
1507
|
+
/** Detect touch device */
|
|
1508
|
+
function isMobile() {
|
|
1509
|
+
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Initialise the remobi overlay.
|
|
1513
|
+
* Called automatically when loaded in a browser (via the IIFE in build output).
|
|
1514
|
+
* Config is embedded at build time.
|
|
1515
|
+
*/
|
|
1516
|
+
function init(config = defaultConfig, hooks = createHookRegistry()) {
|
|
1517
|
+
waitForTerm().then(async (term) => {
|
|
1518
|
+
const disposeReconnect = setupReconnect(term, config.reconnect);
|
|
1519
|
+
const mobile = isMobile();
|
|
1520
|
+
const actions = createDefaultActionRegistry();
|
|
1521
|
+
let disposed = false;
|
|
1522
|
+
function dispose() {
|
|
1523
|
+
if (disposed) return;
|
|
1524
|
+
disposed = true;
|
|
1525
|
+
disposeReconnect();
|
|
1526
|
+
window.removeEventListener("pagehide", onPageHide);
|
|
1527
|
+
}
|
|
1528
|
+
function onPageHide(event) {
|
|
1529
|
+
if (event.persisted) return;
|
|
1530
|
+
dispose();
|
|
1531
|
+
}
|
|
1532
|
+
window.addEventListener("beforeunload", dispose, { once: true });
|
|
1533
|
+
window.addEventListener("pagehide", onPageHide);
|
|
1534
|
+
try {
|
|
1535
|
+
await hooks.runOverlayInitStart({
|
|
1536
|
+
term,
|
|
1537
|
+
config,
|
|
1538
|
+
mobile
|
|
1539
|
+
});
|
|
1540
|
+
document.fonts.ready.then(() => resizeTerm()).catch(() => {});
|
|
1541
|
+
document.title = `${config.name} · ${location.hostname.replace(/\..*/, "")}`;
|
|
1542
|
+
if (!mobile) {
|
|
1543
|
+
await hooks.runOverlayReady({
|
|
1544
|
+
term,
|
|
1545
|
+
config,
|
|
1546
|
+
mobile
|
|
1547
|
+
});
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
applyTheme(term, config.theme);
|
|
1551
|
+
term.options.fontSize = config.font.mobileSizeDefault;
|
|
1552
|
+
term.options.fontFamily = config.font.family;
|
|
1553
|
+
resizeTerm();
|
|
1554
|
+
const comboPicker = createComboPicker();
|
|
1555
|
+
document.body.appendChild(comboPicker.element);
|
|
1556
|
+
const drawer = createDrawer(term, config.drawer.buttons, {
|
|
1557
|
+
hooks,
|
|
1558
|
+
appConfig: config,
|
|
1559
|
+
actions,
|
|
1560
|
+
openComboPicker: comboPicker.open
|
|
1561
|
+
});
|
|
1562
|
+
document.body.appendChild(drawer.backdrop);
|
|
1563
|
+
document.body.appendChild(drawer.drawer);
|
|
1564
|
+
await hooks.runDrawerCreated({
|
|
1565
|
+
term,
|
|
1566
|
+
config,
|
|
1567
|
+
drawer: drawer.drawer,
|
|
1568
|
+
backdrop: drawer.backdrop
|
|
1569
|
+
});
|
|
1570
|
+
const { element: toolbar } = createToolbar(term, config, drawer.open, hooks, actions, comboPicker.open);
|
|
1571
|
+
document.body.appendChild(toolbar);
|
|
1572
|
+
await hooks.runToolbarCreated({
|
|
1573
|
+
term,
|
|
1574
|
+
config,
|
|
1575
|
+
toolbar
|
|
1576
|
+
});
|
|
1577
|
+
const { element: fontControls, helpButton } = createFontControls(term, config.font);
|
|
1578
|
+
document.body.appendChild(fontControls);
|
|
1579
|
+
if (config.floatingButtons.length > 0) {
|
|
1580
|
+
const { elements: floatingEls } = createFloatingButtons(term, config.floatingButtons, config, hooks, actions, drawer.open, comboPicker.open);
|
|
1581
|
+
for (const floatingEl of floatingEls) document.body.appendChild(floatingEl);
|
|
1582
|
+
}
|
|
1583
|
+
const { element: scrollButtons } = createScrollButtons(term, config.gestures.scroll);
|
|
1584
|
+
document.body.appendChild(scrollButtons);
|
|
1585
|
+
const gestureLock = createGestureLock();
|
|
1586
|
+
if (config.gestures.swipe.enabled) {
|
|
1587
|
+
const indicator = attachSwipeGestures(term, config.gestures.swipe, drawer.isOpen);
|
|
1588
|
+
document.body.appendChild(indicator);
|
|
1589
|
+
}
|
|
1590
|
+
if (config.gestures.pinch.enabled) attachPinchGestures(term, config.font, gestureLock);
|
|
1591
|
+
if (config.gestures.scroll.enabled) attachScrollGesture(term, config.gestures.scroll, gestureLock, drawer.isOpen);
|
|
1592
|
+
initHeightManager(toolbar);
|
|
1593
|
+
if (config.mobile.initData !== null && window.innerWidth < config.mobile.widthThreshold) {
|
|
1594
|
+
const data = config.mobile.initData;
|
|
1595
|
+
const before = await hooks.runBeforeSendData({
|
|
1596
|
+
term,
|
|
1597
|
+
config,
|
|
1598
|
+
source: "mobile-init",
|
|
1599
|
+
actionType: "send",
|
|
1600
|
+
kbWasOpen: false,
|
|
1601
|
+
data
|
|
1602
|
+
});
|
|
1603
|
+
if (!before.blocked) {
|
|
1604
|
+
sendData(term, before.data);
|
|
1605
|
+
await hooks.runAfterSendData({
|
|
1606
|
+
term,
|
|
1607
|
+
config,
|
|
1608
|
+
source: "mobile-init",
|
|
1609
|
+
actionType: "send",
|
|
1610
|
+
kbWasOpen: false,
|
|
1611
|
+
data: before.data
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
try {
|
|
1616
|
+
const { element: helpOverlay } = createHelpOverlay(term, helpButton, config);
|
|
1617
|
+
document.body.appendChild(helpOverlay);
|
|
1618
|
+
} catch (error) {
|
|
1619
|
+
console.error("remobi: failed to initialise help overlay", error);
|
|
1620
|
+
}
|
|
1621
|
+
await hooks.runOverlayReady({
|
|
1622
|
+
term,
|
|
1623
|
+
config,
|
|
1624
|
+
mobile
|
|
1625
|
+
});
|
|
1626
|
+
} catch (error) {
|
|
1627
|
+
dispose();
|
|
1628
|
+
throw error;
|
|
1629
|
+
}
|
|
1630
|
+
}).catch((error) => {
|
|
1631
|
+
console.error("remobi: failed to initialise overlay", error);
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
//#endregion
|
|
1636
|
+
export { createHookRegistry, defineConfig, init };
|
|
1637
|
+
//# sourceMappingURL=index.mjs.map
|