diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index 16f33f82..e8bd520d 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -8,7 +8,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle"; import { loadFromJSON, saveAsJSON } from "../data"; import { resaveAsImageWithScene } from "../data/resave"; import { t } from "../i18n"; -import { useIsMobile } from "../components/App"; +import { useDeviceType } from "../components/App"; import { KEYS } from "../keys"; import { register } from "./register"; import { CheckboxItem } from "../components/CheckboxItem"; @@ -200,7 +200,7 @@ export const actionSaveFileToDisk = register({ icon={saveAs} title={t("buttons.saveAs")} aria-label={t("buttons.saveAs")} - showAriaLabel={useIsMobile()} + showAriaLabel={useDeviceType().isMobile} hidden={!nativeFileSystemSupported} onClick={() => updateData(null)} data-testid="save-as-button" @@ -243,7 +243,7 @@ export const actionLoadScene = register({ icon={load} title={t("buttons.load")} aria-label={t("buttons.load")} - showAriaLabel={useIsMobile()} + showAriaLabel={useDeviceType().isMobile} onClick={updateData} data-testid="load-button" /> diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index e89112af..7e9ba25b 100644 --- a/src/actions/actionFinalize.tsx +++ b/src/actions/actionFinalize.tsx @@ -165,7 +165,7 @@ export const actionFinalize = register({ (!appState.draggingElement && appState.multiElement === null))) || ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && appState.multiElement !== null), - PanelComponent: ({ appState, updateData }) => ( + PanelComponent: ({ appState, updateData, data }) => ( ), }); diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 0efed036..21cbbcf8 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, PointerType } from "../element/types"; import { t } from "../i18n"; -import { useIsMobile } from "../components/App"; +import { useDeviceType } from "../components/App"; import { canChangeSharpness, canHaveArrowheads, @@ -46,7 +46,7 @@ export const SelectedShapeActions = ({ isSingleElementBoundContainer = true; } const isEditing = Boolean(appState.editingElement); - const isMobile = useIsMobile(); + const deviceType = useDeviceType(); const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const showFillIcons = @@ -168,8 +168,8 @@ export const SelectedShapeActions = ({
{t("labels.actions")}
- {!isMobile && renderAction("duplicateSelection")} - {!isMobile && renderAction("deleteSelectedElements")} + {!deviceType.isMobile && renderAction("duplicateSelection")} + {!deviceType.isMobile && renderAction("deleteSelectedElements")} {renderAction("group")} {renderAction("ungroup")} {targetElements.length === 1 && renderAction("hyperlink")} diff --git a/src/components/App.tsx b/src/components/App.tsx index 417969f0..c9c68593 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -195,6 +195,7 @@ import { LibraryItems, PointerDownState, SceneData, + DeviceType, } from "../types"; import { debounce, @@ -214,6 +215,7 @@ import { withBatchedUpdates, wrapEvent, withBatchedUpdatesThrottled, + updateObject, setEraserCursor, } from "../utils"; import ContextMenu, { ContextMenuOption } from "./ContextMenu"; @@ -253,8 +255,12 @@ import { isLocalLink, } from "../element/Hyperlink"; -const IsMobileContext = React.createContext(false); -export const useIsMobile = () => useContext(IsMobileContext); +const defaultDeviceTypeContext: DeviceType = { + isMobile: false, + isTouchScreen: false, +}; +const DeviceTypeContext = React.createContext(defaultDeviceTypeContext); +export const useDeviceType = () => useContext(DeviceTypeContext); const ExcalidrawContainerContext = React.createContext<{ container: HTMLDivElement | null; id: string | null; @@ -286,7 +292,10 @@ class App extends React.Component { rc: RoughCanvas | null = null; unmounted: boolean = false; actionManager: ActionManager; - isMobile = false; + deviceType: DeviceType = { + isMobile: false, + isTouchScreen: false, + }; detachIsMobileMqHandler?: () => void; private excalidrawContainerRef = React.createRef(); @@ -468,7 +477,7 @@ class App extends React.Component {
{ - + { /> )}
{this.renderCanvas()}
-
+
); @@ -891,9 +900,12 @@ class App extends React.Component { // --------------------------------------------------------------------- const { width, height } = this.excalidrawContainerRef.current!.getBoundingClientRect(); - this.isMobile = - width < MQ_MAX_WIDTH_PORTRAIT || - (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE); + this.deviceType = updateObject(this.deviceType, { + isMobile: + width < MQ_MAX_WIDTH_PORTRAIT || + (height < MQ_MAX_HEIGHT_LANDSCAPE && + width < MQ_MAX_WIDTH_LANDSCAPE), + }); // refresh offsets // --------------------------------------------------------------------- this.updateDOMRect(); @@ -903,7 +915,11 @@ class App extends React.Component { const mediaQuery = window.matchMedia( `(max-width: ${MQ_MAX_WIDTH_PORTRAIT}px), (max-height: ${MQ_MAX_HEIGHT_LANDSCAPE}px) and (max-width: ${MQ_MAX_WIDTH_LANDSCAPE}px)`, ); - const handler = () => (this.isMobile = mediaQuery.matches); + const handler = () => { + this.deviceType = updateObject(this.deviceType, { + isMobile: mediaQuery.matches, + }); + }; mediaQuery.addListener(handler); this.detachIsMobileMqHandler = () => mediaQuery.removeListener(handler); } @@ -1205,7 +1221,7 @@ class App extends React.Component { theme: this.state.theme, imageCache: this.imageCache, isExporting: false, - renderScrollbars: !this.isMobile, + renderScrollbars: !this.deviceType.isMobile, }, ); @@ -2391,7 +2407,7 @@ class App extends React.Component { element, this.state, [scenePointer.x, scenePointer.y], - this.isMobile, + this.deviceType.isMobile, ) && index <= hitElementIndex ); @@ -2424,7 +2440,7 @@ class App extends React.Component { this.hitLinkElement!, this.state, [lastPointerDownCoords.x, lastPointerDownCoords.y], - this.isMobile, + this.deviceType.isMobile, ); const lastPointerUpCoords = viewportCoordsToSceneCoords( this.lastPointerUp!, @@ -2434,7 +2450,7 @@ class App extends React.Component { this.hitLinkElement!, this.state, [lastPointerUpCoords.x, lastPointerUpCoords.y], - this.isMobile, + this.deviceType.isMobile, ); if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) { const url = this.hitLinkElement.link; @@ -2856,6 +2872,13 @@ class App extends React.Component { }); } + if ( + !this.deviceType.isTouchScreen && + ["pen", "touch"].includes(event.pointerType) + ) { + this.deviceType = updateObject(this.deviceType, { isTouchScreen: true }); + } + if (isPanning) { return; } @@ -2986,9 +3009,7 @@ class App extends React.Component { event: React.PointerEvent, ) => { this.lastPointerUp = event; - const isTouchScreen = ["pen", "touch"].includes(event.pointerType); - - if (isTouchScreen) { + if (this.deviceType.isTouchScreen) { const scenePointer = viewportCoordsToSceneCoords( { clientX: event.clientX, clientY: event.clientY }, this.state, @@ -3006,7 +3027,7 @@ class App extends React.Component { this.hitLinkElement && !this.state.selectedElementIds[this.hitLinkElement.id] ) { - this.redirectToLink(event, isTouchScreen); + this.redirectToLink(event, this.deviceType.isTouchScreen); } this.removePointer(event); @@ -3376,7 +3397,7 @@ class App extends React.Component { pointerDownState.hit.element, this.state, [pointerDownState.origin.x, pointerDownState.origin.y], - this.isMobile, + this.deviceType.isMobile, ) ) { return false; @@ -5407,7 +5428,7 @@ class App extends React.Component { } else { ContextMenu.push({ options: [ - this.isMobile && + this.deviceType.isMobile && navigator.clipboard && { name: "paste", perform: (elements, appStates) => { @@ -5418,7 +5439,7 @@ class App extends React.Component { }, contextItemLabel: "labels.paste", }, - this.isMobile && navigator.clipboard && separator, + this.deviceType.isMobile && navigator.clipboard && separator, probablySupportsClipboardBlob && elements.length > 0 && actionCopyAsPng, @@ -5464,9 +5485,9 @@ class App extends React.Component { } else { ContextMenu.push({ options: [ - this.isMobile && actionCut, - this.isMobile && navigator.clipboard && actionCopy, - this.isMobile && + this.deviceType.isMobile && actionCut, + this.deviceType.isMobile && navigator.clipboard && actionCopy, + this.deviceType.isMobile && navigator.clipboard && { name: "paste", perform: (elements, appStates) => { @@ -5477,7 +5498,7 @@ class App extends React.Component { }, contextItemLabel: "labels.paste", }, - this.isMobile && separator, + this.deviceType.isMobile && separator, ...options, separator, actionCopyStyles, diff --git a/src/components/ClearCanvas.tsx b/src/components/ClearCanvas.tsx index 4f325df5..6d25a4a1 100644 --- a/src/components/ClearCanvas.tsx +++ b/src/components/ClearCanvas.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { t } from "../i18n"; -import { useIsMobile } from "./App"; +import { useDeviceType } from "./App"; import { trash } from "./icons"; import { ToolButton } from "./ToolButton"; @@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { icon={trash} title={t("buttons.clearReset")} aria-label={t("buttons.clearReset")} - showAriaLabel={useIsMobile()} + showAriaLabel={useDeviceType().isMobile} onClick={toggleDialog} data-testid="clear-canvas-button" /> diff --git a/src/components/CollabButton.tsx b/src/components/CollabButton.tsx index e0bf3600..3e9c370c 100644 --- a/src/components/CollabButton.tsx +++ b/src/components/CollabButton.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import { ToolButton } from "./ToolButton"; import { t } from "../i18n"; -import { useIsMobile } from "../components/App"; +import { useDeviceType } from "../components/App"; import { users } from "./icons"; import "./CollabButton.scss"; @@ -26,7 +26,7 @@ const CollabButton = ({ type="button" title={t("labels.liveCollaboration")} aria-label={t("labels.liveCollaboration")} - showAriaLabel={useIsMobile()} + showAriaLabel={useDeviceType().isMobile} > {collaboratorCount > 0 && (
{collaboratorCount}
diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index cd7573d5..6de3f00b 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import React, { useEffect, useState } from "react"; import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { t } from "../i18n"; -import { useExcalidrawContainer, useIsMobile } from "../components/App"; +import { useExcalidrawContainer, useDeviceType } from "../components/App"; import { KEYS } from "../keys"; import "./Dialog.scss"; import { back, close } from "./icons"; @@ -94,7 +94,7 @@ export const Dialog = (props: DialogProps) => { onClick={onClose} aria-label={t("buttons.close")} > - {useIsMobile() ? back : close} + {useDeviceType().isMobile ? back : close}
{props.children}
diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index 3fafec65..ca2e9077 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -6,7 +6,7 @@ import { canvasToBlob } from "../data/blob"; import { NonDeletedExcalidrawElement } from "../element/types"; import { CanvasError } from "../errors"; import { t } from "../i18n"; -import { useIsMobile } from "./App"; +import { useDeviceType } from "./App"; import { getSelectedElements, isSomeElementSelected } from "../scene"; import { exportToCanvas } from "../scene/export"; import { AppState, BinaryFiles } from "../types"; @@ -250,7 +250,7 @@ export const ImageExportDialog = ({ icon={exportImage} type="button" aria-label={t("buttons.exportImage")} - showAriaLabel={useIsMobile()} + showAriaLabel={useDeviceType().isMobile} title={t("buttons.exportImage")} /> {modalIsShown && ( diff --git a/src/components/JSONExportDialog.tsx b/src/components/JSONExportDialog.tsx index 6ecc850d..0b367230 100644 --- a/src/components/JSONExportDialog.tsx +++ b/src/components/JSONExportDialog.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { ActionsManagerInterface } from "../actions/types"; import { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; -import { useIsMobile } from "./App"; +import { useDeviceType } from "./App"; import { AppState, ExportOpts, BinaryFiles } from "../types"; import { Dialog } from "./Dialog"; import { exportFile, exportToFileIcon, link } from "./icons"; @@ -114,7 +114,7 @@ export const JSONExportDialog = ({ icon={exportFile} type="button" aria-label={t("buttons.export")} - showAriaLabel={useIsMobile()} + showAriaLabel={useDeviceType().isMobile} title={t("buttons.export")} /> {modalIsShown && ( diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 4c887773..ef1df4bb 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -6,7 +6,6 @@ import { exportCanvas } from "../data"; import { isTextElement, showSelectedShapeActions } from "../element"; import { NonDeletedExcalidrawElement } from "../element/types"; import { Language, t } from "../i18n"; -import { useIsMobile } from "../components/App"; import { calculateScrollCenter, getSelectedElements } from "../scene"; import { ExportType } from "../scene/types"; import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; @@ -37,6 +36,7 @@ import { LibraryMenu } from "./LibraryMenu"; import "./LayerUI.scss"; import "./Toolbar.scss"; import { PenModeButton } from "./PenModeButton"; +import { useDeviceType } from "../components/App"; interface LayerUIProps { actionManager: ActionManager; @@ -95,7 +95,7 @@ const LayerUI = ({ id, onImageAction, }: LayerUIProps) => { - const isMobile = useIsMobile(); + const deviceType = useDeviceType(); const renderJSONExportDialog = () => { if (!UIOptions.canvasActions.export) { @@ -338,7 +338,7 @@ const LayerUI = ({ {heading} @@ -389,7 +389,7 @@ const LayerUI = ({ ))} - {renderTopRightUI?.(isMobile, appState)} + {renderTopRightUI?.(deviceType.isMobile, appState)}
@@ -440,6 +440,18 @@ const LayerUI = ({ )} + {!viewModeEnabled && + appState.multiElement && + deviceType.isTouchScreen && ( +
+ {actionManager.renderAction("finalize", { size: "small" })} +
+ )} @@ -507,7 +519,7 @@ const LayerUI = ({ ); - return isMobile ? ( + return deviceType.isMobile ? ( <> {dialogs} { const itemsSelected = !!selectedItems.length; diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index 46b0e8d4..a24a4c5c 100644 --- a/src/components/LibraryUnit.tsx +++ b/src/components/LibraryUnit.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import oc from "open-color"; import { useEffect, useRef, useState } from "react"; import { MIME_TYPES } from "../constants"; -import { useIsMobile } from "../components/App"; +import { useDeviceType } from "../components/App"; import { exportToSvg } from "../scene/export"; import { BinaryFiles, LibraryItem } from "../types"; import "./LibraryUnit.scss"; @@ -66,7 +66,7 @@ export const LibraryUnit = ({ }, [elements, files]); const [isHovered, setIsHovered] = useState(false); - const isMobile = useIsMobile(); + const isMobile = useDeviceType().isMobile; const adder = isPending && (
{PLUS_ICON}
); diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 54baa798..2c2a339b 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react"; import { createPortal } from "react-dom"; import clsx from "clsx"; import { KEYS } from "../keys"; -import { useExcalidrawContainer, useIsMobile } from "./App"; +import { useExcalidrawContainer, useDeviceType } from "./App"; import { AppState } from "../types"; import { THEME } from "../constants"; @@ -59,17 +59,17 @@ export const Modal = (props: { const useBodyRoot = (theme: AppState["theme"]) => { const [div, setDiv] = useState(null); - const isMobile = useIsMobile(); - const isMobileRef = useRef(isMobile); - isMobileRef.current = isMobile; + const deviceType = useDeviceType(); + const isMobileRef = useRef(deviceType.isMobile); + isMobileRef.current = deviceType.isMobile; const { container: excalidrawContainer } = useExcalidrawContainer(); useLayoutEffect(() => { if (div) { - div.classList.toggle("excalidraw--mobile", isMobile); + div.classList.toggle("excalidraw--mobile", deviceType.isMobile); } - }, [div, isMobile]); + }, [div, deviceType.isMobile]); useLayoutEffect(() => { const isDarkTheme = diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx index 6df7f850..61b2df09 100644 --- a/src/components/Stats.tsx +++ b/src/components/Stats.tsx @@ -2,7 +2,7 @@ import React from "react"; import { getCommonBounds } from "../element/bounds"; import { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; -import { useIsMobile } from "../components/App"; +import { useDeviceType } from "../components/App"; import { getTargetElements } from "../scene"; import { AppState, ExcalidrawProps } from "../types"; import { close } from "./icons"; @@ -16,13 +16,13 @@ export const Stats = (props: { onClose: () => void; renderCustomStats: ExcalidrawProps["renderCustomStats"]; }) => { - const isMobile = useIsMobile(); + const deviceType = useDeviceType(); const boundingBox = getCommonBounds(props.elements); const selectedElements = getTargetElements(props.elements, props.appState); const selectedBoundingBox = getCommonBounds(selectedElements); - if (isMobile && props.appState.openMenu) { + if (deviceType.isMobile && props.appState.openMenu) { return null; } diff --git a/src/css/styles.scss b/src/css/styles.scss index aef449a4..0de93901 100644 --- a/src/css/styles.scss +++ b/src/css/styles.scss @@ -477,6 +477,15 @@ font-family: var(--ui-font); } + .finalize-button { + display: grid; + grid-auto-flow: column; + gap: 0.4em; + margin-top: auto; + margin-bottom: auto; + margin-inline-start: 0.6em; + } + .undo-redo-buttons, .eraser-buttons { display: grid; diff --git a/src/types.ts b/src/types.ts index 71c2b53e..b2597d17 100644 --- a/src/types.ts +++ b/src/types.ts @@ -408,3 +408,8 @@ export type ExcalidrawImperativeAPI = { ready: true; id: string; }; + +export type DeviceType = { + isMobile: boolean; + isTouchScreen: boolean; +}; diff --git a/src/utils.ts b/src/utils.ts index e65fe967..36482867 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -583,3 +583,32 @@ export const wrapEvent = (name: EVENT, nativeEvent: T) => { cancelable: true, }); }; + +export const updateObject = >( + obj: T, + updates: Partial, +): T => { + let didChange = false; + for (const key in updates) { + const value = (updates as any)[key]; + if (typeof value !== "undefined") { + if ( + (obj as any)[key] === value && + // if object, always update because its attrs could have changed + (typeof value !== "object" || value === null) + ) { + continue; + } + didChange = true; + } + } + + if (!didChange) { + return obj; + } + + return { + ...obj, + ...updates, + }; +};