feat: add bare-bones cursor and rectangle select tool

they do not currently move blocks
This commit is contained in:
trafficlunar 2025-01-14 18:02:42 +00:00
parent 246afd9120
commit 776ff73bf1
8 changed files with 169 additions and 36 deletions

View file

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@pixi/graphics-smooth": "^1.1.1",
"@pixi/react": "^7.1.2",
"@pixi/tilemap": "4.1.0",
"@radix-ui/react-checkbox": "^1.1.3",

View file

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@pixi/graphics-smooth':
specifier: ^1.1.1
version: 1.1.1(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))(@pixi/graphics@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))(@pixi/sprite@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))))
'@pixi/react':
specifier: ^7.1.2
version: 7.1.2(rbk2pa5kjfujbi7gk4qxenqa5a)
@ -690,6 +693,13 @@ packages:
peerDependencies:
'@pixi/core': 7.4.2
'@pixi/graphics-smooth@1.1.1':
resolution: {integrity: sha512-9xIFWZhHGEb6KCnyWL6TVPYG/QkF0YDM/yDU5EvjTQbaj/1cITrXtI5P3tBkB5H0DQi+8J8/QS38MjfqNEJAYQ==}
peerDependencies:
'@pixi/core': ^7.2.0
'@pixi/display': ^7.2.0
'@pixi/graphics': ^7.2.0
'@pixi/graphics@7.4.2':
resolution: {integrity: sha512-jH4/Tum2RqWzHGzvlwEr7HIVduoLO57Ze705N2zQPkUD57TInn5911aGUeoua7f/wK8cTLGzgB9BzSo2kTdcHw==}
peerDependencies:
@ -3499,6 +3509,12 @@ snapshots:
dependencies:
'@pixi/core': 7.4.2
'@pixi/graphics-smooth@1.1.1(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))(@pixi/graphics@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))(@pixi/sprite@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))))':
dependencies:
'@pixi/core': 7.4.2
'@pixi/display': 7.4.2(@pixi/core@7.4.2)
'@pixi/graphics': 7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))(@pixi/sprite@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2)))
'@pixi/graphics@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))(@pixi/sprite@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2)))':
dependencies:
'@pixi/core': 7.4.2

View file

@ -13,6 +13,7 @@ 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";
@ -40,6 +41,7 @@ function Canvas() {
const [holdingAlt, setHoldingAlt] = useState(false);
const [oldTool, setOldTool] = useState<Tool>("hand");
const [selectionBoxBounds, setSelectionBoxBounds] = useState<BoundingBox>({ minX: 0, minY: 0, maxX: 0, maxY: 0 });
const visibleArea = useMemo(() => {
const blockSize = 16 * scale;
@ -64,14 +66,15 @@ function Canvas() {
);
}, [blocks, visibleArea]);
const zoomToMousePosition = useCallback(
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]
[coords, mousePosition, scale, setCoords, setScale]
);
const updateCssCursor = useCallback(() => {
@ -148,39 +151,71 @@ function Canvas() {
const onMouseMove = useCallback(
(e: React.MouseEvent) => {
if (dragging) {
if (tool === "hand") {
setCoords((prevCoords) => ({
x: prevCoords.x + e.movementX,
y: prevCoords.y + e.movementY,
}));
}
onToolUse();
}
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({
x: Math.floor((mouseX - coords.x) / (16 * scale)),
y: Math.floor((mouseY - coords.y) / (16 * scale)),
});
setMouseCoords(newMouseCoords);
if (dragging) {
switch (tool) {
case "hand":
setCoords((prevCoords) => ({
x: prevCoords.x + e.movementX,
y: prevCoords.y + e.movementY,
}));
break;
case "move": {
setSelectionBoxBounds((prev) => ({
minX: prev.minX + (newMouseCoords.x - oldMouseCoords.x),
minY: prev.minY + (newMouseCoords.y - oldMouseCoords.y),
maxX: prev.maxX + (newMouseCoords.x - oldMouseCoords.x),
maxY: prev.maxY + (newMouseCoords.y - oldMouseCoords.y),
}));
break;
}
case "rectangle-select":
setSelectionBoxBounds((prev) => ({
...prev,
maxX: mouseCoords.x + 1,
maxY: mouseCoords.y + 1,
}));
break;
}
onToolUse();
}
},
[dragging, coords, scale, tool, onToolUse, setCoords]
[dragging, coords, scale, tool, onToolUse, setCoords, setSelectionBoxBounds, mouseCoords]
);
const onMouseDown = useCallback(() => {
setDragging(true);
onToolUse();
updateCssCursor();
}, [onToolUse, updateCssCursor]);
if (tool == "rectangle-select") {
setSelectionBoxBounds({
minX: mouseCoords.x,
minY: mouseCoords.y,
maxX: mouseCoords.x,
maxY: mouseCoords.y,
});
}
}, [onToolUse, updateCssCursor, tool, setSelectionBoxBounds, mouseCoords]);
const onMouseUp = useCallback(() => {
setDragging(false);
@ -192,10 +227,9 @@ function Canvas() {
e.preventDefault();
const scaleChange = e.deltaY > 0 ? -0.1 : 0.1;
const newScale = Math.min(Math.max(scale + scaleChange * scale, 0.1), 32);
setScale(newScale);
zoomToMousePosition(newScale);
zoom(newScale);
},
[scale, zoomToMousePosition, setScale]
[scale, zoom]
);
const onClick = useCallback(() => {
@ -208,15 +242,14 @@ function Canvas() {
case "zoom": {
const scaleChange = holdingAlt ? -0.1 : 0.1;
const newScale = Math.min(Math.max(scale + scaleChange * scale, 0.1), 32);
setScale(newScale);
zoomToMousePosition(newScale);
zoom(newScale);
break;
}
default:
break;
}
}, [tool, holdingAlt, scale, mouseCoords, blocks, zoomToMousePosition, setScale, setSelectedBlock]);
}, [tool, holdingAlt, scale, mouseCoords, blocks, setSelectedBlock, zoom]);
const onKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
@ -230,15 +263,21 @@ function Canvas() {
setTool("hand");
break;
case "2":
setTool("pencil");
setTool("move");
break;
case "3":
setTool("eraser");
setTool("rectangle-select");
break;
case "4":
setTool("eyedropper");
setTool("pencil");
break;
case "5":
setTool("eraser");
break;
case "6":
setTool("eyedropper");
break;
case "7":
setTool("zoom");
break;
case "Alt":
@ -327,6 +366,7 @@ function Canvas() {
<Container x={coords.x} y={coords.y} scale={scale}>
{settings.canvasBorder && <CanvasBorder canvasSize={canvasSize} isDark={isDark} />}
<Cursor mouseCoords={mouseCoords} radius={radius} isDark={isDark} />
<SelectionBox bounds={selectionBoxBounds} coords={coords} scale={scale} isDark={isDark} />
</Container>
{settings.grid && (

View file

@ -1,7 +1,7 @@
import { Graphics } from "@pixi/react";
interface Props {
canvasSize: CanvasSize;
canvasSize: BoundingBox;
isDark: boolean;
}

View file

@ -0,0 +1,52 @@
import { useEffect, useRef } from "react";
import { useApp } from "@pixi/react";
import { DashLineShader, SmoothGraphics } from "@pixi/graphics-smooth";
interface Props {
bounds: BoundingBox;
coords: Position;
scale: number;
isDark: boolean;
}
const shader = new DashLineShader({ dash: 8, gap: 5 });
function SelectionBox({ bounds, coords, scale, isDark }: Props) {
const app = useApp();
const selectionRef = useRef<SmoothGraphics>();
const drawSelection = () => {
if (!selectionRef.current) return;
const graphics = selectionRef.current;
graphics.clear();
graphics.lineStyle({ width: 1, color: isDark ? 0xffffff : 0x000000, shader });
graphics.drawRect(
bounds.minX * 16 * scale,
bounds.minY * 16 * scale,
(bounds.maxX - bounds.minX) * 16 * scale,
(bounds.maxY - bounds.minY) * 16 * scale
);
};
useEffect(() => {
const graphics = new SmoothGraphics();
selectionRef.current = graphics;
drawSelection();
app.stage.addChild(graphics);
}, []);
useEffect(() => {
if (!selectionRef.current) return;
const graphics = selectionRef.current;
graphics.x = coords.x;
graphics.y = coords.y;
drawSelection();
}, [coords]);
useEffect(drawSelection, [bounds]);
return null;
}
export default SelectionBox;

View file

@ -1,5 +1,5 @@
import { useContext } from "react";
import { EraserIcon, HandIcon, PencilIcon, PipetteIcon, ZoomInIcon } from "lucide-react";
import { EraserIcon, HandIcon, MousePointer2Icon, PencilIcon, PipetteIcon, SquareDashedIcon, ZoomInIcon } from "lucide-react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@ -33,6 +33,30 @@ function Toolbar() {
</TooltipContent>
</Tooltip>
{/* Move */}
<Tooltip delayDuration={0}>
<TooltipTrigger>
<ToggleGroupItem value="move" className="!p-0 !h-8 !min-w-8">
<MousePointer2Icon />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={10}>
<p>Move (2)</p>
</TooltipContent>
</Tooltip>
{/* Rectangle Select */}
<Tooltip delayDuration={0}>
<TooltipTrigger>
<ToggleGroupItem value="rectangle-select" className="!p-0 !h-8 !min-w-8">
<SquareDashedIcon />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={10}>
<p>Rectangle Select (3)</p>
</TooltipContent>
</Tooltip>
{/* Pencil */}
<Tooltip delayDuration={0}>
<TooltipTrigger>
@ -41,7 +65,7 @@ function Toolbar() {
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={10}>
<p>Pencil (2)</p>
<p>Pencil (4)</p>
</TooltipContent>
</Tooltip>
@ -53,7 +77,7 @@ function Toolbar() {
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={10}>
<p>Eraser (3)</p>
<p>Eraser (5)</p>
</TooltipContent>
</Tooltip>
@ -65,7 +89,7 @@ function Toolbar() {
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={10}>
<p>Eyedropper (4)</p>
<p>Eyedropper (6)</p>
</TooltipContent>
</Tooltip>
@ -77,7 +101,7 @@ function Toolbar() {
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={10}>
<p>Zoom (5)</p>
<p>Zoom (7)</p>
</TooltipContent>
</Tooltip>

View file

@ -2,7 +2,7 @@ import React, { createContext, ReactNode, useMemo, useState } from "react";
interface Context {
stageSize: Dimension;
canvasSize: CanvasSize;
canvasSize: BoundingBox;
blocks: Block[];
coords: Position;
scale: number;

4
src/types.d.ts vendored
View file

@ -10,7 +10,7 @@ interface Dimension {
height: number;
}
interface CanvasSize {
interface BoundingBox {
minX: number;
minY: number;
maxX: number;
@ -21,7 +21,7 @@ interface Block extends Position {
name: string;
}
type Tool = "hand" | "pencil" | "eraser" | "eyedropper" | "zoom";
type Tool = "hand" | "move" | "rectangle-select" | "pencil" | "eraser" | "eyedropper" | "zoom";
interface Settings {
grid: boolean;