diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx new file mode 100644 index 000000000..65f10e8cc --- /dev/null +++ b/src/components/Canvas.tsx @@ -0,0 +1,269 @@ +import React, { + createContext, + useReducer, + useCallback, + Dispatch, + useContext, + useState, + useRef, + useEffect +} from "react"; +import rough from "roughjs/bin/wrappers/rough"; +import { RoughCanvas } from "roughjs/bin/canvas"; +import { Drawable } from "roughjs/bin/core"; + +type CanvasProps = { + width?: number; + height?: number; + children?: React.ReactNode; + backgroundColor?: string; +}; + +type CanvasState = { + scrollX: number; + scrollY: number; + scale: number; +}; + +type CanvasDraw = { + context: CanvasRenderingContext2D; + rc: RoughCanvas; +}; + +type MouseEventListener = (ev: React.MouseEvent) => void; + +type CanvasEventListeners = { + mouseMoveListeners: MouseEventListener[]; + mouseDownListeners: MouseEventListener[]; +}; + +const CanvasEventListenersContext = createContext({ + mouseDownListeners: [], + mouseMoveListeners: [] +}); + +const CanvasDrawContext = createContext(null); + +const CanvasStateContext = createContext({ + scale: 1, + scrollX: 0, + scrollY: 0 +}); + +const CanvasDispatchContext = createContext>(() => {}); + +type Action = + | { type: "shift-scroll"; dx: number; dy: number } + | { type: "set-scale"; scale: number }; + +function reducer(state: CanvasState, action: Action): CanvasState { + switch (action.type) { + case "set-scale": + return { + ...state, + scale: action.scale + }; + case "shift-scroll": + return { + ...state, + scrollX: state.scrollX + action.dx, + scrollY: state.scrollY + action.dy + }; + default: + return state; + } +} + +export function Canvas({ + width, + height, + children, + backgroundColor +}: CanvasProps) { + const eventListenersRef = useRef({ + mouseDownListeners: [], + mouseMoveListeners: [] + }); + + const [state, dispatch] = useReducer(reducer, { + scrollX: 0, + scrollY: 0, + scale: 1 + }); + + const [canvasDraw, setCanvasDraw] = useState(null); + + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + dispatch({ type: "shift-scroll", dx: -e.deltaX, dy: -e.deltaY }); + }, []); + + const handleCanvasRef = useCallback((canvas: HTMLCanvasElement | null) => { + if (canvas) { + setCanvasDraw({ + context: canvas.getContext("2d")!, + rc: rough.canvas(canvas) + }); + } else { + setCanvasDraw(null); + } + }, []); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + eventListenersRef.current.mouseDownListeners.forEach(fn => fn(e)); + }, []); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + eventListenersRef.current.mouseMoveListeners.forEach(fn => fn(e)); + }, []); + + return ( + + + + + + {canvasDraw && ( + + )} + + + {children} + + + + + ); +} + +function CanvasBackground({ + context, + color +}: { + context: CanvasRenderingContext2D; + color?: string; +}) { + const fillStyle = context.fillStyle; + if (typeof color === "string") { + context.fillStyle = color; + context.fillRect(-0.5, -0.5, context.canvas.width, context.canvas.height); + } else { + context.clearRect(-0.5, -0.5, context.canvas.width, context.canvas.height); + } + context.fillStyle = fillStyle; + + return null; +} + +function useCanvasState(): CanvasState { + return useContext(CanvasStateContext); +} + +function useCanvasDispatch(): Dispatch { + return useContext(CanvasDispatchContext); +} + +function useCanvasEventListener(): CanvasEventListeners { + return useContext(CanvasEventListenersContext); +} + +function useCanvasDraw(): CanvasDraw | null { + return useContext(CanvasDrawContext); +} + +type CanvasElementProps = { + x: number; + y: number; + hitTest(targetX: number, targetY: number): boolean; + getShape?(rc: RoughCanvas): Drawable; + draw( + rc: RoughCanvas, + context: CanvasRenderingContext2D, + shape: Drawable | undefined + ): void; + onTap?(ev: React.MouseEvent): void; + onHover?(ev: React.MouseEvent): void; +}; + +export function CanvasElement({ + x, + y, + getShape, + draw, + hitTest, + onTap, + onHover +}: CanvasElementProps) { + const canvasDraw = useCanvasDraw(); + const { scrollX, scrollY, scale } = useCanvasState(); + const shapeRef = useRef(undefined); + const { mouseDownListeners, mouseMoveListeners } = useCanvasEventListener(); + + useEffect(() => { + const mouseDownHandler: MouseEventListener = e => { + if (onTap && e.target instanceof HTMLElement) { + const targetX = e.clientX - e.target.offsetLeft - scrollX; + const targetY = e.clientY - e.target.offsetTop - scrollY; + if (hitTest(targetX, targetY)) onTap(e); + } + }; + mouseDownListeners.push(mouseDownHandler); + + const mouseMoveHandler: MouseEventListener = e => { + if (onHover && e.target instanceof HTMLElement) { + const targetX = e.clientX - e.target.offsetLeft - scrollX; + const targetY = e.clientY - e.target.offsetTop - scrollY; + if (hitTest(targetX, targetY)) onHover(e); + } + }; + mouseMoveListeners.push(mouseMoveHandler); + + return () => { + const mouseDownIdx = mouseDownListeners.indexOf(mouseDownHandler); + mouseDownListeners.splice(mouseDownIdx, 1); + const mouseMoveIdx = mouseMoveListeners.indexOf(mouseMoveHandler); + mouseMoveListeners.splice(mouseMoveIdx, 1); + }; + }, [ + hitTest, + mouseDownListeners, + mouseMoveListeners, + onHover, + onTap, + scrollX, + scrollY + ]); + + useEffect(() => { + if (canvasDraw && getShape) shapeRef.current = getShape(canvasDraw.rc); + }, [getShape, canvasDraw]); + + if (!canvasDraw) { + return null; + } + + if (!shapeRef.current && getShape) { + shapeRef.current = getShape(canvasDraw.rc); + } + + const { rc, context } = canvasDraw; + context.translate(x + scrollX, y + scrollY); + draw(rc, context, shapeRef.current); + context.translate(-x - scrollX, -y - scrollY); + + return null; +} diff --git a/src/components/Rectangle.tsx b/src/components/Rectangle.tsx new file mode 100644 index 000000000..bee45d57e --- /dev/null +++ b/src/components/Rectangle.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { distanceBetweenPointAndSegment } from "../utils/distanceBetweenPointAndSegment"; +import { CanvasElement } from "./Canvas"; + +type RectangleProps = { + x: number; + y: number; + width: number; + height: number; + strokeColor?: string; + fillColor?: string; + onHover?(): void; + onTap?(): void; +}; + +export function Rectangle({ + x, + y, + width, + height, + strokeColor, + fillColor, + onHover, + onTap +}: RectangleProps) { + return ( + { + const x1 = Math.min(x, x + width); + const x2 = Math.max(x, x + width); + const y1 = Math.min(y, y + height); + const y2 = Math.max(y, y + height); + return rectHitTest(targetX, targetY, x1, y1, x2, y2); + }} + getShape={rc => + rc.generator.rectangle(0, 0, width, height, { + stroke: strokeColor, + fill: fillColor + }) + } + draw={(rc, context, shape) => { + rc.draw(shape!); + }} + onHover={onHover} + onTap={onTap} + /> + ); +} + +function rectHitTest( + x: number, + y: number, + x1: number, + y1: number, + x2: number, + y2: number +) { + const lineThreshold = 10; + return ( + distanceBetweenPointAndSegment(x, y, x1, y1, x2, y1) < lineThreshold || // A + distanceBetweenPointAndSegment(x, y, x2, y1, x2, y2) < lineThreshold || // B + distanceBetweenPointAndSegment(x, y, x2, y2, x1, y2) < lineThreshold || // C + distanceBetweenPointAndSegment(x, y, x1, y2, x1, y1) < lineThreshold // D + ); +} diff --git a/src/components/TestApp.tsx b/src/components/TestApp.tsx new file mode 100644 index 000000000..045612947 --- /dev/null +++ b/src/components/TestApp.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Canvas } from "./Canvas"; +import { Rectangle } from "./Rectangle"; + +export function TestApp() { + return ( + + alert("Clicked on rect 1")} + strokeColor="blue" + /> + alert("Hovered over rect 2")} + fillColor="red" + /> + + ); +} diff --git a/src/index.tsx b/src/index.tsx index e98dff1d8..b2b5aae3e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,6 +6,7 @@ import { RoughCanvas } from "roughjs/bin/canvas"; import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex"; import "./styles.scss"; +import { TestApp } from "./components/TestApp"; type ExcalidrawElement = ReturnType; type ExcalidrawTextElement = ExcalidrawElement & { @@ -1399,7 +1400,7 @@ class App extends React.Component<{}, AppState> { } const rootElement = document.getElementById("root"); -ReactDOM.render(, rootElement); +ReactDOM.render(, rootElement); const canvas = document.getElementById("canvas") as HTMLCanvasElement; const rc = rough.canvas(canvas); const context = canvas.getContext("2d")!; @@ -1408,4 +1409,4 @@ const context = canvas.getContext("2d")!; // https://stackoverflow.com/questions/13879322/drawing-a-1px-thick-line-in-canvas-creates-a-2px-thick-line/13879402#comment90766599_13879402 context.translate(0.5, 0.5); -ReactDOM.render(, rootElement); +ReactDOM.render(, rootElement); diff --git a/src/utils/distanceBetweenPointAndSegment.tsx b/src/utils/distanceBetweenPointAndSegment.tsx new file mode 100644 index 000000000..047057217 --- /dev/null +++ b/src/utils/distanceBetweenPointAndSegment.tsx @@ -0,0 +1,35 @@ +// https://stackoverflow.com/a/6853926/232122 +export function distanceBetweenPointAndSegment( + x: number, + y: number, + x1: number, + y1: number, + x2: number, + y2: number +) { + const A = x - x1; + const B = y - y1; + const C = x2 - x1; + const D = y2 - y1; + const dot = A * C + B * D; + const lenSquare = C * C + D * D; + let param = -1; + if (lenSquare !== 0) { + // in case of 0 length line + param = dot / lenSquare; + } + let xx, yy; + if (param < 0) { + xx = x1; + yy = y1; + } else if (param > 1) { + xx = x2; + yy = y2; + } else { + xx = x1 + param * C; + yy = y1 + param * D; + } + const dx = x - xx; + const dy = y - yy; + return Math.hypot(dx, dy); +}