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>
127 lines
3.7 KiB
TypeScript
127 lines
3.7 KiB
TypeScript
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 };
|