Add NonDeleted<ExcalidrawElement> (#1068)

* add NonDeleted

* make test:all script run tests without prompt

* rename helper

* replace with helper

* make element contructors return nonDeleted elements

* cache filtered elements where appliacable for better perf

* rename manager element getter

* remove unnecessary assertion

* fix test

* make element types in resizeElement into nonDeleted

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Pete Hunt 2020-04-08 09:49:52 -07:00 committed by GitHub
parent c714c778ab
commit df0613d8ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 260 additions and 189 deletions

View File

@ -110,6 +110,7 @@
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start",
"test": "npm run test:app",
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
"test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
"test:app": "react-scripts test --env=jsdom --passWithNoTests",
"test:code": "eslint --max-warnings=0 --ignore-path .gitignore --ext .js,.ts,.tsx .",

View File

@ -5,6 +5,7 @@ import React from "react";
import { trash } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
export const actionDeleteSelected = register({
name: "deleteSelectedElements",
@ -20,7 +21,10 @@ export const actionDeleteSelected = register({
elementType: "selection",
multiElement: null,
},
commitToHistory: isSomeElementSelected(elements, appState),
commitToHistory: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
};
},
contextItemLabel: "labels.delete",
@ -33,7 +37,7 @@ export const actionDeleteSelected = register({
title={t("labels.delete")}
aria-label={t("labels.delete")}
onClick={() => updateData(null)}
visible={isSomeElementSelected(elements, appState)}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});

View File

@ -2,7 +2,7 @@ import React from "react";
import { KEYS } from "../keys";
import { register } from "./register";
import { ExcalidrawElement } from "../element/types";
import { duplicateElement } from "../element";
import { duplicateElement, getNonDeletedElements } from "../element";
import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton";
import { clone } from "../components/icons";
@ -43,7 +43,7 @@ export const actionDuplicateSelection = register({
)}`}
aria-label={t("labels.duplicateSelection")}
onClick={() => updateData(null)}
visible={isSomeElementSelected(elements, appState)}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});

View File

@ -2,7 +2,7 @@ import React from "react";
import { menu, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { showSelectedShapeActions } from "../element";
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { KEYS } from "../keys";
@ -39,7 +39,10 @@ export const actionToggleEditMenu = register({
}),
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
visible={showSelectedShapeActions(appState, elements)}
visible={showSelectedShapeActions(
appState,
getNonDeletedElements(elements),
)}
type="button"
icon={palette}
aria-label={t("buttons.edit")}

View File

@ -5,7 +5,11 @@ import {
isSomeElementSelected,
} from "../scene";
import { ButtonSelect } from "../components/ButtonSelect";
import { isTextElement, redrawTextBoundingBox } from "../element";
import {
isTextElement,
redrawTextBoundingBox,
getNonDeletedElements,
} from "../element";
import { ColorPicker } from "../components/ColorPicker";
import { AppState } from "../../src/types";
import { t } from "../i18n";
@ -33,10 +37,15 @@ const getFormValue = function <T>(
defaultValue?: T,
): T | null {
const editingElement = appState.editingElement;
const nonDeletedElements = getNonDeletedElements(elements);
return (
(editingElement && getAttribute(editingElement)) ??
(isSomeElementSelected(elements, appState)
? getCommonAttributeOfSelectedElements(elements, appState, getAttribute)
(isSomeElementSelected(nonDeletedElements, appState)
? getCommonAttributeOfSelectedElements(
nonDeletedElements,
appState,
getAttribute,
)
: defaultValue) ??
null
);

View File

@ -9,6 +9,7 @@ import {
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { t } from "../i18n";
import { globalSceneState } from "../scene";
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
@ -17,16 +18,18 @@ export class ActionManager implements ActionsManagerInterface {
getAppState: () => AppState;
getElements: () => readonly ExcalidrawElement[];
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElements: () => readonly ExcalidrawElement[],
getElementsIncludingDeleted: () => ReturnType<
typeof globalSceneState["getElementsIncludingDeleted"]
>,
) {
this.updater = updater;
this.getAppState = getAppState;
this.getElements = getElements;
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
}
registerAction(action: Action) {
@ -43,7 +46,11 @@ export class ActionManager implements ActionsManagerInterface {
.filter(
(action) =>
action.keyTest &&
action.keyTest(event, this.getAppState(), this.getElements()),
action.keyTest(
event,
this.getAppState(),
this.getElementsIncludingDeleted(),
),
);
if (data.length === 0) {
@ -51,12 +58,24 @@ export class ActionManager implements ActionsManagerInterface {
}
event.preventDefault();
this.updater(data[0].perform(this.getElements(), this.getAppState(), null));
this.updater(
data[0].perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
),
);
return true;
}
executeAction(action: Action) {
this.updater(action.perform(this.getElements(), this.getAppState(), null));
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
),
);
}
getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) {
@ -72,7 +91,11 @@ export class ActionManager implements ActionsManagerInterface {
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
action: () => {
this.updater(
action.perform(this.getElements(), this.getAppState(), null),
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
),
);
},
}));
@ -84,13 +107,17 @@ export class ActionManager implements ActionsManagerInterface {
const PanelComponent = action.PanelComponent!;
const updateData = (formState?: any) => {
this.updater(
action.perform(this.getElements(), this.getAppState(), formState),
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
formState,
),
);
};
return (
<PanelComponent
elements={this.getElements()}
elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()}
updateData={updateData}
/>

View File

@ -1,4 +1,7 @@
import { ExcalidrawElement } from "./element/types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "./element/types";
import { getSelectedElements } from "./scene";
import { AppState } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
@ -19,7 +22,7 @@ export const probablySupportsClipboardBlob =
"toBlob" in HTMLCanvasElement.prototype;
export async function copyToAppClipboard(
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) {
CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState));

View File

@ -9,6 +9,7 @@ import { ToolButton } from "./ToolButton";
import { capitalizeString, setCursorForShape } from "../utils";
import Stack from "./Stack";
import useIsMobile from "../is-mobile";
import { getNonDeletedElements } from "../element";
export function SelectedShapeActions({
appState,
@ -21,7 +22,10 @@ export function SelectedShapeActions({
renderAction: ActionManager["renderAction"];
elementType: ExcalidrawElement["type"];
}) {
const targetElements = getTargetElement(elements, appState);
const targetElements = getTargetElement(
getNonDeletedElements(elements),
appState,
);
const isEditing = Boolean(appState.editingElement);
const isMobile = useIsMobile();
@ -82,13 +86,9 @@ export function SelectedShapeActions({
export function ShapesSwitcher({
elementType,
setAppState,
setElements,
elements,
}: {
elementType: ExcalidrawElement["type"];
setAppState: any;
setElements: any;
elements: readonly ExcalidrawElement[];
}) {
return (
<>

View File

@ -20,7 +20,6 @@ import {
getElementMap,
getDrawingVersion,
getSyncableElements,
hasNonDeletedElements,
newLinearElement,
ResizeArrowFnType,
resizeElements,
@ -185,7 +184,7 @@ export class App extends React.Component<any, AppState> {
this.actionManager = new ActionManager(
this.syncActionResult,
() => this.state,
() => globalSceneState.getAllElements(),
() => globalSceneState.getElementsIncludingDeleted(),
);
this.actionManager.registerAll(actions);
@ -209,10 +208,7 @@ export class App extends React.Component<any, AppState> {
appState={this.state}
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={globalSceneState.getAllElements().filter((element) => {
return !element.isDeleted;
})}
setElements={this.setElements}
elements={globalSceneState.getElements()}
onRoomCreate={this.openPortal}
onRoomDestroy={this.closePortal}
onLockToggle={this.toggleLock}
@ -310,7 +306,7 @@ export class App extends React.Component<any, AppState> {
try {
await Promise.race([
document.fonts?.ready?.then(() => {
globalSceneState.getAllElements().forEach((element) => {
globalSceneState.getElementsIncludingDeleted().forEach((element) => {
if (isTextElement(element)) {
invalidateShapeForElement(element);
}
@ -431,7 +427,7 @@ export class App extends React.Component<any, AppState> {
}
private onResize = withBatchedUpdates(() => {
globalSceneState
.getAllElements()
.getElementsIncludingDeleted()
.forEach((element) => invalidateShapeForElement(element));
this.setState({});
});
@ -439,7 +435,7 @@ export class App extends React.Component<any, AppState> {
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
if (
this.state.isCollaborating &&
hasNonDeletedElements(globalSceneState.getAllElements())
globalSceneState.getElements().length > 0
) {
event.preventDefault();
// NOTE: modern browsers no longer allow showing a custom message here
@ -484,8 +480,9 @@ export class App extends React.Component<any, AppState> {
);
cursorButton[socketID] = user.button;
});
const elements = globalSceneState.getElements();
const { atLeastOneVisibleElement, scrollBars } = renderScene(
globalSceneState.getAllElements().filter((element) => {
elements.filter((element) => {
// don't render text element that's being currently edited (it's
// rendered on remote only)
return (
@ -517,22 +514,20 @@ export class App extends React.Component<any, AppState> {
if (scrollBars) {
currentScrollBars = scrollBars;
}
const scrolledOutside =
!atLeastOneVisibleElement &&
hasNonDeletedElements(globalSceneState.getAllElements());
const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
if (this.state.scrolledOutside !== scrolledOutside) {
this.setState({ scrolledOutside: scrolledOutside });
}
this.saveDebounced();
if (
getDrawingVersion(globalSceneState.getAllElements()) >
getDrawingVersion(globalSceneState.getElementsIncludingDeleted()) >
this.lastBroadcastedOrReceivedSceneVersion
) {
this.broadcastScene("SCENE_UPDATE");
}
history.record(this.state, globalSceneState.getAllElements());
history.record(this.state, globalSceneState.getElementsIncludingDeleted());
}
// Copy/paste
@ -543,7 +538,7 @@ export class App extends React.Component<any, AppState> {
}
this.copyAll();
const { elements: nextElements, appState } = deleteSelectedElements(
globalSceneState.getAllElements(),
globalSceneState.getElementsIncludingDeleted(),
this.state,
);
globalSceneState.replaceAllElements(nextElements);
@ -561,19 +556,16 @@ export class App extends React.Component<any, AppState> {
});
private copyAll = () => {
copyToAppClipboard(globalSceneState.getAllElements(), this.state);
copyToAppClipboard(globalSceneState.getElements(), this.state);
};
private copyToClipboardAsPng = () => {
const selectedElements = getSelectedElements(
globalSceneState.getAllElements(),
this.state,
);
const elements = globalSceneState.getElements();
const selectedElements = getSelectedElements(elements, this.state);
exportCanvas(
"clipboard",
selectedElements.length
? selectedElements
: globalSceneState.getAllElements(),
selectedElements.length ? selectedElements : elements,
this.state,
this.canvas!,
this.state,
@ -582,14 +574,14 @@ export class App extends React.Component<any, AppState> {
private copyToClipboardAsSvg = () => {
const selectedElements = getSelectedElements(
globalSceneState.getAllElements(),
globalSceneState.getElements(),
this.state,
);
exportCanvas(
"clipboard-svg",
selectedElements.length
? selectedElements
: globalSceneState.getAllElements(),
: globalSceneState.getElements(),
this.state,
this.canvas!,
this.state,
@ -669,7 +661,7 @@ export class App extends React.Component<any, AppState> {
);
globalSceneState.replaceAllElements([
...globalSceneState.getAllElements(),
...globalSceneState.getElementsIncludingDeleted(),
...newElements,
]);
history.resumeRecording();
@ -703,7 +695,7 @@ export class App extends React.Component<any, AppState> {
});
globalSceneState.replaceAllElements([
...globalSceneState.getAllElements(),
...globalSceneState.getElementsIncludingDeleted(),
element,
]);
this.setState({ selectedElementIds: { [element.id]: true } });
@ -789,15 +781,15 @@ export class App extends React.Component<any, AppState> {
// elements with more staler versions than ours, ignore them
// and keep ours.
if (
globalSceneState.getAllElements() == null ||
globalSceneState.getAllElements().length === 0
globalSceneState.getElementsIncludingDeleted() == null ||
globalSceneState.getElementsIncludingDeleted().length === 0
) {
globalSceneState.replaceAllElements(remoteElements);
} else {
// create a map of ids so we don't have to iterate
// over the array more than once.
const localElementMap = getElementMap(
globalSceneState.getAllElements(),
globalSceneState.getElementsIncludingDeleted(),
);
// Reconcile
@ -982,12 +974,14 @@ export class App extends React.Component<any, AppState> {
const data: SocketUpdateDataSource[typeof sceneType] = {
type: sceneType,
payload: {
elements: getSyncableElements(globalSceneState.getAllElements()),
elements: getSyncableElements(
globalSceneState.getElementsIncludingDeleted(),
),
},
};
this.lastBroadcastedOrReceivedSceneVersion = Math.max(
this.lastBroadcastedOrReceivedSceneVersion,
getDrawingVersion(globalSceneState.getAllElements()),
getDrawingVersion(globalSceneState.getElementsIncludingDeleted()),
);
return this._broadcastSocketData(
data as typeof data & { _brand: "socketUpdateData" },
@ -1063,7 +1057,7 @@ export class App extends React.Component<any, AppState> {
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
: ELEMENT_TRANSLATE_AMOUNT;
globalSceneState.replaceAllElements(
globalSceneState.getAllElements().map((el) => {
globalSceneState.getElementsIncludingDeleted().map((el) => {
if (this.state.selectedElementIds[el.id]) {
const update: { x?: number; y?: number } = {};
if (event.key === KEYS.ARROW_LEFT) {
@ -1083,7 +1077,7 @@ export class App extends React.Component<any, AppState> {
event.preventDefault();
} else if (event.key === KEYS.ENTER) {
const selectedElements = getSelectedElements(
globalSceneState.getAllElements(),
globalSceneState.getElements(),
this.state,
);
@ -1188,7 +1182,7 @@ export class App extends React.Component<any, AppState> {
const deleteElement = () => {
globalSceneState.replaceAllElements([
...globalSceneState.getAllElements().map((_element) => {
...globalSceneState.getElementsIncludingDeleted().map((_element) => {
if (_element.id === element.id) {
return newElementWith(_element, { isDeleted: true });
}
@ -1199,7 +1193,7 @@ export class App extends React.Component<any, AppState> {
const updateElement = (text: string) => {
globalSceneState.replaceAllElements([
...globalSceneState.getAllElements().map((_element) => {
...globalSceneState.getElementsIncludingDeleted().map((_element) => {
if (_element.id === element.id) {
return newTextElement({
...(_element as ExcalidrawTextElement),
@ -1271,7 +1265,7 @@ export class App extends React.Component<any, AppState> {
centerIfPossible?: boolean;
}) => {
const elementAtPosition = getElementAtPosition(
globalSceneState.getAllElements(),
globalSceneState.getElements(),
this.state,
x,
y,
@ -1326,7 +1320,7 @@ export class App extends React.Component<any, AppState> {
});
} else {
globalSceneState.replaceAllElements([
...globalSceneState.getAllElements(),
...globalSceneState.getElementsIncludingDeleted(),
element,
]);
@ -1503,13 +1497,12 @@ export class App extends React.Component<any, AppState> {
return;
}
const selectedElements = getSelectedElements(
globalSceneState.getAllElements(),
this.state,
);
const elements = globalSceneState.getElements();
const selectedElements = getSelectedElements(elements, this.state);
if (selectedElements.length === 1 && !isOverScrollBar) {
const elementWithResizeHandler = getElementWithResizeHandler(
globalSceneState.getAllElements(),
elements,
this.state,
{ x, y },
this.state.zoom,
@ -1538,7 +1531,7 @@ export class App extends React.Component<any, AppState> {
}
}
const hitElement = getElementAtPosition(
globalSceneState.getAllElements(),
elements,
this.state,
x,
y,
@ -1737,13 +1730,11 @@ export class App extends React.Component<any, AppState> {
let hitElement: ExcalidrawElement | null = null;
let hitElementWasAddedToSelection = false;
if (this.state.elementType === "selection") {
const selectedElements = getSelectedElements(
globalSceneState.getAllElements(),
this.state,
);
const elements = globalSceneState.getElements();
const selectedElements = getSelectedElements(elements, this.state);
if (selectedElements.length === 1) {
const elementWithResizeHandler = getElementWithResizeHandler(
globalSceneState.getAllElements(),
elements,
this.state,
{ x, y },
this.state.zoom,
@ -1781,7 +1772,7 @@ export class App extends React.Component<any, AppState> {
}
if (!isResizingElements) {
hitElement = getElementAtPosition(
globalSceneState.getAllElements(),
elements,
this.state,
x,
y,
@ -1809,7 +1800,7 @@ export class App extends React.Component<any, AppState> {
},
}));
globalSceneState.replaceAllElements(
globalSceneState.getAllElements(),
globalSceneState.getElementsIncludingDeleted(),
);
hitElementWasAddedToSelection = true;
}
@ -1820,7 +1811,7 @@ export class App extends React.Component<any, AppState> {
// put the duplicates where the selected elements used to be.
const nextElements = [];
const elementsToAppend = [];
for (const element of globalSceneState.getAllElements()) {
for (const element of globalSceneState.getElementsIncludingDeleted()) {
if (
this.state.selectedElementIds[element.id] ||
(element.id === hitElement.id && hitElementWasAddedToSelection)
@ -1930,7 +1921,7 @@ export class App extends React.Component<any, AppState> {
points: [...element.points, [0, 0]],
});
globalSceneState.replaceAllElements([
...globalSceneState.getAllElements(),
...globalSceneState.getElementsIncludingDeleted(),
element,
]);
this.setState({
@ -1958,7 +1949,7 @@ export class App extends React.Component<any, AppState> {
});
} else {
globalSceneState.replaceAllElements([
...globalSceneState.getAllElements(),
...globalSceneState.getElementsIncludingDeleted(),
element,
]);
this.setState({
@ -2047,7 +2038,7 @@ export class App extends React.Component<any, AppState> {
// if elements should be deselected on pointerup
draggingOccurred = true;
const selectedElements = getSelectedElements(
globalSceneState.getAllElements(),
globalSceneState.getElements(),
this.state,
);
if (selectedElements.length > 0) {
@ -2123,14 +2114,12 @@ export class App extends React.Component<any, AppState> {
}
if (this.state.elementType === "selection") {
if (
!event.shiftKey &&
isSomeElementSelected(globalSceneState.getAllElements(), this.state)
) {
const elements = globalSceneState.getElements();
if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
this.setState({ selectedElementIds: {} });
}
const elementsWithinSelection = getElementsWithinSelection(
globalSceneState.getAllElements(),
elements,
draggingElement,
);
this.setState((prevState) => ({
@ -2223,7 +2212,7 @@ export class App extends React.Component<any, AppState> {
) {
// remove invisible element which was added in onPointerDown
globalSceneState.replaceAllElements(
globalSceneState.getAllElements().slice(0, -1),
globalSceneState.getElementsIncludingDeleted().slice(0, -1),
);
this.setState({
draggingElement: null,
@ -2240,7 +2229,7 @@ export class App extends React.Component<any, AppState> {
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
globalSceneState.replaceAllElements(
globalSceneState
.getAllElements()
.getElementsIncludingDeleted()
.filter((el) => el.id !== resizingElement.id),
);
}
@ -2285,7 +2274,7 @@ export class App extends React.Component<any, AppState> {
if (
elementType !== "selection" ||
isSomeElementSelected(globalSceneState.getAllElements(), this.state)
isSomeElementSelected(globalSceneState.getElements(), this.state)
) {
history.resumeRecording();
}
@ -2366,8 +2355,9 @@ export class App extends React.Component<any, AppState> {
window.devicePixelRatio,
);
const elements = globalSceneState.getElements();
const element = getElementAtPosition(
globalSceneState.getAllElements(),
elements,
this.state,
x,
y,
@ -2381,12 +2371,12 @@ export class App extends React.Component<any, AppState> {
action: () => this.pasteFromClipboard(null),
},
probablySupportsClipboardBlob &&
hasNonDeletedElements(globalSceneState.getAllElements()) && {
elements.length > 0 && {
label: t("labels.copyAsPng"),
action: this.copyToClipboardAsPng,
},
probablySupportsClipboardWriteText &&
hasNonDeletedElements(globalSceneState.getAllElements()) && {
elements.length > 0 && {
label: t("labels.copyAsSvg"),
action: this.copyToClipboardAsSvg,
},
@ -2468,7 +2458,7 @@ export class App extends React.Component<any, AppState> {
scale: number,
) {
const elementClickedInside = getElementContainingPosition(
globalSceneState.getAllElements(),
globalSceneState.getElementsIncludingDeleted(),
x,
y,
);
@ -2522,7 +2512,10 @@ export class App extends React.Component<any, AppState> {
}, 300);
private saveDebounced = debounce(() => {
saveToLocalStorage(globalSceneState.getAllElements(), this.state);
saveToLocalStorage(
globalSceneState.getElementsIncludingDeleted(),
this.state,
);
}, 300);
}
@ -2548,7 +2541,7 @@ if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") {
Object.defineProperties(window.h, {
elements: {
get() {
return globalSceneState.getAllElements();
return globalSceneState.getElementsIncludingDeleted();
},
set(elements: ExcalidrawElement[]) {
return globalSceneState.replaceAllElements(elements);

View File

@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef } from "react";
import { ToolButton } from "./ToolButton";
import { clipboard, exportFile, link } from "./icons";
import { ExcalidrawElement } from "../element/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { exportToCanvas } from "../scene/export";
import { ActionsManagerInterface } from "../actions/types";
@ -20,7 +20,7 @@ const scales = [1, 2, 3];
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
export type ExportCB = (
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
scale?: number,
) => void;
@ -35,7 +35,7 @@ function ExportModal({
onExportToBackend,
}: {
appState: AppState;
elements: readonly ExcalidrawElement[];
elements: readonly NonDeletedExcalidrawElement[];
exportPadding?: number;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
@ -166,7 +166,7 @@ export function ExportDialog({
onExportToBackend,
}: {
appState: AppState;
elements: readonly ExcalidrawElement[];
elements: readonly NonDeletedExcalidrawElement[];
exportPadding?: number;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;

View File

@ -1,6 +1,6 @@
import React from "react";
import { t } from "../i18n";
import { ExcalidrawElement } from "../element/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene";
import "./HintViewer.scss";
@ -9,7 +9,7 @@ import { isLinearElement } from "../element/typeChecks";
interface Hint {
appState: AppState;
elements: readonly ExcalidrawElement[];
elements: readonly NonDeletedExcalidrawElement[];
}
const getHints = ({ appState, elements }: Hint) => {

View File

@ -4,7 +4,7 @@ import { calculateScrollCenter } from "../scene";
import { exportCanvas } from "../data";
import { AppState } from "../types";
import { ExcalidrawElement } from "../element/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { ActionManager } from "../actions/manager";
import { Island } from "./Island";
@ -31,8 +31,7 @@ interface LayerUIProps {
appState: AppState;
canvas: HTMLCanvasElement | null;
setAppState: any;
elements: readonly ExcalidrawElement[];
setElements: (elements: readonly ExcalidrawElement[]) => void;
elements: readonly NonDeletedExcalidrawElement[];
onRoomCreate: () => void;
onRoomDestroy: () => void;
onLockToggle: () => void;
@ -45,7 +44,6 @@ export const LayerUI = React.memo(
setAppState,
canvas,
elements,
setElements,
onRoomCreate,
onRoomDestroy,
onLockToggle,
@ -96,7 +94,6 @@ export const LayerUI = React.memo(
<MobileMenu
appState={appState}
elements={elements}
setElements={setElements}
actionManager={actionManager}
exportButton={renderExportDialog()}
setAppState={setAppState}
@ -170,8 +167,6 @@ export const LayerUI = React.memo(
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
setElements={setElements}
elements={elements}
/>
</Stack.Row>
</Island>

View File

@ -5,7 +5,7 @@ import { t, setLanguage } from "../i18n";
import Stack from "./Stack";
import { LanguageList } from "./LanguageList";
import { showSelectedShapeActions } from "../element";
import { ExcalidrawElement } from "../element/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { FixedSideContainer } from "./FixedSideContainer";
import { Island } from "./Island";
import { HintViewer } from "./HintViewer";
@ -22,8 +22,7 @@ type MobileMenuProps = {
actionManager: ActionManager;
exportButton: React.ReactNode;
setAppState: any;
elements: readonly ExcalidrawElement[];
setElements: any;
elements: readonly NonDeletedExcalidrawElement[];
onRoomCreate: () => void;
onRoomDestroy: () => void;
onLockToggle: () => void;
@ -32,7 +31,6 @@ type MobileMenuProps = {
export function MobileMenu({
appState,
elements,
setElements,
actionManager,
exportButton,
setAppState,
@ -54,8 +52,6 @@ export function MobileMenu({
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
setElements={setElements}
elements={elements}
/>
</Stack.Row>
</Island>

View File

@ -1,4 +1,7 @@
import { ExcalidrawElement } from "../element/types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { getDefaultAppState } from "../appState";
@ -16,7 +19,6 @@ import { serializeAsJSON } from "./json";
import { ExportType } from "../scene/types";
import { restore } from "./restore";
import { restoreFromLocalStorage } from "./localStorage";
import { hasNonDeletedElements } from "../element";
export { loadFromBlob } from "./blob";
export { saveAsJSON, loadFromJSON } from "./json";
@ -283,7 +285,7 @@ export async function importFromBackend(
export async function exportCanvas(
type: ExportType,
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement,
{
@ -300,7 +302,7 @@ export async function exportCanvas(
scale?: number;
},
) {
if (!hasNonDeletedElements(elements)) {
if (elements.length === 0) {
return window.alert(t("alerts.cannotExportEmptyCanvas"));
}
if (type === "svg" || type === "clipboard-svg") {

View File

@ -1,6 +1,6 @@
import { distanceBetweenPointAndSegment } from "../math";
import { ExcalidrawElement } from "./types";
import { NonDeletedExcalidrawElement } from "./types";
import { getDiamondPoints, getElementAbsoluteCoords } from "./bounds";
import { Point } from "../types";
@ -11,7 +11,7 @@ import { isLinearElement } from "./typeChecks";
import { rotate } from "../math";
function isElementDraggableFromInside(
element: ExcalidrawElement,
element: NonDeletedExcalidrawElement,
appState: AppState,
): boolean {
return (
@ -21,7 +21,7 @@ function isElementDraggableFromInside(
}
export function hitTest(
element: ExcalidrawElement,
element: NonDeletedExcalidrawElement,
appState: AppState,
x: number,
y: number,

View File

@ -1,4 +1,4 @@
import { ExcalidrawElement } from "./types";
import { ExcalidrawElement, NonDeletedExcalidrawElement } from "./types";
import { isInvisiblySmallElement } from "./sizeHelpers";
export {
@ -63,6 +63,9 @@ export function getDrawingVersion(elements: readonly ExcalidrawElement[]) {
return elements.reduce((acc, el) => acc + el.version, 0);
}
export function hasNonDeletedElements(elements: readonly ExcalidrawElement[]) {
return elements.some((element) => !element.isDeleted);
export function getNonDeletedElements(elements: readonly ExcalidrawElement[]) {
return (
elements.filter((element) => !element.isDeleted) as
readonly NonDeletedExcalidrawElement[]
);
}

View File

@ -3,6 +3,7 @@ import {
ExcalidrawTextElement,
ExcalidrawLinearElement,
ExcalidrawGenericElement,
NonDeleted,
} from "../element/types";
import { measureText } from "../utils";
import { randomInteger, randomId } from "../random";
@ -56,7 +57,7 @@ function _newElementBase<T extends ExcalidrawElement>(
seed: rest.seed ?? randomInteger(),
version: rest.version || 1,
versionNonce: rest.versionNonce ?? 0,
isDeleted: rest.isDeleted ?? false,
isDeleted: false as false,
};
}
@ -64,7 +65,7 @@ export function newElement(
opts: {
type: ExcalidrawGenericElement["type"];
} & ElementConstructorOpts,
): ExcalidrawGenericElement {
): NonDeleted<ExcalidrawGenericElement> {
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
}
@ -73,13 +74,12 @@ export function newTextElement(
text: string;
font: string;
} & ElementConstructorOpts,
): ExcalidrawTextElement {
): NonDeleted<ExcalidrawTextElement> {
const { text, font } = opts;
const metrics = measureText(text, font);
const textElement = newElementWith(
{
..._newElementBase<ExcalidrawTextElement>("text", opts),
isDeleted: false,
text: text,
font: font,
// Center the text
@ -100,7 +100,7 @@ export function newLinearElement(
type: ExcalidrawLinearElement["type"];
lastCommittedPoint?: ExcalidrawLinearElement["lastCommittedPoint"];
} & ElementConstructorOpts,
): ExcalidrawLinearElement {
): NonDeleted<ExcalidrawLinearElement> {
return {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: [],

View File

@ -3,7 +3,11 @@ import { SHIFT_LOCKING_ANGLE } from "../constants";
import { getSelectedElements, globalSceneState } from "../scene";
import { rescalePoints } from "../points";
import { rotate, adjustXYWithRotation } from "../math";
import { ExcalidrawElement, ExcalidrawLinearElement } from "./types";
import {
ExcalidrawLinearElement,
NonDeletedExcalidrawElement,
NonDeleted,
} from "./types";
import { getElementAbsoluteCoords, getCommonBounds } from "./bounds";
import { isLinearElement } from "./typeChecks";
import { mutateElement } from "./mutateElement";
@ -17,7 +21,7 @@ import {
type ResizeTestType = ReturnType<typeof resizeTest>;
export type ResizeArrowFnType = (
element: ExcalidrawLinearElement,
element: NonDeleted<ExcalidrawLinearElement>,
pointIndex: number,
deltaX: number,
deltaY: number,
@ -27,13 +31,13 @@ export type ResizeArrowFnType = (
) => void;
const arrowResizeOrigin: ResizeArrowFnType = (
element: ExcalidrawLinearElement,
pointIndex: number,
deltaX: number,
deltaY: number,
pointerX: number,
pointerY: number,
perfect: boolean,
element,
pointIndex,
deltaX,
deltaY,
pointerX,
pointerY,
perfect,
) => {
const [px, py] = element.points[pointIndex];
let x = element.x + deltaX;
@ -63,13 +67,13 @@ const arrowResizeOrigin: ResizeArrowFnType = (
};
const arrowResizeEnd: ResizeArrowFnType = (
element: ExcalidrawLinearElement,
pointIndex: number,
deltaX: number,
deltaY: number,
pointerX: number,
pointerY: number,
perfect: boolean,
element,
pointIndex,
deltaX,
deltaY,
pointerX,
pointerY,
perfect,
) => {
const [px, py] = element.points[pointIndex];
if (perfect) {
@ -110,7 +114,7 @@ export function resizeElements(
isRotating: resizeHandle === "rotation",
});
const selectedElements = getSelectedElements(
globalSceneState.getAllElements(),
globalSceneState.getElements(),
appState,
);
if (selectedElements.length === 1) {
@ -451,7 +455,7 @@ export function resizeElements(
}
export function canResizeMutlipleElements(
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
) {
return elements.every((element) =>
["rectangle", "diamond", "ellipse"].includes(element.type),

View File

@ -1,4 +1,8 @@
import { ExcalidrawElement, PointerType } from "./types";
import {
ExcalidrawElement,
PointerType,
NonDeletedExcalidrawElement,
} from "./types";
import {
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
@ -24,7 +28,7 @@ function isInHandlerRect(
}
export function resizeTest(
element: ExcalidrawElement,
element: NonDeletedExcalidrawElement,
appState: AppState,
x: number,
y: number,
@ -66,7 +70,7 @@ export function resizeTest(
}
export function getElementWithResizeHandler(
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
{ x, y }: { x: number; y: number },
zoom: number,
@ -78,7 +82,7 @@ export function getElementWithResizeHandler(
}
const resizeHandle = resizeTest(element, appState, x, y, zoom, pointerType);
return resizeHandle ? { element, resizeHandle } : null;
}, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
}, null as { element: NonDeletedExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
}
export function getResizeHandlerFromCoords(

View File

@ -1,10 +1,10 @@
import { AppState } from "../types";
import { ExcalidrawElement } from "./types";
import { NonDeletedExcalidrawElement } from "./types";
import { getSelectedElements } from "../scene";
export const showSelectedShapeActions = (
appState: AppState,
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
) =>
Boolean(
appState.editingElement ||

View File

@ -33,6 +33,12 @@ export type ExcalidrawElement =
| ExcalidrawTextElement
| ExcalidrawLinearElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: false;
};
export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
export type ExcalidrawTextElement = _ExcalidrawElementBase &
Readonly<{
type: "text";

View File

@ -1,4 +1,8 @@
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import {
ExcalidrawElement,
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { isTextElement } from "../element/typeChecks";
import {
getDiamondPoints,
@ -24,7 +28,7 @@ export interface ExcalidrawElementWithCanvas {
}
function generateElementCanvas(
element: ExcalidrawElement,
element: NonDeletedExcalidrawElement,
zoom: number,
): ExcalidrawElementWithCanvas {
const canvas = document.createElement("canvas");
@ -72,7 +76,7 @@ function generateElementCanvas(
}
function drawElementOnCanvas(
element: ExcalidrawElement,
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
) {
@ -133,7 +137,7 @@ export function invalidateShapeForElement(element: ExcalidrawElement) {
}
function generateElement(
element: ExcalidrawElement,
element: NonDeletedExcalidrawElement,
generator: RoughGenerator,
sceneState?: SceneState,
) {
@ -285,7 +289,7 @@ function drawElementFromCanvas(
}
export function renderElement(
element: ExcalidrawElement,
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderOptimizations: boolean,
@ -342,7 +346,7 @@ export function renderElement(
}
export function renderElementToSvg(
element: ExcalidrawElement,
element: NonDeletedExcalidrawElement,
rsvg: RoughSVG,
svgRoot: SVGElement,
offsetX?: number,

View File

@ -2,7 +2,10 @@ import { RoughCanvas } from "roughjs/bin/canvas";
import { RoughSVG } from "roughjs/bin/svg";
import { FlooredNumber, AppState } from "../types";
import { ExcalidrawElement } from "../element/types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import {
getElementAbsoluteCoords,
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
@ -74,9 +77,9 @@ function strokeCircle(
}
export function renderScene(
allElements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
selectionElement: ExcalidrawElement | null,
selectionElement: NonDeletedExcalidrawElement | null,
scale: number,
rc: RoughCanvas,
canvas: HTMLCanvasElement,
@ -99,8 +102,6 @@ export function renderScene(
return { atLeastOneVisibleElement: false };
}
const elements = allElements.filter((element) => !element.isDeleted);
const context = canvas.getContext("2d")!;
context.scale(scale, scale);
@ -493,7 +494,7 @@ function isVisibleElement(
// This should be only called for exporting purposes
export function renderSceneToSvg(
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
rsvg: RoughSVG,
svgRoot: SVGElement,
{

View File

@ -1,4 +1,7 @@
import { ExcalidrawElement } from "../element/types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { getElementAbsoluteCoords, hitTest } from "../element";
import { AppState } from "../types";
@ -16,7 +19,7 @@ export const hasStroke = (type: string) =>
export const hasText = (type: string) => type === "text";
export function getElementAtPosition(
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
x: number,
y: number,

View File

@ -1,5 +1,5 @@
import rough from "roughjs/bin/rough";
import { ExcalidrawElement } from "../element/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getCommonBounds } from "../element/bounds";
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
import { distance, SVG_NS } from "../utils";
@ -9,7 +9,7 @@ import { AppState } from "../types";
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
export function exportToCanvas(
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
{
exportBackground,
@ -66,7 +66,7 @@ export function exportToCanvas(
}
export function exportToSvg(
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
{
exportBackground,
exportPadding = 10,

View File

@ -1,4 +1,8 @@
import { ExcalidrawElement } from "../element/types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { getNonDeletedElements } from "../element";
export interface SceneStateCallback {
(): void;
@ -8,15 +12,19 @@ export interface SceneStateCallbackRemover {
(): void;
}
class SceneState {
class GlobalScene {
private callbacks: Set<SceneStateCallback> = new Set();
constructor(private _elements: readonly ExcalidrawElement[] = []) {}
getAllElements() {
getElementsIncludingDeleted() {
return this._elements;
}
getElements(): readonly NonDeletedExcalidrawElement[] {
return getNonDeletedElements(this._elements);
}
replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
this._elements = nextElements;
this.informMutation();
@ -44,4 +52,4 @@ class SceneState {
}
}
export const globalSceneState = new SceneState();
export const globalSceneState = new GlobalScene();

View File

@ -1,11 +1,14 @@
import { ExcalidrawElement } from "../element/types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { getElementAbsoluteCoords, getElementBounds } from "../element";
import { AppState } from "../types";
import { newElementWith } from "../element/mutateElement";
export function getElementsWithinSelection(
elements: readonly ExcalidrawElement[],
selection: ExcalidrawElement,
elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement,
) {
const [
selectionX1,
@ -47,7 +50,7 @@ export function deleteSelectedElements(
}
export function isSomeElementSelected(
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
): boolean {
return elements.some((element) => appState.selectedElementIds[element.id]);
@ -58,7 +61,7 @@ export function isSomeElementSelected(
* elements. If elements don't share the same value, returns `null`.
*/
export function getCommonAttributeOfSelectedElements<T>(
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
getAttribute: (element: ExcalidrawElement) => T,
): T | null {
@ -73,14 +76,14 @@ export function getCommonAttributeOfSelectedElements<T>(
}
export function getSelectedElements(
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
): readonly ExcalidrawElement[] {
) {
return elements.filter((element) => appState.selectedElementIds[element.id]);
}
export function getTargetElement(
elements: readonly ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) {
return appState.editingElement

View File

@ -10,6 +10,7 @@ import {
actionBringToFront,
actionSendToBack,
} from "../actions";
import { ExcalidrawElement } from "../element/types";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@ -27,7 +28,7 @@ function populateElements(
const selectedElementIds: any = {};
h.elements = elements.map(({ id, isDeleted = false, isSelected = false }) => {
const element: Mutable<ReturnType<typeof newElement>> = newElement({
const element: Mutable<ExcalidrawElement> = newElement({
type: "rectangle",
x: 100,
y: 100,

View File

@ -1,7 +1,8 @@
import {
ExcalidrawElement,
PointerType,
ExcalidrawLinearElement,
NonDeletedExcalidrawElement,
NonDeleted,
} from "./element/types";
import { SHAPES } from "./shapes";
import { Point as RoughPoint } from "roughjs/bin/geometry";
@ -12,13 +13,13 @@ export type Point = Readonly<RoughPoint>;
export type AppState = {
isLoading: boolean;
errorMessage: string | null;
draggingElement: ExcalidrawElement | null;
resizingElement: ExcalidrawElement | null;
multiElement: ExcalidrawLinearElement | null;
selectionElement: ExcalidrawElement | null;
draggingElement: NonDeletedExcalidrawElement | null;
resizingElement: NonDeletedExcalidrawElement | null;
multiElement: NonDeleted<ExcalidrawLinearElement> | null;
selectionElement: NonDeletedExcalidrawElement | null;
// element being edited, but not necessarily added to elements array yet
// (e.g. text element when typing into the input)
editingElement: ExcalidrawElement | null;
editingElement: NonDeletedExcalidrawElement | null;
elementType: typeof SHAPES[number]["value"];
elementLocked: boolean;
exportBackground: boolean;