2979d06f1c
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>
750 lines
30 KiB
TypeScript
750 lines
30 KiB
TypeScript
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>
|
||
);
|
||
}
|