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>
This commit is contained in:
@@ -90,22 +90,6 @@
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 7px 28px 7px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--stroke, #2a2f3a);
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 12px 12px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.range {
|
||||
width: 100%;
|
||||
accent-color: #f5c542;
|
||||
@@ -200,7 +184,7 @@
|
||||
|
||||
.trap {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.55);
|
||||
@@ -252,6 +236,189 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tokensPanel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.tokensToolbar {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.tokensToolbar button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tokenGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tokenTile {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--stroke, #2a2f3a);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tokenTile:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tokenThumb {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.tokenThumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.tokenTileMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tokenTileName {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tokenTileMore {
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.tokenTileMore:hover,
|
||||
.tokenTileMore[aria-expanded='true'] {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tokenCtxMenu {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
min-width: 160px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--stroke, #333);
|
||||
background: #1a1d24;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tokenCtxItem,
|
||||
.tokenCtxItemDanger {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #e8eaef;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tokenCtxItem:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tokenCtxItemDanger {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.tokenCtxItemDanger:hover {
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
}
|
||||
|
||||
.clearSceneBtn {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.clearSceneBtn button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sceneToken {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
overflow: visible;
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.sceneTokenSelected {
|
||||
border-color: #f5c542;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 3px rgba(245, 197, 66, 0.35);
|
||||
}
|
||||
|
||||
.sceneTokenImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rotateHandle {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -18px;
|
||||
transform: translateX(-50%);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #000;
|
||||
background: #f5c542;
|
||||
color: #111;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
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, SceneTrap, SceneTrapType } from '../../shared/types';
|
||||
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,
|
||||
@@ -16,33 +23,74 @@ import {
|
||||
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 { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
||||
import { TrapGlyph } from '../shared/traps/TrapGlyph';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
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: 'move'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
|
||||
| { kind: 'resize'; trapId: string; startSize: number; startDist: 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 randomTrapId(): string {
|
||||
return `trap_${Math.random().toString(36).slice(2, 10)}`;
|
||||
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(true);
|
||||
const [gridOpen, setGridOpen] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | 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,
|
||||
@@ -50,6 +98,7 @@ export function SceneEditorApp() {
|
||||
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);
|
||||
|
||||
@@ -59,23 +108,32 @@ export function SceneEditorApp() {
|
||||
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 });
|
||||
setSelectedId(null);
|
||||
setSelected(null);
|
||||
setView({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
}, [sceneId, scene?.previewAssetId]);
|
||||
|
||||
useEffect(() => {
|
||||
// External updates (other windows) — sync when not dragging
|
||||
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 });
|
||||
@@ -91,7 +149,7 @@ export function SceneEditorApp() {
|
||||
}, [api]);
|
||||
|
||||
const persistTraps = useCallback(
|
||||
async (next: SceneTrap[]) => {
|
||||
(next: SceneTrap[]) => {
|
||||
if (!sceneId) return;
|
||||
setLocalTraps(next);
|
||||
trapsRef.current = next;
|
||||
@@ -103,6 +161,19 @@ export function SceneEditorApp() {
|
||||
[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;
|
||||
@@ -117,15 +188,20 @@ export function SceneEditorApp() {
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isTypingTarget(e.target)) return;
|
||||
if (e.code === 'Space') spaceDownRef.current = true;
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId && sceneId) {
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selected && sceneId) {
|
||||
e.preventDefault();
|
||||
const next = trapsRef.current.filter((t) => t.id !== selectedId);
|
||||
setSelectedId(null);
|
||||
void persistTraps(next);
|
||||
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);
|
||||
@@ -134,7 +210,7 @@ export function SceneEditorApp() {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
}, [persistTraps, sceneId, selectedId]);
|
||||
}, [persistTraps, persistTokens, sceneId, selected]);
|
||||
|
||||
const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
|
||||
const host = hostRef.current;
|
||||
@@ -147,11 +223,6 @@ export function SceneEditorApp() {
|
||||
};
|
||||
};
|
||||
|
||||
const onWheel = (_e: React.WheelEvent) => {
|
||||
// native non-passive listener handles zoom
|
||||
};
|
||||
void onWheel;
|
||||
|
||||
const viewCamera = useMemo(() => view, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -190,37 +261,58 @@ export function SceneEditorApp() {
|
||||
|
||||
const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => {
|
||||
const trap: SceneTrap = {
|
||||
id: asSceneTrapId(randomTrapId()),
|
||||
id: asSceneTrapId(randomId('trap')),
|
||||
type,
|
||||
nx,
|
||||
ny,
|
||||
sizeN: DEFAULT_SCENE_TRAP_SIZE_N,
|
||||
...(type === 'freeform' ? { label: trapTypeLabelRu(type) } : {}),
|
||||
};
|
||||
const next = [...trapsRef.current, trap];
|
||||
setSelectedId(trap.id);
|
||||
void persistTraps(next);
|
||||
setSelected({ kind: 'trap', id: trap.id });
|
||||
persistTraps([...trapsRef.current, trap]);
|
||||
};
|
||||
|
||||
const onPaletteDragStart = (type: SceneTrapType) => (e: React.DragEvent) => {
|
||||
e.dataTransfer.setData('application/x-dnd-trap-type', type);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
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 type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType;
|
||||
if (!SCENE_TRAP_TYPES.includes(type)) return;
|
||||
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>) => {
|
||||
const next = trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t));
|
||||
void persistTraps(next);
|
||||
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 (
|
||||
@@ -228,8 +320,9 @@ export function SceneEditorApp() {
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.sideTitle}>{scene?.title ?? 'Сцена'}</div>
|
||||
<div className={styles.hint}>
|
||||
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранную ловушку.
|
||||
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранное.
|
||||
</div>
|
||||
|
||||
<div className={styles.accordion}>
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
|
||||
Сетка {gridOpen ? '▾' : '▸'}
|
||||
@@ -246,20 +339,21 @@ export function SceneEditorApp() {
|
||||
</label>
|
||||
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
|
||||
<span className={styles.fieldLabel}>Тип</span>
|
||||
<select
|
||||
className={styles.select}
|
||||
<Select
|
||||
disabled={!localGrid.enabled}
|
||||
value={localGrid.type}
|
||||
onChange={(e) =>
|
||||
ariaLabel="Тип сетки"
|
||||
options={[
|
||||
{ value: 'square', label: sceneGridTypeLabelRu('square') },
|
||||
{ value: 'hex', label: sceneGridTypeLabelRu('hex') },
|
||||
]}
|
||||
onChange={(next) =>
|
||||
persistGrid({
|
||||
...localGrid,
|
||||
type: e.target.value === 'hex' ? 'hex' : 'square',
|
||||
type: next === 'hex' ? 'hex' : 'square',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="square">{sceneGridTypeLabelRu('square')}</option>
|
||||
<option value="hex">{sceneGridTypeLabelRu('hex')}</option>
|
||||
</select>
|
||||
/>
|
||||
</label>
|
||||
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
|
||||
<span className={styles.fieldLabel}>Цвет</span>
|
||||
@@ -295,6 +389,41 @@ export function SceneEditorApp() {
|
||||
</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 ? '▾' : '▸'}
|
||||
@@ -306,7 +435,10 @@ export function SceneEditorApp() {
|
||||
key={type}
|
||||
className={styles.paletteItem}
|
||||
draggable
|
||||
onDragStart={onPaletteDragStart(type)}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/x-dnd-trap-type', type);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
title="Перетащите на карту"
|
||||
>
|
||||
<TrapGlyph type={type} size={22} />
|
||||
@@ -316,19 +448,19 @@ export function SceneEditorApp() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedId ? (
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const next = localTraps.filter((t) => t.id !== selectedId);
|
||||
setSelectedId(null);
|
||||
void persistTraps(next);
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.clearSceneBtn}>
|
||||
<Button
|
||||
disabled={!sceneId || (localTraps.length === 0 && localTokens.length === 0)}
|
||||
onClick={() => {
|
||||
persistTraps([]);
|
||||
persistTokens([]);
|
||||
setSelected(null);
|
||||
}}
|
||||
>
|
||||
Очистить сцену
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={styles.stage}>
|
||||
@@ -364,7 +496,7 @@ export function SceneEditorApp() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'move') {
|
||||
if (d.kind === 'moveTrap') {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
updateTrap(d.trapId, {
|
||||
@@ -373,7 +505,7 @@ export function SceneEditorApp() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'resize') {
|
||||
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;
|
||||
@@ -382,6 +514,32 @@ export function SceneEditorApp() {
|
||||
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={() => {
|
||||
@@ -399,27 +557,98 @@ export function SceneEditorApp() {
|
||||
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 selected = selectedId === trap.id;
|
||||
const isSelected = selected?.kind === 'trap' && selected.id === trap.id;
|
||||
return (
|
||||
<div
|
||||
key={trap.id}
|
||||
className={[styles.trap, selected ? styles.trapSelected : ''].filter(Boolean).join(' ')}
|
||||
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();
|
||||
setSelectedId(trap.id);
|
||||
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: 'move',
|
||||
kind: 'moveTrap',
|
||||
trapId: trap.id,
|
||||
startNx: trap.nx,
|
||||
startNy: trap.ny,
|
||||
@@ -429,8 +658,10 @@ export function SceneEditorApp() {
|
||||
}}
|
||||
>
|
||||
<TrapGlyph type={trap.type} size={Math.max(14, sizePx * 0.55)} />
|
||||
{trap.label ? <div className={styles.trapLabel}>{trap.label}</div> : null}
|
||||
{selected ? (
|
||||
{trap.label && trap.label.trim().toLowerCase() !== 'свободная' ? (
|
||||
<div className={styles.trapLabel}>{trap.label}</div>
|
||||
) : null}
|
||||
{isSelected ? (
|
||||
<div
|
||||
className={styles.handle}
|
||||
onPointerDown={(e) => {
|
||||
@@ -440,7 +671,7 @@ export function SceneEditorApp() {
|
||||
if (!p) return;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
kind: 'resize',
|
||||
kind: 'resizeTrap',
|
||||
trapId: trap.id,
|
||||
startSize: trap.sizeN,
|
||||
startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)),
|
||||
@@ -455,6 +686,57 @@ export function SceneEditorApp() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { SceneToken } from '../../shared/types';
|
||||
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
|
||||
|
||||
import styles from './SceneEditorApp.module.css';
|
||||
|
||||
type Props = {
|
||||
token: SceneToken;
|
||||
left: number;
|
||||
top: number;
|
||||
sizePx: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
onMovePointerDown: (e: React.PointerEvent) => void;
|
||||
onResizePointerDown: (e: React.PointerEvent) => void;
|
||||
onRotatePointerDown: (e: React.PointerEvent) => void;
|
||||
};
|
||||
|
||||
export function SceneTokenMarker({
|
||||
token,
|
||||
left,
|
||||
top,
|
||||
sizePx,
|
||||
selected,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onMovePointerDown,
|
||||
onResizePointerDown,
|
||||
onRotatePointerDown,
|
||||
}: Props) {
|
||||
const url = useTokenImageUrl(token.tokenId);
|
||||
return (
|
||||
<div
|
||||
className={[styles.sceneToken, selected ? styles.sceneTokenSelected : ''].filter(Boolean).join(' ')}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
transform: `translate(-50%, -50%) rotate(${String(token.rotationDeg)}deg)`,
|
||||
}}
|
||||
title="Перетащите · ПКМ — удалить"
|
||||
onContextMenu={onContextMenu}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
onSelect();
|
||||
onMovePointerDown(e);
|
||||
}}
|
||||
>
|
||||
{url ? <img className={styles.sceneTokenImg} src={url} alt="" draggable={false} /> : null}
|
||||
{selected ? (
|
||||
<>
|
||||
<div className={styles.handle} onPointerDown={onResizePointerDown} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.rotateHandle}
|
||||
aria-label="Повернуть"
|
||||
title="Повернуть"
|
||||
onPointerDown={onRotatePointerDown}
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { AppToken } from '../../shared/types';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
getDroppedFileEntries,
|
||||
pickFirstMaterialImagePath,
|
||||
useFileDropZone,
|
||||
} from '../editor/fileDrop';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import matStyles from '../editor/MaterialsModals.module.css';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
initial: AppToken | null;
|
||||
existingNames: string[];
|
||||
onClose: () => void;
|
||||
onSaved: (token: AppToken) => void;
|
||||
};
|
||||
|
||||
function normalizeName(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function TokenEditModal({ open, initial, existingNames, onClose, onSaved }: Props) {
|
||||
const api = getDndApi();
|
||||
const [name, setName] = useState('');
|
||||
const [filePath, setFilePath] = useState<string | null>(null);
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const existingUrl = useTokenImageUrl(initial?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? '');
|
||||
setFilePath(null);
|
||||
setLocalPreviewUrl(null);
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
}, [open, initial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||
setFilePath(path);
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return previewUrl || null;
|
||||
});
|
||||
};
|
||||
|
||||
const drop = useFileDropZone({
|
||||
onDropPaths: (paths) => {
|
||||
const picked = pickFirstMaterialImagePath(paths);
|
||||
if (!picked) return;
|
||||
setPreviewFromPathAndUrl(picked, '');
|
||||
},
|
||||
filterPaths: filterMaterialImagePaths,
|
||||
});
|
||||
|
||||
const trimmed = name.trim();
|
||||
const nameOk = trimmed.length >= 1;
|
||||
const nameDup =
|
||||
nameOk &&
|
||||
existingNames.some(
|
||||
(n) =>
|
||||
normalizeName(n) === normalizeName(trimmed) &&
|
||||
normalizeName(n) !== normalizeName(initial?.name ?? ''),
|
||||
);
|
||||
const hasImage = Boolean(filePath) || Boolean(initial);
|
||||
const canSave = nameOk && !nameDup && hasImage && !saving;
|
||||
const previewSrc = localPreviewUrl || existingUrl;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label="Закрыть" onClick={onClose} className={editorStyles.modalBackdrop} />
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>
|
||||
{initial ? 'Изменить токен' : 'Добавить токен'}
|
||||
</div>
|
||||
<button type="button" aria-label="Закрыть" onClick={onClose} className={editorStyles.modalClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>Название</div>
|
||||
<Input value={name} onChange={setName} placeholder="Название токена" />
|
||||
{!nameOk ? <div className={editorStyles.fieldError}>Укажите название</div> : null}
|
||||
{nameDup ? <div className={editorStyles.fieldError}>Такое название уже есть</div> : null}
|
||||
</div>
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>Изображение</div>
|
||||
<div
|
||||
className={[matStyles.imageDrop, drop.dragOver ? matStyles.imageDropOver : ''].join(' ')}
|
||||
onDragEnter={drop.onDragEnter}
|
||||
onDragLeave={drop.onDragLeave}
|
||||
onDragOver={drop.onDragOver}
|
||||
onDrop={(e) => {
|
||||
drop.onDrop(e);
|
||||
const entries = getDroppedFileEntries(e);
|
||||
const files = e.dataTransfer.files;
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
const entry = entries[i];
|
||||
if (!entry || !pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files[i];
|
||||
if (file) {
|
||||
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
|
||||
return;
|
||||
}
|
||||
setPreviewFromPathAndUrl(entry.path, '');
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drop.dragOver ? (
|
||||
<div className={editorStyles.dropHintOverlay}>Отпустите, чтобы загрузить изображение</div>
|
||||
) : null}
|
||||
{previewSrc ? (
|
||||
<img src={previewSrc} alt="" className={matStyles.previewThumb} draggable={false} />
|
||||
) : (
|
||||
<div className={editorStyles.muted}>Перетащите изображение или выберите файл</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
const res = await api.invoke(ipcChannels.tokens.pickImage, {});
|
||||
if (res.canceled) return;
|
||||
setPreviewFromPathAndUrl(res.filePath, res.previewDataUrl);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Выбрать изображение
|
||||
</Button>
|
||||
</div>
|
||||
{!hasImage ? <div className={editorStyles.fieldError}>Нужно изображение</div> : null}
|
||||
</div>
|
||||
|
||||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
if (!canSave) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { token } = await api.invoke(ipcChannels.tokens.upsert, {
|
||||
id: initial?.id ?? null,
|
||||
name: trimmed,
|
||||
filePath: filePath ?? null,
|
||||
});
|
||||
onSaved(token);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Не удалось сохранить');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user