Browse Source

feat: add view mode in Excalidraw (#2840)

Co-authored-by: Lipis <lipiridis@gmail.com>
Aakansha Doshi 4 years ago
parent
commit
675da16ca4

+ 22 - 0
src/actions/actionToggleViewMode.tsx

@@ -0,0 +1,22 @@
+import { CODES, KEYS } from "../keys";
+import { register } from "./register";
+import { trackEvent } from "../analytics";
+
+export const actionToggleViewMode = register({
+  name: "viewMode",
+  perform(elements, appState) {
+    trackEvent("view", "mode", "view");
+    return {
+      appState: {
+        ...appState,
+        viewModeEnabled: !this.checked!(appState),
+        selectedElementIds: {},
+      },
+      commitToHistory: false,
+    };
+  },
+  checked: (appState) => appState.viewModeEnabled,
+  contextItemLabel: "labels.viewMode",
+  keyTest: (event) =>
+    !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
+});

+ 8 - 2
src/actions/manager.tsx

@@ -7,11 +7,11 @@ import {
   ActionResult,
 } from "./types";
 import { ExcalidrawElement } from "../element/types";
-import { AppState } from "../types";
+import { AppState, ExcalidrawProps } from "../types";
 
 // This is the <App> component, but for now we don't care about anything but its
 // `canvas` state.
-type App = { canvas: HTMLCanvasElement | null };
+type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
 
 export class ActionManager implements ActionsManagerInterface {
   actions = {} as ActionsManagerInterface["actions"];
@@ -66,6 +66,12 @@ export class ActionManager implements ActionsManagerInterface {
     if (data.length === 0) {
       return false;
     }
+    const { viewModeEnabled } = this.getAppState();
+    if (viewModeEnabled) {
+      if (data[0].name !== "viewMode") {
+        return false;
+      }
+    }
 
     event.preventDefault();
     this.updater(

+ 3 - 1
src/actions/shortcuts.ts

@@ -22,7 +22,8 @@ export type ShortcutName =
   | "gridMode"
   | "zenMode"
   | "stats"
-  | "addToLibrary";
+  | "addToLibrary"
+  | "viewMode";
 
 const shortcutMap: Record<ShortcutName, string[]> = {
   cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -56,6 +57,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   zenMode: [getShortcutKey("Alt+Z")],
   stats: [],
   addToLibrary: [],
+  viewMode: [getShortcutKey("Alt+R")],
 };
 
 export const getShortcutFromShortcutName = (name: ShortcutName) => {

+ 2 - 1
src/actions/types.ts

@@ -84,7 +84,8 @@ export type ActionName =
   | "alignVerticallyCentered"
   | "alignHorizontallyCentered"
   | "distributeHorizontally"
-  | "distributeVertically";
+  | "distributeVertically"
+  | "viewMode";
 
 export interface Action {
   name: ActionName;

+ 2 - 0
src/appState.ts

@@ -72,6 +72,7 @@ export const getDefaultAppState = (): Omit<
     width: window.innerWidth,
     zenModeEnabled: false,
     zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
+    viewModeEnabled: false,
   };
 };
 
@@ -151,6 +152,7 @@ const APP_STATE_STORAGE_CONF = (<
   width: { browser: false, export: false },
   zenModeEnabled: { browser: true, export: false },
   zoom: { browser: true, export: false },
+  viewModeEnabled: { browser: false, export: false },
 });
 
 const _clearAppStateForStorage = <ExportType extends "export" | "browser">(

+ 162 - 57
src/components/App.tsx

@@ -2,6 +2,8 @@ import { Point, simplify } from "points-on-curve";
 import React from "react";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import rough from "roughjs/bin/rough";
+import clsx from "clsx";
+
 import "../actions";
 import {
   actionAddToLibrary,
@@ -175,10 +177,11 @@ import {
   withBatchedUpdates,
 } from "../utils";
 import { isMobile } from "../is-mobile";
-import ContextMenu from "./ContextMenu";
+import ContextMenu, { ContextMenuOption } from "./ContextMenu";
 import LayerUI from "./LayerUI";
 import { Stats } from "./Stats";
 import { Toast } from "./Toast";
+import { actionToggleViewMode } from "../actions/actionToggleViewMode";
 
 const { history } = createHistory();
 
@@ -295,6 +298,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       offsetLeft,
       offsetTop,
       excalidrawRef,
+      viewModeEnabled = false,
     } = props;
     this.state = {
       ...defaultAppState,
@@ -302,6 +306,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       width,
       height,
       ...this.getCanvasOffsets({ offsetLeft, offsetTop }),
+      viewModeEnabled,
     };
     if (excalidrawRef) {
       const readyPromise =
@@ -342,6 +347,62 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     this.actionManager.registerAction(createRedoAction(history));
   }
 
+  private renderCanvas() {
+    const canvasScale = window.devicePixelRatio;
+    const {
+      width: canvasDOMWidth,
+      height: canvasDOMHeight,
+      viewModeEnabled,
+    } = this.state;
+    const canvasWidth = canvasDOMWidth * canvasScale;
+    const canvasHeight = canvasDOMHeight * canvasScale;
+    if (viewModeEnabled) {
+      return (
+        <canvas
+          id="canvas"
+          style={{
+            width: canvasDOMWidth,
+            height: canvasDOMHeight,
+            cursor: "grabbing",
+          }}
+          width={canvasWidth}
+          height={canvasHeight}
+          ref={this.handleCanvasRef}
+          onContextMenu={this.handleCanvasContextMenu}
+          onPointerMove={this.handleCanvasPointerMove}
+          onPointerUp={this.removePointer}
+          onPointerCancel={this.removePointer}
+          onTouchMove={this.handleTouchMove}
+          onPointerDown={this.handleCanvasPointerDown}
+        >
+          {t("labels.drawingCanvas")}
+        </canvas>
+      );
+    }
+    return (
+      <canvas
+        id="canvas"
+        style={{
+          width: canvasDOMWidth,
+          height: canvasDOMHeight,
+        }}
+        width={canvasWidth}
+        height={canvasHeight}
+        ref={this.handleCanvasRef}
+        onContextMenu={this.handleCanvasContextMenu}
+        onPointerDown={this.handleCanvasPointerDown}
+        onDoubleClick={this.handleCanvasDoubleClick}
+        onPointerMove={this.handleCanvasPointerMove}
+        onPointerUp={this.removePointer}
+        onPointerCancel={this.removePointer}
+        onTouchMove={this.handleTouchMove}
+        onDrop={this.handleCanvasOnDrop}
+      >
+        {t("labels.drawingCanvas")}
+      </canvas>
+    );
+  }
+
   public render() {
     const {
       zenModeEnabled,
@@ -349,20 +410,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       height: canvasDOMHeight,
       offsetTop,
       offsetLeft,
+      viewModeEnabled,
     } = this.state;
 
     const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
-    const canvasScale = window.devicePixelRatio;
-
-    const canvasWidth = canvasDOMWidth * canvasScale;
-    const canvasHeight = canvasDOMHeight * canvasScale;
 
     const DEFAULT_PASTE_X = canvasDOMWidth / 2;
     const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
 
     return (
       <div
-        className="excalidraw"
+        className={clsx("excalidraw", {
+          "excalidraw--view-mode": viewModeEnabled,
+        })}
         ref={this.excalidrawContainerRef}
         style={{
           width: canvasDOMWidth,
@@ -392,6 +452,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
           isCollaborating={this.props.isCollaborating || false}
           onExportToBackend={onExportToBackend}
           renderCustomFooter={renderFooter}
+          viewModeEnabled={viewModeEnabled}
         />
         {this.state.showStats && (
           <Stats
@@ -406,28 +467,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
             clearToast={this.clearToast}
           />
         )}
-        <main>
-          <canvas
-            id="canvas"
-            style={{
-              width: canvasDOMWidth,
-              height: canvasDOMHeight,
-            }}
-            width={canvasWidth}
-            height={canvasHeight}
-            ref={this.handleCanvasRef}
-            onContextMenu={this.handleCanvasContextMenu}
-            onPointerDown={this.handleCanvasPointerDown}
-            onDoubleClick={this.handleCanvasDoubleClick}
-            onPointerMove={this.handleCanvasPointerMove}
-            onPointerUp={this.removePointer}
-            onPointerCancel={this.removePointer}
-            onTouchMove={this.handleTouchMove}
-            onDrop={this.handleCanvasOnDrop}
-          >
-            {t("labels.drawingCanvas")}
-          </canvas>
-        </main>
+        <main>{this.renderCanvas()}</main>
       </div>
     );
   }
@@ -467,6 +507,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         if (actionResult.commitToHistory) {
           history.resumeRecording();
         }
+
+        let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
+
+        if (typeof this.props.viewModeEnabled !== "undefined") {
+          viewModeEnabled = this.props.viewModeEnabled;
+        }
+
         this.setState(
           (state) => ({
             ...actionResult.appState,
@@ -476,6 +523,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
             height: state.height,
             offsetTop: state.offsetTop,
             offsetLeft: state.offsetLeft,
+            viewModeEnabled,
           }),
           () => {
             if (actionResult.syncHistory) {
@@ -658,7 +706,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     }
 
     this.scene.addCallback(this.onSceneUpdated);
-
     this.addEventListeners();
 
     // optim to avoid extra render on init
@@ -725,25 +772,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
   }
 
   private addEventListeners() {
+    this.removeEventListeners();
     document.addEventListener(EVENT.COPY, this.onCopy);
-    document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
-    document.addEventListener(EVENT.CUT, this.onCut);
-
     document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
     document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true });
     document.addEventListener(
       EVENT.MOUSE_MOVE,
       this.updateCurrentCursorPosition,
     );
-    window.addEventListener(EVENT.RESIZE, this.onResize, false);
-    window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
-    window.addEventListener(EVENT.BLUR, this.onBlur, false);
-    window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
-    window.addEventListener(EVENT.DROP, this.disableEvent, false);
-
     // rerender text elements on font load to fix #637 && #1553
     document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
-
     // Safari-only desktop pinch zoom
     document.addEventListener(
       EVENT.GESTURE_START,
@@ -760,6 +798,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       this.onGestureEnd as any,
       false,
     );
+    if (this.state.viewModeEnabled) {
+      return;
+    }
+
+    document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
+    document.addEventListener(EVENT.CUT, this.onCut);
+
+    window.addEventListener(EVENT.RESIZE, this.onResize, false);
+    window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
+    window.addEventListener(EVENT.BLUR, this.onBlur, false);
+    window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
+    window.addEventListener(EVENT.DROP, this.disableEvent, false);
   }
 
   componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
@@ -782,6 +832,17 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       });
     }
 
+    if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
+      this.setState(
+        { viewModeEnabled: !!this.props.viewModeEnabled },
+        this.addEventListeners,
+      );
+    }
+
+    if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
+      this.addEventListeners();
+    }
+
     document
       .querySelector(".excalidraw")
       ?.classList.toggle("Appearance_dark", this.state.appearance === "dark");
@@ -1134,10 +1195,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     this.actionManager.executeAction(actionToggleZenMode);
   };
 
-  toggleGridMode = () => {
-    this.actionManager.executeAction(actionToggleGridMode);
-  };
-
   toggleStats = () => {
     if (!this.state.showStats) {
       trackEvent("dialog", "stats");
@@ -1232,14 +1289,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       });
     }
 
-    if (event[KEYS.CTRL_OR_CMD]) {
-      this.setState({ isBindingEnabled: false });
+    if (this.actionManager.handleKeyDown(event)) {
+      return;
     }
 
-    if (this.actionManager.handleKeyDown(event)) {
+    if (this.state.viewModeEnabled) {
       return;
     }
 
+    if (event[KEYS.CTRL_OR_CMD]) {
+      this.setState({ isBindingEnabled: false });
+    }
+
     if (event.code === CODES.NINE) {
       this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
     }
@@ -2046,14 +2107,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 
     lastPointerUp = onPointerUp;
 
-    window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
-    window.addEventListener(EVENT.POINTER_UP, onPointerUp);
-    window.addEventListener(EVENT.KEYDOWN, onKeyDown);
-    window.addEventListener(EVENT.KEYUP, onKeyUp);
-    pointerDownState.eventListeners.onMove = onPointerMove;
-    pointerDownState.eventListeners.onUp = onPointerUp;
-    pointerDownState.eventListeners.onKeyUp = onKeyUp;
-    pointerDownState.eventListeners.onKeyDown = onKeyDown;
+    if (!this.state.viewModeEnabled) {
+      window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
+      window.addEventListener(EVENT.POINTER_UP, onPointerUp);
+      window.addEventListener(EVENT.KEYDOWN, onKeyDown);
+      window.addEventListener(EVENT.KEYUP, onKeyUp);
+      pointerDownState.eventListeners.onMove = onPointerMove;
+      pointerDownState.eventListeners.onUp = onPointerUp;
+      pointerDownState.eventListeners.onKeyUp = onKeyUp;
+      pointerDownState.eventListeners.onKeyDown = onKeyDown;
+    }
   };
 
   private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
@@ -2103,7 +2166,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       !(
         gesture.pointers.size === 0 &&
         (event.button === POINTER_BUTTON.WHEEL ||
-          (event.button === POINTER_BUTTON.MAIN && isHoldingSpace))
+          (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
+          this.state.viewModeEnabled)
       )
     ) {
       return false;
@@ -3590,7 +3654,36 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 
     const elements = this.scene.getElements();
     const element = this.getElementAtPosition(x, y);
+    const options: ContextMenuOption[] = [];
+    if (probablySupportsClipboardBlob && elements.length > 0) {
+      options.push(actionCopyAsPng);
+    }
+
+    if (probablySupportsClipboardWriteText && elements.length > 0) {
+      options.push(actionCopyAsSvg);
+    }
     if (!element) {
+      const viewModeOptions: ContextMenuOption[] = [
+        ...options,
+        actionToggleStats,
+      ];
+
+      if (typeof this.props.viewModeEnabled === "undefined") {
+        viewModeOptions.push(actionToggleViewMode);
+      }
+
+      ContextMenu.push({
+        options: viewModeOptions,
+        top: clientY,
+        left: clientX,
+        actionManager: this.actionManager,
+        appState: this.state,
+      });
+
+      if (this.state.viewModeEnabled) {
+        return;
+      }
+
       ContextMenu.push({
         options: [
           _isMobile &&
@@ -3618,6 +3711,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
           separator,
           actionToggleGridMode,
           actionToggleZenMode,
+          typeof this.props.viewModeEnabled === "undefined" &&
+            actionToggleViewMode,
           actionToggleStats,
         ],
         top: clientY,
@@ -3632,6 +3727,17 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       this.setState({ selectedElementIds: { [element.id]: true } });
     }
 
+    if (this.state.viewModeEnabled) {
+      ContextMenu.push({
+        options: [navigator.clipboard && actionCopy, ...options],
+        top: clientY,
+        left: clientX,
+        actionManager: this.actionManager,
+        appState: this.state,
+      });
+      return;
+    }
+
     ContextMenu.push({
       options: [
         _isMobile && actionCut,
@@ -3648,8 +3754,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
             contextItemLabel: "labels.paste",
           },
         _isMobile && separator,
-        probablySupportsClipboardBlob && actionCopyAsPng,
-        probablySupportsClipboardWriteText && actionCopyAsSvg,
+        ...options,
         separator,
         actionCopyStyles,
         actionPasteStyles,

+ 1 - 1
src/components/ContextMenu.tsx

@@ -13,7 +13,7 @@ import { Action } from "../actions/types";
 import { ActionManager } from "../actions/manager";
 import { AppState } from "../types";
 
-type ContextMenuOption = "separator" | Action;
+export type ContextMenuOption = "separator" | Action;
 
 type ContextMenuProps = {
   options: ContextMenuOption[];

+ 4 - 0
src/components/HelpDialog.tsx

@@ -227,6 +227,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
                   label={t("labels.gridMode")}
                   shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
                 />
+                <Shortcut
+                  label={t("labels.viewMode")}
+                  shortcuts={[getShortcutKey("Alt+R")]}
+                />
               </ShortcutIsland>
             </Column>
             <Column>

+ 74 - 42
src/components/LayerUI.tsx

@@ -61,6 +61,7 @@ interface LayerUIProps {
     canvas: HTMLCanvasElement | null,
   ) => void;
   renderCustomFooter?: (isMobile: boolean) => JSX.Element;
+  viewModeEnabled: boolean;
 }
 
 const useOnClickOutside = (
@@ -299,6 +300,7 @@ const LayerUI = ({
   isCollaborating,
   onExportToBackend,
   renderCustomFooter,
+  viewModeEnabled,
 }: LayerUIProps) => {
   const isMobile = useIsMobile();
 
@@ -358,6 +360,28 @@ const LayerUI = ({
     );
   };
 
+  const renderViewModeCanvasActions = () => {
+    return (
+      <Section
+        heading="canvasActions"
+        className={clsx("zen-mode-transition", {
+          "transition-left": zenModeEnabled,
+        })}
+      >
+        {/* the zIndex ensures this menu has higher stacking order,
+         see https://github.com/excalidraw/excalidraw/pull/1445 */}
+        <Island padding={2} style={{ zIndex: 1 }}>
+          <Stack.Col gap={4}>
+            <Stack.Row gap={1} justifyContent="space-between">
+              {actionManager.renderAction("saveScene")}
+              {actionManager.renderAction("saveAsScene")}
+              {renderExportDialog()}
+            </Stack.Row>
+          </Stack.Col>
+        </Island>
+      </Section>
+    );
+  };
   const renderCanvasActions = () => (
     <Section
       heading="canvasActions"
@@ -448,38 +472,42 @@ const LayerUI = ({
             gap={4}
             className={clsx({ "disable-pointerEvents": zenModeEnabled })}
           >
-            {renderCanvasActions()}
+            {viewModeEnabled
+              ? renderViewModeCanvasActions()
+              : renderCanvasActions()}
             {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
           </Stack.Col>
-          <Section heading="shapes">
-            {(heading) => (
-              <Stack.Col gap={4} align="start">
-                <Stack.Row gap={1}>
-                  <Island
-                    padding={1}
-                    className={clsx({ "zen-mode": zenModeEnabled })}
-                  >
-                    <HintViewer appState={appState} elements={elements} />
-                    {heading}
-                    <Stack.Row gap={1}>
-                      <ShapesSwitcher
-                        elementType={appState.elementType}
-                        setAppState={setAppState}
-                        isLibraryOpen={appState.isLibraryOpen}
-                      />
-                    </Stack.Row>
-                  </Island>
-                  <LockIcon
-                    zenModeEnabled={zenModeEnabled}
-                    checked={appState.elementLocked}
-                    onChange={onLockToggle}
-                    title={t("toolBar.lock")}
-                  />
-                </Stack.Row>
-                {libraryMenu}
-              </Stack.Col>
-            )}
-          </Section>
+          {!viewModeEnabled && (
+            <Section heading="shapes">
+              {(heading) => (
+                <Stack.Col gap={4} align="start">
+                  <Stack.Row gap={1}>
+                    <Island
+                      padding={1}
+                      className={clsx({ "zen-mode": zenModeEnabled })}
+                    >
+                      <HintViewer appState={appState} elements={elements} />
+                      {heading}
+                      <Stack.Row gap={1}>
+                        <ShapesSwitcher
+                          elementType={appState.elementType}
+                          setAppState={setAppState}
+                          isLibraryOpen={appState.isLibraryOpen}
+                        />
+                      </Stack.Row>
+                    </Island>
+                    <LockIcon
+                      zenModeEnabled={zenModeEnabled}
+                      checked={appState.elementLocked}
+                      onChange={onLockToggle}
+                      title={t("toolBar.lock")}
+                    />
+                  </Stack.Row>
+                  {libraryMenu}
+                </Stack.Col>
+              )}
+            </Section>
+          )}
           <UserList
             className={clsx("zen-mode-transition", {
               "transition-right": zenModeEnabled,
@@ -524,6 +552,20 @@ const LayerUI = ({
     );
   };
 
+  const renderGitHubCorner = () => {
+    return (
+      <aside
+        className={clsx(
+          "layer-ui__wrapper__github-corner zen-mode-transition",
+          {
+            "transition-right": zenModeEnabled,
+          },
+        )}
+      >
+        <GitHubCorner appearance={appState.appearance} />
+      </aside>
+    );
+  };
   const renderFooter = () => (
     <footer role="contentinfo" className="layer-ui__wrapper__footer">
       <div
@@ -599,6 +641,7 @@ const LayerUI = ({
         canvas={canvas}
         isCollaborating={isCollaborating}
         renderCustomFooter={renderCustomFooter}
+        viewModeEnabled={viewModeEnabled}
       />
     </>
   ) : (
@@ -610,18 +653,7 @@ const LayerUI = ({
       {dialogs}
       {renderFixedSideContainer()}
       {renderBottomAppMenu()}
-      {
-        <aside
-          className={clsx(
-            "layer-ui__wrapper__github-corner zen-mode-transition",
-            {
-              "transition-right": zenModeEnabled,
-            },
-          )}
-        >
-          <GitHubCorner appearance={appState.appearance} />
-        </aside>
-      }
+      {renderGitHubCorner()}
       {renderFooter()}
     </div>
   );

+ 160 - 116
src/components/MobileMenu.tsx

@@ -29,6 +29,7 @@ type MobileMenuProps = {
   canvas: HTMLCanvasElement | null;
   isCollaborating: boolean;
   renderCustomFooter?: (isMobile: boolean) => JSX.Element;
+  viewModeEnabled: boolean;
 };
 
 export const MobileMenu = ({
@@ -43,121 +44,164 @@ export const MobileMenu = ({
   canvas,
   isCollaborating,
   renderCustomFooter,
-}: MobileMenuProps) => (
-  <>
-    <FixedSideContainer side="top">
-      <Section heading="shapes">
-        {(heading) => (
-          <Stack.Col gap={4} align="center">
-            <Stack.Row gap={1}>
-              <Island padding={1}>
-                {heading}
-                <Stack.Row gap={1}>
-                  <ShapesSwitcher
-                    elementType={appState.elementType}
-                    setAppState={setAppState}
-                    isLibraryOpen={appState.isLibraryOpen}
-                  />
-                </Stack.Row>
-              </Island>
-              <LockIcon
-                checked={appState.elementLocked}
-                onChange={onLockToggle}
-                title={t("toolBar.lock")}
-              />
-            </Stack.Row>
-            {libraryMenu}
-          </Stack.Col>
-        )}
-      </Section>
-      <HintViewer appState={appState} elements={elements} />
-    </FixedSideContainer>
-    <div
-      className="App-bottom-bar"
-      style={{
-        marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
-        marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
-        marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
-      }}
-    >
-      <Island padding={0}>
-        {appState.openMenu === "canvas" ? (
-          <Section className="App-mobile-menu" heading="canvasActions">
-            <div className="panelColumn">
-              <Stack.Col gap={4}>
-                {actionManager.renderAction("loadScene")}
-                {actionManager.renderAction("saveScene")}
-                {actionManager.renderAction("saveAsScene")}
-                {exportButton}
-                {actionManager.renderAction("clearCanvas")}
-                {onCollabButtonClick && (
-                  <CollabButton
-                    isCollaborating={isCollaborating}
-                    collaboratorCount={appState.collaborators.size}
-                    onClick={onCollabButtonClick}
-                  />
-                )}
-                <BackgroundPickerAndDarkModeToggle
-                  actionManager={actionManager}
-                  appState={appState}
-                  setAppState={setAppState}
+  viewModeEnabled,
+}: MobileMenuProps) => {
+  const renderFixedSideContainer = () => {
+    return (
+      <FixedSideContainer side="top">
+        <Section heading="shapes">
+          {(heading) => (
+            <Stack.Col gap={4} align="center">
+              <Stack.Row gap={1}>
+                <Island padding={1}>
+                  {heading}
+                  <Stack.Row gap={1}>
+                    <ShapesSwitcher
+                      elementType={appState.elementType}
+                      setAppState={setAppState}
+                      isLibraryOpen={appState.isLibraryOpen}
+                    />
+                  </Stack.Row>
+                </Island>
+                <LockIcon
+                  checked={appState.elementLocked}
+                  onChange={onLockToggle}
+                  title={t("toolBar.lock")}
                 />
-                {renderCustomFooter?.(true)}
-                <fieldset>
-                  <legend>{t("labels.collaborators")}</legend>
-                  <UserList mobile>
-                    {Array.from(appState.collaborators)
-                      // Collaborator is either not initialized or is actually the current user.
-                      .filter(([_, client]) => Object.keys(client).length !== 0)
-                      .map(([clientId, client]) => (
-                        <React.Fragment key={clientId}>
-                          {actionManager.renderAction(
-                            "goToCollaborator",
-                            clientId,
-                          )}
-                        </React.Fragment>
-                      ))}
-                  </UserList>
-                </fieldset>
-              </Stack.Col>
-            </div>
-          </Section>
-        ) : appState.openMenu === "shape" &&
-          showSelectedShapeActions(appState, elements) ? (
-          <Section className="App-mobile-menu" heading="selectedShapeActions">
-            <SelectedShapeActions
-              appState={appState}
-              elements={elements}
-              renderAction={actionManager.renderAction}
-              elementType={appState.elementType}
-            />
-          </Section>
-        ) : null}
-        <footer className="App-toolbar">
-          <div className="App-toolbar-content">
-            {actionManager.renderAction("toggleCanvasMenu")}
-            {actionManager.renderAction("toggleEditMenu")}
-            {actionManager.renderAction("undo")}
-            {actionManager.renderAction("redo")}
-            {actionManager.renderAction(
-              appState.multiElement ? "finalize" : "duplicateSelection",
-            )}
-            {actionManager.renderAction("deleteSelectedElements")}
-          </div>
-          {appState.scrolledOutside && !appState.openMenu && (
-            <button
-              className="scroll-back-to-content"
-              onClick={() => {
-                setAppState({
-                  ...calculateScrollCenter(elements, appState, canvas),
-                });
-              }}
-            >
-              {t("buttons.scrollBackToContent")}
-            </button>
+              </Stack.Row>
+              {libraryMenu}
+            </Stack.Col>
           )}
-        </footer>
-      </Island>
-    </div>
-  </>
-);
+        </Section>
+        <HintViewer appState={appState} elements={elements} />
+      </FixedSideContainer>
+    );
+  };
+
+  const renderAppToolbar = () => {
+    if (viewModeEnabled) {
+      return (
+        <div className="App-toolbar-content">
+          {actionManager.renderAction("toggleCanvasMenu")}
+        </div>
+      );
+    }
+    return (
+      <div className="App-toolbar-content">
+        {actionManager.renderAction("toggleCanvasMenu")}
+        {actionManager.renderAction("toggleEditMenu")}
+        {actionManager.renderAction("undo")}
+        {actionManager.renderAction("redo")}
+        {actionManager.renderAction(
+          appState.multiElement ? "finalize" : "duplicateSelection",
+        )}
+        {actionManager.renderAction("deleteSelectedElements")}
+      </div>
+    );
+  };
+
+  const renderCanvasActions = () => {
+    if (viewModeEnabled) {
+      return (
+        <>
+          {actionManager.renderAction("saveScene")}
+          {actionManager.renderAction("saveAsScene")}
+          {exportButton}
+        </>
+      );
+    }
+    return (
+      <>
+        {actionManager.renderAction("loadScene")}
+        {actionManager.renderAction("saveScene")}
+        {actionManager.renderAction("saveAsScene")}
+        {exportButton}
+        {actionManager.renderAction("clearCanvas")}
+        {onCollabButtonClick && (
+          <CollabButton
+            isCollaborating={isCollaborating}
+            collaboratorCount={appState.collaborators.size}
+            onClick={onCollabButtonClick}
+          />
+        )}
+        {
+          <BackgroundPickerAndDarkModeToggle
+            actionManager={actionManager}
+            appState={appState}
+            setAppState={setAppState}
+          />
+        }
+      </>
+    );
+  };
+  return (
+    <>
+      {!viewModeEnabled && renderFixedSideContainer()}
+      <div
+        className="App-bottom-bar"
+        style={{
+          marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
+          marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
+          marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
+        }}
+      >
+        <Island padding={0}>
+          {appState.openMenu === "canvas" ? (
+            <Section className="App-mobile-menu" heading="canvasActions">
+              <div className="panelColumn">
+                <Stack.Col gap={4}>
+                  {renderCanvasActions()}
+                  {renderCustomFooter?.(true)}
+                  <fieldset>
+                    <legend>{t("labels.collaborators")}</legend>
+                    <UserList mobile>
+                      {Array.from(appState.collaborators)
+                        // Collaborator is either not initialized or is actually the current user.
+                        .filter(
+                          ([_, client]) => Object.keys(client).length !== 0,
+                        )
+                        .map(([clientId, client]) => (
+                          <React.Fragment key={clientId}>
+                            {actionManager.renderAction(
+                              "goToCollaborator",
+                              clientId,
+                            )}
+                          </React.Fragment>
+                        ))}
+                    </UserList>
+                  </fieldset>
+                </Stack.Col>
+              </div>
+            </Section>
+          ) : appState.openMenu === "shape" &&
+            !viewModeEnabled &&
+            showSelectedShapeActions(appState, elements) ? (
+            <Section className="App-mobile-menu" heading="selectedShapeActions">
+              <SelectedShapeActions
+                appState={appState}
+                elements={elements}
+                renderAction={actionManager.renderAction}
+                elementType={appState.elementType}
+              />
+            </Section>
+          ) : null}
+          <footer className="App-toolbar">
+            {renderAppToolbar()}
+            {appState.scrolledOutside && !appState.openMenu && (
+              <button
+                className="scroll-back-to-content"
+                onClick={() => {
+                  setAppState({
+                    ...calculateScrollCenter(elements, appState, canvas),
+                  });
+                }}
+              >
+                {t("buttons.scrollBackToContent")}
+              </button>
+            )}
+          </footer>
+        </Island>
+      </div>
+    </>
+  );
+};

+ 7 - 0
src/css/styles.scss

@@ -492,6 +492,13 @@
     pointer-events: none !important;
   }
 
+  &.excalidraw--view-mode {
+    .App-menu {
+      display: flex;
+      justify-content: space-between;
+    }
+  }
+
   @media print {
     .App-bottom-bar,
     .FixedSideContainer,

+ 4 - 3
src/element/showSelectedShapeActions.ts

@@ -7,7 +7,8 @@ export const showSelectedShapeActions = (
   elements: readonly NonDeletedExcalidrawElement[],
 ) =>
   Boolean(
-    appState.editingElement ||
-      getSelectedElements(elements, appState).length ||
-      appState.elementType !== "selection",
+    !appState.viewModeEnabled &&
+      (appState.editingElement ||
+        getSelectedElements(elements, appState).length ||
+        appState.elementType !== "selection"),
   );

+ 1 - 0
src/keys.ts

@@ -21,6 +21,7 @@ export const CODES = {
   V: "KeyV",
   X: "KeyX",
   Z: "KeyZ",
+  R: "KeyR",
 } as const;
 
 export const KEYS = {

+ 2 - 1
src/locales/en.json

@@ -91,7 +91,8 @@
     "centerVertically": "Center vertically",
     "centerHorizontally": "Center horizontally",
     "distributeHorizontally": "Distribute horizontally",
-    "distributeVertically": "Distribute vertically"
+    "distributeVertically": "Distribute vertically",
+    "viewMode": "View mode"
   },
   "buttons": {
     "clearReset": "Reset the canvas",

+ 4 - 0
src/packages/excalidraw/CHANGELOG.md

@@ -16,12 +16,16 @@ Please add the latest change on the top under the correct section.
 
 ## Excalidraw API
 
+### Features
+
+- Add `viewModeEnabled` prop which enabled the view mode [#2840](https://github.com/excalidraw/excalidraw/pull/2840). When this prop is used, the view mode will not show up in context menu is so it is fully controlled by host.
 - Expose `getAppState` on `excalidrawRef` [#2834](https://github.com/excalidraw/excalidraw/pull/2834).
 
 ## Excalidraw Library
 
 ### Features
 
+- Add view mode [#2840](https://github.com/excalidraw/excalidraw/pull/2840).
 - Remove `copy`, `cut`, and `paste` actions from contextmenu [#2872](https://github.com/excalidraw/excalidraw/pull/2872)
 - Support `Ctrl-Y` shortcut to redo on Windows [#2831](https://github.com/excalidraw/excalidraw/pull/2831).
 

+ 5 - 0
src/packages/excalidraw/README.md

@@ -138,6 +138,7 @@ export default function App() {
 | [`onExportToBackend`](#onExportToBackend) | Function |  | Callback triggered when link button is clicked on export dialog |
 | [`langCode`](#langCode) | string | `en` | Language code string |
 | [`renderFooter `](#renderFooter) | Function |  | Function that renders custom UI footer |
+| [`viewModeEnabled`](#viewModeEnabled) | boolean | false | This implies if the app is in view mode. |
 
 ### `Extra API's`
 
@@ -330,3 +331,7 @@ import { defaultLang, languages } from "@excalidraw/excalidraw";
 #### `renderFooter`
 
 A function that renders (returns JSX) custom UI footer. For example, you can use this to render a language picker that was previously being rendered by Excalidraw itself (for now, you'll need to implement your own language picker).
+
+#### `viewModeEnabled`
+
+This prop indicates if the app is in `view mode`. When this prop is used, the `view mode` will not show up in context menu is so it is fully controlled by host. Also the value of this prop if passed will be used over the value of `intialData.appState.viewModeEnabled`

+ 6 - 1
src/packages/excalidraw/index.tsx

@@ -26,6 +26,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
     onExportToBackend,
     renderFooter,
     langCode = defaultLang.code,
+    viewModeEnabled,
   } = props;
 
   useEffect(() => {
@@ -64,6 +65,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
           onExportToBackend={onExportToBackend}
           renderFooter={renderFooter}
           langCode={langCode}
+          viewModeEnabled={viewModeEnabled}
         />
       </IsMobileProvider>
     </InitializeApp>
@@ -81,7 +83,6 @@ const areEqual = (
 
   const prevKeys = Object.keys(prevProps) as (keyof typeof prev)[];
   const nextKeys = Object.keys(nextProps) as (keyof typeof next)[];
-
   return (
     prevUser?.name === nextUser?.name &&
     prevKeys.length === nextKeys.length &&
@@ -89,6 +90,10 @@ const areEqual = (
   );
 };
 
+Excalidraw.defaultProps = {
+  lanCode: defaultLang.code,
+};
+
 const forwardedRefComp = forwardRef<
   ExcalidrawAPIRefValue,
   PublicExcalidrawProps

+ 8 - 6
src/renderer/renderScene.ts

@@ -373,12 +373,14 @@ export const renderScene = (
         sceneState.zoom,
         "mouse", // when we render we don't know which pointer type so use mouse
       );
-      renderTransformHandles(
-        context,
-        sceneState,
-        transformHandles,
-        locallySelectedElements[0].angle,
-      );
+      if (!appState.viewModeEnabled) {
+        renderTransformHandles(
+          context,
+          sceneState,
+          transformHandles,
+          locallySelectedElements[0].angle,
+        );
+      }
     } else if (locallySelectedElements.length > 1 && !appState.isRotating) {
       const dashedLinePadding = 4 / sceneState.zoom.value;
       context.fillStyle = oc.white;

+ 67 - 0
src/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -76,6 +76,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -542,6 +543,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -990,6 +992,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -1766,6 +1769,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -1973,6 +1977,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -2424,6 +2429,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -2672,6 +2678,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -2837,6 +2844,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -3309,6 +3317,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -3618,6 +3627,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -3822,6 +3832,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -4062,6 +4073,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -4313,6 +4325,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -4714,6 +4727,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -4985,6 +4999,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -5310,6 +5325,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -5494,6 +5510,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -5656,6 +5673,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -6114,6 +6132,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -6423,6 +6442,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -8460,6 +8480,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -8821,6 +8842,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -9072,6 +9094,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -9324,6 +9347,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -9632,6 +9656,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -9794,6 +9819,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -9956,6 +9982,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -10118,6 +10145,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -10310,6 +10338,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -10502,6 +10531,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -10694,6 +10724,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -10886,6 +10917,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -11048,6 +11080,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -11210,6 +11243,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -11402,6 +11436,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -11564,6 +11599,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -11767,6 +11803,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -12474,6 +12511,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -12721,6 +12759,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -12819,6 +12858,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -12919,6 +12959,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -13081,6 +13122,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -13387,6 +13429,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -13693,6 +13736,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": "Copied styles.",
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -13853,6 +13897,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -14049,6 +14094,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -14302,6 +14348,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -14618,6 +14665,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": "Copied styles.",
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -15455,6 +15503,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -15761,6 +15810,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -16071,6 +16121,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -16447,6 +16498,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -16618,6 +16670,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -16931,6 +16984,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -17171,6 +17225,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -17426,6 +17481,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -17741,6 +17797,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -17841,6 +17898,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -18014,6 +18072,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -18820,6 +18879,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -18922,6 +18982,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -19698,6 +19759,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -20099,6 +20161,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -20346,6 +20409,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -20446,6 +20510,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -20940,6 +21005,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {
@@ -21038,6 +21104,7 @@ Object {
   "suggestedBindings": Array [],
   "toastMessage": null,
   "viewBackgroundColor": "#ffffff",
+  "viewModeEnabled": false,
   "width": 1024,
   "zenModeEnabled": false,
   "zoom": Object {

+ 1 - 0
src/tests/regressionTests.test.tsx

@@ -623,6 +623,7 @@ describe("regression tests", () => {
       "selectAll",
       "gridMode",
       "zenMode",
+      "viewMode",
       "stats",
     ];
 

+ 2 - 0
src/types.ts

@@ -85,6 +85,7 @@ export type AppState = {
   zenModeEnabled: boolean;
   appearance: "light" | "dark";
   gridSize: number | null;
+  viewModeEnabled: boolean;
 
   /** top-most selected groups (i.e. does not include nested groups) */
   selectedGroupIds: { [groupId: string]: boolean };
@@ -181,6 +182,7 @@ export interface ExcalidrawProps {
   ) => void;
   renderFooter?: (isMobile: boolean) => JSX.Element;
   langCode?: Language["code"];
+  viewModeEnabled?: boolean;
 }
 
 export type SceneData = {