552 lines
16 KiB
TypeScript
552 lines
16 KiB
TypeScript
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useGesture } from "@use-gesture/react";
|
|
import { isMobile } from "react-device-detect";
|
|
|
|
import * as PIXI from "pixi.js";
|
|
import { Application, ApplicationRef } from "@pixi/react";
|
|
|
|
import { LoadingContext } from "@/context/Loading";
|
|
import { CanvasContext } from "@/context/Canvas";
|
|
import { HistoryContext } from "@/context/History";
|
|
import { SelectionContext } from "@/context/Selection";
|
|
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 { useBlockData } from "@/hooks/useBlockData";
|
|
import { useClipboard } from "@/hooks/useClipboard";
|
|
|
|
import { useMoveTool } from "@/hooks/tools/move";
|
|
import { useRectangleSelectTool } from "@/hooks/tools/rectangle-select";
|
|
import { useLassoTool } from "@/hooks/tools/lasso";
|
|
import { useMagicWandTool } from "@/hooks/tools/magic-wand";
|
|
import { usePencilTool } from "@/hooks/tools/pencil";
|
|
import { useEraserTool } from "@/hooks/tools/eraser";
|
|
import { usePaintBucketTool } from "@/hooks/tools/paint-bucket";
|
|
import { useShapeTool } from "@/hooks/tools/shape";
|
|
import { useEyedropperTool } from "@/hooks/tools/eyedropper";
|
|
import { useZoomTool } from "@/hooks/tools/zoom";
|
|
|
|
import Blocks from "./Blocks";
|
|
import Cursor from "./Cursor";
|
|
import Selection from "./Selection";
|
|
import Grid from "./Grid";
|
|
import CanvasBorder from "./CanvasBorder";
|
|
|
|
import CursorInformation from "./information/Cursor";
|
|
import CanvasInformation from "./information/Canvas";
|
|
import SelectionBar from "./SelectionBar";
|
|
import FPS from "./information/FPS";
|
|
|
|
// Set scale mode to NEAREST
|
|
PIXI.TextureSource.defaultOptions.scaleMode = "nearest";
|
|
|
|
function Canvas() {
|
|
const { loading } = useContext(LoadingContext);
|
|
const { stageSize, canvasSize, blocks, coords, scale, version, setStageSize, setBlocks, setCoords, setScale, centerCanvas } =
|
|
useContext(CanvasContext);
|
|
const { addHistory, undo, redo } = useContext(HistoryContext);
|
|
const { selectionCoords, selectionLayerBlocks, setSelectionCoords, setSelectionLayerBlocks } = useContext(SelectionContext);
|
|
const { settings } = useContext(SettingsContext);
|
|
const { missingTexture } = useContext(TexturesContext);
|
|
const { isDark } = useContext(ThemeContext);
|
|
const { tool, radius, selectedBlock, setTool } = useContext(ToolContext);
|
|
|
|
const textures = useTextures(version);
|
|
const blockData = useBlockData(version);
|
|
const stageContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const appRef = useRef<ApplicationRef>(null);
|
|
|
|
const mousePosition = useRef<Position>({ x: 0, y: 0 });
|
|
const [mouseCoords, setMouseCoords] = useState<Position>({ x: 0, y: 0 });
|
|
const mouseMovementRef = useRef<Position>({ x: 0, y: 0 });
|
|
const dragging = useRef(false);
|
|
const dragStartCoordsRef = useRef<Position>({ x: 0, y: 0 });
|
|
const hasCenteredRef = useRef(false); // For centering canvas on startup
|
|
|
|
const holdingShiftRef = useRef(false);
|
|
const holdingAltRef = useRef(false);
|
|
const oldToolRef = useRef<Tool>(null);
|
|
const [cssCursor, setCssCursor] = useState("crosshair");
|
|
|
|
const startBlocksRef = useRef<Block[]>([]);
|
|
const startSelectionCoordsRef = useRef<CoordinateArray>([]);
|
|
|
|
const clipboard = useClipboard();
|
|
|
|
const moveTool = useMoveTool(mouseMovementRef.current);
|
|
const rectangleSelectTool = useRectangleSelectTool(mouseCoords, dragStartCoordsRef.current, holdingShiftRef.current);
|
|
const lassoTool = useLassoTool(mouseCoords, holdingAltRef.current);
|
|
const magicWandTool = useMagicWandTool(mouseCoords, holdingShiftRef.current, holdingAltRef.current);
|
|
const pencilTool = usePencilTool(mouseCoords);
|
|
const eraserTool = useEraserTool(mouseCoords);
|
|
const paintBucketTool = usePaintBucketTool(mouseCoords);
|
|
const shapeTool = useShapeTool(mouseCoords, dragStartCoordsRef.current, holdingShiftRef.current);
|
|
const eyedropperTool = useEyedropperTool(mouseCoords);
|
|
const zoomTool = useZoomTool(mousePosition.current, holdingAltRef.current);
|
|
|
|
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 updateCssCursor = useCallback(() => {
|
|
const cursorMapping: Partial<Record<Tool, string>> = {
|
|
hand: dragging.current ? "grabbing" : "grab",
|
|
move: "move",
|
|
zoom: holdingAltRef.current ? "zoom-out" : "zoom-in",
|
|
};
|
|
|
|
setCssCursor(cursorMapping[tool] || "crosshair");
|
|
}, [dragging, holdingAltRef, tool, setCssCursor]);
|
|
|
|
const onToolUse = useCallback(() => {
|
|
const tools: Partial<Record<Tool, { use: () => void }>> = {
|
|
move: moveTool,
|
|
"rectangle-select": rectangleSelectTool,
|
|
lasso: lassoTool,
|
|
pencil: pencilTool,
|
|
eraser: eraserTool,
|
|
shape: shapeTool,
|
|
};
|
|
|
|
// Switch to eraser tool if selected block is air when using pencil
|
|
if (tool === "pencil" && selectedBlock === "air") {
|
|
eraserTool.use();
|
|
return;
|
|
}
|
|
|
|
tools[tool]?.use();
|
|
}, [tool, selectedBlock, moveTool, rectangleSelectTool, lassoTool, pencilTool, eraserTool, shapeTool]);
|
|
|
|
const onPointerMove = 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)),
|
|
};
|
|
|
|
mousePosition.current = {
|
|
x: mouseX,
|
|
y: mouseY,
|
|
};
|
|
if (newMouseCoords.x !== mouseCoords.x || newMouseCoords.y !== mouseCoords.y) {
|
|
setMouseCoords(newMouseCoords);
|
|
}
|
|
|
|
mouseMovementRef.current = {
|
|
x: newMouseCoords.x - oldMouseCoords.x,
|
|
y: newMouseCoords.y - oldMouseCoords.y,
|
|
};
|
|
|
|
if (dragging.current) {
|
|
if (tool === "hand") {
|
|
setCoords((prevCoords) => ({
|
|
x: prevCoords.x + e.movementX,
|
|
y: prevCoords.y + e.movementY,
|
|
}));
|
|
}
|
|
|
|
onToolUse();
|
|
}
|
|
},
|
|
[dragging, coords, scale, tool, mouseCoords, onToolUse, setCoords]
|
|
);
|
|
|
|
const onPointerDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
dragging.current = true;
|
|
|
|
if (isMobile) onPointerMove(e);
|
|
onToolUse();
|
|
updateCssCursor();
|
|
|
|
dragStartCoordsRef.current = mouseCoords;
|
|
startBlocksRef.current = [...blocks];
|
|
startSelectionCoordsRef.current = [...selectionCoords];
|
|
|
|
// Clear selection on click
|
|
if (tool === "rectangle-select") setSelectionCoords([]);
|
|
},
|
|
[onPointerMove, onToolUse, updateCssCursor, mouseCoords, blocks, selectionCoords, tool, setSelectionCoords]
|
|
);
|
|
|
|
const onPointerUp = useCallback(() => {
|
|
dragging.current = false;
|
|
|
|
updateCssCursor();
|
|
|
|
pencilTool.stop();
|
|
eraserTool.stop();
|
|
|
|
// History entries for pencil and eraser
|
|
if (tool === "pencil" || tool === "eraser") {
|
|
// startBlocksRef will mutate if we pass it directly
|
|
const prevBlocks = [...startBlocksRef.current];
|
|
|
|
addHistory(
|
|
tool === "pencil" ? "Pencil" : "Eraser",
|
|
() => setBlocks([...blocks]),
|
|
() => setBlocks([...prevBlocks])
|
|
);
|
|
}
|
|
|
|
if (["rectangle-select", "magic-wand", "lasso"].includes(tool)) {
|
|
// startSelectionCoordsRef will mutate if we pass it directly
|
|
const prevSelection = [...startSelectionCoordsRef.current];
|
|
|
|
addHistory(
|
|
tool === "rectangle-select" ? "Rectangle Select" : tool == "lasso" ? "Lasso" : "Magic Wand",
|
|
() => setSelectionCoords([...selectionCoords]),
|
|
() => setSelectionCoords([...prevSelection])
|
|
);
|
|
}
|
|
}, [updateCssCursor, pencilTool, eraserTool, blocks, tool, addHistory, setBlocks, selectionCoords, setSelectionCoords]);
|
|
|
|
const onWheel = useCallback(
|
|
(e: React.WheelEvent) => {
|
|
e.preventDefault();
|
|
const zoomFactor = e.deltaY > 0 ? -0.1 : 0.1;
|
|
const newScale = Math.min(Math.max(scale + zoomFactor * scale, 0.1), 32);
|
|
|
|
setScale(newScale);
|
|
setCoords({
|
|
x: mousePosition.current.x - ((mousePosition.current.x - coords.x) / scale) * newScale,
|
|
y: mousePosition.current.y - ((mousePosition.current.y - coords.y) / scale) * newScale,
|
|
});
|
|
},
|
|
[scale, setScale, setCoords, mousePosition, coords]
|
|
);
|
|
|
|
const onClick = useCallback(() => {
|
|
const tools: Partial<Record<Tool, { use: () => void }>> = {
|
|
"magic-wand": magicWandTool,
|
|
"paint-bucket": paintBucketTool,
|
|
eyedropper: eyedropperTool,
|
|
zoom: zoomTool,
|
|
};
|
|
|
|
tools[tool]?.use();
|
|
}, [tool, magicWandTool, paintBucketTool, eyedropperTool, zoomTool]);
|
|
|
|
const onKeyDown = useCallback(
|
|
async (e: React.KeyboardEvent) => {
|
|
switch (e.key) {
|
|
case "Escape":
|
|
setBlocks(startBlocksRef.current);
|
|
setSelectionLayerBlocks([]);
|
|
break;
|
|
case "Enter": {
|
|
if (selectionLayerBlocks.length == 0) return;
|
|
|
|
const combinedBlocks = [...blocks, ...selectionLayerBlocks];
|
|
const uniqueBlocks = Array.from(new Map(combinedBlocks.map((block) => [`${block.x},${block.y}`, block])).values());
|
|
|
|
setBlocks(uniqueBlocks.filter((b) => b.name !== "air"));
|
|
setSelectionLayerBlocks([]);
|
|
|
|
addHistory(
|
|
"Move Selection",
|
|
() => setBlocks(uniqueBlocks),
|
|
() => setBlocks(startBlocksRef.current)
|
|
);
|
|
break;
|
|
}
|
|
case " ": // Space
|
|
dragging.current = true;
|
|
oldToolRef.current = tool;
|
|
setTool("hand");
|
|
setCssCursor("grabbing");
|
|
break;
|
|
case "Shift":
|
|
holdingShiftRef.current = true;
|
|
break;
|
|
case "Alt":
|
|
holdingAltRef.current = true;
|
|
if (tool === "zoom") setCssCursor("zoom-out");
|
|
break;
|
|
case "Delete":
|
|
setBlocks((prev) => {
|
|
const deletedBlocks = prev.filter((b) => !selectionCoords.some(([x2, y2]) => x2 === b.x && y2 === b.y));
|
|
addHistory(
|
|
"Delete",
|
|
() => setBlocks(deletedBlocks),
|
|
() => setBlocks(prev)
|
|
);
|
|
|
|
return deletedBlocks;
|
|
});
|
|
break;
|
|
case "a": {
|
|
if (!e.ctrlKey) return;
|
|
e.preventDefault();
|
|
|
|
const newSelection: CoordinateArray = [];
|
|
|
|
for (let x = canvasSize.minX; x < canvasSize.maxX; x++) {
|
|
for (let y = canvasSize.minY; y < canvasSize.maxY; y++) {
|
|
newSelection.push([x, y]);
|
|
}
|
|
}
|
|
|
|
setSelectionCoords((prev) => {
|
|
const prevSelection = [...prev];
|
|
addHistory(
|
|
"Select All",
|
|
() => setSelectionCoords(newSelection),
|
|
() => setSelectionCoords(prevSelection)
|
|
);
|
|
|
|
return newSelection;
|
|
});
|
|
break;
|
|
}
|
|
case "z":
|
|
if (e.ctrlKey) undo();
|
|
else setTool("zoom");
|
|
break;
|
|
case "y":
|
|
if (!e.ctrlKey) return;
|
|
redo();
|
|
break;
|
|
case "c":
|
|
if (!e.ctrlKey) return;
|
|
clipboard.copy();
|
|
break;
|
|
case "v":
|
|
if (e.ctrlKey) clipboard.paste();
|
|
else setTool("move");
|
|
break;
|
|
case "h":
|
|
setTool("hand");
|
|
break;
|
|
case "m":
|
|
setTool("rectangle-select");
|
|
break;
|
|
case "l":
|
|
setTool("lasso");
|
|
break;
|
|
case "w":
|
|
setTool("magic-wand");
|
|
break;
|
|
case "b":
|
|
setTool("pencil");
|
|
break;
|
|
case "e":
|
|
setTool("eraser");
|
|
break;
|
|
case "g":
|
|
setTool("paint-bucket");
|
|
break;
|
|
case "i":
|
|
setTool("eyedropper");
|
|
break;
|
|
case "ArrowRight": {
|
|
// Debug key combination
|
|
if (!e.altKey && !e.shiftKey) return;
|
|
|
|
const newBlocks: Block[] = [];
|
|
|
|
Object.keys(blockData).forEach((name, index) => {
|
|
const x = index % 16;
|
|
const y = Math.floor(index / 16);
|
|
newBlocks.push({ name, x, y });
|
|
});
|
|
|
|
setBlocks(newBlocks);
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
[
|
|
tool,
|
|
selectionCoords,
|
|
canvasSize,
|
|
blockData,
|
|
blocks,
|
|
selectionLayerBlocks,
|
|
clipboard,
|
|
setBlocks,
|
|
setSelectionCoords,
|
|
setSelectionLayerBlocks,
|
|
setTool,
|
|
addHistory,
|
|
redo,
|
|
undo,
|
|
]
|
|
);
|
|
|
|
const onKeyUp = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
switch (e.key) {
|
|
case " ": // Space
|
|
dragging.current = false;
|
|
setCssCursor("grab");
|
|
|
|
if (!oldToolRef.current) return;
|
|
setTool(oldToolRef.current);
|
|
break;
|
|
case "Shift":
|
|
holdingShiftRef.current = false;
|
|
break;
|
|
case "Alt":
|
|
holdingAltRef.current = false;
|
|
if (tool === "zoom") setCssCursor("zoom-in");
|
|
break;
|
|
}
|
|
},
|
|
[setCssCursor, setTool, tool]
|
|
);
|
|
|
|
// Tool cursor handler
|
|
useEffect(() => {
|
|
const cursorMapping: Partial<Record<Tool, string>> = {
|
|
hand: "grab",
|
|
move: "move",
|
|
zoom: "zoom-in",
|
|
};
|
|
|
|
setCssCursor(cursorMapping[tool] || "crosshair");
|
|
}, [tool]);
|
|
|
|
// Resize canvas handler
|
|
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();
|
|
}, [loading, setStageSize]);
|
|
|
|
// Window events handler
|
|
useEffect(() => {
|
|
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
e.preventDefault();
|
|
};
|
|
|
|
window.addEventListener("beforeunload", onBeforeUnload);
|
|
return () => {
|
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
};
|
|
}, [onKeyDown, onKeyUp]);
|
|
|
|
useGesture(
|
|
{
|
|
onPinch: ({ offset: [d] }) => {
|
|
setScale(d);
|
|
setCoords({
|
|
x: mousePosition.current.x - ((mousePosition.current.x - coords.x) / scale) * d,
|
|
y: mousePosition.current.y - ((mousePosition.current.y - coords.y) / scale) * d,
|
|
});
|
|
},
|
|
},
|
|
{
|
|
target: stageContainerRef,
|
|
pinch: { threshold: 0.1, scaleBounds: { min: 0.1, max: 32 }, pinchOnWheel: false },
|
|
eventOptions: { passive: false },
|
|
}
|
|
);
|
|
|
|
// Center canvas on startup
|
|
useEffect(() => {
|
|
if (hasCenteredRef.current || loading) return;
|
|
centerCanvas();
|
|
|
|
hasCenteredRef.current = true;
|
|
}, [centerCanvas]);
|
|
|
|
if (loading) return null;
|
|
|
|
return (
|
|
// Relative for canvas information
|
|
<div className="relative">
|
|
{/* Wrap application around div to use events */}
|
|
<div
|
|
ref={stageContainerRef}
|
|
tabIndex={0}
|
|
onClick={onClick}
|
|
onKeyDown={onKeyDown}
|
|
onKeyUp={onKeyUp}
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
onWheel={onWheel}
|
|
style={{ cursor: cssCursor }}
|
|
className="size-full"
|
|
>
|
|
<Application resizeTo={stageContainerRef} ref={appRef} backgroundAlpha={0} className="touch-none select-none">
|
|
<Blocks blocks={visibleBlocks} missingTexture={missingTexture} textures={textures} coords={coords} scale={scale} version={version} />
|
|
{/* Selection layer */}
|
|
<Blocks
|
|
isSelectionLayer
|
|
blocks={selectionLayerBlocks}
|
|
missingTexture={missingTexture}
|
|
textures={textures}
|
|
coords={coords}
|
|
scale={scale}
|
|
version={version}
|
|
/>
|
|
|
|
<pixiContainer x={coords.x} y={coords.y} scale={scale}>
|
|
{settings.canvasBorder && <CanvasBorder canvasSize={canvasSize} isDark={isDark} />}
|
|
<Cursor mouseCoords={mouseCoords} radius={radius} isDark={isDark} />
|
|
<Selection selection={selectionCoords} isDark={isDark} />
|
|
</pixiContainer>
|
|
|
|
{settings.grid && (
|
|
<pixiContainer filters={[new PIXI.AlphaFilter({ alpha: 0.1 })]}>
|
|
<Grid stageSize={stageSize} coords={coords} scale={scale} isDark={isDark} />
|
|
</pixiContainer>
|
|
)}
|
|
</Application>
|
|
</div>
|
|
|
|
<CursorInformation mouseCoords={mouseCoords} />
|
|
<CanvasInformation />
|
|
|
|
{settings.fpsCounter && <FPS fps={appRef.current?.getApplication()?.ticker.FPS} />}
|
|
|
|
<SelectionBar startBlocks={startBlocksRef.current} startSelectionCoords={startSelectionCoordsRef.current} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Canvas;
|