From 01805f734d87b56b60febe56f33790cd744a3fac Mon Sep 17 00:00:00 2001 From: Gasim Gasimzada Date: Mon, 6 Jan 2020 19:34:22 +0400 Subject: [PATCH 1/5] Extract element functions into modules (#207) --- .gitignore | 1 + src/element/bounds.ts | 52 ++++ src/element/collision.ts | 124 ++++++++ src/element/generateDraw.ts | 145 +++++++++ src/element/handlerRectangles.ts | 85 ++++++ src/element/index.ts | 15 + src/element/newElement.ts | 40 +++ src/element/resizeTest.ts | 32 ++ src/element/typeChecks.ts | 7 + src/element/types.ts | 9 + src/index.tsx | 489 +------------------------------ src/math.ts | 16 + src/scene/types.ts | 6 + 13 files changed, 547 insertions(+), 474 deletions(-) create mode 100644 src/element/bounds.ts create mode 100644 src/element/collision.ts create mode 100644 src/element/generateDraw.ts create mode 100644 src/element/handlerRectangles.ts create mode 100644 src/element/index.ts create mode 100644 src/element/newElement.ts create mode 100644 src/element/resizeTest.ts create mode 100644 src/element/typeChecks.ts create mode 100644 src/element/types.ts create mode 100644 src/scene/types.ts diff --git a/.gitignore b/.gitignore index f171f6f93..46e15b10f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ yarn.lock # Editors .vscode/ +.DS_Store \ No newline at end of file diff --git a/src/element/bounds.ts b/src/element/bounds.ts new file mode 100644 index 000000000..a95d63dc2 --- /dev/null +++ b/src/element/bounds.ts @@ -0,0 +1,52 @@ +import { ExcalidrawElement } from "./types"; +import { rotate } from "../math"; + +// If the element is created from right to left, the width is going to be negative +// This set of functions retrieves the absolute position of the 4 points. +// We can't just always normalize it since we need to remember the fact that an arrow +// is pointing left or right. +export function getElementAbsoluteX1(element: ExcalidrawElement) { + return element.width >= 0 ? element.x : element.x + element.width; +} +export function getElementAbsoluteX2(element: ExcalidrawElement) { + return element.width >= 0 ? element.x + element.width : element.x; +} +export function getElementAbsoluteY1(element: ExcalidrawElement) { + return element.height >= 0 ? element.y : element.y + element.height; +} +export function getElementAbsoluteY2(element: ExcalidrawElement) { + return element.height >= 0 ? element.y + element.height : element.y; +} + +export function getDiamondPoints(element: ExcalidrawElement) { + const topX = Math.floor(element.width / 2) + 1; + const topY = 0; + const rightX = element.width; + const rightY = Math.floor(element.height / 2) + 1; + const bottomX = topX; + const bottomY = element.height; + const leftX = topY; + const leftY = rightY; + + return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY]; +} + +export function getArrowPoints(element: ExcalidrawElement) { + const x1 = 0; + const y1 = 0; + const x2 = element.width; + const y2 = element.height; + + const size = 30; // pixels + const distance = Math.hypot(x2 - x1, y2 - y1); + // Scale down the arrow until we hit a certain size so that it doesn't look weird + const minSize = Math.min(size, distance / 2); + const xs = x2 - ((x2 - x1) / distance) * minSize; + const ys = y2 - ((y2 - y1) / distance) * minSize; + + const angle = 20; // degrees + const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180); + const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180); + + return [x1, y1, x2, y2, x3, y3, x4, y4]; +} diff --git a/src/element/collision.ts b/src/element/collision.ts new file mode 100644 index 000000000..c1b3efb57 --- /dev/null +++ b/src/element/collision.ts @@ -0,0 +1,124 @@ +import { distanceBetweenPointAndSegment } from "../math"; + +import { ExcalidrawElement } from "./types"; +import { + getElementAbsoluteX1, + getElementAbsoluteX2, + getElementAbsoluteY1, + getElementAbsoluteY2, + getArrowPoints, + getDiamondPoints +} from "./bounds"; + +export function hitTest( + element: ExcalidrawElement, + x: number, + y: number +): boolean { + // For shapes that are composed of lines, we only enable point-selection when the distance + // of the click is less than x pixels of any of the lines that the shape is composed of + const lineThreshold = 10; + + if (element.type === "ellipse") { + // https://stackoverflow.com/a/46007540/232122 + const px = Math.abs(x - element.x - element.width / 2); + const py = Math.abs(y - element.y - element.height / 2); + + let tx = 0.707; + let ty = 0.707; + + const a = element.width / 2; + const b = element.height / 2; + + [0, 1, 2, 3].forEach(x => { + const xx = a * tx; + const yy = b * ty; + + const ex = ((a * a - b * b) * tx ** 3) / a; + const ey = ((b * b - a * a) * ty ** 3) / b; + + const rx = xx - ex; + const ry = yy - ey; + + const qx = px - ex; + const qy = py - ey; + + const r = Math.hypot(ry, rx); + const q = Math.hypot(qy, qx); + + tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); + ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); + const t = Math.hypot(ty, tx); + tx /= t; + ty /= t; + }); + + return Math.hypot(a * tx - px, b * ty - py) < lineThreshold; + } else if (element.type === "rectangle") { + const x1 = getElementAbsoluteX1(element); + const x2 = getElementAbsoluteX2(element); + const y1 = getElementAbsoluteY1(element); + const y2 = getElementAbsoluteY2(element); + + // (x1, y1) --A-- (x2, y1) + // |D |B + // (x1, y2) --C-- (x2, y2) + 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 + ); + } else if (element.type === "diamond") { + x -= element.x; + y -= element.y; + + const [ + topX, + topY, + rightX, + rightY, + bottomX, + bottomY, + leftX, + leftY + ] = getDiamondPoints(element); + + return ( + distanceBetweenPointAndSegment(x, y, topX, topY, rightX, rightY) < + lineThreshold || + distanceBetweenPointAndSegment(x, y, rightX, rightY, bottomX, bottomY) < + lineThreshold || + distanceBetweenPointAndSegment(x, y, bottomX, bottomY, leftX, leftY) < + lineThreshold || + distanceBetweenPointAndSegment(x, y, leftX, leftY, topX, topY) < + lineThreshold + ); + } else if (element.type === "arrow") { + let [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element); + // The computation is done at the origin, we need to add a translation + x -= element.x; + y -= element.y; + + return ( + // \ + distanceBetweenPointAndSegment(x, y, x3, y3, x2, y2) < lineThreshold || + // ----- + distanceBetweenPointAndSegment(x, y, x1, y1, x2, y2) < lineThreshold || + // / + distanceBetweenPointAndSegment(x, y, x4, y4, x2, y2) < lineThreshold + ); + } else if (element.type === "text") { + const x1 = getElementAbsoluteX1(element); + const x2 = getElementAbsoluteX2(element); + const y1 = getElementAbsoluteY1(element); + const y2 = getElementAbsoluteY2(element); + + return x >= x1 && x <= x2 && y >= y1 && y <= y2; + } else if (element.type === "selection") { + console.warn("This should not happen, we need to investigate why it does."); + return false; + } else { + throw new Error("Unimplemented type " + element.type); + } +} diff --git a/src/element/generateDraw.ts b/src/element/generateDraw.ts new file mode 100644 index 000000000..7a7086d2b --- /dev/null +++ b/src/element/generateDraw.ts @@ -0,0 +1,145 @@ +import rough from "roughjs/bin/wrappers/rough"; + +import { withCustomMathRandom } from "../random"; + +import { ExcalidrawElement } from "./types"; +import { isTextElement } from "./typeChecks"; +import { getDiamondPoints, getArrowPoints } from "./bounds"; + +// Casting second argument (DrawingSurface) to any, +// because it is requred by TS definitions and not required at runtime +const generator = rough.generator(null, null as any); + +export function generateDraw(element: ExcalidrawElement) { + if (element.type === "selection") { + element.draw = (rc, context, { scrollX, scrollY }) => { + const fillStyle = context.fillStyle; + context.fillStyle = "rgba(0, 0, 255, 0.10)"; + context.fillRect( + element.x + scrollX, + element.y + scrollY, + element.width, + element.height + ); + context.fillStyle = fillStyle; + }; + } else if (element.type === "rectangle") { + const shape = withCustomMathRandom(element.seed, () => { + return generator.rectangle(0, 0, element.width, element.height, { + stroke: element.strokeColor, + fill: element.backgroundColor, + fillStyle: element.fillStyle, + strokeWidth: element.strokeWidth, + roughness: element.roughness + }); + }); + element.draw = (rc, context, { scrollX, scrollY }) => { + context.globalAlpha = element.opacity / 100; + context.translate(element.x + scrollX, element.y + scrollY); + rc.draw(shape); + context.translate(-element.x - scrollX, -element.y - scrollY); + context.globalAlpha = 1; + }; + } else if (element.type === "diamond") { + const shape = withCustomMathRandom(element.seed, () => { + const [ + topX, + topY, + rightX, + rightY, + bottomX, + bottomY, + leftX, + leftY + ] = getDiamondPoints(element); + return generator.polygon( + [ + [topX, topY], + [rightX, rightY], + [bottomX, bottomY], + [leftX, leftY] + ], + { + stroke: element.strokeColor, + fill: element.backgroundColor, + fillStyle: element.fillStyle, + strokeWidth: element.strokeWidth, + roughness: element.roughness + } + ); + }); + element.draw = (rc, context, { scrollX, scrollY }) => { + context.globalAlpha = element.opacity / 100; + context.translate(element.x + scrollX, element.y + scrollY); + rc.draw(shape); + context.translate(-element.x - scrollX, -element.y - scrollY); + context.globalAlpha = 1; + }; + } else if (element.type === "ellipse") { + const shape = withCustomMathRandom(element.seed, () => + generator.ellipse( + element.width / 2, + element.height / 2, + element.width, + element.height, + { + stroke: element.strokeColor, + fill: element.backgroundColor, + fillStyle: element.fillStyle, + strokeWidth: element.strokeWidth, + roughness: element.roughness + } + ) + ); + element.draw = (rc, context, { scrollX, scrollY }) => { + context.globalAlpha = element.opacity / 100; + context.translate(element.x + scrollX, element.y + scrollY); + rc.draw(shape); + context.translate(-element.x - scrollX, -element.y - scrollY); + context.globalAlpha = 1; + }; + } else if (element.type === "arrow") { + const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element); + const options = { + stroke: element.strokeColor, + strokeWidth: element.strokeWidth, + roughness: element.roughness + }; + + const shapes = withCustomMathRandom(element.seed, () => [ + // \ + generator.line(x3, y3, x2, y2, options), + // ----- + generator.line(x1, y1, x2, y2, options), + // / + generator.line(x4, y4, x2, y2, options) + ]); + + element.draw = (rc, context, { scrollX, scrollY }) => { + context.globalAlpha = element.opacity / 100; + context.translate(element.x + scrollX, element.y + scrollY); + shapes.forEach(shape => rc.draw(shape)); + context.translate(-element.x - scrollX, -element.y - scrollY); + context.globalAlpha = 1; + }; + return; + } else if (isTextElement(element)) { + element.draw = (rc, context, { scrollX, scrollY }) => { + context.globalAlpha = element.opacity / 100; + const font = context.font; + context.font = element.font; + const fillStyle = context.fillStyle; + context.fillStyle = element.strokeColor; + context.fillText( + element.text, + element.x + scrollX, + element.y + element.actualBoundingBoxAscent + scrollY + ); + context.fillStyle = fillStyle; + context.font = font; + context.globalAlpha = 1; + }; + } else { + throw new Error("Unimplemented type " + element.type); + } +} diff --git a/src/element/handlerRectangles.ts b/src/element/handlerRectangles.ts new file mode 100644 index 000000000..e261ac978 --- /dev/null +++ b/src/element/handlerRectangles.ts @@ -0,0 +1,85 @@ +import { SceneState } from "../scene/types"; +import { ExcalidrawElement } from "./types"; + +export function handlerRectangles( + element: ExcalidrawElement, + sceneState: SceneState +) { + const elementX1 = element.x; + const elementX2 = element.x + element.width; + const elementY1 = element.y; + const elementY2 = element.y + element.height; + + const margin = 4; + const minimumSize = 40; + const handlers: { [handler: string]: number[] } = {}; + + const marginX = element.width < 0 ? 8 : -8; + const marginY = element.height < 0 ? 8 : -8; + + if (Math.abs(elementX2 - elementX1) > minimumSize) { + handlers["n"] = [ + elementX1 + (elementX2 - elementX1) / 2 + sceneState.scrollX - 4, + elementY1 - margin + sceneState.scrollY + marginY, + 8, + 8 + ]; + + handlers["s"] = [ + elementX1 + (elementX2 - elementX1) / 2 + sceneState.scrollX - 4, + elementY2 - margin + sceneState.scrollY - marginY, + 8, + 8 + ]; + } + + if (Math.abs(elementY2 - elementY1) > minimumSize) { + handlers["w"] = [ + elementX1 - margin + sceneState.scrollX + marginX, + elementY1 + (elementY2 - elementY1) / 2 + sceneState.scrollY - 4, + 8, + 8 + ]; + + handlers["e"] = [ + elementX2 - margin + sceneState.scrollX - marginX, + elementY1 + (elementY2 - elementY1) / 2 + sceneState.scrollY - 4, + 8, + 8 + ]; + } + + handlers["nw"] = [ + elementX1 - margin + sceneState.scrollX + marginX, + elementY1 - margin + sceneState.scrollY + marginY, + 8, + 8 + ]; // nw + handlers["ne"] = [ + elementX2 - margin + sceneState.scrollX - marginX, + elementY1 - margin + sceneState.scrollY + marginY, + 8, + 8 + ]; // ne + handlers["sw"] = [ + elementX1 - margin + sceneState.scrollX + marginX, + elementY2 - margin + sceneState.scrollY - marginY, + 8, + 8 + ]; // sw + handlers["se"] = [ + elementX2 - margin + sceneState.scrollX - marginX, + elementY2 - margin + sceneState.scrollY - marginY, + 8, + 8 + ]; // se + + if (element.type === "arrow") { + return { + nw: handlers.nw, + se: handlers.se + }; + } + + return handlers; +} diff --git a/src/element/index.ts b/src/element/index.ts new file mode 100644 index 000000000..2a96d8cf9 --- /dev/null +++ b/src/element/index.ts @@ -0,0 +1,15 @@ +export { newElement } from "./newElement"; +export { + getElementAbsoluteX1, + getElementAbsoluteX2, + getElementAbsoluteY1, + getElementAbsoluteY2, + getDiamondPoints, + getArrowPoints +} from "./bounds"; + +export { handlerRectangles } from "./handlerRectangles"; +export { hitTest } from "./collision"; +export { resizeTest } from "./resizeTest"; +export { generateDraw } from "./generateDraw"; +export { isTextElement } from "./typeChecks"; diff --git a/src/element/newElement.ts b/src/element/newElement.ts new file mode 100644 index 000000000..609b27d6f --- /dev/null +++ b/src/element/newElement.ts @@ -0,0 +1,40 @@ +import { RoughCanvas } from "roughjs/bin/canvas"; + +import { SceneState } from "../scene/types"; +import { randomSeed } from "../random"; + +export function newElement( + type: string, + x: number, + y: number, + strokeColor: string, + backgroundColor: string, + fillStyle: string, + strokeWidth: number, + roughness: number, + opacity: number, + width = 0, + height = 0 +) { + const element = { + type: type, + x: x, + y: y, + width: width, + height: height, + isSelected: false, + strokeColor: strokeColor, + backgroundColor: backgroundColor, + fillStyle: fillStyle, + strokeWidth: strokeWidth, + roughness: roughness, + opacity: opacity, + seed: randomSeed(), + draw( + rc: RoughCanvas, + context: CanvasRenderingContext2D, + sceneState: SceneState + ) {} + }; + return element; +} diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts new file mode 100644 index 000000000..8af1b148d --- /dev/null +++ b/src/element/resizeTest.ts @@ -0,0 +1,32 @@ +import { ExcalidrawElement } from "./types"; +import { SceneState } from "../scene/types"; + +import { handlerRectangles } from "./handlerRectangles"; + +export function resizeTest( + element: ExcalidrawElement, + x: number, + y: number, + sceneState: SceneState +): string | false { + if (element.type === "text") return false; + + const handlers = handlerRectangles(element, sceneState); + + const filter = Object.keys(handlers).filter(key => { + const handler = handlers[key]; + + return ( + x + sceneState.scrollX >= handler[0] && + x + sceneState.scrollX <= handler[0] + handler[2] && + y + sceneState.scrollY >= handler[1] && + y + sceneState.scrollY <= handler[1] + handler[3] + ); + }); + + if (filter.length > 0) { + return filter[0]; + } + + return false; +} diff --git a/src/element/typeChecks.ts b/src/element/typeChecks.ts new file mode 100644 index 000000000..9fa6e01ce --- /dev/null +++ b/src/element/typeChecks.ts @@ -0,0 +1,7 @@ +import { ExcalidrawElement, ExcalidrawTextElement } from "./types"; + +export function isTextElement( + element: ExcalidrawElement +): element is ExcalidrawTextElement { + return element.type === "text"; +} diff --git a/src/element/types.ts b/src/element/types.ts new file mode 100644 index 000000000..1662bfdf0 --- /dev/null +++ b/src/element/types.ts @@ -0,0 +1,9 @@ +import { newElement } from "./newElement"; + +export type ExcalidrawElement = ReturnType; +export type ExcalidrawTextElement = ExcalidrawElement & { + type: "text"; + font: string; + text: string; + actualBoundingBoxAscent: number; +}; diff --git a/src/index.tsx b/src/index.tsx index d29567b4b..e1ed35ac5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,22 +5,27 @@ import { RoughCanvas } from "roughjs/bin/canvas"; import { TwitterPicker } from "react-color"; import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex"; -import { LCG, randomSeed, withCustomMathRandom } from "./random"; -import { distanceBetweenPointAndSegment } from "./math"; +import { randomSeed } from "./random"; import { roundRect } from "./roundRect"; +import { + newElement, + resizeTest, + generateDraw, + getElementAbsoluteX1, + getElementAbsoluteX2, + getElementAbsoluteY1, + getElementAbsoluteY2, + handlerRectangles, + hitTest, + isTextElement +} from "./element"; +import { SceneState } from "./scene/types"; +import { ExcalidrawElement, ExcalidrawTextElement } from "./element/types"; import EditableText from "./components/EditableText"; import "./styles.scss"; -type ExcalidrawElement = ReturnType; -type ExcalidrawTextElement = ExcalidrawElement & { - type: "text"; - font: string; - text: string; - actualBoundingBoxAscent: number; -}; - const LOCAL_STORAGE_KEY = "excalidraw"; const LOCAL_STORAGE_KEY_STATE = "excalidraw-state"; @@ -58,186 +63,6 @@ function restoreHistoryEntry(entry: string) { skipHistory = true; } -function hitTest(element: ExcalidrawElement, x: number, y: number): boolean { - // For shapes that are composed of lines, we only enable point-selection when the distance - // of the click is less than x pixels of any of the lines that the shape is composed of - const lineThreshold = 10; - - if (element.type === "ellipse") { - // https://stackoverflow.com/a/46007540/232122 - const px = Math.abs(x - element.x - element.width / 2); - const py = Math.abs(y - element.y - element.height / 2); - - let tx = 0.707; - let ty = 0.707; - - const a = element.width / 2; - const b = element.height / 2; - - [0, 1, 2, 3].forEach(x => { - const xx = a * tx; - const yy = b * ty; - - const ex = ((a * a - b * b) * tx ** 3) / a; - const ey = ((b * b - a * a) * ty ** 3) / b; - - const rx = xx - ex; - const ry = yy - ey; - - const qx = px - ex; - const qy = py - ey; - - const r = Math.hypot(ry, rx); - const q = Math.hypot(qy, qx); - - tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); - ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); - const t = Math.hypot(ty, tx); - tx /= t; - ty /= t; - }); - - return Math.hypot(a * tx - px, b * ty - py) < lineThreshold; - } else if (element.type === "rectangle") { - const x1 = getElementAbsoluteX1(element); - const x2 = getElementAbsoluteX2(element); - const y1 = getElementAbsoluteY1(element); - const y2 = getElementAbsoluteY2(element); - - // (x1, y1) --A-- (x2, y1) - // |D |B - // (x1, y2) --C-- (x2, y2) - 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 - ); - } else if (element.type === "diamond") { - x -= element.x; - y -= element.y; - - const [ - topX, - topY, - rightX, - rightY, - bottomX, - bottomY, - leftX, - leftY - ] = getDiamondPoints(element); - - return ( - distanceBetweenPointAndSegment(x, y, topX, topY, rightX, rightY) < - lineThreshold || - distanceBetweenPointAndSegment(x, y, rightX, rightY, bottomX, bottomY) < - lineThreshold || - distanceBetweenPointAndSegment(x, y, bottomX, bottomY, leftX, leftY) < - lineThreshold || - distanceBetweenPointAndSegment(x, y, leftX, leftY, topX, topY) < - lineThreshold - ); - } else if (element.type === "arrow") { - let [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element); - // The computation is done at the origin, we need to add a translation - x -= element.x; - y -= element.y; - - return ( - // \ - distanceBetweenPointAndSegment(x, y, x3, y3, x2, y2) < lineThreshold || - // ----- - distanceBetweenPointAndSegment(x, y, x1, y1, x2, y2) < lineThreshold || - // / - distanceBetweenPointAndSegment(x, y, x4, y4, x2, y2) < lineThreshold - ); - } else if (element.type === "text") { - const x1 = getElementAbsoluteX1(element); - const x2 = getElementAbsoluteX2(element); - const y1 = getElementAbsoluteY1(element); - const y2 = getElementAbsoluteY2(element); - - return x >= x1 && x <= x2 && y >= y1 && y <= y2; - } else if (element.type === "selection") { - console.warn("This should not happen, we need to investigate why it does."); - return false; - } else { - throw new Error("Unimplemented type " + element.type); - } -} - -function resizeTest( - element: ExcalidrawElement, - x: number, - y: number, - sceneState: SceneState -): string | false { - if (element.type === "text") return false; - - const handlers = handlerRectangles(element, sceneState); - - const filter = Object.keys(handlers).filter(key => { - const handler = handlers[key]; - - return ( - x + sceneState.scrollX >= handler[0] && - x + sceneState.scrollX <= handler[0] + handler[2] && - y + sceneState.scrollY >= handler[1] && - y + sceneState.scrollY <= handler[1] + handler[3] - ); - }); - - if (filter.length > 0) { - return filter[0]; - } - - return false; -} - -function newElement( - type: string, - x: number, - y: number, - strokeColor: string, - backgroundColor: string, - fillStyle: string, - strokeWidth: number, - roughness: number, - opacity: number, - width = 0, - height = 0 -) { - const element = { - type: type, - x: x, - y: y, - width: width, - height: height, - isSelected: false, - strokeColor: strokeColor, - backgroundColor: backgroundColor, - fillStyle: fillStyle, - strokeWidth: strokeWidth, - roughness: roughness, - opacity: opacity, - seed: randomSeed(), - draw( - rc: RoughCanvas, - context: CanvasRenderingContext2D, - sceneState: SceneState - ) {} - }; - return element; -} - -type SceneState = { - scrollX: number; - scrollY: number; - // null indicates transparent bg - viewBackgroundColor: string | null; -}; - const SCROLLBAR_WIDTH = 6; const SCROLLBAR_MIN_SIZE = 15; const SCROLLBAR_MARGIN = 4; @@ -340,86 +165,6 @@ function isOverScrollBars( }; } -function handlerRectangles(element: ExcalidrawElement, sceneState: SceneState) { - const elementX1 = element.x; - const elementX2 = element.x + element.width; - const elementY1 = element.y; - const elementY2 = element.y + element.height; - - const margin = 4; - const minimumSize = 40; - const handlers: { [handler: string]: number[] } = {}; - - const marginX = element.width < 0 ? 8 : -8; - const marginY = element.height < 0 ? 8 : -8; - - if (Math.abs(elementX2 - elementX1) > minimumSize) { - handlers["n"] = [ - elementX1 + (elementX2 - elementX1) / 2 + sceneState.scrollX - 4, - elementY1 - margin + sceneState.scrollY + marginY, - 8, - 8 - ]; - - handlers["s"] = [ - elementX1 + (elementX2 - elementX1) / 2 + sceneState.scrollX - 4, - elementY2 - margin + sceneState.scrollY - marginY, - 8, - 8 - ]; - } - - if (Math.abs(elementY2 - elementY1) > minimumSize) { - handlers["w"] = [ - elementX1 - margin + sceneState.scrollX + marginX, - elementY1 + (elementY2 - elementY1) / 2 + sceneState.scrollY - 4, - 8, - 8 - ]; - - handlers["e"] = [ - elementX2 - margin + sceneState.scrollX - marginX, - elementY1 + (elementY2 - elementY1) / 2 + sceneState.scrollY - 4, - 8, - 8 - ]; - } - - handlers["nw"] = [ - elementX1 - margin + sceneState.scrollX + marginX, - elementY1 - margin + sceneState.scrollY + marginY, - 8, - 8 - ]; // nw - handlers["ne"] = [ - elementX2 - margin + sceneState.scrollX - marginX, - elementY1 - margin + sceneState.scrollY + marginY, - 8, - 8 - ]; // ne - handlers["sw"] = [ - elementX1 - margin + sceneState.scrollX + marginX, - elementY2 - margin + sceneState.scrollY - marginY, - 8, - 8 - ]; // sw - handlers["se"] = [ - elementX2 - margin + sceneState.scrollX - marginX, - elementY2 - margin + sceneState.scrollY - marginY, - 8, - 8 - ]; // se - - if (element.type === "arrow") { - return { - nw: handlers.nw, - se: handlers.se - }; - } - - return handlers; -} - function renderScene( rc: RoughCanvas, canvas: HTMLCanvasElement, @@ -624,16 +369,6 @@ function saveFile(name: string, data: string) { link.remove(); } -function rotate(x1: number, y1: number, x2: number, y2: number, angle: number) { - // π‘Žβ€²π‘₯=(π‘Žπ‘₯βˆ’π‘π‘₯)cosπœƒβˆ’(π‘Žπ‘¦βˆ’π‘π‘¦)sinπœƒ+𝑐π‘₯ - // π‘Žβ€²π‘¦=(π‘Žπ‘₯βˆ’π‘π‘₯)sinπœƒ+(π‘Žπ‘¦βˆ’π‘π‘¦)cosπœƒ+𝑐𝑦. - // https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line - return [ - (x1 - x2) * Math.cos(angle) - (y1 - y2) * Math.sin(angle) + x2, - (x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2 - ]; -} - function getDateTime() { const date = new Date(); const year = date.getFullYear(); @@ -646,16 +381,6 @@ function getDateTime() { return `${year}${month}${day}${hr}${min}${secs}`; } -// Casting second argument (DrawingSurface) to any, -// because it is requred by TS definitions and not required at runtime -const generator = rough.generator(null, null as any); - -function isTextElement( - element: ExcalidrawElement -): element is ExcalidrawTextElement { - return element.type === "text"; -} - function isInputLike( target: Element | EventTarget | null ): target is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement { @@ -666,190 +391,6 @@ function isInputLike( ); } -function getArrowPoints(element: ExcalidrawElement) { - const x1 = 0; - const y1 = 0; - const x2 = element.width; - const y2 = element.height; - - const size = 30; // pixels - const distance = Math.hypot(x2 - x1, y2 - y1); - // Scale down the arrow until we hit a certain size so that it doesn't look weird - const minSize = Math.min(size, distance / 2); - const xs = x2 - ((x2 - x1) / distance) * minSize; - const ys = y2 - ((y2 - y1) / distance) * minSize; - - const angle = 20; // degrees - const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180); - const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180); - - return [x1, y1, x2, y2, x3, y3, x4, y4]; -} - -function getDiamondPoints(element: ExcalidrawElement) { - const topX = Math.floor(element.width / 2) + 1; - const topY = 0; - const rightX = element.width; - const rightY = Math.floor(element.height / 2) + 1; - const bottomX = topX; - const bottomY = element.height; - const leftX = topY; - const leftY = rightY; - - return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY]; -} - -function generateDraw(element: ExcalidrawElement) { - if (element.type === "selection") { - element.draw = (rc, context, { scrollX, scrollY }) => { - const fillStyle = context.fillStyle; - context.fillStyle = "rgba(0, 0, 255, 0.10)"; - context.fillRect( - element.x + scrollX, - element.y + scrollY, - element.width, - element.height - ); - context.fillStyle = fillStyle; - }; - } else if (element.type === "rectangle") { - const shape = withCustomMathRandom(element.seed, () => { - return generator.rectangle(0, 0, element.width, element.height, { - stroke: element.strokeColor, - fill: element.backgroundColor, - fillStyle: element.fillStyle, - strokeWidth: element.strokeWidth, - roughness: element.roughness - }); - }); - element.draw = (rc, context, { scrollX, scrollY }) => { - context.globalAlpha = element.opacity / 100; - context.translate(element.x + scrollX, element.y + scrollY); - rc.draw(shape); - context.translate(-element.x - scrollX, -element.y - scrollY); - context.globalAlpha = 1; - }; - } else if (element.type === "diamond") { - const shape = withCustomMathRandom(element.seed, () => { - const [ - topX, - topY, - rightX, - rightY, - bottomX, - bottomY, - leftX, - leftY - ] = getDiamondPoints(element); - return generator.polygon( - [ - [topX, topY], - [rightX, rightY], - [bottomX, bottomY], - [leftX, leftY] - ], - { - stroke: element.strokeColor, - fill: element.backgroundColor, - fillStyle: element.fillStyle, - strokeWidth: element.strokeWidth, - roughness: element.roughness - } - ); - }); - element.draw = (rc, context, { scrollX, scrollY }) => { - context.globalAlpha = element.opacity / 100; - context.translate(element.x + scrollX, element.y + scrollY); - rc.draw(shape); - context.translate(-element.x - scrollX, -element.y - scrollY); - context.globalAlpha = 1; - }; - } else if (element.type === "ellipse") { - const shape = withCustomMathRandom(element.seed, () => - generator.ellipse( - element.width / 2, - element.height / 2, - element.width, - element.height, - { - stroke: element.strokeColor, - fill: element.backgroundColor, - fillStyle: element.fillStyle, - strokeWidth: element.strokeWidth, - roughness: element.roughness - } - ) - ); - element.draw = (rc, context, { scrollX, scrollY }) => { - context.globalAlpha = element.opacity / 100; - context.translate(element.x + scrollX, element.y + scrollY); - rc.draw(shape); - context.translate(-element.x - scrollX, -element.y - scrollY); - context.globalAlpha = 1; - }; - } else if (element.type === "arrow") { - const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element); - const options = { - stroke: element.strokeColor, - strokeWidth: element.strokeWidth, - roughness: element.roughness - }; - - const shapes = withCustomMathRandom(element.seed, () => [ - // \ - generator.line(x3, y3, x2, y2, options), - // ----- - generator.line(x1, y1, x2, y2, options), - // / - generator.line(x4, y4, x2, y2, options) - ]); - - element.draw = (rc, context, { scrollX, scrollY }) => { - context.globalAlpha = element.opacity / 100; - context.translate(element.x + scrollX, element.y + scrollY); - shapes.forEach(shape => rc.draw(shape)); - context.translate(-element.x - scrollX, -element.y - scrollY); - context.globalAlpha = 1; - }; - return; - } else if (isTextElement(element)) { - element.draw = (rc, context, { scrollX, scrollY }) => { - context.globalAlpha = element.opacity / 100; - const font = context.font; - context.font = element.font; - const fillStyle = context.fillStyle; - context.fillStyle = element.strokeColor; - context.fillText( - element.text, - element.x + scrollX, - element.y + element.actualBoundingBoxAscent + scrollY - ); - context.fillStyle = fillStyle; - context.font = font; - context.globalAlpha = 1; - }; - } else { - throw new Error("Unimplemented type " + element.type); - } -} - -// If the element is created from right to left, the width is going to be negative -// This set of functions retrieves the absolute position of the 4 points. -// We can't just always normalize it since we need to remember the fact that an arrow -// is pointing left or right. -function getElementAbsoluteX1(element: ExcalidrawElement) { - return element.width >= 0 ? element.x : element.x + element.width; -} -function getElementAbsoluteX2(element: ExcalidrawElement) { - return element.width >= 0 ? element.x + element.width : element.x; -} -function getElementAbsoluteY1(element: ExcalidrawElement) { - return element.height >= 0 ? element.y : element.y + element.height; -} -function getElementAbsoluteY2(element: ExcalidrawElement) { - return element.height >= 0 ? element.y + element.height : element.y; -} - function setSelection(selection: ExcalidrawElement) { const selectionX1 = getElementAbsoluteX1(selection); const selectionX2 = getElementAbsoluteX2(selection); diff --git a/src/math.ts b/src/math.ts index f699fea34..5b8f119fc 100644 --- a/src/math.ts +++ b/src/math.ts @@ -36,3 +36,19 @@ export function distanceBetweenPointAndSegment( const dy = y - yy; return Math.hypot(dx, dy); } + +export function rotate( + x1: number, + y1: number, + x2: number, + y2: number, + angle: number +) { + // π‘Žβ€²π‘₯=(π‘Žπ‘₯βˆ’π‘π‘₯)cosπœƒβˆ’(π‘Žπ‘¦βˆ’π‘π‘¦)sinπœƒ+𝑐π‘₯ + // π‘Žβ€²π‘¦=(π‘Žπ‘₯βˆ’π‘π‘₯)sinπœƒ+(π‘Žπ‘¦βˆ’π‘π‘¦)cosπœƒ+𝑐𝑦. + // https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line + return [ + (x1 - x2) * Math.cos(angle) - (y1 - y2) * Math.sin(angle) + x2, + (x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2 + ]; +} diff --git a/src/scene/types.ts b/src/scene/types.ts new file mode 100644 index 000000000..30d5c58d2 --- /dev/null +++ b/src/scene/types.ts @@ -0,0 +1,6 @@ +export type SceneState = { + scrollX: number; + scrollY: number; + // null indicates transparent bg + viewBackgroundColor: string | null; +}; From 86a1c29eec7793eb9fbe29820753e5fcc01089c5 Mon Sep 17 00:00:00 2001 From: Gasim Gasimzada Date: Mon, 6 Jan 2020 20:24:54 +0400 Subject: [PATCH 2/5] Extract scene functions to their respective modules (#208) - Also, extract utilities into utils module -- capitalizeString, getDateTime, isInputLike --- src/index.tsx | 603 +++++---------------------------------- src/scene/comparisons.ts | 38 +++ src/scene/createScene.ts | 6 + src/scene/data.ts | 183 ++++++++++++ src/scene/index.ts | 19 ++ src/scene/render.ts | 109 +++++++ src/scene/roundRect.ts | 37 +++ src/scene/scrollbars.ts | 115 ++++++++ src/scene/selection.ts | 70 +++++ src/scene/types.ts | 6 + src/types.ts | 14 + src/utils.ts | 25 ++ 12 files changed, 695 insertions(+), 530 deletions(-) create mode 100644 src/scene/comparisons.ts create mode 100644 src/scene/createScene.ts create mode 100644 src/scene/data.ts create mode 100644 src/scene/index.ts create mode 100644 src/scene/render.ts create mode 100644 src/scene/roundRect.ts create mode 100644 src/scene/scrollbars.ts create mode 100644 src/scene/selection.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts diff --git a/src/index.tsx b/src/index.tsx index e1ed35ac5..fb4966eeb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,35 +1,40 @@ import React from "react"; import ReactDOM from "react-dom"; import rough from "roughjs/bin/wrappers/rough"; -import { RoughCanvas } from "roughjs/bin/canvas"; import { TwitterPicker } from "react-color"; import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex"; import { randomSeed } from "./random"; -import { roundRect } from "./roundRect"; +import { newElement, resizeTest, generateDraw, isTextElement } from "./element"; import { - newElement, - resizeTest, - generateDraw, - getElementAbsoluteX1, - getElementAbsoluteX2, - getElementAbsoluteY1, - getElementAbsoluteY2, - handlerRectangles, - hitTest, - isTextElement -} from "./element"; -import { SceneState } from "./scene/types"; + renderScene, + clearSelection, + getSelectedIndices, + deleteSelectedElements, + setSelection, + isOverScrollBars, + someElementIsSelected, + getSelectedAttribute, + loadFromJSON, + saveAsJSON, + exportAsPNG, + restoreFromLocalStorage, + saveToLocalStorage, + hasBackground, + hasStroke, + getElementAtPosition, + createScene +} from "./scene"; +import { AppState } from "./types"; import { ExcalidrawElement, ExcalidrawTextElement } from "./element/types"; +import { getDateTime, capitalizeString, isInputLike } from "./utils"; + import EditableText from "./components/EditableText"; import "./styles.scss"; -const LOCAL_STORAGE_KEY = "excalidraw"; -const LOCAL_STORAGE_KEY_STATE = "excalidraw-state"; - -const elements = Array.of(); +const { elements } = createScene(); const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`; @@ -63,429 +68,9 @@ function restoreHistoryEntry(entry: string) { skipHistory = true; } -const SCROLLBAR_WIDTH = 6; -const SCROLLBAR_MIN_SIZE = 15; -const SCROLLBAR_MARGIN = 4; -const SCROLLBAR_COLOR = "rgba(0,0,0,0.3)"; const CANVAS_WINDOW_OFFSET_LEFT = 250; const CANVAS_WINDOW_OFFSET_TOP = 0; -function getScrollBars( - canvasWidth: number, - canvasHeight: number, - scrollX: number, - scrollY: number -) { - let minX = Infinity; - let maxX = 0; - let minY = Infinity; - let maxY = 0; - - elements.forEach(element => { - minX = Math.min(minX, getElementAbsoluteX1(element)); - maxX = Math.max(maxX, getElementAbsoluteX2(element)); - minY = Math.min(minY, getElementAbsoluteY1(element)); - maxY = Math.max(maxY, getElementAbsoluteY2(element)); - }); - - minX += scrollX; - maxX += scrollX; - minY += scrollY; - maxY += scrollY; - const leftOverflow = Math.max(-minX, 0); - const rightOverflow = Math.max(-(canvasWidth - maxX), 0); - const topOverflow = Math.max(-minY, 0); - const bottomOverflow = Math.max(-(canvasHeight - maxY), 0); - - // horizontal scrollbar - let horizontalScrollBar = null; - if (leftOverflow || rightOverflow) { - horizontalScrollBar = { - x: Math.min( - leftOverflow + SCROLLBAR_MARGIN, - canvasWidth - SCROLLBAR_MIN_SIZE - SCROLLBAR_MARGIN - ), - y: canvasHeight - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN, - width: Math.max( - canvasWidth - rightOverflow - leftOverflow - SCROLLBAR_MARGIN * 2, - SCROLLBAR_MIN_SIZE - ), - height: SCROLLBAR_WIDTH - }; - } - - // vertical scrollbar - let verticalScrollBar = null; - if (topOverflow || bottomOverflow) { - verticalScrollBar = { - x: canvasWidth - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN, - y: Math.min( - topOverflow + SCROLLBAR_MARGIN, - canvasHeight - SCROLLBAR_MIN_SIZE - SCROLLBAR_MARGIN - ), - width: SCROLLBAR_WIDTH, - height: Math.max( - canvasHeight - bottomOverflow - topOverflow - SCROLLBAR_WIDTH * 2, - SCROLLBAR_MIN_SIZE - ) - }; - } - - return { - horizontal: horizontalScrollBar, - vertical: verticalScrollBar - }; -} - -function isOverScrollBars( - x: number, - y: number, - canvasWidth: number, - canvasHeight: number, - scrollX: number, - scrollY: number -) { - const scrollBars = getScrollBars(canvasWidth, canvasHeight, scrollX, scrollY); - - const [isOverHorizontalScrollBar, isOverVerticalScrollBar] = [ - scrollBars.horizontal, - scrollBars.vertical - ].map( - scrollBar => - scrollBar && - scrollBar.x <= x && - x <= scrollBar.x + scrollBar.width && - scrollBar.y <= y && - y <= scrollBar.y + scrollBar.height - ); - - return { - isOverHorizontalScrollBar, - isOverVerticalScrollBar - }; -} - -function renderScene( - rc: RoughCanvas, - canvas: HTMLCanvasElement, - sceneState: SceneState, - // extra options, currently passed by export helper - { - offsetX, - offsetY, - renderScrollbars = true, - renderSelection = true - }: { - offsetX?: number; - offsetY?: number; - renderScrollbars?: boolean; - renderSelection?: boolean; - } = {} -) { - if (!canvas) return; - const context = canvas.getContext("2d")!; - - const fillStyle = context.fillStyle; - if (typeof sceneState.viewBackgroundColor === "string") { - context.fillStyle = sceneState.viewBackgroundColor; - context.fillRect(0, 0, canvas.width, canvas.height); - } else { - context.clearRect(0, 0, canvas.width, canvas.height); - } - context.fillStyle = fillStyle; - - const selectedIndices = getSelectedIndices(); - - sceneState = { - ...sceneState, - scrollX: typeof offsetX === "number" ? offsetX : sceneState.scrollX, - scrollY: typeof offsetY === "number" ? offsetY : sceneState.scrollY - }; - - elements.forEach(element => { - element.draw(rc, context, sceneState); - if (renderSelection && element.isSelected) { - const margin = 4; - - const elementX1 = getElementAbsoluteX1(element); - const elementX2 = getElementAbsoluteX2(element); - const elementY1 = getElementAbsoluteY1(element); - const elementY2 = getElementAbsoluteY2(element); - const lineDash = context.getLineDash(); - context.setLineDash([8, 4]); - context.strokeRect( - elementX1 - margin + sceneState.scrollX, - elementY1 - margin + sceneState.scrollY, - elementX2 - elementX1 + margin * 2, - elementY2 - elementY1 + margin * 2 - ); - context.setLineDash(lineDash); - - if (element.type !== "text" && selectedIndices.length === 1) { - const handlers = handlerRectangles(element, sceneState); - Object.values(handlers).forEach(handler => { - context.strokeRect(handler[0], handler[1], handler[2], handler[3]); - }); - } - } - }); - - if (renderScrollbars) { - const scrollBars = getScrollBars( - context.canvas.width / window.devicePixelRatio, - context.canvas.height / window.devicePixelRatio, - sceneState.scrollX, - sceneState.scrollY - ); - - const strokeStyle = context.strokeStyle; - context.fillStyle = SCROLLBAR_COLOR; - context.strokeStyle = "rgba(255,255,255,0.8)"; - [scrollBars.horizontal, scrollBars.vertical].forEach(scrollBar => { - if (scrollBar) - roundRect( - context, - scrollBar.x, - scrollBar.y, - scrollBar.width, - scrollBar.height, - SCROLLBAR_WIDTH / 2 - ); - }); - context.strokeStyle = strokeStyle; - context.fillStyle = fillStyle; - } -} - -function saveAsJSON(name: string) { - const serialized = JSON.stringify({ - version: 1, - source: window.location.origin, - elements - }); - - saveFile( - `${name}.json`, - "data:text/plain;charset=utf-8," + encodeURIComponent(serialized) - ); -} - -function loadFromJSON() { - const input = document.createElement("input"); - const reader = new FileReader(); - input.type = "file"; - input.accept = ".json"; - - input.onchange = () => { - if (!input.files!.length) { - alert("A file was not selected."); - return; - } - - reader.readAsText(input.files![0], "utf8"); - }; - - input.click(); - - return new Promise(resolve => { - reader.onloadend = () => { - if (reader.readyState === FileReader.DONE) { - const data = JSON.parse(reader.result as string); - restore(data.elements, null); - resolve(); - } - }; - }); -} - -function exportAsPNG({ - exportBackground, - exportPadding = 10, - viewBackgroundColor, - name -}: { - exportBackground: boolean; - exportPadding?: number; - viewBackgroundColor: string; - scrollX: number; - scrollY: number; - name: string; -}) { - if (!elements.length) return window.alert("Cannot export empty canvas."); - // calculate smallest area to fit the contents in - - let subCanvasX1 = Infinity; - let subCanvasX2 = 0; - let subCanvasY1 = Infinity; - let subCanvasY2 = 0; - - elements.forEach(element => { - subCanvasX1 = Math.min(subCanvasX1, getElementAbsoluteX1(element)); - subCanvasX2 = Math.max(subCanvasX2, getElementAbsoluteX2(element)); - subCanvasY1 = Math.min(subCanvasY1, getElementAbsoluteY1(element)); - subCanvasY2 = Math.max(subCanvasY2, getElementAbsoluteY2(element)); - }); - - function distance(x: number, y: number) { - return Math.abs(x > y ? x - y : y - x); - } - - const tempCanvas = document.createElement("canvas"); - tempCanvas.style.display = "none"; - document.body.appendChild(tempCanvas); - tempCanvas.width = distance(subCanvasX1, subCanvasX2) + exportPadding * 2; - tempCanvas.height = distance(subCanvasY1, subCanvasY2) + exportPadding * 2; - - renderScene( - rough.canvas(tempCanvas), - tempCanvas, - { - viewBackgroundColor: exportBackground ? viewBackgroundColor : null, - scrollX: 0, - scrollY: 0 - }, - { - offsetX: -subCanvasX1 + exportPadding, - offsetY: -subCanvasY1 + exportPadding, - renderScrollbars: false, - renderSelection: false - } - ); - - saveFile(`${name}.png`, tempCanvas.toDataURL("image/png")); - - // clean up the DOM - if (tempCanvas !== canvas) tempCanvas.remove(); -} - -function saveFile(name: string, data: string) { - // create a temporary elem which we'll use to download the image - const link = document.createElement("a"); - link.setAttribute("download", name); - link.setAttribute("href", data); - link.click(); - - // clean up - link.remove(); -} - -function getDateTime() { - const date = new Date(); - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - const hr = date.getHours(); - const min = date.getMinutes(); - const secs = date.getSeconds(); - - return `${year}${month}${day}${hr}${min}${secs}`; -} - -function isInputLike( - target: Element | EventTarget | null -): target is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement { - return ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement - ); -} - -function setSelection(selection: ExcalidrawElement) { - const selectionX1 = getElementAbsoluteX1(selection); - const selectionX2 = getElementAbsoluteX2(selection); - const selectionY1 = getElementAbsoluteY1(selection); - const selectionY2 = getElementAbsoluteY2(selection); - elements.forEach(element => { - const elementX1 = getElementAbsoluteX1(element); - const elementX2 = getElementAbsoluteX2(element); - const elementY1 = getElementAbsoluteY1(element); - const elementY2 = getElementAbsoluteY2(element); - element.isSelected = - element.type !== "selection" && - selectionX1 <= elementX1 && - selectionY1 <= elementY1 && - selectionX2 >= elementX2 && - selectionY2 >= elementY2; - }); -} - -function clearSelection() { - elements.forEach(element => { - element.isSelected = false; - }); -} - -function resetCursor() { - document.documentElement.style.cursor = ""; -} - -function deleteSelectedElements() { - for (let i = elements.length - 1; i >= 0; --i) { - if (elements[i].isSelected) { - elements.splice(i, 1); - } - } -} - -function save(state: AppState) { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements)); - localStorage.setItem(LOCAL_STORAGE_KEY_STATE, JSON.stringify(state)); -} - -function restoreFromLocalStorage() { - const savedElements = localStorage.getItem(LOCAL_STORAGE_KEY); - const savedState = localStorage.getItem(LOCAL_STORAGE_KEY_STATE); - - return restore(savedElements, savedState); -} - -function restore( - savedElements: string | ExcalidrawElement[] | null, - savedState: string | null -) { - try { - if (savedElements) { - elements.splice( - 0, - elements.length, - ...(typeof savedElements === "string" - ? JSON.parse(savedElements) - : savedElements) - ); - elements.forEach((element: ExcalidrawElement) => { - element.fillStyle = element.fillStyle || "hachure"; - element.strokeWidth = element.strokeWidth || 1; - element.roughness = element.roughness || 1; - element.opacity = - element.opacity === null || element.opacity === undefined - ? 100 - : element.opacity; - - generateDraw(element); - }); - } - - return savedState ? JSON.parse(savedState) : null; - } catch (e) { - elements.splice(0, elements.length); - return null; - } -} - -type AppState = { - draggingElement: ExcalidrawElement | null; - resizingElement: ExcalidrawElement | null; - elementType: string; - exportBackground: boolean; - currentItemStrokeColor: string; - currentItemBackgroundColor: string; - viewBackgroundColor: string; - scrollX: number; - scrollY: number; - name: string; -}; - const KEYS = { ARROW_LEFT: "ArrowLeft", ARROW_RIGHT: "ArrowRight", @@ -556,10 +141,6 @@ const SHAPES = [ const shapesShortcutKeys = SHAPES.map(shape => shape.value[0]); -function capitalize(str: string) { - return str.charAt(0).toUpperCase() + str.slice(1); -} - function findElementByKey(key: string) { const defaultElement = "selection"; return SHAPES.reduce((element, shape) => { @@ -578,49 +159,8 @@ function isArrowKey(keyCode: string) { ); } -function getSelectedIndices() { - const selectedIndices: number[] = []; - elements.forEach((element, index) => { - if (element.isSelected) { - selectedIndices.push(index); - } - }); - return selectedIndices; -} - -const someElementIsSelected = () => - elements.some(element => element.isSelected); - -const hasBackground = () => - elements.some( - element => - element.isSelected && - (element.type === "rectangle" || - element.type === "ellipse" || - element.type === "diamond") - ); - -const hasStroke = () => - elements.some( - element => - element.isSelected && - (element.type === "rectangle" || - element.type === "ellipse" || - element.type === "diamond" || - element.type === "arrow") - ); - -function getSelectedAttribute( - getAttribute: (element: ExcalidrawElement) => T -): T | null { - const attributes = Array.from( - new Set( - elements - .filter(element => element.isSelected) - .map(element => getAttribute(element)) - ) - ); - return attributes.length === 1 ? attributes[0] : null; +function resetCursor() { + document.documentElement.style.cursor = ""; } function addTextElement(element: ExcalidrawTextElement) { @@ -651,19 +191,6 @@ function addTextElement(element: ExcalidrawTextElement) { return true; } -function getElementAtPosition(x: number, y: number) { - let hitElement = null; - // We need to to hit testing from front (end of the array) to back (beginning of the array) - for (let i = elements.length - 1; i >= 0; --i) { - if (hitTest(elements[i], x, y)) { - hitElement = elements[i]; - break; - } - } - - return hitElement; -} - function ButtonSelect({ options, value, @@ -751,7 +278,7 @@ class App extends React.Component<{}, AppState> { document.addEventListener("keydown", this.onKeyDown, false); window.addEventListener("resize", this.onResize, false); - const savedState = restoreFromLocalStorage(); + const savedState = restoreFromLocalStorage(elements); if (savedState) { this.setState(savedState); } @@ -783,11 +310,11 @@ class App extends React.Component<{}, AppState> { if (isInputLike(event.target)) return; if (event.key === KEYS.ESCAPE) { - clearSelection(); + clearSelection(elements); this.forceUpdate(); event.preventDefault(); } else if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) { - deleteSelectedElements(); + deleteSelectedElements(elements); this.forceUpdate(); event.preventDefault(); } else if (isArrowKey(event.key)) { @@ -871,7 +398,7 @@ class App extends React.Component<{}, AppState> { }; private deleteSelectedElements = () => { - deleteSelectedElements(); + deleteSelectedElements(elements); this.forceUpdate(); }; @@ -888,22 +415,22 @@ class App extends React.Component<{}, AppState> { }; private moveAllLeft = () => { - moveAllLeft(elements, getSelectedIndices()); + moveAllLeft(elements, getSelectedIndices(elements)); this.forceUpdate(); }; private moveOneLeft = () => { - moveOneLeft(elements, getSelectedIndices()); + moveOneLeft(elements, getSelectedIndices(elements)); this.forceUpdate(); }; private moveAllRight = () => { - moveAllRight(elements, getSelectedIndices()); + moveAllRight(elements, getSelectedIndices(elements)); this.forceUpdate(); }; private moveOneRight = () => { - moveOneRight(elements, getSelectedIndices()); + moveOneRight(elements, getSelectedIndices(elements)); this.forceUpdate(); }; @@ -950,7 +477,7 @@ class App extends React.Component<{}, AppState> { "text/plain", JSON.stringify(elements.filter(element => element.isSelected)) ); - deleteSelectedElements(); + deleteSelectedElements(elements); this.forceUpdate(); e.preventDefault(); }} @@ -972,7 +499,7 @@ class App extends React.Component<{}, AppState> { parsedElements.length > 0 && parsedElements[0].type // need to implement a better check here... ) { - clearSelection(); + clearSelection(elements); parsedElements.forEach(parsedElement => { parsedElement.x += 10; parsedElement.y += 10; @@ -992,14 +519,16 @@ class App extends React.Component<{}, AppState> { ))} - {someElementIsSelected() && ( + {someElementIsSelected(elements) && (

Selection

@@ -1020,15 +549,19 @@ class App extends React.Component<{}, AppState> {
Stroke Color
element.strokeColor)} + color={getSelectedAttribute( + elements, + element => element.strokeColor + )} onChange={color => this.changeStrokeColor(color)} /> - {hasBackground() && ( + {hasBackground(elements) && ( <>
Background Color
element.backgroundColor )} onChange={color => this.changeBackgroundColor(color)} @@ -1040,7 +573,10 @@ class App extends React.Component<{}, AppState> { { value: "hachure", text: "Hachure" }, { value: "cross-hatch", text: "Cross-hatch" } ]} - value={getSelectedAttribute(element => element.fillStyle)} + value={getSelectedAttribute( + elements, + element => element.fillStyle + )} onChange={value => { this.changeProperty(element => { element.fillStyle = value; @@ -1050,7 +586,7 @@ class App extends React.Component<{}, AppState> { )} - {hasStroke() && ( + {hasStroke(elements) && ( <>
Stroke Width
{ { value: 2, text: "Bold" }, { value: 4, text: "Extra Bold" } ]} - value={getSelectedAttribute(element => element.strokeWidth)} + value={getSelectedAttribute( + elements, + element => element.strokeWidth + )} onChange={value => { this.changeProperty(element => { element.strokeWidth = value; @@ -1074,7 +613,10 @@ class App extends React.Component<{}, AppState> { { value: 1, text: "Artist" }, { value: 3, text: "Cartoonist" } ]} - value={getSelectedAttribute(element => element.roughness)} + value={getSelectedAttribute( + elements, + element => element.roughness + )} onChange={value => this.changeProperty(element => { element.roughness = value; @@ -1091,7 +633,7 @@ class App extends React.Component<{}, AppState> { max="100" onChange={this.changeOpacity} value={ - getSelectedAttribute(element => element.opacity) || + getSelectedAttribute(elements, element => element.opacity) || 0 /* Put the opacity at 0 if there are two conflicting ones */ } /> @@ -1127,7 +669,7 @@ class App extends React.Component<{}, AppState> {
Image
+ ))} +
+ ); +} diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx new file mode 100644 index 000000000..65dfc78b5 --- /dev/null +++ b/src/components/ColorPicker.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { TwitterPicker } from "react-color"; + +export function ColorPicker({ + color, + onChange +}: { + color: string | null; + onChange: (color: string) => void; +}) { + const [isActive, setActive] = React.useState(false); + return ( +
+ - ))} -
- ); -} - -function ColorPicker({ - color, - onChange -}: { - color: string | null; - onChange: (color: string) => void; -}) { - const [isActive, setActive] = React.useState(false); - return ( -
-