From c6a0cfc2b164c6e3d723d0ced856ca6062b6b983 Mon Sep 17 00:00:00 2001 From: Jed Fox Date: Sat, 7 Mar 2020 10:20:38 -0500 Subject: [PATCH] Refactor (#862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial factoring out of parts of the LayerUI component 2360 → 2224 LOC * Create a Section component * Break up src/index.tsx * Refactor actions to reduce duplication, fix CSS Also consolidate icons * Move scene/data.ts to its own directory * Fix accidental reverts, banish further single-character variables * ACTIVE_ELEM_COLOR → ACTIVE_ELEMENT_COLOR * Further refactoring the icons file * Log all errors * Pointer Event polyfill to make the tests work * add test hooks & fix tests Co-authored-by: dwelle --- package-lock.json | 6 + package.json | 4 +- src/actions/actionCanvas.tsx | 28 +- src/actions/actionDeleteSelected.tsx | 6 +- src/actions/actionExport.tsx | 24 +- src/actions/actionFinalize.tsx | 6 +- src/actions/actionHistory.tsx | 57 +- src/actions/actionMenu.tsx | 10 +- src/actions/actionProperties.tsx | 34 +- src/actions/actionSelectAll.ts | 6 +- src/actions/actionStyles.ts | 10 +- src/actions/actionZindex.tsx | 103 +- src/actions/index.ts | 1 - src/actions/manager.tsx | 8 +- src/actions/register.ts | 8 + src/clipboard.ts | 10 +- src/components/Actions.tsx | 125 ++ src/components/App.tsx | 1874 +++++++++++++++++++ src/components/ExportDialog.tsx | 4 +- src/components/LanguageList.tsx | 14 +- src/components/LayerUI.tsx | 233 +++ src/components/MobileMenu.tsx | 120 ++ src/components/Section.tsx | 27 + src/components/TopErrorBoundary.tsx | 152 ++ src/components/icons.tsx | 107 +- src/constants.ts | 15 + src/data/blob.ts | 47 + src/data/index.ts | 239 +++ src/data/json.ts | 48 + src/data/localStorage.ts | 52 + src/data/restore.ts | 75 + src/data/types.ts | 11 + src/element/collision.ts | 2 +- src/element/newElement.ts | 8 +- src/gesture.ts | 2 +- src/index.tsx | 2509 +------------------------- src/scene/data.ts | 473 ----- src/scene/export.ts | 2 +- src/scene/index.ts | 14 +- src/scene/scroll.ts | 21 + src/scene/selection.ts | 7 + src/tests/dragCreate.test.tsx | 117 +- src/tests/move.test.tsx | 44 +- src/tests/multiPointCreate.test.tsx | 43 +- src/tests/queries/toolQueries.ts | 4 +- src/tests/resize.test.tsx | 50 +- src/tests/selection.test.tsx | 55 +- src/tests/test-utils.ts | 2 + src/utils.ts | 53 +- 49 files changed, 3498 insertions(+), 3372 deletions(-) create mode 100644 src/actions/register.ts create mode 100644 src/components/Actions.tsx create mode 100644 src/components/App.tsx create mode 100644 src/components/LayerUI.tsx create mode 100644 src/components/MobileMenu.tsx create mode 100644 src/components/Section.tsx create mode 100644 src/components/TopErrorBoundary.tsx create mode 100644 src/constants.ts create mode 100644 src/data/blob.ts create mode 100644 src/data/index.ts create mode 100644 src/data/json.ts create mode 100644 src/data/localStorage.ts create mode 100644 src/data/restore.ts create mode 100644 src/data/types.ts delete mode 100644 src/scene/data.ts create mode 100644 src/scene/scroll.ts diff --git a/package-lock.json b/package-lock.json index 4eefdc3b..34c3867a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10744,6 +10744,12 @@ "sha.js": "^2.4.8" } }, + "pepjs": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/pepjs/-/pepjs-0.5.2.tgz", + "integrity": "sha512-acPfplXnTKaG8p7VBgkNaBJSGsZu7LYrqEmLCQFoeHYl21B74mMBeVoQA/Gl9u5GmysgzrOCeHsEjw0mXo61nw==", + "dev": true + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", diff --git a/package.json b/package.json index 27f41f62..d0e8190f 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,13 @@ "stacktrace-js": "2.0.2" }, "devDependencies": { - "asar": "2.1.0", "@testing-library/jest-dom": "5.1.1", "@testing-library/react": "9.4.1", "@types/jest": "25.1.3", "@types/nanoid": "2.1.0", "@types/react": "16.9.23", "@types/react-dom": "16.9.5", + "asar": "2.1.0", "eslint": "6.8.0", "eslint-config-prettier": "6.10.0", "eslint-plugin-prettier": "3.1.2", @@ -30,6 +30,7 @@ "jest-canvas-mock": "2.2.0", "lint-staged": "10.0.8", "node-sass": "4.13.1", + "pepjs": "0.5.2", "prettier": "1.19.1", "rewire": "4.0.1", "typescript": "3.8.3" @@ -92,6 +93,7 @@ "start": "react-scripts start", "test": "npm run test:app", "test:app": "react-scripts test --env=jsdom --passWithNoTests", + "test:debug": "react-scripts --inspect-brk test --runInBand --no-cache", "test:code": "eslint --ignore-path .gitignore --ext .js,.ts,.tsx .", "test:other": "npm run prettier -- --list-different" }, diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index dc7cec93..d1671eb1 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Action } from "./types"; import { ColorPicker } from "../components/ColorPicker"; import { getDefaultAppState } from "../appState"; import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons"; @@ -8,8 +7,9 @@ import { t } from "../i18n"; import { getNormalizedZoom } from "../scene"; import { KEYS } from "../keys"; import useIsMobile from "../is-mobile"; +import { register } from "./register"; -export const actionChangeViewBackgroundColor: Action = { +export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", perform: (_, appState, value) => { return { appState: { ...appState, viewBackgroundColor: value } }; @@ -27,9 +27,9 @@ export const actionChangeViewBackgroundColor: Action = { ); }, commitToHistory: () => true, -}; +}); -export const actionClearCanvas: Action = { +export const actionClearCanvas = register({ name: "clearCanvas", commitToHistory: () => true, perform: () => { @@ -56,7 +56,7 @@ export const actionClearCanvas: Action = { }} /> ), -}; +}); const ZOOM_STEP = 0.1; @@ -69,9 +69,9 @@ const KEY_CODES = { NUM_ZERO: "Numpad0", }; -export const actionZoomIn: Action = { +export const actionZoomIn = register({ name: "zoomIn", - perform: (elements, appState) => { + perform: (_elements, appState) => { return { appState: { ...appState, @@ -93,11 +93,11 @@ export const actionZoomIn: Action = { keyTest: event => (event.code === KEY_CODES.EQUAL || event.code === KEY_CODES.NUM_ADD) && (event[KEYS.META] || event.shiftKey), -}; +}); -export const actionZoomOut: Action = { +export const actionZoomOut = register({ name: "zoomOut", - perform: (elements, appState) => { + perform: (_elements, appState) => { return { appState: { ...appState, @@ -119,11 +119,11 @@ export const actionZoomOut: Action = { keyTest: event => (event.code === KEY_CODES.MINUS || event.code === KEY_CODES.NUM_SUBTRACT) && (event[KEYS.META] || event.shiftKey), -}; +}); -export const actionResetZoom: Action = { +export const actionResetZoom = register({ name: "resetZoom", - perform: (elements, appState) => { + perform: (_elements, appState) => { return { appState: { ...appState, @@ -145,4 +145,4 @@ export const actionResetZoom: Action = { keyTest: event => (event.code === KEY_CODES.ZERO || event.code === KEY_CODES.NUM_ZERO) && (event[KEYS.META] || event.shiftKey), -}; +}); diff --git a/src/actions/actionDeleteSelected.tsx b/src/actions/actionDeleteSelected.tsx index 6bc15572..115b30af 100644 --- a/src/actions/actionDeleteSelected.tsx +++ b/src/actions/actionDeleteSelected.tsx @@ -1,12 +1,12 @@ -import { Action } from "./types"; import { deleteSelectedElements, isSomeElementSelected } from "../scene"; import { KEYS } from "../keys"; import { ToolButton } from "../components/ToolButton"; import React from "react"; import { trash } from "../components/icons"; import { t } from "../i18n"; +import { register } from "./register"; -export const actionDeleteSelected: Action = { +export const actionDeleteSelected = register({ name: "deleteSelectedElements", perform: (elements, appState) => { return { @@ -28,4 +28,4 @@ export const actionDeleteSelected: Action = { visible={isSomeElementSelected(elements)} /> ), -}; +}); diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index 531dd2f8..59c4d3db 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -1,15 +1,15 @@ import React from "react"; -import { Action } from "./types"; import { ProjectName } from "../components/ProjectName"; -import { saveAsJSON, loadFromJSON } from "../scene"; +import { saveAsJSON, loadFromJSON } from "../data"; import { load, save } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; import { t } from "../i18n"; import useIsMobile from "../is-mobile"; +import { register } from "./register"; -export const actionChangeProjectName: Action = { +export const actionChangeProjectName = register({ name: "changeProjectName", - perform: (elements, appState, value) => { + perform: (_elements, appState, value) => { return { appState: { ...appState, name: value } }; }, PanelComponent: ({ appState, updateData }) => ( @@ -19,11 +19,11 @@ export const actionChangeProjectName: Action = { onChange={(name: string) => updateData(name)} /> ), -}; +}); -export const actionChangeExportBackground: Action = { +export const actionChangeExportBackground = register({ name: "changeExportBackground", - perform: (elements, appState, value) => { + perform: (_elements, appState, value) => { return { appState: { ...appState, exportBackground: value } }; }, PanelComponent: ({ appState, updateData }) => ( @@ -36,9 +36,9 @@ export const actionChangeExportBackground: Action = { {t("labels.withBackground")} ), -}; +}); -export const actionSaveScene: Action = { +export const actionSaveScene = register({ name: "saveScene", perform: (elements, appState, value) => { saveAsJSON(elements, appState).catch(error => console.error(error)); @@ -54,9 +54,9 @@ export const actionSaveScene: Action = { onClick={() => updateData(null)} /> ), -}; +}); -export const actionLoadScene: Action = { +export const actionLoadScene = register({ name: "loadScene", perform: ( elements, @@ -81,4 +81,4 @@ export const actionLoadScene: Action = { }} /> ), -}; +}); diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index 8092b191..fe298212 100644 --- a/src/actions/actionFinalize.tsx +++ b/src/actions/actionFinalize.tsx @@ -1,4 +1,3 @@ -import { Action } from "./types"; import { KEYS } from "../keys"; import { clearSelection } from "../scene"; import { isInvisiblySmallElement } from "../element"; @@ -7,8 +6,9 @@ import React from "react"; import { ToolButton } from "../components/ToolButton"; import { done } from "../components/icons"; import { t } from "../i18n"; +import { register } from "./register"; -export const actionFinalize: Action = { +export const actionFinalize = register({ name: "finalize", perform: (elements, appState) => { let newElements = clearSelection(elements); @@ -63,4 +63,4 @@ export const actionFinalize: Action = { visible={appState.multiElement != null} /> ), -}; +}); diff --git a/src/actions/actionHistory.tsx b/src/actions/actionHistory.tsx index 9c05e79c..6c78634f 100644 --- a/src/actions/actionHistory.tsx +++ b/src/actions/actionHistory.tsx @@ -10,33 +10,36 @@ import { KEYS } from "../keys"; const writeData = ( appState: AppState, - data: { elements: ExcalidrawElement[]; appState: AppState } | null, + updater: () => { elements: ExcalidrawElement[]; appState: AppState } | null, ) => { - if (data !== null) { - return { - elements: data.elements, - appState: { ...appState, ...data.appState }, - }; - } - return {}; -}; - -const testUndo = (shift: boolean) => ( - event: KeyboardEvent, - appState: AppState, -) => event[KEYS.META] && /z/i.test(event.key) && event.shiftKey === shift; - -export const createUndoAction: (h: SceneHistory) => Action = history => ({ - name: "undo", - perform: (_, appState) => + if ( [ appState.multiElement, appState.resizingElement, appState.editingElement, appState.draggingElement, - ].every(x => x === null) - ? writeData(appState, history.undoOnce()) - : {}, + ].some(Boolean) + ) { + const data = updater(); + + return data === null + ? {} + : { + elements: data.elements, + appState: { ...appState, ...data.appState }, + }; + } + return {}; +}; + +const testUndo = (shift: boolean) => (event: KeyboardEvent) => + event[KEYS.META] && /z/i.test(event.key) && event.shiftKey === shift; + +type ActionCreator = (history: SceneHistory) => Action; + +export const createUndoAction: ActionCreator = history => ({ + name: "undo", + perform: (_, appState) => writeData(appState, () => history.undoOnce()), keyTest: testUndo(false), PanelComponent: ({ updateData }) => ( Action = history => ({ commitToHistory: () => false, }); -export const createRedoAction: (h: SceneHistory) => Action = history => ({ +export const createRedoAction: ActionCreator = history => ({ name: "redo", - perform: (_, appState) => - [ - appState.multiElement, - appState.resizingElement, - appState.editingElement, - appState.draggingElement, - ].every(x => x === null) - ? writeData(appState, history.redoOnce()) - : {}, + perform: (_, appState) => writeData(appState, () => history.redoOnce()), keyTest: testUndo(true), PanelComponent: ({ updateData }) => ( ({ appState: { @@ -22,9 +22,9 @@ export const actionToggleCanvasMenu: Action = { selected={appState.openMenu === "canvas"} /> ), -}; +}); -export const actionToggleEditMenu: Action = { +export const actionToggleEditMenu = register({ name: "toggleEditMenu", perform: (_elements, appState) => ({ appState: { @@ -42,4 +42,4 @@ export const actionToggleEditMenu: Action = { selected={appState.openMenu === "shape"} /> ), -}; +}); diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index b012308c..8cd767a3 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Action } from "./types"; import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { getCommonAttributeOfSelectedElements, @@ -11,6 +10,7 @@ import { ColorPicker } from "../components/ColorPicker"; import { AppState } from "../../src/types"; import { t } from "../i18n"; import { DEFAULT_FONT } from "../appState"; +import { register } from "./register"; const changeProperty = ( elements: readonly ExcalidrawElement[], @@ -39,7 +39,7 @@ const getFormValue = function( ); }; -export const actionChangeStrokeColor: Action = { +export const actionChangeStrokeColor = register({ name: "changeStrokeColor", perform: (elements, appState, value) => { return { @@ -68,9 +68,9 @@ export const actionChangeStrokeColor: Action = { /> ), -}; +}); -export const actionChangeBackgroundColor: Action = { +export const actionChangeBackgroundColor = register({ name: "changeBackgroundColor", perform: (elements, appState, value) => { return { @@ -99,9 +99,9 @@ export const actionChangeBackgroundColor: Action = { /> ), -}; +}); -export const actionChangeFillStyle: Action = { +export const actionChangeFillStyle = register({ name: "changeFillStyle", perform: (elements, appState, value) => { return { @@ -136,9 +136,9 @@ export const actionChangeFillStyle: Action = { /> ), -}; +}); -export const actionChangeStrokeWidth: Action = { +export const actionChangeStrokeWidth = register({ name: "changeStrokeWidth", perform: (elements, appState, value) => { return { @@ -171,9 +171,9 @@ export const actionChangeStrokeWidth: Action = { /> ), -}; +}); -export const actionChangeSloppiness: Action = { +export const actionChangeSloppiness = register({ name: "changeSloppiness", perform: (elements, appState, value) => { return { @@ -206,9 +206,9 @@ export const actionChangeSloppiness: Action = { /> ), -}; +}); -export const actionChangeOpacity: Action = { +export const actionChangeOpacity = register({ name: "changeOpacity", perform: (elements, appState, value) => { return { @@ -255,9 +255,9 @@ export const actionChangeOpacity: Action = { /> ), -}; +}); -export const actionChangeFontSize: Action = { +export const actionChangeFontSize = register({ name: "changeFontSize", perform: (elements, appState, value) => { return { @@ -304,9 +304,9 @@ export const actionChangeFontSize: Action = { /> ), -}; +}); -export const actionChangeFontFamily: Action = { +export const actionChangeFontFamily = register({ name: "changeFontFamily", perform: (elements, appState, value) => { return { @@ -352,4 +352,4 @@ export const actionChangeFontFamily: Action = { /> ), -}; +}); diff --git a/src/actions/actionSelectAll.ts b/src/actions/actionSelectAll.ts index 86850cd9..fd0c0b39 100644 --- a/src/actions/actionSelectAll.ts +++ b/src/actions/actionSelectAll.ts @@ -1,7 +1,7 @@ -import { Action } from "./types"; import { KEYS } from "../keys"; +import { register } from "./register"; -export const actionSelectAll: Action = { +export const actionSelectAll = register({ name: "selectAll", perform: elements => { return { @@ -10,4 +10,4 @@ export const actionSelectAll: Action = { }, contextItemLabel: "labels.selectAll", keyTest: event => event[KEYS.META] && event.key === "a", -}; +}); diff --git a/src/actions/actionStyles.ts b/src/actions/actionStyles.ts index 8c7038cd..411163bc 100644 --- a/src/actions/actionStyles.ts +++ b/src/actions/actionStyles.ts @@ -1,4 +1,3 @@ -import { Action } from "./types"; import { isTextElement, isExcalidrawElement, @@ -6,10 +5,11 @@ import { } from "../element"; import { KEYS } from "../keys"; import { DEFAULT_FONT } from "../appState"; +import { register } from "./register"; let copiedStyles: string = "{}"; -export const actionCopyStyles: Action = { +export const actionCopyStyles = register({ name: "copyStyles", perform: elements => { const element = elements.find(el => el.isSelected); @@ -21,9 +21,9 @@ export const actionCopyStyles: Action = { contextItemLabel: "labels.copyStyles", keyTest: event => event[KEYS.META] && event.shiftKey && event.key === "C", contextMenuOrder: 0, -}; +}); -export const actionPasteStyles: Action = { +export const actionPasteStyles = register({ name: "pasteStyles", perform: elements => { const pastedElement = JSON.parse(copiedStyles); @@ -57,4 +57,4 @@ export const actionPasteStyles: Action = { contextItemLabel: "labels.pasteStyles", keyTest: event => event[KEYS.META] && event.shiftKey && event.key === "V", contextMenuOrder: 1, -}; +}); diff --git a/src/actions/actionZindex.tsx b/src/actions/actionZindex.tsx index b957b650..f5b743dc 100644 --- a/src/actions/actionZindex.tsx +++ b/src/actions/actionZindex.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Action } from "./types"; import { moveOneLeft, moveOneRight, @@ -9,73 +8,15 @@ import { import { getSelectedIndices } from "../scene"; import { KEYS } from "../keys"; import { t } from "../i18n"; +import { register } from "./register"; +import { + sendBackward, + bringToFront, + sendToBack, + bringForward, +} from "../components/icons"; -const ACTIVE_ELEM_COLOR = "#ffa94d"; // OC ORANGE 4 - -const ICONS = { - bringForward: ( - - - - - ), - sendBackward: ( - - - - - ), - bringToFront: ( - - - - - ), - sendToBack: ( - - - - - ), -}; - -export const actionSendBackward: Action = { +export const actionSendBackward = register({ name: "sendBackward", perform: (elements, appState) => { return { @@ -91,15 +32,15 @@ export const actionSendBackward: Action = { ), -}; +}); -export const actionBringForward: Action = { +export const actionBringForward = register({ name: "bringForward", perform: (elements, appState) => { return { @@ -115,15 +56,15 @@ export const actionBringForward: Action = { ), -}; +}); -export const actionSendToBack: Action = { +export const actionSendToBack = register({ name: "sendToBack", perform: (elements, appState) => { return { @@ -138,15 +79,15 @@ export const actionSendToBack: Action = { ), -}; +}); -export const actionBringToFront: Action = { +export const actionBringToFront = register({ name: "bringToFront", perform: (elements, appState) => { return { @@ -164,7 +105,7 @@ export const actionBringToFront: Action = { onClick={event => updateData(null)} title={t("labels.bringToFront")} > - {ICONS.bringToFront} + {bringToFront} ), -}; +}); diff --git a/src/actions/index.ts b/src/actions/index.ts index e366adeb..f6d0e0ee 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,4 +1,3 @@ -export { ActionManager } from "./manager"; export { actionDeleteSelected } from "./actionDeleteSelected"; export { actionBringForward, diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index e9cb310e..52a9872c 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -32,6 +32,10 @@ export class ActionManager implements ActionsManagerInterface { this.actions[action.name] = action; } + registerAll(actions: readonly Action[]) { + actions.forEach(action => this.registerAction(action)); + } + handleKeyDown(event: KeyboardEvent) { const data = Object.values(this.actions) .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0)) @@ -79,7 +83,7 @@ export class ActionManager implements ActionsManagerInterface { })); } - renderAction(name: string) { + renderAction = (name: string) => { if (this.actions[name] && "PanelComponent" in this.actions[name]) { const action = this.actions[name]; const PanelComponent = action.PanelComponent!; @@ -103,5 +107,5 @@ export class ActionManager implements ActionsManagerInterface { } return null; - } + }; } diff --git a/src/actions/register.ts b/src/actions/register.ts new file mode 100644 index 00000000..1eeb99c3 --- /dev/null +++ b/src/actions/register.ts @@ -0,0 +1,8 @@ +import { Action } from "./types"; + +export let actions: readonly Action[] = []; + +export function register(action: Action): Action { + actions = actions.concat(action); + return action; +} diff --git a/src/clipboard.ts b/src/clipboard.ts index e1448b44..48d74378 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -28,7 +28,7 @@ export async function copyToAppClipboard( // copied elements, and thus we should prefer the text content. await copyTextToSystemClipboard(null); PREFER_APP_CLIPBOARD = false; - } catch (error) { + } catch { // if clearing system clipboard didn't work, we should prefer in-app // clipboard even if there's text in system clipboard on paste, because // we can't be sure of the order of copy operations @@ -105,7 +105,9 @@ export async function copyTextToSystemClipboard(text: string | null) { // not focused await navigator.clipboard.writeText(text || ""); copied = true; - } catch (error) {} + } catch (error) { + console.error(error); + } } // Note that execCommand doesn't allow copying empty strings, so if we're @@ -143,7 +145,9 @@ function copyTextViaExecCommand(text: string) { textarea.setSelectionRange(0, textarea.value.length); success = document.execCommand("copy"); - } catch (error) {} + } catch (error) { + console.error(error); + } textarea.remove(); diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx new file mode 100644 index 00000000..79f98cf4 --- /dev/null +++ b/src/components/Actions.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import { ExcalidrawElement } from "../element/types"; +import { ActionManager } from "../actions/manager"; +import { hasBackground, hasStroke, hasText, clearSelection } from "../scene"; +import { t } from "../i18n"; +import { SHAPES } from "../shapes"; +import { ToolButton } from "./ToolButton"; +import { capitalizeString } from "../utils"; +import { CURSOR_TYPE } from "../constants"; +import Stack from "./Stack"; + +export function SelectedShapeActions({ + targetElements, + renderAction, + elementType, +}: { + targetElements: readonly ExcalidrawElement[]; + renderAction: ActionManager["renderAction"]; + elementType: ExcalidrawElement["type"]; +}) { + return ( +
+ {renderAction("changeStrokeColor")} + {(hasBackground(elementType) || + targetElements.some(element => hasBackground(element.type))) && ( + <> + {renderAction("changeBackgroundColor")} + + {renderAction("changeFillStyle")} + + )} + + {(hasStroke(elementType) || + targetElements.some(element => hasStroke(element.type))) && ( + <> + {renderAction("changeStrokeWidth")} + + {renderAction("changeSloppiness")} + + )} + + {(hasText(elementType) || + targetElements.some(element => hasText(element.type))) && ( + <> + {renderAction("changeFontSize")} + + {renderAction("changeFontFamily")} + + )} + + {renderAction("changeOpacity")} + +
+ {t("labels.layers")} +
+ {renderAction("sendToBack")} + {renderAction("sendBackward")} + {renderAction("bringToFront")} + {renderAction("bringForward")} +
+
+
+ ); +} + +export function ShapesSwitcher({ + elementType, + setAppState, + setElements, + elements, +}: { + elementType: ExcalidrawElement["type"]; + setAppState: any; + setElements: any; + elements: readonly ExcalidrawElement[]; +}) { + return ( + <> + {SHAPES.map(({ value, icon }, index) => { + const label = t(`toolBar.${value}`); + return ( + { + setAppState({ elementType: value, multiElement: null }); + setElements(clearSelection(elements)); + document.documentElement.style.cursor = + value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR; + setAppState({}); + }} + > + ); + })} + + ); +} + +export function ZoomActions({ + renderAction, + zoom, +}: { + renderAction: ActionManager["renderAction"]; + zoom: number; +}) { + return ( + + + {renderAction("zoomIn")} + {renderAction("zoomOut")} + {renderAction("resetZoom")} +
{(zoom * 100).toFixed(0)}%
+
+
+ ); +} diff --git a/src/components/App.tsx b/src/components/App.tsx new file mode 100644 index 00000000..e324dcc1 --- /dev/null +++ b/src/components/App.tsx @@ -0,0 +1,1874 @@ +import React from "react"; + +import rough from "roughjs/bin/rough"; +import { RoughCanvas } from "roughjs/bin/canvas"; +import { Point } from "roughjs/bin/geometry"; + +import { + newElement, + newTextElement, + duplicateElement, + resizeTest, + normalizeResizeHandle, + isInvisiblySmallElement, + isTextElement, + textWysiwyg, + getCommonBounds, + getCursorForResizingElement, + getPerfectElementSize, + normalizeDimensions, +} from "../element"; +import { + clearSelection, + deleteSelectedElements, + getElementsWithinSelection, + isOverScrollBars, + getElementAtPosition, + createScene, + getElementContainingPosition, + getNormalizedZoom, + getSelectedElements, + isSomeElementSelected, +} from "../scene"; +import { saveToLocalStorage, loadScene, loadFromBlob } from "../data"; + +import { renderScene } from "../renderer"; +import { AppState, GestureEvent, Gesture } from "../types"; +import { ExcalidrawElement } from "../element/types"; + +import { + isWritableElement, + isInputLike, + isToolIcon, + debounce, + distance, + distance2d, + resetCursor, + viewportCoordsToSceneCoords, + sceneCoordsToViewportCoords, +} from "../utils"; +import { KEYS, isArrowKey } from "../keys"; + +import { findShapeByKey, shapesShortcutKeys } from "../shapes"; +import { createHistory } from "../history"; + +import ContextMenu from "./ContextMenu"; + +import { getElementWithResizeHandler } from "../element/resizeTest"; +import { ActionManager } from "../actions/manager"; +import "../actions"; +import { actions } from "../actions/register"; + +import { ActionResult } from "../actions/types"; +import { getDefaultAppState } from "../appState"; +import { t, getLanguage } from "../i18n"; + +import { copyToAppClipboard, getClipboardContent } from "../clipboard"; +import { normalizeScroll } from "../scene"; +import { getCenter, getDistance } from "../gesture"; +import { createUndoAction, createRedoAction } from "../actions/actionHistory"; +import { + CURSOR_TYPE, + ELEMENT_SHIFT_TRANSLATE_AMOUNT, + ELEMENT_TRANSLATE_AMOUNT, + POINTER_BUTTON, + DRAGGING_THRESHOLD, + TEXT_TO_CENTER_SNAP_THRESHOLD, +} from "../constants"; +import { LayerUI } from "./LayerUI"; +import { ScrollBars } from "../scene/types"; + +// ----------------------------------------------------------------------------- +// TEST HOOKS +// ----------------------------------------------------------------------------- + +declare global { + interface Window { + __TEST__: { + elements: typeof elements; + appState: AppState; + }; + } +} + +if (process.env.NODE_ENV === "test") { + window.__TEST__ = {} as Window["__TEST__"]; +} + +// ----------------------------------------------------------------------------- + +let { elements } = createScene(); + +if (process.env.NODE_ENV === "test") { + Object.defineProperty(window.__TEST__, "elements", { + get() { + return elements; + }, + }); +} + +const { history } = createHistory(); + +let cursorX = 0; +let cursorY = 0; +let isHoldingSpace: boolean = false; +let isPanning: boolean = false; +let isDraggingScrollBar: boolean = false; +let currentScrollBars: ScrollBars = { horizontal: null, vertical: null }; + +let lastPointerUp: ((event: any) => void) | null = null; +const gesture: Gesture = { + pointers: [], + lastCenter: null, + initialDistance: null, + initialScale: null, +}; + +function setCursorForShape(shape: string) { + if (shape === "selection") { + resetCursor(); + } else { + document.documentElement.style.cursor = + shape === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR; + } +} + +export class App extends React.Component { + canvas: HTMLCanvasElement | null = null; + rc: RoughCanvas | null = null; + + actionManager: ActionManager; + canvasOnlyActions = ["selectAll"]; + constructor(props: any) { + super(props); + this.actionManager = new ActionManager( + this.syncActionResult, + () => this.state, + () => elements, + ); + this.actionManager.registerAll(actions); + + this.actionManager.registerAction(createUndoAction(history)); + this.actionManager.registerAction(createRedoAction(history)); + } + + private syncActionResult = ( + res: ActionResult, + commitToHistory: boolean = true, + ) => { + if (this.unmounted) { + return; + } + if (res.elements) { + elements = res.elements; + if (commitToHistory) { + history.resumeRecording(); + } + this.setState({}); + } + + if (res.appState) { + if (commitToHistory) { + history.resumeRecording(); + } + this.setState({ ...res.appState }); + } + }; + + private onCut = (event: ClipboardEvent) => { + if (isWritableElement(event.target)) { + return; + } + copyToAppClipboard(elements); + elements = deleteSelectedElements(elements); + history.resumeRecording(); + this.setState({}); + event.preventDefault(); + }; + private onCopy = (event: ClipboardEvent) => { + if (isWritableElement(event.target)) { + return; + } + copyToAppClipboard(elements); + event.preventDefault(); + }; + + private onUnload = () => { + isHoldingSpace = false; + this.saveDebounced(); + this.saveDebounced.flush(); + }; + + private disableEvent: EventHandlerNonNull = event => { + event.preventDefault(); + }; + + private unmounted = false; + public async componentDidMount() { + if (process.env.NODE_ENV === "test") { + Object.defineProperty(window.__TEST__, "appState", { + configurable: true, + get: () => { + return this.state; + }, + }); + } + + document.addEventListener("copy", this.onCopy); + document.addEventListener("paste", this.pasteFromClipboard); + document.addEventListener("cut", this.onCut); + + document.addEventListener("keydown", this.onKeyDown, false); + document.addEventListener("keyup", this.onKeyUp, { passive: true }); + document.addEventListener("mousemove", this.updateCurrentCursorPosition); + window.addEventListener("resize", this.onResize, false); + window.addEventListener("unload", this.onUnload, false); + window.addEventListener("blur", this.onUnload, false); + window.addEventListener("dragover", this.disableEvent, false); + window.addEventListener("drop", this.disableEvent, false); + + // Safari-only desktop pinch zoom + document.addEventListener( + "gesturestart", + this.onGestureStart as any, + false, + ); + document.addEventListener( + "gesturechange", + this.onGestureChange as any, + false, + ); + document.addEventListener("gestureend", this.onGestureEnd as any, false); + + const searchParams = new URLSearchParams(window.location.search); + const id = searchParams.get("id"); + + if (id) { + // Backwards compatibility with legacy url format + const scene = await loadScene(id); + this.syncActionResult(scene); + } else { + const match = window.location.hash.match( + /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/, + ); + if (match) { + const scene = await loadScene(match[1], match[2]); + this.syncActionResult(scene); + } else { + const scene = await loadScene(null); + this.syncActionResult(scene); + } + } + } + + public componentWillUnmount() { + this.unmounted = true; + document.removeEventListener("copy", this.onCopy); + document.removeEventListener("paste", this.pasteFromClipboard); + document.removeEventListener("cut", this.onCut); + + document.removeEventListener("keydown", this.onKeyDown, false); + document.removeEventListener( + "mousemove", + this.updateCurrentCursorPosition, + false, + ); + document.removeEventListener("keyup", this.onKeyUp); + window.removeEventListener("resize", this.onResize, false); + window.removeEventListener("unload", this.onUnload, false); + window.removeEventListener("blur", this.onUnload, false); + window.removeEventListener("dragover", this.disableEvent, false); + window.removeEventListener("drop", this.disableEvent, false); + + document.removeEventListener( + "gesturestart", + this.onGestureStart as any, + false, + ); + document.removeEventListener( + "gesturechange", + this.onGestureChange as any, + false, + ); + document.removeEventListener("gestureend", this.onGestureEnd as any, false); + } + + public state: AppState = getDefaultAppState(); + + private onResize = () => { + elements = elements.map(el => ({ ...el, shape: null })); + this.setState({}); + }; + + private updateCurrentCursorPosition = (event: MouseEvent) => { + cursorX = event.x; + cursorY = event.y; + }; + + private onKeyDown = (event: KeyboardEvent) => { + if ( + (isWritableElement(event.target) && event.key !== KEYS.ESCAPE) || + // case: using arrows to move between buttons + (isArrowKey(event.key) && isInputLike(event.target)) + ) { + return; + } + + if (this.actionManager.handleKeyDown(event)) { + return; + } + + const shape = findShapeByKey(event.key); + + if (isArrowKey(event.key)) { + const step = event.shiftKey + ? ELEMENT_SHIFT_TRANSLATE_AMOUNT + : ELEMENT_TRANSLATE_AMOUNT; + elements = elements.map(el => { + if (el.isSelected) { + const element = { ...el }; + if (event.key === KEYS.ARROW_LEFT) { + element.x -= step; + } else if (event.key === KEYS.ARROW_RIGHT) { + element.x += step; + } else if (event.key === KEYS.ARROW_UP) { + element.y -= step; + } else if (event.key === KEYS.ARROW_DOWN) { + element.y += step; + } + return element; + } + return el; + }); + this.setState({}); + event.preventDefault(); + } else if ( + shapesShortcutKeys.includes(event.key.toLowerCase()) && + !event.ctrlKey && + !event.altKey && + !event.metaKey && + this.state.draggingElement === null + ) { + this.selectShapeTool(shape); + } else if (event.key === KEYS.SPACE && gesture.pointers.length === 0) { + isHoldingSpace = true; + document.documentElement.style.cursor = CURSOR_TYPE.GRABBING; + } + }; + + private onKeyUp = (event: KeyboardEvent) => { + if (event.key === KEYS.SPACE) { + if (this.state.elementType === "selection") { + resetCursor(); + } else { + elements = clearSelection(elements); + document.documentElement.style.cursor = + this.state.elementType === "text" + ? CURSOR_TYPE.TEXT + : CURSOR_TYPE.CROSSHAIR; + this.setState({}); + } + isHoldingSpace = false; + } + }; + + private copyToAppClipboard = () => { + copyToAppClipboard(elements); + }; + + private pasteFromClipboard = async (event: ClipboardEvent | null) => { + // #686 + const target = document.activeElement; + const elementUnderCursor = document.elementFromPoint(cursorX, cursorY); + if ( + // if no ClipboardEvent supplied, assume we're pasting via contextMenu + // thus these checks don't make sense + !event || + (elementUnderCursor instanceof HTMLCanvasElement && + !isWritableElement(target)) + ) { + const data = await getClipboardContent(event); + if (data.elements) { + this.addElementsFromPaste(data.elements); + } else if (data.text) { + const { x, y } = viewportCoordsToSceneCoords( + { clientX: cursorX, clientY: cursorY }, + this.state, + this.canvas, + ); + + const element = newTextElement( + newElement( + "text", + x, + y, + this.state.currentItemStrokeColor, + this.state.currentItemBackgroundColor, + this.state.currentItemFillStyle, + this.state.currentItemStrokeWidth, + this.state.currentItemRoughness, + this.state.currentItemOpacity, + ), + data.text, + this.state.currentItemFont, + ); + + element.isSelected = true; + + elements = [...clearSelection(elements), element]; + history.resumeRecording(); + } + this.selectShapeTool("selection"); + event?.preventDefault(); + } + }; + + private selectShapeTool(elementType: AppState["elementType"]) { + if (!isHoldingSpace) { + setCursorForShape(elementType); + } + if (isToolIcon(document.activeElement)) { + document.activeElement.blur(); + } + if (elementType !== "selection") { + elements = clearSelection(elements); + } + this.setState({ elementType }); + } + + private onGestureStart = (event: GestureEvent) => { + event.preventDefault(); + gesture.initialScale = this.state.zoom; + }; + private onGestureChange = (event: GestureEvent) => { + event.preventDefault(); + + this.setState({ + zoom: getNormalizedZoom(gesture.initialScale! * event.scale), + }); + }; + private onGestureEnd = (event: GestureEvent) => { + event.preventDefault(); + gesture.initialScale = null; + }; + + setAppState = (obj: any) => { + this.setState(obj); + }; + + setElements = (elements_: readonly ExcalidrawElement[]) => { + elements = elements_; + this.setState({}); + }; + + removePointer = (event: React.PointerEvent) => { + gesture.pointers = gesture.pointers.filter( + pointer => pointer.id !== event.pointerId, + ); + }; + + public render() { + const canvasDOMWidth = window.innerWidth; + const canvasDOMHeight = window.innerHeight; + + const canvasScale = window.devicePixelRatio; + + const canvasWidth = canvasDOMWidth * canvasScale; + const canvasHeight = canvasDOMHeight * canvasScale; + + return ( +
+ +
+ { + // canvas is null when unmounting + if (canvas !== null) { + this.canvas = canvas; + this.rc = rough.canvas(this.canvas); + + this.canvas.addEventListener("wheel", this.handleWheel, { + passive: false, + }); + + this.canvas + .getContext("2d") + ?.setTransform(canvasScale, 0, 0, canvasScale, 0, 0); + } else { + this.canvas?.removeEventListener("wheel", this.handleWheel); + } + }} + onContextMenu={event => { + event.preventDefault(); + + const { x, y } = viewportCoordsToSceneCoords( + event, + this.state, + this.canvas, + ); + + const element = getElementAtPosition( + elements, + x, + y, + this.state.zoom, + ); + if (!element) { + ContextMenu.push({ + options: [ + navigator.clipboard && { + label: t("labels.paste"), + action: () => this.pasteFromClipboard(null), + }, + ...this.actionManager.getContextMenuItems(action => + this.canvasOnlyActions.includes(action.name), + ), + ], + top: event.clientY, + left: event.clientX, + }); + return; + } + + if (!element.isSelected) { + elements = clearSelection(elements); + element.isSelected = true; + this.setState({}); + } + + ContextMenu.push({ + options: [ + navigator.clipboard && { + label: t("labels.copy"), + action: this.copyToAppClipboard, + }, + navigator.clipboard && { + label: t("labels.paste"), + action: () => this.pasteFromClipboard(null), + }, + ...this.actionManager.getContextMenuItems( + action => !this.canvasOnlyActions.includes(action.name), + ), + ], + top: event.clientY, + left: event.clientX, + }); + }} + onPointerDown={event => { + if (lastPointerUp !== null) { + // Unfortunately, sometimes we don't get a pointerup after a pointerdown, + // this can happen when a contextual menu or alert is triggered. In order to avoid + // being in a weird state, we clean up on the next pointerdown + lastPointerUp(event); + } + + if (isPanning) { + return; + } + + this.setState({ lastPointerDownWith: event.pointerType }); + + // pan canvas on wheel button drag or space+drag + if ( + gesture.pointers.length === 0 && + (event.button === POINTER_BUTTON.WHEEL || + (event.button === POINTER_BUTTON.MAIN && isHoldingSpace)) + ) { + isPanning = true; + document.documentElement.style.cursor = CURSOR_TYPE.GRABBING; + let { clientX: lastX, clientY: lastY } = event; + const onPointerMove = (event: PointerEvent) => { + const deltaX = lastX - event.clientX; + const deltaY = lastY - event.clientY; + lastX = event.clientX; + lastY = event.clientY; + + this.setState({ + scrollX: normalizeScroll( + this.state.scrollX - deltaX / this.state.zoom, + ), + scrollY: normalizeScroll( + this.state.scrollY - deltaY / this.state.zoom, + ), + }); + }; + const teardown = (lastPointerUp = () => { + lastPointerUp = null; + isPanning = false; + if (!isHoldingSpace) { + setCursorForShape(this.state.elementType); + } + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", teardown); + window.removeEventListener("blur", teardown); + }); + window.addEventListener("blur", teardown); + window.addEventListener("pointermove", onPointerMove, { + passive: true, + }); + window.addEventListener("pointerup", teardown); + return; + } + + // only handle left mouse button or touch + if ( + event.button !== POINTER_BUTTON.MAIN && + event.button !== POINTER_BUTTON.TOUCH + ) { + return; + } + + gesture.pointers.push({ + id: event.pointerId, + x: event.clientX, + y: event.clientY, + }); + if (gesture.pointers.length === 2) { + gesture.lastCenter = getCenter(gesture.pointers); + gesture.initialScale = this.state.zoom; + gesture.initialDistance = getDistance(gesture.pointers); + } + + // fixes pointermove causing selection of UI texts #32 + event.preventDefault(); + // Preventing the event above disables default behavior + // of defocusing potentially focused element, which is what we + // want when clicking inside the canvas. + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + + // don't select while panning + if (gesture.pointers.length > 1) { + return; + } + + // Handle scrollbars dragging + const { + isOverHorizontalScrollBar, + isOverVerticalScrollBar, + } = isOverScrollBars( + currentScrollBars, + event.clientX, + event.clientY, + ); + + const { x, y } = viewportCoordsToSceneCoords( + event, + this.state, + this.canvas, + ); + let lastX = x; + let lastY = y; + + if ( + (isOverHorizontalScrollBar || isOverVerticalScrollBar) && + !this.state.multiElement + ) { + isDraggingScrollBar = true; + lastX = event.clientX; + lastY = event.clientY; + const onPointerMove = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + if (isOverHorizontalScrollBar) { + const x = event.clientX; + const dx = x - lastX; + this.setState({ + scrollX: normalizeScroll( + this.state.scrollX - dx / this.state.zoom, + ), + }); + lastX = x; + return; + } + + if (isOverVerticalScrollBar) { + const y = event.clientY; + const dy = y - lastY; + this.setState({ + scrollY: normalizeScroll( + this.state.scrollY - dy / this.state.zoom, + ), + }); + lastY = y; + } + }; + + const onPointerUp = () => { + isDraggingScrollBar = false; + setCursorForShape(this.state.elementType); + lastPointerUp = null; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }; + + lastPointerUp = onPointerUp; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + return; + } + + const originX = x; + const originY = y; + + let element = newElement( + this.state.elementType, + x, + y, + this.state.currentItemStrokeColor, + this.state.currentItemBackgroundColor, + this.state.currentItemFillStyle, + this.state.currentItemStrokeWidth, + this.state.currentItemRoughness, + this.state.currentItemOpacity, + ); + + if (isTextElement(element)) { + element = newTextElement( + element, + "", + this.state.currentItemFont, + ); + } + + type ResizeTestType = ReturnType; + let resizeHandle: ResizeTestType = false; + let isResizingElements = false; + let draggingOccurred = false; + let hitElement: ExcalidrawElement | null = null; + let elementIsAddedToSelection = false; + if (this.state.elementType === "selection") { + const resizeElement = getElementWithResizeHandler( + elements, + { x, y }, + this.state.zoom, + event.pointerType, + ); + + const selectedElements = getSelectedElements(elements); + if (selectedElements.length === 1 && resizeElement) { + this.setState({ + resizingElement: resizeElement + ? resizeElement.element + : null, + }); + + resizeHandle = resizeElement.resizeHandle; + document.documentElement.style.cursor = getCursorForResizingElement( + resizeElement, + ); + isResizingElements = true; + } else { + hitElement = getElementAtPosition( + elements, + x, + y, + this.state.zoom, + ); + // clear selection if shift is not clicked + if (!hitElement?.isSelected && !event.shiftKey) { + elements = clearSelection(elements); + } + + // If we click on something + if (hitElement) { + // deselect if item is selected + // if shift is not clicked, this will always return true + // otherwise, it will trigger selection based on current + // state of the box + if (!hitElement.isSelected) { + hitElement.isSelected = true; + elements = elements.slice(); + elementIsAddedToSelection = true; + } + + // We duplicate the selected element if alt is pressed on pointer down + if (event.altKey) { + elements = [ + ...elements.map(element => ({ + ...element, + isSelected: false, + })), + ...getSelectedElements(elements).map(element => { + const newElement = duplicateElement(element); + newElement.isSelected = true; + return newElement; + }), + ]; + } + } + } + } else { + elements = clearSelection(elements); + } + + if (isTextElement(element)) { + // if we're currently still editing text, clicking outside + // should only finalize it, not create another (irrespective + // of state.elementLocked) + if (this.state.editingElement?.type === "text") { + return; + } + if (elementIsAddedToSelection) { + element = hitElement!; + } + let textX = event.clientX; + let textY = event.clientY; + if (!event.altKey) { + const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition( + x, + y, + ); + if (snappedToCenterPosition) { + element.x = snappedToCenterPosition.elementCenterX; + element.y = snappedToCenterPosition.elementCenterY; + textX = snappedToCenterPosition.wysiwygX; + textY = snappedToCenterPosition.wysiwygY; + } + } + + const resetSelection = () => { + this.setState({ + draggingElement: null, + editingElement: null, + }); + }; + + textWysiwyg({ + initText: "", + x: textX, + y: textY, + strokeColor: this.state.currentItemStrokeColor, + opacity: this.state.currentItemOpacity, + font: this.state.currentItemFont, + zoom: this.state.zoom, + onSubmit: text => { + if (text) { + elements = [ + ...elements, + { + ...newTextElement( + element, + text, + this.state.currentItemFont, + ), + isSelected: true, + }, + ]; + } + if (this.state.elementLocked) { + setCursorForShape(this.state.elementType); + } + history.resumeRecording(); + resetSelection(); + }, + onCancel: () => { + resetSelection(); + }, + }); + resetCursor(); + if (!this.state.elementLocked) { + this.setState({ + editingElement: element, + elementType: "selection", + }); + } else { + this.setState({ + editingElement: element, + }); + } + return; + } else if ( + this.state.elementType === "arrow" || + this.state.elementType === "line" + ) { + if (this.state.multiElement) { + const { multiElement } = this.state; + const { x: rx, y: ry } = multiElement; + multiElement.isSelected = true; + multiElement.points.push([x - rx, y - ry]); + multiElement.shape = null; + } else { + element.isSelected = false; + element.points.push([0, 0]); + element.shape = null; + elements = [...elements, element]; + this.setState({ + draggingElement: element, + }); + } + } else if (element.type === "selection") { + this.setState({ + selectionElement: element, + draggingElement: element, + }); + } else { + elements = [...elements, element]; + this.setState({ multiElement: null, draggingElement: element }); + } + + let resizeArrowFn: + | (( + element: ExcalidrawElement, + p1: Point, + deltaX: number, + deltaY: number, + pointerX: number, + pointerY: number, + perfect: boolean, + ) => void) + | null = null; + + const arrowResizeOrigin = ( + element: ExcalidrawElement, + p1: Point, + deltaX: number, + deltaY: number, + pointerX: number, + pointerY: number, + perfect: boolean, + ) => { + if (perfect) { + const absPx = p1[0] + element.x; + const absPy = p1[1] + element.y; + + const { width, height } = getPerfectElementSize( + element.type, + pointerX - element.x - p1[0], + pointerY - element.y - p1[1], + ); + + const dx = element.x + width + p1[0]; + const dy = element.y + height + p1[1]; + element.x = dx; + element.y = dy; + p1[0] = absPx - element.x; + p1[1] = absPy - element.y; + } else { + element.x += deltaX; + element.y += deltaY; + p1[0] -= deltaX; + p1[1] -= deltaY; + } + }; + + const arrowResizeEnd = ( + element: ExcalidrawElement, + p1: Point, + deltaX: number, + deltaY: number, + pointerX: number, + pointerY: number, + perfect: boolean, + ) => { + if (perfect) { + const { width, height } = getPerfectElementSize( + element.type, + pointerX - element.x, + pointerY - element.y, + ); + p1[0] = width; + p1[1] = height; + } else { + p1[0] += deltaX; + p1[1] += deltaY; + } + }; + + const onPointerMove = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + if (isOverHorizontalScrollBar) { + const x = event.clientX; + const dx = x - lastX; + this.setState({ + scrollX: normalizeScroll( + this.state.scrollX - dx / this.state.zoom, + ), + }); + lastX = x; + return; + } + + if (isOverVerticalScrollBar) { + const y = event.clientY; + const dy = y - lastY; + this.setState({ + scrollY: normalizeScroll( + this.state.scrollY - dy / this.state.zoom, + ), + }); + lastY = y; + return; + } + + // for arrows, don't start dragging until a given threshold + // to ensure we don't create a 2-point arrow by mistake when + // user clicks mouse in a way that it moves a tiny bit (thus + // triggering pointermove) + if ( + !draggingOccurred && + (this.state.elementType === "arrow" || + this.state.elementType === "line") + ) { + const { x, y } = viewportCoordsToSceneCoords( + event, + this.state, + this.canvas, + ); + if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) { + return; + } + } + + if (isResizingElements && this.state.resizingElement) { + this.setState({ isResizing: true }); + const el = this.state.resizingElement; + const selectedElements = getSelectedElements(elements); + if (selectedElements.length === 1) { + const { x, y } = viewportCoordsToSceneCoords( + event, + this.state, + this.canvas, + ); + const deltaX = x - lastX; + const deltaY = y - lastY; + const element = selectedElements[0]; + const isLinear = + element.type === "line" || element.type === "arrow"; + switch (resizeHandle) { + case "nw": + if (isLinear && element.points.length === 2) { + const [, p1] = element.points; + + if (!resizeArrowFn) { + if (p1[0] < 0 || p1[1] < 0) { + resizeArrowFn = arrowResizeEnd; + } else { + resizeArrowFn = arrowResizeOrigin; + } + } + resizeArrowFn( + element, + p1, + deltaX, + deltaY, + x, + y, + event.shiftKey, + ); + } else { + element.width -= deltaX; + element.x += deltaX; + + if (event.shiftKey) { + element.y += element.height - element.width; + element.height = element.width; + } else { + element.height -= deltaY; + element.y += deltaY; + } + } + break; + case "ne": + if (isLinear && element.points.length === 2) { + const [, p1] = element.points; + if (!resizeArrowFn) { + if (p1[0] >= 0) { + resizeArrowFn = arrowResizeEnd; + } else { + resizeArrowFn = arrowResizeOrigin; + } + } + resizeArrowFn( + element, + p1, + deltaX, + deltaY, + x, + y, + event.shiftKey, + ); + } else { + element.width += deltaX; + if (event.shiftKey) { + element.y += element.height - element.width; + element.height = element.width; + } else { + element.height -= deltaY; + element.y += deltaY; + } + } + break; + case "sw": + if (isLinear && element.points.length === 2) { + const [, p1] = element.points; + if (!resizeArrowFn) { + if (p1[0] <= 0) { + resizeArrowFn = arrowResizeEnd; + } else { + resizeArrowFn = arrowResizeOrigin; + } + } + resizeArrowFn( + element, + p1, + deltaX, + deltaY, + x, + y, + event.shiftKey, + ); + } else { + element.width -= deltaX; + element.x += deltaX; + if (event.shiftKey) { + element.height = element.width; + } else { + element.height += deltaY; + } + } + break; + case "se": + if (isLinear && element.points.length === 2) { + const [, p1] = element.points; + if (!resizeArrowFn) { + if (p1[0] > 0 || p1[1] > 0) { + resizeArrowFn = arrowResizeEnd; + } else { + resizeArrowFn = arrowResizeOrigin; + } + } + resizeArrowFn( + element, + p1, + deltaX, + deltaY, + x, + y, + event.shiftKey, + ); + } else { + if (event.shiftKey) { + element.width += deltaX; + element.height = element.width; + } else { + element.width += deltaX; + element.height += deltaY; + } + } + break; + case "n": { + element.height -= deltaY; + element.y += deltaY; + + if (element.points.length > 0) { + const len = element.points.length; + + const points = [...element.points].sort( + (a, b) => a[1] - b[1], + ); + + for (let i = 1; i < points.length; ++i) { + const pnt = points[i]; + pnt[1] -= deltaY / (len - i); + } + } + break; + } + case "w": { + element.width -= deltaX; + element.x += deltaX; + + if (element.points.length > 0) { + const len = element.points.length; + const points = [...element.points].sort( + (a, b) => a[0] - b[0], + ); + + for (let i = 0; i < points.length; ++i) { + const pnt = points[i]; + pnt[0] -= deltaX / (len - i); + } + } + break; + } + case "s": { + element.height += deltaY; + if (element.points.length > 0) { + const len = element.points.length; + const points = [...element.points].sort( + (a, b) => a[1] - b[1], + ); + + for (let i = 1; i < points.length; ++i) { + const pnt = points[i]; + pnt[1] += deltaY / (len - i); + } + } + break; + } + case "e": { + element.width += deltaX; + if (element.points.length > 0) { + const len = element.points.length; + const points = [...element.points].sort( + (a, b) => a[0] - b[0], + ); + + for (let i = 1; i < points.length; ++i) { + const pnt = points[i]; + pnt[0] += deltaX / (len - i); + } + } + break; + } + } + + if (resizeHandle) { + resizeHandle = normalizeResizeHandle( + element, + resizeHandle, + ); + } + normalizeDimensions(element); + + document.documentElement.style.cursor = getCursorForResizingElement( + { element, resizeHandle }, + ); + el.x = element.x; + el.y = element.y; + el.shape = null; + + lastX = x; + lastY = y; + this.setState({}); + return; + } + } + + if (hitElement?.isSelected) { + // Marking that click was used for dragging to check + // if elements should be deselected on pointerup + draggingOccurred = true; + const selectedElements = getSelectedElements(elements); + if (selectedElements.length > 0) { + const { x, y } = viewportCoordsToSceneCoords( + event, + this.state, + this.canvas, + ); + + selectedElements.forEach(element => { + element.x += x - lastX; + element.y += y - lastY; + }); + lastX = x; + lastY = y; + this.setState({}); + return; + } + } + + // It is very important to read this.state within each move event, + // otherwise we would read a stale one! + const draggingElement = this.state.draggingElement; + if (!draggingElement) { + return; + } + + const { x, y } = viewportCoordsToSceneCoords( + event, + this.state, + this.canvas, + ); + + let width = distance(originX, x); + let height = distance(originY, y); + + const isLinear = + this.state.elementType === "line" || + this.state.elementType === "arrow"; + + if (isLinear) { + draggingOccurred = true; + const points = draggingElement.points; + let dx = x - draggingElement.x; + let dy = y - draggingElement.y; + + if (event.shiftKey && points.length === 2) { + ({ width: dx, height: dy } = getPerfectElementSize( + this.state.elementType, + dx, + dy, + )); + } + + if (points.length === 1) { + points.push([dx, dy]); + } else if (points.length > 1) { + const pnt = points[points.length - 1]; + pnt[0] = dx; + pnt[1] = dy; + } + } else { + if (event.shiftKey) { + ({ width, height } = getPerfectElementSize( + this.state.elementType, + width, + y < originY ? -height : height, + )); + + if (height < 0) { + height = -height; + } + } + + draggingElement.x = x < originX ? originX - width : originX; + draggingElement.y = y < originY ? originY - height : originY; + + draggingElement.width = width; + draggingElement.height = height; + } + + draggingElement.shape = null; + + if (this.state.elementType === "selection") { + if (!event.shiftKey && isSomeElementSelected(elements)) { + elements = clearSelection(elements); + } + const elementsWithinSelection = getElementsWithinSelection( + elements, + draggingElement, + ); + elementsWithinSelection.forEach(element => { + element.isSelected = true; + }); + } + this.setState({}); + }; + + const onPointerUp = (event: PointerEvent) => { + const { + draggingElement, + resizingElement, + multiElement, + elementType, + elementLocked, + } = this.state; + + this.setState({ + isResizing: false, + resizingElement: null, + selectionElement: null, + }); + + resizeArrowFn = null; + lastPointerUp = null; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + + if (elementType === "arrow" || elementType === "line") { + if (draggingElement!.points.length > 1) { + history.resumeRecording(); + this.setState({}); + } + if (!draggingOccurred && draggingElement && !multiElement) { + const { x, y } = viewportCoordsToSceneCoords( + event, + this.state, + this.canvas, + ); + draggingElement.points.push([ + x - draggingElement.x, + y - draggingElement.y, + ]); + draggingElement.shape = null; + this.setState({ multiElement: this.state.draggingElement }); + } else if (draggingOccurred && !multiElement) { + this.state.draggingElement!.isSelected = true; + if (!elementLocked) { + resetCursor(); + this.setState({ + draggingElement: null, + elementType: "selection", + }); + } else { + this.setState({ + draggingElement: null, + }); + } + } + return; + } + + if ( + elementType !== "selection" && + draggingElement && + isInvisiblySmallElement(draggingElement) + ) { + // remove invisible element which was added in onPointerDown + elements = elements.slice(0, -1); + this.setState({ + draggingElement: null, + }); + return; + } + + if (normalizeDimensions(draggingElement)) { + this.setState({}); + } + + if (resizingElement) { + history.resumeRecording(); + this.setState({}); + } + + if ( + resizingElement && + isInvisiblySmallElement(resizingElement) + ) { + elements = elements.filter( + el => el.id !== resizingElement.id, + ); + } + + // If click occurred on already selected element + // it is needed to remove selection from other elements + // or if SHIFT or META key pressed remove selection + // from hitted element + // + // If click occurred and elements were dragged or some element + // was added to selection (on pointerdown phase) we need to keep + // selection unchanged + if ( + hitElement && + !draggingOccurred && + !elementIsAddedToSelection + ) { + if (event.shiftKey) { + hitElement.isSelected = false; + } else { + elements = clearSelection(elements); + hitElement.isSelected = true; + } + } + + if (draggingElement === null) { + // if no element is clicked, clear the selection and redraw + elements = clearSelection(elements); + this.setState({}); + return; + } + + if (!elementLocked) { + draggingElement.isSelected = true; + } + + if ( + elementType !== "selection" || + isSomeElementSelected(elements) + ) { + history.resumeRecording(); + } + + if (!elementLocked) { + resetCursor(); + this.setState({ + draggingElement: null, + elementType: "selection", + }); + } else { + this.setState({ + draggingElement: null, + }); + } + }; + + lastPointerUp = onPointerUp; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + }} + onDoubleClick={event => { + resetCursor(); + + const { x, y } = viewportCoordsToSceneCoords( + event, + this.state, + this.canvas, + ); + + const elementAtPosition = getElementAtPosition( + elements, + x, + y, + this.state.zoom, + ); + + const element = + elementAtPosition && isTextElement(elementAtPosition) + ? elementAtPosition + : newTextElement( + newElement( + "text", + x, + y, + this.state.currentItemStrokeColor, + this.state.currentItemBackgroundColor, + this.state.currentItemFillStyle, + this.state.currentItemStrokeWidth, + this.state.currentItemRoughness, + this.state.currentItemOpacity, + ), + "", // default text + this.state.currentItemFont, // default font + ); + + this.setState({ editingElement: element }); + + let textX = event.clientX; + let textY = event.clientY; + + if (elementAtPosition && isTextElement(elementAtPosition)) { + elements = elements.filter( + element => element.id !== elementAtPosition.id, + ); + this.setState({}); + + const centerElementX = + elementAtPosition.x + elementAtPosition.width / 2; + const centerElementY = + elementAtPosition.y + elementAtPosition.height / 2; + + const { + x: centerElementXInViewport, + y: centerElementYInViewport, + } = sceneCoordsToViewportCoords( + { sceneX: centerElementX, sceneY: centerElementY }, + this.state, + this.canvas, + ); + + textX = centerElementXInViewport; + textY = centerElementYInViewport; + + // x and y will change after calling newTextElement function + element.x = centerElementX; + element.y = centerElementY; + } else if (!event.altKey) { + const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition( + x, + y, + ); + + if (snappedToCenterPosition) { + element.x = snappedToCenterPosition.elementCenterX; + element.y = snappedToCenterPosition.elementCenterY; + textX = snappedToCenterPosition.wysiwygX; + textY = snappedToCenterPosition.wysiwygY; + } + } + + const resetSelection = () => { + this.setState({ + draggingElement: null, + editingElement: null, + }); + }; + + textWysiwyg({ + initText: element.text, + x: textX, + y: textY, + strokeColor: element.strokeColor, + font: element.font, + opacity: this.state.currentItemOpacity, + zoom: this.state.zoom, + onSubmit: text => { + if (text) { + elements = [ + ...elements, + { + // we need to recreate the element to update dimensions & + // position + ...newTextElement(element, text, element.font), + isSelected: true, + }, + ]; + } + history.resumeRecording(); + resetSelection(); + }, + onCancel: () => { + resetSelection(); + }, + }); + }} + onPointerMove={event => { + gesture.pointers = gesture.pointers.map(pointer => + pointer.id === event.pointerId + ? { + id: event.pointerId, + x: event.clientX, + y: event.clientY, + } + : pointer, + ); + + if (gesture.pointers.length === 2) { + const center = getCenter(gesture.pointers); + const deltaX = center.x - gesture.lastCenter!.x; + const deltaY = center.y - gesture.lastCenter!.y; + gesture.lastCenter = center; + + const distance = getDistance(gesture.pointers); + const scaleFactor = distance / gesture.initialDistance!; + + this.setState({ + scrollX: normalizeScroll( + this.state.scrollX + deltaX / this.state.zoom, + ), + scrollY: normalizeScroll( + this.state.scrollY + deltaY / this.state.zoom, + ), + zoom: getNormalizedZoom(gesture.initialScale! * scaleFactor), + }); + } else { + gesture.lastCenter = gesture.initialDistance = gesture.initialScale = null; + } + + if (isHoldingSpace || isPanning || isDraggingScrollBar) { + return; + } + const { + isOverHorizontalScrollBar, + isOverVerticalScrollBar, + } = isOverScrollBars( + currentScrollBars, + event.clientX, + event.clientY, + ); + const isOverScrollBar = + isOverVerticalScrollBar || isOverHorizontalScrollBar; + if (!this.state.draggingElement && !this.state.multiElement) { + if (isOverScrollBar) { + resetCursor(); + } else { + setCursorForShape(this.state.elementType); + } + } + + const { x, y } = viewportCoordsToSceneCoords( + event, + this.state, + this.canvas, + ); + if (this.state.multiElement) { + const { multiElement } = this.state; + const originX = multiElement.x; + const originY = multiElement.y; + const points = multiElement.points; + const pnt = points[points.length - 1]; + pnt[0] = x - originX; + pnt[1] = y - originY; + multiElement.shape = null; + this.setState({}); + return; + } + + const hasDeselectedButton = Boolean(event.buttons); + if ( + hasDeselectedButton || + this.state.elementType !== "selection" + ) { + return; + } + + const selectedElements = getSelectedElements(elements); + if (selectedElements.length === 1 && !isOverScrollBar) { + const resizeElement = getElementWithResizeHandler( + elements, + { x, y }, + this.state.zoom, + event.pointerType, + ); + if (resizeElement && resizeElement.resizeHandle) { + document.documentElement.style.cursor = getCursorForResizingElement( + resizeElement, + ); + return; + } + } + const hitElement = getElementAtPosition( + elements, + x, + y, + this.state.zoom, + ); + document.documentElement.style.cursor = + hitElement && !isOverScrollBar ? "move" : ""; + }} + onPointerUp={this.removePointer} + onPointerCancel={this.removePointer} + onDrop={event => { + const file = event.dataTransfer.files[0]; + if ( + file?.type === "application/json" || + file?.name.endsWith(".excalidraw") + ) { + loadFromBlob(file) + .then(({ elements, appState }) => + this.syncActionResult({ elements, appState }), + ) + .catch(error => console.error(error)); + } + }} + > + {t("labels.drawingCanvas")} + +
+
+ ); + } + + private handleWheel = (event: WheelEvent) => { + event.preventDefault(); + const { deltaX, deltaY } = event; + + if (event.metaKey || event.ctrlKey) { + const sign = Math.sign(deltaY); + const MAX_STEP = 10; + let delta = Math.abs(deltaY); + if (delta > MAX_STEP) { + delta = MAX_STEP; + } + delta *= sign; + this.setState(({ zoom }) => ({ + zoom: getNormalizedZoom(zoom - delta / 100), + })); + return; + } + + this.setState(({ zoom, scrollX, scrollY }) => ({ + scrollX: normalizeScroll(scrollX - deltaX / zoom), + scrollY: normalizeScroll(scrollY - deltaY / zoom), + })); + }; + + private addElementsFromPaste = ( + clipboardElements: readonly ExcalidrawElement[], + ) => { + elements = clearSelection(elements); + + const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements); + + const elementsCenterX = distance(minX, maxX) / 2; + const elementsCenterY = distance(minY, maxY) / 2; + + const { x, y } = viewportCoordsToSceneCoords( + { clientX: cursorX, clientY: cursorY }, + this.state, + this.canvas, + ); + + const dx = x - elementsCenterX; + const dy = y - elementsCenterY; + + elements = [ + ...elements, + ...clipboardElements.map(clipboardElements => { + const duplicate = duplicateElement(clipboardElements); + duplicate.x += dx - minX; + duplicate.y += dy - minY; + return duplicate; + }), + ]; + history.resumeRecording(); + this.setState({}); + }; + + private getTextWysiwygSnappedToCenterPosition(x: number, y: number) { + const elementClickedInside = getElementContainingPosition(elements, x, y); + if (elementClickedInside) { + const elementCenterX = + elementClickedInside.x + elementClickedInside.width / 2; + const elementCenterY = + elementClickedInside.y + elementClickedInside.height / 2; + const distanceToCenter = Math.hypot( + x - elementCenterX, + y - elementCenterY, + ); + const isSnappedToCenter = + distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD; + if (isSnappedToCenter) { + const wysiwygX = + this.state.scrollX + + elementClickedInside.x + + elementClickedInside.width / 2; + const wysiwygY = + this.state.scrollY + + elementClickedInside.y + + elementClickedInside.height / 2; + return { wysiwygX, wysiwygY, elementCenterX, elementCenterY }; + } + } + } + + private saveDebounced = debounce(() => { + saveToLocalStorage(elements, this.state); + }, 300); + + componentDidUpdate() { + const { atLeastOneVisibleElement, scrollBars } = renderScene( + elements, + this.state.selectionElement, + this.rc!, + this.canvas!, + { + scrollX: this.state.scrollX, + scrollY: this.state.scrollY, + viewBackgroundColor: this.state.viewBackgroundColor, + zoom: this.state.zoom, + }, + { + renderOptimizations: true, + }, + ); + if (scrollBars) { + currentScrollBars = scrollBars; + } + const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0; + if (this.state.scrolledOutside !== scrolledOutside) { + this.setState({ scrolledOutside: scrolledOutside }); + } + this.saveDebounced(); + if (history.isRecording()) { + history.pushEntry(this.state, elements); + history.skipRecording(); + } + } +} diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index 80923d3b..c34a7f76 100644 --- a/src/components/ExportDialog.tsx +++ b/src/components/ExportDialog.tsx @@ -22,7 +22,7 @@ import useIsMobile from "../is-mobile"; const scales = [1, 2, 3]; const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1; -type ExportCB = ( +export type ExportCB = ( elements: readonly ExcalidrawElement[], scale?: number, ) => void; @@ -173,7 +173,7 @@ function ExportModal({ name="export-canvas-scale" aria-label={`Scale ${s} x`} id="export-canvas-scale" - checked={scale === s} + checked={s === scale} onChange={() => setScale(s)} /> ))} diff --git a/src/components/LanguageList.tsx b/src/components/LanguageList.tsx index c64fdb9a..009f08db 100644 --- a/src/components/LanguageList.tsx +++ b/src/components/LanguageList.tsx @@ -1,15 +1,15 @@ import React from "react"; -import { t } from "../i18n"; +import * as i18n from "../i18n"; -export function LanguageList({ +export function LanguageList({ onChange, - languages, - currentLanguage, + languages = i18n.languages, + currentLanguage = i18n.getLanguage(), floating, }: { - languages: { lng: string; label: string }[]; + languages?: { lng: string; label: string }[]; onChange: (value: string) => void; - currentLanguage: string; + currentLanguage?: string; floating?: boolean; }) { return ( @@ -20,7 +20,7 @@ export function LanguageList({ }`} onChange={({ target }) => onChange(target.value)} value={currentLanguage} - aria-label={t("buttons.selectLanguage")} + aria-label={i18n.t("buttons.selectLanguage")} > {languages.map(language => (