Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <div>Something went wrong</div>;

const App = () => {
const view = useView();
useKeyboardShortcuts();

return (
<div className={css.root}>
Expand Down
8 changes: 8 additions & 0 deletions src/components/Menu/createMenuConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
102 changes: 94 additions & 8 deletions src/components/SvgCanvas/SvgCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,13 +10,27 @@ 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<SVGSVGElement>["onClick"];
export type OnDragFn = React.SVGProps<SVGSVGElement>["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();
Expand All @@ -26,10 +40,14 @@ const SvgCanvas = () => {
const handleCanvasEvent = (
e: React.MouseEvent<SVGSVGElement, 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.
Expand All @@ -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,
Expand All @@ -56,9 +75,54 @@ const SvgCanvas = () => {
eraseFaces: null,
} as const satisfies Record<ModeKey, unknown>;

// 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<SVGSVGElement, 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 && (
Expand All @@ -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",
}}
/>
>
<g style={transformStyle}>
{/* This g element will be transformed, containing all the triangulation */}
</g>
{hoverPoint && mode === "draw" && (
<circle
cx={hoverPoint.x}
cy={hoverPoint.y}
r='3'
fill='rgba(255, 255, 255, 0.8)'
stroke='rgba(0, 0, 0, 0.5)'
strokeWidth='1'
pointerEvents='none'
/>
)}
</svg>
{currentMode?.key === "eraseVertices" && (
<SvgCanvasCustomCursor mode={currentMode} svgRef={svgRef} />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion src/components/SvgCanvas/renderers/dotRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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);
}
};
29 changes: 28 additions & 1 deletion src/components/SvgCanvas/renderers/gradientRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const gradientRenderer: ViewRenderer = (
delaunayTriangles,
canvas,
) => {
const { points } = canvas;
const { points, selectedPoints, showVertices } = canvas;

// Create a <defs> section if it doesn't exist
let defs = svgElem.querySelector("defs");
Expand Down Expand Up @@ -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);
}
};
29 changes: 28 additions & 1 deletion src/components/SvgCanvas/renderers/lineRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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);
}
};
1 change: 1 addition & 0 deletions src/config/modes.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/config/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
Expand Down
Loading