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
This commit is contained in:
Gasim Gasimzada 2020-02-04 13:45:22 +04:00 committed by GitHub
parent f70bd0081c
commit dab35c9033
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 102 additions and 178 deletions

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -4,8 +4,7 @@ export {
getCommonBounds,
getDiamondPoints,
getArrowPoints,
getLinePoints,
getArrowAbsoluteBounds,
getLinearElementAbsoluteBounds,
} from "./bounds";
export { handlerRectangles } from "./handlerRectangles";

View File

@ -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;
}

View File

@ -16,7 +16,6 @@ import {
getCommonBounds,
getCursorForResizingElement,
getPerfectElementSize,
resizePerfectLineForNWHandler,
normalizeDimensions,
} from "./element";
import {
@ -1050,7 +1049,10 @@ export class App extends React.Component<any, AppState> {
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<any, AppState> {
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<any, AppState> {
) => {
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<any, AppState> {
// 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<any, AppState> {
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<any, AppState> {
element.x += deltaX;
if (e.shiftKey) {
if (isLinear) {
resizePerfectLineForNWHandler(element, x, y);
} else {
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<any, AppState> {
}
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<any, AppState> {
}
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<any, AppState> {
}
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<any, AppState> {
);
} 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;
}
} else {
element.width += deltaX;
element.height += deltaY;
@ -1473,34 +1453,7 @@ export class App extends React.Component<any, AppState> {
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<any, AppState> {
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<any, AppState> {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
if (elementType === "arrow") {
if (elementType === "arrow" || elementType === "line") {
if (draggingElement!.points.length > 1) {
history.resumeRecording();
}

View File

@ -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 = [
// \
// 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.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);
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;

View File

@ -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 {