-
- )}
+
+ 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 (