feat(npcs): inspect cards, Show overlay, and optional external link
Clicking an NPC tile opens description cards (multi-select accordion); Show puts avatars on screen. Optional URL opens a webview tab. Avatar pan in the editor commits on pointer up. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react';
|
||||
|
||||
import type { PlayerImageOffset } from '../../../shared/types/appPlayers';
|
||||
import {
|
||||
@@ -11,6 +11,17 @@ import {
|
||||
|
||||
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;
|
||||
@@ -28,6 +39,16 @@ export type PlayerTokenViewProps = {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -44,13 +65,24 @@ export function PlayerTokenView({
|
||||
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 scaleRef = useRef(scale);
|
||||
scaleRef.current = scale;
|
||||
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<{
|
||||
@@ -61,9 +93,63 @@ export function PlayerTokenView({
|
||||
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 || !onImageOffsetChange) return;
|
||||
if (!panEnabled || !onImageOffsetChangeRef.current || !imageUrl) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
@@ -71,45 +157,56 @@ export function PlayerTokenView({
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: offset.x,
|
||||
origY: offset.y,
|
||||
origX: visualOffsetRef.current.x,
|
||||
origY: visualOffsetRef.current.y,
|
||||
};
|
||||
},
|
||||
[offset.x, offset.y, onImageOffsetChange, panEnabled],
|
||||
[imageUrl, panEnabled],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.pointerId !== e.pointerId || !onImageOffsetChange) return;
|
||||
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);
|
||||
onImageOffsetChange(clampPlayerImageOffset({ x: d.origX + dx, y: d.origY + dy }));
|
||||
const next = clampPlayerImageOffset({ x: d.origX + dx, y: d.origY + dy });
|
||||
visualOffsetRef.current = next;
|
||||
writeAvatarTransform(imgRef.current, next, visualScaleRef.current);
|
||||
},
|
||||
[onImageOffsetChange, sizePx],
|
||||
[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) => {
|
||||
const cb = onImageScaleChangeRef.current;
|
||||
if (!cb) return;
|
||||
if (!onImageScaleChangeRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
cb(clampPlayerImageScale(scaleRef.current + direction * PLAYER_IMAGE_SCALE_STEP));
|
||||
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);
|
||||
@@ -118,6 +215,7 @@ export function PlayerTokenView({
|
||||
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
|
||||
@@ -127,31 +225,47 @@ export function PlayerTokenView({
|
||||
>
|
||||
<div
|
||||
ref={circleRef}
|
||||
className={[styles.circle, panEnabled ? styles.circlePan : ''].filter(Boolean).join(' ')}
|
||||
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: inactive ? '#9ca3af' : ringColor,
|
||||
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={{
|
||||
transform: `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`,
|
||||
...(inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : {}),
|
||||
}}
|
||||
style={inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.avatarEmpty} />
|
||||
<div className={[styles.avatarEmpty, emptyContent ? styles.avatarEmptySolid : ''].filter(Boolean).join(' ')}>
|
||||
{emptyContent}
|
||||
</div>
|
||||
)}
|
||||
{circleOverlay}
|
||||
</div>
|
||||
{!hideName ? (
|
||||
<div className={styles.namePlate} title={name} style={{ fontSize: nameFontPx }}>
|
||||
|
||||
Reference in New Issue
Block a user