Files
Ivan Fontosh 060bbd471a feat(ui): confirm ending a show and surface help
End-session buttons, Escape, and window close share one confirm. Help is a question-mark icon on the control panel and after the version. About opens directly. Scene editor follows the UI language.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 08:10:54 +08:00

129 lines
3.8 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { AppToken } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
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 { t } = useEditorI18n();
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={t('sceneEditor.dragToMap')}
>
<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={t('sceneEditor.actions')}
aria-haspopup="menu"
aria-expanded={menu !== null}
title={t('sceneEditor.actions')}
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();
}}
>
{t('sceneEditor.edit')}
</button>
<button
type="button"
role="menuitem"
className={styles.tokenCtxItemDanger}
onClick={() => {
setMenu(null);
onDelete();
}}
>
{t('common.delete')}
</button>
</div>,
document.body,
)
: null}
</div>
);
}
export { TOKEN_DND_MIME };