import React, { useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'; import type { PlayerImageOffset } from '../../../shared/types/appPlayers'; import { clampPlayerImageOffset, clampPlayerImageScale, DEFAULT_PLAYER_IMAGE_SCALE, DEFAULT_PLAYER_RING_COLOR, PLAYER_IMAGE_SCALE_STEP, } from '../../../shared/types/appPlayers'; import styles from './PlayerTokenView.module.css'; const SCALE_PERSIST_MS = 200; function writeAvatarTransform( img: HTMLImageElement | null, offset: PlayerImageOffset, scale: number, ) { if (!img) return; img.style.transform = `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`; } export type PlayerTokenViewProps = { name: string; imageUrl: string | null; ringColor?: string; imageOffset?: PlayerImageOffset; imageScale?: number; /** Размер круга в px (для превью в модалке). На сцене задавайте через CSS width/height родителя. */ sizePx?: number; /** Разрешить drag-pan аватара внутри круга и zoom колесом. */ panEnabled?: boolean; onImageOffsetChange?: (offset: PlayerImageOffset) => void; onImageScaleChange?: (scale: number) => void; className?: string; /** Без подписи имени (компактный маркер). */ hideName?: boolean; /** Session «Неактивен»: серый фильтр аватара. */ inactive?: boolean; /** Текст/контент внутри пустого круга (только редактор, не маркеры на карте). */ emptyContent?: ReactNode; /** Оверлей поверх круга (карандаш, drop-hint). */ circleOverlay?: ReactNode; /** Подсветка круга при файловом DnD. */ dropActive?: boolean; /** Клик по пустому кругу (выбор файла). */ onCircleClick?: () => void; /** Сброс локального pan/zoom при смене сущности (npcId / playerId). */ syncKey?: string; 'data-testid'?: string; }; export function PlayerTokenView({ name, imageUrl, ringColor = DEFAULT_PLAYER_RING_COLOR, imageOffset, imageScale, sizePx = 160, panEnabled = false, onImageOffsetChange, onImageScaleChange, className, hideName = false, inactive = false, emptyContent, circleOverlay, dropActive = false, onCircleClick, syncKey, 'data-testid': testId, }: PlayerTokenViewProps) { const offset = clampPlayerImageOffset(imageOffset); const scale = clampPlayerImageScale(imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE); const circleRef = useRef(null); const imgRef = useRef(null); const visualOffsetRef = useRef(offset); const visualScaleRef = useRef(scale); const pendingOffsetRef = useRef(null); const pendingScaleRef = useRef(null); const scaleTimerRef = useRef(0); const onImageOffsetChangeRef = useRef(onImageOffsetChange); onImageOffsetChangeRef.current = onImageOffsetChange; const onImageScaleChangeRef = useRef(onImageScaleChange); onImageScaleChangeRef.current = onImageScaleChange; const dragRef = useRef<{ pointerId: number; startX: number; startY: number; origX: number; origY: number; } | null>(null); useEffect(() => { pendingOffsetRef.current = null; pendingScaleRef.current = null; dragRef.current = null; visualOffsetRef.current = clampPlayerImageOffset(imageOffset); visualScaleRef.current = clampPlayerImageScale(imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE); writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current); // identity only — offset/scale sync is handled below // eslint-disable-next-line react-hooks/exhaustive-deps }, [syncKey]); useEffect(() => { if (dragRef.current) return; if (pendingOffsetRef.current) { if ( Math.abs(offset.x - pendingOffsetRef.current.x) < 1e-6 && Math.abs(offset.y - pendingOffsetRef.current.y) < 1e-6 ) { pendingOffsetRef.current = null; } else { return; } } visualOffsetRef.current = offset; writeAvatarTransform(imgRef.current, offset, visualScaleRef.current); }, [offset.x, offset.y]); useEffect(() => { if (scaleTimerRef.current) return; if (pendingScaleRef.current != null) { if (Math.abs(scale - pendingScaleRef.current) < 1e-6) { pendingScaleRef.current = null; } else { return; } } visualScaleRef.current = scale; writeAvatarTransform(imgRef.current, visualOffsetRef.current, scale); }, [scale]); useLayoutEffect(() => { writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current); }); useEffect(() => { return () => { if (scaleTimerRef.current) { window.clearTimeout(scaleTimerRef.current); scaleTimerRef.current = 0; onImageScaleChangeRef.current?.(visualScaleRef.current); } }; }, []); const onPointerDown = useCallback( (e: React.PointerEvent) => { if (!panEnabled || !onImageOffsetChangeRef.current || !imageUrl) return; e.preventDefault(); e.stopPropagation(); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); dragRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, origX: visualOffsetRef.current.x, origY: visualOffsetRef.current.y, }; }, [imageUrl, panEnabled], ); const onPointerMove = useCallback( (e: React.PointerEvent) => { const d = dragRef.current; if (!d || d.pointerId !== e.pointerId) return; const dx = (e.clientX - d.startX) / Math.max(1, sizePx); const dy = (e.clientY - d.startY) / Math.max(1, sizePx); const next = clampPlayerImageOffset({ x: d.origX + dx, y: d.origY + dy }); visualOffsetRef.current = next; writeAvatarTransform(imgRef.current, next, visualScaleRef.current); }, [sizePx], ); const onPointerUp = useCallback((e: React.PointerEvent) => { const d = dragRef.current; if (!d || d.pointerId !== e.pointerId) return; dragRef.current = null; pendingOffsetRef.current = visualOffsetRef.current; try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ } onImageOffsetChangeRef.current?.(visualOffsetRef.current); }, []); useEffect(() => { const el = circleRef.current; if (!el || !panEnabled) return; const onWheel = (e: WheelEvent) => { if (!onImageScaleChangeRef.current) return; e.preventDefault(); e.stopPropagation(); const direction = e.deltaY < 0 ? 1 : -1; const next = clampPlayerImageScale(visualScaleRef.current + direction * PLAYER_IMAGE_SCALE_STEP); visualScaleRef.current = next; writeAvatarTransform(imgRef.current, visualOffsetRef.current, next); if (scaleTimerRef.current) window.clearTimeout(scaleTimerRef.current); scaleTimerRef.current = window.setTimeout(() => { scaleTimerRef.current = 0; pendingScaleRef.current = visualScaleRef.current; onImageScaleChangeRef.current?.(visualScaleRef.current); }, SCALE_PERSIST_MS); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, [panEnabled]); const rootClass = [styles.root, className ?? ''].filter(Boolean).join(' '); const nameFontPx = Math.max(9, Math.min(13, Math.round(sizePx * 0.16))); const nameGapPx = Math.max(2, Math.min(8, Math.round(sizePx * 0.06))); const canPickEmpty = Boolean(onCircleClick) && !imageUrl; return (
{ onCircleClick?.(); } : undefined } > {imageUrl ? ( ) : (
{emptyContent}
)} {circleOverlay}
{!hideName ? (
{name || '—'}
) : null}
); }