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 { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex";
|
||||||
|
|
||||||
import "./styles.scss";
|
import "./styles.scss";
|
||||||
|
import { TestApp } from "./components/TestApp";
|
||||||
|
|
||||||
type ExcalidrawElement = ReturnType<typeof newElement>;
|
type ExcalidrawElement = ReturnType<typeof newElement>;
|
||||||
type ExcalidrawTextElement = ExcalidrawElement & {
|
type ExcalidrawTextElement = ExcalidrawElement & {
|
||||||
|
@ -1399,7 +1400,7 @@ class App extends React.Component<{}, AppState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
ReactDOM.render(<App />, rootElement);
|
ReactDOM.render(<TestApp />, rootElement);
|
||||||
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
|
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
|
||||||
const rc = rough.canvas(canvas);
|
const rc = rough.canvas(canvas);
|
||||||
const context = canvas.getContext("2d")!;
|
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
|
// 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);
|
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