Implement map resizing, image editing tools, and import/export functionality#3
Implement map resizing, image editing tools, and import/export functionality#3wernerbihl merged 12 commits intomainfrom
Conversation
Co-authored-by: Copilot <copilot@github.com>
- Implemented line, rectangle, and contour drawing tools in `image-editor-shape-tools.ts`. - Added Bresenham's line algorithm and pixel manipulation functions in `image-editor-tools-shared.ts`. - Created utility functions for property cloning and remapping layer IDs in `project-import.ts`. - Defined application shell props and image editor hook internals types. - Introduced crop state and rectangle types for image editing. - Developed layers panel types for managing layer interactions and state. - Expanded map panel types for enhanced map editing capabilities, including clipboard actions and history controls. Co-authored-by: Copilot <copilot@github.com>
- Cleaned up import statements across multiple files for better organization. - Reformatted state management and function calls to enhance clarity. - Adjusted line breaks and indentation for improved code style. - Ensured consistent use of arrow functions and callback structures. - Removed unnecessary comments and streamlined code logic.
Co-authored-by: Copilot <copilot@github.com>
…ctions Co-authored-by: Copilot <copilot@github.com>
…t for multiple asset types Co-authored-by: Copilot <copilot@github.com>
…nctionality Co-authored-by: Copilot <copilot@github.com>
- Implemented functions to handle importing raster images from files, including support for various formats (PNG, JPG, WEBP, BMP, GIF). - Added functionality to render tilesets and maps to canvas, supporting multiple layer types and visibility settings. - Introduced encoding methods for exporting canvas content as raster images in specified formats, including handling transparency and quality settings. - Created utility functions for managing raster file types, MIME types, and image loading. Co-authored-by: Copilot <copilot@github.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR introduces raster import/export utilities, adds new image editor tools (shape/selection/crop), and refactors UI/editor structure to support map resizing and a cleaner import/export flow.
Changes:
- Added raster rendering/encoding utilities (PNG/JPG/WebP/GIF/BMP) and ZIP helpers for import/export.
- Implemented new image editor tooling (shape drawing, selection/crop manipulation) plus palette/layer/frame action hooks.
- Refactored editor UI: extracted resize controls, introduced reusable MapPanel components, and moved the main editor shell into a dedicated
AppShell.
Reviewed changes
Copilot reviewed 39 out of 55 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/import-export-raster.ts | New raster import/render/export helpers for maps/tilesets and canvas encoding. |
| src/lib/image-editor-tools-shared.ts | Shared low-level pixel + geometry utilities for image editor tools. |
| src/lib/image-editor-shape-tools.ts | New line/rectangle/contour drawing tools built on shared pixel helpers. |
| src/lib/image-editor-selection-tools.ts | New selection/floating transform logic (move/resize/copy/paste). |
| src/lib/image-editor-document.ts | Centralized image editor document state, compositing, and undo/redo stacks. |
| src/lib/image-editor-crop-tools.ts | New crop interaction implementation with resize handles. |
| src/lib/format.ts | Added filename sanitization and ZIP archive creation helper. |
| src/hooks/use-image-editor-palette-actions.ts | Added palette import/export and palette management actions. |
| src/hooks/use-image-editor-layer-actions.ts | Added layer/image-layer/group CRUD + reorder actions for image editor. |
| src/hooks/use-image-editor-frame-actions.ts | Added frame CRUD + undo/redo operations for frames. |
| src/components/tools/ImageEditor/ImageCanvasResizeControls.tsx | Extracted canvas resize UI controls into a dedicated component. |
| src/components/tools/ImageEditor/ImageCanvas.tsx | Refactored to use extracted resize controls component. |
| src/components/layout/Toolbar.tsx | Simplified import/export menu into new dialog entry points + updated help text. |
| src/components/editor/TilesetPanel.tsx | Added drag-and-drop image import to create tilesets + minor input attributes. |
| src/components/editor/MapPanel/use-map-panel-canvas-actions.ts | Extracted MapPanel canvas action handlers into a reusable hook. |
| src/components/editor/MapPanel/MapPanelWorkspace.tsx | New workspace wrapper wiring MapCanvas + context menu actions. |
| src/components/editor/MapPanel/MapPanelToolbar.tsx | New MapPanel toolbar with tool/brush/fill/orientation/zoom controls. |
| src/components/editor/MapPanel/MapPanelTabs.tsx | New map group selector + map tabs UI with rename/duplicate/delete intents. |
| src/components/editor/MapPanel/MapPanelDialogs.tsx | New MapPanel dialog composition (options, new map/group, delete confirm). |
| src/components/editor/MapCanvas/use-map-resize.ts | New hook encapsulating map resize pointer interaction and preview state. |
| src/components/editor/MapCanvas/scene-interaction-handlers.ts | Expanded scene interactions (object placement, polygon, image layer transforms). |
| src/components/editor/MapCanvas/index.tsx | Refactored MapCanvas to use useMapResize + extracted resize controls. |
| src/components/editor/MapCanvas/MapResizeControls.tsx | Extracted map resize UI controls into a dedicated component. |
| src/components/editor/LayersPanel/index.tsx | Refactored LayersPanel rendering into LayersTree + extracted delete dialog. |
| src/components/editor/LayersPanel/LayersTree.tsx | New component encapsulating layer/group tree rendering. |
| src/components/editor/LayersPanel/DeleteLayerDialog.tsx | New extracted delete-confirm dialog for layers/groups. |
| src/components/dialogs/ProjectDialog.tsx | Refactored project open to route through openProjectInEditor. |
| src/components/dialogs/BugReportDialog.tsx | Updated copy to include feature requests and adjusted textarea placeholder usage. |
| src/components/app/AppShell.tsx | New top-level editor shell coordinating layout, dialogs, and import/export. |
| src/App.tsx | Slimmed down by moving editor shell logic into components/app/AppShell. |
| components.json | Removed external registry configuration. |
| TODO.txt | Updated TODOs related to import/export and UX polish. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const file = Array.from(e.dataTransfer.files).find((candidate) => | ||
| candidate.type.startsWith("image/"), | ||
| ); | ||
| if (!file) return; | ||
|
|
||
| e.preventDefault(); | ||
| setIsDropTargetActive(false); |
There was a problem hiding this comment.
handleCanvasDrop returns early without calling preventDefault() when the drop payload contains no image. This can cause the browser to navigate/open the dropped file, and it can also leave isDropTargetActive stuck true (overlay remains visible). Move e.preventDefault() (and setIsDropTargetActive(false)) before the if (!file) return, and consider also calling e.stopPropagation() to avoid unintended parent drop handlers.
| const file = Array.from(e.dataTransfer.files).find((candidate) => | |
| candidate.type.startsWith("image/"), | |
| ); | |
| if (!file) return; | |
| e.preventDefault(); | |
| setIsDropTargetActive(false); | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setIsDropTargetActive(false); | |
| const file = Array.from(e.dataTransfer.files).find((candidate) => | |
| candidate.type.startsWith("image/"), | |
| ); | |
| if (!file) return; |
| anchor.click(); | ||
| URL.revokeObjectURL(url); |
There was a problem hiding this comment.
URL.revokeObjectURL(url) is called immediately after anchor.click(). In some browsers this can revoke the URL before the download is initiated, leading to flaky/failed downloads. Prefer appending the anchor to the DOM and revoking the URL asynchronously (e.g., setTimeout(..., 0) or requestAnimationFrame) after the click.
| anchor.click(); | |
| URL.revokeObjectURL(url); | |
| document.body.appendChild(anchor); | |
| anchor.click(); | |
| setTimeout(() => { | |
| anchor.remove(); | |
| URL.revokeObjectURL(url); | |
| }, 0); |
| } from "@/lib/ids"; | ||
| import { findLastLayerId, getAllGroupIds, getAllLayerIds } from "@/lib/layers"; | ||
| import { clearTileEditorContext } from "@/lib/tile-editor-context"; | ||
| import {} from "@/lib/db"; |
There was a problem hiding this comment.
import {} from "@/lib/db"; is a no-op import and is likely unintended. It adds noise and may trip lint rules; it should be removed.
| import {} from "@/lib/db"; |
| const baseName = activePalette.name || "palette"; | ||
| const colors = activePalette.colors; | ||
|
|
||
| let blob: Blob; | ||
| let filename: string; | ||
|
|
||
| if (format === "ase") { | ||
| const buffer = writePhotoshopAse(colors); | ||
| blob = new Blob([buffer], { type: "application/octet-stream" }); | ||
| filename = `${baseName}.ase`; | ||
| } else if (format === "aseprite") { | ||
| const buffer = writeAsePalette(colors); | ||
| blob = new Blob([buffer], { type: "application/octet-stream" }); | ||
| filename = `${baseName}.aseprite`; | ||
| } else if (format === "gpl") { | ||
| blob = new Blob([writeGpl(colors, baseName)], { type: "text/plain" }); | ||
| filename = `${baseName}.gpl`; | ||
| } else if (format === "pal") { | ||
| blob = new Blob([writeJascPal(colors)], { type: "text/plain" }); | ||
| filename = `${baseName}.pal`; | ||
| } else if (format === "txt") { | ||
| blob = new Blob([writePaintNetTxt(colors, baseName)], { | ||
| type: "text/plain", | ||
| }); | ||
| filename = `${baseName}.txt`; | ||
| } else if (format === "hex") { | ||
| blob = new Blob([writeHex(colors)], { type: "text/plain" }); | ||
| filename = `${baseName}.hex`; | ||
| } else { | ||
| blob = await writePng(colors, swatchSize); | ||
| filename = `${baseName}-${swatchSize}px.png`; | ||
| } |
There was a problem hiding this comment.
Export filenames are built directly from activePalette.name, which may contain characters invalid for downloads on some platforms (e.g., /, \, :). Since sanitizeDownloadSegment/buildDownloadFilename were added in src/lib/format.ts, consider using them here (or otherwise sanitizing baseName) before calling downloadBlob.
| export function createZipArchive( | ||
| entries: ImportExportArchiveEntry[], | ||
| ): Uint8Array { | ||
| const archiveEntries: Record<string, Uint8Array> = {}; | ||
|
|
||
| for (const entry of entries) { | ||
| archiveEntries[entry.path.replace(/\\/g, "/")] = entry.data; | ||
| } | ||
|
|
||
| return zipSync(archiveEntries, { level: 6 }); | ||
| } |
There was a problem hiding this comment.
entry.path is normalized for slashes but not validated for traversal segments (e.g., ../, leading /, or drive letters). If any entries paths can be influenced by user-controlled names, the produced ZIP could contain “zip-slip” style paths when extracted. Consider sanitizing paths to strip traversal components and enforce a safe, relative path structure before passing to zipSync.
| const sourceCanvas = document.createElement("canvas"); | ||
| sourceCanvas.width = state.floatingPixels.width; | ||
| sourceCanvas.height = state.floatingPixels.height; | ||
| const sourceContext = sourceCanvas.getContext("2d"); | ||
| if (!sourceContext) return null; | ||
| sourceContext.putImageData(state.floatingPixels, 0, 0); | ||
|
|
||
| const destinationCanvas = document.createElement("canvas"); | ||
| destinationCanvas.width = displayWidth; | ||
| destinationCanvas.height = displayHeight; | ||
| const destinationContext = destinationCanvas.getContext("2d"); | ||
| if (!destinationContext) return null; | ||
| destinationContext.imageSmoothingEnabled = false; | ||
| destinationContext.drawImage(sourceCanvas, 0, 0, displayWidth, displayHeight); | ||
|
|
||
| return destinationContext.getImageData(0, 0, displayWidth, displayHeight); |
There was a problem hiding this comment.
getScaledFloating() allocates two canvases (and contexts) each time it needs to scale the floating selection. This is likely to be called frequently during resize/drag operations and can cause GC churn and jank for larger selections. Consider caching/reusing a single pair of offscreen canvases/contexts across calls (module-level or captured in closure), resizing them as needed.
| for (const layerId of visibleIds) { | ||
| const data = moduleLayerFrameData.get(layerDataKey(frameId, layerId)); | ||
| if (!data) continue; | ||
|
|
||
| const tempCanvas = document.createElement("canvas"); | ||
| tempCanvas.width = width; | ||
| tempCanvas.height = height; | ||
| const tempContext = tempCanvas.getContext("2d"); | ||
| if (!tempContext) continue; | ||
| tempContext.putImageData(data, 0, 0); | ||
| context.drawImage(tempCanvas, 0, 0); | ||
| } |
There was a problem hiding this comment.
computeComposite* creates a new temporary canvas for every layer on every call. For multi-layer or animated documents this can be a significant overhead. Consider reusing a single tempCanvas/tempContext per composite call (create once outside the loop and just putImageData each iteration), or caching reusable offscreen resources to reduce allocations.
Introduce map resizing functionality and enhance image editing capabilities with new shape tools. Improve import/export features with support for multiple asset types and a refined user interface. Refactor code for better readability and consistency throughout the project.