improve & granularize ExcalidrawElement types (#991)

* improve & granularize ExcalidrawElement types

* fix incorrectly passing type

* fix tests

* fix more tests

* fix unnecessary spreads & refactor

* add comments
This commit is contained in:
David Luzar 2020-03-17 20:55:40 +01:00 committed by GitHub
parent 1c545c1d47
commit 373d16abe6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 430 additions and 272 deletions

View file

@ -3,7 +3,7 @@ import { ExcalidrawElement } from "./types";
const _ce = ({ x, y, w, h }: { x: number; y: number; w: number; h: number }) =>
({
type: "test",
type: "rectangle",
strokeColor: "#000",
backgroundColor: "#000",
fillStyle: "solid",

View file

@ -1,13 +1,14 @@
import { ExcalidrawElement } from "./types";
import { ExcalidrawElement, ExcalidrawLinearElement } from "./types";
import { rotate } from "../math";
import { Drawable } from "roughjs/bin/core";
import { Point } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
import { isLinearElement } from "./typeChecks";
// 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.
export function getElementAbsoluteCoords(element: ExcalidrawElement) {
if (element.type === "arrow" || element.type === "line") {
if (isLinearElement(element)) {
return getLinearElementAbsoluteBounds(element);
}
return [
@ -33,7 +34,9 @@ export function getDiamondPoints(element: ExcalidrawElement) {
return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
}
export function getLinearElementAbsoluteBounds(element: ExcalidrawElement) {
export function getLinearElementAbsoluteBounds(
element: ExcalidrawLinearElement,
) {
if (element.points.length < 2 || !getShapeForElement(element)) {
const { minX, minY, maxX, maxY } = element.points.reduce(
(limits, [x, y]) => {
@ -119,7 +122,10 @@ export function getLinearElementAbsoluteBounds(element: ExcalidrawElement) {
];
}
export function getArrowPoints(element: ExcalidrawElement, shape: Drawable[]) {
export function getArrowPoints(
element: ExcalidrawLinearElement,
shape: Drawable[],
) {
const ops = shape[0].sets[0].ops;
const data = ops[ops.length - 1].data;

View file

@ -11,6 +11,7 @@ import { Point } from "../types";
import { Drawable, OpSet } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
import { isLinearElement } from "./typeChecks";
function isElementDraggableFromInside(
element: ExcalidrawElement,
@ -158,7 +159,7 @@ export function hitTest(
distanceBetweenPointAndSegment(x, y, leftX, leftY, topX, topY) <
lineThreshold
);
} else if (element.type === "arrow" || element.type === "line") {
} else if (isLinearElement(element)) {
if (!getShapeForElement(element)) {
return false;
}

View file

@ -1,7 +1,12 @@
import { ExcalidrawElement } from "./types";
import { isInvisiblySmallElement } from "./sizeHelpers";
export { newElement, newTextElement, duplicateElement } from "./newElement";
export {
newElement,
newTextElement,
newLinearElement,
duplicateElement,
} from "./newElement";
export {
getElementAbsoluteCoords,
getCommonBounds,

View file

@ -13,33 +13,36 @@ type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
// The version is used to compare updates when more than one user is working in
// the same drawing. Note: this will trigger the component to update. Make sure you
// are calling it either from a React event handler or within unstable_batchedUpdates().
export function mutateElement<TElement extends ExcalidrawElement>(
export function mutateElement<TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
) {
const mutableElement = element as any;
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points } = updates as any;
if (typeof updates.points !== "undefined") {
updates = { ...getSizeFromPoints(updates.points!), ...updates };
if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates };
}
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
mutableElement[key] = value;
// @ts-ignore
element[key] = value;
}
}
if (
typeof updates.height !== "undefined" ||
typeof updates.width !== "undefined" ||
typeof updates.points !== "undefined"
typeof points !== "undefined"
) {
invalidateShapeForElement(element);
}
mutableElement.version++;
mutableElement.versionNonce = randomSeed();
element.version++;
element.versionNonce = randomSeed();
globalSceneState.informMutation();
}

View file

@ -1,4 +1,9 @@
import { newElement, newTextElement, duplicateElement } from "./newElement";
import {
newTextElement,
duplicateElement,
newLinearElement,
} from "./newElement";
import { mutateElement } from "./mutateElement";
function isPrimitive(val: any) {
const type = typeof val;
@ -17,25 +22,27 @@ function assertCloneObjects(source: any, clone: any) {
}
it("clones arrow element", () => {
const element = newElement(
"arrow",
0,
0,
"#000000",
"transparent",
"hachure",
1,
1,
100,
);
const element = newLinearElement({
type: "arrow",
x: 0,
y: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
roughness: 1,
opacity: 100,
});
// @ts-ignore
element.__proto__ = { hello: "world" };
element.points = [
[1, 2],
[3, 4],
];
mutateElement(element, {
points: [
[1, 2],
[3, 4],
],
});
const copy = duplicateElement(element);
@ -59,17 +66,24 @@ it("clones arrow element", () => {
});
it("clones text element", () => {
const element = newTextElement(
newElement("text", 0, 0, "#000000", "transparent", "hachure", 1, 1, 100),
"hello",
"Arial 20px",
);
const element = newTextElement({
x: 0,
y: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
roughness: 1,
opacity: 100,
text: "hello",
font: "Arial 20px",
});
const copy = duplicateElement(element);
assertCloneObjects(element, copy);
expect(copy.points).not.toBe(element.points);
expect(copy).not.toHaveProperty("points");
expect(copy).not.toHaveProperty("shape");
expect(copy.id).not.toBe(element.id);
expect(typeof copy.id).toBe("string");

View file

@ -1,25 +1,45 @@
import { randomSeed } from "roughjs/bin/math";
import nanoid from "nanoid";
import { Point } from "../types";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import {
ExcalidrawElement,
ExcalidrawTextElement,
ExcalidrawLinearElement,
ExcalidrawGenericElement,
} from "../element/types";
import { measureText } from "../utils";
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,
type ElementConstructorOpts = {
x: ExcalidrawGenericElement["x"];
y: ExcalidrawGenericElement["y"];
strokeColor: ExcalidrawGenericElement["strokeColor"];
backgroundColor: ExcalidrawGenericElement["backgroundColor"];
fillStyle: ExcalidrawGenericElement["fillStyle"];
strokeWidth: ExcalidrawGenericElement["strokeWidth"];
roughness: ExcalidrawGenericElement["roughness"];
opacity: ExcalidrawGenericElement["opacity"];
width?: ExcalidrawGenericElement["width"];
height?: ExcalidrawGenericElement["height"];
};
function _newElementBase<T extends ExcalidrawElement>(
type: T["type"],
{
x,
y,
strokeColor,
backgroundColor,
fillStyle,
strokeWidth,
roughness,
opacity,
width = 0,
height = 0,
...rest
}: ElementConstructorOpts & Partial<ExcalidrawGenericElement>,
) {
const element = {
id: nanoid(),
return {
id: rest.id || nanoid(),
type,
x,
y,
@ -31,29 +51,36 @@ export function newElement(
strokeWidth,
roughness,
opacity,
seed: randomSeed(),
points: [] as readonly Point[],
version: 1,
versionNonce: 0,
isDeleted: false,
seed: rest.seed ?? randomSeed(),
version: rest.version || 1,
versionNonce: rest.versionNonce ?? 0,
isDeleted: rest.isDeleted ?? false,
};
return element;
}
export function newElement(
opts: {
type: ExcalidrawGenericElement["type"];
} & ElementConstructorOpts,
): ExcalidrawGenericElement {
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
}
export function newTextElement(
element: ExcalidrawElement,
text: string,
font: string,
) {
opts: {
text: string;
font: string;
} & ElementConstructorOpts,
): ExcalidrawTextElement {
const { text, font } = opts;
const metrics = measureText(text, font);
const textElement: ExcalidrawTextElement = {
...element,
type: "text",
const textElement = {
..._newElementBase<ExcalidrawTextElement>("text", opts),
text: text,
font: font,
// Center the text
x: element.x - metrics.width / 2,
y: element.y - metrics.height / 2,
x: opts.x - metrics.width / 2,
y: opts.y - metrics.height / 2,
width: metrics.width,
height: metrics.height,
baseline: metrics.baseline,
@ -62,6 +89,17 @@ export function newTextElement(
return textElement;
}
export function newLinearElement(
opts: {
type: "arrow" | "line";
} & ElementConstructorOpts,
): ExcalidrawLinearElement {
return {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: [],
};
}
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
//
@ -100,11 +138,15 @@ function _duplicateElement(val: any, depth: number = 0) {
return val;
}
export function duplicateElement(
element: ReturnType<typeof newElement>,
): ReturnType<typeof newElement> {
const copy = _duplicateElement(element);
export function duplicateElement<TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
overrides?: Partial<TElement>,
): TElement {
let copy: TElement = _duplicateElement(element);
copy.id = nanoid();
copy.seed = randomSeed();
if (overrides) {
copy = Object.assign(copy, overrides);
}
return copy;
}

View file

@ -2,6 +2,7 @@ import { ExcalidrawElement, PointerType } from "./types";
import { handlerRectangles } from "./handlerRectangles";
import { AppState } from "../types";
import { isLinearElement } from "./typeChecks";
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
@ -102,11 +103,7 @@ export function normalizeResizeHandle(
element: ExcalidrawElement,
resizeHandle: HandlerRectanglesRet,
): HandlerRectanglesRet {
if (
(element.width >= 0 && element.height >= 0) ||
element.type === "line" ||
element.type === "arrow"
) {
if ((element.width >= 0 && element.height >= 0) || isLinearElement(element)) {
return resizeHandle;
}

View file

@ -1,8 +1,9 @@
import { ExcalidrawElement } from "./types";
import { mutateElement } from "./mutateElement";
import { isLinearElement } from "./typeChecks";
export function isInvisiblySmallElement(element: ExcalidrawElement): boolean {
if (element.type === "arrow" || element.type === "line") {
if (isLinearElement(element)) {
return element.points.length < 2;
}
return element.width === 0 && element.height === 0;
@ -78,8 +79,7 @@ export function normalizeDimensions(
if (
!element ||
(element.width >= 0 && element.height >= 0) ||
element.type === "line" ||
element.type === "arrow"
isLinearElement(element)
) {
return false;
}

View file

@ -1,4 +1,8 @@
import { ExcalidrawElement, ExcalidrawTextElement } from "./types";
import {
ExcalidrawElement,
ExcalidrawTextElement,
ExcalidrawLinearElement,
} from "./types";
export function isTextElement(
element: ExcalidrawElement,
@ -6,6 +10,14 @@ export function isTextElement(
return element.type === "text";
}
export function isLinearElement(
element?: ExcalidrawElement | null,
): element is ExcalidrawLinearElement {
return (
element != null && (element.type === "arrow" || element.type === "line")
);
}
export function isExcalidrawElement(element: any): boolean {
return (
element?.type === "text" ||

View file

@ -1,20 +1,49 @@
import { newElement } from "./newElement";
import { Point } from "../types";
type _ExcalidrawElementBase = Readonly<{
id: string;
x: number;
y: number;
strokeColor: string;
backgroundColor: string;
fillStyle: string;
strokeWidth: number;
roughness: number;
opacity: number;
width: number;
height: number;
seed: number;
version: number;
versionNonce: number;
isDeleted: boolean;
}>;
export type ExcalidrawGenericElement = _ExcalidrawElementBase & {
type: "selection" | "rectangle" | "diamond" | "ellipse";
};
/**
* ExcalidrawElement should be JSON serializable and (eventually) contain
* no computed data. The list of all ExcalidrawElements should be shareable
* between peers and contain no state local to the peer.
*/
export type ExcalidrawElement = Readonly<ReturnType<typeof newElement>>;
export type ExcalidrawElement =
| ExcalidrawGenericElement
| ExcalidrawTextElement
| ExcalidrawLinearElement;
export type ExcalidrawTextElement = ExcalidrawElement &
export type ExcalidrawTextElement = _ExcalidrawElementBase &
Readonly<{
type: "text";
font: string;
text: string;
// for backward compatibility
actualBoundingBoxAscent?: number;
baseline: number;
}>;
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
type: "arrow" | "line";
points: Point[];
}>;
export type PointerType = "mouse" | "pen" | "touch";