Fix issues related to history (#701)

* Separate UI from Canvas

* Explicitly define history recording

* ActionManager: Set syncActionState during construction instead of in every call

* Add commit to history flag to necessary actions

* Disable undoing during multiElement

* Write custom equality function for UI component to render it only when specific props and elements change

* Remove stale comments about history skipping

* Stop undo/redoing when in resizing element mode

* wip

* correctly reset resizingElement & add undo check

* Separate selection element from the rest of the array and stop redrawing the UI when dragging the selection

* Remove selectionElement from local storage

* Remove unnecessary readonly type casting in actionFinalize

* Fix undo / redo for multi points

* Fix an issue that did not update history when elements were locked

* Disable committing to history for noops

- deleteSelected without deleting anything
- Basic selection

* Use generateEntry only inside history and pass elements and appstate to history

* Update component after every history resume

* Remove last item from the history only if in multi mode

* Resume recording when element type is not selection

* ensure we prevent hotkeys only on writable elements

* Remove selection clearing from history

* Remove one point arrows as they are invisibly small

* Remove shape of elements from local storage

* Fix removing invisible element from the array

* add missing history resuming cases & simplify slice

* fix lint

* don't regenerate elements if no elements deselected

* regenerate elements array on selection

* reset state.selectionElement unconditionally

* Use getter instead of passing appState and scene data through functions to actions

* fix import

Co-authored-by: David Luzar <luzar.david@gmail.com>
This commit is contained in:
Gasim Gasimzada 2020-02-05 22:47:10 +04:00 committed by GitHub
parent 972d69da6c
commit 33016bf6bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 450 additions and 413 deletions

View File

@ -8,7 +8,7 @@ import { t } from "../i18n";
export const actionChangeViewBackgroundColor: Action = { export const actionChangeViewBackgroundColor: Action = {
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
perform: (elements, appState, value) => { perform: (_, appState, value) => {
return { appState: { ...appState, viewBackgroundColor: value } }; return { appState: { ...appState, viewBackgroundColor: value } };
}, },
PanelComponent: ({ appState, updateData }) => { PanelComponent: ({ appState, updateData }) => {
@ -23,10 +23,12 @@ export const actionChangeViewBackgroundColor: Action = {
</div> </div>
); );
}, },
commitToHistory: () => true,
}; };
export const actionClearCanvas: Action = { export const actionClearCanvas: Action = {
name: "clearCanvas", name: "clearCanvas",
commitToHistory: () => true,
perform: () => { perform: () => {
return { return {
elements: [], elements: [],

View File

@ -12,5 +12,6 @@ export const actionDeleteSelected: Action = {
}, },
contextItemLabel: "labels.delete", contextItemLabel: "labels.delete",
contextMenuOrder: 3, contextMenuOrder: 3,
commitToHistory: (_, elements) => elements.some(el => el.isSelected),
keyTest: event => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE, keyTest: event => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
}; };

View File

@ -1,10 +1,12 @@
import { Action } from "./types"; import { Action } from "./types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { clearSelection } from "../scene"; import { clearSelection } from "../scene";
import { isInvisiblySmallElement } from "../element";
export const actionFinalize: Action = { export const actionFinalize: Action = {
name: "finalize", name: "finalize",
perform: (elements, appState) => { perform: (elements, appState) => {
let newElements = clearSelection(elements);
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
window.document.activeElement.blur(); window.document.activeElement.blur();
} }
@ -13,10 +15,13 @@ export const actionFinalize: Action = {
0, 0,
appState.multiElement.points.length - 1, appState.multiElement.points.length - 1,
); );
if (isInvisiblySmallElement(appState.multiElement)) {
newElements = newElements.slice(0, -1);
}
appState.multiElement.shape = null; appState.multiElement.shape = null;
} }
return { return {
elements: clearSelection(elements), elements: newElements,
appState: { appState: {
...appState, ...appState,
elementType: "selection", elementType: "selection",

View File

@ -47,6 +47,7 @@ export const actionChangeStrokeColor: Action = {
appState: { ...appState, currentItemStrokeColor: value }, appState: { ...appState, currentItemStrokeColor: value },
}; };
}, },
commitToHistory: () => true,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<> <>
<h3 aria-hidden="true">{t("labels.stroke")}</h3> <h3 aria-hidden="true">{t("labels.stroke")}</h3>
@ -77,6 +78,7 @@ export const actionChangeBackgroundColor: Action = {
appState: { ...appState, currentItemBackgroundColor: value }, appState: { ...appState, currentItemBackgroundColor: value },
}; };
}, },
commitToHistory: () => true,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<> <>
<h3 aria-hidden="true">{t("labels.background")}</h3> <h3 aria-hidden="true">{t("labels.background")}</h3>
@ -107,6 +109,7 @@ export const actionChangeFillStyle: Action = {
appState: { ...appState, currentItemFillStyle: value }, appState: { ...appState, currentItemFillStyle: value },
}; };
}, },
commitToHistory: () => true,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
<legend>{t("labels.fill")}</legend> <legend>{t("labels.fill")}</legend>
@ -143,6 +146,7 @@ export const actionChangeStrokeWidth: Action = {
appState: { ...appState, currentItemStrokeWidth: value }, appState: { ...appState, currentItemStrokeWidth: value },
}; };
}, },
commitToHistory: () => true,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
<legend>{t("labels.strokeWidth")}</legend> <legend>{t("labels.strokeWidth")}</legend>
@ -177,6 +181,7 @@ export const actionChangeSloppiness: Action = {
appState: { ...appState, currentItemRoughness: value }, appState: { ...appState, currentItemRoughness: value },
}; };
}, },
commitToHistory: () => true,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
<legend>{t("labels.sloppiness")}</legend> <legend>{t("labels.sloppiness")}</legend>
@ -211,6 +216,7 @@ export const actionChangeOpacity: Action = {
appState: { ...appState, currentItemOpacity: value }, appState: { ...appState, currentItemOpacity: value },
}; };
}, },
commitToHistory: () => true,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<label className="control-label"> <label className="control-label">
{t("labels.opacity")} {t("labels.opacity")}
@ -272,6 +278,7 @@ export const actionChangeFontSize: Action = {
}, },
}; };
}, },
commitToHistory: () => true,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
<legend>{t("labels.fontSize")}</legend> <legend>{t("labels.fontSize")}</legend>
@ -320,6 +327,7 @@ export const actionChangeFontFamily: Action = {
}, },
}; };
}, },
commitToHistory: () => true,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
<legend>{t("labels.fontFamily")}</legend> <legend>{t("labels.fontFamily")}</legend>

View File

@ -45,6 +45,7 @@ export const actionPasteStyles: Action = {
}), }),
}; };
}, },
commitToHistory: () => true,
contextItemLabel: "labels.pasteStyles", contextItemLabel: "labels.pasteStyles",
keyTest: event => event[KEYS.META] && event.shiftKey && event.key === "V", keyTest: event => event[KEYS.META] && event.shiftKey && event.key === "V",
contextMenuOrder: 1, contextMenuOrder: 1,

View File

@ -18,6 +18,7 @@ export const actionSendBackward: Action = {
}, },
contextItemLabel: "labels.sendBackward", contextItemLabel: "labels.sendBackward",
keyPriority: 40, keyPriority: 40,
commitToHistory: () => true,
keyTest: event => event[KEYS.META] && event.altKey && event.key === "B", keyTest: event => event[KEYS.META] && event.altKey && event.key === "B",
}; };
@ -31,6 +32,7 @@ export const actionBringForward: Action = {
}, },
contextItemLabel: "labels.bringForward", contextItemLabel: "labels.bringForward",
keyPriority: 40, keyPriority: 40,
commitToHistory: () => true,
keyTest: event => event[KEYS.META] && event.altKey && event.key === "F", keyTest: event => event[KEYS.META] && event.altKey && event.key === "F",
}; };
@ -43,6 +45,7 @@ export const actionSendToBack: Action = {
}; };
}, },
contextItemLabel: "labels.sendToBack", contextItemLabel: "labels.sendToBack",
commitToHistory: () => true,
keyTest: event => event[KEYS.META] && event.shiftKey && event.key === "B", keyTest: event => event[KEYS.META] && event.shiftKey && event.key === "B",
}; };
@ -54,6 +57,7 @@ export const actionBringToFront: Action = {
appState, appState,
}; };
}, },
commitToHistory: () => true,
contextItemLabel: "labels.bringToFront", contextItemLabel: "labels.bringToFront",
keyTest: event => event[KEYS.META] && event.shiftKey && event.key === "F", keyTest: event => event[KEYS.META] && event.shiftKey && event.key === "F",
}; };

View File

@ -12,29 +12,37 @@ import { t } from "../i18n";
export class ActionManager implements ActionsManagerInterface { export class ActionManager implements ActionsManagerInterface {
actions: { [keyProp: string]: Action } = {}; actions: { [keyProp: string]: Action } = {};
updater: updater: UpdaterFn;
| ((elements: ExcalidrawElement[], appState: AppState) => void)
| null = null;
setUpdater( resumeHistoryRecording: () => void;
updater: (elements: ExcalidrawElement[], appState: AppState) => void,
getAppState: () => AppState;
getElements: () => readonly ExcalidrawElement[];
constructor(
updater: UpdaterFn,
resumeHistoryRecording: () => void,
getAppState: () => AppState,
getElements: () => readonly ExcalidrawElement[],
) { ) {
this.updater = updater; this.updater = updater;
this.resumeHistoryRecording = resumeHistoryRecording;
this.getAppState = getAppState;
this.getElements = getElements;
} }
registerAction(action: Action) { registerAction(action: Action) {
this.actions[action.name] = action; this.actions[action.name] = action;
} }
handleKeyDown( handleKeyDown(event: KeyboardEvent) {
event: KeyboardEvent,
elements: readonly ExcalidrawElement[],
appState: AppState,
) {
const data = Object.values(this.actions) const data = Object.values(this.actions)
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0)) .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
.filter( .filter(
action => action.keyTest && action.keyTest(event, appState, elements), action =>
action.keyTest &&
action.keyTest(event, this.getAppState(), this.getElements()),
); );
if (data.length === 0) { if (data.length === 0) {
@ -42,15 +50,16 @@ export class ActionManager implements ActionsManagerInterface {
} }
event.preventDefault(); event.preventDefault();
return data[0].perform(elements, appState, null); if (
data[0].commitToHistory &&
data[0].commitToHistory(this.getAppState(), this.getElements())
) {
this.resumeHistoryRecording();
}
return data[0].perform(this.getElements(), this.getAppState(), null);
} }
getContextMenuItems( getContextMenuItems(actionFilter: ActionFilterFn = action => action) {
elements: readonly ExcalidrawElement[],
appState: AppState,
updater: UpdaterFn,
actionFilter: ActionFilterFn = action => action,
) {
return Object.values(this.actions) return Object.values(this.actions)
.filter(actionFilter) .filter(actionFilter)
.filter(action => "contextItemLabel" in action) .filter(action => "contextItemLabel" in action)
@ -62,28 +71,40 @@ export class ActionManager implements ActionsManagerInterface {
.map(action => ({ .map(action => ({
label: action.contextItemLabel ? t(action.contextItemLabel) : "", label: action.contextItemLabel ? t(action.contextItemLabel) : "",
action: () => { action: () => {
updater(action.perform(elements, appState, null)); if (
action.commitToHistory &&
action.commitToHistory(this.getAppState(), this.getElements())
) {
this.resumeHistoryRecording();
}
this.updater(
action.perform(this.getElements(), this.getAppState(), null),
);
}, },
})); }));
} }
renderAction( renderAction(name: string) {
name: string,
elements: readonly ExcalidrawElement[],
appState: AppState,
updater: UpdaterFn,
) {
if (this.actions[name] && "PanelComponent" in this.actions[name]) { if (this.actions[name] && "PanelComponent" in this.actions[name]) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
const updateData = (formState: any) => { const updateData = (formState: any) => {
updater(action.perform(elements, appState, formState)); if (
action.commitToHistory &&
action.commitToHistory(this.getAppState(), this.getElements()) ===
true
) {
this.resumeHistoryRecording();
}
this.updater(
action.perform(this.getElements(), this.getAppState(), formState),
);
}; };
return ( return (
<PanelComponent <PanelComponent
elements={elements} elements={this.getElements()}
appState={appState} appState={this.getAppState()}
updateData={updateData} updateData={updateData}
/> />
); );

View File

@ -3,7 +3,7 @@ import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
export type ActionResult = { export type ActionResult = {
elements?: ExcalidrawElement[]; elements?: readonly ExcalidrawElement[];
appState?: AppState; appState?: AppState;
}; };
@ -32,6 +32,10 @@ export interface Action {
) => boolean; ) => boolean;
contextItemLabel?: string; contextItemLabel?: string;
contextMenuOrder?: number; contextMenuOrder?: number;
commitToHistory?: (
appState: AppState,
elements: readonly ExcalidrawElement[],
) => boolean;
} }
export interface ActionsManagerInterface { export interface ActionsManagerInterface {
@ -39,21 +43,9 @@ export interface ActionsManagerInterface {
[keyProp: string]: Action; [keyProp: string]: Action;
}; };
registerAction: (action: Action) => void; registerAction: (action: Action) => void;
handleKeyDown: ( handleKeyDown: (event: KeyboardEvent) => ActionResult | null;
event: KeyboardEvent,
elements: readonly ExcalidrawElement[],
appState: AppState,
) => ActionResult | null;
getContextMenuItems: ( getContextMenuItems: (
elements: readonly ExcalidrawElement[],
appState: AppState,
updater: UpdaterFn,
actionFilter: ActionFilterFn, actionFilter: ActionFilterFn,
) => { label: string; action: () => void }[]; ) => { label: string; action: () => void }[];
renderAction: ( renderAction: (name: string) => React.ReactElement | null;
name: string,
elements: readonly ExcalidrawElement[],
appState: AppState,
updater: UpdaterFn,
) => React.ReactElement | null;
} }

View File

@ -27,6 +27,7 @@ export function getDefaultAppState(): AppState {
scrolledOutside: false, scrolledOutside: false,
name: DEFAULT_PROJECT_NAME, name: DEFAULT_PROJECT_NAME,
isResizing: false, isResizing: false,
selectionElement: null,
}; };
} }
@ -36,12 +37,30 @@ export function clearAppStateForLocalStorage(appState: AppState) {
resizingElement, resizingElement,
multiElement, multiElement,
editingElement, editingElement,
selectionElement,
isResizing, isResizing,
...exportedState ...exportedState
} = appState; } = appState;
return exportedState; return exportedState;
} }
export function clearAppStatePropertiesForHistory(
appState: AppState,
): Partial<AppState> {
return {
exportBackground: appState.exportBackground,
currentItemStrokeColor: appState.currentItemStrokeColor,
currentItemBackgroundColor: appState.currentItemBackgroundColor,
currentItemFillStyle: appState.currentItemFillStyle,
currentItemStrokeWidth: appState.currentItemStrokeWidth,
currentItemRoughness: appState.currentItemRoughness,
currentItemOpacity: appState.currentItemOpacity,
currentItemFont: appState.currentItemFont,
viewBackgroundColor: appState.viewBackgroundColor,
name: appState.name,
};
}
export function cleanAppStateForExport(appState: AppState) { export function cleanAppStateForExport(appState: AppState) {
return { return {
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,

View File

@ -9,7 +9,7 @@ import { Island } from "./Island";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { exportToCanvas } from "../scene/export"; import { exportToCanvas } from "../scene/export";
import { ActionsManagerInterface, UpdaterFn } from "../actions/types"; import { ActionsManagerInterface } from "../actions/types";
import Stack from "./Stack"; import Stack from "./Stack";
import { t } from "../i18n"; import { t } from "../i18n";
@ -30,7 +30,6 @@ function ExportModal({
appState, appState,
exportPadding = 10, exportPadding = 10,
actionManager, actionManager,
syncActionResult,
onExportToPng, onExportToPng,
onExportToSvg, onExportToSvg,
onExportToClipboard, onExportToClipboard,
@ -41,7 +40,6 @@ function ExportModal({
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
exportPadding?: number; exportPadding?: number;
actionManager: ActionsManagerInterface; actionManager: ActionsManagerInterface;
syncActionResult: UpdaterFn;
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
onExportToClipboard: ExportCB; onExportToClipboard: ExportCB;
@ -160,12 +158,7 @@ function ExportModal({
</Stack.Row> </Stack.Row>
</Stack.Col> </Stack.Col>
{actionManager.renderAction( {actionManager.renderAction("changeProjectName")}
"changeProjectName",
elements,
appState,
syncActionResult,
)}
<Stack.Col gap={1}> <Stack.Col gap={1}>
<div className="ExportDialog__scales"> <div className="ExportDialog__scales">
<Stack.Row gap={2} align="baseline"> <Stack.Row gap={2} align="baseline">
@ -184,12 +177,7 @@ function ExportModal({
))} ))}
</Stack.Row> </Stack.Row>
</div> </div>
{actionManager.renderAction( {actionManager.renderAction("changeExportBackground")}
"changeExportBackground",
elements,
appState,
syncActionResult,
)}
{someElementIsSelected && ( {someElementIsSelected && (
<div> <div>
<label> <label>
@ -215,7 +203,6 @@ export function ExportDialog({
appState, appState,
exportPadding = 10, exportPadding = 10,
actionManager, actionManager,
syncActionResult,
onExportToPng, onExportToPng,
onExportToSvg, onExportToSvg,
onExportToClipboard, onExportToClipboard,
@ -225,7 +212,6 @@ export function ExportDialog({
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
exportPadding?: number; exportPadding?: number;
actionManager: ActionsManagerInterface; actionManager: ActionsManagerInterface;
syncActionResult: UpdaterFn;
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
onExportToClipboard: ExportCB; onExportToClipboard: ExportCB;
@ -260,7 +246,6 @@ export function ExportDialog({
appState={appState} appState={appState}
exportPadding={exportPadding} exportPadding={exportPadding}
actionManager={actionManager} actionManager={actionManager}
syncActionResult={syncActionResult}
onExportToPng={onExportToPng} onExportToPng={onExportToPng}
onExportToSvg={onExportToSvg} onExportToSvg={onExportToSvg}
onExportToClipboard={onExportToClipboard} onExportToClipboard={onExportToClipboard}

View File

@ -2,7 +2,7 @@ import { ExcalidrawElement } from "./types";
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") {
return element.points.length === 0; return element.points.length < 2;
} }
return element.width === 0 && element.height === 0; return element.width === 0 && element.height === 0;
} }

View File

@ -1,25 +1,31 @@
import { AppState } from "./types"; import { AppState } from "./types";
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { clearAppStatePropertiesForHistory } from "./appState";
class SceneHistory { class SceneHistory {
private recording: boolean = true; private recording: boolean = true;
private stateHistory: string[] = []; private stateHistory: string[] = [];
private redoStack: string[] = []; private redoStack: string[] = [];
generateCurrentEntry( private generateEntry(
appState: Partial<AppState>, appState: AppState,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) { ) {
return JSON.stringify({ return JSON.stringify({
appState, appState: clearAppStatePropertiesForHistory(appState),
elements: elements.map(({ shape, ...element }) => ({ elements: elements.map(({ shape, ...element }) => ({
...element, ...element,
isSelected: false, shape: null,
points:
appState.multiElement && appState.multiElement.id === element.id
? element.points.slice(0, -1)
: element.points,
})), })),
}); });
} }
pushEntry(newEntry: string) { pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
const newEntry = this.generateEntry(appState, elements);
if ( if (
this.stateHistory.length > 0 && this.stateHistory.length > 0 &&
this.stateHistory[this.stateHistory.length - 1] === newEntry this.stateHistory[this.stateHistory.length - 1] === newEntry
@ -67,6 +73,7 @@ class SceneHistory {
} }
const currentEntry = this.stateHistory.pop(); const currentEntry = this.stateHistory.pop();
const entryToRestore = this.stateHistory[this.stateHistory.length - 1]; const entryToRestore = this.stateHistory[this.stateHistory.length - 1];
if (currentEntry !== undefined) { if (currentEntry !== undefined) {

View File

@ -44,8 +44,8 @@ import { AppState } from "./types";
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { import {
isInputLike,
isWritableElement, isWritableElement,
isInputLike,
debounce, debounce,
capitalizeString, capitalizeString,
distance, distance,
@ -154,37 +154,267 @@ export function viewportCoordsToSceneCoords(
return { x, y }; return { x, y };
} }
function pickAppStatePropertiesForHistory(
appState: AppState,
): Partial<AppState> {
return {
exportBackground: appState.exportBackground,
currentItemStrokeColor: appState.currentItemStrokeColor,
currentItemBackgroundColor: appState.currentItemBackgroundColor,
currentItemFillStyle: appState.currentItemFillStyle,
currentItemStrokeWidth: appState.currentItemStrokeWidth,
currentItemRoughness: appState.currentItemRoughness,
currentItemOpacity: appState.currentItemOpacity,
currentItemFont: appState.currentItemFont,
viewBackgroundColor: appState.viewBackgroundColor,
name: appState.name,
};
}
let cursorX = 0; let cursorX = 0;
let cursorY = 0; let cursorY = 0;
let isHoldingSpace: boolean = false; let isHoldingSpace: boolean = false;
let isPanning: boolean = false; let isPanning: boolean = false;
let isHoldingMouseButton: boolean = false; let isHoldingMouseButton: boolean = false;
interface LayerUIProps {
actionManager: ActionManager;
appState: AppState;
canvas: HTMLCanvasElement | null;
setAppState: any;
elements: readonly ExcalidrawElement[];
setElements: (elements: readonly ExcalidrawElement[]) => void;
}
const LayerUI = React.memo(
({
actionManager,
appState,
setAppState,
canvas,
elements,
setElements,
}: LayerUIProps) => {
function renderCanvasActions() {
return (
<Stack.Col gap={4}>
<Stack.Row justifyContent={"space-between"}>
{actionManager.renderAction("loadScene")}
{actionManager.renderAction("saveScene")}
<ExportDialog
elements={elements}
appState={appState}
actionManager={actionManager}
onExportToPng={(exportedElements, scale) => {
if (canvas) {
exportCanvas("png", exportedElements, canvas, {
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
scale,
});
}
}}
onExportToSvg={(exportedElements, scale) => {
if (canvas) {
exportCanvas("svg", exportedElements, canvas, {
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
scale,
});
}
}}
onExportToClipboard={(exportedElements, scale) => {
if (canvas) {
exportCanvas("clipboard", exportedElements, canvas, {
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
scale,
});
}
}}
onExportToBackend={exportedElements => {
if (canvas) {
exportCanvas(
"backend",
exportedElements.map(element => ({
...element,
isSelected: false,
})),
canvas,
appState,
);
}
}}
/>
{actionManager.renderAction("clearCanvas")}
</Stack.Row>
{actionManager.renderAction("changeViewBackgroundColor")}
</Stack.Col>
);
}
function renderSelectedShapeActions(
elements: readonly ExcalidrawElement[],
) {
const { elementType, editingElement } = appState;
const targetElements = editingElement
? [editingElement]
: elements.filter(el => el.isSelected);
if (!targetElements.length && elementType === "selection") {
return null;
}
return (
<Island padding={4}>
<div className="panelColumn">
{actionManager.renderAction("changeStrokeColor")}
{(hasBackground(elementType) ||
targetElements.some(element => hasBackground(element.type))) && (
<>
{actionManager.renderAction("changeBackgroundColor")}
{actionManager.renderAction("changeFillStyle")}
</>
)}
{(hasStroke(elementType) ||
targetElements.some(element => hasStroke(element.type))) && (
<>
{actionManager.renderAction("changeStrokeWidth")}
{actionManager.renderAction("changeSloppiness")}
</>
)}
{(hasText(elementType) ||
targetElements.some(element => hasText(element.type))) && (
<>
{actionManager.renderAction("changeFontSize")}
{actionManager.renderAction("changeFontFamily")}
</>
)}
{actionManager.renderAction("changeOpacity")}
{actionManager.renderAction("deleteSelectedElements")}
</div>
</Island>
);
}
function renderShapesSwitcher() {
return (
<>
{SHAPES.map(({ value, icon }, index) => {
const label = t(`toolBar.${value}`);
return (
<ToolButton
key={value}
type="radio"
icon={icon}
checked={appState.elementType === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${
capitalizeString(value)[0]
}, ${index + 1}`}
keyBindingLabel={`${index + 1}`}
aria-label={capitalizeString(label)}
aria-keyshortcuts={`${label[0]} ${index + 1}`}
onChange={() => {
setAppState({ elementType: value, multiElement: null });
setElements(clearSelection(elements));
document.documentElement.style.cursor =
value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
setAppState({});
}}
></ToolButton>
);
})}
</>
);
}
return (
<FixedSideContainer side="top">
<div className="App-menu App-menu_top">
<Stack.Col gap={4} align="end">
<section
className="App-right-menu"
aria-labelledby="canvas-actions-title"
>
<h2 className="visually-hidden" id="canvas-actions-title">
{t("headings.canvasActions")}
</h2>
<Island padding={4}>{renderCanvasActions()}</Island>
</section>
<section
className="App-right-menu"
aria-labelledby="selected-shape-title"
>
<h2 className="visually-hidden" id="selected-shape-title">
{t("headings.selectedShapeActions")}
</h2>
{renderSelectedShapeActions(elements)}
</section>
</Stack.Col>
<section aria-labelledby="shapes-title">
<Stack.Col gap={4} align="start">
<Stack.Row gap={1}>
<Island padding={1}>
<h2 className="visually-hidden" id="shapes-title">
{t("headings.shapes")}
</h2>
<Stack.Row gap={1}>{renderShapesSwitcher()}</Stack.Row>
</Island>
<LockIcon
checked={appState.elementLocked}
onChange={() => {
setAppState({
elementLocked: !appState.elementLocked,
elementType: appState.elementLocked
? "selection"
: appState.elementType,
});
}}
title={t("toolBar.lock")}
/>
</Stack.Row>
</Stack.Col>
</section>
<div />
</div>
</FixedSideContainer>
);
},
(prev, next) => {
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
const {
draggingElement,
resizingElement,
multiElement,
editingElement,
isResizing,
cursorX,
cursorY,
...ret
} = appState;
return ret;
};
const prevAppState = getNecessaryObj(prev.appState);
const nextAppState = getNecessaryObj(next.appState);
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
return (
prev.elements === next.elements &&
keys.every(k => prevAppState[k] === nextAppState[k])
);
},
);
export class App extends React.Component<any, AppState> { export class App extends React.Component<any, AppState> {
canvas: HTMLCanvasElement | null = null; canvas: HTMLCanvasElement | null = null;
rc: RoughCanvas | null = null; rc: RoughCanvas | null = null;
actionManager: ActionManager = new ActionManager(); actionManager: ActionManager;
canvasOnlyActions: Array<Action>; canvasOnlyActions: Array<Action>;
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.actionManager = new ActionManager(
this.syncActionResult,
() => {
history.resumeRecording();
},
() => this.state,
() => elements,
);
this.actionManager.registerAction(actionFinalize); this.actionManager.registerAction(actionFinalize);
this.actionManager.registerAction(actionDeleteSelected); this.actionManager.registerAction(actionDeleteSelected);
this.actionManager.registerAction(actionSendToBack); this.actionManager.registerAction(actionSendToBack);
@ -233,6 +463,7 @@ export class App extends React.Component<any, AppState> {
} }
copyToAppClipboard(elements); copyToAppClipboard(elements);
elements = deleteSelectedElements(elements); elements = deleteSelectedElements(elements);
history.resumeRecording();
this.setState({}); this.setState({});
e.preventDefault(); e.preventDefault();
}; };
@ -279,6 +510,7 @@ export class App extends React.Component<any, AppState> {
element.isSelected = true; element.isSelected = true;
elements = [...clearSelection(elements), element]; elements = [...clearSelection(elements), element];
history.resumeRecording();
this.setState({}); this.setState({});
} }
e.preventDefault(); e.preventDefault();
@ -291,17 +523,6 @@ export class App extends React.Component<any, AppState> {
this.saveDebounced.flush(); this.saveDebounced.flush();
}; };
public shouldComponentUpdate(props: any, nextState: AppState) {
if (!history.isRecording()) {
// temporary hack to fix #592
// eslint-disable-next-line react/no-direct-mutation-state
this.state = nextState;
this.componentDidUpdate();
return false;
}
return true;
}
private async loadScene(id: string | null, k: string | undefined) { private async loadScene(id: string | null, k: string | undefined) {
let data; let data;
let selectedId; let selectedId;
@ -321,6 +542,7 @@ export class App extends React.Component<any, AppState> {
} }
if (data.appState) { if (data.appState) {
history.resumeRecording();
this.setState({ ...data.appState, selectedId }); this.setState({ ...data.appState, selectedId });
} else { } else {
this.setState({}); this.setState({});
@ -395,11 +617,7 @@ export class App extends React.Component<any, AppState> {
return; return;
} }
const actionResult = this.actionManager.handleKeyDown( const actionResult = this.actionManager.handleKeyDown(event);
event,
elements,
this.state,
);
if (actionResult) { if (actionResult) {
this.syncActionResult(actionResult); this.syncActionResult(actionResult);
@ -452,9 +670,10 @@ export class App extends React.Component<any, AppState> {
event.preventDefault(); event.preventDefault();
if ( if (
this.state.resizingElement ||
this.state.multiElement || this.state.multiElement ||
this.state.editingElement this.state.resizingElement ||
this.state.editingElement ||
this.state.draggingElement
) { ) {
return; return;
} }
@ -509,212 +728,14 @@ export class App extends React.Component<any, AppState> {
} }
}; };
private renderSelectedShapeActions(elements: readonly ExcalidrawElement[]) { setAppState = (obj: any) => {
const { elementType, editingElement } = this.state; this.setState(obj);
const targetElements = editingElement };
? [editingElement]
: elements.filter(el => el.isSelected);
if (!targetElements.length && elementType === "selection") {
return null;
}
return ( setElements = (elements_: readonly ExcalidrawElement[]) => {
<Island padding={4}> elements = elements_;
<div className="panelColumn"> this.setState({});
{this.actionManager.renderAction( };
"changeStrokeColor",
elements,
this.state,
this.syncActionResult,
)}
{(hasBackground(elementType) ||
targetElements.some(element => hasBackground(element.type))) && (
<>
{this.actionManager.renderAction(
"changeBackgroundColor",
elements,
this.state,
this.syncActionResult,
)}
{this.actionManager.renderAction(
"changeFillStyle",
elements,
this.state,
this.syncActionResult,
)}
</>
)}
{(hasStroke(elementType) ||
targetElements.some(element => hasStroke(element.type))) && (
<>
{this.actionManager.renderAction(
"changeStrokeWidth",
elements,
this.state,
this.syncActionResult,
)}
{this.actionManager.renderAction(
"changeSloppiness",
elements,
this.state,
this.syncActionResult,
)}
</>
)}
{(hasText(elementType) ||
targetElements.some(element => hasText(element.type))) && (
<>
{this.actionManager.renderAction(
"changeFontSize",
elements,
this.state,
this.syncActionResult,
)}
{this.actionManager.renderAction(
"changeFontFamily",
elements,
this.state,
this.syncActionResult,
)}
</>
)}
{this.actionManager.renderAction(
"changeOpacity",
elements,
this.state,
this.syncActionResult,
)}
{this.actionManager.renderAction(
"deleteSelectedElements",
elements,
this.state,
this.syncActionResult,
)}
</div>
</Island>
);
}
private renderShapesSwitcher() {
return (
<>
{SHAPES.map(({ value, icon }, index) => {
const label = t(`toolBar.${value}`);
return (
<ToolButton
key={value}
type="radio"
icon={icon}
checked={this.state.elementType === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${
capitalizeString(value)[0]
}, ${index + 1}`}
keyBindingLabel={`${index + 1}`}
aria-label={capitalizeString(label)}
aria-keyshortcuts={`${label[0]} ${index + 1}`}
onChange={() => {
this.setState({ elementType: value, multiElement: null });
elements = clearSelection(elements);
document.documentElement.style.cursor =
value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
this.setState({});
}}
></ToolButton>
);
})}
</>
);
}
private renderCanvasActions() {
return (
<Stack.Col gap={4}>
<Stack.Row justifyContent={"space-between"}>
{this.actionManager.renderAction(
"loadScene",
elements,
this.state,
this.syncActionResult,
)}
{this.actionManager.renderAction(
"saveScene",
elements,
this.state,
this.syncActionResult,
)}
<ExportDialog
elements={elements}
appState={this.state}
actionManager={this.actionManager}
syncActionResult={this.syncActionResult}
onExportToPng={(exportedElements, scale) => {
if (this.canvas) {
exportCanvas("png", exportedElements, this.canvas, {
exportBackground: this.state.exportBackground,
name: this.state.name,
viewBackgroundColor: this.state.viewBackgroundColor,
scale,
});
}
}}
onExportToSvg={(exportedElements, scale) => {
if (this.canvas) {
exportCanvas("svg", exportedElements, this.canvas, {
exportBackground: this.state.exportBackground,
name: this.state.name,
viewBackgroundColor: this.state.viewBackgroundColor,
scale,
});
}
}}
onExportToClipboard={(exportedElements, scale) => {
if (this.canvas) {
exportCanvas("clipboard", exportedElements, this.canvas, {
exportBackground: this.state.exportBackground,
name: this.state.name,
viewBackgroundColor: this.state.viewBackgroundColor,
scale,
});
}
}}
onExportToBackend={exportedElements => {
if (this.canvas) {
exportCanvas(
"backend",
exportedElements.map(element => ({
...element,
isSelected: false,
})),
this.canvas,
this.state,
);
}
}}
/>
{this.actionManager.renderAction(
"clearCanvas",
elements,
this.state,
this.syncActionResult,
)}
</Stack.Row>
{this.actionManager.renderAction(
"changeViewBackgroundColor",
elements,
this.state,
this.syncActionResult,
)}
</Stack.Col>
);
}
public render() { public render() {
const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT; const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
@ -722,55 +743,14 @@ export class App extends React.Component<any, AppState> {
return ( return (
<div className="container"> <div className="container">
<FixedSideContainer side="top"> <LayerUI
<div className="App-menu App-menu_top"> canvas={this.canvas}
<Stack.Col gap={4} align="end"> appState={this.state}
<section setAppState={this.setAppState}
className="App-right-menu" actionManager={this.actionManager}
aria-labelledby="canvas-actions-title" elements={elements}
> setElements={this.setElements}
<h2 className="visually-hidden" id="canvas-actions-title"> />
{t("headings.canvasActions")}
</h2>
<Island padding={4}>{this.renderCanvasActions()}</Island>
</section>
<section
className="App-right-menu"
aria-labelledby="selected-shape-title"
>
<h2 className="visually-hidden" id="selected-shape-title">
{t("headings.selectedShapeActions")}
</h2>
{this.renderSelectedShapeActions(elements)}
</section>
</Stack.Col>
<section aria-labelledby="shapes-title">
<Stack.Col gap={4} align="start">
<Stack.Row gap={1}>
<Island padding={1}>
<h2 className="visually-hidden" id="shapes-title">
{t("headings.shapes")}
</h2>
<Stack.Row gap={1}>{this.renderShapesSwitcher()}</Stack.Row>
</Island>
<LockIcon
checked={this.state.elementLocked}
onChange={() => {
this.setState({
elementLocked: !this.state.elementLocked,
elementType: this.state.elementLocked
? "selection"
: this.state.elementType,
});
}}
title={t("toolBar.lock")}
/>
</Stack.Row>
</Stack.Col>
</section>
<div />
</div>
</FixedSideContainer>
<main> <main>
<canvas <canvas
id="canvas" id="canvas"
@ -822,11 +802,8 @@ export class App extends React.Component<any, AppState> {
label: t("labels.paste"), label: t("labels.paste"),
action: () => this.pasteFromClipboard(), action: () => this.pasteFromClipboard(),
}, },
...this.actionManager.getContextMenuItems( ...this.actionManager.getContextMenuItems(action =>
elements, this.canvasOnlyActions.includes(action),
this.state,
this.syncActionResult,
action => this.canvasOnlyActions.includes(action),
), ),
], ],
top: e.clientY, top: e.clientY,
@ -852,9 +829,6 @@ export class App extends React.Component<any, AppState> {
action: () => this.pasteFromClipboard(), action: () => this.pasteFromClipboard(),
}, },
...this.actionManager.getContextMenuItems( ...this.actionManager.getContextMenuItems(
elements,
this.state,
this.syncActionResult,
action => !this.canvasOnlyActions.includes(action), action => !this.canvasOnlyActions.includes(action),
), ),
], ],
@ -889,8 +863,7 @@ export class App extends React.Component<any, AppState> {
const deltaY = lastY - e.clientY; const deltaY = lastY - e.clientY;
lastX = e.clientX; lastX = e.clientX;
lastY = e.clientY; lastY = e.clientY;
// We don't want to save history when panning around
history.skipRecording();
this.setState({ this.setState({
scrollX: this.state.scrollX - deltaX, scrollX: this.state.scrollX - deltaX,
scrollY: this.state.scrollY - deltaY, scrollY: this.state.scrollY - deltaY,
@ -1004,6 +977,7 @@ export class App extends React.Component<any, AppState> {
// state of the box // state of the box
if (!hitElement.isSelected) { if (!hitElement.isSelected) {
hitElement.isSelected = true; hitElement.isSelected = true;
elements = elements.slice();
elementIsAddedToSelection = true; elementIsAddedToSelection = true;
} }
@ -1074,6 +1048,7 @@ export class App extends React.Component<any, AppState> {
}, },
]; ];
} }
history.resumeRecording();
resetSelection(); resetSelection();
}, },
onCancel: () => { onCancel: () => {
@ -1104,6 +1079,11 @@ export class App extends React.Component<any, AppState> {
draggingElement: element, draggingElement: element,
}); });
} }
} else if (element.type === "selection") {
this.setState({
selectionElement: element,
draggingElement: element,
});
} else { } else {
elements = [...elements, element]; elements = [...elements, element];
this.setState({ multiElement: null, draggingElement: element }); this.setState({ multiElement: null, draggingElement: element });
@ -1138,7 +1118,6 @@ export class App extends React.Component<any, AppState> {
mouseY: number, mouseY: number,
perfect: boolean, perfect: boolean,
) => { ) => {
// TODO: Implement perfect sizing for origin
if (perfect) { if (perfect) {
const absPx = p1[0] + element.x; const absPx = p1[0] + element.x;
const absPy = p1[1] + element.y; const absPy = p1[1] + element.y;
@ -1195,8 +1174,6 @@ export class App extends React.Component<any, AppState> {
if (isOverHorizontalScrollBar) { if (isOverHorizontalScrollBar) {
const x = e.clientX - CANVAS_WINDOW_OFFSET_LEFT; const x = e.clientX - CANVAS_WINDOW_OFFSET_LEFT;
const dx = x - lastX; const dx = x - lastX;
// We don't want to save history when scrolling
history.skipRecording();
this.setState({ scrollX: this.state.scrollX - dx }); this.setState({ scrollX: this.state.scrollX - dx });
lastX = x; lastX = x;
return; return;
@ -1205,8 +1182,6 @@ export class App extends React.Component<any, AppState> {
if (isOverVerticalScrollBar) { if (isOverVerticalScrollBar) {
const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP; const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
const dy = y - lastY; const dy = y - lastY;
// We don't want to save history when scrolling
history.skipRecording();
this.setState({ scrollY: this.state.scrollY - dy }); this.setState({ scrollY: this.state.scrollY - dy });
lastY = y; lastY = y;
return; return;
@ -1444,8 +1419,6 @@ export class App extends React.Component<any, AppState> {
lastX = x; lastX = x;
lastY = y; lastY = y;
// We don't want to save history when resizing an element
history.skipRecording();
this.setState({}); this.setState({});
return; return;
} }
@ -1465,8 +1438,6 @@ export class App extends React.Component<any, AppState> {
}); });
lastX = x; lastX = x;
lastY = y; lastY = y;
// We don't want to save history when dragging an element to initially size it
history.skipRecording();
this.setState({}); this.setState({});
return; return;
} }
@ -1532,7 +1503,7 @@ export class App extends React.Component<any, AppState> {
draggingElement.shape = null; draggingElement.shape = null;
if (this.state.elementType === "selection") { if (this.state.elementType === "selection") {
if (!e.shiftKey) { if (!e.shiftKey && elements.some(el => el.isSelected)) {
elements = clearSelection(elements); elements = clearSelection(elements);
} }
const elementsWithinSelection = getElementsWithinSelection( const elementsWithinSelection = getElementsWithinSelection(
@ -1543,13 +1514,10 @@ export class App extends React.Component<any, AppState> {
element.isSelected = true; element.isSelected = true;
}); });
} }
// We don't want to save history when moving an element
history.skipRecording();
this.setState({}); this.setState({});
}; };
const onMouseUp = (e: MouseEvent) => { const onMouseUp = (e: MouseEvent) => {
this.setState({ isResizing: false });
const { const {
draggingElement, draggingElement,
resizingElement, resizingElement,
@ -1558,6 +1526,12 @@ export class App extends React.Component<any, AppState> {
elementLocked, elementLocked,
} = this.state; } = this.state;
this.setState({
isResizing: false,
resizingElement: null,
selectionElement: null,
});
resizeArrowFn = null; resizeArrowFn = null;
lastMouseUp = null; lastMouseUp = null;
isHoldingMouseButton = false; isHoldingMouseButton = false;
@ -1567,6 +1541,7 @@ export class App extends React.Component<any, AppState> {
if (elementType === "arrow" || elementType === "line") { if (elementType === "arrow" || elementType === "line") {
if (draggingElement!.points.length > 1) { if (draggingElement!.points.length > 1) {
history.resumeRecording(); history.resumeRecording();
this.setState({});
} }
if (!draggingOccurred && draggingElement && !multiElement) { if (!draggingOccurred && draggingElement && !multiElement) {
const { x, y } = viewportCoordsToSceneCoords(e, this.state); const { x, y } = viewportCoordsToSceneCoords(e, this.state);
@ -1603,6 +1578,11 @@ export class App extends React.Component<any, AppState> {
this.setState({}); this.setState({});
} }
if (resizingElement) {
history.resumeRecording();
this.setState({});
}
if ( if (
resizingElement && resizingElement &&
isInvisiblySmallElement(resizingElement) isInvisiblySmallElement(resizingElement)
@ -1640,15 +1620,19 @@ export class App extends React.Component<any, AppState> {
return; return;
} }
if (elementType === "selection") { if (!elementLocked) {
elements = elements.slice(0, -1);
} else if (!elementLocked) {
draggingElement.isSelected = true; draggingElement.isSelected = true;
} }
if (
elementType !== "selection" ||
elements.some(el => el.isSelected)
) {
history.resumeRecording();
}
if (!elementLocked) { if (!elementLocked) {
resetCursor(); resetCursor();
this.setState({ this.setState({
draggingElement: null, draggingElement: null,
elementType: "selection", elementType: "selection",
@ -1664,16 +1648,6 @@ export class App extends React.Component<any, AppState> {
window.addEventListener("mousemove", onMouseMove); window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp); window.addEventListener("mouseup", onMouseUp);
if (
!this.state.multiElement ||
(this.state.multiElement &&
this.state.multiElement.points.length < 2)
) {
// We don't want to save history on mouseDown, only on mouseUp when it's fully configured
history.skipRecording();
this.setState({});
}
}} }}
onDoubleClick={e => { onDoubleClick={e => {
const { x, y } = viewportCoordsToSceneCoords(e, this.state); const { x, y } = viewportCoordsToSceneCoords(e, this.state);
@ -1765,6 +1739,7 @@ export class App extends React.Component<any, AppState> {
}, },
]; ];
} }
history.resumeRecording();
resetSelection(); resetSelection();
}, },
onCancel: () => { onCancel: () => {
@ -1883,8 +1858,7 @@ export class App extends React.Component<any, AppState> {
private handleWheel = (e: WheelEvent) => { private handleWheel = (e: WheelEvent) => {
e.preventDefault(); e.preventDefault();
const { deltaX, deltaY } = e; const { deltaX, deltaY } = e;
// We don't want to save history when panning around
history.skipRecording();
this.setState({ this.setState({
scrollX: this.state.scrollX - deltaX, scrollX: this.state.scrollX - deltaX,
scrollY: this.state.scrollY - deltaY, scrollY: this.state.scrollY - deltaY,
@ -1918,6 +1892,7 @@ export class App extends React.Component<any, AppState> {
return duplicate; return duplicate;
}), }),
]; ];
history.resumeRecording();
this.setState({}); this.setState({});
}; };
@ -1960,6 +1935,7 @@ export class App extends React.Component<any, AppState> {
componentDidUpdate() { componentDidUpdate() {
const atLeastOneVisibleElement = renderScene( const atLeastOneVisibleElement = renderScene(
elements, elements,
this.state.selectionElement,
this.rc!, this.rc!,
this.canvas!, this.canvas!,
{ {
@ -1974,14 +1950,8 @@ export class App extends React.Component<any, AppState> {
} }
this.saveDebounced(); this.saveDebounced();
if (history.isRecording()) { if (history.isRecording()) {
history.pushEntry( history.pushEntry(this.state, elements);
history.generateCurrentEntry( history.skipRecording();
pickAppStatePropertiesForHistory(this.state),
elements,
),
);
} else {
history.resumeRecording();
} }
} }
} }

View File

@ -16,6 +16,7 @@ import { renderElement, renderElementToSvg } from "./renderElement";
export function renderScene( export function renderScene(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
selectionElement: ExcalidrawElement | null,
rc: RoughCanvas, rc: RoughCanvas,
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
sceneState: SceneState, sceneState: SceneState,
@ -86,6 +87,18 @@ export function renderScene(
); );
}); });
if (selectionElement) {
context.translate(
selectionElement.x + sceneState.scrollX,
selectionElement.y + sceneState.scrollY,
);
renderElement(selectionElement, rc, context);
context.translate(
-selectionElement.x - sceneState.scrollX,
-selectionElement.y - sceneState.scrollY,
);
}
if (renderSelection) { if (renderSelection) {
const selectedElements = elements.filter(el => el.isSelected); const selectedElements = elements.filter(el => el.isSelected);

View File

@ -437,7 +437,12 @@ export function saveToLocalStorage(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) { ) {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements)); localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify(
elements.map(({ shape, ...element }: ExcalidrawElement) => element),
),
);
localStorage.setItem( localStorage.setItem(
LOCAL_STORAGE_KEY_STATE, LOCAL_STORAGE_KEY_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)), JSON.stringify(clearAppStateForLocalStorage(appState)),

View File

@ -37,6 +37,7 @@ export function exportToCanvas(
renderScene( renderScene(
elements, elements,
null,
rough.canvas(tempCanvas), rough.canvas(tempCanvas),
tempCanvas, tempCanvas,
{ {

View File

@ -30,13 +30,15 @@ export function getElementsWithinSelection(
} }
export function clearSelection(elements: readonly ExcalidrawElement[]) { export function clearSelection(elements: readonly ExcalidrawElement[]) {
const newElements = [...elements]; let someWasSelected = false;
elements.forEach(element => {
newElements.forEach(element => { if (element.isSelected) {
element.isSelected = false; someWasSelected = true;
element.isSelected = false;
}
}); });
return newElements; return someWasSelected ? elements.slice() : elements;
} }
export function deleteSelectedElements(elements: readonly ExcalidrawElement[]) { export function deleteSelectedElements(elements: readonly ExcalidrawElement[]) {

View File

@ -4,6 +4,7 @@ export type AppState = {
draggingElement: ExcalidrawElement | null; draggingElement: ExcalidrawElement | null;
resizingElement: ExcalidrawElement | null; resizingElement: ExcalidrawElement | null;
multiElement: ExcalidrawElement | null; multiElement: ExcalidrawElement | null;
selectionElement: ExcalidrawElement | null;
// element being edited, but not necessarily added to elements array yet // element being edited, but not necessarily added to elements array yet
// (e.g. text element when typing into the input) // (e.g. text element when typing into the input)
editingElement: ExcalidrawElement | null; editingElement: ExcalidrawElement | null;