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": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/excalidraw/excalidraw.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", name: "deleteSelectedElements",
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: deleteSelectedElements(elements), elements: deleteSelectedElements(elements, appState),
appState: { ...appState, elementType: "selection", multiElement: null }, appState: { ...appState, elementType: "selection", multiElement: null },
}; };
}, },
contextItemLabel: "labels.delete", contextItemLabel: "labels.delete",
contextMenuOrder: 3, contextMenuOrder: 3,
commitToHistory: (_, elements) => isSomeElementSelected(elements), commitToHistory: (appState, elements) =>
isSomeElementSelected(elements, appState),
keyTest: event => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE, keyTest: event => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
PanelComponent: ({ elements, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={trash} icon={trash}
title={t("labels.delete")} title={t("labels.delete")}
aria-label={t("labels.delete")} aria-label={t("labels.delete")}
onClick={() => updateData(null)} onClick={() => updateData(null)}
visible={isSomeElementSelected(elements)} visible={isSomeElementSelected(elements, appState)}
/> />
), ),
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -57,10 +57,6 @@ export function restore(
? 100 ? 100
: element.opacity, : element.opacity,
points, 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 { rotate } from "../math";
import { Drawable } from "roughjs/bin/core"; import { Drawable } from "roughjs/bin/core";
import { Point } from "roughjs/bin/geometry"; 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 // 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.
@ -33,7 +34,7 @@ export function getDiamondPoints(element: ExcalidrawElement) {
} }
export function getLinearElementAbsoluteBounds(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( const { minX, minY, maxX, maxY } = element.points.reduce(
(limits, [x, y]) => { (limits, [x, y]) => {
limits.minY = Math.min(limits.minY, 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 // first element is always the curve
const ops = shape[0].sets[0].ops; const ops = shape[0].sets[0].ops;
@ -118,8 +119,7 @@ export function getLinearElementAbsoluteBounds(element: ExcalidrawElement) {
]; ];
} }
export function getArrowPoints(element: ExcalidrawElement) { export function getArrowPoints(element: ExcalidrawElement, shape: Drawable[]) {
const shape = element.shape as 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

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

View File

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

View File

@ -1,6 +1,5 @@
import { randomSeed } from "roughjs/bin/math"; import { randomSeed } from "roughjs/bin/math";
import nanoid from "nanoid"; import nanoid from "nanoid";
import { Drawable } from "roughjs/bin/core";
import { Point } from "roughjs/bin/geometry"; import { Point } from "roughjs/bin/geometry";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
@ -32,14 +31,8 @@ export function newElement(
strokeWidth, strokeWidth,
roughness, roughness,
opacity, opacity,
isSelected: false,
seed: randomSeed(), seed: randomSeed(),
shape: null as Drawable | Drawable[] | null,
points: [] as Point[], 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; return element;
} }
@ -52,7 +45,6 @@ export function newTextElement(
const metrics = measureText(text, font); const metrics = measureText(text, font);
const textElement: ExcalidrawTextElement = { const textElement: ExcalidrawTextElement = {
...element, ...element,
shape: null,
type: "text", type: "text",
text: text, text: text,
font: font, font: font,

View File

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

View File

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

View File

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

View File

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

View File

@ -18,10 +18,8 @@ export class SceneHistory {
) { ) {
return JSON.stringify({ return JSON.stringify({
appState: clearAppStatePropertiesForHistory(appState), appState: clearAppStatePropertiesForHistory(appState),
elements: elements.map(({ shape, canvas, ...element }) => ({ elements: elements.map(element => ({
...element, ...element,
shape: null,
canvas: null,
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { getElementAbsoluteCoords } from "../element"; import { getElementAbsoluteCoords } from "../element";
import { AppState } from "../types";
export function getElementsWithinSelection( export function getElementsWithinSelection(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -29,26 +30,20 @@ export function getElementsWithinSelection(
}); });
} }
export function clearSelection(elements: readonly ExcalidrawElement[]) { export function deleteSelectedElements(
let someWasSelected = false; elements: readonly ExcalidrawElement[],
elements.forEach(element => { appState: AppState,
if (element.isSelected) { ) {
someWasSelected = true; return elements.filter(el => !appState.selectedElementIds[el.id]);
element.isSelected = false;
}
});
return someWasSelected ? elements.slice() : elements;
} }
export function deleteSelectedElements(elements: readonly ExcalidrawElement[]) { export function getSelectedIndices(
return elements.filter(el => !el.isSelected); elements: readonly ExcalidrawElement[],
} appState: AppState,
) {
export function getSelectedIndices(elements: readonly ExcalidrawElement[]) {
const selectedIndices: number[] = []; const selectedIndices: number[] = [];
elements.forEach((element, index) => { elements.forEach((element, index) => {
if (element.isSelected) { if (appState.selectedElementIds[element.id]) {
selectedIndices.push(index); selectedIndices.push(index);
} }
}); });
@ -57,8 +52,9 @@ export function getSelectedIndices(elements: readonly ExcalidrawElement[]) {
export function isSomeElementSelected( export function isSomeElementSelected(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState,
): boolean { ): 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>( export function getCommonAttributeOfSelectedElements<T>(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState,
getAttribute: (element: ExcalidrawElement) => T, getAttribute: (element: ExcalidrawElement) => T,
): T | null { ): T | null {
const attributes = Array.from( const attributes = Array.from(
new Set( new Set(
getSelectedElements(elements).map(element => getAttribute(element)), getSelectedElements(elements, appState).map(element =>
getAttribute(element),
),
), ),
); );
return attributes.length === 1 ? attributes[0] : null; return attributes.length === 1 ? attributes[0] : null;
@ -79,13 +78,16 @@ export function getCommonAttributeOfSelectedElements<T>(
export function getSelectedElements( export function getSelectedElements(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState,
): readonly ExcalidrawElement[] { ): readonly ExcalidrawElement[] {
return elements.filter(element => element.isSelected); return elements.filter(element => appState.selectedElementIds[element.id]);
} }
export function getTargetElement( export function getTargetElement(
editingElement: ExcalidrawElement | null,
elements: readonly ExcalidrawElement[], 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(renderScene).toHaveBeenCalledTimes(4);
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].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]);
renderScene.mockClear(); renderScene.mockClear();
@ -64,7 +64,7 @@ describe("duplicate element on move when ALT is clicked", () => {
expect(renderScene).toHaveBeenCalledTimes(4); expect(renderScene).toHaveBeenCalledTimes(4);
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].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]);
renderScene.mockClear(); renderScene.mockClear();
@ -77,6 +77,7 @@ describe("duplicate element on move when ALT is clicked", () => {
expect(renderScene).toHaveBeenCalledTimes(3); expect(renderScene).toHaveBeenCalledTimes(3);
expect(h.appState.selectionElement).toBeNull(); expect(h.appState.selectionElement).toBeNull();
expect(h.elements.length).toEqual(2); expect(h.elements.length).toEqual(2);
// previous element should stay intact // previous element should stay intact
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[1].x, h.elements[1].y]).toEqual([0, 40]); 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(renderScene).toHaveBeenCalledTimes(4);
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].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]); 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(renderScene).toHaveBeenCalledTimes(4);
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].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].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]); 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(renderScene).toHaveBeenCalledTimes(7);
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].isSelected).toBeTruthy(); expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
}); });
it("diamond", () => { it("diamond", () => {
@ -122,7 +122,7 @@ describe("select single element on the scene", () => {
expect(renderScene).toHaveBeenCalledTimes(7); expect(renderScene).toHaveBeenCalledTimes(7);
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].isSelected).toBeTruthy(); expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
}); });
it("ellipse", () => { it("ellipse", () => {
@ -147,7 +147,7 @@ describe("select single element on the scene", () => {
expect(renderScene).toHaveBeenCalledTimes(7); expect(renderScene).toHaveBeenCalledTimes(7);
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].isSelected).toBeTruthy(); expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
}); });
it("arrow", () => { it("arrow", () => {
@ -172,7 +172,7 @@ describe("select single element on the scene", () => {
expect(renderScene).toHaveBeenCalledTimes(7); expect(renderScene).toHaveBeenCalledTimes(7);
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].isSelected).toBeTruthy(); expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
}); });
it("arrow", () => { it("arrow", () => {
@ -197,6 +197,6 @@ describe("select single element on the scene", () => {
expect(renderScene).toHaveBeenCalledTimes(7); expect(renderScene).toHaveBeenCalledTimes(7);
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].isSelected).toBeTruthy(); expect(h.appState.selectedElementIds[h.elements[0].id]).toBeTruthy();
}); });
}); });

View File

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