Files
DndGamePlayer/app/renderer/sceneEditor/SceneEditorApp.tsx
T
Ivan Fontosh 2979d06f1c feat: multi-material overlays, presentation guides, and release prebuilds
Align control/presentation with presentation screen rect and darkness z-order; sync window titles and session window cleanup. Pack Win/Mac/Linux with npmRebuild disabled and release-native-prep for classic-level and sharp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:28:18 +08:00

750 lines
30 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, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { SceneGrid, SceneToken, SceneTrap, SceneTrapType, TokenId } from '../../shared/types';
import {
asSceneTokenId,
asTokenId,
clampSceneTokenSizeN,
DEFAULT_SCENE_TOKEN_SIZE_N,
} from '../../shared/types/appTokens';
import {
clampSceneGridSizeN,
DEFAULT_SCENE_GRID,
SCENE_GRID_SIZE_MAX,
SCENE_GRID_SIZE_MIN,
sceneGridTypeLabelRu,
} from '../../shared/types/sceneGrid';
import {
asSceneTrapId,
DEFAULT_SCENE_TRAP_SIZE_N,
SCENE_TRAP_TYPES,
trapTypeLabelRu,
} from '../../shared/types/sceneTraps';
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import editorStyles from '../editor/EditorApp.module.css';
import { getDndApi } from '../shared/dndApi';
import { EllipsisText } from '../shared/ui/EllipsisText';
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { RotatedImage } from '../shared/RotatedImage';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { TrapGlyph } from '../shared/traps/TrapGlyph';
import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { SceneTokenMarker } from './SceneTokenMarker';
import styles from './SceneEditorApp.module.css';
import { TokenEditModal } from './TokenEditModal';
import { TOKEN_DND_MIME, TokenTile } from './TokenTile';
function isTypingTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
return el.isContentEditable;
}
type LocalView = { scale: number; ox: number; oy: number };
type Selection = { kind: 'trap' | 'token'; id: string } | null;
type DragMode =
| { kind: 'pan'; lastX: number; lastY: number }
| { kind: 'moveTrap'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
| { kind: 'resizeTrap'; trapId: string; startSize: number; startDist: number }
| { kind: 'moveToken'; tokenId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
| { kind: 'resizeToken'; tokenId: string; startSize: number; startDist: number }
| {
kind: 'rotateToken';
tokenId: string;
startRotation: number;
startPointerAngle: number;
centerClientX: number;
centerClientY: number;
}
| null;
function randomId(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
function pointerAngleDeg(cx: number, cy: number, x: number, y: number): number {
return (Math.atan2(y - cy, x - cx) * 180) / Math.PI;
}
function shortestAngleDelta(fromDeg: number, toDeg: number): number {
let d = toDeg - fromDeg;
while (d > 180) d -= 360;
while (d < -180) d += 360;
return d;
}
export function SceneEditorApp() {
const api = getDndApi();
const appTokens = useAppTokens();
const [session, setSession] = useState<SessionState | null>(null);
const [trapsOpen, setTrapsOpen] = useState(false);
const [gridOpen, setGridOpen] = useState(false);
const [tokensOpen, setTokensOpen] = useState(false);
const [tokenSearch, setTokenSearch] = useState('');
const [tokenModal, setTokenModal] = useState<{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null>(
null,
);
const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null);
const [selected, setSelected] = useState<Selection>(null);
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
null,
);
const hostRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragMode>(null);
const saveTrapsTimerRef = useRef(0);
const saveTokensTimerRef = useRef(0);
const saveGridTimerRef = useRef(0);
const spaceDownRef = useRef(false);
const project = session?.project ?? null;
const sceneId = project?.currentSceneId ?? null;
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
const url = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0;
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
const [localTokens, setLocalTokens] = useState<SceneToken[]>([]);
const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
const trapsRef = useRef<SceneTrap[]>([]);
const tokensRef = useRef<SceneToken[]>([]);
trapsRef.current = localTraps;
tokensRef.current = localTokens;
useEffect(() => {
setLocalTraps(scene?.traps ?? []);
setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
setSelected(null);
setView({ scale: 1, ox: 0.5, oy: 0.5 });
}, [sceneId, scene?.previewAssetId]);
useEffect(() => {
if (dragRef.current) return;
setLocalTraps(scene?.traps ?? []);
}, [scene?.traps]);
useEffect(() => {
if (dragRef.current) return;
const known = new Set(appTokens.map((t) => t.id));
setLocalTokens((scene?.tokens ?? []).filter((t) => known.has(t.tokenId)));
}, [scene?.tokens, appTokens]);
useEffect(() => {
if (dragRef.current) return;
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
}, [scene?.grid]);
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
});
}, [api]);
const persistTraps = useCallback(
(next: SceneTrap[]) => {
if (!sceneId) return;
setLocalTraps(next);
trapsRef.current = next;
if (saveTrapsTimerRef.current) window.clearTimeout(saveTrapsTimerRef.current);
saveTrapsTimerRef.current = window.setTimeout(() => {
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { traps: next } });
}, 120);
},
[api, sceneId],
);
const persistTokens = useCallback(
(next: SceneToken[]) => {
if (!sceneId) return;
setLocalTokens(next);
tokensRef.current = next;
if (saveTokensTimerRef.current) window.clearTimeout(saveTokensTimerRef.current);
saveTokensTimerRef.current = window.setTimeout(() => {
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { tokens: next } });
}, 120);
},
[api, sceneId],
);
const persistGrid = useCallback(
(next: SceneGrid) => {
if (!sceneId) return;
setLocalGrid(next);
if (saveGridTimerRef.current) window.clearTimeout(saveGridTimerRef.current);
saveGridTimerRef.current = window.setTimeout(() => {
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { grid: next } });
}, 120);
},
[api, sceneId],
);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e.target)) return;
if (e.code === 'Space') spaceDownRef.current = true;
if ((e.key === 'Delete' || e.key === 'Backspace') && selected && sceneId) {
e.preventDefault();
if (selected.kind === 'trap') {
persistTraps(trapsRef.current.filter((t) => t.id !== selected.id));
} else {
persistTokens(tokensRef.current.filter((t) => t.id !== selected.id));
}
setSelected(null);
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (isTypingTarget(e.target)) return;
if (e.code === 'Space') spaceDownRef.current = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [persistTraps, persistTokens, sceneId, selected]);
const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
const host = hostRef.current;
const cr = contentRect;
if (!host || !cr || cr.w < 1 || cr.h < 1) return null;
const r = host.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (clientX - (r.left + cr.x)) / cr.w)),
y: Math.max(0, Math.min(1, (clientY - (r.top + cr.y)) / cr.h)),
};
};
const viewCamera = useMemo(() => view, [view]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const nativeWheel = (e: WheelEvent) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const cr = contentRect;
if (!cr) {
setView((v) => {
const nextScale = Math.max(1, Math.min(8, v.scale * factor));
if (nextScale <= 1.001) return { scale: 1, ox: 0.5, oy: 0.5 };
return { ...v, scale: nextScale };
});
return;
}
const r = host.getBoundingClientRect();
setView((v) => {
const containW = cr.w / Math.max(1e-6, v.scale);
const containH = cr.h / Math.max(1e-6, v.scale);
return sceneViewZoomAt(v, {
hostW: r.width,
hostH: r.height,
containW,
containH,
hostX: e.clientX - r.left,
hostY: e.clientY - r.top,
factor,
});
});
};
host.addEventListener('wheel', nativeWheel, { passive: false });
return () => host.removeEventListener('wheel', nativeWheel);
}, [contentRect]);
const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => {
const trap: SceneTrap = {
id: asSceneTrapId(randomId('trap')),
type,
nx,
ny,
sizeN: DEFAULT_SCENE_TRAP_SIZE_N,
};
setSelected({ kind: 'trap', id: trap.id });
persistTraps([...trapsRef.current, trap]);
};
const addTokenAt = (tokenId: TokenId, nx: number, ny: number) => {
const placement: SceneToken = {
id: asSceneTokenId(randomId('stoken')),
tokenId,
nx,
ny,
sizeN: DEFAULT_SCENE_TOKEN_SIZE_N,
rotationDeg: 0,
};
setSelected({ kind: 'token', id: placement.id });
persistTokens([...tokensRef.current, placement]);
};
const onStageDrop = (e: React.DragEvent) => {
e.preventDefault();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
const tokenId = e.dataTransfer.getData(TOKEN_DND_MIME);
if (tokenId) {
addTokenAt(asTokenId(tokenId), p.x, p.y);
return;
}
const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType;
if (!SCENE_TRAP_TYPES.includes(type)) return;
addTrapAt(type, p.x, p.y);
};
const updateTrap = (id: string, patch: Partial<SceneTrap>) => {
persistTraps(trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
};
const updateToken = (id: string, patch: Partial<SceneToken>) => {
persistTokens(tokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
};
const filteredTokens = useMemo(() => {
const q = tokenSearch.trim().toLowerCase();
if (!q) return appTokens;
return appTokens.filter((t) => t.name.toLowerCase().includes(q));
}, [appTokens, tokenSearch]);
const editingToken = tokenModal?.mode === 'edit' ? appTokens.find((t) => t.id === tokenModal.tokenId) ?? null : null;
const isImage = scene?.previewAssetType === 'image' && Boolean(url);
return (
<div className={styles.page}>
<aside className={styles.sidebar}>
<EllipsisText
text={scene?.title ?? 'Сцена'}
className={[styles.sideTitle, ellipsisStyles.root].join(' ')}
/>
<div className={styles.hint}>
Колесо зум. СКМ / Space+ЛКМ пан. Delete удалить выбранное.
</div>
<div className={styles.accordionScroll}>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
Сетка {gridOpen ? '▾' : '▸'}
</button>
{gridOpen ? (
<div className={styles.gridPanel}>
<label className={styles.checkRow}>
<input
type="checkbox"
checked={localGrid.enabled}
onChange={(e) => persistGrid({ ...localGrid, enabled: e.target.checked })}
/>
<span>Наложить сетку</span>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>Тип</span>
<Select
disabled={!localGrid.enabled}
value={localGrid.type}
ariaLabel="Тип сетки"
options={[
{ value: 'square', label: sceneGridTypeLabelRu('square') },
{ value: 'hex', label: sceneGridTypeLabelRu('hex') },
]}
onChange={(next) =>
persistGrid({
...localGrid,
type: next === 'hex' ? 'hex' : 'square',
})
}
/>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>Цвет</span>
<input
type="color"
className={styles.colorInput}
disabled={!localGrid.enabled}
value={localGrid.color}
onChange={(e) => persistGrid({ ...localGrid, color: e.target.value })}
aria-label="Цвет сетки"
/>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>
Размер <span className={styles.fieldValue}>{Math.round(localGrid.sizeN * 100)}</span>
</span>
<input
type="range"
className={styles.range}
disabled={!localGrid.enabled}
min={SCENE_GRID_SIZE_MIN}
max={SCENE_GRID_SIZE_MAX}
step={0.005}
value={localGrid.sizeN}
onChange={(e) =>
persistGrid({
...localGrid,
sizeN: clampSceneGridSizeN(Number(e.currentTarget.value)),
})
}
/>
</label>
</div>
) : null}
</div>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
Неигровые токены {tokensOpen ? '▾' : '▸'}
</button>
{tokensOpen ? (
<div className={styles.tokensPanel}>
<div className={styles.tokensToolbar}>
<Button onClick={() => setTokenModal({ mode: 'create' })}>Добавить</Button>
</div>
<Input
value={tokenSearch}
onChange={setTokenSearch}
placeholder="Поиск…"
autoFocus={tokensOpen}
onKeyDown={(e) => e.stopPropagation()}
/>
<div className={styles.tokenGrid}>
{filteredTokens.length === 0 ? (
<div className={styles.hint}>Нет токенов</div>
) : (
filteredTokens.map((token) => (
<TokenTile
key={token.id}
token={token}
onEdit={() => setTokenModal({ mode: 'edit', tokenId: token.id })}
onDelete={() => setPendingDeleteToken({ id: token.id, name: token.name })}
/>
))
)}
</div>
</div>
) : null}
</div>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
Ловушки {trapsOpen ? '▾' : '▸'}
</button>
{trapsOpen ? (
<div className={styles.palette}>
{SCENE_TRAP_TYPES.map((type) => (
<div
key={type}
className={styles.paletteItem}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/x-dnd-trap-type', type);
e.dataTransfer.effectAllowed = 'copy';
}}
title="Перетащите на карту"
>
<TrapGlyph type={type} size={22} />
<span className={styles.paletteLabel}>{trapTypeLabelRu(type)}</span>
</div>
))}
</div>
) : null}
</div>
<div className={styles.clearSceneBtn}>
<Button
disabled={!sceneId || (localTraps.length === 0 && localTokens.length === 0)}
onClick={() => {
persistTraps([]);
persistTokens([]);
setSelected(null);
}}
>
Очистить сцену
</Button>
</div>
</div>
</aside>
<div className={styles.stage}>
{!isImage ? (
<div className={styles.empty}>Нужно изображение сцены</div>
) : (
<div
ref={hostRef}
className={styles.viewport}
onDragOver={(e) => e.preventDefault()}
onDrop={onStageDrop}
onPointerDown={(e) => {
if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) {
e.preventDefault();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
}
}}
onPointerMove={(e) => {
const d = dragRef.current;
if (!d) return;
if (d.kind === 'pan') {
const cr = contentRect;
if (!cr) return;
const dx = e.clientX - d.lastX;
const dy = e.clientY - d.lastY;
d.lastX = e.clientX;
d.lastY = e.clientY;
setView((v) => {
const containW = cr.w / Math.max(1e-6, v.scale);
const containH = cr.h / Math.max(1e-6, v.scale);
return sceneViewPanBy(v, { containW, containH, dx, dy });
});
return;
}
if (d.kind === 'moveTrap') {
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
updateTrap(d.trapId, {
nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
});
return;
}
if (d.kind === 'resizeTrap') {
const p = hostToNorm(e.clientX, e.clientY);
const trap = trapsRef.current.find((t) => t.id === d.trapId);
if (!p || !trap) return;
const dist = Math.hypot(p.x - trap.nx, p.y - trap.ny);
const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
updateTrap(d.trapId, {
sizeN: Math.max(0.02, Math.min(0.45, d.startSize * ratio)),
});
return;
}
if (d.kind === 'moveToken') {
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
updateToken(d.tokenId, {
nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
});
return;
}
if (d.kind === 'resizeToken') {
const p = hostToNorm(e.clientX, e.clientY);
const tok = tokensRef.current.find((t) => t.id === d.tokenId);
if (!p || !tok) return;
const dist = Math.hypot(p.x - tok.nx, p.y - tok.ny);
const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
updateToken(d.tokenId, {
sizeN: clampSceneTokenSizeN(d.startSize * ratio),
});
return;
}
if (d.kind === 'rotateToken') {
const ang = pointerAngleDeg(d.centerClientX, d.centerClientY, e.clientX, e.clientY);
const delta = shortestAngleDelta(d.startPointerAngle, ang);
updateToken(d.tokenId, { rotationDeg: d.startRotation + delta });
}
}}
onPointerUp={() => {
dragRef.current = null;
}}
onPointerCancel={() => {
dragRef.current = null;
}}
>
<RotatedImage
url={url!}
rotationDeg={rot}
mode="contain"
viewCamera={viewCamera}
onContentRectChange={setContentRect}
/>
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
{contentRect
? localTokens.map((tok) => {
const minDim = Math.min(contentRect.w, contentRect.h);
const sizePx = Math.max(16, tok.sizeN * minDim);
const left = contentRect.x + tok.nx * contentRect.w;
const top = contentRect.y + tok.ny * contentRect.h;
return (
<SceneTokenMarker
key={tok.id}
token={tok}
left={left}
top={top}
sizePx={sizePx}
selected={selected?.kind === 'token' && selected.id === tok.id}
onSelect={() => setSelected({ kind: 'token', id: tok.id })}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
persistTokens(tokensRef.current.filter((t) => t.id !== tok.id));
setSelected((cur) => (cur?.kind === 'token' && cur.id === tok.id ? null : cur));
}}
onMovePointerDown={(e) => {
if (spaceDownRef.current) return;
e.stopPropagation();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'moveToken',
tokenId: tok.id,
startNx: tok.nx,
startNy: tok.ny,
pointerNx: p.x,
pointerNy: p.y,
};
}}
onResizePointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'resizeToken',
tokenId: tok.id,
startSize: tok.sizeN,
startDist: Math.max(1e-4, Math.hypot(p.x - tok.nx, p.y - tok.ny)),
};
}}
onRotatePointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const host = hostRef.current;
if (!host || !contentRect) return;
const r = host.getBoundingClientRect();
const cx = r.left + contentRect.x + tok.nx * contentRect.w;
const cy = r.top + contentRect.y + tok.ny * contentRect.h;
(e.currentTarget as HTMLButtonElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'rotateToken',
tokenId: tok.id,
startRotation: tok.rotationDeg,
startPointerAngle: pointerAngleDeg(cx, cy, e.clientX, e.clientY),
centerClientX: cx,
centerClientY: cy,
};
}}
/>
);
})
: null}
{contentRect
? localTraps.map((trap) => {
const minDim = Math.min(contentRect.w, contentRect.h);
const sizePx = Math.max(16, trap.sizeN * minDim);
const left = contentRect.x + trap.nx * contentRect.w;
const top = contentRect.y + trap.ny * contentRect.h;
const isSelected = selected?.kind === 'trap' && selected.id === trap.id;
return (
<div
key={trap.id}
className={[styles.trap, isSelected ? styles.trapSelected : ''].filter(Boolean).join(' ')}
style={{ left, top, width: sizePx, height: sizePx }}
onPointerDown={(e) => {
if (e.button !== 0 || spaceDownRef.current) return;
e.stopPropagation();
setSelected({ kind: 'trap', id: trap.id });
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'moveTrap',
trapId: trap.id,
startNx: trap.nx,
startNy: trap.ny,
pointerNx: p.x,
pointerNy: p.y,
};
}}
>
<TrapGlyph type={trap.type} size={Math.max(14, sizePx * 0.55)} />
{trap.label && trap.label.trim().toLowerCase() !== 'свободная' ? (
<div className={styles.trapLabel}>{trap.label}</div>
) : null}
{isSelected ? (
<div
className={styles.handle}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'resizeTrap',
trapId: trap.id,
startSize: trap.sizeN,
startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)),
};
}}
/>
) : null}
</div>
);
})
: null}
</div>
)}
</div>
<TokenEditModal
open={tokenModal !== null}
initial={editingToken}
existingNames={appTokens.map((t) => t.name)}
onClose={() => setTokenModal(null)}
onSaved={() => setTokenModal(null)}
/>
{pendingDeleteToken
? createPortal(
<>
<button
type="button"
aria-label="Закрыть"
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteToken(null)}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>Удалить токен</div>
<button
type="button"
className={editorStyles.modalClose}
onClick={() => setPendingDeleteToken(null)}
>
×
</button>
</div>
<div>
Удалить токен «{pendingDeleteToken.name}» из пула? Он также будет убран с текущей сцены.
</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteToken(null)}>Отмена</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeleteToken.id;
setPendingDeleteToken(null);
void api.invoke(ipcChannels.tokens.delete, { id });
persistTokens(tokensRef.current.filter((t) => t.tokenId !== id));
}}
>
Удалить
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div>
);
}