Files
DndGamePlayer/app/renderer/shared/tokens/SceneTokensOverlay.tsx
T
2026-08-18 14:42:06 +08:00

339 lines
11 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
import { clampSceneTokenSizeN } from '../../../shared/types/appTokens';
import { useLiveResizeBroadcast } from '../playerToken/useLiveDragBroadcast';
import { useTokenImageUrl } from './useTokenImageUrl';
import styles from './SceneTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
export type TokenPoseOverride = {
nx: number;
ny: number;
rotationDeg?: number;
};
type Props = {
placements: readonly SceneToken[];
library: readonly AppToken[];
session: SceneTokensSessionState | null;
viewport: Viewport | null;
editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
/** Snap во время drag (пульт, привязка к сетке). */
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
/** Live path playback / other pose overrides by placement id. */
poseOverrides?: Record<string, TokenPoseOverride> | null;
/** Per-placement drag lock (e.g. while path is animating). Default: all editable. */
dragEnabledById?: Record<string, boolean> | null;
};
function layerFromEvent(e: React.PointerEvent<HTMLElement>): HTMLElement | null {
return e.currentTarget.closest('[data-token-layer="1"]');
}
function TokenSprite({
placement,
nx,
ny,
sizeN,
rotationDeg,
viewport,
editable,
dragEnabled,
onMove,
onResize,
resizeTitle,
snapNorm,
onContextMenu,
}: {
placement: SceneToken;
nx: number;
ny: number;
sizeN: number;
rotationDeg: number;
viewport: Viewport;
editable: boolean;
dragEnabled: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
}) {
const url = useTokenImageUrl(placement.tokenId);
const dragRef = useRef<{
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
pointerId: number;
lastNx: number;
lastNy: number;
} | null>(null);
const resizeRef = useRef<{
pointerId: number;
startSize: number;
startDist: number;
lastSize: number;
} | null>(null);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const [localSize, setLocalSize] = useState<number | null>(null);
const frameRef = useRef(0);
const { schedule: scheduleResize, flush: flushResize } = useLiveResizeBroadcast(
onResize,
String(placement.id),
);
useEffect(() => {
if (dragRef.current) return;
setLocalPos(null);
}, [nx, ny]);
useEffect(() => {
if (resizeRef.current) return;
setLocalSize(null);
}, [sizeN]);
useEffect(() => {
return () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
};
}, []);
const posNx = localPos?.nx ?? nx;
const posNy = localPos?.ny ?? ny;
const shownSizeN = localSize ?? sizeN;
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, shownSizeN * minDim);
const left = viewport.x + posNx * viewport.w;
const top = viewport.y + posNy * viewport.h;
const canDrag = editable && dragEnabled && Boolean(onMove);
const canResize = editable && dragEnabled && Boolean(onResize);
const interactive = canDrag || canResize || Boolean(onContextMenu);
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
const r = host.getBoundingClientRect();
const w = Math.max(1e-6, viewport.w);
const h = Math.max(1e-6, viewport.h);
return {
x: Math.max(0, Math.min(1, (clientX - (r.left + viewport.x)) / w)),
y: Math.max(0, Math.min(1, (clientY - (r.top + viewport.y)) / h)),
};
};
const endDrag = (el: HTMLDivElement, pointerId: number) => {
const d = dragRef.current;
if (!d || d.pointerId !== pointerId) return;
dragRef.current = null;
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
frameRef.current = 0;
}
onMove?.(String(placement.id), d.lastNx, d.lastNy);
setLocalPos({ nx: d.lastNx, ny: d.lastNy });
try {
if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId);
} catch {
/* ignore */
}
};
const endResize = (el: HTMLDivElement, pointerId: number) => {
const d = resizeRef.current;
if (!d || d.pointerId !== pointerId) return;
resizeRef.current = null;
flushResize(d.lastSize);
setLocalSize(null);
try {
if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId);
} catch {
/* ignore */
}
};
return (
<div
className={[
styles.token,
canDrag ? styles.tokenEditable : '',
interactive && !canDrag ? styles.tokenInteractive : '',
]
.filter(Boolean)
.join(' ')}
style={{
left,
top,
width: sizePx,
height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}}
onContextMenu={
onContextMenu
? (e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, placement);
}
: undefined
}
onPointerDown={
canDrag
? (e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const host = (e.currentTarget.parentElement as HTMLElement | null) ?? e.currentTarget;
const p = hostToNorm(e.clientX, e.clientY, host);
dragRef.current = {
startNx: posNx,
startNy: posNy,
pointerNx: p.x,
pointerNy: p.y,
pointerId: e.pointerId,
lastNx: posNx,
lastNy: posNy,
};
setLocalPos({ nx: posNx, ny: posNy });
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
}
: undefined
}
onPointerMove={
canDrag
? (e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
const host = (e.currentTarget.parentElement as HTMLElement | null) ?? e.currentTarget;
const p = hostToNorm(e.clientX, e.clientY, host);
const nextNx = Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx)));
const nextNy = Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy)));
const snapped = snapNorm ? snapNorm(nextNx, nextNy) : { nx: nextNx, ny: nextNy };
d.lastNx = snapped.nx;
d.lastNy = snapped.ny;
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0;
const cur = dragRef.current;
if (!cur) return;
setLocalPos({ nx: cur.lastNx, ny: cur.lastNy });
});
}
: undefined
}
onPointerUp={(e) => {
endDrag(e.currentTarget as HTMLDivElement, e.pointerId);
}}
onPointerCancel={(e) => {
endDrag(e.currentTarget as HTMLDivElement, e.pointerId);
}}
>
{url ? (
<div className={styles.tokenFace}>
<img className={styles.tokenImg} src={url} alt="" draggable={false} />
</div>
) : null}
{canResize ? (
<div
className={styles.resizeHandle}
title={resizeTitle}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const host = layerFromEvent(e);
if (!host) return;
const p = hostToNorm(e.clientX, e.clientY, host);
const dist = Math.max(1e-4, Math.hypot(p.x - posNx, p.y - posNy));
resizeRef.current = {
pointerId: e.pointerId,
startSize: shownSizeN,
startDist: dist,
lastSize: shownSizeN,
};
setLocalSize(shownSizeN);
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = resizeRef.current;
if (!d || d.pointerId !== e.pointerId) return;
const host = layerFromEvent(e);
if (!host) return;
const p = hostToNorm(e.clientX, e.clientY, host);
const dist = Math.max(1e-4, Math.hypot(p.x - posNx, p.y - posNy));
const next = clampSceneTokenSizeN(d.startSize * (dist / d.startDist));
d.lastSize = next;
setLocalSize(next);
scheduleResize(next);
}}
onPointerUp={(e) => {
endResize(e.currentTarget, e.pointerId);
}}
onPointerCancel={(e) => {
endResize(e.currentTarget, e.pointerId);
}}
/>
) : null}
</div>
);
}
export function SceneTokensOverlay({
placements,
library,
session,
viewport,
editable = false,
onMove,
onResize,
resizeTitle,
snapNorm,
onContextMenu,
poseOverrides = null,
dragEnabledById = null,
}: Props) {
if (!viewport || placements.length === 0) return null;
const known = new Set(library.map((t) => t.id));
return (
<div className={styles.layer} data-token-layer="1">
{placements
.filter((p) => known.has(p.tokenId))
.map((placement) => {
const key = String(placement.id);
const pose = poseOverrides?.[key];
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
const nx = pose?.nx ?? override?.nx ?? placement.nx;
const ny = pose?.ny ?? override?.ny ?? placement.ny;
const sizeN = override?.sizeN ?? placement.sizeN;
const rotationDeg = pose?.rotationDeg ?? placement.rotationDeg;
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
return (
<TokenSprite
key={key}
placement={placement}
nx={nx}
ny={ny}
sizeN={sizeN}
rotationDeg={rotationDeg}
viewport={viewport}
editable={editable}
dragEnabled={dragEnabled}
{...(onMove ? { onMove } : {})}
{...(onResize ? { onResize } : {})}
{...(resizeTitle ? { resizeTitle } : {})}
{...(snapNorm ? { snapNorm } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
})}
</div>
);
}