Compare commits

...

2 Commits

Author SHA1 Message Date
Michael Peters
1f3895a025 better grid colors 2024-01-27 13:17:20 -08:00
Michael Peters
a258592054 undo/redo 2024-01-27 12:40:13 -08:00

View File

@ -1,5 +1,13 @@
import { useMouse, useWindowSize } from '@uidotdev/usehooks';
import { FC, MouseEvent, useCallback, useMemo, useState } from 'react';
import {
FC,
KeyboardEvent,
MouseEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import './grid.scss';
@ -17,6 +25,14 @@ interface Range {
y1: number;
}
interface Line {
x0: number;
y0: number;
x1: number;
y1: number;
stroke: string;
}
function pointsToRange(p0: Point, p1: Point): Range {
return { x0: p0.x, y0: p0.y, x1: p1.x, y1: p1.y };
}
@ -48,31 +64,128 @@ function useMouseInRange(range: Range): Point {
return { x: mouse.x + range.x0, y: mouse.y + range.y0 };
}
function useKeyPress(
callback: (event: KeyboardEvent) => void,
key: string,
ctrlKey: boolean = false
) {
useEffect(() => {
const listener = (eventRaw: Event) => {
const event = eventRaw as any as KeyboardEvent;
if (event.key == key && event.ctrlKey == ctrlKey) {
callback(event);
}
};
window.addEventListener('keypress', listener);
return () => {
window.removeEventListener('keypress', listener);
};
}, [callback, key, ctrlKey]);
}
const THICK_COLOR = '#123456';
const THIN_COLOR = '#0d1c2b';
const Grid: FC = () => {
const GAP = 20;
// const [offset, setOffset] = useState<Point>({ x: GAP / 2, y: GAP / 2 });
const [offset, setOffset] = useState<Point>({ x: 10, y: 10 });
const [offset, setOffset] = useState<Point>({ x: GAP * 2, y: GAP * 2 });
const range = useRange(offset);
const mousePoint = useMouseInRange(range);
const gridLines = useMemo(() => {
const lines = [];
const lines_thin: Line[] = [];
const lines_thick: Line[] = [];
const start = snapToGrid({ x: range.x0, y: range.y0 }, GAP);
const end = snapToGrid({ x: range.x1, y: range.y1 }, GAP);
for (let x = start.x; x <= end.x; x += GAP) {
lines.push({ ...range, x0: x, x1: x });
const thick = x % (GAP * 4) == 0;
const lines = thick ? lines_thick : lines_thin;
lines.push({
...range,
x0: x,
x1: x,
stroke: thick ? THICK_COLOR : THIN_COLOR,
});
}
for (let y = start.y; y <= end.y; y += GAP) {
lines.push({ ...range, y0: y, y1: y });
const thick = y % (GAP * 4) == 0;
const lines = thick ? lines_thick : lines_thin;
lines.push({
...range,
y0: y,
y1: y,
stroke: thick ? THICK_COLOR : THIN_COLOR,
});
}
return lines;
return lines_thin.concat(lines_thick);
}, [range, GAP]);
const [userLines, setUserLines] = useState<Range[]>([]);
const [userRedoLines, setUserRedoLines] = useState<Range[]>([]);
const [userStartPoint, setUserStartPoint] = useState<Point | null>(null);
// mouse clicks create lines
const onGridClick = useCallback(
(event: MouseEvent<SVGSVGElement>) => {
// right-click to unset the start point
if (event.button === 2) {
setUserStartPoint(null);
return;
}
const point = snapToGrid(mousePoint, GAP);
setUserRedoLines([]);
if (lodash.isEqual(userStartPoint, point)) return;
if (lodash.isNull(userStartPoint)) {
setUserStartPoint(point);
return;
}
const line = pointsToRange(userStartPoint, point);
setUserLines([...userLines, line]);
if (event.shiftKey == false) {
setUserStartPoint(null);
} else {
setUserStartPoint(point);
}
},
[userStartPoint, setUserStartPoint, setUserLines, GAP, mousePoint]
);
const onGridContextMenu = useCallback(
(event: MouseEvent<SVGSVGElement>) => {
event.preventDefault(); // disable default context menu
},
[setUserStartPoint]
);
// ctrl+z/ctrl+y for undo/redo
const onUndo = useCallback(() => {
console.log('onUndo', userLines);
const line = userLines.at(-1);
if (!line) return;
setUserLines(userLines.slice(0, -1));
setUserRedoLines([...userRedoLines, line]);
}, [userLines, setUserLines, userRedoLines, setUserRedoLines]);
const onRedo = useCallback(() => {
console.log('onRedo', userRedoLines);
const line = userRedoLines.at(-1);
if (!line) return;
setUserRedoLines(userRedoLines.slice(0, -1));
setUserLines([...userLines, line]);
}, [userLines, setUserLines, userRedoLines, setUserRedoLines]);
useKeyPress(onUndo, 'z', true);
useKeyPress(onRedo, 'y', true);
const gridLineElements = useMemo(() => {
return gridLines.map((line, idx) => (
<line
@ -82,8 +195,8 @@ const Grid: FC = () => {
y1={line.y0}
x2={line.x1}
y2={line.y1}
stroke="#123456"
strokeWidth="2"
stroke={line.stroke}
strokeWidth={2}
/>
));
}, [gridLines]);
@ -130,28 +243,6 @@ const Grid: FC = () => {
));
}, [userLines]);
const onGridClick = useCallback(
(event: MouseEvent<SVGSVGElement>) => {
const point = snapToGrid(mousePoint, GAP);
if (lodash.isEqual(userStartPoint, point)) return;
if (lodash.isNull(userStartPoint)) {
setUserStartPoint(point);
return;
}
const line = pointsToRange(userStartPoint, point);
setUserLines([...userLines, line]);
if (event.shiftKey == false) {
setUserStartPoint(null);
} else {
setUserStartPoint(point);
}
},
[userStartPoint, setUserStartPoint, setUserLines, GAP, mousePoint]
);
return (
<svg
className="grid"
@ -159,20 +250,15 @@ const Grid: FC = () => {
viewBox={`${range.x0} ${range.y0} ${range.x1 - range.x0} ${
range.y1 - range.y0
}`}
onClick={onGridClick}
onMouseDown={onGridClick}
onContextMenu={onGridContextMenu}
// tabIndex={0}
// onKeyDown={onGridKeyDown}
>
{gridLineElements}
{userLineElements}
{userLineIndicatorElement}
{userPointIndicatorElement}
<text
x={range.x0 + 20}
y={range.y0 + 60} // of baseline
fill="#ffffff"
fontSize="40px"
>
GRID
</text>
</svg>
);
};