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" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@pixi/graphics-smooth": "^1.1.1",
"@pixi/react": "^7.1.2", "@pixi/react": "^7.1.2",
"@pixi/tilemap": "4.1.0", "@pixi/tilemap": "4.1.0",
"@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.3",

View file

@ -8,6 +8,9 @@ importers:
.: .:
dependencies: 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': '@pixi/react':
specifier: ^7.1.2 specifier: ^7.1.2
version: 7.1.2(rbk2pa5kjfujbi7gk4qxenqa5a) version: 7.1.2(rbk2pa5kjfujbi7gk4qxenqa5a)
@ -690,6 +693,13 @@ packages:
peerDependencies: peerDependencies:
'@pixi/core': 7.4.2 '@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': '@pixi/graphics@7.4.2':
resolution: {integrity: sha512-jH4/Tum2RqWzHGzvlwEr7HIVduoLO57Ze705N2zQPkUD57TInn5911aGUeoua7f/wK8cTLGzgB9BzSo2kTdcHw==} resolution: {integrity: sha512-jH4/Tum2RqWzHGzvlwEr7HIVduoLO57Ze705N2zQPkUD57TInn5911aGUeoua7f/wK8cTLGzgB9BzSo2kTdcHw==}
peerDependencies: peerDependencies:
@ -3499,6 +3509,12 @@ snapshots:
dependencies: dependencies:
'@pixi/core': 7.4.2 '@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)))': '@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: dependencies:
'@pixi/core': 7.4.2 '@pixi/core': 7.4.2

View file

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

View file

@ -1,7 +1,7 @@
import { Graphics } from "@pixi/react"; import { Graphics } from "@pixi/react";
interface Props { interface Props {
canvasSize: CanvasSize; canvasSize: BoundingBox;
isDark: boolean; 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 { 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 { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@ -33,6 +33,30 @@ function Toolbar() {
</TooltipContent> </TooltipContent>
</Tooltip> </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 */} {/* Pencil */}
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger> <TooltipTrigger>
@ -41,7 +65,7 @@ function Toolbar() {
</ToggleGroupItem> </ToggleGroupItem>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" sideOffset={10}> <TooltipContent side="right" sideOffset={10}>
<p>Pencil (2)</p> <p>Pencil (4)</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -53,7 +77,7 @@ function Toolbar() {
</ToggleGroupItem> </ToggleGroupItem>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" sideOffset={10}> <TooltipContent side="right" sideOffset={10}>
<p>Eraser (3)</p> <p>Eraser (5)</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -65,7 +89,7 @@ function Toolbar() {
</ToggleGroupItem> </ToggleGroupItem>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" sideOffset={10}> <TooltipContent side="right" sideOffset={10}>
<p>Eyedropper (4)</p> <p>Eyedropper (6)</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -77,7 +101,7 @@ function Toolbar() {
</ToggleGroupItem> </ToggleGroupItem>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" sideOffset={10}> <TooltipContent side="right" sideOffset={10}>
<p>Zoom (5)</p> <p>Zoom (7)</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View file

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

4
src/types.d.ts vendored
View file

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