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
39 changed files with 416 additions and 306 deletions

View File

@ -1,7 +1,7 @@
import React from "react";
import { ExcalidrawElement } from "../element/types";
import { ActionManager } from "../actions/manager";
import { hasBackground, hasStroke, hasText, clearSelection } from "../scene";
import { hasBackground, hasStroke, hasText } from "../scene";
import { t } from "../i18n";
import { SHAPES } from "../shapes";
import { ToolButton } from "./ToolButton";
@ -92,8 +92,11 @@ export function ShapesSwitcher({
aria-label={capitalizeString(label)}
aria-keyshortcuts={`${label[0]} ${index + 1}`}
onChange={() => {
setAppState({ elementType: value, multiElement: null });
setElements(clearSelection(elements));
setAppState({
elementType: value,
multiElement: null,
selectedElementIds: {},
});
document.documentElement.style.cursor =
value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
setAppState({});

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!,

View File

@ -48,7 +48,7 @@ function ExportModal({
onExportToBackend: ExportCB;
onCloseRequest: () => void;
}) {
const someElementIsSelected = isSomeElementSelected(elements);
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [scale, setScale] = useState(defaultScale);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null);
@ -58,7 +58,7 @@ function ExportModal({
const onlySelectedInput = useRef<HTMLInputElement>(null);
const exportedElements = exportSelected
? getSelectedElements(elements)
? getSelectedElements(elements, appState)
: elements;
useEffect(() => {
@ -67,7 +67,7 @@ function ExportModal({
useEffect(() => {
const previewNode = previewRef.current;
const canvas = exportToCanvas(exportedElements, {
const canvas = exportToCanvas(exportedElements, appState, {
exportBackground,
viewBackgroundColor,
exportPadding,
@ -78,6 +78,7 @@ function ExportModal({
previewNode?.removeChild(canvas);
};
}, [
appState,
exportedElements,
exportBackground,
exportPadding,

View File

@ -4,15 +4,16 @@ import { ExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene";
import "./HintViewer.css";
import { AppState } from "../types";
interface Hint {
elementType: string;
multiMode: boolean;
isResizing: boolean;
appState: AppState;
elements: readonly ExcalidrawElement[];
}
const getHints = ({ elementType, multiMode, isResizing, elements }: Hint) => {
const getHints = ({ appState, elements }: Hint) => {
const { elementType, isResizing } = appState;
const multiMode = appState.multiElement !== null;
if (elementType === "arrow" || elementType === "line") {
if (!multiMode) {
return t("hints.linearElement");
@ -21,7 +22,7 @@ const getHints = ({ elementType, multiMode, isResizing, elements }: Hint) => {
}
if (isResizing) {
const selectedElements = getSelectedElements(elements);
const selectedElements = getSelectedElements(elements, appState);
if (
selectedElements.length === 1 &&
(selectedElements[0].type === "arrow" ||
@ -36,16 +37,9 @@ const getHints = ({ elementType, multiMode, isResizing, elements }: Hint) => {
return null;
};
export const HintViewer = ({
elementType,
multiMode,
isResizing,
elements,
}: Hint) => {
export const HintViewer = ({ appState, elements }: Hint) => {
const hint = getHints({
elementType,
multiMode,
isResizing,
appState,
elements,
});
if (!hint) {

View File

@ -50,7 +50,7 @@ export const LayerUI = React.memo(
scale,
) => {
if (canvas) {
exportCanvas(type, exportedElements, canvas, {
exportCanvas(type, exportedElements, appState, canvas, {
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
@ -70,10 +70,11 @@ export const LayerUI = React.memo(
if (canvas) {
exportCanvas(
"backend",
exportedElements.map(element => ({
...element,
isSelected: false,
})),
exportedElements,
{
...appState,
selectedElementIds: {},
},
canvas,
appState,
);
@ -95,12 +96,7 @@ export const LayerUI = React.memo(
) : (
<>
<FixedSideContainer side="top">
<HintViewer
elementType={appState.elementType}
multiMode={appState.multiElement !== null}
isResizing={appState.isResizing}
elements={elements}
/>
<HintViewer appState={appState} elements={elements} />
<div className="App-menu App-menu_top">
<Stack.Col gap={4} align="end">
<Section className="App-right-menu" heading="canvasActions">
@ -123,10 +119,7 @@ export const LayerUI = React.memo(
>
<Island padding={4}>
<SelectedShapeActions
targetElements={getTargetElement(
appState.editingElement,
elements,
)}
targetElements={getTargetElement(elements, appState)}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
/>

View File

@ -58,10 +58,7 @@ export function MobileMenu({
<Section className="App-mobile-menu" heading="selectedShapeActions">
<div className="App-mobile-menu-scroller">
<SelectedShapeActions
targetElements={getTargetElement(
appState.editingElement,
elements,
)}
targetElements={getTargetElement(elements, appState)}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
/>
@ -88,12 +85,7 @@ export function MobileMenu({
</Stack.Col>
)}
</Section>
<HintViewer
elementType={appState.elementType}
multiMode={appState.multiElement !== null}
isResizing={appState.isResizing}
elements={elements}
/>
<HintViewer appState={appState} elements={elements} />
</FixedSideContainer>
<footer className="App-toolbar">
<div className="App-toolbar-content">