Fullscreen on Safari, and a fallback where there is none
Safari on a Mac wants the webkit prefix; Safari on iOS has no fullscreen for an ordinary element at all, only for a <video>, so requestFullscreen was simply undefined and the tab threw. It now uses whichever spelling the browser has, and where there is none the frame fills the viewport in-page instead — the same result minus the browser's own chrome — with a Done button and Escape to come back. The button reads Expand rather than Fullscreen when that is what it will do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e64b0a1727
commit
d8bf5df89f
+47
-9
@@ -22,11 +22,35 @@ import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
|||||||
type KeyboardLock = { lock?: (keys?: string[]) => Promise<void>; unlock?: () => void }
|
type KeyboardLock = { lock?: (keys?: string[]) => Promise<void>; unlock?: () => void }
|
||||||
const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard
|
const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard
|
||||||
|
|
||||||
|
// Fullscreen, across the three ways browsers spell it. Safari on a Mac wants the webkit
|
||||||
|
// prefix; Safari on iOS has no fullscreen for an ordinary element at all — only for a
|
||||||
|
// <video> — so there the page expands inside the viewport instead, which is the same thing
|
||||||
|
// minus the browser's own chrome.
|
||||||
|
type FSElement = HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void }
|
||||||
|
type FSDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => Promise<void> | void }
|
||||||
|
const nativeFullscreen = () => {
|
||||||
|
if (typeof document === 'undefined') return false
|
||||||
|
const el = document.documentElement as FSElement
|
||||||
|
return typeof el.requestFullscreen === 'function' || typeof el.webkitRequestFullscreen === 'function'
|
||||||
|
}
|
||||||
|
const fullscreenElement = () => (document as FSDocument).fullscreenElement ?? (document as FSDocument).webkitFullscreenElement ?? null
|
||||||
|
async function enterFullscreen(el: FSElement) {
|
||||||
|
if (typeof el.requestFullscreen === 'function') return el.requestFullscreen()
|
||||||
|
if (typeof el.webkitRequestFullscreen === 'function') return el.webkitRequestFullscreen()
|
||||||
|
throw new Error('this browser has no fullscreen for a page element')
|
||||||
|
}
|
||||||
|
function exitFullscreen() {
|
||||||
|
const d = document as FSDocument
|
||||||
|
if (typeof d.exitFullscreen === 'function') return d.exitFullscreen()
|
||||||
|
if (typeof d.webkitExitFullscreen === 'function') return d.webkitExitFullscreen()
|
||||||
|
}
|
||||||
|
|
||||||
export default function KVM({ machine, title }: { machine: string | null; title: string }) {
|
export default function KVM({ machine, title }: { machine: string | null; title: string }) {
|
||||||
const [kvm, setKvm] = useState<Device | null | undefined>(undefined)
|
const [kvm, setKvm] = useState<Device | null | undefined>(undefined)
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [full, setFull] = useState(false)
|
const [full, setFull] = useState(false) // native fullscreen is on
|
||||||
|
const [expanded, setExpanded] = useState(false) // the fallback: filling the viewport in-page
|
||||||
const box = useRef<HTMLDivElement>(null)
|
const box = useRef<HTMLDivElement>(null)
|
||||||
const frame = useRef<HTMLIFrameElement>(null)
|
const frame = useRef<HTMLIFrameElement>(null)
|
||||||
const key = machine ?? '' // the hub is a machine too, and its key is the empty one
|
const key = machine ?? '' // the hub is a machine too, and its key is the empty one
|
||||||
@@ -41,21 +65,33 @@ export default function KVM({ machine, title }: { machine: string | null; title:
|
|||||||
// than the browser. Both are undone together when fullscreen ends, however it ends.
|
// than the browser. Both are undone together when fullscreen ends, however it ends.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChange = () => {
|
const onChange = () => {
|
||||||
const on = !!document.fullscreenElement
|
const on = !!fullscreenElement()
|
||||||
setFull(on)
|
setFull(on)
|
||||||
if (!on) keyboardAPI()?.unlock?.()
|
if (!on) keyboardAPI()?.unlock?.()
|
||||||
}
|
}
|
||||||
document.addEventListener('fullscreenchange', onChange)
|
document.addEventListener('fullscreenchange', onChange)
|
||||||
return () => { document.removeEventListener('fullscreenchange', onChange); keyboardAPI()?.unlock?.() }
|
document.addEventListener('webkitfullscreenchange', onChange)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('fullscreenchange', onChange)
|
||||||
|
document.removeEventListener('webkitfullscreenchange', onChange)
|
||||||
|
keyboardAPI()?.unlock?.()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
// Expanding is in-page, so Escape is ours to handle.
|
||||||
|
useEscape(() => setExpanded(false), expanded)
|
||||||
|
|
||||||
const goFullscreen = async () => {
|
const goBig = async () => {
|
||||||
|
if (!nativeFullscreen()) { setExpanded(true); frame.current?.focus(); return }
|
||||||
try {
|
try {
|
||||||
await box.current?.requestFullscreen()
|
if (box.current) await enterFullscreen(box.current)
|
||||||
await keyboardAPI()?.lock?.()
|
await keyboardAPI()?.lock?.()
|
||||||
frame.current?.focus()
|
frame.current?.focus()
|
||||||
} catch (e) { toast(e instanceof Error ? e.message : 'fullscreen refused', 'err') }
|
} catch {
|
||||||
|
setExpanded(true) // refused (an iframe policy, a permissions policy): fill the page instead
|
||||||
|
frame.current?.focus()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
const goSmall = () => { setExpanded(false); if (fullscreenElement()) exitFullscreen() }
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!kvm || !await askYes(`Forget ${title}'s KVM? The device itself is not touched — this only removes its address.`, 'Remove')) return
|
if (!kvm || !await askYes(`Forget ${title}'s KVM? The device itself is not touched — this only removes its address.`, 'Remove')) return
|
||||||
@@ -66,6 +102,7 @@ export default function KVM({ machine, title }: { machine: string | null; title:
|
|||||||
if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
|
if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
|
||||||
|
|
||||||
const locks = !!keyboardAPI()?.lock
|
const locks = !!keyboardAPI()?.lock
|
||||||
|
const big = full || expanded
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kvm">
|
<div className="kvm">
|
||||||
@@ -76,7 +113,7 @@ export default function KVM({ machine, title }: { machine: string | null; title:
|
|||||||
: <span className="muted small">no KVM</span>}
|
: <span className="muted small">no KVM</span>}
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{kvm && <>
|
{kvm && <>
|
||||||
<Btn className="btn" onClick={goFullscreen} title={locks ? 'fullscreen, and the browser stops keeping Escape, Tab and Ctrl-W for itself' : 'fullscreen — this browser will still keep some keys for itself'}>Fullscreen</Btn>
|
<Btn className="btn" onClick={goBig} title={!nativeFullscreen() ? 'fill the screen — this browser has no true fullscreen for a page element' : locks ? 'fullscreen, and the browser stops keeping Escape, Tab and Ctrl-W for itself' : 'fullscreen — this browser will still keep some keys for itself'}>{nativeFullscreen() ? 'Fullscreen' : 'Expand'}</Btn>
|
||||||
<a className="btn" href={kvm.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab — only works on the LAN">Open ↗</a>
|
<a className="btn" href={kvm.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab — only works on the LAN">Open ↗</a>
|
||||||
<Btn className="btn" onClick={() => setAdding(true)}>Change</Btn>
|
<Btn className="btn" onClick={() => setAdding(true)}>Change</Btn>
|
||||||
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
||||||
@@ -85,9 +122,10 @@ export default function KVM({ machine, title }: { machine: string | null; title:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{kvm ? (
|
{kvm ? (
|
||||||
<div className={'kvm-frame' + (full ? ' full' : '')} ref={box}>
|
<div className={'kvm-frame' + (full ? ' full' : '') + (expanded ? ' expanded' : '')} ref={box}>
|
||||||
<iframe ref={frame} key={kvm.id} src={`/kvm/${kvm.id}/`} title={kvm.name}
|
<iframe ref={frame} key={kvm.id} src={`/kvm/${kvm.id}/`} title={kvm.name}
|
||||||
allow="fullscreen; clipboard-read; clipboard-write" />
|
allow="fullscreen; clipboard-read; clipboard-write" />
|
||||||
|
{expanded && <button className="btn kvm-shrink" onClick={goSmall} title="back to the console">Done</button>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="svc-empty muted kvm-none">
|
<div className="svc-empty muted kvm-none">
|
||||||
@@ -98,7 +136,7 @@ export default function KVM({ machine, title }: { machine: string | null; title:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{kvm && !full && (
|
{kvm && !big && (
|
||||||
<div className="kvm-hint muted small">
|
<div className="kvm-hint muted small">
|
||||||
Click the picture, then type — the keys go to {title}.
|
Click the picture, then type — the keys go to {title}.
|
||||||
{locks ? ' Fullscreen also takes Escape, Tab and Ctrl-W back from the browser.' : ' This browser keeps Escape, Tab and Ctrl-W for itself; use the device’s own on-screen keys for those, or a Chromium browser.'}
|
{locks ? ' Fullscreen also takes Escape, Tab and Ctrl-W back from the browser.' : ' This browser keeps Escape, Tab and Ctrl-W for itself; use the device’s own on-screen keys for those, or a Chromium browser.'}
|
||||||
|
|||||||
@@ -751,3 +751,8 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
|||||||
.kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); }
|
.kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); }
|
||||||
@media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } }
|
@media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } }
|
||||||
.kvm-none { text-align: center; } .kvm-none p { margin: 4px 0; }
|
.kvm-none { text-align: center; } .kvm-none p { margin: 4px 0; }
|
||||||
|
/* No true fullscreen for a page element (Safari on iOS): fill the viewport in-page instead. */
|
||||||
|
.kvm-frame.expanded { position: fixed; inset: 0; z-index: 60; }
|
||||||
|
.kvm-frame.expanded iframe { height: 100%; }
|
||||||
|
.kvm-shrink { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 61; background: var(--bg2); border: 1px solid var(--border); opacity: .75; }
|
||||||
|
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
|
||||||
|
|||||||
Reference in New Issue
Block a user