import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import * as PIXI from "pixi.js"; import { Container, Stage } from "@pixi/react"; import { CanvasContext } from "@/context/Canvas"; import { SettingsContext } from "@/context/Settings"; import { TexturesContext } from "@/context/Textures"; import { ThemeContext } from "@/context/Theme"; import { ToolContext } from "@/context/Tool"; import { useTextures } from "@/hooks/useTextures"; import Blocks from "./Blocks"; import Cursor from "./Cursor"; import SelectionBox from "./SelectionBox"; import Grid from "./Grid"; import CanvasBorder from "./CanvasBorder"; import CursorInformation from "./information/Cursor"; import CanvasInformation from "./information/Canvas"; import welcomeBlocksData from "@/data/welcome.json"; // Set scale mode to NEAREST PIXI.settings.SCALE_MODE = PIXI.SCALE_MODES.NEAREST; function Canvas() { const { stageSize, canvasSize, blocks, coords, scale, version, setStageSize, setBlocks, setCoords, setScale } = useContext(CanvasContext); const { settings } = useContext(SettingsContext); const { missingTexture, solidTextures } = useContext(TexturesContext); const { isDark } = useContext(ThemeContext); const { tool, radius, selectedBlock, selectionCoords, cssCursor, setTool, setSelectedBlock, setSelectionCoords, setCssCursor } = useContext(ToolContext); const textures = useTextures(version); const stageContainerRef = useRef(null); const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); const [mouseCoords, setMouseCoords] = useState({ x: 0, y: 0 }); const mouseMovementRef = useRef(); const [dragging, setDragging] = useState(false); const dragStartCoordsRef = useRef(); const holdingAltRef = useRef(false); const oldToolRef = useRef(); const selectionCoordsRef = useRef(selectionCoords); const visibleArea = useMemo(() => { const blockSize = 16 * scale; const visibleWidthBlocks = Math.ceil(stageSize.width / blockSize); const visibleHeightBlocks = Math.ceil(stageSize.height / blockSize); const startX = Math.floor(-coords.x / blockSize); const startY = Math.floor(-coords.y / blockSize); return { startX, startY, endX: startX + visibleWidthBlocks + 1, endY: startY + visibleHeightBlocks + 1, }; }, [coords, scale, stageSize]); const visibleBlocks = useMemo(() => { return blocks.filter( (block) => block.x >= visibleArea.startX && block.x < visibleArea.endX && block.y >= visibleArea.startY && block.y < visibleArea.endY ); }, [blocks, visibleArea]); const zoom = useCallback( (newScale: number) => { setScale(newScale); setCoords({ x: mousePosition.x - ((mousePosition.x - coords.x) / scale) * newScale, y: mousePosition.y - ((mousePosition.y - coords.y) / scale) * newScale, }); }, [coords, mousePosition, scale, setCoords, setScale] ); const updateCssCursor = useCallback(() => { const cursorMapping: Partial> = { hand: dragging ? "grab" : "grabbing", zoom: holdingAltRef.current ? "zoom-out" : "zoom-in", }; setCssCursor(cursorMapping[tool] || "crosshair"); }, [dragging, holdingAltRef, tool, setCssCursor]); const onToolUse = useCallback(() => { // If number is odd, cursor is in the center // if number is even, cursor is in the top-left corner const getRadiusPosition = (): Position => { const halfSize = Math.floor(radius / 2); const x = mouseCoords.x - (radius % 2 === 0 ? 0 : halfSize); const y = mouseCoords.y - (radius % 2 === 0 ? 0 : halfSize); return { x, y }; }; // Check if a block is within the selection const isInSelection = (x: number, y: number): boolean => { if (selectionCoords.length !== 0) { return selectionCoords.some(([x2, y2]) => x2 === x && y2 === y); } return false; }; const eraseTool = () => { // Fixes Infinity and NaN errors when no blocks are present if (blocks.length == 1) return; const radiusPosition = getRadiusPosition(); const updated = blocks.filter((block) => { const withinRadius = block.x >= radiusPosition.x && block.x < radiusPosition.x + radius && block.y >= radiusPosition.y && block.y < radiusPosition.y + radius; return !withinRadius || !isInSelection(block.x, block.y); }); setBlocks(updated); }; switch (tool) { case "move": { const mouseMovement = mouseMovementRef.current; if (!mouseMovement) return; // Increase each coordinate in the selection by the mouse movement setSelectionCoords((prev) => prev.map(([x, y]) => [x + mouseMovement.x, y + mouseMovement.y])); // Increase each block in the selection by the mouse movement setBlocks((prev) => prev.map((block) => { if (isInSelection(block.x, block.y)) { return { ...block, x: block.x + mouseMovement.x, y: block.y + mouseMovement.y, }; } return block; }) ); break; } case "lasso": { setSelectionCoords((prev) => { const radiusPosition = getRadiusPosition(); const radiusCoords: CoordinateArray = []; for (let x = 0; x < radius; x++) { for (let y = 0; y < radius; y++) { const tileX = radiusPosition.x + x; const tileY = radiusPosition.y + y; const exists = prev.some(([x2, y2]) => x2 === tileX && y2 === tileY); if ((holdingAltRef.current && exists) || !exists) radiusCoords.push([tileX, tileY]); } } if (holdingAltRef.current) { return prev.filter(([x, y]) => !radiusCoords.some(([x2, y2]) => x2 === x && y2 === y)); } else { return [...prev, ...radiusCoords]; } }); break; } case "pencil": { if (selectedBlock == "air") { eraseTool(); break; } const radiusPosition = getRadiusPosition(); const radiusBlocks: Block[] = []; for (let x = 0; x < radius; x++) { for (let y = 0; y < radius; y++) { const tileX = radiusPosition.x + x; const tileY = radiusPosition.y + y; // Only add blocks within the selection if (isInSelection(tileX, tileY)) { radiusBlocks.push({ name: selectedBlock, x: tileX, y: tileY, }); } } } const mergedBlocks = blocks.filter((block) => { return !radiusBlocks.some((newBlock) => block.x === newBlock.x && block.y === newBlock.y); }); setBlocks([...mergedBlocks, ...radiusBlocks]); break; } case "eraser": { eraseTool(); break; } } }, [tool, mouseCoords, selectedBlock, blocks, radius, selectionCoords, setSelectionCoords, setBlocks]); const onMouseMove = useCallback( (e: React.MouseEvent) => { if (!stageContainerRef.current) return; const oldMouseCoords = mouseCoords; const rect = stageContainerRef.current.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const newMouseCoords = { x: Math.floor((mouseX - coords.x) / (16 * scale)), y: Math.floor((mouseY - coords.y) / (16 * scale)), }; setMousePosition({ x: mouseX, y: mouseY, }); setMouseCoords(newMouseCoords); mouseMovementRef.current = { x: newMouseCoords.x - oldMouseCoords.x, y: newMouseCoords.y - oldMouseCoords.y, }; if (dragging) { switch (tool) { case "hand": setCoords((prevCoords) => ({ x: prevCoords.x + e.movementX, y: prevCoords.y + e.movementY, })); break; case "rectangle-select": { const dragStartCoords = dragStartCoordsRef.current; if (!dragStartCoords) return; setSelectionCoords(() => { const newSelection: CoordinateArray = []; const startX = Math.min(dragStartCoords.x, mouseCoords.x); const endX = Math.max(dragStartCoords.x, mouseCoords.x); const startY = Math.min(dragStartCoords.y, mouseCoords.y); const endY = Math.max(dragStartCoords.y, mouseCoords.y); const isRadiusEven = radius % 2 == 0; for (let x = startX; x < endX + (isRadiusEven ? radius : radius - 1); x++) { for (let y = startY; y < endY + (isRadiusEven ? radius : radius - 1); y++) { newSelection.push([x, y]); } } return newSelection; }); break; } } onToolUse(); } }, [dragging, coords, scale, tool, mouseCoords, onToolUse, setCoords, setSelectionCoords, radius] ); const onMouseDown = useCallback(() => { setDragging(true); onToolUse(); updateCssCursor(); dragStartCoordsRef.current = mouseCoords; // Clear selection on click if (tool === "rectangle-select") setSelectionCoords([]); }, [onToolUse, updateCssCursor, mouseCoords, tool, setSelectionCoords]); const onMouseUp = useCallback(() => { setDragging(false); updateCssCursor(); }, [updateCssCursor]); const onWheel = useCallback( (e: React.WheelEvent) => { e.preventDefault(); const scaleChange = e.deltaY > 0 ? -0.1 : 0.1; const newScale = Math.min(Math.max(scale + scaleChange * scale, 0.1), 32); zoom(newScale); }, [scale, zoom] ); const onClick = useCallback(() => { switch (tool) { case "magic-wand": { const visited = new Set(); const result: CoordinateArray = []; const startBlock = blocks.find((block) => block.x === mouseCoords.x && block.y === mouseCoords.y); // Return if the block is not found if (!startBlock) return result; function depthFirstSearch(block: Block) { const key = `${block.x},${block.y}`; if (visited.has(key)) return; visited.add(key); result.push([block.x, block.y]); // Directions for adjacent blocks (up, down, left, right) const directions = [ { dx: 0, dy: 1 }, { dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: -1, dy: 0 }, ]; for (const { dx, dy } of directions) { const newX = block.x + dx; const newY = block.y + dy; const adjacentBlock = blocks.find((b) => b.x === newX && b.y === newY && b.name === block.name); if (adjacentBlock) { depthFirstSearch({ ...block, x: newX, y: newY }); } } } depthFirstSearch({ name: startBlock.name, x: mouseCoords.x, y: mouseCoords.y }); setSelectionCoords((prev) => { if (holdingAltRef.current) { // If holding alt, remove new magic wand selection return prev.filter(([x, y]) => !result.some(([x2, y2]) => x2 === x && y2 === y)); } // If not holding alt or shift, replace the existing selection with the magic wand selection return result; }); break; } case "eyedropper": { const mouseBlock = blocks.find((block) => block.x === mouseCoords.x && block.y === mouseCoords.y); if (mouseBlock) setSelectedBlock(mouseBlock.name); break; } case "zoom": { const scaleChange = holdingAltRef.current ? -0.1 : 0.1; const newScale = Math.min(Math.max(scale + scaleChange * scale, 0.1), 32); zoom(newScale); break; } default: break; } }, [tool, holdingAltRef, scale, mouseCoords, blocks, setSelectionCoords, setSelectedBlock, zoom]); const onKeyDown = (e: KeyboardEvent) => { switch (e.key) { case " ": // Space setDragging(true); oldToolRef.current = tool; setTool("hand"); setCssCursor("grabbing"); break; case "Alt": holdingAltRef.current = true; if (tool === "zoom") setCssCursor("zoom-out"); break; case "Delete": { setBlocks((prev) => prev.filter((b) => !selectionCoordsRef.current.some(([x2, y2]) => x2 === b.x && y2 === b.y))); break; } case "1": setTool("hand"); break; case "2": setTool("move"); break; case "3": setTool("rectangle-select"); break; case "4": setTool("lasso"); break; case "5": setTool("magic-wand"); break; case "6": setTool("pencil"); break; case "7": setTool("eraser"); break; case "8": setTool("eyedropper"); break; case "9": setTool("zoom"); break; } }; const onKeyUp = (e: KeyboardEvent) => { switch (e.key) { case " ": // Space if (!oldToolRef.current) return; setDragging(false); setCssCursor("grab"); setTool(oldToolRef.current); break; case "Alt": holdingAltRef.current = false; setCssCursor("zoom-in"); break; } }; useEffect(() => { selectionCoordsRef.current = selectionCoords; }, [selectionCoords]); useEffect(() => { const container = stageContainerRef.current; if (!container) return; const resizeCanvas = () => { setStageSize({ width: container.offsetWidth, height: container.offsetHeight, }); }; const resizeObserver = new ResizeObserver(resizeCanvas); resizeObserver.observe(container); resizeCanvas(); return () => resizeObserver.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [stageContainerRef]); useEffect(() => { setBlocks(welcomeBlocksData); window.addEventListener("keydown", onKeyDown); window.addEventListener("keyup", onKeyUp); window.addEventListener("beforeunload", (e) => { e.preventDefault(); }); return () => { window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keyup", onKeyUp); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
{settings.canvasBorder && } {settings.grid && ( )}
); } export default Canvas;