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 =
Something went wrong
; const App = () => { const view = useView(); + useKeyboardShortcuts(); return (
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, }, });