Explorar o código

fix: Add multiElement-edit finalize action to Desktop (currently only visible in Mobile view) (#4764)

* add finalize action to Desktop UI

* Update LayerUI.tsx

* add size to panel component

* finzalize button style

* add finalize button

* changed isMobile to DeviceInfo, added isTouchScreen

* cleanup

* rename deviceInfo to deviceType

* rename deviceInfo to deviceType

* added updateObject

* Update App.tsx
zsviczian %!s(int64=3) %!d(string=hai) anos
pai
achega
192debd829

+ 3 - 3
src/actions/actionExport.tsx

@@ -8,7 +8,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
 import { loadFromJSON, saveAsJSON } from "../data";
 import { loadFromJSON, saveAsJSON } from "../data";
 import { resaveAsImageWithScene } from "../data/resave";
 import { resaveAsImageWithScene } from "../data/resave";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useIsMobile } from "../components/App";
+import { useDeviceType } from "../components/App";
 import { KEYS } from "../keys";
 import { KEYS } from "../keys";
 import { register } from "./register";
 import { register } from "./register";
 import { CheckboxItem } from "../components/CheckboxItem";
 import { CheckboxItem } from "../components/CheckboxItem";
@@ -200,7 +200,7 @@ export const actionSaveFileToDisk = register({
       icon={saveAs}
       icon={saveAs}
       title={t("buttons.saveAs")}
       title={t("buttons.saveAs")}
       aria-label={t("buttons.saveAs")}
       aria-label={t("buttons.saveAs")}
-      showAriaLabel={useIsMobile()}
+      showAriaLabel={useDeviceType().isMobile}
       hidden={!nativeFileSystemSupported}
       hidden={!nativeFileSystemSupported}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
       data-testid="save-as-button"
       data-testid="save-as-button"
@@ -243,7 +243,7 @@ export const actionLoadScene = register({
       icon={load}
       icon={load}
       title={t("buttons.load")}
       title={t("buttons.load")}
       aria-label={t("buttons.load")}
       aria-label={t("buttons.load")}
-      showAriaLabel={useIsMobile()}
+      showAriaLabel={useDeviceType().isMobile}
       onClick={updateData}
       onClick={updateData}
       data-testid="load-button"
       data-testid="load-button"
     />
     />

+ 2 - 1
src/actions/actionFinalize.tsx

@@ -165,7 +165,7 @@ export const actionFinalize = register({
         (!appState.draggingElement && appState.multiElement === null))) ||
         (!appState.draggingElement && appState.multiElement === null))) ||
     ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
     ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
       appState.multiElement !== null),
       appState.multiElement !== null),
-  PanelComponent: ({ appState, updateData }) => (
+  PanelComponent: ({ appState, updateData, data }) => (
     <ToolButton
     <ToolButton
       type="button"
       type="button"
       icon={done}
       icon={done}
@@ -173,6 +173,7 @@ export const actionFinalize = register({
       aria-label={t("buttons.done")}
       aria-label={t("buttons.done")}
       onClick={updateData}
       onClick={updateData}
       visible={appState.multiElement != null}
       visible={appState.multiElement != null}
+      size={data?.size || "medium"}
     />
     />
   ),
   ),
 });
 });

+ 4 - 4
src/components/Actions.tsx

@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
 import { getNonDeletedElements } from "../element";
 import { getNonDeletedElements } from "../element";
 import { ExcalidrawElement, PointerType } from "../element/types";
 import { ExcalidrawElement, PointerType } from "../element/types";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useIsMobile } from "../components/App";
+import { useDeviceType } from "../components/App";
 import {
 import {
   canChangeSharpness,
   canChangeSharpness,
   canHaveArrowheads,
   canHaveArrowheads,
@@ -46,7 +46,7 @@ export const SelectedShapeActions = ({
     isSingleElementBoundContainer = true;
     isSingleElementBoundContainer = true;
   }
   }
   const isEditing = Boolean(appState.editingElement);
   const isEditing = Boolean(appState.editingElement);
-  const isMobile = useIsMobile();
+  const deviceType = useDeviceType();
   const isRTL = document.documentElement.getAttribute("dir") === "rtl";
   const isRTL = document.documentElement.getAttribute("dir") === "rtl";
 
 
   const showFillIcons =
   const showFillIcons =
@@ -168,8 +168,8 @@ export const SelectedShapeActions = ({
         <fieldset>
         <fieldset>
           <legend>{t("labels.actions")}</legend>
           <legend>{t("labels.actions")}</legend>
           <div className="buttonList">
           <div className="buttonList">
-            {!isMobile && renderAction("duplicateSelection")}
-            {!isMobile && renderAction("deleteSelectedElements")}
+            {!deviceType.isMobile && renderAction("duplicateSelection")}
+            {!deviceType.isMobile && renderAction("deleteSelectedElements")}
             {renderAction("group")}
             {renderAction("group")}
             {renderAction("ungroup")}
             {renderAction("ungroup")}
             {targetElements.length === 1 && renderAction("hyperlink")}
             {targetElements.length === 1 && renderAction("hyperlink")}

+ 46 - 25
src/components/App.tsx

@@ -195,6 +195,7 @@ import {
   LibraryItems,
   LibraryItems,
   PointerDownState,
   PointerDownState,
   SceneData,
   SceneData,
+  DeviceType,
 } from "../types";
 } from "../types";
 import {
 import {
   debounce,
   debounce,
@@ -214,6 +215,7 @@ import {
   withBatchedUpdates,
   withBatchedUpdates,
   wrapEvent,
   wrapEvent,
   withBatchedUpdatesThrottled,
   withBatchedUpdatesThrottled,
+  updateObject,
   setEraserCursor,
   setEraserCursor,
 } from "../utils";
 } from "../utils";
 import ContextMenu, { ContextMenuOption } from "./ContextMenu";
 import ContextMenu, { ContextMenuOption } from "./ContextMenu";
@@ -253,8 +255,12 @@ import {
   isLocalLink,
   isLocalLink,
 } from "../element/Hyperlink";
 } 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<{
 const ExcalidrawContainerContext = React.createContext<{
   container: HTMLDivElement | null;
   container: HTMLDivElement | null;
   id: string | null;
   id: string | null;
@@ -286,7 +292,10 @@ class App extends React.Component<AppProps, AppState> {
   rc: RoughCanvas | null = null;
   rc: RoughCanvas | null = null;
   unmounted: boolean = false;
   unmounted: boolean = false;
   actionManager: ActionManager;
   actionManager: ActionManager;
-  isMobile = false;
+  deviceType: DeviceType = {
+    isMobile: false,
+    isTouchScreen: false,
+  };
   detachIsMobileMqHandler?: () => void;
   detachIsMobileMqHandler?: () => void;
 
 
   private excalidrawContainerRef = React.createRef<HTMLDivElement>();
   private excalidrawContainerRef = React.createRef<HTMLDivElement>();
@@ -468,7 +477,7 @@ class App extends React.Component<AppProps, AppState> {
       <div
       <div
         className={clsx("excalidraw excalidraw-container", {
         className={clsx("excalidraw excalidraw-container", {
           "excalidraw--view-mode": viewModeEnabled,
           "excalidraw--view-mode": viewModeEnabled,
-          "excalidraw--mobile": this.isMobile,
+          "excalidraw--mobile": this.deviceType.isMobile,
         })}
         })}
         ref={this.excalidrawContainerRef}
         ref={this.excalidrawContainerRef}
         onDrop={this.handleAppOnDrop}
         onDrop={this.handleAppOnDrop}
@@ -480,7 +489,7 @@ class App extends React.Component<AppProps, AppState> {
         <ExcalidrawContainerContext.Provider
         <ExcalidrawContainerContext.Provider
           value={this.excalidrawContainerValue}
           value={this.excalidrawContainerValue}
         >
         >
-          <IsMobileContext.Provider value={this.isMobile}>
+          <DeviceTypeContext.Provider value={this.deviceType}>
             <LayerUI
             <LayerUI
               canvas={this.canvas}
               canvas={this.canvas}
               appState={this.state}
               appState={this.state}
@@ -547,7 +556,7 @@ class App extends React.Component<AppProps, AppState> {
               />
               />
             )}
             )}
             <main>{this.renderCanvas()}</main>
             <main>{this.renderCanvas()}</main>
-          </IsMobileContext.Provider>
+          </DeviceTypeContext.Provider>
         </ExcalidrawContainerContext.Provider>
         </ExcalidrawContainerContext.Provider>
       </div>
       </div>
     );
     );
@@ -891,9 +900,12 @@ class App extends React.Component<AppProps, AppState> {
         // ---------------------------------------------------------------------
         // ---------------------------------------------------------------------
         const { width, height } =
         const { width, height } =
           this.excalidrawContainerRef.current!.getBoundingClientRect();
           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
         // refresh offsets
         // ---------------------------------------------------------------------
         // ---------------------------------------------------------------------
         this.updateDOMRect();
         this.updateDOMRect();
@@ -903,7 +915,11 @@ class App extends React.Component<AppProps, AppState> {
       const mediaQuery = window.matchMedia(
       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)`,
         `(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);
       mediaQuery.addListener(handler);
       this.detachIsMobileMqHandler = () => mediaQuery.removeListener(handler);
       this.detachIsMobileMqHandler = () => mediaQuery.removeListener(handler);
     }
     }
@@ -1205,7 +1221,7 @@ class App extends React.Component<AppProps, AppState> {
         theme: this.state.theme,
         theme: this.state.theme,
         imageCache: this.imageCache,
         imageCache: this.imageCache,
         isExporting: false,
         isExporting: false,
-        renderScrollbars: !this.isMobile,
+        renderScrollbars: !this.deviceType.isMobile,
       },
       },
     );
     );
 
 
@@ -2391,7 +2407,7 @@ class App extends React.Component<AppProps, AppState> {
           element,
           element,
           this.state,
           this.state,
           [scenePointer.x, scenePointer.y],
           [scenePointer.x, scenePointer.y],
-          this.isMobile,
+          this.deviceType.isMobile,
         ) &&
         ) &&
         index <= hitElementIndex
         index <= hitElementIndex
       );
       );
@@ -2424,7 +2440,7 @@ class App extends React.Component<AppProps, AppState> {
       this.hitLinkElement!,
       this.hitLinkElement!,
       this.state,
       this.state,
       [lastPointerDownCoords.x, lastPointerDownCoords.y],
       [lastPointerDownCoords.x, lastPointerDownCoords.y],
-      this.isMobile,
+      this.deviceType.isMobile,
     );
     );
     const lastPointerUpCoords = viewportCoordsToSceneCoords(
     const lastPointerUpCoords = viewportCoordsToSceneCoords(
       this.lastPointerUp!,
       this.lastPointerUp!,
@@ -2434,7 +2450,7 @@ class App extends React.Component<AppProps, AppState> {
       this.hitLinkElement!,
       this.hitLinkElement!,
       this.state,
       this.state,
       [lastPointerUpCoords.x, lastPointerUpCoords.y],
       [lastPointerUpCoords.x, lastPointerUpCoords.y],
-      this.isMobile,
+      this.deviceType.isMobile,
     );
     );
     if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
     if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
       const url = this.hitLinkElement.link;
       const url = this.hitLinkElement.link;
@@ -2856,6 +2872,13 @@ class App extends React.Component<AppProps, AppState> {
       });
       });
     }
     }
 
 
+    if (
+      !this.deviceType.isTouchScreen &&
+      ["pen", "touch"].includes(event.pointerType)
+    ) {
+      this.deviceType = updateObject(this.deviceType, { isTouchScreen: true });
+    }
+
     if (isPanning) {
     if (isPanning) {
       return;
       return;
     }
     }
@@ -2986,9 +3009,7 @@ class App extends React.Component<AppProps, AppState> {
     event: React.PointerEvent<HTMLCanvasElement>,
     event: React.PointerEvent<HTMLCanvasElement>,
   ) => {
   ) => {
     this.lastPointerUp = event;
     this.lastPointerUp = event;
-    const isTouchScreen = ["pen", "touch"].includes(event.pointerType);
-
-    if (isTouchScreen) {
+    if (this.deviceType.isTouchScreen) {
       const scenePointer = viewportCoordsToSceneCoords(
       const scenePointer = viewportCoordsToSceneCoords(
         { clientX: event.clientX, clientY: event.clientY },
         { clientX: event.clientX, clientY: event.clientY },
         this.state,
         this.state,
@@ -3006,7 +3027,7 @@ class App extends React.Component<AppProps, AppState> {
       this.hitLinkElement &&
       this.hitLinkElement &&
       !this.state.selectedElementIds[this.hitLinkElement.id]
       !this.state.selectedElementIds[this.hitLinkElement.id]
     ) {
     ) {
-      this.redirectToLink(event, isTouchScreen);
+      this.redirectToLink(event, this.deviceType.isTouchScreen);
     }
     }
 
 
     this.removePointer(event);
     this.removePointer(event);
@@ -3376,7 +3397,7 @@ class App extends React.Component<AppProps, AppState> {
               pointerDownState.hit.element,
               pointerDownState.hit.element,
               this.state,
               this.state,
               [pointerDownState.origin.x, pointerDownState.origin.y],
               [pointerDownState.origin.x, pointerDownState.origin.y],
-              this.isMobile,
+              this.deviceType.isMobile,
             )
             )
           ) {
           ) {
             return false;
             return false;
@@ -5407,7 +5428,7 @@ class App extends React.Component<AppProps, AppState> {
       } else {
       } else {
         ContextMenu.push({
         ContextMenu.push({
           options: [
           options: [
-            this.isMobile &&
+            this.deviceType.isMobile &&
               navigator.clipboard && {
               navigator.clipboard && {
                 name: "paste",
                 name: "paste",
                 perform: (elements, appStates) => {
                 perform: (elements, appStates) => {
@@ -5418,7 +5439,7 @@ class App extends React.Component<AppProps, AppState> {
                 },
                 },
                 contextItemLabel: "labels.paste",
                 contextItemLabel: "labels.paste",
               },
               },
-            this.isMobile && navigator.clipboard && separator,
+            this.deviceType.isMobile && navigator.clipboard && separator,
             probablySupportsClipboardBlob &&
             probablySupportsClipboardBlob &&
               elements.length > 0 &&
               elements.length > 0 &&
               actionCopyAsPng,
               actionCopyAsPng,
@@ -5464,9 +5485,9 @@ class App extends React.Component<AppProps, AppState> {
       } else {
       } else {
         ContextMenu.push({
         ContextMenu.push({
           options: [
           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 && {
               navigator.clipboard && {
                 name: "paste",
                 name: "paste",
                 perform: (elements, appStates) => {
                 perform: (elements, appStates) => {
@@ -5477,7 +5498,7 @@ class App extends React.Component<AppProps, AppState> {
                 },
                 },
                 contextItemLabel: "labels.paste",
                 contextItemLabel: "labels.paste",
               },
               },
-            this.isMobile && separator,
+            this.deviceType.isMobile && separator,
             ...options,
             ...options,
             separator,
             separator,
             actionCopyStyles,
             actionCopyStyles,

+ 2 - 2
src/components/ClearCanvas.tsx

@@ -1,6 +1,6 @@
 import { useState } from "react";
 import { useState } from "react";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useIsMobile } from "./App";
+import { useDeviceType } from "./App";
 import { trash } from "./icons";
 import { trash } from "./icons";
 import { ToolButton } from "./ToolButton";
 import { ToolButton } from "./ToolButton";
 
 
@@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
         icon={trash}
         icon={trash}
         title={t("buttons.clearReset")}
         title={t("buttons.clearReset")}
         aria-label={t("buttons.clearReset")}
         aria-label={t("buttons.clearReset")}
-        showAriaLabel={useIsMobile()}
+        showAriaLabel={useDeviceType().isMobile}
         onClick={toggleDialog}
         onClick={toggleDialog}
         data-testid="clear-canvas-button"
         data-testid="clear-canvas-button"
       />
       />

+ 2 - 2
src/components/CollabButton.tsx

@@ -1,7 +1,7 @@
 import clsx from "clsx";
 import clsx from "clsx";
 import { ToolButton } from "./ToolButton";
 import { ToolButton } from "./ToolButton";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useIsMobile } from "../components/App";
+import { useDeviceType } from "../components/App";
 import { users } from "./icons";
 import { users } from "./icons";
 
 
 import "./CollabButton.scss";
 import "./CollabButton.scss";
@@ -26,7 +26,7 @@ const CollabButton = ({
         type="button"
         type="button"
         title={t("labels.liveCollaboration")}
         title={t("labels.liveCollaboration")}
         aria-label={t("labels.liveCollaboration")}
         aria-label={t("labels.liveCollaboration")}
-        showAriaLabel={useIsMobile()}
+        showAriaLabel={useDeviceType().isMobile}
       >
       >
         {collaboratorCount > 0 && (
         {collaboratorCount > 0 && (
           <div className="CollabButton-collaborators">{collaboratorCount}</div>
           <div className="CollabButton-collaborators">{collaboratorCount}</div>

+ 2 - 2
src/components/Dialog.tsx

@@ -2,7 +2,7 @@ import clsx from "clsx";
 import React, { useEffect, useState } from "react";
 import React, { useEffect, useState } from "react";
 import { useCallbackRefState } from "../hooks/useCallbackRefState";
 import { useCallbackRefState } from "../hooks/useCallbackRefState";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useExcalidrawContainer, useIsMobile } from "../components/App";
+import { useExcalidrawContainer, useDeviceType } from "../components/App";
 import { KEYS } from "../keys";
 import { KEYS } from "../keys";
 import "./Dialog.scss";
 import "./Dialog.scss";
 import { back, close } from "./icons";
 import { back, close } from "./icons";
@@ -94,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
             onClick={onClose}
             onClick={onClose}
             aria-label={t("buttons.close")}
             aria-label={t("buttons.close")}
           >
           >
-            {useIsMobile() ? back : close}
+            {useDeviceType().isMobile ? back : close}
           </button>
           </button>
         </h2>
         </h2>
         <div className="Dialog__content">{props.children}</div>
         <div className="Dialog__content">{props.children}</div>

+ 2 - 2
src/components/ImageExportDialog.tsx

@@ -6,7 +6,7 @@ import { canvasToBlob } from "../data/blob";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { CanvasError } from "../errors";
 import { CanvasError } from "../errors";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useIsMobile } from "./App";
+import { useDeviceType } from "./App";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { exportToCanvas } from "../scene/export";
 import { exportToCanvas } from "../scene/export";
 import { AppState, BinaryFiles } from "../types";
 import { AppState, BinaryFiles } from "../types";
@@ -250,7 +250,7 @@ export const ImageExportDialog = ({
         icon={exportImage}
         icon={exportImage}
         type="button"
         type="button"
         aria-label={t("buttons.exportImage")}
         aria-label={t("buttons.exportImage")}
-        showAriaLabel={useIsMobile()}
+        showAriaLabel={useDeviceType().isMobile}
         title={t("buttons.exportImage")}
         title={t("buttons.exportImage")}
       />
       />
       {modalIsShown && (
       {modalIsShown && (

+ 2 - 2
src/components/JSONExportDialog.tsx

@@ -2,7 +2,7 @@ import React, { useState } from "react";
 import { ActionsManagerInterface } from "../actions/types";
 import { ActionsManagerInterface } from "../actions/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useIsMobile } from "./App";
+import { useDeviceType } from "./App";
 import { AppState, ExportOpts, BinaryFiles } from "../types";
 import { AppState, ExportOpts, BinaryFiles } from "../types";
 import { Dialog } from "./Dialog";
 import { Dialog } from "./Dialog";
 import { exportFile, exportToFileIcon, link } from "./icons";
 import { exportFile, exportToFileIcon, link } from "./icons";
@@ -114,7 +114,7 @@ export const JSONExportDialog = ({
         icon={exportFile}
         icon={exportFile}
         type="button"
         type="button"
         aria-label={t("buttons.export")}
         aria-label={t("buttons.export")}
-        showAriaLabel={useIsMobile()}
+        showAriaLabel={useDeviceType().isMobile}
         title={t("buttons.export")}
         title={t("buttons.export")}
       />
       />
       {modalIsShown && (
       {modalIsShown && (

+ 17 - 5
src/components/LayerUI.tsx

@@ -6,7 +6,6 @@ import { exportCanvas } from "../data";
 import { isTextElement, showSelectedShapeActions } from "../element";
 import { isTextElement, showSelectedShapeActions } from "../element";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { Language, t } from "../i18n";
 import { Language, t } from "../i18n";
-import { useIsMobile } from "../components/App";
 import { calculateScrollCenter, getSelectedElements } from "../scene";
 import { calculateScrollCenter, getSelectedElements } from "../scene";
 import { ExportType } from "../scene/types";
 import { ExportType } from "../scene/types";
 import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
 import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
@@ -37,6 +36,7 @@ import { LibraryMenu } from "./LibraryMenu";
 import "./LayerUI.scss";
 import "./LayerUI.scss";
 import "./Toolbar.scss";
 import "./Toolbar.scss";
 import { PenModeButton } from "./PenModeButton";
 import { PenModeButton } from "./PenModeButton";
+import { useDeviceType } from "../components/App";
 
 
 interface LayerUIProps {
 interface LayerUIProps {
   actionManager: ActionManager;
   actionManager: ActionManager;
@@ -95,7 +95,7 @@ const LayerUI = ({
   id,
   id,
   onImageAction,
   onImageAction,
 }: LayerUIProps) => {
 }: LayerUIProps) => {
-  const isMobile = useIsMobile();
+  const deviceType = useDeviceType();
 
 
   const renderJSONExportDialog = () => {
   const renderJSONExportDialog = () => {
     if (!UIOptions.canvasActions.export) {
     if (!UIOptions.canvasActions.export) {
@@ -338,7 +338,7 @@ const LayerUI = ({
                       <HintViewer
                       <HintViewer
                         appState={appState}
                         appState={appState}
                         elements={elements}
                         elements={elements}
-                        isMobile={isMobile}
+                        isMobile={deviceType.isMobile}
                       />
                       />
                       {heading}
                       {heading}
                       <Stack.Row gap={1}>
                       <Stack.Row gap={1}>
@@ -389,7 +389,7 @@ const LayerUI = ({
                     </Tooltip>
                     </Tooltip>
                   ))}
                   ))}
             </UserList>
             </UserList>
-            {renderTopRightUI?.(isMobile, appState)}
+            {renderTopRightUI?.(deviceType.isMobile, appState)}
           </div>
           </div>
         </div>
         </div>
       </FixedSideContainer>
       </FixedSideContainer>
@@ -440,6 +440,18 @@ const LayerUI = ({
                   </div>
                   </div>
                 </>
                 </>
               )}
               )}
+              {!viewModeEnabled &&
+                appState.multiElement &&
+                deviceType.isTouchScreen && (
+                  <div
+                    className={clsx("finalize-button zen-mode-transition", {
+                      "layer-ui__wrapper__footer-left--transition-left":
+                        zenModeEnabled,
+                    })}
+                  >
+                    {actionManager.renderAction("finalize", { size: "small" })}
+                  </div>
+                )}
             </Section>
             </Section>
           </Stack.Col>
           </Stack.Col>
         </div>
         </div>
@@ -507,7 +519,7 @@ const LayerUI = ({
     </>
     </>
   );
   );
 
 
-  return isMobile ? (
+  return deviceType.isMobile ? (
     <>
     <>
       {dialogs}
       {dialogs}
       <MobileMenu
       <MobileMenu

+ 2 - 2
src/components/LibraryMenuItems.tsx

@@ -12,7 +12,7 @@ import {
   LibraryItems,
   LibraryItems,
 } from "../types";
 } from "../types";
 import { muteFSAbortError } from "../utils";
 import { muteFSAbortError } from "../utils";
-import { useIsMobile } from "./App";
+import { useDeviceType } from "./App";
 import ConfirmDialog from "./ConfirmDialog";
 import ConfirmDialog from "./ConfirmDialog";
 import { exportToFileIcon, load, publishIcon, trash } from "./icons";
 import { exportToFileIcon, load, publishIcon, trash } from "./icons";
 import { LibraryUnit } from "./LibraryUnit";
 import { LibraryUnit } from "./LibraryUnit";
@@ -85,7 +85,7 @@ const LibraryMenuItems = ({
 
 
   const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
   const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
 
 
-  const isMobile = useIsMobile();
+  const isMobile = useDeviceType().isMobile;
 
 
   const renderLibraryActions = () => {
   const renderLibraryActions = () => {
     const itemsSelected = !!selectedItems.length;
     const itemsSelected = !!selectedItems.length;

+ 2 - 2
src/components/LibraryUnit.tsx

@@ -2,7 +2,7 @@ import clsx from "clsx";
 import oc from "open-color";
 import oc from "open-color";
 import { useEffect, useRef, useState } from "react";
 import { useEffect, useRef, useState } from "react";
 import { MIME_TYPES } from "../constants";
 import { MIME_TYPES } from "../constants";
-import { useIsMobile } from "../components/App";
+import { useDeviceType } from "../components/App";
 import { exportToSvg } from "../scene/export";
 import { exportToSvg } from "../scene/export";
 import { BinaryFiles, LibraryItem } from "../types";
 import { BinaryFiles, LibraryItem } from "../types";
 import "./LibraryUnit.scss";
 import "./LibraryUnit.scss";
@@ -66,7 +66,7 @@ export const LibraryUnit = ({
   }, [elements, files]);
   }, [elements, files]);
 
 
   const [isHovered, setIsHovered] = useState(false);
   const [isHovered, setIsHovered] = useState(false);
-  const isMobile = useIsMobile();
+  const isMobile = useDeviceType().isMobile;
   const adder = isPending && (
   const adder = isPending && (
     <div className="library-unit__adder">{PLUS_ICON}</div>
     <div className="library-unit__adder">{PLUS_ICON}</div>
   );
   );

+ 6 - 6
src/components/Modal.tsx

@@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
 import { createPortal } from "react-dom";
 import { createPortal } from "react-dom";
 import clsx from "clsx";
 import clsx from "clsx";
 import { KEYS } from "../keys";
 import { KEYS } from "../keys";
-import { useExcalidrawContainer, useIsMobile } from "./App";
+import { useExcalidrawContainer, useDeviceType } from "./App";
 import { AppState } from "../types";
 import { AppState } from "../types";
 import { THEME } from "../constants";
 import { THEME } from "../constants";
 
 
@@ -59,17 +59,17 @@ export const Modal = (props: {
 const useBodyRoot = (theme: AppState["theme"]) => {
 const useBodyRoot = (theme: AppState["theme"]) => {
   const [div, setDiv] = useState<HTMLDivElement | null>(null);
   const [div, setDiv] = useState<HTMLDivElement | null>(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();
   const { container: excalidrawContainer } = useExcalidrawContainer();
 
 
   useLayoutEffect(() => {
   useLayoutEffect(() => {
     if (div) {
     if (div) {
-      div.classList.toggle("excalidraw--mobile", isMobile);
+      div.classList.toggle("excalidraw--mobile", deviceType.isMobile);
     }
     }
-  }, [div, isMobile]);
+  }, [div, deviceType.isMobile]);
 
 
   useLayoutEffect(() => {
   useLayoutEffect(() => {
     const isDarkTheme =
     const isDarkTheme =

+ 3 - 3
src/components/Stats.tsx

@@ -2,7 +2,7 @@ import React from "react";
 import { getCommonBounds } from "../element/bounds";
 import { getCommonBounds } from "../element/bounds";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useIsMobile } from "../components/App";
+import { useDeviceType } from "../components/App";
 import { getTargetElements } from "../scene";
 import { getTargetElements } from "../scene";
 import { AppState, ExcalidrawProps } from "../types";
 import { AppState, ExcalidrawProps } from "../types";
 import { close } from "./icons";
 import { close } from "./icons";
@@ -16,13 +16,13 @@ export const Stats = (props: {
   onClose: () => void;
   onClose: () => void;
   renderCustomStats: ExcalidrawProps["renderCustomStats"];
   renderCustomStats: ExcalidrawProps["renderCustomStats"];
 }) => {
 }) => {
-  const isMobile = useIsMobile();
+  const deviceType = useDeviceType();
 
 
   const boundingBox = getCommonBounds(props.elements);
   const boundingBox = getCommonBounds(props.elements);
   const selectedElements = getTargetElements(props.elements, props.appState);
   const selectedElements = getTargetElements(props.elements, props.appState);
   const selectedBoundingBox = getCommonBounds(selectedElements);
   const selectedBoundingBox = getCommonBounds(selectedElements);
 
 
-  if (isMobile && props.appState.openMenu) {
+  if (deviceType.isMobile && props.appState.openMenu) {
     return null;
     return null;
   }
   }
 
 

+ 9 - 0
src/css/styles.scss

@@ -477,6 +477,15 @@
     font-family: var(--ui-font);
     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,
   .undo-redo-buttons,
   .eraser-buttons {
   .eraser-buttons {
     display: grid;
     display: grid;

+ 5 - 0
src/types.ts

@@ -408,3 +408,8 @@ export type ExcalidrawImperativeAPI = {
   ready: true;
   ready: true;
   id: string;
   id: string;
 };
 };
+
+export type DeviceType = {
+  isMobile: boolean;
+  isTouchScreen: boolean;
+};

+ 29 - 0
src/utils.ts

@@ -583,3 +583,32 @@ export const wrapEvent = <T extends Event>(name: EVENT, nativeEvent: T) => {
     cancelable: true,
     cancelable: true,
   });
   });
 };
 };
+
+export const updateObject = <T extends Record<string, any>>(
+  obj: T,
+  updates: Partial<T>,
+): 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,
+  };
+};