- 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:
+206
-57
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user