diff --git a/src/App.tsx b/src/App.tsx
index e39a994..82f0ac3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,11 +3,13 @@ import ErrorBoundary from "./components/ErrorBoundary/ErrorBoundary";
import css from "./App.module.css";
import Menu from "./components/Menu/Menu";
import { useView } from "./hooks/useView";
+import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
const ERROR_FALLBACK_COMPONENT =
diff --git a/src/components/Menu/createMenuConfig.ts b/src/components/Menu/createMenuConfig.ts
index 3a25c9e..e4727b7 100644
--- a/src/components/Menu/createMenuConfig.ts
+++ b/src/components/Menu/createMenuConfig.ts
@@ -88,6 +88,14 @@ export const createMenuConfig: CreateMenuConfig = (
},
],
+ Display: [
+ {
+ type: "button",
+ label: canvas.showVertices ? "hide vertices" : "show vertices",
+ onClick: () => dispatch(canvasActions.toggleShowVertices()),
+ },
+ ],
+
Export: [
{
type: "button",
diff --git a/src/components/SvgCanvas/SvgCanvas.tsx b/src/components/SvgCanvas/SvgCanvas.tsx
index 68f7aef..bdabc4e 100644
--- a/src/components/SvgCanvas/SvgCanvas.tsx
+++ b/src/components/SvgCanvas/SvgCanvas.tsx
@@ -1,5 +1,5 @@
import css from "./SvgCanvas.module.css";
-import React, { useMemo } from "react";
+import React, { useMemo, useState } from "react";
import SvgCanvasCustomCursor from "./SvgCanvasCustomCursor/SvgCanvasCustomCursor";
import { canvasActions } from "../../store/canvasSlice";
@@ -10,6 +10,7 @@ import { useDispatch } from "react-redux";
import { useDelaunayWorker } from "../../hooks/useDelaunayWorker";
import { MODES, type ModeKey } from "../../config/modes";
import { useEditorPosition } from "../../hooks/useEditorPosition";
+import { useCanvasTransform } from "../../hooks/useCanvasTransform";
export type OnClickFn = React.SVGProps
["onClick"];
export type OnDragFn = React.SVGProps["onPointerMove"];
@@ -17,6 +18,19 @@ export type OnDragFn = React.SVGProps["onPointerMove"];
const SvgCanvas = () => {
const { svgRef, isLoading } = useDelaunayWorker();
const { editorPosition } = useEditorPosition();
+ const [hoverPoint, setHoverPoint] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+ const {
+ transformStyle,
+ isPanning,
+ handleWheel,
+ handleMouseDown,
+ handleMouseMove: handleTransformMouseMove,
+ handleMouseUp,
+ transformToCanvasCoords,
+ } = useCanvasTransform();
const dispatch = useDispatch();
const { mode } = useCanvas();
@@ -26,10 +40,14 @@ const SvgCanvas = () => {
const handleCanvasEvent = (
e: React.MouseEvent,
) => {
+ // Don't handle canvas events while panning
+ if (isPanning) return;
+
// Get the coordinates of the click relative to the SVG element
const rect = e.currentTarget.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const y = e.clientY - rect.top;
+ const canvasCoords = transformToCanvasCoords(e.clientX, e.clientY, rect);
+ const x = canvasCoords.x;
+ const y = canvasCoords.y;
// Edit mode is momentary. It removes the polygon that was clicked using CSS,
// but does not modify the state.
@@ -48,6 +66,7 @@ const SvgCanvas = () => {
// Actions for each mode
const actions = {
draw: canvasActions.addPoint([x, y]),
+ select: null, // Handle selection separately
eraseVertices: canvasActions.erasePoints({
x,
y,
@@ -56,9 +75,54 @@ const SvgCanvas = () => {
eraseFaces: null,
} as const satisfies Record;
+ // Handle point selection separately
+ if (mode === "select") {
+ const target = e.target as SVGElement;
+ if (target.tagName === "circle" && target.dataset.pointIndex) {
+ const pointIndex = parseInt(target.dataset.pointIndex, 10);
+ if (e.ctrlKey || e.metaKey) {
+ dispatch(canvasActions.togglePointSelection(pointIndex));
+ } else {
+ dispatch(canvasActions.clearSelection());
+ dispatch(canvasActions.selectPoint(pointIndex));
+ }
+ } else if (!e.ctrlKey && !e.metaKey) {
+ dispatch(canvasActions.clearSelection());
+ }
+ return;
+ }
+
dispatch(actions[mode] ?? { type: "NOOP" });
};
+ const handleMouseMove = (e: React.MouseEvent) => {
+ handleTransformMouseMove(e);
+
+ if (!isPanning) {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const canvasCoords = transformToCanvasCoords(
+ e.clientX,
+ e.clientY,
+ rect,
+ );
+
+ if (mode === "draw") {
+ setHoverPoint(canvasCoords);
+ } else {
+ setHoverPoint(null);
+ }
+
+ // Handle dragging when mouse button is pressed (but not panning)
+ if (e.buttons === 1 && !isPanning) {
+ handleCanvasEvent(e);
+ }
+ }
+ };
+
+ const handleMouseLeave = () => {
+ setHoverPoint(null);
+ };
+
return (
<>
{isLoading && (
@@ -73,12 +137,34 @@ const SvgCanvas = () => {
aria-label='Canvas'
className={css.root}
onClick={handleCanvasEvent}
- onPointerMove={(e) => {
- // Handle dragging when mouse button is pressed
- if (e.buttons !== 1) return;
- handleCanvasEvent(e);
+ onPointerMove={handleMouseMove}
+ onMouseLeave={handleMouseLeave}
+ onWheel={handleWheel}
+ onMouseDown={handleMouseDown}
+ onMouseUp={handleMouseUp}
+ style={{
+ cursor: isPanning
+ ? "grabbing"
+ : mode === "draw"
+ ? "crosshair"
+ : "default",
}}
- />
+ >
+
+ {/* This g element will be transformed, containing all the triangulation */}
+
+ {hoverPoint && mode === "draw" && (
+
+ )}
+
{currentMode?.key === "eraseVertices" && (
)}
diff --git a/src/components/SvgCanvas/SvgCanvasCustomCursor/SvgCanvasCustomCursor.tsx b/src/components/SvgCanvas/SvgCanvasCustomCursor/SvgCanvasCustomCursor.tsx
index 3db2a21..0f6766b 100644
--- a/src/components/SvgCanvas/SvgCanvasCustomCursor/SvgCanvasCustomCursor.tsx
+++ b/src/components/SvgCanvas/SvgCanvasCustomCursor/SvgCanvasCustomCursor.tsx
@@ -10,6 +10,7 @@ const CUSTOM_CURSORS = {
eraseVertices: EraseCursor,
eraseFaces: EraseCursor,
draw: null,
+ select: null,
} as const satisfies Record<
ModeKey,
(({ mode, svgRef }: SvgCanvasCustomCursorProps) => JSX.Element) | null
diff --git a/src/components/SvgCanvas/renderers/dotRenderer.ts b/src/components/SvgCanvas/renderers/dotRenderer.ts
index 51b2a76..7ac50c3 100644
--- a/src/components/SvgCanvas/renderers/dotRenderer.ts
+++ b/src/components/SvgCanvas/renderers/dotRenderer.ts
@@ -7,7 +7,7 @@ export const dotRenderer: ViewRenderer = (
delaunayTriangles,
canvas,
) => {
- const { points } = canvas;
+ const { points, selectedPoints, showVertices } = canvas;
// Create circles using DocumentFragment for better performance
const circleElements: SVGCircleElement[] = [];
@@ -37,4 +37,29 @@ export const dotRenderer: ViewRenderer = (
// Batch append all circles
SvgService.batchAppendChildren(svgElem, circleElements);
+
+ // Add individual point circles for selection (if enabled)
+ if (showVertices) {
+ const pointElements: SVGCircleElement[] = [];
+ points.forEach((point, index) => {
+ const pointCircle = SvgService.createSvgElement("circle");
+ pointCircle.setAttribute("cx", point[0].toString());
+ pointCircle.setAttribute("cy", point[1].toString());
+ pointCircle.setAttribute("r", "4");
+ pointCircle.setAttribute(
+ "fill",
+ selectedPoints.includes(index) ? "#ff6b35" : "#333",
+ );
+ pointCircle.setAttribute(
+ "stroke",
+ selectedPoints.includes(index) ? "#ff6b35" : "#666",
+ );
+ pointCircle.setAttribute("stroke-width", "2");
+ pointCircle.setAttribute("cursor", "pointer");
+ pointCircle.dataset.pointIndex = index.toString();
+ pointElements.push(pointCircle);
+ });
+
+ SvgService.batchAppendChildren(svgElem, pointElements);
+ }
};
diff --git a/src/components/SvgCanvas/renderers/gradientRenderer.ts b/src/components/SvgCanvas/renderers/gradientRenderer.ts
index eeeb2a0..27df80b 100644
--- a/src/components/SvgCanvas/renderers/gradientRenderer.ts
+++ b/src/components/SvgCanvas/renderers/gradientRenderer.ts
@@ -10,7 +10,7 @@ export const gradientRenderer: ViewRenderer = (
delaunayTriangles,
canvas,
) => {
- const { points } = canvas;
+ const { points, selectedPoints, showVertices } = canvas;
// Create a section if it doesn't exist
let defs = svgElem.querySelector("defs");
@@ -77,4 +77,31 @@ export const gradientRenderer: ViewRenderer = (
// Batch append all triangles
SvgService.batchAppendChildren(svgElem, triangleElements);
+
+ // Add individual point circles for selection (if enabled)
+ if (showVertices) {
+ const pointElements: SVGCircleElement[] = [];
+ points.forEach((point, index) => {
+ const pointCircle = SvgService.createSvgElement("circle");
+ pointCircle.setAttribute("cx", point[0].toString());
+ pointCircle.setAttribute("cy", point[1].toString());
+ pointCircle.setAttribute("r", "4");
+ pointCircle.setAttribute(
+ "fill",
+ selectedPoints.includes(index)
+ ? "#ff6b35"
+ : "rgba(255, 255, 255, 0.8)",
+ );
+ pointCircle.setAttribute(
+ "stroke",
+ selectedPoints.includes(index) ? "#ff6b35" : "rgba(0, 0, 0, 0.6)",
+ );
+ pointCircle.setAttribute("stroke-width", "2");
+ pointCircle.setAttribute("cursor", "pointer");
+ pointCircle.dataset.pointIndex = index.toString();
+ pointElements.push(pointCircle);
+ });
+
+ SvgService.batchAppendChildren(svgElem, pointElements);
+ }
};
diff --git a/src/components/SvgCanvas/renderers/lineRenderer.ts b/src/components/SvgCanvas/renderers/lineRenderer.ts
index 0708ad9..ee1f257 100644
--- a/src/components/SvgCanvas/renderers/lineRenderer.ts
+++ b/src/components/SvgCanvas/renderers/lineRenderer.ts
@@ -6,7 +6,7 @@ export const lineRenderer: ViewRenderer = (
delaunayTriangles,
canvas,
) => {
- const { points } = canvas;
+ const { points, selectedPoints, showVertices } = canvas;
// Create triangles using DocumentFragment for better performance
const triangleElements: SVGPolygonElement[] = [];
@@ -31,4 +31,31 @@ export const lineRenderer: ViewRenderer = (
// Batch append all triangles
SvgService.batchAppendChildren(svgElem, triangleElements);
+
+ // Add individual point circles for selection (if enabled)
+ if (showVertices) {
+ const pointElements: SVGCircleElement[] = [];
+ points.forEach((point, index) => {
+ const pointCircle = SvgService.createSvgElement("circle");
+ pointCircle.setAttribute("cx", point[0].toString());
+ pointCircle.setAttribute("cy", point[1].toString());
+ pointCircle.setAttribute("r", "4");
+ pointCircle.setAttribute(
+ "fill",
+ selectedPoints.includes(index)
+ ? "#ff6b35"
+ : "rgba(255, 255, 255, 0.8)",
+ );
+ pointCircle.setAttribute(
+ "stroke",
+ selectedPoints.includes(index) ? "#ff6b35" : "currentcolor",
+ );
+ pointCircle.setAttribute("stroke-width", "2");
+ pointCircle.setAttribute("cursor", "pointer");
+ pointCircle.dataset.pointIndex = index.toString();
+ pointElements.push(pointCircle);
+ });
+
+ SvgService.batchAppendChildren(svgElem, pointElements);
+ }
};
diff --git a/src/config/modes.ts b/src/config/modes.ts
index 4379f4f..f11218d 100644
--- a/src/config/modes.ts
+++ b/src/config/modes.ts
@@ -1,5 +1,6 @@
export const MODES = [
{ key: "draw", name: "draw" },
+ { key: "select", name: "select" },
{ key: "eraseVertices", name: "erase points" },
{ key: "eraseFaces", name: "erase shapes" },
] as const;
diff --git a/src/config/storage.ts b/src/config/storage.ts
index b39163e..05704f7 100644
--- a/src/config/storage.ts
+++ b/src/config/storage.ts
@@ -9,6 +9,7 @@ export const LOCAL_STORAGE_KEYS = {
MODE: "mode",
THEME: "theme",
MAX_EDGE_LENGTH: "max-edge-length",
+ SHOW_VERTICES: "show-vertices",
GRADIENT_COLOR_START: "gradient-color-start",
GRADIENT_COLOR_END: "gradient-color-end",
} as const satisfies Record;
diff --git a/src/hooks/useCanvasTransform.ts b/src/hooks/useCanvasTransform.ts
new file mode 100644
index 0000000..aaab591
--- /dev/null
+++ b/src/hooks/useCanvasTransform.ts
@@ -0,0 +1,113 @@
+import { useState, useCallback, useRef } from "react";
+
+export type Transform = {
+ x: number;
+ y: number;
+ scale: number;
+};
+
+const INITIAL_TRANSFORM: Transform = {
+ x: 0,
+ y: 0,
+ scale: 1,
+};
+
+const MIN_SCALE = 0.1;
+const MAX_SCALE = 10;
+const ZOOM_FACTOR = 1.1;
+
+export const useCanvasTransform = () => {
+ const [transform, setTransform] = useState(INITIAL_TRANSFORM);
+ const [isPanning, setIsPanning] = useState(false);
+ const lastPanPoint = useRef<{ x: number; y: number } | null>(null);
+
+ const handleWheel = useCallback((e: React.WheelEvent) => {
+ e.preventDefault();
+
+ const rect = e.currentTarget.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const mouseY = e.clientY - rect.top;
+
+ setTransform((prev) => {
+ const delta = e.deltaY > 0 ? 1 / ZOOM_FACTOR : ZOOM_FACTOR;
+ const newScale = Math.max(
+ MIN_SCALE,
+ Math.min(MAX_SCALE, prev.scale * delta),
+ );
+
+ if (newScale === prev.scale) return prev;
+
+ const scaleDiff = newScale - prev.scale;
+ const newX = prev.x - (mouseX - prev.x) * (scaleDiff / prev.scale);
+ const newY = prev.y - (mouseY - prev.y) * (scaleDiff / prev.scale);
+
+ return {
+ x: newX,
+ y: newY,
+ scale: newScale,
+ };
+ });
+ }, []);
+
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
+ if (e.button === 1 || (e.button === 0 && (e.altKey || e.metaKey))) {
+ e.preventDefault();
+ setIsPanning(true);
+ lastPanPoint.current = { x: e.clientX, y: e.clientY };
+ }
+ }, []);
+
+ const handleMouseMove = useCallback(
+ (e: React.MouseEvent) => {
+ if (isPanning && lastPanPoint.current) {
+ e.preventDefault();
+ const deltaX = e.clientX - lastPanPoint.current.x;
+ const deltaY = e.clientY - lastPanPoint.current.y;
+
+ setTransform((prev) => ({
+ ...prev,
+ x: prev.x + deltaX,
+ y: prev.y + deltaY,
+ }));
+
+ lastPanPoint.current = { x: e.clientX, y: e.clientY };
+ }
+ },
+ [isPanning],
+ );
+
+ const handleMouseUp = useCallback(() => {
+ setIsPanning(false);
+ lastPanPoint.current = null;
+ }, []);
+
+ const resetTransform = useCallback(() => {
+ setTransform(INITIAL_TRANSFORM);
+ }, []);
+
+ const transformStyle = {
+ transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
+ transformOrigin: "0 0",
+ };
+
+ const transformToCanvasCoords = useCallback(
+ (clientX: number, clientY: number, rect: DOMRect) => {
+ const canvasX = (clientX - rect.left - transform.x) / transform.scale;
+ const canvasY = (clientY - rect.top - transform.y) / transform.scale;
+ return { x: canvasX, y: canvasY };
+ },
+ [transform],
+ );
+
+ return {
+ transform,
+ transformStyle,
+ isPanning,
+ handleWheel,
+ handleMouseDown,
+ handleMouseMove,
+ handleMouseUp,
+ resetTransform,
+ transformToCanvasCoords,
+ };
+};
diff --git a/src/hooks/useDelaunayWorker.ts b/src/hooks/useDelaunayWorker.ts
index 8e7834f..c3f575a 100644
--- a/src/hooks/useDelaunayWorker.ts
+++ b/src/hooks/useDelaunayWorker.ts
@@ -20,6 +20,13 @@ export const useDelaunayWorker = () => {
return;
}
+ // Find the transform group inside the SVG
+ const transformGroup = svgElem.querySelector("g");
+ if (!transformGroup) {
+ console.error("Transform group not found");
+ return;
+ }
+
const requestId = ++latestRequestId.current;
setIsLoading(true);
@@ -36,7 +43,11 @@ export const useDelaunayWorker = () => {
// Only update if this is still the latest request and component is mounted
if (requestId === latestRequestId.current && isMounted) {
- generateView(svgElem, delaunayTriangles, canvas);
+ generateView(
+ transformGroup as SVGGElement,
+ delaunayTriangles,
+ canvas,
+ );
setIsLoading(false);
}
} catch (error) {
diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts
new file mode 100644
index 0000000..7a452bc
--- /dev/null
+++ b/src/hooks/useKeyboardShortcuts.ts
@@ -0,0 +1,92 @@
+import { useEffect } from "react";
+import { useDispatch } from "react-redux";
+import { canvasActions } from "../store/canvasSlice";
+import { editorActions } from "../store/editorSlice";
+
+export const useKeyboardShortcuts = () => {
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement
+ ) {
+ return;
+ }
+
+ const isCtrlOrMeta = e.ctrlKey || e.metaKey;
+ const key = e.key.toLowerCase();
+
+ if (isCtrlOrMeta) {
+ switch (key) {
+ case "z":
+ if (e.shiftKey) {
+ dispatch(canvasActions.redo());
+ } else {
+ dispatch(canvasActions.undo());
+ }
+ e.preventDefault();
+ break;
+ case "y":
+ dispatch(canvasActions.redo());
+ e.preventDefault();
+ break;
+ case "s":
+ dispatch(canvasActions.saveToLocalStorage());
+ dispatch(editorActions.saveToLocalStorage());
+ e.preventDefault();
+ break;
+ }
+ return;
+ }
+
+ switch (key) {
+ case "d":
+ dispatch(canvasActions.setMode("draw"));
+ break;
+ case "s":
+ if (!isCtrlOrMeta) {
+ dispatch(canvasActions.setMode("select"));
+ }
+ break;
+ case "e":
+ dispatch(canvasActions.setMode("eraseVertices"));
+ break;
+ case "f":
+ dispatch(canvasActions.setMode("eraseFaces"));
+ break;
+ case "delete":
+ case "backspace":
+ dispatch(canvasActions.deleteSelectedPoints());
+ break;
+ case "r":
+ dispatch(canvasActions.randomize());
+ break;
+ case "c":
+ if (!isCtrlOrMeta) {
+ dispatch(canvasActions.clearPoints());
+ }
+ break;
+ case "1":
+ dispatch(canvasActions.setView("gradient"));
+ break;
+ case "2":
+ dispatch(canvasActions.setView("lines"));
+ break;
+ case "3":
+ dispatch(canvasActions.setView("dots"));
+ break;
+ case "t":
+ dispatch(editorActions.invertTheme());
+ break;
+ case "v":
+ dispatch(canvasActions.toggleShowVertices());
+ break;
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [dispatch]);
+};
diff --git a/src/store/canvasSlice.ts b/src/store/canvasSlice.ts
index d648b2a..2dae8ea 100644
--- a/src/store/canvasSlice.ts
+++ b/src/store/canvasSlice.ts
@@ -2,6 +2,7 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { Point, Points, View } from "../components/SvgCanvas/renderers";
import { generateRandomPoints } from "../utils/svg";
import { store } from "../utils/storage";
+import { LOCAL_STORAGE_KEYS } from "../config/storage";
import type { ModeKey } from "../config/modes";
export type CanvasState = {
@@ -14,15 +15,24 @@ export type CanvasState = {
endColor: string;
};
imageFile: File | null;
+ history: {
+ past: Points[];
+ present: Points;
+ future: Points[];
+ };
+ selectedPoints: number[];
+ showVertices: boolean;
};
// Attempt to retrieve the program state from local storage.
// If no state is found, return hardcoded defaults.
const getInitialState = (): CanvasState => {
- const mode = store.get("mode") ?? "draw";
+ // Always start in draw mode for immediate usability
+ const mode: ModeKey = "draw";
const view = store.get("view") ?? "gradient";
const points = store.get("points") ?? generateRandomPoints();
const maxEdgeLength = store.get("max-edge-length") ?? 500;
+ const showVertices = store.get(LOCAL_STORAGE_KEYS.SHOW_VERTICES) ?? true;
// Default colors must be in hex format: #RRGGBB
const startColor = store.get("gradient-color-start") ?? "#ff0000";
const endColor = store.get("gradient-color-end") ?? "#0000ff";
@@ -37,6 +47,13 @@ const getInitialState = (): CanvasState => {
endColor,
},
imageFile: null,
+ history: {
+ past: [],
+ present: points,
+ future: [],
+ },
+ selectedPoints: [],
+ showVertices,
};
};
@@ -48,6 +65,7 @@ const saveToLocalStorage = (state: CanvasState) => {
store.put("view", state.view);
store.put("mode", state.mode);
store.put("max-edge-length", state.maxEdgeLength);
+ store.put(LOCAL_STORAGE_KEYS.SHOW_VERTICES, state.showVertices);
store.put("gradient-color-start", state.gradient.startColor);
store.put("gradient-color-end", state.gradient.endColor);
};
@@ -63,7 +81,7 @@ const setView = (state: CanvasState, action: PayloadAction) => {
};
const addPoint = (state: CanvasState, action: PayloadAction) => {
- // Add a point to the array
+ pushToHistory(state);
state.points.push(action.payload);
};
@@ -74,28 +92,35 @@ const erasePoints = (
const { x, y, radius } = action.payload;
const radiusSquared = radius * radius;
- state.points = state.points.filter((point) => {
+ const newPoints = state.points.filter((point) => {
const dx = point[0] - x;
const dy = point[1] - y;
const distanceSquared = dx * dx + dy * dy;
return distanceSquared > radiusSquared + 0.0001;
});
+
+ if (newPoints.length !== state.points.length) {
+ pushToHistory(state);
+ state.points = newPoints;
+ }
};
const clearPoints = (state: CanvasState) => {
- // Delete all points from the array.
- // Cannot be restored unless saved to local storage.
- state.points = [];
+ if (state.points.length > 0) {
+ pushToHistory(state);
+ state.points = [];
+ }
};
const randomize = (state: CanvasState) => {
- // Generate random points.
+ pushToHistory(state);
state.points = generateRandomPoints();
};
const setPoints = (state: CanvasState, action: PayloadAction) => {
- // Set all points at once.
+ pushToHistory(state);
state.points = action.payload;
+ state.history.present = [...action.payload];
};
const setMaxEdgeLength = (
@@ -123,21 +148,104 @@ const setGradientEndColor = (
state.gradient.endColor = action.payload;
};
+// UNDO/REDO SYSTEM
+
+const pushToHistory = (state: CanvasState) => {
+ state.history.past.push(state.history.present);
+ state.history.present = [...state.points];
+ state.history.future = [];
+
+ if (state.history.past.length > 50) {
+ state.history.past.shift();
+ }
+};
+
+const undo = (state: CanvasState) => {
+ if (state.history.past.length === 0) return;
+
+ state.history.future.unshift(state.history.present);
+ state.history.present = state.history.past.pop()!;
+ state.points = [...state.history.present];
+};
+
+const redo = (state: CanvasState) => {
+ if (state.history.future.length === 0) return;
+
+ state.history.past.push(state.history.present);
+ state.history.present = state.history.future.shift()!;
+ state.points = [...state.history.present];
+};
+
+// POINT SELECTION
+
+const selectPoint = (state: CanvasState, action: PayloadAction) => {
+ const pointIndex = action.payload;
+ if (!state.selectedPoints.includes(pointIndex)) {
+ state.selectedPoints.push(pointIndex);
+ }
+};
+
+const deselectPoint = (state: CanvasState, action: PayloadAction) => {
+ const pointIndex = action.payload;
+ state.selectedPoints = state.selectedPoints.filter((i) => i !== pointIndex);
+};
+
+const togglePointSelection = (
+ state: CanvasState,
+ action: PayloadAction,
+) => {
+ const pointIndex = action.payload;
+ if (state.selectedPoints.includes(pointIndex)) {
+ state.selectedPoints = state.selectedPoints.filter(
+ (i) => i !== pointIndex,
+ );
+ } else {
+ state.selectedPoints.push(pointIndex);
+ }
+};
+
+const clearSelection = (state: CanvasState) => {
+ state.selectedPoints = [];
+};
+
+const deleteSelectedPoints = (state: CanvasState) => {
+ if (state.selectedPoints.length > 0) {
+ pushToHistory(state);
+ const indicesToDelete = [...state.selectedPoints].sort((a, b) => b - a);
+ indicesToDelete.forEach((index) => {
+ state.points.splice(index, 1);
+ });
+ state.selectedPoints = [];
+ }
+};
+
+const toggleShowVertices = (state: CanvasState) => {
+ state.showVertices = !state.showVertices;
+};
+
const canvasSlice = createSlice({
name: "canvas",
initialState: getInitialState(),
reducers: {
addPoint,
clearPoints,
+ clearSelection,
+ deleteSelectedPoints,
+ deselectPoint,
erasePoints,
randomize,
saveToLocalStorage,
+ selectPoint,
setGradientEndColor,
setGradientStartColor,
setMaxEdgeLength,
setMode,
setPoints,
setView,
+ togglePointSelection,
+ toggleShowVertices,
+ undo,
+ redo,
},
});