Refactor ExcalidrawElement (#874)

* Get rid of isSelected, canvas, canvasZoom, canvasOffsetX and canvasOffsetY on ExcalidrawElement.

* Fix most unit tests. Fix cmd a. Fix alt drag

* Focus on paste

* shift select should include previously selected items

* Fix last test

* Move this.shape out of ExcalidrawElement and into a WeakMap
This commit is contained in:
Pete Hunt 2020-03-08 10:20:55 -07:00 committed by GitHub
parent 8ecb4201db
commit ccbbdb75a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 416 additions and 306 deletions

View file

@ -19,7 +19,6 @@ import {
normalizeDimensions,
} from "../element";
import {
clearSelection,
deleteSelectedElements,
getElementsWithinSelection,
isOverScrollBars,
@ -77,6 +76,7 @@ import {
} from "../constants";
import { LayerUI } from "./LayerUI";
import { ScrollBars } from "../scene/types";
import { invalidateShapeForElement } from "../renderer/renderElement";
// -----------------------------------------------------------------------------
// TEST HOOKS
@ -179,8 +179,8 @@ export class App extends React.Component<any, AppState> {
if (isWritableElement(event.target)) {
return;
}
copyToAppClipboard(elements);
elements = deleteSelectedElements(elements);
copyToAppClipboard(elements, this.state);
elements = deleteSelectedElements(elements, this.state);
history.resumeRecording();
this.setState({});
event.preventDefault();
@ -189,7 +189,7 @@ export class App extends React.Component<any, AppState> {
if (isWritableElement(event.target)) {
return;
}
copyToAppClipboard(elements);
copyToAppClipboard(elements, this.state);
event.preventDefault();
};
@ -296,7 +296,7 @@ export class App extends React.Component<any, AppState> {
public state: AppState = getDefaultAppState();
private onResize = () => {
elements = elements.map(el => ({ ...el, shape: null }));
elements.forEach(element => invalidateShapeForElement(element));
this.setState({});
};
@ -325,7 +325,7 @@ export class App extends React.Component<any, AppState> {
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
: ELEMENT_TRANSLATE_AMOUNT;
elements = elements.map(el => {
if (el.isSelected) {
if (this.state.selectedElementIds[el.id]) {
const element = { ...el };
if (event.key === KEYS.ARROW_LEFT) {
element.x -= step;
@ -361,19 +361,18 @@ export class App extends React.Component<any, AppState> {
if (this.state.elementType === "selection") {
resetCursor();
} else {
elements = clearSelection(elements);
document.documentElement.style.cursor =
this.state.elementType === "text"
? CURSOR_TYPE.TEXT
: CURSOR_TYPE.CROSSHAIR;
this.setState({});
this.setState({ selectedElementIds: {} });
}
isHoldingSpace = false;
}
};
private copyToAppClipboard = () => {
copyToAppClipboard(elements);
copyToAppClipboard(elements, this.state);
};
private pasteFromClipboard = async (event: ClipboardEvent | null) => {
@ -413,9 +412,8 @@ export class App extends React.Component<any, AppState> {
this.state.currentItemFont,
);
element.isSelected = true;
elements = [...clearSelection(elements), element];
elements = [...elements, element];
this.setState({ selectedElementIds: { [element.id]: true } });
history.resumeRecording();
}
this.selectShapeTool("selection");
@ -431,9 +429,10 @@ export class App extends React.Component<any, AppState> {
document.activeElement.blur();
}
if (elementType !== "selection") {
elements = clearSelection(elements);
this.setState({ elementType, selectedElementIds: {} });
} else {
this.setState({ elementType });
}
this.setState({ elementType });
}
private onGestureStart = (event: GestureEvent) => {
@ -524,6 +523,7 @@ export class App extends React.Component<any, AppState> {
const element = getElementAtPosition(
elements,
this.state,
x,
y,
this.state.zoom,
@ -545,10 +545,8 @@ export class App extends React.Component<any, AppState> {
return;
}
if (!element.isSelected) {
elements = clearSelection(elements);
element.isSelected = true;
this.setState({});
if (!this.state.selectedElementIds[element.id]) {
this.setState({ selectedElementIds: { [element.id]: true } });
}
ContextMenu.push({
@ -760,12 +758,16 @@ export class App extends React.Component<any, AppState> {
if (this.state.elementType === "selection") {
const resizeElement = getElementWithResizeHandler(
elements,
this.state,
{ x, y },
this.state.zoom,
event.pointerType,
);
const selectedElements = getSelectedElements(elements);
const selectedElements = getSelectedElements(
elements,
this.state,
);
if (selectedElements.length === 1 && resizeElement) {
this.setState({
resizingElement: resizeElement
@ -781,13 +783,19 @@ export class App extends React.Component<any, AppState> {
} else {
hitElement = getElementAtPosition(
elements,
this.state,
x,
y,
this.state.zoom,
);
// clear selection if shift is not clicked
if (!hitElement?.isSelected && !event.shiftKey) {
elements = clearSelection(elements);
if (
!(
hitElement && this.state.selectedElementIds[hitElement.id]
) &&
!event.shiftKey
) {
this.setState({ selectedElementIds: {} });
}
// If we click on something
@ -796,30 +804,37 @@ export class App extends React.Component<any, AppState> {
// if shift is not clicked, this will always return true
// otherwise, it will trigger selection based on current
// state of the box
if (!hitElement.isSelected) {
hitElement.isSelected = true;
if (!this.state.selectedElementIds[hitElement.id]) {
this.setState(prevState => ({
selectedElementIds: {
...prevState.selectedElementIds,
[hitElement!.id]: true,
},
}));
elements = elements.slice();
elementIsAddedToSelection = true;
}
// We duplicate the selected element if alt is pressed on pointer down
if (event.altKey) {
elements = [
...elements.map(element => ({
...element,
isSelected: false,
})),
...getSelectedElements(elements).map(element => {
const newElement = duplicateElement(element);
newElement.isSelected = true;
return newElement;
}),
];
// Move the currently selected elements to the top of the z index stack, and
// put the duplicates where the selected elements used to be.
const nextElements = [];
const elementsToAppend = [];
for (const element of elements) {
if (this.state.selectedElementIds[element.id]) {
nextElements.push(duplicateElement(element));
elementsToAppend.push(element);
} else {
nextElements.push(element);
}
}
elements = [...nextElements, ...elementsToAppend];
}
}
}
} else {
elements = clearSelection(elements);
this.setState({ selectedElementIds: {} });
}
if (isTextElement(element)) {
@ -872,10 +887,15 @@ export class App extends React.Component<any, AppState> {
text,
this.state.currentItemFont,
),
isSelected: true,
},
];
}
this.setState(prevState => ({
selectedElementIds: {
...prevState.selectedElementIds,
[element.id]: true,
},
}));
if (this.state.elementLocked) {
setCursorForShape(this.state.elementType);
}
@ -905,13 +925,23 @@ export class App extends React.Component<any, AppState> {
if (this.state.multiElement) {
const { multiElement } = this.state;
const { x: rx, y: ry } = multiElement;
multiElement.isSelected = true;
this.setState(prevState => ({
selectedElementIds: {
...prevState.selectedElementIds,
[multiElement.id]: true,
},
}));
multiElement.points.push([x - rx, y - ry]);
multiElement.shape = null;
invalidateShapeForElement(multiElement);
} else {
element.isSelected = false;
this.setState(prevState => ({
selectedElementIds: {
...prevState.selectedElementIds,
[element.id]: false,
},
}));
element.points.push([0, 0]);
element.shape = null;
invalidateShapeForElement(element);
elements = [...elements, element];
this.setState({
draggingElement: element,
@ -1047,7 +1077,10 @@ export class App extends React.Component<any, AppState> {
if (isResizingElements && this.state.resizingElement) {
this.setState({ isResizing: true });
const el = this.state.resizingElement;
const selectedElements = getSelectedElements(elements);
const selectedElements = getSelectedElements(
elements,
this.state,
);
if (selectedElements.length === 1) {
const { x, y } = viewportCoordsToSceneCoords(
event,
@ -1261,7 +1294,7 @@ export class App extends React.Component<any, AppState> {
);
el.x = element.x;
el.y = element.y;
el.shape = null;
invalidateShapeForElement(el);
lastX = x;
lastY = y;
@ -1270,11 +1303,17 @@ export class App extends React.Component<any, AppState> {
}
}
if (hitElement?.isSelected) {
if (
hitElement &&
this.state.selectedElementIds[hitElement.id]
) {
// Marking that click was used for dragging to check
// if elements should be deselected on pointerup
draggingOccurred = true;
const selectedElements = getSelectedElements(elements);
const selectedElements = getSelectedElements(
elements,
this.state,
);
if (selectedElements.length > 0) {
const { x, y } = viewportCoordsToSceneCoords(
event,
@ -1354,19 +1393,30 @@ export class App extends React.Component<any, AppState> {
draggingElement.height = height;
}
draggingElement.shape = null;
invalidateShapeForElement(draggingElement);
if (this.state.elementType === "selection") {
if (!event.shiftKey && isSomeElementSelected(elements)) {
elements = clearSelection(elements);
if (
!event.shiftKey &&
isSomeElementSelected(elements, this.state)
) {
this.setState({ selectedElementIds: {} });
}
const elementsWithinSelection = getElementsWithinSelection(
elements,
draggingElement,
);
elementsWithinSelection.forEach(element => {
element.isSelected = true;
});
this.setState(prevState => ({
selectedElementIds: {
...prevState.selectedElementIds,
...Object.fromEntries(
elementsWithinSelection.map(element => [
element.id,
true,
]),
),
},
}));
}
this.setState({});
};
@ -1406,20 +1456,27 @@ export class App extends React.Component<any, AppState> {
x - draggingElement.x,
y - draggingElement.y,
]);
draggingElement.shape = null;
invalidateShapeForElement(draggingElement);
this.setState({ multiElement: this.state.draggingElement });
} else if (draggingOccurred && !multiElement) {
this.state.draggingElement!.isSelected = true;
if (!elementLocked) {
resetCursor();
this.setState({
this.setState(prevState => ({
draggingElement: null,
elementType: "selection",
});
selectedElementIds: {
...prevState.selectedElementIds,
[this.state.draggingElement!.id]: true,
},
}));
} else {
this.setState({
this.setState(prevState => ({
draggingElement: null,
});
selectedElementIds: {
...prevState.selectedElementIds,
[this.state.draggingElement!.id]: true,
},
}));
}
}
return;
@ -1470,27 +1527,37 @@ export class App extends React.Component<any, AppState> {
!elementIsAddedToSelection
) {
if (event.shiftKey) {
hitElement.isSelected = false;
this.setState(prevState => ({
selectedElementIds: {
...prevState.selectedElementIds,
[hitElement!.id]: false,
},
}));
} else {
elements = clearSelection(elements);
hitElement.isSelected = true;
this.setState(prevState => ({
selectedElementIds: { [hitElement!.id]: true },
}));
}
}
if (draggingElement === null) {
// if no element is clicked, clear the selection and redraw
elements = clearSelection(elements);
this.setState({});
this.setState({ selectedElementIds: {} });
return;
}
if (!elementLocked) {
draggingElement.isSelected = true;
this.setState(prevState => ({
selectedElementIds: {
...prevState.selectedElementIds,
[draggingElement.id]: true,
},
}));
}
if (
elementType !== "selection" ||
isSomeElementSelected(elements)
isSomeElementSelected(elements, this.state)
) {
history.resumeRecording();
}
@ -1524,6 +1591,7 @@ export class App extends React.Component<any, AppState> {
const elementAtPosition = getElementAtPosition(
elements,
this.state,
x,
y,
this.state.zoom,
@ -1616,10 +1684,15 @@ export class App extends React.Component<any, AppState> {
// we need to recreate the element to update dimensions &
// position
...newTextElement(element, text, element.font),
isSelected: true,
},
];
}
this.setState(prevState => ({
selectedElementIds: {
...prevState.selectedElementIds,
[element.id]: true,
},
}));
history.resumeRecording();
resetSelection();
},
@ -1695,7 +1768,7 @@ export class App extends React.Component<any, AppState> {
const pnt = points[points.length - 1];
pnt[0] = x - originX;
pnt[1] = y - originY;
multiElement.shape = null;
invalidateShapeForElement(multiElement);
this.setState({});
return;
}
@ -1708,10 +1781,14 @@ export class App extends React.Component<any, AppState> {
return;
}
const selectedElements = getSelectedElements(elements);
const selectedElements = getSelectedElements(
elements,
this.state,
);
if (selectedElements.length === 1 && !isOverScrollBar) {
const resizeElement = getElementWithResizeHandler(
elements,
this.state,
{ x, y },
this.state.zoom,
event.pointerType,
@ -1725,6 +1802,7 @@ export class App extends React.Component<any, AppState> {
}
const hitElement = getElementAtPosition(
elements,
this.state,
x,
y,
this.state.zoom,
@ -1782,8 +1860,6 @@ export class App extends React.Component<any, AppState> {
private addElementsFromPaste = (
clipboardElements: readonly ExcalidrawElement[],
) => {
elements = clearSelection(elements);
const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements);
const elementsCenterX = distance(minX, maxX) / 2;
@ -1798,17 +1874,20 @@ export class App extends React.Component<any, AppState> {
const dx = x - elementsCenterX;
const dy = y - elementsCenterY;
elements = [
...elements,
...clipboardElements.map(clipboardElements => {
const duplicate = duplicateElement(clipboardElements);
duplicate.x += dx - minX;
duplicate.y += dy - minY;
return duplicate;
}),
];
const newElements = clipboardElements.map(clipboardElements => {
const duplicate = duplicateElement(clipboardElements);
duplicate.x += dx - minX;
duplicate.y += dy - minY;
return duplicate;
});
elements = [...elements, ...newElements];
history.resumeRecording();
this.setState({});
this.setState({
selectedElementIds: Object.fromEntries(
newElements.map(element => [element.id, true]),
),
});
};
private getTextWysiwygSnappedToCenterPosition(x: number, y: number) {
@ -1845,6 +1924,7 @@ export class App extends React.Component<any, AppState> {
componentDidUpdate() {
const { atLeastOneVisibleElement, scrollBars } = renderScene(
elements,
this.state,
this.state.selectionElement,
this.rc!,
this.canvas!,