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

@ -11,9 +11,10 @@ export const actionDuplicateSelection = register({
elements: elements.reduce( elements: elements.reduce(
(acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => { (acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
if (appState.selectedElementIds[element.id]) { if (appState.selectedElementIds[element.id]) {
const newElement = duplicateElement(element); const newElement = duplicateElement(element, {
newElement.x = newElement.x + 10; x: element.x + 10,
newElement.y = newElement.y + 10; y: element.y + 10,
});
appState.selectedElementIds[newElement.id] = true; appState.selectedElementIds[newElement.id] = true;
delete appState.selectedElementIds[element.id]; delete appState.selectedElementIds[element.id];
return acc.concat([element, newElement]); return acc.concat([element, newElement]);

View File

@ -21,6 +21,7 @@ import {
getDrawingVersion, getDrawingVersion,
getSyncableElements, getSyncableElements,
hasNonDeletedElements, hasNonDeletedElements,
newLinearElement,
} from "../element"; } from "../element";
import { import {
deleteSelectedElements, deleteSelectedElements,
@ -47,7 +48,7 @@ import { restore } from "../data/restore";
import { renderScene } from "../renderer"; import { renderScene } from "../renderer";
import { AppState, GestureEvent, Gesture } from "../types"; import { AppState, GestureEvent, Gesture } from "../types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement, ExcalidrawLinearElement } from "../element/types";
import { import {
isWritableElement, isWritableElement,
@ -99,6 +100,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement";
import { invalidateShapeForElement } from "../renderer/renderElement"; import { invalidateShapeForElement } from "../renderer/renderElement";
import { unstable_batchedUpdates } from "react-dom"; import { unstable_batchedUpdates } from "react-dom";
import { SceneStateCallbackRemover } from "../scene/globalScene"; import { SceneStateCallbackRemover } from "../scene/globalScene";
import { isLinearElement } from "../element/typeChecks";
import { rescalePoints } from "../points"; import { rescalePoints } from "../points";
function withBatchedUpdates< function withBatchedUpdates<
@ -707,21 +709,18 @@ export class App extends React.Component<any, AppState> {
window.devicePixelRatio, window.devicePixelRatio,
); );
const element = newTextElement( const element = newTextElement({
newElement( x: x,
"text", y: y,
x, strokeColor: this.state.currentItemStrokeColor,
y, backgroundColor: this.state.currentItemBackgroundColor,
this.state.currentItemStrokeColor, fillStyle: this.state.currentItemFillStyle,
this.state.currentItemBackgroundColor, strokeWidth: this.state.currentItemStrokeWidth,
this.state.currentItemFillStyle, roughness: this.state.currentItemRoughness,
this.state.currentItemStrokeWidth, opacity: this.state.currentItemOpacity,
this.state.currentItemRoughness, text: data.text,
this.state.currentItemOpacity, font: this.state.currentItemFont,
), });
data.text,
this.state.currentItemFont,
);
globalSceneState.replaceAllElements([ globalSceneState.replaceAllElements([
...globalSceneState.getAllElements(), ...globalSceneState.getAllElements(),
@ -960,21 +959,18 @@ export class App extends React.Component<any, AppState> {
const element = const element =
elementAtPosition && isTextElement(elementAtPosition) elementAtPosition && isTextElement(elementAtPosition)
? elementAtPosition ? elementAtPosition
: newTextElement( : newTextElement({
newElement( x: x,
"text", y: y,
x, strokeColor: this.state.currentItemStrokeColor,
y, backgroundColor: this.state.currentItemBackgroundColor,
this.state.currentItemStrokeColor, fillStyle: this.state.currentItemFillStyle,
this.state.currentItemBackgroundColor, strokeWidth: this.state.currentItemStrokeWidth,
this.state.currentItemFillStyle, roughness: this.state.currentItemRoughness,
this.state.currentItemStrokeWidth, opacity: this.state.currentItemOpacity,
this.state.currentItemRoughness, text: "",
this.state.currentItemOpacity, font: this.state.currentItemFont,
), });
"", // default text
this.state.currentItemFont, // default font
);
this.setState({ editingElement: element }); this.setState({ editingElement: element });
@ -1044,11 +1040,8 @@ export class App extends React.Component<any, AppState> {
if (text) { if (text) {
globalSceneState.replaceAllElements([ globalSceneState.replaceAllElements([
...globalSceneState.getAllElements(), ...globalSceneState.getAllElements(),
{ // we need to recreate the element to update dimensions & position
// we need to recreate the element to update dimensions & newTextElement({ ...element, text, font: element.font }),
// position
...newTextElement(element, text, element.font),
},
]); ]);
} }
this.setState(prevState => ({ this.setState(prevState => ({
@ -1332,22 +1325,6 @@ export class App extends React.Component<any, AppState> {
const originX = x; const originX = x;
const originY = y; const originY = y;
let element = newElement(
this.state.elementType,
x,
y,
this.state.currentItemStrokeColor,
this.state.currentItemBackgroundColor,
this.state.currentItemFillStyle,
this.state.currentItemStrokeWidth,
this.state.currentItemRoughness,
this.state.currentItemOpacity,
);
if (isTextElement(element)) {
element = newTextElement(element, "", this.state.currentItemFont);
}
type ResizeTestType = ReturnType<typeof resizeTest>; type ResizeTestType = ReturnType<typeof resizeTest>;
let resizeHandle: ResizeTestType = false; let resizeHandle: ResizeTestType = false;
let isResizingElements = false; let isResizingElements = false;
@ -1437,30 +1414,30 @@ export class App extends React.Component<any, AppState> {
this.setState({ selectedElementIds: {} }); this.setState({ selectedElementIds: {} });
} }
if (isTextElement(element)) { if (this.state.elementType === "text") {
// if we're currently still editing text, clicking outside // if we're currently still editing text, clicking outside
// should only finalize it, not create another (irrespective // should only finalize it, not create another (irrespective
// of state.elementLocked) // of state.elementLocked)
if (this.state.editingElement?.type === "text") { if (this.state.editingElement?.type === "text") {
return; return;
} }
if (elementIsAddedToSelection) {
element = hitElement!; const snappedToCenterPosition = event.altKey
} ? null
let textX = event.clientX; : this.getTextWysiwygSnappedToCenterPosition(x, y);
let textY = event.clientY;
if (!event.altKey) { const element = newTextElement({
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition( x: snappedToCenterPosition?.elementCenterX ?? x,
x, y: snappedToCenterPosition?.elementCenterY ?? y,
y, strokeColor: this.state.currentItemStrokeColor,
); backgroundColor: this.state.currentItemBackgroundColor,
if (snappedToCenterPosition) { fillStyle: this.state.currentItemFillStyle,
element.x = snappedToCenterPosition.elementCenterX; strokeWidth: this.state.currentItemStrokeWidth,
element.y = snappedToCenterPosition.elementCenterY; roughness: this.state.currentItemRoughness,
textX = snappedToCenterPosition.wysiwygX; opacity: this.state.currentItemOpacity,
textY = snappedToCenterPosition.wysiwygY; text: "",
} font: this.state.currentItemFont,
} });
const resetSelection = () => { const resetSelection = () => {
this.setState({ this.setState({
@ -1471,8 +1448,8 @@ export class App extends React.Component<any, AppState> {
textWysiwyg({ textWysiwyg({
initText: "", initText: "",
x: textX, x: snappedToCenterPosition?.wysiwygX ?? event.clientX,
y: textY, y: snappedToCenterPosition?.wysiwygY ?? event.clientY,
strokeColor: this.state.currentItemStrokeColor, strokeColor: this.state.currentItemStrokeColor,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
font: this.state.currentItemFont, font: this.state.currentItemFont,
@ -1481,9 +1458,11 @@ export class App extends React.Component<any, AppState> {
if (text) { if (text) {
globalSceneState.replaceAllElements([ globalSceneState.replaceAllElements([
...globalSceneState.getAllElements(), ...globalSceneState.getAllElements(),
{ newTextElement({
...newTextElement(element, text, this.state.currentItemFont), ...element,
}, text,
font: this.state.currentItemFont,
}),
]); ]);
} }
this.setState(prevState => ({ this.setState(prevState => ({
@ -1531,6 +1510,17 @@ export class App extends React.Component<any, AppState> {
points: [...multiElement.points, [x - rx, y - ry]], points: [...multiElement.points, [x - rx, y - ry]],
}); });
} else { } else {
const element = newLinearElement({
type: this.state.elementType,
x: x,
y: y,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
});
this.setState(prevState => ({ this.setState(prevState => ({
selectedElementIds: { selectedElementIds: {
...prevState.selectedElementIds, ...prevState.selectedElementIds,
@ -1549,7 +1539,20 @@ export class App extends React.Component<any, AppState> {
editingElement: element, editingElement: element,
}); });
} }
} else if (element.type === "selection") { } else {
const element = newElement({
type: this.state.elementType,
x: x,
y: y,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
});
if (element.type === "selection") {
this.setState({ this.setState({
selectionElement: element, selectionElement: element,
draggingElement: element, draggingElement: element,
@ -1565,10 +1568,11 @@ export class App extends React.Component<any, AppState> {
editingElement: element, editingElement: element,
}); });
} }
}
let resizeArrowFn: let resizeArrowFn:
| (( | ((
element: ExcalidrawElement, element: ExcalidrawLinearElement,
pointIndex: number, pointIndex: number,
deltaX: number, deltaX: number,
deltaY: number, deltaY: number,
@ -1579,7 +1583,7 @@ export class App extends React.Component<any, AppState> {
| null = null; | null = null;
const arrowResizeOrigin = ( const arrowResizeOrigin = (
element: ExcalidrawElement, element: ExcalidrawLinearElement,
pointIndex: number, pointIndex: number,
deltaX: number, deltaX: number,
deltaY: number, deltaY: number,
@ -1604,7 +1608,9 @@ export class App extends React.Component<any, AppState> {
x: dx, x: dx,
y: dy, y: dy,
points: element.points.map((point, i) => points: element.points.map((point, i) =>
i === pointIndex ? [absPx - element.x, absPy - element.y] : point, i === pointIndex
? ([absPx - element.x, absPy - element.y] as const)
: point,
), ),
}); });
} else { } else {
@ -1612,14 +1618,16 @@ export class App extends React.Component<any, AppState> {
x: element.x + deltaX, x: element.x + deltaX,
y: element.y + deltaY, y: element.y + deltaY,
points: element.points.map((point, i) => points: element.points.map((point, i) =>
i === pointIndex ? [p1[0] - deltaX, p1[1] - deltaY] : point, i === pointIndex
? ([p1[0] - deltaX, p1[1] - deltaY] as const)
: point,
), ),
}); });
} }
}; };
const arrowResizeEnd = ( const arrowResizeEnd = (
element: ExcalidrawElement, element: ExcalidrawLinearElement,
pointIndex: number, pointIndex: number,
deltaX: number, deltaX: number,
deltaY: number, deltaY: number,
@ -1636,13 +1644,15 @@ export class App extends React.Component<any, AppState> {
); );
mutateElement(element, { mutateElement(element, {
points: element.points.map((point, i) => points: element.points.map((point, i) =>
i === pointIndex ? [width, height] : point, i === pointIndex ? ([width, height] as const) : point,
), ),
}); });
} else { } else {
mutateElement(element, { mutateElement(element, {
points: element.points.map((point, i) => points: element.points.map((point, i) =>
i === pointIndex ? [p1[0] + deltaX, p1[1] + deltaY] : point, i === pointIndex
? ([p1[0] + deltaX, p1[1] + deltaY] as const)
: point,
), ),
}); });
} }
@ -1711,10 +1721,9 @@ export class App extends React.Component<any, AppState> {
const deltaX = x - lastX; const deltaX = x - lastX;
const deltaY = y - lastY; const deltaY = y - lastY;
const element = selectedElements[0]; const element = selectedElements[0];
const isLinear = element.type === "line" || element.type === "arrow";
switch (resizeHandle) { switch (resizeHandle) {
case "nw": case "nw":
if (isLinear && element.points.length === 2) { if (isLinearElement(element) && element.points.length === 2) {
const [, p1] = element.points; const [, p1] = element.points;
if (!resizeArrowFn) { if (!resizeArrowFn) {
@ -1739,7 +1748,7 @@ export class App extends React.Component<any, AppState> {
} }
break; break;
case "ne": case "ne":
if (isLinear && element.points.length === 2) { if (isLinearElement(element) && element.points.length === 2) {
const [, p1] = element.points; const [, p1] = element.points;
if (!resizeArrowFn) { if (!resizeArrowFn) {
if (p1[0] >= 0) { if (p1[0] >= 0) {
@ -1761,7 +1770,7 @@ export class App extends React.Component<any, AppState> {
} }
break; break;
case "sw": case "sw":
if (isLinear && element.points.length === 2) { if (isLinearElement(element) && element.points.length === 2) {
const [, p1] = element.points; const [, p1] = element.points;
if (!resizeArrowFn) { if (!resizeArrowFn) {
if (p1[0] <= 0) { if (p1[0] <= 0) {
@ -1782,7 +1791,7 @@ export class App extends React.Component<any, AppState> {
} }
break; break;
case "se": case "se":
if (isLinear && element.points.length === 2) { if (isLinearElement(element) && element.points.length === 2) {
const [, p1] = element.points; const [, p1] = element.points;
if (!resizeArrowFn) { if (!resizeArrowFn) {
if (p1[0] > 0 || p1[1] > 0) { if (p1[0] > 0 || p1[1] > 0) {
@ -1807,14 +1816,18 @@ export class App extends React.Component<any, AppState> {
break; break;
} }
if (isLinearElement(element)) {
mutateElement(element, { mutateElement(element, {
height, height,
y: element.y + deltaY, y: element.y + deltaY,
points: points: rescalePoints(1, height, element.points),
element.points.length > 0
? rescalePoints(1, height, element.points)
: undefined,
}); });
} else {
mutateElement(element, {
height,
y: element.y + deltaY,
});
}
break; break;
} }
@ -1825,15 +1838,18 @@ export class App extends React.Component<any, AppState> {
// Someday we should implement logic to flip the shape. But for now, just stop. // Someday we should implement logic to flip the shape. But for now, just stop.
break; break;
} }
if (isLinearElement(element)) {
mutateElement(element, { mutateElement(element, {
width, width,
x: element.x + deltaX, x: element.x + deltaX,
points: points: rescalePoints(0, width, element.points),
element.points.length > 0
? rescalePoints(0, width, element.points)
: undefined,
}); });
} else {
mutateElement(element, {
width,
x: element.x + deltaX,
});
}
break; break;
} }
case "s": { case "s": {
@ -1842,14 +1858,16 @@ export class App extends React.Component<any, AppState> {
break; break;
} }
if (isLinearElement(element)) {
mutateElement(element, { mutateElement(element, {
height, height,
points: points: rescalePoints(1, height, element.points),
element.points.length > 0
? rescalePoints(1, height, element.points)
: undefined,
}); });
} else {
mutateElement(element, {
height,
});
}
break; break;
} }
case "e": { case "e": {
@ -1858,13 +1876,16 @@ export class App extends React.Component<any, AppState> {
break; break;
} }
if (isLinearElement(element)) {
mutateElement(element, { mutateElement(element, {
width, width,
points: points: rescalePoints(0, width, element.points),
element.points.length > 0
? rescalePoints(0, width, element.points)
: undefined,
}); });
} else {
mutateElement(element, {
width,
});
}
break; break;
} }
} }
@ -1934,10 +1955,7 @@ export class App extends React.Component<any, AppState> {
let width = distance(originX, x); let width = distance(originX, x);
let height = distance(originY, y); let height = distance(originY, y);
const isLinear = if (isLinearElement(draggingElement)) {
this.state.elementType === "line" || this.state.elementType === "arrow";
if (isLinear) {
draggingOccurred = true; draggingOccurred = true;
const points = draggingElement.points; const points = draggingElement.points;
let dx = x - draggingElement.x; let dx = x - draggingElement.x;
@ -2023,7 +2041,7 @@ export class App extends React.Component<any, AppState> {
window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp); window.removeEventListener("pointerup", onPointerUp);
if (elementType === "arrow" || elementType === "line") { if (isLinearElement(draggingElement)) {
if (draggingElement!.points.length > 1) { if (draggingElement!.points.length > 1) {
history.resumeRecording(); history.resumeRecording();
} }
@ -2041,7 +2059,7 @@ export class App extends React.Component<any, AppState> {
], ],
}); });
this.setState({ this.setState({
multiElement: this.state.draggingElement, multiElement: draggingElement,
editingElement: this.state.draggingElement, editingElement: this.state.draggingElement,
}); });
} else if (draggingOccurred && !multiElement) { } else if (draggingOccurred && !multiElement) {
@ -2215,12 +2233,12 @@ export class App extends React.Component<any, AppState> {
const dx = x - elementsCenterX; const dx = x - elementsCenterX;
const dy = y - elementsCenterY; const dy = y - elementsCenterY;
const newElements = clipboardElements.map(clipboardElements => { const newElements = clipboardElements.map(element =>
const duplicate = duplicateElement(clipboardElements); duplicateElement(element, {
duplicate.x += dx - minX; x: element.x + dx - minX,
duplicate.y += dy - minY; y: element.y + dy - minY,
return duplicate; }),
}); );
globalSceneState.replaceAllElements([ globalSceneState.replaceAllElements([
...globalSceneState.getAllElements(), ...globalSceneState.getAllElements(),

View File

@ -5,6 +5,7 @@ import { getSelectedElements } from "../scene";
import "./HintViewer.scss"; import "./HintViewer.scss";
import { AppState } from "../types"; import { AppState } from "../types";
import { isLinearElement } from "../element/typeChecks";
interface Hint { interface Hint {
appState: AppState; appState: AppState;
@ -23,12 +24,8 @@ const getHints = ({ appState, elements }: Hint) => {
if (isResizing) { if (isResizing) {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
if ( const targetElement = selectedElements[0];
selectedElements.length === 1 && if (isLinearElement(targetElement) && targetElement.points.length > 2) {
(selectedElements[0].type === "arrow" ||
selectedElements[0].type === "line") &&
selectedElements[0].points.length > 2
) {
return null; return null;
} }
return t("hints.resize"); return t("hints.resize");

View File

@ -8,7 +8,9 @@ import nanoid from "nanoid";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
export function restore( export function restore(
savedElements: readonly ExcalidrawElement[], // we're making the elements mutable for this API because we want to
// efficiently remove/tweak properties on them (to migrate old scenes)
savedElements: readonly Mutable<ExcalidrawElement>[],
savedState: AppState | null, savedState: AppState | null,
opts?: { scrollToContent: boolean }, opts?: { scrollToContent: boolean },
): DataState { ): DataState {
@ -35,6 +37,7 @@ export function restore(
[element.width, element.height], [element.width, element.height],
]; ];
} }
element.points = points;
} else if (element.type === "line") { } else if (element.type === "line") {
// old spec, pre-arrows // old spec, pre-arrows
// old spec, post-arrows // old spec, post-arrows
@ -46,8 +49,13 @@ export function restore(
} else { } else {
points = element.points; points = element.points;
} }
element.points = points;
} else { } else {
normalizeDimensions(element); normalizeDimensions(element);
// old spec, where non-linear elements used to have empty points arrays
if ("points" in element) {
delete element.points;
}
} }
return { return {
@ -62,7 +70,6 @@ export function restore(
element.opacity === null || element.opacity === undefined element.opacity === null || element.opacity === undefined
? 100 ? 100
: element.opacity, : element.opacity,
points,
}; };
}); });

View File

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

View File

@ -1,13 +1,14 @@
import { ExcalidrawElement } from "./types"; import { ExcalidrawElement, ExcalidrawLinearElement } from "./types";
import { rotate } from "../math"; import { rotate } from "../math";
import { Drawable } from "roughjs/bin/core"; import { Drawable } from "roughjs/bin/core";
import { Point } from "../types"; import { Point } from "../types";
import { getShapeForElement } from "../renderer/renderElement"; 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 // 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. // This set of functions retrieves the absolute position of the 4 points.
export function getElementAbsoluteCoords(element: ExcalidrawElement) { export function getElementAbsoluteCoords(element: ExcalidrawElement) {
if (element.type === "arrow" || element.type === "line") { if (isLinearElement(element)) {
return getLinearElementAbsoluteBounds(element); return getLinearElementAbsoluteBounds(element);
} }
return [ return [
@ -33,7 +34,9 @@ export function getDiamondPoints(element: ExcalidrawElement) {
return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY]; 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)) { if (element.points.length < 2 || !getShapeForElement(element)) {
const { minX, minY, maxX, maxY } = element.points.reduce( const { minX, minY, maxX, maxY } = element.points.reduce(
(limits, [x, y]) => { (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 ops = shape[0].sets[0].ops;
const data = ops[ops.length - 1].data; 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 { Drawable, OpSet } from "roughjs/bin/core";
import { AppState } from "../types"; import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement"; import { getShapeForElement } from "../renderer/renderElement";
import { isLinearElement } from "./typeChecks";
function isElementDraggableFromInside( function isElementDraggableFromInside(
element: ExcalidrawElement, element: ExcalidrawElement,
@ -158,7 +159,7 @@ export function hitTest(
distanceBetweenPointAndSegment(x, y, leftX, leftY, topX, topY) < distanceBetweenPointAndSegment(x, y, leftX, leftY, topX, topY) <
lineThreshold lineThreshold
); );
} else if (element.type === "arrow" || element.type === "line") { } else if (isLinearElement(element)) {
if (!getShapeForElement(element)) { if (!getShapeForElement(element)) {
return false; return false;
} }

View File

@ -1,7 +1,12 @@
import { ExcalidrawElement } from "./types"; import { ExcalidrawElement } from "./types";
import { isInvisiblySmallElement } from "./sizeHelpers"; import { isInvisiblySmallElement } from "./sizeHelpers";
export { newElement, newTextElement, duplicateElement } from "./newElement"; export {
newElement,
newTextElement,
newLinearElement,
duplicateElement,
} from "./newElement";
export { export {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getCommonBounds, 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 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 // 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(). // 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, element: TElement,
updates: ElementUpdate<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") { if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(updates.points!), ...updates }; updates = { ...getSizeFromPoints(points), ...updates };
} }
for (const key in updates) { for (const key in updates) {
const value = (updates as any)[key]; const value = (updates as any)[key];
if (typeof value !== "undefined") { if (typeof value !== "undefined") {
mutableElement[key] = value; // @ts-ignore
element[key] = value;
} }
} }
if ( if (
typeof updates.height !== "undefined" || typeof updates.height !== "undefined" ||
typeof updates.width !== "undefined" || typeof updates.width !== "undefined" ||
typeof updates.points !== "undefined" typeof points !== "undefined"
) { ) {
invalidateShapeForElement(element); invalidateShapeForElement(element);
} }
mutableElement.version++; element.version++;
mutableElement.versionNonce = randomSeed(); element.versionNonce = randomSeed();
globalSceneState.informMutation(); 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) { function isPrimitive(val: any) {
const type = typeof val; const type = typeof val;
@ -17,25 +22,27 @@ function assertCloneObjects(source: any, clone: any) {
} }
it("clones arrow element", () => { it("clones arrow element", () => {
const element = newElement( const element = newLinearElement({
"arrow", type: "arrow",
0, x: 0,
0, y: 0,
"#000000", strokeColor: "#000000",
"transparent", backgroundColor: "transparent",
"hachure", fillStyle: "hachure",
1, strokeWidth: 1,
1, roughness: 1,
100, opacity: 100,
); });
// @ts-ignore // @ts-ignore
element.__proto__ = { hello: "world" }; element.__proto__ = { hello: "world" };
element.points = [ mutateElement(element, {
points: [
[1, 2], [1, 2],
[3, 4], [3, 4],
]; ],
});
const copy = duplicateElement(element); const copy = duplicateElement(element);
@ -59,17 +66,24 @@ it("clones arrow element", () => {
}); });
it("clones text element", () => { it("clones text element", () => {
const element = newTextElement( const element = newTextElement({
newElement("text", 0, 0, "#000000", "transparent", "hachure", 1, 1, 100), x: 0,
"hello", y: 0,
"Arial 20px", strokeColor: "#000000",
); backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
roughness: 1,
opacity: 100,
text: "hello",
font: "Arial 20px",
});
const copy = duplicateElement(element); const copy = duplicateElement(element);
assertCloneObjects(element, copy); assertCloneObjects(element, copy);
expect(copy.points).not.toBe(element.points); expect(copy).not.toHaveProperty("points");
expect(copy).not.toHaveProperty("shape"); expect(copy).not.toHaveProperty("shape");
expect(copy.id).not.toBe(element.id); expect(copy.id).not.toBe(element.id);
expect(typeof copy.id).toBe("string"); expect(typeof copy.id).toBe("string");

View File

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

View File

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

View File

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

View File

@ -1,4 +1,8 @@
import { ExcalidrawElement, ExcalidrawTextElement } from "./types"; import {
ExcalidrawElement,
ExcalidrawTextElement,
ExcalidrawLinearElement,
} from "./types";
export function isTextElement( export function isTextElement(
element: ExcalidrawElement, element: ExcalidrawElement,
@ -6,6 +10,14 @@ export function isTextElement(
return element.type === "text"; 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 { export function isExcalidrawElement(element: any): boolean {
return ( return (
element?.type === "text" || 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 * ExcalidrawElement should be JSON serializable and (eventually) contain
* no computed data. The list of all ExcalidrawElements should be shareable * no computed data. The list of all ExcalidrawElements should be shareable
* between peers and contain no state local to the peer. * 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<{ Readonly<{
type: "text"; type: "text";
font: string; font: string;
text: string; text: string;
// for backward compatibility
actualBoundingBoxAscent?: number;
baseline: number; baseline: number;
}>; }>;
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
type: "arrow" | "line";
points: Point[];
}>;
export type PointerType = "mouse" | "pen" | "touch"; export type PointerType = "mouse" | "pen" | "touch";

4
src/global.d.ts vendored
View File

@ -5,3 +5,7 @@ interface Window {
interface Clipboard extends EventTarget { interface Clipboard extends EventTarget {
write(data: any[]): Promise<void>; write(data: any[]): Promise<void>;
} }
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};

View File

@ -2,6 +2,7 @@ import { AppState } from "./types";
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { clearAppStatePropertiesForHistory } from "./appState"; import { clearAppStatePropertiesForHistory } from "./appState";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { isLinearElement } from "./element/typeChecks";
type Result = { type Result = {
appState: AppState; appState: AppState;
@ -24,14 +25,17 @@ export class SceneHistory {
) { ) {
return JSON.stringify({ return JSON.stringify({
appState: clearAppStatePropertiesForHistory(appState), appState: clearAppStatePropertiesForHistory(appState),
elements: elements.map(element => elements: elements.map(element => {
newElementWith(element, { if (isLinearElement(element)) {
return newElementWith(element, {
points: points:
appState.multiElement && appState.multiElement.id === element.id appState.multiElement && appState.multiElement.id === element.id
? element.points.slice(0, -1) ? element.points.slice(0, -1)
: element.points, : element.points,
});
}
return newElementWith(element, {});
}), }),
),
}); });
} }

View File

@ -12,7 +12,7 @@ export function rescalePoints(
dimension: 0 | 1, dimension: 0 | 1,
nextDimensionSize: number, nextDimensionSize: number,
prevPoints: readonly Point[], prevPoints: readonly Point[],
): readonly Point[] { ): Point[] {
const prevDimValues = prevPoints.map(point => point[dimension]); const prevDimValues = prevPoints.map(point => point[dimension]);
const prevMaxDimension = Math.max(...prevDimValues); const prevMaxDimension = Math.max(...prevDimValues);
const prevMinDimension = Math.min(...prevDimValues); const prevMinDimension = Math.min(...prevDimValues);

View File

@ -330,6 +330,7 @@ export function renderElement(
break; break;
} }
default: { default: {
// @ts-ignore
throw new Error(`Unimplemented type ${element.type}`); throw new Error(`Unimplemented type ${element.type}`);
} }
} }
@ -420,6 +421,7 @@ export function renderElementToSvg(
} }
svgRoot.appendChild(node); svgRoot.appendChild(node);
} else { } else {
// @ts-ignore
throw new Error(`Unimplemented type ${element.type}`); throw new Error(`Unimplemented type ${element.type}`);
} }
} }

View File

@ -4,6 +4,7 @@ import { App } from "../components/App";
import * as Renderer from "../renderer/renderScene"; import * as Renderer from "../renderer/renderScene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { render, fireEvent } from "./test-utils"; import { render, fireEvent } from "./test-utils";
import { ExcalidrawLinearElement } from "../element/types";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@ -122,12 +123,15 @@ describe("add element to the scene when pointer dragging long enough", () => {
expect(h.appState.selectionElement).toBeNull(); expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.elements[0].type).toEqual("arrow");
expect(h.elements[0].x).toEqual(30); const element = h.elements[0] as ExcalidrawLinearElement;
expect(h.elements[0].y).toEqual(20);
expect(h.elements[0].points.length).toEqual(2); expect(element.type).toEqual("arrow");
expect(h.elements[0].points[0]).toEqual([0, 0]); expect(element.x).toEqual(30);
expect(h.elements[0].points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20) expect(element.y).toEqual(20);
expect(element.points.length).toEqual(2);
expect(element.points[0]).toEqual([0, 0]);
expect(element.points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20)
}); });
it("line", () => { it("line", () => {
@ -151,12 +155,15 @@ describe("add element to the scene when pointer dragging long enough", () => {
expect(h.appState.selectionElement).toBeNull(); expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.elements[0].type).toEqual("line");
expect(h.elements[0].x).toEqual(30); const element = h.elements[0] as ExcalidrawLinearElement;
expect(h.elements[0].y).toEqual(20);
expect(h.elements[0].points.length).toEqual(2); expect(element.type).toEqual("line");
expect(h.elements[0].points[0]).toEqual([0, 0]); expect(element.x).toEqual(30);
expect(h.elements[0].points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20) expect(element.y).toEqual(20);
expect(element.points.length).toEqual(2);
expect(element.points[0]).toEqual([0, 0]);
expect(element.points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20)
}); });
}); });

View File

@ -4,6 +4,7 @@ import { render, fireEvent } from "./test-utils";
import { App } from "../components/App"; import { App } from "../components/App";
import * as Renderer from "../renderer/renderScene"; import * as Renderer from "../renderer/renderScene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { ExcalidrawLinearElement } from "../element/types";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@ -88,10 +89,12 @@ describe("multi point mode in linear elements", () => {
expect(renderScene).toHaveBeenCalledTimes(10); expect(renderScene).toHaveBeenCalledTimes(10);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.elements[0].type).toEqual("arrow"); const element = h.elements[0] as ExcalidrawLinearElement;
expect(h.elements[0].x).toEqual(30);
expect(h.elements[0].y).toEqual(30); expect(element.type).toEqual("arrow");
expect(h.elements[0].points).toEqual([ expect(element.x).toEqual(30);
expect(element.y).toEqual(30);
expect(element.points).toEqual([
[0, 0], [0, 0],
[20, 30], [20, 30],
[70, 110], [70, 110],
@ -125,10 +128,12 @@ describe("multi point mode in linear elements", () => {
expect(renderScene).toHaveBeenCalledTimes(10); expect(renderScene).toHaveBeenCalledTimes(10);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.elements[0].type).toEqual("line"); const element = h.elements[0] as ExcalidrawLinearElement;
expect(h.elements[0].x).toEqual(30);
expect(h.elements[0].y).toEqual(30); expect(element.type).toEqual("line");
expect(h.elements[0].points).toEqual([ expect(element.x).toEqual(30);
expect(element.y).toEqual(30);
expect(element.points).toEqual([
[0, 0], [0, 0],
[20, 30], [20, 30],
[70, 110], [70, 110],

View File

@ -1,4 +1,8 @@
import { ExcalidrawElement, PointerType } from "./element/types"; import {
ExcalidrawElement,
PointerType,
ExcalidrawLinearElement,
} from "./element/types";
import { SHAPES } from "./shapes"; import { SHAPES } from "./shapes";
import { Point as RoughPoint } from "roughjs/bin/geometry"; import { Point as RoughPoint } from "roughjs/bin/geometry";
@ -8,7 +12,7 @@ export type Point = Readonly<RoughPoint>;
export type AppState = { export type AppState = {
draggingElement: ExcalidrawElement | null; draggingElement: ExcalidrawElement | null;
resizingElement: ExcalidrawElement | null; resizingElement: ExcalidrawElement | null;
multiElement: ExcalidrawElement | null; multiElement: ExcalidrawLinearElement | null;
selectionElement: ExcalidrawElement | null; selectionElement: ExcalidrawElement | null;
// element being edited, but not necessarily added to elements array yet // element being edited, but not necessarily added to elements array yet
// (e.g. text element when typing into the input) // (e.g. text element when typing into the input)