f270812219
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label. Co-authored-by: Cursor <cursoragent@cursor.com>
194 lines
5.8 KiB
TypeScript
194 lines
5.8 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
|
|
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
|
|
|
|
import { useTokenImageUrl } from './useTokenImageUrl';
|
|
import styles from './SceneTokensOverlay.module.css';
|
|
|
|
type Viewport = { x: number; y: number; w: number; h: 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;
|
|
};
|
|
|
|
function TokenSprite({
|
|
placement,
|
|
nx,
|
|
ny,
|
|
viewport,
|
|
editable,
|
|
onMove,
|
|
}: {
|
|
placement: SceneToken;
|
|
nx: number;
|
|
ny: number;
|
|
viewport: Viewport;
|
|
editable: boolean;
|
|
onMove?: (placementId: string, nx: number, ny: number) => 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 [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
|
const frameRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
if (dragRef.current) return;
|
|
setLocalPos(null);
|
|
}, [nx, ny]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const posNx = localPos?.nx ?? nx;
|
|
const posNy = localPos?.ny ?? ny;
|
|
|
|
const minDim = Math.min(viewport.w, viewport.h);
|
|
const sizePx = Math.max(16, placement.sizeN * minDim);
|
|
const left = viewport.x + posNx * viewport.w;
|
|
const top = viewport.y + posNy * viewport.h;
|
|
|
|
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;
|
|
}
|
|
// Финальный commit в session store — один раз на отпускание.
|
|
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 */
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={[styles.token, editable ? styles.tokenEditable : ''].filter(Boolean).join(' ')}
|
|
style={{
|
|
left,
|
|
top,
|
|
width: sizePx,
|
|
height: sizePx,
|
|
transform: `translate(-50%, -50%) rotate(${String(placement.rotationDeg)}deg)`,
|
|
}}
|
|
onPointerDown={
|
|
editable && onMove
|
|
? (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={
|
|
editable && onMove
|
|
? (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)));
|
|
d.lastNx = nextNx;
|
|
d.lastNy = nextNy;
|
|
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 ? <img className={styles.tokenImg} src={url} alt="" draggable={false} /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SceneTokensOverlay({
|
|
placements,
|
|
library,
|
|
session,
|
|
viewport,
|
|
editable = false,
|
|
onMove,
|
|
}: Props) {
|
|
if (!viewport || placements.length === 0) return null;
|
|
const known = new Set(library.map((t) => t.id));
|
|
|
|
return (
|
|
<div className={styles.layer}>
|
|
{placements
|
|
.filter((p) => known.has(p.tokenId))
|
|
.map((placement) => {
|
|
const key = String(placement.id);
|
|
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
|
|
const nx = override?.nx ?? placement.nx;
|
|
const ny = override?.ny ?? placement.ny;
|
|
return (
|
|
<TokenSprite
|
|
key={key}
|
|
placement={placement}
|
|
nx={nx}
|
|
ny={ny}
|
|
viewport={viewport}
|
|
editable={editable}
|
|
onMove={onMove}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|