import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import type { NpcId, ProjectNpc, SceneGrid, SceneNpcToken, SceneToken, SceneTrap, SceneTrapType, TokenId, } from '../../shared/types'; import { asSceneNpcTokenId, clampSceneNpcTokenSizeN, DEFAULT_SCENE_NPC_TOKEN_SIZE_N, } from '../../shared/types/appPlayers'; 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, sceneGridTokenFitFactor, 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 { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView'; 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 styles from './SceneEditorApp.module.css'; import { SceneTokenMarker } from './SceneTokenMarker'; 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' | 'npcToken'; 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: 'moveNpcToken'; tokenId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number; } | { kind: 'resizeNpcToken'; 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; } const SCENE_NPC_DND_MIME = 'application/x-dnd-scene-npc-id'; function SceneNpcMarker({ placement, npc, left, top, sizePx, selected, onSelect, onDelete, onMovePointerDown, onResizePointerDown, }: { placement: SceneNpcToken; npc: ProjectNpc; left: number; top: number; sizePx: number; selected: boolean; onSelect: () => void; onDelete: () => void; onMovePointerDown: (e: React.PointerEvent) => void; onResizePointerDown: (e: React.PointerEvent) => void; }) { const imageUrl = useAssetUrl(npc.avatarAssetId); return (