import { useCallback, useEffect, useRef, useState } from 'react' import { Plus, X } from 'lucide-react' import { useWorldStore } from '../store/worldStore' import { listMaps, loadMapIntoEditor, subscribeMapLibrary, type MapIndexEntry, } from '../store/mapPersistence' import { getMapMinimap, invalidateMinimap, type MapMinimap } from '../lib/minimap' import { useUiStore } from '../store/uiStore' import Library from './Library' import ToolsHeader from './ToolsHeader' import DocumentActions from './DocumentActions' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { cn } from '@/lib/utils' const MIN_ZOOM = 0.1 const MAX_ZOOM = 4 /** Base on-screen pixels per cell at zoom 1 (display-only, abstract cell units). */ const CELL = 20 /** Below this on-screen cell size, the world grid is hidden to avoid noise. */ const MIN_GRID_PX = 4 function clamp(z: number) { return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)) } const GRID_STROKE = '#495062' const SLOT_STROKE = 'rgba(73, 80, 98, 0.6)' const SELECT_STROKE = '#22d3ee' export default function WorldEditor() { const name = useWorldStore((s) => s.name) const gridWidth = useWorldStore((s) => s.gridWidth) const gridHeight = useWorldStore((s) => s.gridHeight) const placements = useWorldStore((s) => s.placements) const selectedMapId = useWorldStore((s) => s.selectedMapId) const setGrid = useWorldStore((s) => s.setGrid) const addMap = useWorldStore((s) => s.addMap) const removeMap = useWorldStore((s) => s.removeMap) const moveMap = useWorldStore((s) => s.moveMap) const select = useWorldStore((s) => s.select) const setEditorKind = useUiStore((s) => s.setEditorKind) const sidebarView = useUiStore((s) => s.sidebarView) // ---- view state ---- const [zoom, setZoom] = useState(0.5) // pan = screen-pixel position of cell (0,0)'s top-left corner const [pan, setPan] = useState({ x: 120, y: 120 }) const [isPanning, setIsPanning] = useState(false) const canvasRef = useRef(null) const viewportRef = useRef(null) const zoomRef = useRef(zoom) const panRef = useRef(pan) const panningRef = useRef<{ x: number; y: number } | null>(null) // active map move-drag: which map, and the pointer-to-grid offset at grab const dragRef = useRef<{ mapId: string; offGx: number; offGy: number } | null>(null) // ---- minimaps (async, cached) ---- const minimapsRef = useRef(new Map()) const inflightRef = useRef(new Set()) // bump to force a redraw when an async minimap (or the maps list) resolves const [, setTick] = useState(0) const redraw = () => setTick((t) => t + 1) // ---- saved-map library (for the sidebar picker + placement labels) ---- const [mapEntries, setMapEntries] = useState([]) useEffect(() => { zoomRef.current = zoom panRef.current = pan }) // load the saved-map list; refresh + invalidate minimaps when it changes useEffect(() => { let alive = true const refresh = () => { listMaps().then((list) => { if (alive) setMapEntries(list) }) invalidateMinimap() minimapsRef.current.clear() redraw() } refresh() const unsub = subscribeMapLibrary(refresh) return () => { alive = false unsub() } }, []) /** Ensure a minimap is loaded for a placed map; redraw when it arrives. */ const ensureMinimap = useCallback((mapId: string) => { if (minimapsRef.current.has(mapId) || inflightRef.current.has(mapId)) return inflightRef.current.add(mapId) getMapMinimap(mapId) .then((m) => { if (m) minimapsRef.current.set(mapId, m) }) .finally(() => { inflightRef.current.delete(mapId) redraw() }) }, []) /** A placement's footprint in cells: the map's own extent if its minimap is * loaded, else the grid slot as a fallback. */ const footprint = useCallback( (mapId: string) => { const m = minimapsRef.current.get(mapId) return { cols: m?.cols ?? gridWidth, rows: m?.rows ?? gridHeight } }, [gridWidth, gridHeight], ) // ---- drawing ------------------------------------------------------------- const draw = useCallback(() => { const canvas = canvasRef.current const viewport = viewportRef.current if (!canvas || !viewport) return const ctx = canvas.getContext('2d') if (!ctx) return const dpr = window.devicePixelRatio || 1 const vw = viewport.clientWidth const vh = viewport.clientHeight if (canvas.width !== Math.round(vw * dpr) || canvas.height !== Math.round(vh * dpr)) { canvas.width = Math.round(vw * dpr) canvas.height = Math.round(vh * dpr) } canvas.style.width = `${vw}px` canvas.style.height = `${vh}px` ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, vw, vh) const px = CELL * zoom ctx.imageSmoothingEnabled = false // ---- placed maps (composited minimaps) ---- for (const p of placements) { ensureMinimap(p.mapId) const m = minimapsRef.current.get(p.mapId) const fp = footprint(p.mapId) const ox = pan.x + p.gx * gridWidth * px const oy = pan.y + p.gy * gridHeight * px const w = fp.cols * px const h = fp.rows * px if (ox > vw || oy > vh || ox + w < 0 || oy + h < 0) continue if (m) { ctx.drawImage(m.bitmap, ox, oy, w, h) } else { // not loaded yet — faint placeholder block ctx.fillStyle = 'rgba(120, 130, 150, 0.15)' ctx.fillRect(ox, oy, w, h) } // grid-slot outline (gridWidth × gridHeight), so over/under-sized maps read const slotW = gridWidth * px const slotH = gridHeight * px ctx.strokeStyle = SLOT_STROKE ctx.lineWidth = 1 ctx.strokeRect(ox + 0.5, oy + 0.5, slotW - 1, slotH - 1) if (p.mapId === selectedMapId) { ctx.strokeStyle = SELECT_STROKE ctx.lineWidth = 2 ctx.strokeRect(ox + 1, oy + 1, w - 2, h - 2) } } // ---- world grid (lines every gridWidth / gridHeight cells) ---- const stepX = gridWidth * px const stepY = gridHeight * px if (stepX >= MIN_GRID_PX && stepY >= MIN_GRID_PX) { ctx.strokeStyle = GRID_STROKE ctx.lineWidth = 1 ctx.beginPath() const g0 = Math.floor((0 - pan.x) / stepX) const g1 = Math.ceil((vw - pan.x) / stepX) for (let g = g0; g <= g1; g++) { const x = Math.round(pan.x + g * stepX) + 0.5 ctx.moveTo(x, 0) ctx.lineTo(x, vh) } const h0 = Math.floor((0 - pan.y) / stepY) const h1 = Math.ceil((vh - pan.y) / stepY) for (let g = h0; g <= h1; g++) { const y = Math.round(pan.y + g * stepY) + 0.5 ctx.moveTo(0, y) ctx.lineTo(vw, y) } ctx.stroke() } }, [zoom, pan, placements, selectedMapId, gridWidth, gridHeight, ensureMinimap, footprint]) useEffect(() => { draw() }, [draw]) // redraw on viewport resize useEffect(() => { const viewport = viewportRef.current if (!viewport) return const ro = new ResizeObserver(() => draw()) ro.observe(viewport) return () => ro.disconnect() }, [draw]) // ---- zoom (Ctrl/Cmd + wheel or trackpad pinch), centered on cursor ---- useEffect(() => { const viewport = viewportRef.current if (!viewport) return const onWheel = (e: WheelEvent) => { if (!(e.ctrlKey || e.metaKey)) return e.preventDefault() const rect = viewport.getBoundingClientRect() const mx = e.clientX - rect.left const my = e.clientY - rect.top const old = zoomRef.current const next = clamp(old * Math.exp(-e.deltaY * 0.01)) if (next === old) return const wx = (mx - panRef.current.x) / (CELL * old) const wy = (my - panRef.current.y) / (CELL * old) setZoom(next) setPan({ x: mx - wx * CELL * next, y: my - wy * CELL * next }) } viewport.addEventListener('wheel', onWheel, { passive: false }) return () => viewport.removeEventListener('wheel', onWheel) }, []) /** Zoom by a factor, keeping the viewport center fixed. */ const zoomStep = (factor: number) => { const vp = viewportRef.current const old = zoomRef.current const next = clamp(old * factor) if (next === old) return if (!vp) { setZoom(next) return } const rect = vp.getBoundingClientRect() const mx = rect.width / 2 const my = rect.height / 2 const wx = (mx - panRef.current.x) / (CELL * old) const wy = (my - panRef.current.y) / (CELL * old) setZoom(next) setPan({ x: mx - wx * CELL * next, y: my - wy * CELL * next }) } // ---- pointer handling ---------------------------------------------------- /** Float cell coordinates under the pointer (no flooring). */ const screenToCellFloat = (clientX: number, clientY: number) => { const vp = viewportRef.current if (!vp) return null const rect = vp.getBoundingClientRect() const px = CELL * zoomRef.current return { x: (clientX - rect.left - panRef.current.x) / px, y: (clientY - rect.top - panRef.current.y) / px, } } /** Topmost placement whose footprint covers a cell point (last placed wins). */ const placementAt = (cx: number, cy: number) => { for (let i = placements.length - 1; i >= 0; i--) { const p = placements[i] const fp = footprint(p.mapId) const x0 = p.gx * gridWidth const y0 = p.gy * gridHeight if (cx >= x0 && cx < x0 + fp.cols && cy >= y0 && cy < y0 + fp.rows) return p } return null } const onPointerDown = (e: React.PointerEvent) => { const vp = viewportRef.current if (!vp) return // middle-mouse always pans if (e.button === 1) { e.preventDefault() panningRef.current = { x: e.clientX, y: e.clientY } setIsPanning(true) vp.setPointerCapture(e.pointerId) return } if (e.button !== 0) return const f = screenToCellFloat(e.clientX, e.clientY) const hit = f ? placementAt(f.x, f.y) : null if (hit && f) { e.preventDefault() select(hit.mapId) // remember where in grid units the pointer grabbed, relative to the slot dragRef.current = { mapId: hit.mapId, offGx: f.x / gridWidth - hit.gx, offGy: f.y / gridHeight - hit.gy, } vp.setPointerCapture(e.pointerId) return } // empty space → pan + clear selection e.preventDefault() select(null) panningRef.current = { x: e.clientX, y: e.clientY } setIsPanning(true) vp.setPointerCapture(e.pointerId) } const onPointerMove = (e: React.PointerEvent) => { if (panningRef.current) { const dx = e.clientX - panningRef.current.x const dy = e.clientY - panningRef.current.y panningRef.current = { x: e.clientX, y: e.clientY } setPan((p) => ({ x: p.x + dx, y: p.y + dy })) return } if (dragRef.current) { const f = screenToCellFloat(e.clientX, e.clientY) if (!f) return const d = dragRef.current const gx = Math.round(f.x / gridWidth - d.offGx) const gy = Math.round(f.y / gridHeight - d.offGy) moveMap(d.mapId, gx, gy) } } const onPointerUp = (e: React.PointerEvent) => { if (panningRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) panningRef.current = null setIsPanning(false) } if (dragRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) dragRef.current = null } } const onDoubleClick = (e: React.MouseEvent) => { const f = screenToCellFloat(e.clientX, e.clientY) const hit = f ? placementAt(f.x, f.y) : null if (!hit) return e.preventDefault() // open the map in the Map editor (the load is a no-op stub until full map // persistence ships; switching the shell is the user-visible effect today) loadMapIntoEditor(hit.mapId) setEditorKind('map') } // delete the selected placement with Backspace/Delete (ignores text inputs) useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== 'Backspace' && e.key !== 'Delete') return const el = document.activeElement as HTMLElement | null if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return if (selectedMapId) { e.preventDefault() removeMap(selectedMapId) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [selectedMapId, removeMap]) // ---- sidebar helpers ---- const placedIds = new Set(placements.map((p) => p.mapId)) const addable = mapEntries.filter((e) => !placedIds.has(e.id)) const nameOf = (mapId: string) => mapEntries.find((e) => e.id === mapId)?.name || mapId /** Drop a new map near the view center, snapped to the world grid. */ const addAtViewCenter = (mapId: string) => { const vp = viewportRef.current const px = CELL * zoomRef.current const cx = vp ? (vp.clientWidth / 2 - panRef.current.x) / px : 0 const cy = vp ? (vp.clientHeight / 2 - panRef.current.y) / px : 0 addMap(mapId, Math.round(cx / gridWidth), Math.round(cy / gridHeight)) } return (
{name && (
{name}
)}
) }