hop2 0.8.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/.hop-tmux.conf +18 -0
- package/INTEGRATION.md +92 -0
- package/README.md +260 -0
- package/hay/CHANGELOG.md +46 -0
- package/hay/DEV_NOTES.md +39 -0
- package/hay/README.md +290 -0
- package/hay/apps/cli/package.json +25 -0
- package/hay/apps/cli/src/index.ts +1250 -0
- package/hay/apps/cli/tsconfig.json +8 -0
- package/hay/apps/server/package.json +37 -0
- package/hay/apps/server/src/index.ts +103 -0
- package/hay/apps/server/src/lib.ts +161 -0
- package/hay/apps/server/src/pty.ts +68 -0
- package/hay/apps/server/src/rooms.ts +421 -0
- package/hay/apps/server/src/utils.ts +20 -0
- package/hay/apps/server/test/rooms.test.ts +146 -0
- package/hay/apps/server/tsconfig.json +9 -0
- package/hay/apps/web/index.html +12 -0
- package/hay/apps/web/package.json +32 -0
- package/hay/apps/web/scripts/run-e2e.mjs +130 -0
- package/hay/apps/web/src/App.tsx +1302 -0
- package/hay/apps/web/src/components/MobileKeyboard.tsx +701 -0
- package/hay/apps/web/src/main.tsx +13 -0
- package/hay/apps/web/src/setupTests.ts +1 -0
- package/hay/apps/web/src/styles.css +1198 -0
- package/hay/apps/web/src/utils/optimisticEcho.ts +115 -0
- package/hay/apps/web/src/utils/presence.ts +24 -0
- package/hay/apps/web/src/vite-env.d.ts +10 -0
- package/hay/apps/web/test/optimisticEcho.test.ts +39 -0
- package/hay/apps/web/test/presence.test.ts +18 -0
- package/hay/apps/web/tsconfig.json +8 -0
- package/hay/apps/web/vite.config.ts +13 -0
- package/hay/apps/web/vitest.config.ts +12 -0
- package/hay/package-lock.json +5988 -0
- package/hay/package.json +18 -0
- package/hay/packages/shared/package.json +27 -0
- package/hay/packages/shared/src/colors.ts +16 -0
- package/hay/packages/shared/src/index.ts +2 -0
- package/hay/packages/shared/src/protocol.ts +90 -0
- package/hay/packages/shared/test/protocol.test.ts +33 -0
- package/hay/packages/shared/tsconfig.json +7 -0
- package/hay/tsconfig.base.json +15 -0
- package/hay-web/assets/index-CTTzApRp.css +32 -0
- package/hay-web/assets/index-FDfdolCs.js +60 -0
- package/hay-web/assets/index-FDfdolCs.js.map +1 -0
- package/hay-web/index.html +13 -0
- package/hop +3717 -0
- package/package.json +52 -0
- package/sessions.html +243 -0
- package/tunnel-shell.js +254 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { flushSync } from "react-dom";
|
|
3
|
+
|
|
4
|
+
// iOS detection for haptic feedback
|
|
5
|
+
const isIOS = () => {
|
|
6
|
+
if (typeof navigator === "undefined") return false;
|
|
7
|
+
const ua = navigator.userAgent || "";
|
|
8
|
+
return /iPad|iPhone|iPod/.test(ua);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
interface MobileKeyboardProps {
|
|
12
|
+
onInput: (data: string) => void;
|
|
13
|
+
visible: boolean;
|
|
14
|
+
onToggle: () => void;
|
|
15
|
+
hapticsEnabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type KeyboardView = "abc" | "num";
|
|
19
|
+
|
|
20
|
+
interface KeyDef {
|
|
21
|
+
label: string;
|
|
22
|
+
key?: string;
|
|
23
|
+
mod?: "ctrl" | "alt";
|
|
24
|
+
fn?: "sysKb";
|
|
25
|
+
action?: "shift" | "bksp" | "paste" | "ret" | "space" | "123" | "abc";
|
|
26
|
+
className?: string;
|
|
27
|
+
flexGrow?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const LAYOUTS: Record<KeyboardView, string[][]> = {
|
|
31
|
+
abc: [
|
|
32
|
+
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
|
|
33
|
+
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
|
|
34
|
+
["SHIFT", "z", "x", "c", "v", "b", "n", "m", "BKSP"],
|
|
35
|
+
["123", "PASTE", "SPACE", "RET"]
|
|
36
|
+
],
|
|
37
|
+
num: [
|
|
38
|
+
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
|
39
|
+
["-", "/", ":", ";", "(", ")", "$", "&", "@", '"'],
|
|
40
|
+
["SHIFT", ".", ",", "?", "!", "'", "BKSP"],
|
|
41
|
+
["ABC", "PASTE", "SPACE", "RET"]
|
|
42
|
+
]
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const ACC_KEYS: KeyDef[] = [
|
|
46
|
+
{ label: "Esc", key: "\x1b" },
|
|
47
|
+
{ label: "Tab", key: "\t" },
|
|
48
|
+
{ label: "Ctrl", mod: "ctrl" },
|
|
49
|
+
{ label: "Alt", mod: "alt" },
|
|
50
|
+
{ label: "←", key: "\x1b[D" },
|
|
51
|
+
{ label: "↓", key: "\x1b[B" },
|
|
52
|
+
{ label: "↑", key: "\x1b[A" },
|
|
53
|
+
{ label: "→", key: "\x1b[C" },
|
|
54
|
+
{ label: "KB", fn: "sysKb" }
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const ShiftIcon = ({ filled }: { filled: boolean }) => (
|
|
58
|
+
<svg
|
|
59
|
+
viewBox="0 0 24 24"
|
|
60
|
+
stroke="currentColor"
|
|
61
|
+
strokeWidth="1.5"
|
|
62
|
+
fill={filled ? "currentColor" : "none"}
|
|
63
|
+
>
|
|
64
|
+
<path d="M12 4L4 16h16L12 4z" />
|
|
65
|
+
</svg>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const BackspaceIcon = () => (
|
|
69
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" fill="none">
|
|
70
|
+
<path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" />
|
|
71
|
+
<line x1="18" y1="9" x2="12" y2="15" />
|
|
72
|
+
<line x1="12" y1="9" x2="18" y2="15" />
|
|
73
|
+
</svg>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const KeyboardIcon = () => (
|
|
77
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
78
|
+
<rect x="2" y="6" width="20" height="12" rx="2" />
|
|
79
|
+
<line x1="6" y1="10" x2="6.01" y2="10" />
|
|
80
|
+
<line x1="10" y1="10" x2="10.01" y2="10" />
|
|
81
|
+
<line x1="14" y1="10" x2="14.01" y2="10" />
|
|
82
|
+
<line x1="18" y1="10" x2="18.01" y2="10" />
|
|
83
|
+
<line x1="8" y1="14" x2="16" y2="14" />
|
|
84
|
+
</svg>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const PasteIcon = () => (
|
|
88
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
89
|
+
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
|
90
|
+
<rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
|
|
91
|
+
</svg>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
export const MobileKeyboard = ({ onInput, visible, onToggle, hapticsEnabled = true }: MobileKeyboardProps) => {
|
|
95
|
+
const [view, setView] = useState<KeyboardView>("abc");
|
|
96
|
+
const [shift, setShift] = useState(false);
|
|
97
|
+
const [caps, setCaps] = useState(false);
|
|
98
|
+
const [ctrl, setCtrl] = useState(false);
|
|
99
|
+
const [alt, setAlt] = useState(false);
|
|
100
|
+
const [sysKbOpen, setSysKbOpen] = useState(false);
|
|
101
|
+
const [sysKbHintShown, setSysKbHintShown] = useState(false);
|
|
102
|
+
const [showHint, setShowHint] = useState(false);
|
|
103
|
+
const [sysKbDraft, setSysKbDraft] = useState("");
|
|
104
|
+
|
|
105
|
+
const shiftTapAt = useRef(0);
|
|
106
|
+
const bkspTimeout = useRef<number | null>(null);
|
|
107
|
+
const bkspInterval = useRef<number | null>(null);
|
|
108
|
+
const sysInputRef = useRef<HTMLTextAreaElement>(null);
|
|
109
|
+
const hapticLabelRef = useRef<HTMLLabelElement | null>(null);
|
|
110
|
+
|
|
111
|
+
// Use refs to avoid stale closures in rapid key presses
|
|
112
|
+
const onInputRef = useRef(onInput);
|
|
113
|
+
const shiftRef = useRef(shift);
|
|
114
|
+
const capsRef = useRef(caps);
|
|
115
|
+
const ctrlRef = useRef(ctrl);
|
|
116
|
+
|
|
117
|
+
// Keep refs in sync with state
|
|
118
|
+
onInputRef.current = onInput;
|
|
119
|
+
shiftRef.current = shift;
|
|
120
|
+
capsRef.current = caps;
|
|
121
|
+
ctrlRef.current = ctrl;
|
|
122
|
+
|
|
123
|
+
// Create hidden switch elements for iOS haptic feedback
|
|
124
|
+
// iOS doesn't support navigator.vibrate, so we use a hidden checkbox switch
|
|
125
|
+
// element trick - clicking its label triggers native haptic feedback
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!isIOS()) return;
|
|
128
|
+
|
|
129
|
+
const input = document.createElement("input");
|
|
130
|
+
input.type = "checkbox";
|
|
131
|
+
input.id = "hay-haptic-switch";
|
|
132
|
+
input.setAttribute("switch", "");
|
|
133
|
+
// Hide off-screen instead of display:none (display:none prevents haptic)
|
|
134
|
+
input.style.position = "fixed";
|
|
135
|
+
input.style.left = "-9999px";
|
|
136
|
+
input.style.top = "-9999px";
|
|
137
|
+
input.style.opacity = "0";
|
|
138
|
+
input.style.pointerEvents = "none";
|
|
139
|
+
document.body.appendChild(input);
|
|
140
|
+
|
|
141
|
+
const label = document.createElement("label");
|
|
142
|
+
label.htmlFor = "hay-haptic-switch";
|
|
143
|
+
label.style.position = "fixed";
|
|
144
|
+
label.style.left = "-9999px";
|
|
145
|
+
label.style.top = "-9999px";
|
|
146
|
+
label.style.opacity = "0";
|
|
147
|
+
label.style.pointerEvents = "none";
|
|
148
|
+
document.body.appendChild(label);
|
|
149
|
+
hapticLabelRef.current = label;
|
|
150
|
+
|
|
151
|
+
return () => {
|
|
152
|
+
document.body.removeChild(input);
|
|
153
|
+
document.body.removeChild(label);
|
|
154
|
+
hapticLabelRef.current = null;
|
|
155
|
+
};
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
// Helper to add pressed class (fast in, slow fade out via CSS)
|
|
159
|
+
const addPressed = (e: React.TouchEvent | React.MouseEvent) => {
|
|
160
|
+
const target = e.currentTarget as HTMLElement;
|
|
161
|
+
target.classList.add("kb-pressed");
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const removePressed = (e: React.TouchEvent | React.MouseEvent) => {
|
|
165
|
+
const target = e.currentTarget as HTMLElement;
|
|
166
|
+
target.classList.remove("kb-pressed");
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const hapticTap = useCallback(() => {
|
|
170
|
+
if (!hapticsEnabled) return;
|
|
171
|
+
try {
|
|
172
|
+
if (isIOS()) {
|
|
173
|
+
// iOS: click hidden switch label to trigger native haptic
|
|
174
|
+
// Note: iOS 18+ requires this to be called from onClick handler
|
|
175
|
+
hapticLabelRef.current?.click();
|
|
176
|
+
} else if (navigator.vibrate) {
|
|
177
|
+
// Android/other: use Vibration API
|
|
178
|
+
navigator.vibrate(5);
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// Ignore haptic errors
|
|
182
|
+
}
|
|
183
|
+
}, [hapticsEnabled]);
|
|
184
|
+
|
|
185
|
+
// Use refs in send to avoid stale closures - this is critical for rapid typing
|
|
186
|
+
const send = useCallback(
|
|
187
|
+
(key: string) => {
|
|
188
|
+
let data = key;
|
|
189
|
+
|
|
190
|
+
// Handle Ctrl modifier - only for letter keys (A-Z)
|
|
191
|
+
// Read from ref for current value
|
|
192
|
+
if (ctrlRef.current && key.length === 1) {
|
|
193
|
+
const c = key.toUpperCase().charCodeAt(0);
|
|
194
|
+
if (c >= 65 && c <= 90) {
|
|
195
|
+
data = String.fromCharCode(c - 64);
|
|
196
|
+
setCtrl(false);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Call onInput via ref to always use current callback
|
|
201
|
+
onInputRef.current(data);
|
|
202
|
+
|
|
203
|
+
// Clear shift after character input (unless caps lock)
|
|
204
|
+
if (shiftRef.current && !capsRef.current && data.length === 1) {
|
|
205
|
+
shiftTapAt.current = 0;
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
[] // No dependencies - we read from refs
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const handlePaste = useCallback(async () => {
|
|
212
|
+
try {
|
|
213
|
+
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
214
|
+
const text = await navigator.clipboard.readText();
|
|
215
|
+
if (text) {
|
|
216
|
+
send(text);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// Clipboard access denied
|
|
221
|
+
}
|
|
222
|
+
}, [send]); // send is stable
|
|
223
|
+
|
|
224
|
+
const resizeSysTextarea = useCallback(() => {
|
|
225
|
+
const el = sysInputRef.current;
|
|
226
|
+
if (!el) return;
|
|
227
|
+
el.style.height = "auto";
|
|
228
|
+
const maxHeight = Math.min(window.innerHeight * 0.4, 240);
|
|
229
|
+
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
|
|
230
|
+
}, []);
|
|
231
|
+
|
|
232
|
+
const closeSysKb = useCallback(() => {
|
|
233
|
+
sysInputRef.current?.blur();
|
|
234
|
+
setSysKbOpen(false);
|
|
235
|
+
}, []);
|
|
236
|
+
|
|
237
|
+
const focusSysKbInput = useCallback(() => {
|
|
238
|
+
const el = sysInputRef.current;
|
|
239
|
+
if (!el) return;
|
|
240
|
+
try {
|
|
241
|
+
el.focus({ preventScroll: true });
|
|
242
|
+
} catch {
|
|
243
|
+
el.focus();
|
|
244
|
+
}
|
|
245
|
+
const len = el.value.length;
|
|
246
|
+
if (typeof el.setSelectionRange === "function") {
|
|
247
|
+
el.setSelectionRange(len, len);
|
|
248
|
+
}
|
|
249
|
+
resizeSysTextarea();
|
|
250
|
+
}, [resizeSysTextarea]);
|
|
251
|
+
|
|
252
|
+
const openSysKb = useCallback(() => {
|
|
253
|
+
flushSync(() => {
|
|
254
|
+
setSysKbOpen(true);
|
|
255
|
+
});
|
|
256
|
+
focusSysKbInput();
|
|
257
|
+
}, [focusSysKbInput]);
|
|
258
|
+
|
|
259
|
+
const toggleSysKb = useCallback(() => {
|
|
260
|
+
if (sysKbOpen) {
|
|
261
|
+
closeSysKb();
|
|
262
|
+
} else {
|
|
263
|
+
openSysKb();
|
|
264
|
+
}
|
|
265
|
+
}, [sysKbOpen, closeSysKb, openSysKb]);
|
|
266
|
+
|
|
267
|
+
const handleSysKbSubmit = useCallback(() => {
|
|
268
|
+
const text = sysKbDraft;
|
|
269
|
+
if (text) {
|
|
270
|
+
send(text);
|
|
271
|
+
setSysKbDraft("");
|
|
272
|
+
}
|
|
273
|
+
if (sysInputRef.current) {
|
|
274
|
+
sysInputRef.current.style.height = "";
|
|
275
|
+
}
|
|
276
|
+
closeSysKb();
|
|
277
|
+
}, [send, closeSysKb, sysKbDraft]);
|
|
278
|
+
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (!sysKbOpen) return;
|
|
281
|
+
const el = sysInputRef.current;
|
|
282
|
+
if (!el) return;
|
|
283
|
+
const focusAndSize = () => {
|
|
284
|
+
focusSysKbInput();
|
|
285
|
+
};
|
|
286
|
+
const frame = requestAnimationFrame(focusAndSize);
|
|
287
|
+
const frame2 = requestAnimationFrame(focusAndSize);
|
|
288
|
+
return () => {
|
|
289
|
+
cancelAnimationFrame(frame);
|
|
290
|
+
cancelAnimationFrame(frame2);
|
|
291
|
+
};
|
|
292
|
+
}, [sysKbOpen, resizeSysTextarea]);
|
|
293
|
+
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
if (!sysKbOpen) return;
|
|
296
|
+
resizeSysTextarea();
|
|
297
|
+
}, [sysKbDraft, sysKbOpen, resizeSysTextarea]);
|
|
298
|
+
|
|
299
|
+
const handleShift = useCallback(() => {
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
const doubleTap = shiftTapAt.current && now - shiftTapAt.current < 300;
|
|
302
|
+
|
|
303
|
+
if (caps) {
|
|
304
|
+
setCaps(false);
|
|
305
|
+
setShift(false);
|
|
306
|
+
shiftTapAt.current = 0;
|
|
307
|
+
} else if (shift) {
|
|
308
|
+
if (doubleTap) {
|
|
309
|
+
setCaps(true);
|
|
310
|
+
setShift(false);
|
|
311
|
+
shiftTapAt.current = 0;
|
|
312
|
+
} else {
|
|
313
|
+
setShift(false);
|
|
314
|
+
shiftTapAt.current = now;
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
setShift(true);
|
|
318
|
+
shiftTapAt.current = now;
|
|
319
|
+
}
|
|
320
|
+
}, [caps, shift]);
|
|
321
|
+
|
|
322
|
+
const startBksp = useCallback(() => {
|
|
323
|
+
send("\x7f");
|
|
324
|
+
bkspTimeout.current = window.setTimeout(() => {
|
|
325
|
+
bkspInterval.current = window.setInterval(() => {
|
|
326
|
+
send("\x7f");
|
|
327
|
+
}, 50);
|
|
328
|
+
}, 500);
|
|
329
|
+
}, [send]);
|
|
330
|
+
|
|
331
|
+
const stopBksp = useCallback(() => {
|
|
332
|
+
if (bkspTimeout.current) {
|
|
333
|
+
clearTimeout(bkspTimeout.current);
|
|
334
|
+
bkspTimeout.current = null;
|
|
335
|
+
}
|
|
336
|
+
if (bkspInterval.current) {
|
|
337
|
+
clearInterval(bkspInterval.current);
|
|
338
|
+
bkspInterval.current = null;
|
|
339
|
+
}
|
|
340
|
+
}, []);
|
|
341
|
+
|
|
342
|
+
// Show hint for first-time users
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (visible && !sysKbHintShown) {
|
|
345
|
+
try {
|
|
346
|
+
const seen = localStorage.getItem("hay-syskb-seen");
|
|
347
|
+
if (!seen) {
|
|
348
|
+
setSysKbHintShown(true);
|
|
349
|
+
setTimeout(() => {
|
|
350
|
+
setShowHint(true);
|
|
351
|
+
setTimeout(() => {
|
|
352
|
+
setShowHint(false);
|
|
353
|
+
localStorage.setItem("hay-syskb-seen", "1");
|
|
354
|
+
}, 4000);
|
|
355
|
+
}, 1000);
|
|
356
|
+
} else {
|
|
357
|
+
setSysKbHintShown(true);
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
setSysKbHintShown(true);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}, [visible, sysKbHintShown]);
|
|
364
|
+
|
|
365
|
+
// Use refs to avoid stale closures during rapid typing
|
|
366
|
+
const handleCharKey = useCallback(
|
|
367
|
+
(char: string) => {
|
|
368
|
+
const ch = shiftRef.current || capsRef.current ? char.toUpperCase() : char;
|
|
369
|
+
send(ch);
|
|
370
|
+
if (shiftRef.current && !capsRef.current) {
|
|
371
|
+
setShift(false);
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
[send] // send is stable (empty deps)
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const renderKey = (keyStr: string, index: number, isThirdRow: boolean) => {
|
|
378
|
+
const isOn = shift || caps;
|
|
379
|
+
|
|
380
|
+
if (keyStr === "SHIFT") {
|
|
381
|
+
return (
|
|
382
|
+
<button
|
|
383
|
+
key={keyStr}
|
|
384
|
+
type="button"
|
|
385
|
+
className={`kb-key kb-act${isOn ? " kb-on" : ""}`}
|
|
386
|
+
style={{ flexGrow: 1.25 }}
|
|
387
|
+
onTouchStart={(e) => {
|
|
388
|
+
e.preventDefault();
|
|
389
|
+
addPressed(e);
|
|
390
|
+
handleShift();
|
|
391
|
+
}}
|
|
392
|
+
onTouchEnd={removePressed}
|
|
393
|
+
onTouchCancel={removePressed}
|
|
394
|
+
onClick={hapticTap}
|
|
395
|
+
>
|
|
396
|
+
<ShiftIcon filled={isOn} />
|
|
397
|
+
</button>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (keyStr === "BKSP") {
|
|
402
|
+
return (
|
|
403
|
+
<button
|
|
404
|
+
key={keyStr}
|
|
405
|
+
type="button"
|
|
406
|
+
className="kb-key kb-act"
|
|
407
|
+
style={{ flexGrow: 1.25 }}
|
|
408
|
+
onTouchStart={(e) => {
|
|
409
|
+
e.preventDefault();
|
|
410
|
+
addPressed(e);
|
|
411
|
+
startBksp();
|
|
412
|
+
}}
|
|
413
|
+
onTouchEnd={(e) => {
|
|
414
|
+
removePressed(e);
|
|
415
|
+
stopBksp();
|
|
416
|
+
}}
|
|
417
|
+
onTouchCancel={(e) => {
|
|
418
|
+
removePressed(e);
|
|
419
|
+
stopBksp();
|
|
420
|
+
}}
|
|
421
|
+
onClick={hapticTap}
|
|
422
|
+
>
|
|
423
|
+
<BackspaceIcon />
|
|
424
|
+
</button>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (keyStr === "PASTE") {
|
|
429
|
+
return (
|
|
430
|
+
<button
|
|
431
|
+
key={keyStr}
|
|
432
|
+
type="button"
|
|
433
|
+
className="kb-key kb-act"
|
|
434
|
+
style={{ flexGrow: 1.25 }}
|
|
435
|
+
onTouchStart={addPressed}
|
|
436
|
+
onTouchEnd={(e) => {
|
|
437
|
+
removePressed(e);
|
|
438
|
+
handlePaste();
|
|
439
|
+
}}
|
|
440
|
+
onTouchCancel={removePressed}
|
|
441
|
+
onClick={hapticTap}
|
|
442
|
+
>
|
|
443
|
+
<PasteIcon />
|
|
444
|
+
</button>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (keyStr === "RET") {
|
|
449
|
+
return (
|
|
450
|
+
<button
|
|
451
|
+
key={keyStr}
|
|
452
|
+
type="button"
|
|
453
|
+
className="kb-key kb-pri kb-bottom"
|
|
454
|
+
style={{ flexGrow: 2.5 }}
|
|
455
|
+
onTouchStart={(e) => {
|
|
456
|
+
e.preventDefault();
|
|
457
|
+
addPressed(e);
|
|
458
|
+
send("\r");
|
|
459
|
+
}}
|
|
460
|
+
onTouchEnd={removePressed}
|
|
461
|
+
onTouchCancel={removePressed}
|
|
462
|
+
onClick={hapticTap}
|
|
463
|
+
>
|
|
464
|
+
return
|
|
465
|
+
</button>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (keyStr === "SPACE") {
|
|
470
|
+
return (
|
|
471
|
+
<button
|
|
472
|
+
key={keyStr}
|
|
473
|
+
type="button"
|
|
474
|
+
className="kb-key kb-bottom"
|
|
475
|
+
style={{ flexGrow: 5 }}
|
|
476
|
+
onTouchStart={(e) => {
|
|
477
|
+
e.preventDefault();
|
|
478
|
+
addPressed(e);
|
|
479
|
+
send(" ");
|
|
480
|
+
}}
|
|
481
|
+
onTouchEnd={removePressed}
|
|
482
|
+
onTouchCancel={removePressed}
|
|
483
|
+
onClick={hapticTap}
|
|
484
|
+
>
|
|
485
|
+
space
|
|
486
|
+
</button>
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (keyStr === "123") {
|
|
491
|
+
return (
|
|
492
|
+
<button
|
|
493
|
+
key={keyStr}
|
|
494
|
+
type="button"
|
|
495
|
+
className="kb-key kb-act kb-bottom"
|
|
496
|
+
style={{ flexGrow: 1.25 }}
|
|
497
|
+
onTouchStart={(e) => {
|
|
498
|
+
e.preventDefault();
|
|
499
|
+
addPressed(e);
|
|
500
|
+
setView("num");
|
|
501
|
+
}}
|
|
502
|
+
onTouchEnd={removePressed}
|
|
503
|
+
onTouchCancel={removePressed}
|
|
504
|
+
onClick={hapticTap}
|
|
505
|
+
>
|
|
506
|
+
123
|
|
507
|
+
</button>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (keyStr === "ABC") {
|
|
512
|
+
return (
|
|
513
|
+
<button
|
|
514
|
+
key={keyStr}
|
|
515
|
+
type="button"
|
|
516
|
+
className="kb-key kb-act kb-bottom"
|
|
517
|
+
style={{ flexGrow: 1.25 }}
|
|
518
|
+
onTouchStart={(e) => {
|
|
519
|
+
e.preventDefault();
|
|
520
|
+
addPressed(e);
|
|
521
|
+
setView("abc");
|
|
522
|
+
}}
|
|
523
|
+
onTouchEnd={removePressed}
|
|
524
|
+
onTouchCancel={removePressed}
|
|
525
|
+
onClick={hapticTap}
|
|
526
|
+
>
|
|
527
|
+
ABC
|
|
528
|
+
</button>
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Character key
|
|
533
|
+
const displayChar = shift || caps ? keyStr.toUpperCase() : keyStr;
|
|
534
|
+
return (
|
|
535
|
+
<button
|
|
536
|
+
key={`${keyStr}-${index}`}
|
|
537
|
+
type="button"
|
|
538
|
+
className="kb-key"
|
|
539
|
+
onTouchStart={(e) => {
|
|
540
|
+
e.preventDefault();
|
|
541
|
+
addPressed(e);
|
|
542
|
+
handleCharKey(keyStr);
|
|
543
|
+
}}
|
|
544
|
+
onTouchEnd={removePressed}
|
|
545
|
+
onTouchCancel={removePressed}
|
|
546
|
+
onClick={hapticTap}
|
|
547
|
+
>
|
|
548
|
+
{displayChar}
|
|
549
|
+
</button>
|
|
550
|
+
);
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
if (!visible) return null;
|
|
554
|
+
|
|
555
|
+
return (
|
|
556
|
+
<>
|
|
557
|
+
{/* System keyboard hint overlay */}
|
|
558
|
+
{showHint && (
|
|
559
|
+
<div className="kb-hint-overlay">
|
|
560
|
+
<div className="kb-hint">
|
|
561
|
+
Tap the pulsing <strong>KB</strong> button for smooth typing with native keyboard, spellcheck, autocomplete and dictation
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
565
|
+
|
|
566
|
+
{/* System keyboard dialog */}
|
|
567
|
+
{sysKbOpen && (
|
|
568
|
+
<div className="kb-sys-overlay" onClick={toggleSysKb}>
|
|
569
|
+
<div className="kb-sys-dialog" onClick={(e) => e.stopPropagation()}>
|
|
570
|
+
<textarea
|
|
571
|
+
ref={sysInputRef}
|
|
572
|
+
className="kb-sys-input"
|
|
573
|
+
placeholder="Type here..."
|
|
574
|
+
autoCapitalize="off"
|
|
575
|
+
autoComplete="off"
|
|
576
|
+
autoCorrect="off"
|
|
577
|
+
rows={1}
|
|
578
|
+
value={sysKbDraft}
|
|
579
|
+
onKeyDown={(e) => {
|
|
580
|
+
if (e.key === "Enter") {
|
|
581
|
+
e.preventDefault();
|
|
582
|
+
handleSysKbSubmit();
|
|
583
|
+
}
|
|
584
|
+
}}
|
|
585
|
+
onChange={(e) => {
|
|
586
|
+
setSysKbDraft(e.currentTarget.value);
|
|
587
|
+
}}
|
|
588
|
+
onInput={resizeSysTextarea}
|
|
589
|
+
/>
|
|
590
|
+
</div>
|
|
591
|
+
</div>
|
|
592
|
+
)}
|
|
593
|
+
|
|
594
|
+
<div className="mobile-keyboard">
|
|
595
|
+
{/* Accessory row - Esc, Tab, Ctrl, Alt, arrows, system keyboard */}
|
|
596
|
+
<div className="kb-acc-row">
|
|
597
|
+
{ACC_KEYS.map((k, i) => {
|
|
598
|
+
if (k.mod) {
|
|
599
|
+
const isOn = k.mod === "ctrl" ? ctrl : alt;
|
|
600
|
+
return (
|
|
601
|
+
<button
|
|
602
|
+
key={k.label}
|
|
603
|
+
type="button"
|
|
604
|
+
className={`kb-key kb-act kb-acc${isOn ? " kb-on" : ""}`}
|
|
605
|
+
onTouchStart={(e) => {
|
|
606
|
+
e.preventDefault();
|
|
607
|
+
addPressed(e);
|
|
608
|
+
if (k.mod === "ctrl") setCtrl(prev => !prev);
|
|
609
|
+
else setAlt(prev => !prev);
|
|
610
|
+
}}
|
|
611
|
+
onTouchEnd={removePressed}
|
|
612
|
+
onTouchCancel={removePressed}
|
|
613
|
+
onClick={hapticTap}
|
|
614
|
+
>
|
|
615
|
+
{k.label}
|
|
616
|
+
</button>
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
if (k.fn === "sysKb") {
|
|
620
|
+
return (
|
|
621
|
+
<button
|
|
622
|
+
key={k.label}
|
|
623
|
+
type="button"
|
|
624
|
+
className={`kb-key kb-act kb-acc kb-syskb${showHint ? " kb-pulse" : ""}`}
|
|
625
|
+
onTouchStart={(e) => {
|
|
626
|
+
e.preventDefault();
|
|
627
|
+
addPressed(e);
|
|
628
|
+
try {
|
|
629
|
+
localStorage.setItem("hay-syskb-seen", "1");
|
|
630
|
+
} catch {
|
|
631
|
+
// Ignore
|
|
632
|
+
}
|
|
633
|
+
toggleSysKb();
|
|
634
|
+
}}
|
|
635
|
+
onTouchEnd={removePressed}
|
|
636
|
+
onTouchCancel={removePressed}
|
|
637
|
+
onClick={hapticTap}
|
|
638
|
+
>
|
|
639
|
+
<KeyboardIcon />
|
|
640
|
+
</button>
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
return (
|
|
644
|
+
<button
|
|
645
|
+
key={k.label}
|
|
646
|
+
type="button"
|
|
647
|
+
className="kb-key kb-act kb-acc"
|
|
648
|
+
onTouchStart={(e) => {
|
|
649
|
+
e.preventDefault();
|
|
650
|
+
addPressed(e);
|
|
651
|
+
if (k.key) send(k.key);
|
|
652
|
+
}}
|
|
653
|
+
onTouchEnd={removePressed}
|
|
654
|
+
onTouchCancel={removePressed}
|
|
655
|
+
onClick={hapticTap}
|
|
656
|
+
>
|
|
657
|
+
{k.label}
|
|
658
|
+
</button>
|
|
659
|
+
);
|
|
660
|
+
})}
|
|
661
|
+
</div>
|
|
662
|
+
|
|
663
|
+
{/* Main keyboard rows */}
|
|
664
|
+
<div className="kb-keys">
|
|
665
|
+
{LAYOUTS[view].map((row, ri) => {
|
|
666
|
+
const isThirdRow = ri === 2;
|
|
667
|
+
return (
|
|
668
|
+
<div
|
|
669
|
+
key={ri}
|
|
670
|
+
className={`kb-row${ri === 1 ? " kb-r2" : ""}${isThirdRow ? " kb-r3" : ""}`}
|
|
671
|
+
>
|
|
672
|
+
{row.map((keyStr, ki) => {
|
|
673
|
+
const elements = [];
|
|
674
|
+
|
|
675
|
+
// Add gap after SHIFT in third row
|
|
676
|
+
if (keyStr === "SHIFT" && isThirdRow) {
|
|
677
|
+
elements.push(renderKey(keyStr, ki, isThirdRow));
|
|
678
|
+
elements.push(<div key="gap-after-shift" className="kb-gap" />);
|
|
679
|
+
return elements;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Add gap before BKSP in third row
|
|
683
|
+
if (keyStr === "BKSP" && isThirdRow) {
|
|
684
|
+
elements.push(<div key="gap-before-bksp" className="kb-gap" />);
|
|
685
|
+
elements.push(renderKey(keyStr, ki, isThirdRow));
|
|
686
|
+
return elements;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return renderKey(keyStr, ki, isThirdRow);
|
|
690
|
+
})}
|
|
691
|
+
</div>
|
|
692
|
+
);
|
|
693
|
+
})}
|
|
694
|
+
</div>
|
|
695
|
+
|
|
696
|
+
</div>
|
|
697
|
+
</>
|
|
698
|
+
);
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
export default MobileKeyboard;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import App from "./App";
|
|
4
|
+
import "./styles.css";
|
|
5
|
+
|
|
6
|
+
const container = document.getElementById("root");
|
|
7
|
+
if (container) {
|
|
8
|
+
createRoot(container).render(
|
|
9
|
+
<React.StrictMode>
|
|
10
|
+
<App />
|
|
11
|
+
</React.StrictMode>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|