From 250bf5ac191c1613543ab1018727597e2694452f Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:11:31 +0200 Subject: [PATCH] /set: touch lock in viewer, plain grey at top - Viewer ignores taps, swipes, pinch/double-tap zoom, long-press menus, keys and Back; hold the top-left corner 3 s to show controls, which re-lock after 6 s or via the Lock button - Back is trapped with a guard history entry; Close unwinds it - Plain grey moved above 'This screen' - Tips for OS-level lock (iOS Guided Access, Android app pinning) Co-Authored-By: Claude Opus 5.5 --- components/set/MarkerPicker.tsx | 263 +++++++++++++++++++++++++------- 1 file changed, 206 insertions(+), 57 deletions(-) diff --git a/components/set/MarkerPicker.tsx b/components/set/MarkerPicker.tsx index 7b186a2..fa519be 100644 --- a/components/set/MarkerPicker.tsx +++ b/components/set/MarkerPicker.tsx @@ -68,11 +68,39 @@ const subscribeHash = subscribe(["popstate", "hashchange", HASH_EVENT]); const subscribeMode = subscribe(["storage", MODE_EVENT]); const subscribeScreen = subscribe(["resize", "orientationchange"]); +// History entries pushed while the viewer is open. While it is open, Back is trapped +// (a stray press of Android's back button must not drop the marker mid-take), so +// closing has to unwind all of them. +let viewerDepth = 0; +let closingViewer = false; + function setHash(hash: string, push: boolean) { + if (push) viewerDepth++; history[push ? "pushState" : "replaceState"]({ marker: Boolean(hash) }, "", hash || location.pathname); window.dispatchEvent(new Event(HASH_EVENT)); } +function closeViewer() { + closingViewer = true; + if (viewerDepth > 0) { + const n = viewerDepth; + viewerDepth = 0; + history.go(-n); // the popstate handler below finishes the job + } else { + closingViewer = false; + setHash("", false); + } +} + +if (typeof window !== "undefined") { + window.addEventListener("popstate", () => { + if (!closingViewer) return; + closingViewer = false; + // Opened from a direct link: the entry we land on still carries the hash + if (location.hash) setHash("", false); + }); +} + function readMode(): Mode { try { return localStorage.getItem(MODE_KEY) === "edge" ? "edge" : "full"; @@ -180,10 +208,7 @@ export default function MarkerPicker({ screens, extras }: { screens: MarkerScree setHash(`#${e.id}`, true); requestFullscreen(); }; - const close = useCallback(() => { - if (history.state?.marker) history.back(); - else setHash("", false); - }, []); + const close = useCallback(() => closeViewer(), []); const switchMode = useCallback( (m: Mode) => { writeMode(m); @@ -235,6 +260,31 @@ export default function MarkerPicker({ screens, extras }: { screens: MarkerScree ))} + {/* Plain grey and other non-device images */} + {extras.length > 0 && ( +
+ +
+ )} + {/* This screen */} {match && device && (
@@ -299,33 +349,21 @@ export default function MarkerPicker({ screens, extras }: { screens: MarkerScree
))} - {extras.length > 0 && ( -
-

Other

- -
- )} +
+ Lock the whole device (blocks home swipe & notifications) +

+ iPhone / iPad: Settings → Accessibility → Guided Access → on. Open a marker, + triple-click the side (or home) button, turn Touch off in Options, then + Start. Triple-click again to end. +

+

+ Android: Settings → Security → App pinning (Screen pinning) → on. Open a + marker, open Recents, tap the app icon → Pin. Hold Back + Overview to unpin. +

+

- Tap the image for controls · Back or Esc to close · Screen stays awake while showing + Touch is locked while a marker is up · hold the top-left corner 3 s for controls · screen stays awake

{showing && ( @@ -335,6 +373,11 @@ export default function MarkerPicker({ screens, extras }: { screens: MarkerScree ); } +const HOLD_MS = 3000; // how long to hold the corner to unlock +const HOLD_SHOW_MS = 500; // ring only appears after this, so brushes show nothing +const CORNER = 80; // px, top-left hot zone +const CONTROLS_MS = 6000; // controls re-lock after this + function Viewer({ showing, mode, @@ -346,9 +389,14 @@ function Viewer({ onMode: (m: Mode) => void; onClose: () => void; }) { + // Touch-locked by default: only a 3 s hold in the top-left corner shows controls. const [controls, setControls] = useState(false); + const [hold, setHold] = useState(0); // 0..1 progress of the corner hold + const [hint, setHint] = useState(true); const [fullscreen, setFullscreen] = useState(false); const hideTimer = useRef(undefined); + const holdRef = useRef<{ id: number; raf: number } | null>(null); + // Stretch to fill when the image's shape is close to the visible area (markers sit on // the real screen edges); otherwise fit it whole so the dots stay round. const viewport = useSyncExternalStore(subscribeScreen, () => `${innerWidth}x${innerHeight}`, () => "1x1"); @@ -360,71 +408,165 @@ function Viewer({ }, [showing.screen, viewport]); const canFullscreen = typeof document !== "undefined" && document.fullscreenEnabled; + const showControls = useCallback(() => { + window.clearTimeout(hideTimer.current); + setControls(true); + setHint(false); + hideTimer.current = window.setTimeout(() => setControls(false), CONTROLS_MS); + }, []); + const lock = () => { + window.clearTimeout(hideTimer.current); + setControls(false); + }; + // Keep the screen awake while a marker is up useEffect(() => { - let lock: WakeLockSentinel | null = null; + let wake: WakeLockSentinel | null = null; const acquire = async () => { try { - lock = (await navigator.wakeLock?.request("screen")) ?? null; + wake = (await navigator.wakeLock?.request("screen")) ?? null; } catch {} }; const onVisible = () => document.visibilityState === "visible" && acquire(); acquire(); document.addEventListener("visibilitychange", onVisible); - document.body.style.overflow = "hidden"; return () => { document.removeEventListener("visibilitychange", onVisible); - document.body.style.overflow = ""; - lock?.release().catch(() => {}); + wake?.release().catch(() => {}); + }; + }, []); + + // Swallow everything the browser would otherwise do with stray input: + // scrolling, pull-to-refresh, pinch/double-tap zoom, long-press menus, Back. + useEffect(() => { + const root = document.documentElement; + const prev = { overflow: document.body.style.overflow, overscroll: root.style.overscrollBehavior }; + document.body.style.overflow = "hidden"; + root.style.overscrollBehavior = "none"; + + const block = (e: Event) => e.preventDefault(); + const blockTouch = (e: TouchEvent) => { + if (!(e.target as Element).closest?.("[data-controls]")) e.preventDefault(); + }; + document.addEventListener("touchmove", blockTouch, { passive: false }); + document.addEventListener("gesturestart", block); // iOS pinch + document.addEventListener("contextmenu", block); + document.addEventListener("dblclick", block); + + // Guard entry so Back stays on the marker; re-armed every time it is used + viewerDepth++; + history.pushState({ marker: true }, "", location.hash); + const onPop = () => { + if (closingViewer || !location.hash) return; + history.pushState({ marker: true }, "", location.hash); + }; + window.addEventListener("popstate", onPop); + + const hintTimer = window.setTimeout(() => setHint(false), 4000); + return () => { + document.body.style.overflow = prev.overflow; + root.style.overscrollBehavior = prev.overscroll; + document.removeEventListener("touchmove", blockTouch); + document.removeEventListener("gesturestart", block); + document.removeEventListener("contextmenu", block); + document.removeEventListener("dblclick", block); + window.removeEventListener("popstate", onPop); + window.clearTimeout(hintTimer); }; }, []); useEffect(() => { const onFs = () => setFullscreen(Boolean(document.fullscreenElement)); - onFs(); document.addEventListener("fullscreenchange", onFs); const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !document.fullscreenElement) onClose(); - if (e.key === "f") onMode("full"); - if (e.key === "e") onMode("edge"); - if (e.key === " ") setControls((v) => !v); + // Locked: keys do nothing (a prop laptop keyboard may get typed on mid-take). + // The browser itself still leaves full screen on Esc; that cannot be blocked. + if (!controls) { + e.preventDefault(); + return; + } + if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", onKey); return () => { document.removeEventListener("fullscreenchange", onFs); document.removeEventListener("keydown", onKey); }; - }, [onClose, onMode]); + }, [controls, onClose]); - const poke = () => { - window.clearTimeout(hideTimer.current); - setControls((v) => { - if (!v) hideTimer.current = window.setTimeout(() => setControls(false), 4000); - return !v; - }); + // --- corner hold ------------------------------------------------------- + const cancelHold = () => { + if (holdRef.current) cancelAnimationFrame(holdRef.current.raf); + holdRef.current = null; + setHold(0); + }; + const onPointerDown = (e: React.PointerEvent) => { + if (canFullscreen && !document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(() => {}); + if (controls || e.clientX > CORNER || e.clientY > CORNER) return; + const start = performance.now(); + const tick = () => { + if (!holdRef.current) return; + const t = performance.now() - start; + setHold(t < HOLD_SHOW_MS ? 0 : Math.min(1, (t - HOLD_SHOW_MS) / (HOLD_MS - HOLD_SHOW_MS))); + if (t >= HOLD_MS) { + cancelHold(); + showControls(); + return; + } + holdRef.current.raf = requestAnimationFrame(tick); + }; + holdRef.current = { id: e.pointerId, raf: requestAnimationFrame(tick) }; + }; + const onPointerMove = (e: React.PointerEvent) => { + if (holdRef.current?.id === e.pointerId && (e.clientX > CORNER * 1.5 || e.clientY > CORNER * 1.5)) cancelHold(); }; return (
{ - if (canFullscreen && !document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(() => {}); - poke(); - }} + className={`fixed inset-0 z-[100] touch-none bg-black select-none [-webkit-touch-callout:none] ${controls ? "" : "cursor-none"}`} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={cancelHold} + onPointerCancel={cancelHold} + onPointerLeave={cancelHold} > {/* eslint-disable-next-line @next/next/no-img-element */} - {showing.label} + {showing.label} - {canFullscreen && !fullscreen && !controls && ( -

- Tap for full screen -

+ {/* Corner-hold progress ring */} + {hold > 0 && ( + + )} + + {hint && !controls && ( +
+

+ 🔒 Touch locked · hold top-left corner 3 s for controls + {canFullscreen && !fullscreen ? " · tap for full screen" : ""} +

+
)} {controls && (
e.stopPropagation()} + onPointerDown={(e) => { + e.stopPropagation(); + showControls(); // keep them up while being used + }} >
)}