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

@ -102,5 +102,8 @@
"repository": {
"type": "git",
"url": "https://github.com/excalidraw/excalidraw.git"
},
"engines": {
"node": ">=12.0.0"
}
}

View File

@ -10,22 +10,23 @@ export const actionDeleteSelected = register({
name: "deleteSelectedElements",
perform: (elements, appState) => {
return {
elements: deleteSelectedElements(elements),
elements: deleteSelectedElements(elements, appState),
appState: { ...appState, elementType: "selection", multiElement: null },
};
},
contextItemLabel: "labels.delete",
contextMenuOrder: 3,
commitToHistory: (_, elements) => isSomeElementSelected(elements),
commitToHistory: (appState, elements) =>
isSomeElementSelected(elements, appState),
keyTest: event => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
PanelComponent: ({ elements, updateData }) => (
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
type="button"
icon={trash}
title={t("labels.delete")}
aria-label={t("labels.delete")}
onClick={() => updateData(null)}
visible={isSomeElementSelected(elements)}
visible={isSomeElementSelected(elements, appState)}
/>
),
});

View File

@ -1,5 +1,4 @@
import { KEYS } from "../keys";
import { clearSelection } from "../scene";
import { isInvisiblySmallElement } from "../element";
import { resetCursor } from "../utils";
import React from "react";
@ -7,11 +6,12 @@ import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register";
import { invalidateShapeForElement } from "../renderer/renderElement";
export const actionFinalize = register({
name: "finalize",
perform: (elements, appState) => {
let newElements = clearSelection(elements);
let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) {
window.document.activeElement.blur();
}
@ -26,9 +26,9 @@ export const actionFinalize = register({
if (isInvisiblySmallElement(appState.multiElement)) {
newElements = newElements.slice(0, -1);
}
appState.multiElement.shape = null;
invalidateShapeForElement(appState.multiElement);
if (!appState.elementLocked) {
appState.multiElement.isSelected = true;
appState.selectedElementIds[appState.multiElement.id] = true;
}
}
if (!appState.elementLocked || !appState.multiElement) {
@ -44,6 +44,7 @@ export const actionFinalize = register({
: "selection",
draggingElement: null,
multiElement: null,
selectedElementIds: {},
},
};
},

View File

@ -14,10 +14,11 @@ import { register } from "./register";
const changeProperty = (
elements: readonly ExcalidrawElement[],
appState: AppState,
callback: (element: ExcalidrawElement) => ExcalidrawElement,
) => {
return elements.map(element => {
if (element.isSelected) {
if (appState.selectedElementIds[element.id]) {
return callback(element);
}
return element;
@ -25,15 +26,16 @@ const changeProperty = (
};
const getFormValue = function<T>(
editingElement: AppState["editingElement"],
elements: readonly ExcalidrawElement[],
appState: AppState,
getAttribute: (element: ExcalidrawElement) => T,
defaultValue?: T,
): T | null {
const editingElement = appState.editingElement;
return (
(editingElement && getAttribute(editingElement)) ??
(isSomeElementSelected(elements)
? getCommonAttributeOfSelectedElements(elements, getAttribute)
(isSomeElementSelected(elements, appState)
? getCommonAttributeOfSelectedElements(elements, appState, getAttribute)
: defaultValue) ??
null
);
@ -43,9 +45,8 @@ export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, el => ({
elements: changeProperty(elements, appState, el => ({
...el,
shape: null,
strokeColor: value,
})),
appState: { ...appState, currentItemStrokeColor: value },
@ -59,8 +60,8 @@ export const actionChangeStrokeColor = register({
type="elementStroke"
label={t("labels.stroke")}
color={getFormValue(
appState.editingElement,
elements,
appState,
element => element.strokeColor,
appState.currentItemStrokeColor,
)}
@ -74,9 +75,8 @@ export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, el => ({
elements: changeProperty(elements, appState, el => ({
...el,
shape: null,
backgroundColor: value,
})),
appState: { ...appState, currentItemBackgroundColor: value },
@ -90,8 +90,8 @@ export const actionChangeBackgroundColor = register({
type="elementBackground"
label={t("labels.background")}
color={getFormValue(
appState.editingElement,
elements,
appState,
element => element.backgroundColor,
appState.currentItemBackgroundColor,
)}
@ -105,9 +105,8 @@ export const actionChangeFillStyle = register({
name: "changeFillStyle",
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, el => ({
elements: changeProperty(elements, appState, el => ({
...el,
shape: null,
fillStyle: value,
})),
appState: { ...appState, currentItemFillStyle: value },
@ -125,8 +124,8 @@ export const actionChangeFillStyle = register({
]}
group="fill"
value={getFormValue(
appState.editingElement,
elements,
appState,
element => element.fillStyle,
appState.currentItemFillStyle,
)}
@ -142,9 +141,8 @@ export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth",
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, el => ({
elements: changeProperty(elements, appState, el => ({
...el,
shape: null,
strokeWidth: value,
})),
appState: { ...appState, currentItemStrokeWidth: value },
@ -162,8 +160,8 @@ export const actionChangeStrokeWidth = register({
{ value: 4, text: t("labels.extraBold") },
]}
value={getFormValue(
appState.editingElement,
elements,
appState,
element => element.strokeWidth,
appState.currentItemStrokeWidth,
)}
@ -177,9 +175,8 @@ export const actionChangeSloppiness = register({
name: "changeSloppiness",
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, el => ({
elements: changeProperty(elements, appState, el => ({
...el,
shape: null,
roughness: value,
})),
appState: { ...appState, currentItemRoughness: value },
@ -197,8 +194,8 @@ export const actionChangeSloppiness = register({
{ value: 2, text: t("labels.cartoonist") },
]}
value={getFormValue(
appState.editingElement,
elements,
appState,
element => element.roughness,
appState.currentItemRoughness,
)}
@ -212,9 +209,8 @@ export const actionChangeOpacity = register({
name: "changeOpacity",
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, el => ({
elements: changeProperty(elements, appState, el => ({
...el,
shape: null,
opacity: value,
})),
appState: { ...appState, currentItemOpacity: value },
@ -246,8 +242,8 @@ export const actionChangeOpacity = register({
}}
value={
getFormValue(
appState.editingElement,
elements,
appState,
element => element.opacity,
appState.currentItemOpacity,
) ?? undefined
@ -261,11 +257,10 @@ export const actionChangeFontSize = register({
name: "changeFontSize",
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, el => {
elements: changeProperty(elements, appState, el => {
if (isTextElement(el)) {
const element: ExcalidrawTextElement = {
...el,
shape: null,
font: `${value}px ${el.font.split("px ")[1]}`,
};
redrawTextBoundingBox(element);
@ -295,8 +290,8 @@ export const actionChangeFontSize = register({
{ value: 36, text: t("labels.veryLarge") },
]}
value={getFormValue(
appState.editingElement,
elements,
appState,
element => isTextElement(element) && +element.font.split("px ")[0],
+(appState.currentItemFont || DEFAULT_FONT).split("px ")[0],
)}
@ -310,11 +305,10 @@ export const actionChangeFontFamily = register({
name: "changeFontFamily",
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, el => {
elements: changeProperty(elements, appState, el => {
if (isTextElement(el)) {
const element: ExcalidrawTextElement = {
...el,
shape: null,
font: `${el.font.split("px ")[0]}px ${value}`,
};
redrawTextBoundingBox(element);
@ -343,8 +337,8 @@ export const actionChangeFontFamily = register({
{ value: "Cascadia", text: t("labels.code") },
]}
value={getFormValue(
appState.editingElement,
elements,
appState,
element => isTextElement(element) && element.font.split("px ")[1],
(appState.currentItemFont || DEFAULT_FONT).split("px ")[1],
)}

View File

@ -3,9 +3,14 @@ import { register } from "./register";
export const actionSelectAll = register({
name: "selectAll",
perform: elements => {
perform: (elements, appState) => {
return {
elements: elements.map(elem => ({ ...elem, isSelected: true })),
appState: {
...appState,
selectedElementIds: Object.fromEntries(
elements.map(element => [element.id, true]),
),
},
};
},
contextItemLabel: "labels.selectAll",

View File

@ -11,8 +11,8 @@ let copiedStyles: string = "{}";
export const actionCopyStyles = register({
name: "copyStyles",
perform: elements => {
const element = elements.find(el => el.isSelected);
perform: (elements, appState) => {
const element = elements.find(el => appState.selectedElementIds[el.id]);
if (element) {
copiedStyles = JSON.stringify(element);
}
@ -25,17 +25,16 @@ export const actionCopyStyles = register({
export const actionPasteStyles = register({
name: "pasteStyles",
perform: elements => {
perform: (elements, appState) => {
const pastedElement = JSON.parse(copiedStyles);
if (!isExcalidrawElement(pastedElement)) {
return { elements };
}
return {
elements: elements.map(element => {
if (element.isSelected) {
if (appState.selectedElementIds[element.id]) {
const newElement = {
...element,
shape: null,
backgroundColor: pastedElement?.backgroundColor,
strokeWidth: pastedElement?.strokeWidth,
strokeColor: pastedElement?.strokeColor,

View File

@ -20,7 +20,10 @@ export const actionSendBackward = register({
name: "sendBackward",
perform: (elements, appState) => {
return {
elements: moveOneLeft([...elements], getSelectedIndices(elements)),
elements: moveOneLeft(
[...elements],
getSelectedIndices(elements, appState),
),
appState,
};
},
@ -44,7 +47,10 @@ export const actionBringForward = register({
name: "bringForward",
perform: (elements, appState) => {
return {
elements: moveOneRight([...elements], getSelectedIndices(elements)),
elements: moveOneRight(
[...elements],
getSelectedIndices(elements, appState),
),
appState,
};
},
@ -68,7 +74,10 @@ export const actionSendToBack = register({
name: "sendToBack",
perform: (elements, appState) => {
return {
elements: moveAllLeft([...elements], getSelectedIndices(elements)),
elements: moveAllLeft(
[...elements],
getSelectedIndices(elements, appState),
),
appState,
};
},
@ -91,7 +100,10 @@ export const actionBringToFront = register({
name: "bringToFront",
perform: (elements, appState) => {
return {
elements: moveAllRight([...elements], getSelectedIndices(elements)),
elements: moveAllRight(
[...elements],
getSelectedIndices(elements, appState),
),
appState,
};
},

View File

@ -32,6 +32,7 @@ export function getDefaultAppState(): AppState {
zoom: 1,
openMenu: null,
lastPointerDownWith: "mouse",
selectedElementIds: {},
};
}

View File

@ -1,5 +1,6 @@
import { ExcalidrawElement } from "./element/types";
import { getSelectedElements } from "./scene";
import { AppState } from "./types";
let CLIPBOARD = "";
let PREFER_APP_CLIPBOARD = false;
@ -18,10 +19,9 @@ export const probablySupportsClipboardBlob =
export async function copyToAppClipboard(
elements: readonly ExcalidrawElement[],
appState: AppState,
) {
CLIPBOARD = JSON.stringify(
getSelectedElements(elements).map(({ shape, canvas, ...el }) => el),
);
CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState));
try {
// when copying to in-app clipboard, clear system clipboard so that if
// system clip contains text on paste we know it was copied *after* user

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">

View File

@ -149,6 +149,7 @@ export async function importFromBackend(
export async function exportCanvas(
type: ExportType,
elements: readonly ExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement,
{
exportBackground,
@ -181,7 +182,7 @@ export async function exportCanvas(
return;
}
const tempCanvas = exportToCanvas(elements, {
const tempCanvas = exportToCanvas(elements, appState, {
exportBackground,
viewBackgroundColor,
exportPadding,

View File

@ -14,7 +14,7 @@ export function serializeAsJSON(
type: "excalidraw",
version: 1,
source: window.location.origin,
elements: elements.map(({ shape, canvas, isSelected, ...el }) => el),
elements,
appState: cleanAppStateForExport(appState),
},
null,

View File

@ -10,14 +10,7 @@ export function saveToLocalStorage(
elements: readonly ExcalidrawElement[],
appState: AppState,
) {
localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify(
elements.map(
({ shape, canvas, ...element }: ExcalidrawElement) => element,
),
),
);
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements));
localStorage.setItem(
LOCAL_STORAGE_KEY_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)),
@ -31,9 +24,7 @@ export function restoreFromLocalStorage() {
let elements = [];
if (savedElements) {
try {
elements = JSON.parse(savedElements).map(
({ shape, ...element }: ExcalidrawElement) => element,
);
elements = JSON.parse(savedElements);
} catch {
// Do nothing because elements array is already empty
}

View File

@ -57,10 +57,6 @@ export function restore(
? 100
: element.opacity,
points,
shape: null,
canvas: null,
canvasOffsetX: element.canvasOffsetX || 0,
canvasOffsetY: element.canvasOffsetY || 0,
};
});

View File

@ -2,6 +2,7 @@ import { ExcalidrawElement } from "./types";
import { rotate } from "../math";
import { Drawable } from "roughjs/bin/core";
import { Point } from "roughjs/bin/geometry";
import { getShapeForElement } from "../renderer/renderElement";
// 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.
@ -33,7 +34,7 @@ export function getDiamondPoints(element: ExcalidrawElement) {
}
export function getLinearElementAbsoluteBounds(element: ExcalidrawElement) {
if (element.points.length < 2 || !element.shape) {
if (element.points.length < 2 || !getShapeForElement(element)) {
const { minX, minY, maxX, maxY } = element.points.reduce(
(limits, [x, y]) => {
limits.minY = Math.min(limits.minY, y);
@ -54,7 +55,7 @@ export function getLinearElementAbsoluteBounds(element: ExcalidrawElement) {
];
}
const shape = element.shape as Drawable[];
const shape = getShapeForElement(element) as Drawable[];
// first element is always the curve
const ops = shape[0].sets[0].ops;
@ -118,8 +119,7 @@ export function getLinearElementAbsoluteBounds(element: ExcalidrawElement) {
];
}
export function getArrowPoints(element: ExcalidrawElement) {
const shape = element.shape as Drawable[];
export function getArrowPoints(element: ExcalidrawElement, shape: Drawable[]) {
const ops = shape[0].sets[0].ops;
const data = ops[ops.length - 1].data;

View File

@ -9,13 +9,22 @@ import {
} from "./bounds";
import { Point } from "roughjs/bin/geometry";
import { Drawable, OpSet } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
function isElementDraggableFromInside(element: ExcalidrawElement): boolean {
return element.backgroundColor !== "transparent" || element.isSelected;
function isElementDraggableFromInside(
element: ExcalidrawElement,
appState: AppState,
): boolean {
return (
element.backgroundColor !== "transparent" ||
appState.selectedElementIds[element.id]
);
}
export function hitTest(
element: ExcalidrawElement,
appState: AppState,
x: number,
y: number,
zoom: number,
@ -58,7 +67,7 @@ export function hitTest(
ty /= t;
});
if (isElementDraggableFromInside(element)) {
if (isElementDraggableFromInside(element, appState)) {
return (
a * tx - (px - lineThreshold) >= 0 && b * ty - (py - lineThreshold) >= 0
);
@ -67,7 +76,7 @@ export function hitTest(
} else if (element.type === "rectangle") {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
if (isElementDraggableFromInside(element)) {
if (isElementDraggableFromInside(element, appState)) {
return (
x > x1 - lineThreshold &&
x < x2 + lineThreshold &&
@ -99,7 +108,7 @@ export function hitTest(
leftY,
] = getDiamondPoints(element);
if (isElementDraggableFromInside(element)) {
if (isElementDraggableFromInside(element, appState)) {
// TODO: remove this when we normalize coordinates globally
if (topY > bottomY) {
[bottomY, topY] = [topY, bottomY];
@ -150,10 +159,10 @@ export function hitTest(
lineThreshold
);
} else if (element.type === "arrow" || element.type === "line") {
if (!element.shape) {
if (!getShapeForElement(element)) {
return false;
}
const shape = element.shape as Drawable[];
const shape = getShapeForElement(element) as Drawable[];
const [x1, y1, x2, y2] = getLinearElementAbsoluteBounds(element);
if (x < x1 || y < y1 - 10 || x > x2 || y > y2 + 10) {

View File

@ -54,8 +54,6 @@ it("clones arrow element", () => {
...element,
id: copy.id,
seed: copy.seed,
shape: undefined,
canvas: undefined,
});
});

View File

@ -1,6 +1,5 @@
import { randomSeed } from "roughjs/bin/math";
import nanoid from "nanoid";
import { Drawable } from "roughjs/bin/core";
import { Point } from "roughjs/bin/geometry";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
@ -32,14 +31,8 @@ export function newElement(
strokeWidth,
roughness,
opacity,
isSelected: false,
seed: randomSeed(),
shape: null as Drawable | Drawable[] | null,
points: [] as Point[],
canvas: null as HTMLCanvasElement | null,
canvasZoom: 1, // The zoom level used to render the cached canvas
canvasOffsetX: 0,
canvasOffsetY: 0,
};
return element;
}
@ -52,7 +45,6 @@ export function newTextElement(
const metrics = measureText(text, font);
const textElement: ExcalidrawTextElement = {
...element,
shape: null,
type: "text",
text: text,
font: font,

View File

@ -1,17 +1,19 @@
import { ExcalidrawElement, PointerType } from "./types";
import { handlerRectangles } from "./handlerRectangles";
import { AppState } from "../types";
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
export function resizeTest(
element: ExcalidrawElement,
appState: AppState,
x: number,
y: number,
zoom: number,
pointerType: PointerType,
): HandlerRectanglesRet | false {
if (!element.isSelected || element.type === "text") {
if (!appState.selectedElementIds[element.id] || element.type === "text") {
return false;
}
@ -40,6 +42,7 @@ export function resizeTest(
export function getElementWithResizeHandler(
elements: readonly ExcalidrawElement[],
appState: AppState,
{ x, y }: { x: number; y: number },
zoom: number,
pointerType: PointerType,
@ -48,7 +51,7 @@ export function getElementWithResizeHandler(
if (result) {
return result;
}
const resizeHandle = resizeTest(element, x, y, zoom, pointerType);
const resizeHandle = resizeTest(element, appState, x, y, zoom, pointerType);
return resizeHandle ? { element, resizeHandle } : null;
}, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
}

View File

@ -8,6 +8,6 @@ export const showSelectedShapeActions = (
) =>
Boolean(
appState.editingElement ||
getSelectedElements(elements).length ||
getSelectedElements(elements, appState).length ||
appState.elementType !== "selection",
);

View File

@ -1,4 +1,5 @@
import { ExcalidrawElement } from "./types";
import { invalidateShapeForElement } from "../renderer/renderElement";
export function isInvisiblySmallElement(element: ExcalidrawElement): boolean {
if (element.type === "arrow" || element.type === "line") {
@ -86,7 +87,7 @@ export function normalizeDimensions(
element.y -= element.height;
}
element.shape = null;
invalidateShapeForElement(element);
return true;
}

View File

@ -1,5 +1,10 @@
import { newElement } from "./newElement";
/**
* 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 = ReturnType<typeof newElement>;
export type ExcalidrawTextElement = ExcalidrawElement & {
type: "text";

View File

@ -18,10 +18,8 @@ export class SceneHistory {
) {
return JSON.stringify({
appState: clearAppStatePropertiesForHistory(appState),
elements: elements.map(({ shape, canvas, ...element }) => ({
elements: elements.map(element => ({
...element,
shape: null,
canvas: null,
points:
appState.multiElement && appState.multiElement.id === element.id
? element.points.slice(0, -1)

View File

@ -1,4 +1,5 @@
import { exportToCanvas } from "./scene/export";
import { getDefaultAppState } from "./appState";
const { registerFont, createCanvas } = require("canvas");
@ -16,7 +17,6 @@ const elements = [
strokeWidth: 1,
roughness: 1,
opacity: 100,
isSelected: false,
seed: 749612521,
},
{
@ -32,7 +32,6 @@ const elements = [
strokeWidth: 1,
roughness: 1,
opacity: 100,
isSelected: false,
seed: 952056308,
},
{
@ -48,7 +47,6 @@ const elements = [
strokeWidth: 1,
roughness: 1,
opacity: 100,
isSelected: false,
seed: 1683771448,
text: "test",
font: "20px Virgil",
@ -60,6 +58,7 @@ registerFont("./public/FG_Virgil.ttf", { family: "Virgil" });
registerFont("./public/Cascadia.ttf", { family: "Cascadia" });
const canvas = exportToCanvas(
elements as any,
getDefaultAppState(),
{
exportBackground: true,
viewBackgroundColor: "#ffffff",

View File

@ -16,12 +16,26 @@ import rough from "roughjs/bin/rough";
const CANVAS_PADDING = 20;
function generateElementCanvas(element: ExcalidrawElement, zoom: number) {
export interface ExcalidrawElementWithCanvas {
element: ExcalidrawElement | ExcalidrawTextElement;
canvas: HTMLCanvasElement;
canvasZoom: number;
canvasOffsetX: number;
canvasOffsetY: number;
}
function generateElementCanvas(
element: ExcalidrawElement,
zoom: number,
): ExcalidrawElementWithCanvas {
const canvas = document.createElement("canvas");
var context = canvas.getContext("2d")!;
const context = canvas.getContext("2d")!;
const isLinear = /\b(arrow|line)\b/.test(element.type);
let canvasOffsetX = 0;
let canvasOffsetY = 0;
if (isLinear) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
canvas.width =
@ -29,18 +43,15 @@ function generateElementCanvas(element: ExcalidrawElement, zoom: number) {
canvas.height =
distance(y1, y2) * window.devicePixelRatio * zoom + CANVAS_PADDING * 2;
element.canvasOffsetX =
canvasOffsetX =
element.x > x1
? Math.floor(distance(element.x, x1)) * window.devicePixelRatio
: 0;
element.canvasOffsetY =
canvasOffsetY =
element.y > y1
? Math.floor(distance(element.y, y1)) * window.devicePixelRatio
: 0;
context.translate(
element.canvasOffsetX * zoom,
element.canvasOffsetY * zoom,
);
context.translate(canvasOffsetX * zoom, canvasOffsetY * zoom);
} else {
canvas.width =
element.width * window.devicePixelRatio * zoom + CANVAS_PADDING * 2;
@ -53,9 +64,8 @@ function generateElementCanvas(element: ExcalidrawElement, zoom: number) {
const rc = rough.canvas(canvas);
drawElementOnCanvas(element, rc, context);
element.canvas = canvas;
element.canvasZoom = zoom;
context.translate(-CANVAS_PADDING, -CANVAS_PADDING);
return { element, canvas, canvasZoom: zoom, canvasOffsetX, canvasOffsetY };
}
function drawElementOnCanvas(
@ -68,12 +78,14 @@ function drawElementOnCanvas(
case "rectangle":
case "diamond":
case "ellipse": {
rc.draw(element.shape as Drawable);
rc.draw(getShapeForElement(element) as Drawable);
break;
}
case "arrow":
case "line": {
(element.shape as Drawable[]).forEach(shape => rc.draw(shape));
(getShapeForElement(element) as Drawable[]).forEach(shape =>
rc.draw(shape),
);
break;
}
default: {
@ -99,32 +111,44 @@ function drawElementOnCanvas(
context.globalAlpha = 1;
}
const elementWithCanvasCache = new WeakMap<
ExcalidrawElement,
ExcalidrawElementWithCanvas
>();
const shapeCache = new WeakMap<
ExcalidrawElement,
Drawable | Drawable[] | null
>();
export function getShapeForElement(element: ExcalidrawElement) {
return shapeCache.get(element);
}
export function invalidateShapeForElement(element: ExcalidrawElement) {
shapeCache.delete(element);
}
function generateElement(
element: ExcalidrawElement,
generator: RoughGenerator,
sceneState?: SceneState,
) {
if (!element.shape) {
element.canvas = null;
let shape = shapeCache.get(element) || null;
if (!shape) {
switch (element.type) {
case "rectangle":
element.shape = generator.rectangle(
0,
0,
element.width,
element.height,
{
stroke: element.strokeColor,
fill:
element.backgroundColor === "transparent"
? undefined
: element.backgroundColor,
fillStyle: element.fillStyle,
strokeWidth: element.strokeWidth,
roughness: element.roughness,
seed: element.seed,
},
);
shape = generator.rectangle(0, 0, element.width, element.height, {
stroke: element.strokeColor,
fill:
element.backgroundColor === "transparent"
? undefined
: element.backgroundColor,
fillStyle: element.fillStyle,
strokeWidth: element.strokeWidth,
roughness: element.roughness,
seed: element.seed,
});
break;
case "diamond": {
@ -138,7 +162,7 @@ function generateElement(
leftX,
leftY,
] = getDiamondPoints(element);
element.shape = generator.polygon(
shape = generator.polygon(
[
[topX, topY],
[rightX, rightY],
@ -160,7 +184,7 @@ function generateElement(
break;
}
case "ellipse":
element.shape = generator.ellipse(
shape = generator.ellipse(
element.width / 2,
element.height / 2,
element.width,
@ -195,12 +219,12 @@ function generateElement(
// curve is always the first element
// this simplifies finding the curve for an element
element.shape = [generator.curve(points, options)];
shape = [generator.curve(points, options)];
// add lines only in arrow
if (element.type === "arrow") {
const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
element.shape.push(
const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element, shape);
shape.push(
...[
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
@ -211,19 +235,22 @@ function generateElement(
}
case "text": {
// just to ensure we don't regenerate element.canvas on rerenders
element.shape = [];
shape = [];
break;
}
}
shapeCache.set(element, shape);
}
const zoom = sceneState ? sceneState.zoom : 1;
if (!element.canvas || element.canvasZoom !== zoom) {
generateElementCanvas(element, zoom);
const prevElementWithCanvas = elementWithCanvasCache.get(element);
if (!prevElementWithCanvas || prevElementWithCanvas.canvasZoom !== zoom) {
return generateElementCanvas(element, zoom);
}
return prevElementWithCanvas;
}
function drawElementFromCanvas(
element: ExcalidrawElement | ExcalidrawTextElement,
elementWithCanvas: ExcalidrawElementWithCanvas,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
sceneState: SceneState,
@ -234,17 +261,19 @@ function drawElementFromCanvas(
-CANVAS_PADDING / sceneState.zoom,
);
context.drawImage(
element.canvas!,
elementWithCanvas.canvas!,
Math.floor(
-element.canvasOffsetX +
(Math.floor(element.x) + sceneState.scrollX) * window.devicePixelRatio,
-elementWithCanvas.canvasOffsetX +
(Math.floor(elementWithCanvas.element.x) + sceneState.scrollX) *
window.devicePixelRatio,
),
Math.floor(
-element.canvasOffsetY +
(Math.floor(element.y) + sceneState.scrollY) * window.devicePixelRatio,
-elementWithCanvas.canvasOffsetY +
(Math.floor(elementWithCanvas.element.y) + sceneState.scrollY) *
window.devicePixelRatio,
),
element.canvas!.width / sceneState.zoom,
element.canvas!.height / sceneState.zoom,
elementWithCanvas.canvas!.width / sceneState.zoom,
elementWithCanvas.canvas!.height / sceneState.zoom,
);
context.translate(
CANVAS_PADDING / sceneState.zoom,
@ -279,10 +308,10 @@ export function renderElement(
case "line":
case "arrow":
case "text": {
generateElement(element, generator, sceneState);
const elementWithCanvas = generateElement(element, generator, sceneState);
if (renderOptimizations) {
drawElementFromCanvas(element, rc, context, sceneState);
drawElementFromCanvas(elementWithCanvas, rc, context, sceneState);
} else {
const offsetX = Math.floor(element.x + sceneState.scrollX);
const offsetY = Math.floor(element.y + sceneState.scrollY);
@ -316,7 +345,7 @@ export function renderElementToSvg(
case "diamond":
case "ellipse": {
generateElement(element, generator);
const node = rsvg.draw(element.shape as Drawable);
const node = rsvg.draw(getShapeForElement(element) as Drawable);
const opacity = element.opacity / 100;
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
@ -334,7 +363,7 @@ export function renderElementToSvg(
generateElement(element, generator);
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
const opacity = element.opacity / 100;
(element.shape as Drawable[]).forEach(shape => {
(getShapeForElement(element) as Drawable[]).forEach(shape => {
const node = rsvg.draw(shape);
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);

View File

@ -1,7 +1,7 @@
import { RoughCanvas } from "roughjs/bin/canvas";
import { RoughSVG } from "roughjs/bin/svg";
import { FlooredNumber } from "../types";
import { FlooredNumber, AppState } from "../types";
import { ExcalidrawElement } from "../element/types";
import { getElementAbsoluteCoords, handlerRectangles } from "../element";
@ -19,6 +19,7 @@ import { renderElement, renderElementToSvg } from "./renderElement";
export function renderScene(
elements: readonly ExcalidrawElement[],
appState: AppState,
selectionElement: ExcalidrawElement | null,
rc: RoughCanvas,
canvas: HTMLCanvasElement,
@ -129,7 +130,7 @@ export function renderScene(
// Pain selected elements
if (renderSelection) {
const selectedElements = getSelectedElements(elements);
const selectedElements = getSelectedElements(elements, appState);
const dashledLinePadding = 4 / sceneState.zoom;
applyZoom(context);

View File

@ -1,6 +1,7 @@
import { ExcalidrawElement } from "../element/types";
import { getElementAbsoluteCoords, hitTest } from "../element";
import { AppState } from "../types";
export const hasBackground = (type: string) =>
type === "rectangle" || type === "ellipse" || type === "diamond";
@ -16,6 +17,7 @@ export const hasText = (type: string) => type === "text";
export function getElementAtPosition(
elements: readonly ExcalidrawElement[],
appState: AppState,
x: number,
y: number,
zoom: number,
@ -23,7 +25,7 @@ export function getElementAtPosition(
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
for (let i = elements.length - 1; i >= 0; --i) {
if (hitTest(elements[i], x, y, zoom)) {
if (hitTest(elements[i], appState, x, y, zoom)) {
hitElement = elements[i];
break;
}

View File

@ -4,9 +4,11 @@ import { getCommonBounds } from "../element/bounds";
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
import { distance, SVG_NS } from "../utils";
import { normalizeScroll } from "./scroll";
import { AppState } from "../types";
export function exportToCanvas(
elements: readonly ExcalidrawElement[],
appState: AppState,
{
exportBackground,
exportPadding = 10,
@ -38,6 +40,7 @@ export function exportToCanvas(
renderScene(
elements,
appState,
null,
rough.canvas(tempCanvas),
tempCanvas,

View File

@ -1,6 +1,5 @@
export { isOverScrollBars } from "./scrollbars";
export {
clearSelection,
getSelectedIndices,
deleteSelectedElements,
isSomeElementSelected,

View File

@ -1,5 +1,6 @@
import { ExcalidrawElement } from "../element/types";
import { getElementAbsoluteCoords } from "../element";
import { AppState } from "../types";
export function getElementsWithinSelection(
elements: readonly ExcalidrawElement[],
@ -29,26 +30,20 @@ export function getElementsWithinSelection(
});
}
export function clearSelection(elements: readonly ExcalidrawElement[]) {
let someWasSelected = false;
elements.forEach(element => {
if (element.isSelected) {
someWasSelected = true;
element.isSelected = false;
}
});
return someWasSelected ? elements.slice() : elements;
export function deleteSelectedElements(
elements: readonly ExcalidrawElement[],
appState: AppState,
) {
return elements.filter(el => !appState.selectedElementIds[el.id]);
}
export function deleteSelectedElements(elements: readonly ExcalidrawElement[]) {
return elements.filter(el => !el.isSelected);
}
export function getSelectedIndices(elements: readonly ExcalidrawElement[]) {
export function getSelectedIndices(
elements: readonly ExcalidrawElement[],
appState: AppState,
) {
const selectedIndices: number[] = [];
elements.forEach((element, index) => {
if (element.isSelected) {
if (appState.selectedElementIds[element.id]) {
selectedIndices.push(index);
}
});
@ -57,8 +52,9 @@ export function getSelectedIndices(elements: readonly ExcalidrawElement[]) {
export function isSomeElementSelected(
elements: readonly ExcalidrawElement[],
appState: AppState,
): boolean {
return elements.some(element => element.isSelected);
return elements.some(element => appState.selectedElementIds[element.id]);
}
/**
@ -67,11 +63,14 @@ export function isSomeElementSelected(
*/
export function getCommonAttributeOfSelectedElements<T>(
elements: readonly ExcalidrawElement[],
appState: AppState,
getAttribute: (element: ExcalidrawElement) => T,
): T | null {
const attributes = Array.from(
new Set(
getSelectedElements(elements).map(element => getAttribute(element)),
getSelectedElements(elements, appState).map(element =>
getAttribute(element),
),
),
);
return attributes.length === 1 ? attributes[0] : null;
@ -79,13 +78,16 @@ export function getCommonAttributeOfSelectedElements<T>(
export function getSelectedElements(
elements: readonly ExcalidrawElement[],
appState: AppState,
): readonly ExcalidrawElement[] {
return elements.filter(element => element.isSelected);
return elements.filter(element => appState.selectedElementIds[element.id]);
}
export function getTargetElement(
editingElement: ExcalidrawElement | null,
elements: readonly ExcalidrawElement[],
appState: AppState,
) {
return editingElement ? [editingElement] : getSelectedElements(elements);
return appState.editingElement
? [appState.editingElement]
: getSelectedElements(elements, appState);
}

View File

@ -31,7 +31,7 @@ describe("move element", () => {
expect(renderScene).toHaveBeenCalledTimes(4);
expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.elements[0].isSelected).toBeTruthy();
expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
renderScene.mockClear();
@ -64,7 +64,7 @@ describe("duplicate element on move when ALT is clicked", () => {
expect(renderScene).toHaveBeenCalledTimes(4);
expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.elements[0].isSelected).toBeTruthy();
expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
renderScene.mockClear();
@ -77,6 +77,7 @@ describe("duplicate element on move when ALT is clicked", () => {
expect(renderScene).toHaveBeenCalledTimes(3);
expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(2);
// previous element should stay intact
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
expect([h.elements[1].x, h.elements[1].y]).toEqual([0, 40]);

View File

@ -31,7 +31,7 @@ describe("resize element", () => {
expect(renderScene).toHaveBeenCalledTimes(4);
expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.elements[0].isSelected).toBeTruthy();
expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
expect([h.elements[0].width, h.elements[0].height]).toEqual([30, 50]);
@ -72,7 +72,7 @@ describe("resize element with aspect ratio when SHIFT is clicked", () => {
expect(renderScene).toHaveBeenCalledTimes(4);
expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.elements[0].isSelected).toBeTruthy();
expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
expect([h.elements[0].width, h.elements[0].height]).toEqual([30, 50]);

View File

@ -97,7 +97,7 @@ describe("select single element on the scene", () => {
expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.elements[0].isSelected).toBeTruthy();
expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
});
it("diamond", () => {
@ -122,7 +122,7 @@ describe("select single element on the scene", () => {
expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.elements[0].isSelected).toBeTruthy();
expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
});
it("ellipse", () => {
@ -147,7 +147,7 @@ describe("select single element on the scene", () => {
expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.elements[0].isSelected).toBeTruthy();
expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
});
it("arrow", () => {
@ -172,7 +172,7 @@ describe("select single element on the scene", () => {
expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.elements[0].isSelected).toBeTruthy();
expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
});
it("arrow", () => {
@ -197,6 +197,6 @@ describe("select single element on the scene", () => {
expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.elements[0].isSelected).toBeTruthy();
expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
});
});

View File

@ -33,6 +33,7 @@ export type AppState = {
zoom: number;
openMenu: "canvas" | "shape" | null;
lastPointerDownWith: PointerType;
selectedElementIds: { [id: string]: boolean };
};
export type Pointer = Readonly<{