db1168a46c
Отдельное окно countdown с «Показать игрокам»; на превью — move/resize/rotate, на презентации тот же layout. Co-authored-by: Cursor <cursoragent@cursor.com>
343 lines
9.9 KiB
TypeScript
343 lines
9.9 KiB
TypeScript
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<HTMLDivElement | null>(null);
|
|
const [draftLayout, setDraftLayout] = useState<SessionTimerLayout | null>(null);
|
|
const pendingLayoutRef = useRef<SessionTimerLayout | null>(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 (
|
|
<div
|
|
ref={rootRef}
|
|
className={styles.root}
|
|
style={{
|
|
left: viewport.x,
|
|
top: viewport.y,
|
|
width: viewport.w,
|
|
height: viewport.h,
|
|
}}
|
|
data-testid="session-timer-overlay-root"
|
|
>
|
|
<div
|
|
className={[styles.frame, editable ? styles.frameEditable : '', expired ? styles.expired : '']
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
data-testid="session-timer-overlay"
|
|
style={{
|
|
left: visual.left - viewport.x - visual.width / 2,
|
|
top: visual.top - viewport.y - visual.height / 2,
|
|
width: visual.width,
|
|
height: visual.height,
|
|
transform: `rotate(${String(visual.rotationDeg)}deg)`,
|
|
transformOrigin: 'center center',
|
|
fontSize: Math.max(14, visual.height * 0.42),
|
|
}}
|
|
onPointerDown={(e) => startDrag(e, 'move')}
|
|
>
|
|
<div className={styles.display}>{label}</div>
|
|
{editable ? (
|
|
<>
|
|
{(['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
|
|
<button
|
|
key={corner}
|
|
type="button"
|
|
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
|
|
aria-label="Resize"
|
|
onPointerDown={(e) => startDrag(e, 'resize', corner)}
|
|
/>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className={styles.rotateHandle}
|
|
aria-label="Rotate"
|
|
onPointerDown={startRotate}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|