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:
parent
972d69da6c
commit
33016bf6bf
@ -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: [],
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
|
@ -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",
|
||||||
};
|
};
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
646
src/index.tsx
646
src/index.tsx
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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)),
|
||||||
|
@ -37,6 +37,7 @@ export function exportToCanvas(
|
|||||||
|
|
||||||
renderScene(
|
renderScene(
|
||||||
elements,
|
elements,
|
||||||
|
null,
|
||||||
rough.canvas(tempCanvas),
|
rough.canvas(tempCanvas),
|
||||||
tempCanvas,
|
tempCanvas,
|
||||||
{
|
{
|
||||||
|
@ -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[]) {
|
||||||
|
@ -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;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user