/set: touch lock in viewer, plain grey at top
Deploy / deploy (push) Successful in 31s

- 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 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-06 11:11:31 +02:00
parent e6658eae3d
commit 250bf5ac19
+206 -57
View File
@@ -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
))}
</div>
{/* Plain grey and other non-device images */}
{extras.length > 0 && (
<section className="mt-6">
<ul className="divide-y divide-white/10 overflow-hidden rounded-xl bg-white/[0.04]">
{extras.map((e) => (
<li key={e.id}>
<button
type="button"
onClick={() => showExtra(e)}
className="flex min-h-16 w-full items-center gap-4 px-4 py-3 text-left active:bg-white/10"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center">
<span className="block h-7 w-7 rounded-[3px] bg-[#808080]" />
</span>
<span className="flex-1 text-base">{e.name}</span>
<span className="text-xl text-muted" aria-hidden="true">
›
</span>
</button>
</li>
))}
</ul>
</section>
)}
{/* This screen */}
{match && device && (
<section className="mt-6">
@@ -299,33 +349,21 @@ export default function MarkerPicker({ screens, extras }: { screens: MarkerScree
</section>
))}
{extras.length > 0 && (
<section className="mt-8">
<h2 className="mb-2 font-mono text-[11px] tracking-[0.25em] text-muted uppercase">Other</h2>
<ul className="divide-y divide-white/10 overflow-hidden rounded-xl bg-white/[0.04]">
{extras.map((e) => (
<li key={e.id}>
<button
type="button"
onClick={() => showExtra(e)}
className="flex min-h-16 w-full items-center gap-4 px-4 py-3 text-left active:bg-white/10"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center">
<span className="block h-7 w-7 rounded-[3px] bg-[#808080]" />
</span>
<span className="flex-1 text-base">{e.name}</span>
<span className="text-xl text-muted" aria-hidden="true">
›
</span>
</button>
</li>
))}
</ul>
</section>
)}
<details className="mt-8 rounded-xl bg-white/[0.04] px-4 py-3 text-sm leading-relaxed text-muted">
<summary className="cursor-pointer py-2 text-fg">Lock the whole device (blocks home swipe &amp; notifications)</summary>
<p className="mt-2">
<span className="text-fg">iPhone / iPad:</span> Settings → Accessibility → Guided Access → on. Open a marker,
triple-click the side (or home) button, turn <span className="text-fg">Touch</span> off in Options, then
Start. Triple-click again to end.
</p>
<p className="mt-2 pb-1">
<span className="text-fg">Android:</span> Settings → Security → App pinning (Screen pinning) → on. Open a
marker, open Recents, tap the app icon → Pin. Hold Back + Overview to unpin.
</p>
</details>
<p className="mt-10 text-center text-xs leading-relaxed text-muted">
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
</p>
{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<number | undefined>(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 (
<div
className={`fixed inset-0 z-[100] bg-black ${controls ? "" : "cursor-none"}`}
onClick={() => {
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 */}
<img src={showing.src} alt={showing.label} className={`h-full w-full select-none ${fit}`} draggable={false} />
<img src={showing.src} alt={showing.label} className={`pointer-events-none h-full w-full ${fit}`} draggable={false} />
{canFullscreen && !fullscreen && !controls && (
<p className="pointer-events-none absolute inset-x-0 bottom-6 text-center font-mono text-xs text-white/70">
Tap for full screen
</p>
{/* Corner-hold progress ring */}
{hold > 0 && (
<svg className="pointer-events-none absolute top-3 left-3 h-14 w-14 -rotate-90" viewBox="0 0 36 36" aria-hidden="true">
<circle cx="18" cy="18" r="15" fill="rgba(0,0,0,.6)" stroke="rgba(255,255,255,.25)" strokeWidth="3" />
<circle
cx="18"
cy="18"
r="15"
fill="none"
stroke="var(--accent)"
strokeWidth="3"
strokeDasharray={`${hold * 94.25} 94.25`}
strokeLinecap="round"
/>
</svg>
)}
{hint && !controls && (
<div className="pointer-events-none absolute inset-x-0 bottom-[max(1.5rem,env(safe-area-inset-bottom))] flex justify-center px-4">
<p className="rounded-full bg-black/75 px-4 py-2 text-center font-mono text-xs text-white/85">
🔒 Touch locked · hold top-left corner 3 s for controls
{canFullscreen && !fullscreen ? " · tap for full screen" : ""}
</p>
</div>
)}
{controls && (
<div
data-controls
className="absolute inset-x-0 top-0 flex flex-wrap items-center gap-2 bg-black/85 px-3 pt-[max(0.75rem,env(safe-area-inset-top))] pb-3"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => {
e.stopPropagation();
showControls(); // keep them up while being used
}}
>
<button
type="button"
@@ -457,6 +599,13 @@ function Viewer({
>
Save
</a>
<button
type="button"
onClick={lock}
className="min-h-12 rounded-lg bg-accent px-4 text-sm font-semibold text-ink active:opacity-80"
>
🔒 Lock
</button>
</div>
)}
</div>