mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Components POC
This commit is contained in:
parent
3172109050
commit
a73d74c4df
5 changed files with 404 additions and 2 deletions
269
src/components/Canvas.tsx
Normal file
269
src/components/Canvas.tsx
Normal file
|
@ -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<CanvasEventListeners>({
|
||||
mouseDownListeners: [],
|
||||
mouseMoveListeners: []
|
||||
});
|
||||
|
||||
const CanvasDrawContext = createContext<CanvasDraw | null>(null);
|
||||
|
||||
const CanvasStateContext = createContext<CanvasState>({
|
||||
scale: 1,
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
});
|
||||
|
||||
const CanvasDispatchContext = createContext<Dispatch<Action>>(() => {});
|
||||
|
||||
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<CanvasEventListeners>({
|
||||
mouseDownListeners: [],
|
||||
mouseMoveListeners: []
|
||||
});
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
scale: 1
|
||||
});
|
||||
|
||||
const [canvasDraw, setCanvasDraw] = useState<CanvasDraw | null>(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 (
|
||||
<CanvasEventListenersContext.Provider value={eventListenersRef.current}>
|
||||
<CanvasDrawContext.Provider value={canvasDraw}>
|
||||
<CanvasDispatchContext.Provider value={dispatch}>
|
||||
<CanvasStateContext.Provider value={state}>
|
||||
<canvas
|
||||
id="canvas"
|
||||
ref={handleCanvasRef}
|
||||
onWheel={handleWheel}
|
||||
width={width}
|
||||
height={height}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{canvasDraw && (
|
||||
<CanvasBackground
|
||||
context={canvasDraw.context}
|
||||
color={backgroundColor}
|
||||
/>
|
||||
)}
|
||||
</canvas>
|
||||
|
||||
{children}
|
||||
</CanvasStateContext.Provider>
|
||||
</CanvasDispatchContext.Provider>
|
||||
</CanvasDrawContext.Provider>
|
||||
</CanvasEventListenersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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<Action> {
|
||||
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<Drawable | undefined>(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;
|
||||
}
|
67
src/components/Rectangle.tsx
Normal file
67
src/components/Rectangle.tsx
Normal file
|
@ -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 (
|
||||
<CanvasElement
|
||||
x={x}
|
||||
y={y}
|
||||
hitTest={(targetX, targetY) => {
|
||||
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
|
||||
);
|
||||
}
|
30
src/components/TestApp.tsx
Normal file
30
src/components/TestApp.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import React from "react";
|
||||
import { Canvas } from "./Canvas";
|
||||
import { Rectangle } from "./Rectangle";
|
||||
|
||||
export function TestApp() {
|
||||
return (
|
||||
<Canvas
|
||||
width={window.innerWidth}
|
||||
height={window.innerHeight - 4}
|
||||
backgroundColor="#eeeeee"
|
||||
>
|
||||
<Rectangle
|
||||
x={10}
|
||||
y={10}
|
||||
width={50}
|
||||
height={20}
|
||||
onTap={() => alert("Clicked on rect 1")}
|
||||
strokeColor="blue"
|
||||
/>
|
||||
<Rectangle
|
||||
x={70}
|
||||
y={20}
|
||||
width={30}
|
||||
height={40}
|
||||
onHover={() => alert("Hovered over rect 2")}
|
||||
fillColor="red"
|
||||
/>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
|
@ -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<typeof newElement>;
|
||||
type ExcalidrawTextElement = ExcalidrawElement & {
|
||||
|
@ -1399,7 +1400,7 @@ class App extends React.Component<{}, AppState> {
|
|||
}
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
ReactDOM.render(<App />, rootElement);
|
||||
ReactDOM.render(<TestApp />, 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(<App />, rootElement);
|
||||
ReactDOM.render(<TestApp />, rootElement);
|
||||
|
|
35
src/utils/distanceBetweenPointAndSegment.tsx
Normal file
35
src/utils/distanceBetweenPointAndSegment.tsx
Normal file
|
@ -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);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue