Files
DndGamePlayer/app/renderer/shared/playerToken/PlayerTokenView.tsx
T
Ivan Fontosh 0a9168b320 fix: scene clear race, path editor anchors, avatar cover, team color crash
Clear scene now patches traps/tokens/npcTokens atomically and updateScene merges against a fresh scene snapshot. Align NPC path-editor previews on token center, default new paths to fixed facing, size avatars by natural aspect for cover, and read team color synchronously from the picker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 15:30:34 +08:00

294 lines
9.9 KiB
TypeScript

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)})`;
}
/** Size the <img> so it covers the circle while keeping natural aspect (no square pre-crop). */
function syncAvatarCoverSize(img: HTMLImageElement | null, circlePx: number) {
if (!img || circlePx <= 0) return;
const nw = img.naturalWidth;
const nh = img.naturalHeight;
if (nw <= 0 || nh <= 0) return;
const cover = Math.max(circlePx / nw, circlePx / nh);
img.style.width = `${String(nw * cover)}px`;
img.style.height = `${String(nh * cover)}px`;
}
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<HTMLDivElement | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const visualOffsetRef = useRef(offset);
const visualScaleRef = useRef(scale);
const pendingOffsetRef = useRef<PlayerImageOffset | null>(null);
const pendingScaleRef = useRef<number | null>(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(() => {
syncAvatarCoverSize(imgRef.current, sizePx);
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 (
<div
className={rootClass}
style={{ width: sizePx, gap: hideName ? undefined : nameGapPx }}
data-testid={testId}
>
<div
ref={circleRef}
data-token-circle="1"
className={[
styles.circle,
panEnabled && imageUrl ? styles.circlePan : '',
canPickEmpty ? styles.circlePick : '',
dropActive ? styles.circleDrop : '',
]
.filter(Boolean)
.join(' ')}
style={{
width: sizePx,
height: sizePx,
borderColor: dropActive ? undefined : inactive ? '#9ca3af' : ringColor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onClick={
canPickEmpty
? () => {
onCircleClick?.();
}
: undefined
}
>
{imageUrl ? (
<img
ref={imgRef}
className={styles.avatar}
src={imageUrl}
alt=""
draggable={false}
style={inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : undefined}
onLoad={() => {
syncAvatarCoverSize(imgRef.current, sizePx);
writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current);
}}
/>
) : (
<div className={[styles.avatarEmpty, emptyContent ? styles.avatarEmptySolid : ''].filter(Boolean).join(' ')}>
{emptyContent}
</div>
)}
{circleOverlay}
</div>
{!hideName ? (
<div className={styles.namePlate} title={name} style={{ fontSize: nameFontPx }}>
{name || '—'}
</div>
) : null}
</div>
);
}