mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
Keep the page clear of iOS safe areas everywhere; research typing, pointing and mirroring into the Frame
- Header, content, tab bar, status strip, drawer and toasts add the notch, Dynamic Island, rounded-corner and home-indicator insets on every side (zero on desktops). Phones on their side use the bottom tab bar layout. - The phone tab bar hides while a text field has focus, instead of riding on the keyboard. - DEBUG hook FRAME_TEST_LANDSCAPE for checking this in the Simulator. - docs/streaming.md: iPhone mirroring (UxPlay, broadcast extension) and keyboard/mouse input (uinput needs no sudo on the Frame, verified). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3db311da13
commit
fe87a9d826
3 files changed
+63
-25
No files matched your search
+34
-9
@@ -1,9 +1,11 @@
|
|||||||
# Screen and desktop streaming
|
# Screen and desktop streaming
|
||||||
|
|
||||||
This covers two directions:
|
This covers three directions, plus input:
|
||||||
|
|
||||||
- **A. Frame → Mac**: see and control the headset from the Mac.
|
- **A. Frame → Mac**: see and control the headset from the Mac.
|
||||||
- **B. Mac → Frame**: use the Mac's desktop inside the headset.
|
- **B. Mac → Frame**: use the Mac's desktop inside the headset.
|
||||||
|
- **C. iPhone → Frame**: mirror the phone inside the headset.
|
||||||
|
- **Input**: type and point in the Frame from the Mac or iPhone.
|
||||||
|
|
||||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||||
|
|
||||||
@@ -51,14 +53,37 @@ choose to save it. Remmina stores passwords encrypted with a per-install key,
|
|||||||
so the script doesn't try to write the password. (The Remmina file format is
|
so the script doesn't try to write the password. (The Remmina file format is
|
||||||
standard; the Flatpak data path is inferred.)
|
standard; the Flatpak data path is inferred.)
|
||||||
|
|
||||||
## Input and text entry without the virtual keyboard
|
## C. Show the iPhone's screen inside the Frame
|
||||||
|
|
||||||
- **A Bluetooth keyboard and mouse** paired to the Frame is the obvious way to
|
iOS only shares its screen two ways: **AirPlay** (Screen Mirroring in Control
|
||||||
avoid the virtual keyboard. Road to VR says there are "only a few things
|
Centre) or a **ReplayKit broadcast extension** in an app. Nothing else can
|
||||||
you'd actually want to do" on the Linux desktop unless you connect a
|
capture it.
|
||||||
keyboard and mouse.
|
|
||||||
(Pairing a BT keyboard on the Frame is inferred from SteamOS; not verified.)
|
| Option | What it takes | Confidence | Verdict |
|
||||||
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh` (see
|
|---|---|---|---|
|
||||||
[file-transfer.md](file-transfer.md#clipboard)).
|
| **UxPlay** (an open-source AirPlay receiver) on the Frame | Build it for aarch64 (no Flathub package; there's a Snap and distro packages), run it in `~` or a podman container, and advertise it over mDNS. The iPhone *and* the Mac then see "Frame" in Screen Mirroring, with nothing to install on either | **Inferred.** It runs on ARM64 Linux such as the Raspberry Pi ([UxPlay](https://github.com/FDH2/UxPlay)). Not tried on the Frame: needs mDNS registration and its ports (7000, 7001, 7100 and a UDP range) reachable | **Recommended to try first.** It's the only receiver-side option, and it covers the Mac too. The window shows in the Frame's Linux desktop panel |
|
||||||
|
| A broadcast extension in Frame Control | ReplayKit sends the screen to a small extension (50 MB memory limit), which encodes H.264 and sends it through the app's SSH tunnel to the page, shown the same way as the Frame's live view in reverse | **Inferred** from Apple's ReplayKit docs | Full control and no network setup, but several days' work, and the picture only shows where Frame Control's page is open in the headset |
|
||||||
|
|
||||||
|
## Input: type and point in the Frame from the Mac or iPhone
|
||||||
|
|
||||||
|
**Verified 2026-09-27** on the headset: `steamos` is in the `input` group and
|
||||||
|
`/dev/uinput` is `crw-rw-r-- root input`, so **our own code can create a
|
||||||
|
virtual keyboard and mouse without sudo**. The Frame has no `python-evdev`,
|
||||||
|
`ydotool`, `wtype` or KDE Connect; `kwin_wayland` and `plasmashell` run only
|
||||||
|
while the desktop panel is open in the headset.
|
||||||
|
|
||||||
|
| Option | Mac | iPhone | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A uinput keyboard and mouse in Frame Control's server** | ✓ | ✓ | **Recommended.** The server opens `/dev/uinput` with `ctypes` (standard library only) and the page sends key and pointer events through the tunnel it already has. On the phone: a trackpad area (drag to move, tap to click, two fingers to scroll) and the iOS keyboard for typing. On the Mac: a "control the Frame" mode that captures the keyboard and pointer (Esc to release). Uinput devices look like real hardware to the kernel, so libinput, KWin and gamescope should take them; [frame-voice](https://github.com/DeeJanuz/frame-voice) already types into a Frame through a uinput keyboard. **Untested**: which surfaces in VR (desktop panel, SteamVR dashboard, games, Android apps in Lepton) accept the pointer. About a day or two of work |
|
||||||
|
| **Bluetooth keyboard and mouse** | – | – | Real hardware paired in SteamOS settings. The iPhone can't pretend to be a Bluetooth keyboard: iOS won't advertise the HID service ([Apple forums](https://developer.apple.com/forums/thread/733916)) |
|
||||||
|
| **Deskflow** (formerly Input Leap / Barrier) | ✓ | – | Moves the Mac's own mouse and keyboard onto the Frame's screen edge. Flathub has an aarch64 build ([Flathub](https://flathub.org/apps/org.deskflow.deskflow)); on Wayland it needs the InputCapture/libei portal, and only works while Plasma is running. No iPhone client |
|
||||||
|
| **KDE Connect** | ~ | ✓ | Its iOS app has a remote touchpad and keyboard, but the Frame would need KDE Connect installed (not on Flathub; `pacman` on a read-only root). More moving parts than the uinput route |
|
||||||
|
| **Remmina / Steam Link / RDP** | ✓ | – | Input only reaches the streamed session, not the headset's own apps |
|
||||||
|
|
||||||
|
Other ways to get text in:
|
||||||
|
|
||||||
|
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh`, or Frame Control's
|
||||||
|
clipboard box (see [file-transfer.md](file-transfer.md#clipboard)). Needs
|
||||||
|
the desktop panel open.
|
||||||
- **RDP session**: Windows App syncs the clipboard with xrdp, but only inside
|
- **RDP session**: Windows App syncs the clipboard with xrdp, but only inside
|
||||||
that RDP session.
|
that RDP session.
|
||||||
@@ -14,6 +14,11 @@ struct FrameControlApp: App {
|
|||||||
// and connect to FRAME_TEST_HOST with it (`simctl launch` passes
|
// and connect to FRAME_TEST_HOST with it (`simctl launch` passes
|
||||||
// SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST).
|
// SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST).
|
||||||
print("FRAME_CONTROL_KEY: \(model.authorizedKeysLine)")
|
print("FRAME_CONTROL_KEY: \(model.authorizedKeysLine)")
|
||||||
|
// FRAME_TEST_LANDSCAPE=1 turns the app on its side, to check the safe areas there.
|
||||||
|
if ProcessInfo.processInfo.environment["FRAME_TEST_LANDSCAPE"] != nil,
|
||||||
|
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||||
|
scene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscapeRight))
|
||||||
|
}
|
||||||
// FRAME_TEST_PAIR="host|user|password" runs the real password pairing.
|
// FRAME_TEST_PAIR="host|user|password" runs the real password pairing.
|
||||||
if model.settings == nil, let pair = ProcessInfo.processInfo.environment["FRAME_TEST_PAIR"] {
|
if model.settings == nil, let pair = ProcessInfo.processInfo.environment["FRAME_TEST_PAIR"] {
|
||||||
let f = pair.components(separatedBy: "|")
|
let f = pair.components(separatedBy: "|")
|
||||||
|
|||||||
+24
-16
@@ -27,8 +27,8 @@
|
|||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
[hidden] { display: none !important; } /* class display rules would otherwise win */
|
[hidden] { display: none !important; } /* class display rules would otherwise win */
|
||||||
html { scroll-behavior: smooth; scroll-padding-top: 76px; }
|
html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); }
|
||||||
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: 46px;
|
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom));
|
||||||
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
|
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
|
||||||
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
|
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
|
||||||
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
|
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
|
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
|
||||||
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px;
|
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px;
|
||||||
height: 64px; padding: 0 28px; background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
|
height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
|
||||||
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
|
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
|
||||||
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
|
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
|
||||||
.brand svg { width: 30px; height: 30px; }
|
.brand svg { width: 30px; height: 30px; }
|
||||||
@@ -57,10 +57,10 @@
|
|||||||
.dot.on { background: var(--green); box-shadow: 0 0 6px rgba(89,191,64,.8); }
|
.dot.on { background: var(--green); box-shadow: 0 0 6px rgba(89,191,64,.8); }
|
||||||
.dot.off { background: var(--bad); }
|
.dot.off { background: var(--bad); }
|
||||||
|
|
||||||
main { max-width: 1480px; margin: 0 auto; padding: 22px 28px 30px; display: flex; flex-direction: column; gap: 26px; }
|
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
|
||||||
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
|
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
|
||||||
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
|
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
|
||||||
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: 0 18px; } nav a { padding: 0 10px; } }
|
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
|
||||||
@media (max-width: 820px) { .brand b { display: none; } }
|
@media (max-width: 820px) { .brand b { display: none; } }
|
||||||
nav svg { display: none; }
|
nav svg { display: none; }
|
||||||
html { -webkit-text-size-adjust: 100%; }
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
@@ -231,21 +231,21 @@
|
|||||||
.links div { margin: 5px 0; }
|
.links div { margin: 5px 0; }
|
||||||
|
|
||||||
/* ---- bottom bar (Steam's DOWNLOADS strip) ---- */
|
/* ---- bottom bar (Steam's DOWNLOADS strip) ---- */
|
||||||
.bottombar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: 42px; display: flex; align-items: center; gap: 14px;
|
.bottombar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: calc(42px + env(safe-area-inset-bottom)); display: flex; align-items: center; gap: 14px;
|
||||||
padding: 0 28px; background: rgba(23,29,37,.96); box-shadow: 0 -1px 0 rgba(255,255,255,.04); cursor: pointer; }
|
padding: 0 calc(28px + env(safe-area-inset-right)) env(safe-area-inset-bottom) calc(28px + env(safe-area-inset-left)); background: rgba(23,29,37,.96); box-shadow: 0 -1px 0 rgba(255,255,255,.04); cursor: pointer; }
|
||||||
.bottombar .label { font-size: 12px; font-weight: 700; letter-spacing: 1.6px; color: var(--muted); }
|
.bottombar .label { font-size: 12px; font-weight: 700; letter-spacing: 1.6px; color: var(--muted); }
|
||||||
.bottombar .jobs { display: inline-flex; align-items: center; gap: 8px; color: var(--link); font-size: 12.5px; }
|
.bottombar .jobs { display: inline-flex; align-items: center; gap: 8px; color: var(--link); font-size: 12.5px; }
|
||||||
.bottombar .jobs .spinner { width: 13px; height: 13px; border-width: 2px; }
|
.bottombar .jobs .spinner { width: 13px; height: 13px; border-width: 2px; }
|
||||||
.bottombar .last { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: 13px; }
|
.bottombar .last { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: 13px; }
|
||||||
.bottombar .last.e { color: #ff8a73; }
|
.bottombar .last.e { color: #ff8a73; }
|
||||||
.drawer { position: fixed; left: 0; right: 0; bottom: 42px; z-index: 19; max-height: 0; overflow: hidden;
|
.drawer { position: fixed; left: 0; right: 0; bottom: calc(42px + env(safe-area-inset-bottom)); z-index: 19; max-height: 0; overflow: hidden;
|
||||||
background: rgba(14,20,27,.98); transition: max-height .25s ease; box-shadow: 0 -10px 30px rgba(0,0,0,.5); }
|
background: rgba(14,20,27,.98); transition: max-height .25s ease; box-shadow: 0 -10px 30px rgba(0,0,0,.5); }
|
||||||
.drawer.open { max-height: 300px; }
|
.drawer.open { max-height: 300px; }
|
||||||
.log { font: 12px/1.55 ui-monospace, "SF Mono", Menlo, monospace; color: #b8c4d2; max-height: 300px; overflow: auto;
|
.log { font: 12px/1.55 ui-monospace, "SF Mono", Menlo, monospace; color: #b8c4d2; max-height: 300px; overflow: auto;
|
||||||
white-space: pre-wrap; padding: 12px 28px; }
|
white-space: pre-wrap; padding: 12px calc(28px + env(safe-area-inset-right)) 12px calc(28px + env(safe-area-inset-left)); }
|
||||||
.log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); }
|
.log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); }
|
||||||
.log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); }
|
.log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); }
|
||||||
.toast { position: fixed; bottom: 58px; right: 28px; z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
|
.toast { position: fixed; bottom: calc(58px + env(safe-area-inset-bottom)); right: calc(28px + env(safe-area-inset-right)); z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
|
||||||
background: #3d4450; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.5); border-left: 3px solid var(--blue);
|
background: #3d4450; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.5); border-left: 3px solid var(--blue);
|
||||||
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
|
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
|
||||||
.toast.show { opacity: 1; transform: none; }
|
.toast.show { opacity: 1; transform: none; }
|
||||||
@@ -322,15 +322,17 @@
|
|||||||
border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; }
|
border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; }
|
||||||
/* Touch screens can't hover: keep the library's name and Play button showing. */
|
/* Touch screens can't hover: keep the library's name and Play button showing. */
|
||||||
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
|
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
|
||||||
/* ---- phones: tabs move to a bottom bar, as in iOS apps; the page clears the notch and home indicator ---- */
|
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
|
||||||
@media (max-width: 640px) {
|
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
|
||||||
|
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
|
||||||
|
html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
|
||||||
/* No blur here: it would make the header the containing block of the fixed tab bar. */
|
/* No blur here: it would make the header the containing block of the fixed tab bar. */
|
||||||
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) 14px 0; gap: 10px;
|
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
|
||||||
backdrop-filter: none; background: rgb(23, 29, 37); }
|
backdrop-filter: none; background: rgb(23, 29, 37); }
|
||||||
.brand svg { width: 26px; height: 26px; }
|
.brand svg { width: 26px; height: 26px; }
|
||||||
header .chip { height: 30px; padding: 0 10px; }
|
header .chip { height: 30px; padding: 0 10px; }
|
||||||
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
|
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
|
||||||
height: calc(58px + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom);
|
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||||
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
|
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
|
||||||
nav a { flex: 1; flex-direction: column; justify-content: center; gap: 3px; padding: 0; font-size: 10.5px;
|
nav a { flex: 1; flex-direction: column; justify-content: center; gap: 3px; padding: 0; font-size: 10.5px;
|
||||||
letter-spacing: .5px; border-bottom: 0; color: var(--muted); }
|
letter-spacing: .5px; border-bottom: 0; color: var(--muted); }
|
||||||
@@ -338,9 +340,9 @@
|
|||||||
nav svg { display: block; width: 23px; height: 23px; }
|
nav svg { display: block; width: 23px; height: 23px; }
|
||||||
.bottombar, .drawer { display: none; }
|
.bottombar, .drawer { display: none; }
|
||||||
body { padding-bottom: calc(70px + env(safe-area-inset-bottom)); }
|
body { padding-bottom: calc(70px + env(safe-area-inset-bottom)); }
|
||||||
.toast { left: 14px; right: 14px; max-width: none; bottom: calc(70px + env(safe-area-inset-bottom)); }
|
.toast { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); max-width: none; bottom: calc(70px + env(safe-area-inset-bottom)); }
|
||||||
main, .page { gap: 16px; }
|
main, .page { gap: 16px; }
|
||||||
main { padding: 12px 12px 20px; }
|
main { padding: 12px calc(12px + env(safe-area-inset-right)) 20px calc(12px + env(safe-area-inset-left)); }
|
||||||
.panel { padding: 14px; }
|
.panel { padding: 14px; }
|
||||||
.grid-top, .grid-3, .and-grid, .and-col { gap: 14px; }
|
.grid-top, .grid-3, .and-grid, .and-col { gap: 14px; }
|
||||||
.toolbar { gap: 8px; }
|
.toolbar { gap: 8px; }
|
||||||
@@ -356,6 +358,8 @@
|
|||||||
button.small { height: 32px; }
|
button.small { height: 32px; }
|
||||||
.actions button { height: 46px; }
|
.actions button { height: 46px; }
|
||||||
.cat-tools select { max-width: none; flex: 1 1 100%; }
|
.cat-tools select { max-width: none; flex: 1 1 100%; }
|
||||||
|
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
|
||||||
|
body.typing nav { display: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -2118,6 +2122,10 @@ function showPage() {
|
|||||||
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
window.addEventListener("hashchange", showPage);
|
window.addEventListener("hashchange", showPage);
|
||||||
|
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
|
||||||
|
const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=checkbox], [type=radio], [type=file], [type=button])');
|
||||||
|
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
|
||||||
|
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
|
||||||
showPage();
|
showPage();
|
||||||
document.addEventListener("keydown", e => {
|
document.addEventListener("keydown", e => {
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;
|
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;
|
||||||
|
|||||||
Reference in new issue
Block a user