feat: improved freedraw (#3512)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Steve Ruiz 2021-05-09 16:42:10 +01:00 committed by GitHub
parent 198800136e
commit 49c6bdd520
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 786 additions and 247 deletions

View file

@ -1,4 +1,9 @@
import { ExcalidrawElement, ExcalidrawLinearElement, Arrowhead } from "./types";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
} from "./types";
import { distance2d, rotate } from "../math";
import rough from "roughjs/bin/rough";
import { Drawable, Op } from "roughjs/bin/core";
@ -7,7 +12,7 @@ import {
getShapeForElement,
generateRoughOptions,
} from "../renderer/renderElement";
import { isLinearElement } from "./typeChecks";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { rescalePoints } from "../points";
// x and y position of top left corner, x and y position of bottom right corner
@ -18,7 +23,9 @@ export type Bounds = readonly [number, number, number, number];
export const getElementAbsoluteCoords = (
element: ExcalidrawElement,
): Bounds => {
if (isLinearElement(element)) {
if (isFreeDrawElement(element)) {
return getFreeDrawElementAbsoluteCoords(element);
} else if (isLinearElement(element)) {
return getLinearElementAbsoluteCoords(element);
}
return [
@ -120,9 +127,42 @@ const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY];
};
const getBoundsFromPoints = (
points: ExcalidrawFreeDrawElement["points"],
): [number, number, number, number] => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const [x, y] of points) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
return [minX, minY, maxX, maxY];
};
const getFreeDrawElementAbsoluteCoords = (
element: ExcalidrawFreeDrawElement,
): [number, number, number, number] => {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
};
const getLinearElementAbsoluteCoords = (
element: ExcalidrawLinearElement,
): [number, number, number, number] => {
let coords: [number, number, number, number];
if (element.points.length < 2 || !getShapeForElement(element)) {
// XXX this is just a poor estimate and not very useful
const { minX, minY, maxX, maxY } = element.points.reduce(
@ -137,7 +177,21 @@ const getLinearElementAbsoluteCoords = (
},
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
return [
coords = [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else {
const shape = getShapeForElement(element) as Drawable[];
// first element is always the curve
const ops = getCurvePathOps(shape[0]);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
coords = [
minX + element.x,
minY + element.y,
maxX + element.x,
@ -145,19 +199,7 @@ const getLinearElementAbsoluteCoords = (
];
}
const shape = getShapeForElement(element) as Drawable[];
// first element is always the curve
const ops = getCurvePathOps(shape[0]);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
return coords;
};
export const getArrowheadPoints = (
@ -231,7 +273,7 @@ export const getArrowheadPoints = (
const ys = y2 - ny * minSize;
if (arrowhead === "dot") {
const r = Math.hypot(ys - y2, xs - x2);
const r = Math.hypot(ys - y2, xs - x2) + element.strokeWidth;
return [x2, y2, r];
}
@ -277,16 +319,31 @@ const getLinearElementRotatedBounds = (
return getMinMaxXYFromCurvePathOps(ops, transformXY);
};
// We could cache this stuff
export const getElementBounds = (
element: ExcalidrawElement,
): [number, number, number, number] => {
let bounds: [number, number, number, number];
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
if (isLinearElement(element)) {
return getLinearElementRotatedBounds(element, cx, cy);
}
if (element.type === "diamond") {
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle),
),
);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
@ -295,26 +352,28 @@ export const getElementBounds = (
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
return [minX, minY, maxX, maxY];
}
if (element.type === "ellipse") {
bounds = [minX, minY, maxX, maxY];
} else if (element.type === "ellipse") {
const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2;
const cos = Math.cos(element.angle);
const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin);
return [cx - ww, cy - hh, cx + ww, cy + hh];
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
}
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
return [minX, minY, maxX, maxY];
return bounds;
};
export const getCommonBounds = (
@ -345,7 +404,7 @@ export const getResizedElementAbsoluteCoords = (
nextWidth: number,
nextHeight: number,
): [number, number, number, number] => {
if (!isLinearElement(element)) {
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
return [
element.x,
element.y,
@ -360,16 +419,29 @@ export const getResizedElementAbsoluteCoords = (
rescalePoints(1, nextHeight, element.points),
);
const gen = rough.generator();
const curve =
element.strokeSharpness === "sharp"
? gen.linearPath(
points as [number, number][],
generateRoughOptions(element),
)
: gen.curve(points as [number, number][], generateRoughOptions(element));
const ops = getCurvePathOps(curve);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
let bounds: [number, number, number, number];
if (isFreeDrawElement(element)) {
// Free Draw
bounds = getBoundsFromPoints(points);
} else {
// Line
const gen = rough.generator();
const curve =
element.strokeSharpness === "sharp"
? gen.linearPath(
points as [number, number][],
generateRoughOptions(element),
)
: gen.curve(
points as [number, number][],
generateRoughOptions(element),
);
const ops = getCurvePathOps(curve);
bounds = getMinMaxXYFromCurvePathOps(ops);
}
const [minX, minY, maxX, maxY] = bounds;
return [
minX + element.x,
minY + element.y,

View file

@ -4,7 +4,13 @@ import * as GADirection from "../gadirections";
import * as GALine from "../galines";
import * as GATransform from "../gatransforms";
import { isPathALoop, isPointInPolygon, rotate } from "../math";
import {
distance2d,
rotatePoint,
isPathALoop,
isPointInPolygon,
rotate,
} from "../math";
import { pointsOnBezierCurves } from "points-on-curve";
import {
@ -16,6 +22,7 @@ import {
ExcalidrawTextElement,
ExcalidrawEllipseElement,
NonDeleted,
ExcalidrawFreeDrawElement,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@ -30,10 +37,17 @@ const isElementDraggableFromInside = (
if (element.type === "arrow") {
return false;
}
if (element.type === "freedraw") {
return true;
}
const isDraggableFromInside = element.backgroundColor !== "transparent";
if (element.type === "line" || element.type === "draw") {
if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points);
}
return isDraggableFromInside;
};
@ -81,6 +95,7 @@ const isHittingElementNotConsideringBoundingBox = (
: isElementDraggableFromInside(element)
? isInsideCheck
: isNearCheck;
return hitTestPointAgainstElement({ element, point, threshold, check });
};
@ -151,6 +166,18 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
case "ellipse":
const distance = distanceToBindableElement(args.element, args.point);
return args.check(distance, args.threshold);
case "freedraw": {
if (
!args.check(
distanceToRectangle(args.element, args.point),
args.threshold,
)
) {
return false;
}
return hitTestFreeDrawElement(args.element, args.point, args.threshold);
}
case "arrow":
case "line":
case "draw":
@ -195,7 +222,10 @@ const isOutsideCheck = (distance: number, threshold: number): boolean => {
};
const distanceToRectangle = (
element: ExcalidrawRectangleElement | ExcalidrawTextElement,
element:
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@ -267,6 +297,71 @@ const ellipseParamsForTest = (
return [pointRel, tangent];
};
const hitTestFreeDrawElement = (
element: ExcalidrawFreeDrawElement,
point: Point,
threshold: number,
): boolean => {
// Check point-distance-to-line-segment for every segment in the
// element's points (its input points, not its outline points).
// This is... okay? It's plenty fast, but the GA library may
// have a faster option.
let x: number;
let y: number;
if (element.angle === 0) {
x = point[0] - element.x;
y = point[1] - element.y;
} else {
// Counter-rotate the point around center before testing
const [minX, minY, maxX, maxY] = getElementAbsoluteCoords(element);
const rotatedPoint = rotatePoint(
point,
[minX + (maxX - minX) / 2, minY + (maxY - minY) / 2],
-element.angle,
);
x = rotatedPoint[0] - element.x;
y = rotatedPoint[1] - element.y;
}
let [A, B] = element.points;
let P: readonly [number, number];
// For freedraw dots
if (element.points.length === 2) {
return (
distance2d(A[0], A[1], x, y) < threshold ||
distance2d(B[0], B[1], x, y) < threshold
);
}
// For freedraw lines
for (let i = 1; i < element.points.length - 1; i++) {
const delta = [B[0] - A[0], B[1] - A[1]];
const length = Math.hypot(delta[1], delta[0]);
const U = [delta[0] / length, delta[1] / length];
const C = [x - A[0], y - A[1]];
const d = (C[0] * U[0] + C[1] * U[1]) / Math.hypot(U[1], U[0]);
P = [A[0] + U[0] * d, A[1] + U[1] * d];
const da = distance2d(P[0], P[1], A[0], A[1]);
const db = distance2d(P[0], P[1], B[0], B[1]);
P = db < da && da > length ? B : da < db && db > length ? A : P;
if (Math.hypot(y - P[1], x - P[0]) < threshold) {
return true;
}
A = B;
B = element.points[i + 1];
}
return false;
};
const hitTestLinear = (args: HitTestArgs): boolean => {
const { element, threshold } = args;
if (!getShapeForElement(element)) {

View file

@ -9,6 +9,7 @@ import {
GroupId,
VerticalAlign,
Arrowhead,
ExcalidrawFreeDrawElement,
} from "../element/types";
import { measureText, getFontString } from "../utils";
import { randomInteger, randomId } from "../random";
@ -212,6 +213,22 @@ export const updateTextElement = (
});
};
export const newFreeDrawElement = (
opts: {
type: "freedraw";
points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => {
return {
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
points: opts.points || [],
pressures: [],
simulatePressure: opts.simulatePressure,
lastCommittedPoint: null,
};
};
export const newLinearElement = (
opts: {
type: ExcalidrawLinearElement["type"];

View file

@ -18,7 +18,11 @@ import {
getCommonBounds,
getResizedElementAbsoluteCoords,
} from "./bounds";
import { isLinearElement, isTextElement } from "./typeChecks";
import {
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import { measureText, getFontString } from "../utils";
@ -244,7 +248,7 @@ const rescalePointsInElement = (
width: number,
height: number,
) =>
isLinearElement(element)
isLinearElement(element) || isFreeDrawElement(element)
? {
points: rescalePoints(
0,
@ -404,7 +408,7 @@ export const resizeSingleElement = (
-stateAtResizeStart.angle,
);
//Get bounds corners rendered on screen
// Get bounds corners rendered on screen
const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords(
element,
element.width,
@ -644,11 +648,14 @@ const resizeMultipleElements = (
font = { fontSize: nextFont.size, baseline: nextFont.baseline };
}
const origCoords = getElementAbsoluteCoords(element);
const rescaledPoints = rescalePointsInElement(element, width, height);
updateBoundElements(element, {
newSize: { width, height },
simultaneouslyUpdated: elements,
});
const finalCoords = getResizedElementAbsoluteCoords(
{
...element,
@ -657,6 +664,7 @@ const resizeMultipleElements = (
width,
height,
);
const { x, y } = getNextXY(element, origCoords, finalCoords);
return [...prev, { width, height, x, y, ...rescaledPoints, ...font }];
},

View file

@ -1,12 +1,12 @@
import { ExcalidrawElement } from "./types";
import { mutateElement } from "./mutateElement";
import { isLinearElement } from "./typeChecks";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants";
export const isInvisiblySmallElement = (
element: ExcalidrawElement,
): boolean => {
if (isLinearElement(element)) {
if (isLinearElement(element) || isFreeDrawElement(element)) {
return element.points.length < 2;
}
return element.width === 0 && element.height === 0;
@ -26,7 +26,7 @@ export const getPerfectElementSize = (
if (
elementType === "line" ||
elementType === "arrow" ||
elementType === "draw"
elementType === "freedraw"
) {
const lockedAngle =
Math.round(Math.atan(absHeight / absWidth) / SHIFT_LOCKING_ANGLE) *

View file

@ -225,7 +225,7 @@ export const getTransformHandles = (
if (
element.type === "arrow" ||
element.type === "line" ||
element.type === "draw"
element.type === "freedraw"
) {
if (element.points.length === 2) {
// only check the last point because starting point is always (0,0)

View file

@ -4,6 +4,7 @@ import {
ExcalidrawLinearElement,
ExcalidrawBindableElement,
ExcalidrawGenericElement,
ExcalidrawFreeDrawElement,
} from "./types";
export const isGenericElement = (
@ -24,6 +25,18 @@ export const isTextElement = (
return element != null && element.type === "text";
};
export const isFreeDrawElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawFreeDrawElement => {
return element != null && isFreeDrawElementType(element.type);
};
export const isFreeDrawElementType = (
elementType: ExcalidrawElement["type"],
): boolean => {
return elementType === "freedraw";
};
export const isLinearElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLinearElement => {
@ -34,7 +47,7 @@ export const isLinearElementType = (
elementType: ExcalidrawElement["type"],
): boolean => {
return (
elementType === "arrow" || elementType === "line" || elementType === "draw"
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
);
};
@ -69,7 +82,7 @@ export const isExcalidrawElement = (element: any): boolean => {
element?.type === "rectangle" ||
element?.type === "ellipse" ||
element?.type === "arrow" ||
element?.type === "draw" ||
element?.type === "freedraw" ||
element?.type === "line"
);
};

View file

@ -78,7 +78,8 @@ export type ExcalidrawGenericElement =
export type ExcalidrawElement =
| ExcalidrawGenericElement
| ExcalidrawTextElement
| ExcalidrawLinearElement;
| ExcalidrawLinearElement
| ExcalidrawFreeDrawElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: false;
@ -121,3 +122,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null;
}>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
points: readonly Point[];
pressures: readonly number[];
simulatePressure: boolean;
lastCommittedPoint: Point | null;
}>;