Files
DndGamePlayer/app/renderer/sceneEditor/TokenTile.tsx
T
Ivan Fontosh f270812219 feat(tokens): app-local non-player tokens with session moves and UI polish
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>
2026-07-27 11:04:12 +08:00

127 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { AppToken } from '../../shared/types';
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
import styles from './SceneEditorApp.module.css';
const TOKEN_DND_MIME = 'application/x-dnd-app-token-id';
type Props = {
token: AppToken;
onEdit: () => void;
onDelete: () => void;
};
export function TokenTile({ token, onEdit, onDelete }: Props) {
const url = useTokenImageUrl(token.id);
const menuBtnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!menu) return;
const onPointerDown = (e: PointerEvent) => {
const t = e.target;
if (!(t instanceof Node)) return;
if (menuBtnRef.current?.contains(t)) return;
if (menuRef.current?.contains(t)) return;
setMenu(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenu(null);
};
window.addEventListener('pointerdown', onPointerDown, true);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('pointerdown', onPointerDown, true);
window.removeEventListener('keydown', onKey);
};
}, [menu]);
const openMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const pad = 8;
const menuW = 160;
const menuH = 84;
const x = Math.max(pad, Math.min(e.clientX, window.innerWidth - menuW - pad));
const y = Math.max(pad, Math.min(e.clientY, window.innerHeight - menuH - pad));
setMenu({ x, y });
};
return (
<div
className={styles.tokenTile}
draggable
onDragStart={(e) => {
if (menuBtnRef.current?.contains(e.target as Node)) {
e.preventDefault();
return;
}
e.dataTransfer.setData(TOKEN_DND_MIME, token.id);
e.dataTransfer.effectAllowed = 'copy';
}}
title="Перетащите на карту"
>
<div className={styles.tokenThumb}>
{url ? <img src={url} alt="" draggable={false} /> : null}
</div>
<div className={styles.tokenTileMeta}>
<div className={styles.tokenTileName}>{token.name}</div>
<button
ref={menuBtnRef}
type="button"
className={styles.tokenTileMore}
aria-label="Действия"
aria-haspopup="menu"
aria-expanded={menu !== null}
title="Действия"
onClick={openMenu}
onPointerDown={(e) => e.stopPropagation()}
>
</button>
</div>
{menu
? createPortal(
<div
ref={menuRef}
role="menu"
className={styles.tokenCtxMenu}
style={{ left: menu.x, top: menu.y }}
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
role="menuitem"
className={styles.tokenCtxItem}
onClick={() => {
setMenu(null);
onEdit();
}}
>
Изменить
</button>
<button
type="button"
role="menuitem"
className={styles.tokenCtxItemDanger}
onClick={() => {
setMenu(null);
onDelete();
}}
>
Удалить
</button>
</div>,
document.body,
)
: null}
</div>
);
}
export { TOKEN_DND_MIME };