feat(control): таймер сессии поверх сцены
Отдельное окно countdown с «Показать игрокам»; на превью — move/resize/rotate, на презентации тот же layout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user