Build and Push Docker Image / build-and-push (push) Successful in 7m32s
- Text fields are at least 16px on touch screens (stops iOS zooming on focus) - Chapter swipe ignores sheets, fields, sliders, sideways-scrolling rows and text selection - vh -> dvh for sheets, palette and dialogs - Bigger tap targets for small links, chips and the logo; no double-tap delay - No tap flash or landscape text inflation Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
138 lines
8.5 KiB
React
138 lines
8.5 KiB
React
// Present mode: the script or the outline full-screen in large type, with a timer. The script scrolls by
|
||
// itself at a speed you set (a teleprompter), and the outline moves one point at a time with the minutes you
|
||
// planned beside the clock. The screen is kept awake while it is open. Esc, or Close, leaves.
|
||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js';
|
||
|
||
const SIZES = [20, 24, 28, 34, 40, 48, 60, 72];
|
||
const btn = 'rounded-lg border border-current/30 px-3 py-1.5 text-sm hover:bg-white/10 disabled:opacity-40';
|
||
|
||
export default function PresentMode({ title, subtitle = '', mode, script = '', tree = [], onClose }) {
|
||
const [dark, setDark] = useState(true);
|
||
const [size, setSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-size')) || 34)); } catch { return 3; } });
|
||
const [elapsed, setElapsed] = useState(0);
|
||
const [running, setRunning] = useState(false);
|
||
const [scrolling, setScrolling] = useState(false);
|
||
const [speed, setSpeed] = useState(30); // pixels per second
|
||
const [index, setIndex] = useState(0);
|
||
const scroller = useRef(null);
|
||
const steps = mode === 'outline' ? outlineSteps(tree) : [];
|
||
const step = steps[index];
|
||
const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null;
|
||
|
||
// the clock
|
||
useEffect(() => {
|
||
if (!running) return undefined;
|
||
const t = window.setInterval(() => setElapsed((e) => e + 1), 1000);
|
||
return () => window.clearInterval(t);
|
||
}, [running]);
|
||
|
||
// the teleprompter
|
||
useEffect(() => {
|
||
if (!scrolling || mode !== 'script') return undefined;
|
||
let last = performance.now();
|
||
let frame;
|
||
let carry = 0;
|
||
const tick = (now) => {
|
||
const el = scroller.current;
|
||
carry += ((now - last) / 1000) * speed;
|
||
last = now;
|
||
if (el && carry >= 1) { el.scrollTop += Math.floor(carry); carry -= Math.floor(carry); }
|
||
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) { setScrolling(false); return; }
|
||
frame = requestAnimationFrame(tick);
|
||
};
|
||
frame = requestAnimationFrame(tick);
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [scrolling, speed, mode]);
|
||
|
||
// keep the screen on
|
||
useEffect(() => {
|
||
let lock;
|
||
let gone = false;
|
||
navigator.wakeLock?.request?.('screen').then((l) => { if (gone) l.release?.(); else lock = l; }).catch(() => {});
|
||
return () => { gone = true; lock?.release?.().catch?.(() => {}); };
|
||
}, []);
|
||
|
||
const bigger = (d) => setSize((s) => {
|
||
const next = Math.min(SIZES.length - 1, Math.max(0, s + d));
|
||
try { localStorage.setItem('present-size', String(SIZES[next])); } catch { /* storage unavailable */ }
|
||
return next;
|
||
});
|
||
const go = useCallback((d) => setIndex((i) => Math.min(steps.length - 1, Math.max(0, i + d))), [steps.length]);
|
||
|
||
useEffect(() => {
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') { onClose(); return; }
|
||
if (e.target?.tagName === 'INPUT') return;
|
||
if (mode === 'outline') {
|
||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(1); }
|
||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); }
|
||
} else if (e.key === ' ') { e.preventDefault(); setScrolling((s) => !s); }
|
||
if (e.key === '+' || e.key === '=') bigger(1);
|
||
if (e.key === '-') bigger(-1);
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [mode, go, onClose]);
|
||
|
||
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
|
||
const fontSize = `${SIZES[size]}px`;
|
||
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
|
||
|
||
return (
|
||
<div role="dialog" aria-modal="true" aria-label={`Presenting ${title}`} className={`fixed inset-0 z-[80] flex flex-col ${colors}`}>
|
||
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-current/15 px-4 py-2.5">
|
||
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{title}</p>{subtitle && <p className="truncate text-xs opacity-60">{subtitle}</p>}</div>
|
||
<div className="flex items-center gap-2" role="group" aria-label="Timer">
|
||
<span className="min-w-[4.5rem] text-center font-mono text-xl tabular-nums" aria-live="off">{formatClock(elapsed)}</span>
|
||
<button type="button" onClick={() => setRunning((r) => !r)} className={btn}>{running ? 'Pause' : elapsed ? 'Resume' : 'Start'}</button>
|
||
<button type="button" onClick={() => { setElapsed(0); setRunning(false); }} disabled={!elapsed} className={btn}>Reset</button>
|
||
</div>
|
||
{pace && <span className={`text-sm ${pace.diffSeconds < -20 ? 'text-amber-400' : 'opacity-80'}`} role="status">{paceText(pace)} · plan {formatClock(pace.totalSeconds)}</span>}
|
||
<div className="flex items-center gap-1.5" role="group" aria-label="Text size">
|
||
<button type="button" onClick={() => bigger(-1)} disabled={size === 0} aria-label="Smaller text" className={btn}>A−</button>
|
||
<button type="button" onClick={() => bigger(1)} disabled={size === SIZES.length - 1} aria-label="Larger text" className={btn}>A+</button>
|
||
<button type="button" onClick={() => setDark((d) => !d)} aria-pressed={dark} className={btn}>{dark ? 'Light' : 'Dark'}</button>
|
||
</div>
|
||
{mode === 'script' && (
|
||
<div className="flex items-center gap-2" role="group" aria-label="Auto-scroll">
|
||
<button type="button" onClick={() => setScrolling((s) => !s)} aria-pressed={scrolling} className={btn}>{scrolling ? 'Stop scrolling' : 'Auto-scroll'}</button>
|
||
<label className="flex items-center gap-1.5 text-xs opacity-80">Speed
|
||
<input type="range" min="8" max="120" value={speed} onChange={(e) => setSpeed(Number(e.target.value))} aria-label="Scroll speed" />
|
||
</label>
|
||
</div>
|
||
)}
|
||
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
|
||
</div>
|
||
|
||
{mode === 'script' ? (
|
||
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" style={{ scrollBehavior: 'auto' }}>
|
||
{script.trim()
|
||
? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50dvh' }} aria-hidden="true" /></div>
|
||
: <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>}
|
||
</div>
|
||
) : (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6dvh]">
|
||
{step ? (
|
||
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}>
|
||
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}</p>
|
||
<h1 className="mt-2 font-semibold"><span className="mr-3 opacity-60">{step.label}</span>{step.text || '(untitled)'}</h1>
|
||
{step.ref && <p className="mt-2 text-[0.6em] opacity-70">{step.ref}</p>}
|
||
{step.scripture?.text && <blockquote className="mt-5 border-l-4 border-current/30 pl-5 text-[0.8em] italic opacity-90">“{step.scripture.text}” <span className="not-italic opacity-60">— {step.scripture.ref}</span></blockquote>}
|
||
{step.note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{step.note}</p>}
|
||
{step.attachments.length > 0 && <p className="mt-5 text-[0.55em] opacity-60">{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}</p>}
|
||
</div>
|
||
) : <p className="mt-20 text-center text-lg opacity-70">There’s no outline to present yet. Build one on the Outline page.</p>}
|
||
</div>
|
||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-current/15 px-4 py-3">
|
||
<button type="button" onClick={() => go(-1)} disabled={index === 0} className={btn}>← Back</button>
|
||
<span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span>
|
||
<button type="button" onClick={() => go(1)} disabled={index >= steps.length - 1} className={btn}>Next →</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|