import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import type { SceneGrid, SceneTrap, SceneTrapType } from '../../shared/types'; 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 { getDndApi } from '../shared/dndApi'; import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; import { RotatedImage } from '../shared/RotatedImage'; import { TrapGlyph } from '../shared/traps/TrapGlyph'; import { Button } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './SceneEditorApp.module.css'; type LocalView = { scale: number; ox: number; oy: number }; 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 } | null; function randomTrapId(): string { return `trap_${Math.random().toString(36).slice(2, 10)}`; } export function SceneEditorApp() { const api = getDndApi(); const [session, setSession] = useState(null); const [trapsOpen, setTrapsOpen] = useState(true); const [gridOpen, setGridOpen] = useState(true); const [selectedId, setSelectedId] = useState(null); const [view, setView] = useState({ 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(null); const dragRef = useRef(null); const saveTrapsTimerRef = 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([]); const [localGrid, setLocalGrid] = useState({ ...DEFAULT_SCENE_GRID }); const trapsRef = useRef([]); trapsRef.current = localTraps; useEffect(() => { setLocalTraps(scene?.traps ?? []); setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); setSelectedId(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; 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( async (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 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 (e.code === 'Space') spaceDownRef.current = true; if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId && sceneId) { e.preventDefault(); const next = trapsRef.current.filter((t) => t.id !== selectedId); setSelectedId(null); void persistTraps(next); } }; const onKeyUp = (e: KeyboardEvent) => { 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, sceneId, selectedId]); 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 onWheel = (_e: React.WheelEvent) => { // native non-passive listener handles zoom }; void onWheel; 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(randomTrapId()), 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); }; const onPaletteDragStart = (type: SceneTrapType) => (e: React.DragEvent) => { e.dataTransfer.setData('application/x-dnd-trap-type', type); e.dataTransfer.effectAllowed = 'copy'; }; 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; addTrapAt(type, p.x, p.y); }; const updateTrap = (id: string, patch: Partial) => { const next = trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)); void persistTraps(next); }; const isImage = scene?.previewAssetType === 'image' && Boolean(url); return (
{!isImage ? (
Нужно изображение сцены
) : (
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 === 'move') { 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 === 'resize') { 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)), }); } }} onPointerUp={() => { dragRef.current = null; }} onPointerCancel={() => { dragRef.current = 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; return (
{ if (e.button !== 0 || spaceDownRef.current) return; e.stopPropagation(); setSelectedId(trap.id); const p = hostToNorm(e.clientX, e.clientY); if (!p) return; (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); dragRef.current = { kind: 'move', trapId: trap.id, startNx: trap.nx, startNy: trap.ny, pointerNx: p.x, pointerNy: p.y, }; }} > {trap.label ?
{trap.label}
: null} {selected ? (
{ e.stopPropagation(); e.preventDefault(); const p = hostToNorm(e.clientX, e.clientY); if (!p) return; (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); dragRef.current = { kind: 'resize', trapId: trap.id, startSize: trap.sizeN, startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)), }; }} /> ) : null}
); }) : null}
)}
); }