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:
parent
8ecb4201db
commit
ccbbdb75a6
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
@ -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: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -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],
|
||||||
)}
|
)}
|
||||||
|
@ -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",
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -32,6 +32,7 @@ export function getDefaultAppState(): AppState {
|
|||||||
zoom: 1,
|
zoom: 1,
|
||||||
openMenu: null,
|
openMenu: null,
|
||||||
lastPointerDownWith: "mouse",
|
lastPointerDownWith: "mouse",
|
||||||
|
selectedElementIds: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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({});
|
||||||
|
@ -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!,
|
||||||
|
@ -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,
|
||||||
|
@ -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) {
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -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">
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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) {
|
||||||
|
@ -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,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
);
|
);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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";
|
||||||
|
@ -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)
|
||||||
|
@ -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",
|
||||||
|
@ -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}`);
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
export { isOverScrollBars } from "./scrollbars";
|
export { isOverScrollBars } from "./scrollbars";
|
||||||
export {
|
export {
|
||||||
clearSelection,
|
|
||||||
getSelectedIndices,
|
getSelectedIndices,
|
||||||
deleteSelectedElements,
|
deleteSelectedElements,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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]);
|
||||||
|
@ -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]);
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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<{
|
||||||
|
Loading…
x
Reference in New Issue
Block a user