diff --git a/.gitignore b/.gitignore index f21ef9c00..feb368b8f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ package-lock.json static yarn-debug.log* yarn-error.log* +src/packages/excalidraw/types diff --git a/package.json b/package.json index 2845e4004..51bf15cb2 100644 --- a/package.json +++ b/package.json @@ -23,16 +23,16 @@ "@sentry/integrations": "6.2.1", "@testing-library/jest-dom": "5.11.9", "@testing-library/react": "11.2.5", - "@types/jest": "26.0.20", + "@types/jest": "26.0.22", "@types/react": "17.0.3", "@types/react-dom": "17.0.2", "@types/socket.io-client": "1.4.36", - "browser-fs-access": "0.15.3", + "browser-fs-access": "0.16.2", "clsx": "1.1.1", "firebase": "8.2.10", "i18next-browser-languagedetector": "6.0.1", "lodash.throttle": "4.1.1", - "nanoid": "3.1.21", + "nanoid": "3.1.22", "open-color": "1.8.0", "pako": "1.0.11", "png-chunk-text": "1.0.0", @@ -40,8 +40,8 @@ "png-chunks-extract": "1.0.0", "points-on-curve": "0.2.0", "pwacompat": "2.0.17", - "react": "17.0.1", - "react-dom": "17.0.1", + "react": "17.0.2", + "react-dom": "17.0.2", "react-scripts": "4.0.3", "roughjs": "4.3.1", "sass": "1.32.8", diff --git a/public/index.html b/public/index.html index 08981bf8e..c1b9f98c9 100644 --- a/public/index.html +++ b/public/index.html @@ -51,8 +51,7 @@ name="twitter:description" content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." /> - - + @@ -148,6 +147,9 @@ color: var(--popup-text-color); font-size: 1.3em; } + #root { + height: 100%; + } diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 9f23ef040..3a908ebeb 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -8,7 +8,7 @@ import { getCommonBounds, getNonDeletedElements } from "../element"; import { newElementWith } from "../element/mutateElement"; import { ExcalidrawElement } from "../element/types"; import { t } from "../i18n"; -import useIsMobile from "../is-mobile"; +import { useIsMobile } from "../is-mobile"; import { CODES, KEYS } from "../keys"; import { getNormalizedZoom, getSelectedElements } from "../scene"; import { centerScrollOn } from "../scene/scroll"; @@ -33,6 +33,7 @@ export const actionChangeViewBackgroundColor = register({ type="canvasBackground" color={appState.viewBackgroundColor} onChange={(color) => updateData(color)} + data-testid="canvas-background-picker" /> ); @@ -72,6 +73,7 @@ export const actionClearCanvas = register({ updateData(null); } }} + data-testid="clear-canvas-button" /> ), }); diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index 295584fb7..63beb4457 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -8,7 +8,7 @@ import { Tooltip } from "../components/Tooltip"; import { DarkModeToggle, Appearence } from "../components/DarkModeToggle"; import { loadFromJSON, saveAsJSON } from "../data"; import { t } from "../i18n"; -import useIsMobile from "../is-mobile"; +import { useIsMobile } from "../is-mobile"; import { KEYS } from "../keys"; import { register } from "./register"; import { supported } from "browser-fs-access"; @@ -136,6 +136,7 @@ export const actionSaveScene = register({ aria-label={t("buttons.save")} showAriaLabel={useIsMobile()} onClick={() => updateData(null)} + data-testid="save-button" /> ), }); @@ -167,6 +168,7 @@ export const actionSaveAsScene = register({ showAriaLabel={useIsMobile()} hidden={!supported} onClick={() => updateData(null)} + data-testid="save-as-button" /> ), }); @@ -204,6 +206,7 @@ export const actionLoadScene = register({ aria-label={t("buttons.load")} showAriaLabel={useIsMobile()} onClick={updateData} + data-testid="load-button" /> ), }); diff --git a/src/actions/actionFlip.ts b/src/actions/actionFlip.ts new file mode 100644 index 000000000..7be77d5de --- /dev/null +++ b/src/actions/actionFlip.ts @@ -0,0 +1,207 @@ +import { register } from "./register"; +import { getSelectedElements } from "../scene"; +import { getElementMap, getNonDeletedElements } from "../element"; +import { mutateElement } from "../element/mutateElement"; +import { ExcalidrawElement, NonDeleted } from "../element/types"; +import { normalizeAngle, resizeSingleElement } from "../element/resizeElements"; +import { AppState } from "../types"; +import { getTransformHandles } from "../element/transformHandles"; +import { isLinearElement } from "../element/typeChecks"; +import { updateBoundElements } from "../element/binding"; +import { LinearElementEditor } from "../element/linearElementEditor"; + +const enableActionFlipHorizontal = ( + elements: readonly ExcalidrawElement[], + appState: AppState, +) => { + const eligibleElements = getSelectedElements( + getNonDeletedElements(elements), + appState, + ); + return eligibleElements.length === 1 && eligibleElements[0].type !== "text"; +}; + +const enableActionFlipVertical = ( + elements: readonly ExcalidrawElement[], + appState: AppState, +) => { + const eligibleElements = getSelectedElements( + getNonDeletedElements(elements), + appState, + ); + return eligibleElements.length === 1; +}; + +export const actionFlipHorizontal = register({ + name: "flipHorizontal", + perform: (elements, appState) => { + return { + elements: flipSelectedElements(elements, appState, "horizontal"), + appState, + commitToHistory: true, + }; + }, + keyTest: (event) => event.shiftKey && event.code === "KeyH", + contextItemLabel: "labels.flipHorizontal", + contextItemPredicate: (elements, appState) => + enableActionFlipHorizontal(elements, appState), +}); + +export const actionFlipVertical = register({ + name: "flipVertical", + perform: (elements, appState) => { + return { + elements: flipSelectedElements(elements, appState, "vertical"), + appState, + commitToHistory: true, + }; + }, + keyTest: (event) => event.shiftKey && event.code === "KeyV", + contextItemLabel: "labels.flipVertical", + contextItemPredicate: (elements, appState) => + enableActionFlipVertical(elements, appState), +}); + +const flipSelectedElements = ( + elements: readonly ExcalidrawElement[], + appState: Readonly, + flipDirection: "horizontal" | "vertical", +) => { + const selectedElements = getSelectedElements( + getNonDeletedElements(elements), + appState, + ); + + // remove once we allow for groups of elements to be flipped + if (selectedElements.length > 1) { + return elements; + } + + const updatedElements = flipElements( + selectedElements, + appState, + flipDirection, + ); + + const updatedElementsMap = getElementMap(updatedElements); + + return elements.map((element) => updatedElementsMap[element.id] || element); +}; + +const flipElements = ( + elements: NonDeleted[], + appState: AppState, + flipDirection: "horizontal" | "vertical", +): ExcalidrawElement[] => { + for (let i = 0; i < elements.length; i++) { + flipElement(elements[i], appState); + // If vertical flip, rotate an extra 180 + if (flipDirection === "vertical") { + rotateElement(elements[i], Math.PI); + } + } + return elements; +}; + +const flipElement = ( + element: NonDeleted, + appState: AppState, +) => { + const originalX = element.x; + const originalY = element.y; + const width = element.width; + const height = element.height; + const originalAngle = normalizeAngle(element.angle); + + let finalOffsetX = 0; + if (isLinearElement(element)) { + finalOffsetX = + element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 - + element.width; + } + + // Rotate back to zero, if necessary + mutateElement(element, { + angle: normalizeAngle(0), + }); + // Flip unrotated by pulling TransformHandle to opposite side + const transformHandles = getTransformHandles(element, appState.zoom); + let usingNWHandle = true; + let newNCoordsX = 0; + let nHandle = transformHandles.nw; + if (!nHandle) { + // Use ne handle instead + usingNWHandle = false; + nHandle = transformHandles.ne; + if (!nHandle) { + mutateElement(element, { + angle: originalAngle, + }); + return; + } + } + + if (isLinearElement(element)) { + for (let i = 1; i < element.points.length; i++) { + LinearElementEditor.movePoint(element, i, [ + -element.points[i][0], + element.points[i][1], + ]); + } + LinearElementEditor.normalizePoints(element); + } else { + // calculate new x-coord for transformation + newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width; + resizeSingleElement( + element, + true, + element, + usingNWHandle ? "nw" : "ne", + false, + newNCoordsX, + nHandle[1], + ); + // fix the size to account for handle sizes + mutateElement(element, { + width, + height, + }); + } + + // Rotate by (360 degrees - original angle) + let angle = normalizeAngle(2 * Math.PI - originalAngle); + if (angle < 0) { + // check, probably unnecessary + angle = normalizeAngle(angle + 2 * Math.PI); + } + mutateElement(element, { + angle, + }); + + // Move back to original spot to appear "flipped in place" + mutateElement(element, { + x: originalX + finalOffsetX, + y: originalY, + }); + + updateBoundElements(element); +}; + +const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => { + const originalX = element.x; + const originalY = element.y; + let angle = normalizeAngle(element.angle + rotationAngle); + if (angle < 0) { + // check, probably unnecessary + angle = normalizeAngle(2 * Math.PI + angle); + } + mutateElement(element, { + angle, + }); + + // Move back to original spot + mutateElement(element, { + x: originalX, + y: originalY, + }); +}; diff --git a/src/actions/index.ts b/src/actions/index.ts index 4464b36d2..f25740d73 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -67,6 +67,8 @@ export { distributeVertically, } from "./actionDistribute"; +export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip"; + export { actionCopy, actionCut, diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index b242508b8..3cdc81f0a 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -7,12 +7,12 @@ import { ActionResult, } from "./types"; import { ExcalidrawElement } from "../element/types"; -import { AppState, ExcalidrawProps } from "../types"; +import { AppProps, AppState } from "../types"; import { MODES } from "../constants"; // This is the component, but for now we don't care about anything but its // `canvas` state. -type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps }; +type App = { canvas: HTMLCanvasElement | null; props: AppProps }; export class ActionManager implements ActionsManagerInterface { actions = {} as ActionsManagerInterface["actions"]; @@ -52,10 +52,14 @@ export class ActionManager implements ActionsManagerInterface { } handleKeyDown(event: KeyboardEvent) { + const canvasActions = this.app.props.UIOptions.canvasActions; const data = Object.values(this.actions) .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0)) .filter( (action) => + (action.name in canvasActions + ? canvasActions[action.name as keyof typeof canvasActions] + : true) && action.keyTest && action.keyTest( event, @@ -102,7 +106,15 @@ export class ActionManager implements ActionsManagerInterface { // like the user list. We can use this key to extract more // data from app state. This is an alternative to generic prop hell! renderAction = (name: ActionName, id?: string) => { - if (this.actions[name] && "PanelComponent" in this.actions[name]) { + const canvasActions = this.app.props.UIOptions.canvasActions; + + if ( + this.actions[name] && + "PanelComponent" in this.actions[name] && + (name in canvasActions + ? canvasActions[name as keyof typeof canvasActions] + : true) + ) { const action = this.actions[name]; const PanelComponent = action.PanelComponent!; const updateData = (formState?: any) => { diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts index 4c9bc60c4..23df3791f 100644 --- a/src/actions/shortcuts.ts +++ b/src/actions/shortcuts.ts @@ -23,7 +23,9 @@ export type ShortcutName = | "zenMode" | "stats" | "addToLibrary" - | "viewMode"; + | "viewMode" + | "flipHorizontal" + | "flipVertical"; const shortcutMap: Record = { cut: [getShortcutKey("CtrlOrCmd+X")], @@ -57,6 +59,8 @@ const shortcutMap: Record = { zenMode: [getShortcutKey("Alt+Z")], stats: [], addToLibrary: [], + flipHorizontal: [getShortcutKey("Shift+H")], + flipVertical: [getShortcutKey("Shift+V")], viewMode: [getShortcutKey("Alt+R")], }; diff --git a/src/actions/types.ts b/src/actions/types.ts index 529828566..8aa71c9e9 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -6,7 +6,10 @@ import { AppState, ExcalidrawProps } from "../types"; export type ActionResult = | { elements?: readonly ExcalidrawElement[] | null; - appState?: MarkOptional | null; + appState?: MarkOptional< + AppState, + "offsetTop" | "offsetLeft" | "width" | "height" + > | null; commitToHistory: boolean; syncHistory?: boolean; } @@ -86,6 +89,8 @@ export type ActionName = | "alignHorizontallyCentered" | "distributeHorizontally" | "distributeVertically" + | "flipHorizontal" + | "flipVertical" | "viewMode" | "exportWithDarkMode"; diff --git a/src/appState.ts b/src/appState.ts index 954873450..4a1a6a996 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -10,7 +10,7 @@ import { getDateTime } from "./utils"; export const getDefaultAppState = (): Omit< AppState, - "offsetTop" | "offsetLeft" + "offsetTop" | "offsetLeft" | "width" | "height" > => { return { autosave: false, @@ -44,7 +44,6 @@ export const getDefaultAppState = (): Omit< exportWithDarkMode: false, fileHandle: null, gridSize: null, - height: window.innerHeight, isBindingEnabled: true, isLibraryOpen: false, isLoading: false, @@ -71,7 +70,6 @@ export const getDefaultAppState = (): Omit< suggestedBindings: [], toastMessage: null, viewBackgroundColor: oc.white, - width: window.innerWidth, zenModeEnabled: false, zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } }, viewModeEnabled: false, diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 03ecd51a8..3aac01337 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager"; import { getNonDeletedElements } from "../element"; import { ExcalidrawElement } from "../element/types"; import { t } from "../i18n"; -import useIsMobile from "../is-mobile"; +import { useIsMobile } from "../is-mobile"; import { canChangeSharpness, canHaveArrowheads, diff --git a/src/components/App.tsx b/src/components/App.tsx index 92a2b6108..89eb963d0 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -17,6 +17,8 @@ import { actionDeleteSelected, actionDuplicateSelection, actionFinalize, + actionFlipHorizontal, + actionFlipVertical, actionGroup, actionPasteStyles, actionSelectAll, @@ -42,6 +44,7 @@ import { import { APP_NAME, CURSOR_TYPE, + DEFAULT_UI_OPTIONS, DEFAULT_VERTICAL_ALIGN, DRAGGING_THRESHOLD, ELEMENT_SHIFT_TRANSLATE_AMOUNT, @@ -57,6 +60,8 @@ import { TAP_TWICE_TIMEOUT, TEXT_TO_CENTER_SNAP_THRESHOLD, TOUCH_CTX_MENU_TIMEOUT, + URL_HASH_KEYS, + URL_QUERY_KEYS, ZOOM_STEP, } from "../constants"; import { loadFromBlob } from "../data"; @@ -157,13 +162,7 @@ import Scene from "../scene/Scene"; import { SceneState, ScrollBars } from "../scene/types"; import { getNewZoom } from "../scene/zoom"; import { findShapeByKey } from "../shapes"; -import { - AppState, - ExcalidrawProps, - Gesture, - GestureEvent, - SceneData, -} from "../types"; +import { AppProps, AppState, Gesture, GestureEvent, SceneData } from "../types"; import { debounce, distance, @@ -277,29 +276,30 @@ export type ExcalidrawImperativeAPI = { getSceneElements: InstanceType["getSceneElements"]; getAppState: () => InstanceType["state"]; setCanvasOffsets: InstanceType["setCanvasOffsets"]; + importLibrary: InstanceType["importLibraryFromUrl"]; + setToastMessage: InstanceType["setToastMessage"]; readyPromise: ResolvablePromise; ready: true; }; -class App extends React.Component { +class App extends React.Component { canvas: HTMLCanvasElement | null = null; rc: RoughCanvas | null = null; unmounted: boolean = false; actionManager: ActionManager; private excalidrawContainerRef = React.createRef(); - public static defaultProps: Partial = { - width: window.innerWidth, - height: window.innerHeight, + public static defaultProps: Partial = { + // needed for tests to pass since we directly render App in many tests + UIOptions: DEFAULT_UI_OPTIONS, }; + private scene: Scene; - constructor(props: ExcalidrawProps) { + private resizeObserver: ResizeObserver | undefined; + constructor(props: AppProps) { super(props); const defaultAppState = getDefaultAppState(); - const { - width = window.innerWidth, - height = window.innerHeight, excalidrawRef, viewModeEnabled = false, zenModeEnabled = false, @@ -311,13 +311,13 @@ class App extends React.Component { ...defaultAppState, theme, isLoading: true, - width, - height, ...this.getCanvasOffsets(), viewModeEnabled, zenModeEnabled, gridSize: gridModeEnabled ? GRID_SIZE : null, name, + width: window.innerWidth, + height: window.innerHeight, }; if (excalidrawRef) { const readyPromise = @@ -337,6 +337,8 @@ class App extends React.Component { getSceneElements: this.getSceneElements, getAppState: () => this.state, setCanvasOffsets: this.setCanvasOffsets, + importLibrary: this.importLibraryFromUrl, + setToastMessage: this.setToastMessage, } as const; if (typeof excalidrawRef === "function") { excalidrawRef(api); @@ -423,7 +425,12 @@ class App extends React.Component { viewModeEnabled, } = this.state; - const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props; + const { + onCollabButtonClick, + onExportToBackend, + renderFooter, + renderCustomStats, + } = this.props; const DEFAULT_PASTE_X = canvasDOMWidth / 2; const DEFAULT_PASTE_Y = canvasDOMHeight / 2; @@ -434,10 +441,6 @@ class App extends React.Component { "excalidraw--view-mode": viewModeEnabled, })} ref={this.excalidrawContainerRef} - style={{ - width: canvasDOMWidth, - height: canvasDOMHeight, - }} > { showExitZenModeBtn={ typeof this.props?.zenModeEnabled === "undefined" && zenModeEnabled } - showThemeBtn={typeof this.props?.theme === "undefined"} + showThemeBtn={ + typeof this.props?.theme === "undefined" && + this.props.UIOptions.canvasActions.theme + } libraryReturnUrl={this.props.libraryReturnUrl} + UIOptions={this.props.UIOptions} />
{this.state.showStats && ( @@ -474,6 +481,7 @@ class App extends React.Component { setAppState={this.setAppState} elements={this.scene.getElements()} onClose={this.toggleStats} + renderCustomStats={renderCustomStats} /> )} {this.state.toastMessage !== null && ( @@ -547,7 +555,6 @@ class App extends React.Component { if (typeof this.props.name !== "undefined") { name = this.props.name; } - this.setState( (state) => { // using Object.assign instead of spread to fool TS 4.2.2+ into @@ -556,10 +563,6 @@ class App extends React.Component { return Object.assign(actionResult.appState || {}, { editingElement: editingElement || actionResult.appState?.editingElement || null, - width: state.width, - height: state.height, - offsetTop: state.offsetTop, - offsetLeft: state.offsetLeft, viewModeEnabled, zenModeEnabled, gridSize, @@ -604,8 +607,17 @@ class App extends React.Component { this.onSceneUpdated(); }; - private importLibraryFromUrl = async (url: string) => { - window.history.replaceState({}, APP_NAME, window.location.origin); + private importLibraryFromUrl = async (url: string, token?: string | null) => { + if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) { + const hash = new URLSearchParams(window.location.hash.slice(1)); + hash.delete(URL_HASH_KEYS.addLibrary); + window.history.replaceState({}, APP_NAME, `#${hash.toString()}`); + } else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) { + const query = new URLSearchParams(window.location.search); + query.delete(URL_QUERY_KEYS.addLibrary); + window.history.replaceState({}, APP_NAME, `?${query.toString()}`); + } + try { const request = await fetch(decodeURIComponent(url)); const blob = await request.blob(); @@ -614,14 +626,17 @@ class App extends React.Component { throw new Error(); } if ( + token === Library.csrfToken || window.confirm( t("alerts.confirmAddLibrary", { numShapes: json.library.length }), ) ) { await Library.importLibrary(blob); - this.setState({ - isLibraryOpen: true, - }); + // hack to rerender the library items after import + if (this.state.isLibraryOpen) { + this.setState({ isLibraryOpen: false }); + } + this.setState({ isLibraryOpen: true }); } } catch (error) { window.alert(t("alerts.errorLoadingLibrary")); @@ -680,7 +695,6 @@ class App extends React.Component { if (!this.state.isLoading) { this.setState({ isLoading: true }); } - let initialData = null; try { initialData = (await this.props.initialData) || null; @@ -689,7 +703,6 @@ class App extends React.Component { } const scene = restore(initialData, null); - scene.appState = { ...scene.appState, isLoading: false, @@ -717,12 +730,18 @@ class App extends React.Component { commitToHistory: true, }); - const addToLibraryUrl = new URLSearchParams(window.location.search).get( - "addLibrary", - ); + const libraryUrl = + // current + new URLSearchParams(window.location.hash.slice(1)).get( + URL_HASH_KEYS.addLibrary, + ) || + // legacy, kept for compat reasons + new URLSearchParams(window.location.search).get( + URL_QUERY_KEYS.addLibrary, + ); - if (addToLibraryUrl) { - await this.importLibraryFromUrl(addToLibraryUrl); + if (libraryUrl) { + await this.importLibraryFromUrl(libraryUrl); } }; @@ -755,19 +774,24 @@ class App extends React.Component { this.scene.addCallback(this.onSceneUpdated); this.addEventListeners(); + if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) { + this.resizeObserver = new ResizeObserver(() => { + this.updateDOMRect(); + }); + this.resizeObserver?.observe(this.excalidrawContainerRef.current); + } const searchParams = new URLSearchParams(window.location.search.slice(1)); if (searchParams.has("web-share-target")) { // Obtain a file that was shared via the Web Share Target API. this.restoreFileFromShare(); } else { - this.setState(this.getCanvasOffsets(), () => { - this.initializeScene(); - }); + this.updateDOMRect(this.initializeScene); } } public componentWillUnmount() { + this.resizeObserver?.disconnect(); this.unmounted = true; this.removeEventListeners(); this.scene.destroy(); @@ -859,22 +883,11 @@ class App extends React.Component { window.addEventListener(EVENT.DROP, this.disableEvent, false); } - componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) { + componentDidUpdate(prevProps: AppProps, prevState: AppState) { if (prevProps.langCode !== this.props.langCode) { this.updateLanguage(); } - if ( - prevProps.width !== this.props.width || - prevProps.height !== this.props.height - ) { - this.setState({ - width: this.props.width ?? window.innerWidth, - height: this.props.height ?? window.innerHeight, - ...this.getCanvasOffsets(), - }); - } - if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) { this.setState( { viewModeEnabled: !!this.props.viewModeEnabled }, @@ -1334,6 +1347,10 @@ class App extends React.Component { this.setState({ toastMessage: null }); }; + setToastMessage = (toastMessage: string) => { + this.setState({ toastMessage }); + }; + restoreFileFromShare = async () => { try { const webShareTargetCache = await caches.open("web-share-target"); @@ -1907,8 +1924,7 @@ class App extends React.Component { } resetCursor(this.canvas); - - if (!event[KEYS.CTRL_OR_CMD]) { + if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) { this.startTextEditing({ sceneX, sceneY, @@ -2282,10 +2298,7 @@ class App extends React.Component { touchTimeout = window.setTimeout(() => { touchTimeout = 0; if (!invalidateContextMenu) { - this.openContextMenu({ - clientX: event.clientX, - clientY: event.clientY, - }); + this.handleCanvasContextMenu(event); } }, TOUCH_CTX_MENU_TIMEOUT); } @@ -3632,9 +3645,20 @@ class App extends React.Component { const file = event.dataTransfer?.files[0]; if ( - file?.type === "application/json" || - file?.name.endsWith(".excalidraw") + file?.type === MIME_TYPES.excalidrawlib || + file?.name?.endsWith(".excalidrawlib") ) { + Library.importLibrary(file) + .then(() => { + // Close and then open to get the libraries updated + this.setState({ isLibraryOpen: false }); + this.setState({ isLibraryOpen: true }); + }) + .catch((error) => + this.setState({ isLoading: false, errorMessage: error.message }), + ); + // default: assume an Excalidraw file regardless of extension/MimeType + } else { this.setState({ isLoading: true }); if (supported) { try { @@ -3646,23 +3670,7 @@ class App extends React.Component { console.warn(error.name, error.message); } } - this.loadFileToCanvas(file); - } else if ( - file?.type === MIME_TYPES.excalidrawlib || - file?.name.endsWith(".excalidrawlib") - ) { - Library.importLibrary(file) - .then(() => { - this.setState({ isLibraryOpen: false }); - }) - .catch((error) => - this.setState({ isLoading: false, errorMessage: error.message }), - ); - } else { - this.setState({ - isLoading: false, - errorMessage: t("alerts.couldNotLoadInvalidFile"), - }); + await this.loadFileToCanvas(file); } }; @@ -3687,7 +3695,19 @@ class App extends React.Component { event: React.PointerEvent, ) => { event.preventDefault(); - this.openContextMenu(event); + + const { x, y } = viewportCoordsToSceneCoords(event, this.state); + const element = this.getElementAtPosition(x, y); + + const type = element ? "element" : "canvas"; + + if (element && !this.state.selectedElementIds[element.id]) { + this.setState({ selectedElementIds: { [element.id]: true } }, () => { + this._openContextMenu(event, type); + }); + } else { + this._openContextMenu(event, type); + } }; private maybeDragNewGenericElement = ( @@ -3777,18 +3797,17 @@ class App extends React.Component { return false; }; - private openContextMenu = ({ - clientX, - clientY, - }: { - clientX: number; - clientY: number; - }) => { - const { x, y } = viewportCoordsToSceneCoords( - { clientX, clientY }, - this.state, - ); - + /** @private use this.handleCanvasContextMenu */ + private _openContextMenu = ( + { + clientX, + clientY, + }: { + clientX: number; + clientY: number; + }, + type: "canvas" | "element", + ) => { const maybeGroupAction = actionGroup.contextItemPredicate!( this.actionManager.getElementsIncludingDeleted(), this.actionManager.getAppState(), @@ -3799,12 +3818,22 @@ class App extends React.Component { this.actionManager.getAppState(), ); + const maybeFlipHorizontal = actionFlipHorizontal.contextItemPredicate!( + this.actionManager.getElementsIncludingDeleted(), + this.actionManager.getAppState(), + ); + + const maybeFlipVertical = actionFlipVertical.contextItemPredicate!( + this.actionManager.getElementsIncludingDeleted(), + this.actionManager.getAppState(), + ); + const separator = "separator"; const _isMobile = isMobile(); const elements = this.scene.getElements(); - const element = this.getElementAtPosition(x, y); + const options: ContextMenuOption[] = []; if (probablySupportsClipboardBlob && elements.length > 0) { options.push(actionCopyAsPng); @@ -3813,7 +3842,7 @@ class App extends React.Component { if (probablySupportsClipboardWriteText && elements.length > 0) { options.push(actionCopyAsSvg); } - if (!element) { + if (type === "canvas") { const viewModeOptions = [ ...options, typeof this.props.gridModeEnabled === "undefined" && @@ -3877,10 +3906,6 @@ class App extends React.Component { return; } - if (!this.state.selectedElementIds[element.id]) { - this.setState({ selectedElementIds: { [element.id]: true } }); - } - if (this.state.viewModeEnabled) { ContextMenu.push({ options: [navigator.clipboard && actionCopy, ...options], @@ -3923,6 +3948,9 @@ class App extends React.Component { actionSendToBack, actionBringToFront, separator, + maybeFlipHorizontal && actionFlipHorizontal, + maybeFlipVertical && actionFlipVertical, + (maybeFlipHorizontal || maybeFlipVertical) && separator, actionDuplicateSelection, actionDeleteSelected, ], @@ -4063,14 +4091,56 @@ class App extends React.Component { } }, 300); + private updateDOMRect = (cb?: () => void) => { + if (this.excalidrawContainerRef?.current) { + const excalidrawContainer = this.excalidrawContainerRef.current; + const { + width, + height, + left: offsetLeft, + top: offsetTop, + } = excalidrawContainer.getBoundingClientRect(); + const { + width: currentWidth, + height: currentHeight, + offsetTop: currentOffsetTop, + offsetLeft: currentOffsetLeft, + } = this.state; + + if ( + width === currentWidth && + height === currentHeight && + offsetLeft === currentOffsetLeft && + offsetTop === currentOffsetTop + ) { + if (cb) { + cb(); + } + return; + } + + this.setState( + { + width, + height, + offsetLeft, + offsetTop, + }, + () => { + cb && cb(); + }, + ); + } + }; + public setCanvasOffsets = () => { this.setState({ ...this.getCanvasOffsets() }); }; private getCanvasOffsets(): Pick { - if (this.excalidrawContainerRef?.current?.parentElement) { - const parentElement = this.excalidrawContainerRef.current.parentElement; - const { left, top } = parentElement.getBoundingClientRect(); + if (this.excalidrawContainerRef?.current) { + const excalidrawContainer = this.excalidrawContainerRef.current; + const { left, top } = excalidrawContainer.getBoundingClientRect(); return { offsetLeft: left, offsetTop: top, @@ -4104,9 +4174,6 @@ declare global { history: SceneHistory; app: InstanceType; library: typeof Library; - collab: InstanceType< - typeof import("../excalidraw-app/collab/CollabWrapper").default - >; }; } } diff --git a/src/components/ButtonIconCycle.tsx b/src/components/ButtonIconCycle.tsx index 1159a1f42..98a6aa8ad 100644 --- a/src/components/ButtonIconCycle.tsx +++ b/src/components/ButtonIconCycle.tsx @@ -1,4 +1,3 @@ -import React from "react"; import clsx from "clsx"; export const ButtonIconCycle = ({ @@ -14,11 +13,11 @@ export const ButtonIconCycle = ({ }) => { const current = options.find((op) => op.value === value); - function cycle() { + const cycle = () => { const index = options.indexOf(current!); const next = (index + 1) % options.length; onChange(options[next].value); - } + }; return (