import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { SessionTimerLayout } from '../../../shared/types'; import { clampSessionTimerLayout, DEFAULT_SESSION_TIMER_LAYOUT, formatSessionTimerMs, SESSION_TIMER_BASE_WIDTH_FRAC, sessionTimerVisualLayout, } from '../../../shared/types/sessionTimer'; import styles from './SessionTimerOverlay.module.css'; type Corner = 'nw' | 'ne' | 'sw' | 'se'; function pointerAngleDeg(cx: number, cy: number, x: number, y: number): number { return (Math.atan2(y - cy, x - cx) * 180) / Math.PI; } function shortestAngleDelta(from: number, to: number): number { let d = to - from; while (d > 180) d -= 360; while (d < -180) d += 360; return d; } function screenToLocal( clientX: number, clientY: number, centerX: number, centerY: number, rotationDeg: number, ): { x: number; y: number } { const dx = clientX - centerX; const dy = clientY - centerY; const rad = (-rotationDeg * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); return { x: dx * cos - dy * sin, y: dx * sin + dy * cos }; } function localToScreenOffset(lx: number, ly: number, rotationDeg: number): { x: number; y: number } { const rad = (rotationDeg * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); return { x: lx * cos - ly * sin, y: lx * sin + ly * cos }; } export function SessionTimerOverlay({ remainingMs, layout, viewport, editable = false, onLayoutChange, }: { remainingMs: number; layout: SessionTimerLayout; viewport: { x: number; y: number; w: number; h: number }; editable?: boolean; onLayoutChange?: (layout: SessionTimerLayout) => void; }) { const rootRef = useRef(null); const [draftLayout, setDraftLayout] = useState(null); const pendingLayoutRef = useRef(null); const draftRafRef = useRef(0); const onLayoutChangeRef = useRef(onLayoutChange); onLayoutChangeRef.current = onLayoutChange; const dragRef = useRef< | { mode: 'move'; startX: number; startY: number; origin: SessionTimerLayout } | { mode: 'resize'; corner: Corner; origin: SessionTimerLayout; baseW: number; baseH: number; viewW: number; viewH: number; centerClientX: number; centerClientY: number; } | { mode: 'rotate'; origin: SessionTimerLayout; startPointerAngle: number; centerX: number; centerY: number; } | null >(null); useEffect(() => { if (dragRef.current) return; setDraftLayout(null); }, [layout]); useEffect( () => () => { if (draftRafRef.current !== 0) window.cancelAnimationFrame(draftRafRef.current); }, [], ); const effectiveLayout = draftLayout ?? layout ?? DEFAULT_SESSION_TIMER_LAYOUT; const visual = useMemo( () => sessionTimerVisualLayout(effectiveLayout, viewport), [effectiveLayout, viewport], ); const baseW = Math.max(48, viewport.w * SESSION_TIMER_BASE_WIDTH_FRAC); const baseH = baseW * 0.42; const expired = remainingMs <= 0; const label = formatSessionTimerMs(remainingMs); const layoutCenterClient = useCallback(() => { const root = rootRef.current; if (!root) return { x: 0, y: 0 }; const r = root.getBoundingClientRect(); return { x: r.left + effectiveLayout.cx * r.width, y: r.top + effectiveLayout.cy * r.height, }; }, [effectiveLayout.cx, effectiveLayout.cy]); const publishDraft = (next: SessionTimerLayout): void => { pendingLayoutRef.current = next; if (draftRafRef.current !== 0) return; draftRafRef.current = window.requestAnimationFrame(() => { draftRafRef.current = 0; const pending = pendingLayoutRef.current; if (!pending) return; setDraftLayout(pending); onLayoutChangeRef.current?.(pending); }); }; const onPointerMove = (e: PointerEvent) => { const drag = dragRef.current; if (!drag || !onLayoutChange) return; if (drag.mode === 'rotate') { const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY); const delta = shortestAngleDelta(drag.startPointerAngle, angle); publishDraft( clampSessionTimerLayout({ ...drag.origin, rotationDeg: drag.origin.rotationDeg + delta, }), ); return; } const root = rootRef.current; if (!root) return; const r = root.getBoundingClientRect(); if (drag.mode === 'move') { const dx = (e.clientX - drag.startX) / Math.max(1, r.width); const dy = (e.clientY - drag.startY) / Math.max(1, r.height); publishDraft( clampSessionTimerLayout({ ...drag.origin, cx: drag.origin.cx + dx, cy: drag.origin.cy + dy, }), ); return; } const { baseW: bw, baseH: bh, viewW, viewH, origin, corner, centerClientX, centerClientY } = drag; const originW = bw * origin.scale; const originH = bh * origin.scale; const originLeft = -originW / 2; const originTop = -originH / 2; const originRight = originW / 2; const originBottom = originH / 2; let anchorX = originLeft; let anchorY = originTop; if (corner === 'nw') { anchorX = originRight; anchorY = originBottom; } else if (corner === 'ne') { anchorX = originLeft; anchorY = originBottom; } else if (corner === 'sw') { anchorX = originRight; anchorY = originTop; } const local = screenToLocal( e.clientX, e.clientY, centerClientX, centerClientY, origin.rotationDeg ?? 0, ); const newW = Math.max(8, Math.abs(local.x - anchorX)); const newH = Math.max(8, Math.abs(local.y - anchorY)); const nextScale = Math.max(newW / Math.max(1, bw), newH / Math.max(1, bh)); const ww = bw * nextScale; const hh = bh * nextScale; let nextLeft = anchorX; let nextTop = anchorY; if (corner === 'nw') { nextLeft = anchorX - ww; nextTop = anchorY - hh; } else if (corner === 'ne') { nextLeft = anchorX; nextTop = anchorY - hh; } else if (corner === 'sw') { nextLeft = anchorX - ww; nextTop = anchorY; } const localCenterX = nextLeft + ww / 2; const localCenterY = nextTop + hh / 2; const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0); publishDraft( clampSessionTimerLayout({ ...origin, cx: origin.cx + screenOffset.x / Math.max(1, viewW), cy: origin.cy + screenOffset.y / Math.max(1, viewH), scale: nextScale, }), ); }; const endDrag = () => { dragRef.current = null; window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', endDrag); if (draftRafRef.current !== 0) { window.cancelAnimationFrame(draftRafRef.current); draftRafRef.current = 0; } const finalLayout = pendingLayoutRef.current; if (finalLayout) { setDraftLayout(finalLayout); onLayoutChangeRef.current?.(finalLayout); } }; const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => { if (!editable || !onLayoutChange) return; e.preventDefault(); e.stopPropagation(); if (mode === 'move') { dragRef.current = { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...effectiveLayout }, }; } else { const center = layoutCenterClient(); dragRef.current = { mode: 'resize', corner: corner ?? 'se', origin: { ...effectiveLayout }, baseW, baseH, viewW: viewport.w, viewH: viewport.h, centerClientX: center.x, centerClientY: center.y, }; } window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', endDrag); }; const startRotate = (e: React.PointerEvent) => { if (!editable || !onLayoutChange) return; e.preventDefault(); e.stopPropagation(); const center = layoutCenterClient(); dragRef.current = { mode: 'rotate', origin: { ...effectiveLayout }, startPointerAngle: pointerAngleDeg(center.x, center.y, e.clientX, e.clientY), centerX: center.x, centerY: center.y, }; window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', endDrag); }; return (
startDrag(e, 'move')} >
{label}
{editable ? ( <> {(['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
); }