/** * Tilemap Editor Panel - Main editing panel with 3-column layout * Tilemap 编辑器面板 - 三栏布局的主编辑面板 */ import React, { useEffect, useState, useRef, useCallback } from 'react'; import { Grid3x3, Eye, EyeOff, ZoomIn, ZoomOut, RotateCcw, Map, Save, Scaling, X, Search, Folder, FolderOpen, File, Image as ImageIcon, MousePointer2, Move, RotateCw, Maximize2, Minimize2, ChevronDown, Magnet, AlertTriangle, SunDim, Layers, Box, View, Sidebar } from 'lucide-react'; import { Core, Entity } from '@esengine/ecs-framework'; import { MessageHub, ProjectService, IFileSystemService, type IFileSystem, IDialogService, type IDialog } from '@esengine/editor-core'; import { TilemapComponent, type ITilesetData, type ResizeAnchor } from '@esengine/tilemap'; import { useTilemapEditorStore, type TilemapToolType, type LayerState } from '../../stores/TilemapEditorStore'; import { TilemapCanvas } from '../TilemapCanvas'; import { TileSetSelectorPanel } from './TileSetSelectorPanel'; import { TilemapDetailsPanel } from './TilemapDetailsPanel'; import '../../styles/TilemapEditor.css'; // Asset Picker Dialog component interface FileNode { name: string; path: string; isDirectory: boolean; children?: FileNode[]; } interface AssetPickerDialogProps { isOpen: boolean; onClose: () => void; onSelect: (path: string) => void; title?: string; fileExtensions?: string[]; } function AssetPickerDialog({ isOpen, onClose, onSelect, title = '选择资产', fileExtensions = [] }: AssetPickerDialogProps) { const [searchTerm, setSearchTerm] = useState(''); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [selectedPath, setSelectedPath] = useState(null); const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(false); const [previewPath, setPreviewPath] = useState(null); const [previewPosition, setPreviewPosition] = useState({ x: 0, y: 0 }); const [previewSrc, setPreviewSrc] = useState(null); const isImageFile = (name: string) => { const ext = name.split('.').pop()?.toLowerCase(); return ['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext || ''); }; const handleMouseEnter = (e: React.MouseEvent, node: FileNode) => { if (!node.isDirectory && isImageFile(node.name)) { const rect = e.currentTarget.getBoundingClientRect(); setPreviewPosition({ x: rect.right + 10, y: rect.top - 50 }); setPreviewPath(node.path); const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null; if (fileSystem) { const assetUrl = fileSystem.convertToAssetUrl(node.path); setPreviewSrc(assetUrl); } } }; const handleMouseLeave = () => { setPreviewPath(null); setPreviewSrc(null); }; useEffect(() => { if (!isOpen) return; const loadAssets = async () => { setLoading(true); try { const projectService = Core.services.tryResolve(ProjectService); const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null; const currentProject = projectService?.getCurrentProject(); if (projectService && currentProject && fileSystem) { const projectPath = currentProject.path.replace(/\//g, '\\'); const assetsPath = `${projectPath}\\assets`; const buildTree = async (dirPath: string): Promise => { const entries = await fileSystem.listDirectory(dirPath); const nodes: FileNode[] = []; for (const entry of entries) { const node: FileNode = { name: entry.name, path: entry.path, isDirectory: entry.isDirectory }; if (entry.isDirectory) { try { node.children = await buildTree(entry.path); } catch { node.children = []; } } nodes.push(node); } return nodes.sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.localeCompare(b.name); }); }; const tree = await buildTree(assetsPath); setAssets(tree); } } catch (error) { console.error('Failed to load assets:', error); } finally { setLoading(false); } }; loadAssets(); setSelectedPath(null); setSearchTerm(''); }, [isOpen]); const filterNode = useCallback((node: FileNode): FileNode | null => { if (!node.isDirectory && fileExtensions.length > 0) { const hasValidExtension = fileExtensions.some((ext) => node.name.toLowerCase().endsWith(ext.toLowerCase()) ); if (!hasValidExtension) return null; } const matchesSearch = !searchTerm || node.name.toLowerCase().includes(searchTerm.toLowerCase()); if (node.isDirectory && node.children) { const filteredChildren = node.children .map(filterNode) .filter((n): n is FileNode => n !== null); if (filteredChildren.length > 0 || matchesSearch) { return { ...node, children: filteredChildren }; } return null; } return matchesSearch ? node : null; }, [searchTerm, fileExtensions]); const filteredAssets = assets .map(filterNode) .filter((n): n is FileNode => n !== null); const toggleFolder = useCallback((path: string) => { setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(path)) { next.delete(path); } else { next.add(path); } return next; }); }, []); const handleSelect = useCallback((node: FileNode) => { if (node.isDirectory) { toggleFolder(node.path); } else { setSelectedPath(node.path); } }, [toggleFolder]); const toRelativePath = useCallback((absolutePath: string): string => { const projectService = Core.services.tryResolve(ProjectService); const currentProject = projectService?.getCurrentProject(); if (currentProject) { const projectPath = currentProject.path.replace(/\\/g, '/'); const normalizedAbsolute = absolutePath.replace(/\\/g, '/'); if (normalizedAbsolute.startsWith(projectPath)) { return normalizedAbsolute.substring(projectPath.length + 1); } } return absolutePath; }, []); const handleConfirm = useCallback(() => { if (selectedPath) { onSelect(toRelativePath(selectedPath)); onClose(); } }, [selectedPath, onSelect, onClose, toRelativePath]); const handleDoubleClick = useCallback((node: FileNode) => { if (!node.isDirectory) { onSelect(toRelativePath(node.path)); onClose(); } }, [onSelect, onClose, toRelativePath]); const getFileIcon = (name: string) => { const ext = name.split('.').pop()?.toLowerCase(); switch (ext) { case 'png': case 'jpg': case 'jpeg': case 'gif': case 'webp': return ; default: return ; } }; const renderNode = (node: FileNode, depth: number = 0) => { const isExpanded = expandedFolders.has(node.path); const isSelected = selectedPath === node.path; return (
handleSelect(node)} onDoubleClick={() => handleDoubleClick(node)} onMouseEnter={(e) => handleMouseEnter(e, node)} onMouseLeave={handleMouseLeave} > {node.isDirectory ? ( isExpanded ? : ) : ( getFileIcon(node.name) )} {node.name}
{node.isDirectory && isExpanded && node.children && (
{node.children.map((child) => renderNode(child, depth + 1))}
)}
); }; if (!isOpen) return null; return (
e.stopPropagation()}>

{title}

setSearchTerm(e.target.value)} autoFocus />
{loading ? (
加载资产中...
) : filteredAssets.length === 0 ? (
未找到资产
) : (
{filteredAssets.map((node) => renderNode(node))}
)}
{selectedPath ? ( {selectedPath.split(/[\\/]/).pop()} ) : ( 未选择资产 )}
{previewPath && previewSrc && (
Preview { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
); } // Resize Map Dialog component interface ResizeMapDialogProps { isOpen: boolean; onClose: () => void; onConfirm: (width: number, height: number, anchor: ResizeAnchor) => void; currentWidth: number; currentHeight: number; } function ResizeMapDialog({ isOpen, onClose, onConfirm, currentWidth, currentHeight }: ResizeMapDialogProps) { const [newWidth, setNewWidth] = useState(currentWidth); const [newHeight, setNewHeight] = useState(currentHeight); const [anchor, setAnchor] = useState('bottom-left'); useEffect(() => { if (isOpen) { setNewWidth(currentWidth); setNewHeight(currentHeight); } }, [isOpen, currentWidth, currentHeight]); if (!isOpen) return null; const anchorPositions: ResizeAnchor[] = [ 'top-left', 'top-center', 'top-right', 'middle-left', 'center', 'middle-right', 'bottom-left', 'bottom-center', 'bottom-right' ]; const handleConfirm = () => { if (newWidth > 0 && newHeight > 0) { onConfirm(newWidth, newHeight, anchor); onClose(); } }; return (
e.stopPropagation()} style={{ width: '320px' }}>

调整地图大小

setNewWidth(Math.max(1, parseInt(e.target.value) || 1))} min={1} style={{ width: '100%', padding: '8px', backgroundColor: '#1e1e1e', border: '1px solid #444', borderRadius: '4px', color: '#e0e0e0' }} />
setNewHeight(Math.max(1, parseInt(e.target.value) || 1))} min={1} style={{ width: '100%', padding: '8px', backgroundColor: '#1e1e1e', border: '1px solid #444', borderRadius: '4px', color: '#e0e0e0' }} />
{anchorPositions.map((pos) => ( ))}
); } // Helper to convert file path to URL function convertFileSrc(path: string): string { const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null; if (fileSystem) { return fileSystem.convertToAssetUrl(path); } if (path.startsWith('http://') || path.startsWith('https://')) { return path; } return path; } interface TilemapEditorPanelProps { projectPath?: string | null; messageHub?: MessageHub; } // Resizable Panel Divider Component interface PanelDividerProps { onDrag: (delta: number) => void; direction: 'horizontal' | 'vertical'; } const PanelDivider: React.FC = ({ onDrag, direction }) => { const isDraggingRef = useRef(false); const lastPosRef = useRef(0); const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault(); isDraggingRef.current = true; lastPosRef.current = direction === 'horizontal' ? e.clientX : e.clientY; document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize'; document.body.style.userSelect = 'none'; const handleMouseMove = (moveEvent: MouseEvent) => { if (!isDraggingRef.current) return; const currentPos = direction === 'horizontal' ? moveEvent.clientX : moveEvent.clientY; const delta = currentPos - lastPosRef.current; lastPosRef.current = currentPos; onDrag(delta); }; const handleMouseUp = () => { isDraggingRef.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; return (
); }; export const TilemapEditorPanel: React.FC = ({ messageHub: propMessageHub }) => { const [tilemap, setTilemap] = useState(null); const [entity, setEntity] = useState(null); // Panel widths for resizable layout - smaller defaults to give viewport more space const [leftPanelWidth, setLeftPanelWidth] = useState(180); const [rightPanelWidth, setRightPanelWidth] = useState(220); const handleLeftDividerDrag = useCallback((delta: number) => { setLeftPanelWidth(prev => Math.max(120, Math.min(350, prev + delta))); }, []); const handleRightDividerDrag = useCallback((delta: number) => { setRightPanelWidth(prev => Math.max(180, Math.min(400, prev - delta))); }, []); const [tilesetImage, setTilesetImage] = useState(null); const [tilemapKey, setTilemapKey] = useState(''); const [showAssetPicker, setShowAssetPicker] = useState(false); const [showResizeDialog, setShowResizeDialog] = useState(false); const [activeTilesetIndex, setActiveTilesetIndex] = useState(0); // Viewport state const [viewMode, setViewMode] = useState<'right' | 'left' | 'top' | 'bottom'>('right'); const [litMode, setLitMode] = useState(true); const [showViewOptions, setShowViewOptions] = useState(false); const [transformMode, setTransformMode] = useState<'select' | 'move' | 'rotate' | 'scale'>('select'); const messageHub = propMessageHub || Core.services.resolve(MessageHub); const { entityId, pendingFilePath, currentFilePath, currentTool, zoom, showGrid, showCollision, editingCollision, tileWidth, tileHeight, tilesetImageUrl, tilesetColumns, tilesetRows, setEntityId, setPendingFilePath, setCurrentFilePath, setCurrentTool, setZoom, setShowGrid, setShowCollision, setEditingCollision, setPan, setTileset, setLayers, setCurrentLayer } = useTilemapEditorStore(); // Load tileset from component (defined early for use in effects) const loadTilesetFromComponent = useCallback((tilemapComp: TilemapComponent) => { const tilesetRef = tilemapComp.tilesets[activeTilesetIndex]; if (!tilesetRef) { setTileset(null, 0, 0, tilemapComp.tileWidth, tilemapComp.tileHeight); return; } const tilesetPath = tilesetRef.source; const projectService = Core.services.tryResolve(ProjectService); const currentProject = projectService?.getCurrentProject(); let absolutePath = tilesetPath; if (currentProject && !tilesetPath.startsWith('/') && !tilesetPath.match(/^[a-zA-Z]:/)) { const projectPath = currentProject.path.replace(/\\/g, '/'); absolutePath = `${projectPath}/${tilesetPath}`.replace(/\\/g, '/'); } const imageUrl = convertFileSrc(absolutePath); if (tilesetRef.data) { const tilesetData = tilesetRef.data; setTileset(imageUrl, tilesetData.columns, tilesetData.rows, tilesetData.tileWidth, tilesetData.tileHeight); } else { const img = new Image(); img.onload = () => { const columns = Math.floor(img.width / tilemapComp.tileWidth); const rows = Math.floor(img.height / tilemapComp.tileHeight); const tilesetData: ITilesetData = { name: 'tileset', version: 1, image: tilesetPath, imageWidth: img.width, imageHeight: img.height, tileWidth: tilemapComp.tileWidth, tileHeight: tilemapComp.tileHeight, tileCount: columns * rows, columns, rows }; tilemapComp.setTilesetData(activeTilesetIndex, tilesetData); setTileset(imageUrl, columns, rows, tilemapComp.tileWidth, tilemapComp.tileHeight); }; img.onerror = () => { setTileset(null, 0, 0, tilemapComp.tileWidth, tilemapComp.tileHeight); }; img.src = imageUrl; } }, [activeTilesetIndex, setTileset]); // Load file from pendingFilePath on mount (for file-based editing via double-click) useEffect(() => { if (!pendingFilePath) return; const loadTilemapFile = async () => { try { const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null; if (!fileSystem) { console.error('[TilemapEditorPanel] FileSystem service not available'); return; } // Clear entity-based editing state setEntityId(''); setEntity(null); // Load tilemap data from file const content = await fileSystem.readFile(pendingFilePath); const tilemapData = JSON.parse(content); // Create a standalone TilemapComponent const tilemapComp = new TilemapComponent(); tilemapComp.applyTilemapData(tilemapData); tilemapComp.tilemapAssetGuid = pendingFilePath; setCurrentFilePath(pendingFilePath); setTilemap(tilemapComp); // Load tileset loadTilesetFromComponent(tilemapComp); // Set up layers const layerStates: LayerState[] = tilemapComp.layers.map((layer) => ({ id: layer.id, name: layer.name, visible: layer.visible, locked: false, opacity: layer.opacity })); setLayers(layerStates); setCurrentLayer(0); setTilemapKey(`file-${Date.now()}`); // Clear pending file after loading setPendingFilePath(null); console.log('[TilemapEditorPanel] Loaded tilemap from file:', pendingFilePath); } catch (error) { console.error('[TilemapEditorPanel] Failed to load tilemap file:', error); setPendingFilePath(null); messageHub?.publish('notification:show', { type: 'error', message: `Failed to load tilemap: ${error instanceof Error ? error.message : String(error)}`, duration: 3000 }); } }; loadTilemapFile(); }, [pendingFilePath, setEntityId, setCurrentFilePath, setPendingFilePath, setLayers, setCurrentLayer, loadTilesetFromComponent, messageHub]); // Listen for tilemap edit requests (entity-based) useEffect(() => { if (!messageHub) return; const unsubscribe = messageHub.subscribe('tilemap:edit', (data: { entityId: string }) => { // Clear file-based editing state when switching to entity mode setCurrentFilePath(null); setEntityId(data.entityId); }); return unsubscribe; }, [messageHub, setEntityId, setCurrentFilePath]); // Load tilemap component when entityId changes useEffect(() => { if (!entityId) { // Don't clear tilemap if we're in file-based editing mode if (!currentFilePath) { setTilemap(null); setEntity(null); } return; } const scene = Core.scene; if (!scene) return; const foundEntity = scene.findEntityById(parseInt(entityId, 10)); if (!foundEntity) return; const tilemapComp = foundEntity.getComponent(TilemapComponent); if (!tilemapComp) return; setEntity(foundEntity); setTilemap(tilemapComp); loadTilesetFromComponent(tilemapComp); const layerStates: LayerState[] = tilemapComp.layers.map((layer) => ({ id: layer.id, name: layer.name, visible: layer.visible, locked: false, opacity: layer.opacity })); setLayers(layerStates); setCurrentLayer(0); }, [entityId, currentFilePath, loadTilesetFromComponent, setLayers, setCurrentLayer]); // Listen for scene modifications useEffect(() => { if (!messageHub || !tilemap) return; const unsubscribeModified = messageHub.subscribe('scene:modified', () => { loadTilesetFromComponent(tilemap); setTilemapKey(`${tilemap.width}-${tilemap.height}-${Date.now()}`); }); const unsubscribeRestored = messageHub.subscribe('scene:restored', () => { if (!entityId) return; const scene = Core.scene; if (!scene) return; const foundEntity = scene.findEntityById(parseInt(entityId, 10)); if (!foundEntity) return; const newTilemap = foundEntity.getComponent(TilemapComponent); if (!newTilemap) return; setTilemap(newTilemap); loadTilesetFromComponent(newTilemap); }); return () => { unsubscribeModified(); unsubscribeRestored(); }; }, [messageHub, tilemap, entityId, loadTilesetFromComponent]); // Load tileset image useEffect(() => { if (!tilesetImageUrl) { setTilesetImage(null); return; } const img = new Image(); img.onload = () => setTilesetImage(img); img.src = tilesetImageUrl; }, [tilesetImageUrl]); const handleTilemapChange = useCallback(() => { messageHub?.publish('scene:modified', {}); }, [messageHub]); const handleSaveTilemap = useCallback(async () => { if (!tilemap || !entity) return; try { const tilemapData = tilemap.exportToData(); const jsonContent = JSON.stringify(tilemapData, null, 2); const tilemapAssetPath = tilemap.tilemapAssetGuid; if (!tilemapAssetPath) { console.warn('Tilemap asset path not set'); return; } const projectService = Core.services.tryResolve(ProjectService); const currentProject = projectService?.getCurrentProject(); if (!currentProject) return; const normalizedAssetPath = tilemapAssetPath.replace(/\\/g, '/'); const normalizedProjectPath = currentProject.path.replace(/\\/g, '/'); let absolutePath: string; if (normalizedAssetPath.match(/^[a-zA-Z]:/) || normalizedAssetPath.startsWith('/')) { absolutePath = normalizedAssetPath; } else { absolutePath = `${normalizedProjectPath}/${normalizedAssetPath}`; } const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null; if (fileSystem) { await fileSystem.writeFile(absolutePath, jsonContent); messageHub?.publish('notification:show', { type: 'success', message: 'Tilemap saved', duration: 2000 }); } } catch (error) { console.error('Failed to save tilemap:', error); messageHub?.publish('notification:show', { type: 'error', message: `Save failed: ${error instanceof Error ? error.message : String(error)}`, duration: 3000 }); } }, [tilemap, entity, messageHub]); // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); e.stopPropagation(); handleSaveTilemap(); } }; window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); }, [handleSaveTilemap]); const handleZoomIn = () => setZoom(Math.min(10, zoom * 1.2)); const handleZoomOut = () => setZoom(Math.max(0.1, zoom / 1.2)); const handleResetView = () => { setZoom(1); setPan(0, 0); }; // 退出全屏模式 const handleExitFullscreen = useCallback(() => { messageHub?.publish('editor:fullscreen', { fullscreen: false }); }, [messageHub]); // Layer operations const handleAddLayer = useCallback(() => { if (!tilemap) return; tilemap.addLayer(`Layer ${tilemap.layers.length + 1}`); const layerStates: LayerState[] = tilemap.layers.map((layer) => ({ id: layer.id, name: layer.name, visible: layer.visible, locked: false, opacity: layer.opacity })); setLayers(layerStates); setCurrentLayer(tilemap.layers.length - 1); tilemap.renderDirty = true; handleTilemapChange(); }, [tilemap, setLayers, setCurrentLayer, handleTilemapChange]); const handleRemoveLayer = useCallback((index: number) => { if (!tilemap || tilemap.layers.length <= 1) return; tilemap.removeLayer(index); const layerStates: LayerState[] = tilemap.layers.map((layer) => ({ id: layer.id, name: layer.name, visible: layer.visible, locked: false, opacity: layer.opacity })); setLayers(layerStates); const { currentLayer } = useTilemapEditorStore.getState(); if (currentLayer >= tilemap.layers.length) { setCurrentLayer(tilemap.layers.length - 1); } tilemap.renderDirty = true; handleTilemapChange(); }, [tilemap, setLayers, setCurrentLayer, handleTilemapChange]); const handleMoveLayer = useCallback((fromIndex: number, toIndex: number) => { if (!tilemap) return; if (toIndex < 0 || toIndex >= tilemap.layers.length) return; tilemap.moveLayer(fromIndex, toIndex); const layerStates: LayerState[] = tilemap.layers.map((layer) => ({ id: layer.id, name: layer.name, visible: layer.visible, locked: false, opacity: layer.opacity })); setLayers(layerStates); setCurrentLayer(toIndex); tilemap.renderDirty = true; handleTilemapChange(); }, [tilemap, setLayers, setCurrentLayer, handleTilemapChange]); // Tileset operations const handleAddTileset = useCallback(() => { if (!tilemap) return; setShowAssetPicker(true); }, [tilemap]); const handleTilesetSelected = useCallback((path: string) => { if (!tilemap) return; tilemap.addTileset(path); setActiveTilesetIndex(tilemap.tilesets.length - 1); loadTilesetFromComponent(tilemap); handleTilemapChange(); }, [tilemap, loadTilesetFromComponent, handleTilemapChange]); const handleTilesetChange = useCallback((index: number) => { setActiveTilesetIndex(index); if (tilemap) { loadTilesetFromComponent(tilemap); } }, [tilemap, loadTilesetFromComponent]); // Resize map const handleResizeMap = useCallback((newWidth: number, newHeight: number, anchor: ResizeAnchor) => { if (!tilemap) return; tilemap.resize(newWidth, newHeight, anchor); setTilemapKey(`${newWidth}-${newHeight}-${Date.now()}`); handleTilemapChange(); }, [tilemap, handleTilemapChange]); // Get tileset list const tilesetOptions = tilemap?.tilesets.map((t, i) => ({ name: t.data?.name || `Tileset ${i + 1}`, path: t.source })) || []; // Empty state if (!tilemap) { return (

未选择瓦片地图

选择带有 TilemapComponent 的实体
并点击"编辑瓦片地图"开始编辑。

); } return (
{/* Left Panel - Tile Set Selector */}
{/* Left Divider */} {/* Center - Viewport */}
{/* Viewport top toolbar */}
{/* View mode buttons */}
{/* Transform tools */}
{/* Grid/snap controls */}
{/* Zoom controls */}
{Math.round(zoom * 100)}%
{/* Info overlay */}
碰撞几何体 (烘焙)
警告: 碰撞已启用但没有形状
渲染几何体 (烘焙)
区段: 0
三角形: 0 (遮罩)
近似大小: {tilemap.width * tilemap.tileWidth}x{tilemap.height * tilemap.tileHeight}
{/* Canvas */}
{/* Scale ruler - width represents 100 pixels at current zoom */}
{(100 / zoom / tilemap.tileWidth).toFixed(1)} 格
{/* Beta preview watermark */}
测试预览
{/* Right Divider */} {/* Right Panel - Details */}
setShowAssetPicker(true)} />
{/* Dialogs */} setShowAssetPicker(false)} onSelect={handleTilesetSelected} title="选择瓦片集图片" fileExtensions={['.png', '.jpg', '.jpeg', '.webp']} /> setShowResizeDialog(false)} onConfirm={handleResizeMap} currentWidth={tilemap.width} currentHeight={tilemap.height} />
); };