From dab35c9033f6c14769ae7bbf5979c2fdd8d983c3 Mon Sep 17 00:00:00 2001 From: Gasim Gasimzada Date: Tue, 4 Feb 2020 13:45:22 +0400 Subject: [PATCH] Multi Point Lines (based on Multi Point Arrows) (#660) * Enable multi points in lines * Stop retrieving arrow points for lines * Migrate lines to new spec during load * Clean up and refactor some code - Normalize shape dimensions during load - Rename getArrowAbsoluteBounds * Fix linter issues --- src/element/bounds.test.ts | 32 ++------- src/element/bounds.ts | 28 +++----- src/element/collision.ts | 27 ++------ src/element/handlerRectangles.ts | 20 +++--- src/element/index.ts | 3 +- src/element/sizeHelpers.ts | 3 + src/index.tsx | 107 +++++++++++-------------------- src/renderer/renderElement.ts | 45 ++++++------- src/scene/data.ts | 15 ++++- 9 files changed, 102 insertions(+), 178 deletions(-) diff --git a/src/element/bounds.test.ts b/src/element/bounds.test.ts index 4c79e6d30..637bd8e67 100644 --- a/src/element/bounds.test.ts +++ b/src/element/bounds.test.ts @@ -17,51 +17,27 @@ const _ce = ({ x, y, w, h }: { x: number; y: number; w: number; h: number }) => } as ExcalidrawElement); describe("getElementAbsoluteCoords", () => { - it("test x1 coordinate if width is positive or zero", () => { + it("test x1 coordinate", () => { const [x1] = getElementAbsoluteCoords(_ce({ x: 10, y: 0, w: 10, h: 0 })); expect(x1).toEqual(10); }); - it("test x1 coordinate if width is negative", () => { - const [x1] = getElementAbsoluteCoords(_ce({ x: 20, y: 0, w: -10, h: 0 })); - expect(x1).toEqual(10); - }); - - it("test x2 coordinate if width is positive or zero", () => { + it("test x2 coordinate", () => { const [, , x2] = getElementAbsoluteCoords( _ce({ x: 10, y: 0, w: 10, h: 0 }), ); expect(x2).toEqual(20); }); - it("test x2 coordinate if width is negative", () => { - const [, , x2] = getElementAbsoluteCoords( - _ce({ x: 10, y: 0, w: -10, h: 0 }), - ); - expect(x2).toEqual(10); - }); - - it("test y1 coordinate if height is positive or zero", () => { + it("test y1 coordinate", () => { const [, y1] = getElementAbsoluteCoords(_ce({ x: 0, y: 10, w: 0, h: 10 })); expect(y1).toEqual(10); }); - it("test y1 coordinate if height is negative", () => { - const [, y1] = getElementAbsoluteCoords(_ce({ x: 0, y: 20, w: 0, h: -10 })); - expect(y1).toEqual(10); - }); - - it("test y2 coordinate if height is positive or zero", () => { + it("test y2 coordinate", () => { const [, , , y2] = getElementAbsoluteCoords( _ce({ x: 0, y: 10, w: 0, h: 10 }), ); expect(y2).toEqual(20); }); - - it("test y2 coordinate if height is negative", () => { - const [, , , y2] = getElementAbsoluteCoords( - _ce({ x: 0, y: 10, w: 0, h: -10 }), - ); - expect(y2).toEqual(10); - }); }); diff --git a/src/element/bounds.ts b/src/element/bounds.ts index 08428379a..079a79dc3 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -5,17 +5,15 @@ import { Point } from "roughjs/bin/geometry"; // 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 getElementAbsoluteCoords(element: ExcalidrawElement) { - if (element.type === "arrow") { - return getArrowAbsoluteBounds(element); + if (element.type === "arrow" || element.type === "line") { + return getLinearElementAbsoluteBounds(element); } return [ - element.width >= 0 ? element.x : element.x + element.width, // x1 - element.height >= 0 ? element.y : element.y + element.height, // y1 - element.width >= 0 ? element.x + element.width : element.x, // x2 - element.height >= 0 ? element.y + element.height : element.y, // y2 + element.x, + element.y, + element.x + element.width, + element.y + element.height, ]; } @@ -34,7 +32,7 @@ export function getDiamondPoints(element: ExcalidrawElement) { return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY]; } -export function getArrowAbsoluteBounds(element: ExcalidrawElement) { +export function getLinearElementAbsoluteBounds(element: ExcalidrawElement) { if (element.points.length < 2 || !element.shape) { const { minX, minY, maxX, maxY } = element.points.reduce( (limits, [x, y]) => { @@ -58,7 +56,8 @@ export function getArrowAbsoluteBounds(element: ExcalidrawElement) { const shape = element.shape as Drawable[]; - const ops = shape[1].sets[0].ops; + // first element is always the curve + const ops = shape[0].sets[0].ops; let currentP: Point = [0, 0]; @@ -138,15 +137,6 @@ export function getArrowPoints(element: ExcalidrawElement) { return [x2, y2, x3, y3, x4, y4]; } -export function getLinePoints(element: ExcalidrawElement) { - const x1 = 0; - const y1 = 0; - const x2 = element.width; - const y2 = element.height; - - return [x1, y1, x2, y2]; -} - export function getCommonBounds(elements: readonly ExcalidrawElement[]) { let minX = Infinity; let maxX = -Infinity; diff --git a/src/element/collision.ts b/src/element/collision.ts index 9133f3933..b9a614eb2 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -4,8 +4,7 @@ import { ExcalidrawElement } from "./types"; import { getDiamondPoints, getElementAbsoluteCoords, - getLinePoints, - getArrowAbsoluteBounds, + getLinearElementAbsoluteBounds, } from "./bounds"; import { Point } from "roughjs/bin/geometry"; import { Drawable, OpSet } from "roughjs/bin/core"; @@ -148,18 +147,13 @@ export function hitTest( distanceBetweenPointAndSegment(x, y, leftX, leftY, topX, topY) < lineThreshold ); - } else if (element.type === "arrow") { + } else if (element.type === "arrow" || element.type === "line") { if (!element.shape) { return false; } const shape = element.shape as Drawable[]; - // If shape does not consist of curve and two line segments - // for arrow shape, return false - if (shape.length < 3) { - return false; - } - const [x1, y1, x2, y2] = getArrowAbsoluteBounds(element); + const [x1, y1, x2, y2] = getLinearElementAbsoluteBounds(element); if (x < x1 || y < y1 - 10 || x > x2 || y > y2 + 10) { return false; } @@ -167,19 +161,8 @@ export function hitTest( const relX = x - element.x; const relY = y - element.y; - // hit test curve and lien segments for arrow - return ( - hitTestRoughShape(shape[0].sets, relX, relY) || - hitTestRoughShape(shape[1].sets, relX, relY) || - hitTestRoughShape(shape[2].sets, relX, relY) - ); - } else if (element.type === "line") { - const [x1, y1, x2, y2] = getLinePoints(element); - // The computation is done at the origin, we need to add a translation - x -= element.x; - y -= element.y; - - return distanceBetweenPointAndSegment(x, y, x1, y1, x2, y2) < lineThreshold; + // hit thest all "subshapes" of the linear element + return shape.some(s => hitTestRoughShape(s.sets, relX, relY)); } else if (element.type === "text") { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); diff --git a/src/element/handlerRectangles.ts b/src/element/handlerRectangles.ts index a12842a39..342db675c 100644 --- a/src/element/handlerRectangles.ts +++ b/src/element/handlerRectangles.ts @@ -1,6 +1,6 @@ import { ExcalidrawElement } from "./types"; import { SceneScroll } from "../scene/types"; -import { getArrowAbsoluteBounds } from "./bounds"; +import { getLinearElementAbsoluteBounds } from "./bounds"; type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se"; @@ -16,10 +16,13 @@ export function handlerRectangles( let marginY = -8; const minimumSize = 40; - if (element.type === "arrow") { - [elementX1, elementY1, elementX2, elementY2] = getArrowAbsoluteBounds( - element, - ); + if (element.type === "arrow" || element.type === "line") { + [ + elementX1, + elementY1, + elementX2, + elementY2, + ] = getLinearElementAbsoluteBounds(element); } else { elementX1 = element.x; elementX2 = element.x + element.width; @@ -90,12 +93,7 @@ export function handlerRectangles( 8, ]; // se - if (element.type === "line") { - return { - nw: handlers.nw, - se: handlers.se, - } as typeof handlers; - } else if (element.type === "arrow") { + if (element.type === "arrow" || element.type === "line") { if (element.points.length === 2) { // only check the last point because starting point is always (0,0) const [, p1] = element.points; diff --git a/src/element/index.ts b/src/element/index.ts index 6a68a7276..bc6967343 100644 --- a/src/element/index.ts +++ b/src/element/index.ts @@ -4,8 +4,7 @@ export { getCommonBounds, getDiamondPoints, getArrowPoints, - getLinePoints, - getArrowAbsoluteBounds, + getLinearElementAbsoluteBounds, } from "./bounds"; export { handlerRectangles } from "./handlerRectangles"; diff --git a/src/element/sizeHelpers.ts b/src/element/sizeHelpers.ts index 34c1a1848..dde3cbbf4 100644 --- a/src/element/sizeHelpers.ts +++ b/src/element/sizeHelpers.ts @@ -1,6 +1,9 @@ import { ExcalidrawElement } from "./types"; export function isInvisiblySmallElement(element: ExcalidrawElement): boolean { + if (element.type === "arrow" || element.type === "line") { + return element.points.length === 0; + } return element.width === 0 && element.height === 0; } diff --git a/src/index.tsx b/src/index.tsx index b87a1eb9c..0463be65a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -16,7 +16,6 @@ import { getCommonBounds, getCursorForResizingElement, getPerfectElementSize, - resizePerfectLineForNWHandler, normalizeDimensions, } from "./element"; import { @@ -1050,7 +1049,10 @@ export class App extends React.Component { editingElement: element, }); return; - } else if (this.state.elementType === "arrow") { + } else if ( + this.state.elementType === "arrow" || + this.state.elementType === "line" + ) { if (this.state.multiElement) { const { multiElement } = this.state; const { x: rx, y: ry } = multiElement; @@ -1107,7 +1109,7 @@ export class App extends React.Component { const absPy = p1[1] + element.y; const { width, height } = getPerfectElementSize( - "arrow", + element.type, mouseX - element.x - p1[0], mouseY - element.y - p1[1], ); @@ -1137,7 +1139,7 @@ export class App extends React.Component { ) => { if (perfect) { const { width, height } = getPerfectElementSize( - "arrow", + element.type, mouseX - element.x, mouseY - element.y, ); @@ -1179,7 +1181,11 @@ export class App extends React.Component { // to ensure we don't create a 2-point arrow by mistake when // user clicks mouse in a way that it moves a tiny bit (thus // triggering mousemove) - if (!draggingOccurred && this.state.elementType === "arrow") { + if ( + !draggingOccurred && + (this.state.elementType === "arrow" || + this.state.elementType === "line") + ) { const { x, y } = viewportCoordsToSceneCoords(e, this.state); if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) { return; @@ -1199,10 +1205,7 @@ export class App extends React.Component { element.type === "line" || element.type === "arrow"; switch (resizeHandle) { case "nw": - if ( - element.type === "arrow" && - element.points.length === 2 - ) { + if (isLinear && element.points.length === 2) { const [, p1] = element.points; if (!resizeArrowFn) { @@ -1226,12 +1229,8 @@ export class App extends React.Component { element.x += deltaX; if (e.shiftKey) { - if (isLinear) { - resizePerfectLineForNWHandler(element, x, y); - } else { - element.y += element.height - element.width; - element.height = element.width; - } + element.y += element.height - element.width; + element.height = element.width; } else { element.height -= deltaY; element.y += deltaY; @@ -1239,10 +1238,7 @@ export class App extends React.Component { } break; case "ne": - if ( - element.type === "arrow" && - element.points.length === 2 - ) { + if (isLinear && element.points.length === 2) { const [, p1] = element.points; if (!resizeArrowFn) { if (p1[0] >= 0) { @@ -1272,10 +1268,7 @@ export class App extends React.Component { } break; case "sw": - if ( - element.type === "arrow" && - element.points.length === 2 - ) { + if (isLinear && element.points.length === 2) { const [, p1] = element.points; if (!resizeArrowFn) { if (p1[0] <= 0) { @@ -1304,10 +1297,7 @@ export class App extends React.Component { } break; case "se": - if ( - element.type === "arrow" && - element.points.length === 2 - ) { + if (isLinear && element.points.length === 2) { const [, p1] = element.points; if (!resizeArrowFn) { if (p1[0] > 0 || p1[1] > 0) { @@ -1327,18 +1317,8 @@ export class App extends React.Component { ); } else { if (e.shiftKey) { - if (isLinear) { - const { width, height } = getPerfectElementSize( - element.type, - x - element.x, - y - element.y, - ); - element.width = width; - element.height = height; - } else { - element.width += deltaX; - element.height = element.width; - } + element.width += deltaX; + element.height = element.width; } else { element.width += deltaX; element.height += deltaY; @@ -1473,34 +1453,7 @@ export class App extends React.Component { this.state.elementType === "line" || this.state.elementType === "arrow"; - if (isLinear && x < originX) { - width = -width; - } - if (isLinear && y < originY) { - height = -height; - } - - if (e.shiftKey) { - ({ width, height } = getPerfectElementSize( - this.state.elementType, - width, - !isLinear && y < originY ? -height : height, - )); - - if (!isLinear && height < 0) { - height = -height; - } - } - - if (!isLinear) { - draggingElement.x = x < originX ? originX - width : originX; - draggingElement.y = y < originY ? originY - height : originY; - } - - draggingElement.width = width; - draggingElement.height = height; - - if (this.state.elementType === "arrow") { + if (isLinear) { draggingOccurred = true; const points = draggingElement.points; let dx = x - draggingElement.x; @@ -1521,6 +1474,24 @@ export class App extends React.Component { pnt[0] = dx; pnt[1] = dy; } + } else { + if (e.shiftKey) { + ({ width, height } = getPerfectElementSize( + this.state.elementType, + width, + y < originY ? -height : height, + )); + + if (height < 0) { + height = -height; + } + } + + draggingElement.x = x < originX ? originX - width : originX; + draggingElement.y = y < originY ? originY - height : originY; + + draggingElement.width = width; + draggingElement.height = height; } draggingElement.shape = null; @@ -1558,7 +1529,7 @@ export class App extends React.Component { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); - if (elementType === "arrow") { + if (elementType === "arrow" || elementType === "line") { if (draggingElement!.points.length > 1) { history.resumeRecording(); } diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index e42c871db..331e845e6 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -1,10 +1,6 @@ import { ExcalidrawElement } from "../element/types"; import { isTextElement } from "../element/typeChecks"; -import { - getDiamondPoints, - getArrowPoints, - getLinePoints, -} from "../element/bounds"; +import { getDiamondPoints, getArrowPoints } from "../element/bounds"; import { RoughCanvas } from "roughjs/bin/canvas"; import { Drawable } from "roughjs/bin/core"; import { Point } from "roughjs/bin/geometry"; @@ -89,8 +85,8 @@ function generateElement( }, ); break; + case "line": case "arrow": { - const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element); const options = { stroke: element.strokeColor, strokeWidth: element.strokeWidth, @@ -102,25 +98,21 @@ function generateElement( const points: Point[] = element.points.length ? element.points : [[0, 0]]; - element.shape = [ - // \ - generator.line(x3, y3, x2, y2, options), - // ----- - generator.curve(points, options), - // / - generator.line(x4, y4, x2, y2, options), - ]; - break; - } - case "line": { - const [x1, y1, x2, y2] = getLinePoints(element); - const options = { - stroke: element.strokeColor, - strokeWidth: element.strokeWidth, - roughness: element.roughness, - seed: element.seed, - }; - element.shape = generator.line(x1, y1, x2, y2, options); + + // curve is always the first element + // this simplifies finding the curve for an element + element.shape = [generator.curve(points, options)]; + + // add lines only in arrow + if (element.type === "arrow") { + const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element); + element.shape.push( + ...[ + generator.line(x3, y3, x2, y2, options), + generator.line(x4, y4, x2, y2, options), + ], + ); + } break; } } @@ -144,13 +136,12 @@ export function renderElement( case "rectangle": case "diamond": case "ellipse": - case "line": { generateElement(element, generator); context.globalAlpha = element.opacity / 100; rc.draw(element.shape as Drawable); context.globalAlpha = 1; break; - } + case "line": case "arrow": { generateElement(element, generator); context.globalAlpha = element.opacity / 100; diff --git a/src/scene/data.ts b/src/scene/data.ts index 095c4f658..4e02a4bdc 100644 --- a/src/scene/data.ts +++ b/src/scene/data.ts @@ -7,7 +7,7 @@ import { ExportType, PreviousScene } from "./types"; import { exportToCanvas, exportToSvg } from "./export"; import nanoid from "nanoid"; import { fileOpen, fileSave } from "browser-nativefs"; -import { getCommonBounds } from "../element"; +import { getCommonBounds, normalizeDimensions } from "../element"; import { Point } from "roughjs/bin/geometry"; import { t } from "../i18n"; @@ -291,6 +291,19 @@ function restore( [element.width, element.height], ]; } + } else if (element.type === "line") { + // old spec, pre-arrows + // old spec, post-arrows + if (!Array.isArray(element.points) || element.points.length === 0) { + points = [ + [0, 0], + [element.width, element.height], + ]; + } else { + points = element.points; + } + } else { + normalizeDimensions(element); } return {