Преглед изворни кода

feat: compact layout for tablets (#9910)

* feat: allow the hiding of top picks

* feat: allow the hiding of default fonts

* refactor: rename to compactMode

* feat: introduce layout (incomplete)

* tweak icons

* do not show border

* lint

* add isTouchMobile to device

* add isTouchMobile to device

* refactor to use showCompactSidebar instead

* hide library label in compact

* fix icon color in dark theme

* fix library and share btns getting hidden in smaller tablet widths

* update tests

* use a smaller gap between shapes

* proper fix of range

* quicker switching between different popovers

* to not show properties panel at all when editing text

* fix switching between different popovers for texts

* fix popover not closable and font search auto focus

* change properties for a new or editing text

* change icon for more style settings

* use bolt icon for extra actions

* fix breakpoints

* use rem for icon sizes

* fix tests

* improve switching between triggers (incomplete)

* improve trigger switching (complete)

* clean up code

* put compact into app state

* fix button size

* remove redundant PanelComponentProps["compactMode"]

* move fontSize UI on top

* mobile detection (breakpoints incomplete)

* tweak compact mode detection

* rename appState prop & values

* update snapshots

---------

Co-authored-by: dwelle <[email protected]>
Ryan Di пре 1 дан
родитељ
комит
204e06b77b
32 измењених фајлова са 1527 додато и 147 уклоњено
  1. 12 1
      packages/common/src/constants.ts
  2. 58 0
      packages/common/src/utils.ts
  3. 2 1
      packages/excalidraw/actions/actionCanvas.tsx
  4. 4 0
      packages/excalidraw/actions/actionLinearEditor.tsx
  5. 105 36
      packages/excalidraw/actions/actionProperties.tsx
  6. 1 0
      packages/excalidraw/actions/index.ts
  7. 1 0
      packages/excalidraw/actions/types.ts
  8. 2 0
      packages/excalidraw/appState.ts
  9. 117 0
      packages/excalidraw/components/Actions.scss
  10. 458 2
      packages/excalidraw/components/Actions.tsx
  11. 31 23
      packages/excalidraw/components/App.tsx
  12. 16 0
      packages/excalidraw/components/ColorPicker/ColorPicker.scss
  13. 139 14
      packages/excalidraw/components/ColorPicker/ColorPicker.tsx
  14. 5 0
      packages/excalidraw/components/FontPicker/FontPicker.scss
  15. 25 11
      packages/excalidraw/components/FontPicker/FontPicker.tsx
  16. 57 5
      packages/excalidraw/components/FontPicker/FontPickerList.tsx
  17. 15 11
      packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx
  18. 4 0
      packages/excalidraw/components/LayerUI.scss
  19. 97 33
      packages/excalidraw/components/LayerUI.tsx
  20. 8 0
      packages/excalidraw/components/PropertiesPopover.tsx
  21. 10 0
      packages/excalidraw/components/Toolbar.scss
  22. 69 0
      packages/excalidraw/components/icons.tsx
  23. 4 1
      packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx
  24. 9 1
      packages/excalidraw/css/styles.scss
  25. 112 0
      packages/excalidraw/hooks/useTextEditorFocus.ts
  26. 17 0
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  27. 1 0
      packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap
  28. 63 0
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  29. 52 0
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  30. 7 0
      packages/excalidraw/types.ts
  31. 25 8
      packages/excalidraw/wysiwyg/textWysiwyg.tsx
  32. 1 0
      packages/utils/tests/__snapshots__/export.test.ts.snap

+ 12 - 1
packages/common/src/constants.ts

@@ -129,6 +129,7 @@ export const CLASSES = {
   ZOOM_ACTIONS: "zoom-actions",
   SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
   CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
+  SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
 };
 
 export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@@ -347,9 +348,19 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
 // breakpoints
 // -----------------------------------------------------------------------------
 // md screen
-export const MQ_MAX_WIDTH_PORTRAIT = 730;
 export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
 export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
+
+// mobile: up to 699px
+export const MQ_MAX_WIDTH_MOBILE = 699;
+
+// tablets
+export const MQ_MIN_TABLET = 600; // lower bound (excludes phones)
+export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
+
+// desktop/laptop
+export const MQ_MIN_WIDTH_DESKTOP = 1440;
+
 // sidebar
 export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
 // -----------------------------------------------------------------------------

+ 58 - 0
packages/common/src/utils.ts

@@ -21,6 +21,8 @@ import {
   FONT_FAMILY,
   getFontFamilyFallbacks,
   isDarwin,
+  isAndroid,
+  isIOS,
   WINDOWS_EMOJI_FALLBACK_FONT,
 } from "./constants";
 
@@ -1278,3 +1280,59 @@ export const reduceToCommonValue = <T, R = T>(
 
   return commonValue;
 };
+
+export const isMobileOrTablet = (): boolean => {
+  const ua = navigator.userAgent || "";
+  const platform = navigator.platform || "";
+  const uaData = (navigator as any).userAgentData as
+    | { mobile?: boolean; platform?: string }
+    | undefined;
+
+  // --- 1) chromium: prefer ua client hints -------------------------------
+  if (uaData) {
+    const plat = (uaData.platform || "").toLowerCase();
+    const isDesktopOS =
+      plat === "windows" ||
+      plat === "macos" ||
+      plat === "linux" ||
+      plat === "chrome os";
+    if (uaData.mobile === true) {
+      return true;
+    }
+    if (uaData.mobile === false && plat === "android") {
+      const looksTouchTablet =
+        matchMedia?.("(hover: none)").matches &&
+        matchMedia?.("(pointer: coarse)").matches;
+      return looksTouchTablet;
+    }
+    if (isDesktopOS) {
+      return false;
+    }
+  }
+
+  // --- 2) ios (includes ipad) --------------------------------------------
+  if (isIOS) {
+    return true;
+  }
+
+  // --- 3) android legacy ua fallback -------------------------------------
+  if (isAndroid) {
+    const isAndroidPhone = /Mobile/i.test(ua);
+    const isAndroidTablet = !isAndroidPhone;
+    if (isAndroidPhone || isAndroidTablet) {
+      const looksTouchTablet =
+        matchMedia?.("(hover: none)").matches &&
+        matchMedia?.("(pointer: coarse)").matches;
+      return looksTouchTablet;
+    }
+  }
+
+  // --- 4) last resort desktop exclusion ----------------------------------
+  const looksDesktopPlatform =
+    /Win|Linux|CrOS|Mac/.test(platform) ||
+    /Windows NT|X11|CrOS|Macintosh/.test(ua);
+  if (looksDesktopPlatform) {
+    return false;
+  }
+  return false;
+};

+ 2 - 1
packages/excalidraw/actions/actionCanvas.tsx

@@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({
         : CaptureUpdateAction.EVENTUALLY,
     };
   },
-  PanelComponent: ({ elements, appState, updateData, appProps }) => {
+  PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
     // FIXME move me to src/components/mainMenu/DefaultItems.tsx
     return (
       <ColorPicker
@@ -83,6 +83,7 @@ export const actionChangeViewBackgroundColor = register({
         elements={elements}
         appState={appState}
         updateData={updateData}
+        compactMode={appState.stylesPanelMode === "compact"}
       />
     );
   },

+ 4 - 0
packages/excalidraw/actions/actionLinearEditor.tsx

@@ -88,6 +88,10 @@ export const actionToggleLinearEditor = register({
       selectedElementIds: appState.selectedElementIds,
     })[0] as ExcalidrawLinearElement;
 
+    if (!selectedElement) {
+      return null;
+    }
+
     const label = t(
       selectedElement.type === "arrow"
         ? "labels.lineEditor.editArrow"

+ 105 - 36
packages/excalidraw/actions/actionProperties.tsx

@@ -137,6 +137,11 @@ import {
   isSomeElementSelected,
 } from "../scene";
 
+import {
+  withCaretPositionPreservation,
+  restoreCaretPosition,
+} from "../hooks/useTextEditorFocus";
+
 import { register } from "./register";
 
 import type { AppClassProperties, AppState, Primitive } from "../types";
@@ -321,9 +326,11 @@ export const actionChangeStrokeColor = register({
         : CaptureUpdateAction.EVENTUALLY,
     };
   },
-  PanelComponent: ({ elements, appState, updateData, app }) => (
+  PanelComponent: ({ elements, appState, updateData, app, data }) => (
     <>
-      <h3 aria-hidden="true">{t("labels.stroke")}</h3>
+      {appState.stylesPanelMode === "full" && (
+        <h3 aria-hidden="true">{t("labels.stroke")}</h3>
+      )}
       <ColorPicker
         topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
         palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
@@ -341,6 +348,7 @@ export const actionChangeStrokeColor = register({
         elements={elements}
         appState={appState}
         updateData={updateData}
+        compactMode={appState.stylesPanelMode === "compact"}
       />
     </>
   ),
@@ -398,9 +406,11 @@ export const actionChangeBackgroundColor = register({
       captureUpdate: CaptureUpdateAction.IMMEDIATELY,
     };
   },
-  PanelComponent: ({ elements, appState, updateData, app }) => (
+  PanelComponent: ({ elements, appState, updateData, app, data }) => (
     <>
-      <h3 aria-hidden="true">{t("labels.background")}</h3>
+      {appState.stylesPanelMode === "full" && (
+        <h3 aria-hidden="true">{t("labels.background")}</h3>
+      )}
       <ColorPicker
         topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
         palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
@@ -418,6 +428,7 @@ export const actionChangeBackgroundColor = register({
         elements={elements}
         appState={appState}
         updateData={updateData}
+        compactMode={appState.stylesPanelMode === "compact"}
       />
     </>
   ),
@@ -518,9 +529,11 @@ export const actionChangeStrokeWidth = register({
       captureUpdate: CaptureUpdateAction.IMMEDIATELY,
     };
   },
-  PanelComponent: ({ elements, appState, updateData, app }) => (
+  PanelComponent: ({ elements, appState, updateData, app, data }) => (
     <fieldset>
-      <legend>{t("labels.strokeWidth")}</legend>
+      {appState.stylesPanelMode === "full" && (
+        <legend>{t("labels.strokeWidth")}</legend>
+      )}
       <div className="buttonList">
         <RadioSelection
           group="stroke-width"
@@ -575,9 +588,11 @@ export const actionChangeSloppiness = register({
       captureUpdate: CaptureUpdateAction.IMMEDIATELY,
     };
   },
-  PanelComponent: ({ elements, appState, updateData, app }) => (
+  PanelComponent: ({ elements, appState, updateData, app, data }) => (
     <fieldset>
-      <legend>{t("labels.sloppiness")}</legend>
+      {appState.stylesPanelMode === "full" && (
+        <legend>{t("labels.sloppiness")}</legend>
+      )}
       <div className="buttonList">
         <RadioSelection
           group="sloppiness"
@@ -628,9 +643,11 @@ export const actionChangeStrokeStyle = register({
       captureUpdate: CaptureUpdateAction.IMMEDIATELY,
     };
   },
-  PanelComponent: ({ elements, appState, updateData, app }) => (
+  PanelComponent: ({ elements, appState, updateData, app, data }) => (
     <fieldset>
-      <legend>{t("labels.strokeStyle")}</legend>
+      {appState.stylesPanelMode === "full" && (
+        <legend>{t("labels.strokeStyle")}</legend>
+      )}
       <div className="buttonList">
         <RadioSelection
           group="strokeStyle"
@@ -697,7 +714,7 @@ export const actionChangeFontSize = register({
   perform: (elements, appState, value, app) => {
     return changeFontSize(elements, appState, app, () => value, value);
   },
-  PanelComponent: ({ elements, appState, updateData, app }) => (
+  PanelComponent: ({ elements, appState, updateData, app, data }) => (
     <fieldset>
       <legend>{t("labels.fontSize")}</legend>
       <div className="buttonList">
@@ -756,7 +773,14 @@ export const actionChangeFontSize = register({
                 ? null
                 : appState.currentItemFontSize || DEFAULT_FONT_SIZE,
           )}
-          onChange={(value) => updateData(value)}
+          onChange={(value) => {
+            withCaretPositionPreservation(
+              () => updateData(value),
+              appState.stylesPanelMode === "compact",
+              !!appState.editingTextElement,
+              data?.onPreventClose,
+            );
+          }}
         />
       </div>
     </fieldset>
@@ -1016,7 +1040,7 @@ export const actionChangeFontFamily = register({
 
     return result;
   },
-  PanelComponent: ({ elements, appState, app, updateData }) => {
+  PanelComponent: ({ elements, appState, app, updateData, data }) => {
     const cachedElementsRef = useRef<ElementsMap>(new Map());
     const prevSelectedFontFamilyRef = useRef<number | null>(null);
     // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
@@ -1094,20 +1118,28 @@ export const actionChangeFontFamily = register({
 
     return (
       <fieldset>
-        <legend>{t("labels.fontFamily")}</legend>
+        {appState.stylesPanelMode === "full" && (
+          <legend>{t("labels.fontFamily")}</legend>
+        )}
         <FontPicker
           isOpened={appState.openPopup === "fontFamily"}
           selectedFontFamily={selectedFontFamily}
           hoveredFontFamily={appState.currentHoveredFontFamily}
+          compactMode={appState.stylesPanelMode === "compact"}
           onSelect={(fontFamily) => {
-            setBatchedData({
-              openPopup: null,
-              currentHoveredFontFamily: null,
-              currentItemFontFamily: fontFamily,
-            });
-
-            // defensive clear so immediate close won't abuse the cached elements
-            cachedElementsRef.current.clear();
+            withCaretPositionPreservation(
+              () => {
+                setBatchedData({
+                  openPopup: null,
+                  currentHoveredFontFamily: null,
+                  currentItemFontFamily: fontFamily,
+                });
+                // defensive clear so immediate close won't abuse the cached elements
+                cachedElementsRef.current.clear();
+              },
+              appState.stylesPanelMode === "compact",
+              !!appState.editingTextElement,
+            );
           }}
           onHover={(fontFamily) => {
             setBatchedData({
@@ -1164,25 +1196,28 @@ export const actionChangeFontFamily = register({
               }
 
               setBatchedData({
+                ...batchedData,
                 openPopup: "fontFamily",
               });
             } else {
-              // close, use the cache and clear it afterwards
-              const data = {
-                openPopup: null,
+              const fontFamilyData = {
                 currentHoveredFontFamily: null,
                 cachedElements: new Map(cachedElementsRef.current),
                 resetAll: true,
               } as ChangeFontFamilyData;
 
-              if (isUnmounted.current) {
-                // in case the component was unmounted by the parent, trigger the update directly
-                updateData({ ...batchedData, ...data });
-              } else {
-                setBatchedData(data);
-              }
-
+              setBatchedData({
+                ...fontFamilyData,
+              });
               cachedElementsRef.current.clear();
+
+              // Refocus text editor when font picker closes if we were editing text
+              if (
+                appState.stylesPanelMode === "compact" &&
+                appState.editingTextElement
+              ) {
+                restoreCaretPosition(null); // Just refocus without saved position
+              }
             }
           }}
         />
@@ -1225,8 +1260,9 @@ export const actionChangeTextAlign = register({
       captureUpdate: CaptureUpdateAction.IMMEDIATELY,
     };
   },
-  PanelComponent: ({ elements, appState, updateData, app }) => {
+  PanelComponent: ({ elements, appState, updateData, app, data }) => {
     const elementsMap = app.scene.getNonDeletedElementsMap();
+
     return (
       <fieldset>
         <legend>{t("labels.textAlign")}</legend>
@@ -1275,7 +1311,14 @@ export const actionChangeTextAlign = register({
               (hasSelection) =>
                 hasSelection ? null : appState.currentItemTextAlign,
             )}
-            onChange={(value) => updateData(value)}
+            onChange={(value) => {
+              withCaretPositionPreservation(
+                () => updateData(value),
+                appState.stylesPanelMode === "compact",
+                !!appState.editingTextElement,
+                data?.onPreventClose,
+              );
+            }}
           />
         </div>
       </fieldset>
@@ -1317,7 +1360,7 @@ export const actionChangeVerticalAlign = register({
       captureUpdate: CaptureUpdateAction.IMMEDIATELY,
     };
   },
-  PanelComponent: ({ elements, appState, updateData, app }) => {
+  PanelComponent: ({ elements, appState, updateData, app, data }) => {
     return (
       <fieldset>
         <div className="buttonList">
@@ -1367,7 +1410,14 @@ export const actionChangeVerticalAlign = register({
                 ) !== null,
               (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
             )}
-            onChange={(value) => updateData(value)}
+            onChange={(value) => {
+              withCaretPositionPreservation(
+                () => updateData(value),
+                appState.stylesPanelMode === "compact",
+                !!appState.editingTextElement,
+                data?.onPreventClose,
+              );
+            }}
           />
         </div>
       </fieldset>
@@ -1616,6 +1666,25 @@ export const actionChangeArrowhead = register({
   },
 });
 
+export const actionChangeArrowProperties = register({
+  name: "changeArrowProperties",
+  label: "Change arrow properties",
+  trackEvent: false,
+  perform: (elements, appState, value, app) => {
+    // This action doesn't perform any changes directly
+    // It's just a container for the arrow type and arrowhead actions
+    return false;
+  },
+  PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
+    return (
+      <div className="selected-shape-actions">
+        {renderAction("changeArrowType")}
+        {renderAction("changeArrowhead")}
+      </div>
+    );
+  },
+});
+
 export const actionChangeArrowType = register({
   name: "changeArrowType",
   label: "Change arrow types",

+ 1 - 0
packages/excalidraw/actions/index.ts

@@ -18,6 +18,7 @@ export {
   actionChangeFontFamily,
   actionChangeTextAlign,
   actionChangeVerticalAlign,
+  actionChangeArrowProperties,
 } from "./actionProperties";
 
 export {

+ 1 - 0
packages/excalidraw/actions/types.ts

@@ -69,6 +69,7 @@ export type ActionName =
   | "changeStrokeStyle"
   | "changeArrowhead"
   | "changeArrowType"
+  | "changeArrowProperties"
   | "changeOpacity"
   | "changeFontSize"
   | "toggleCanvasMenu"

+ 2 - 0
packages/excalidraw/appState.ts

@@ -123,6 +123,7 @@ export const getDefaultAppState = (): Omit<
     searchMatches: null,
     lockedMultiSelections: {},
     activeLockedId: null,
+    stylesPanelMode: "full",
   };
 };
 
@@ -247,6 +248,7 @@ const APP_STATE_STORAGE_CONF = (<
   searchMatches: { browser: false, export: false, server: false },
   lockedMultiSelections: { browser: true, export: true, server: true },
   activeLockedId: { browser: false, export: false, server: false },
+  stylesPanelMode: { browser: true, export: false, server: false },
 });
 
 const _clearAppStateForStorage = <

+ 117 - 0
packages/excalidraw/components/Actions.scss

@@ -91,3 +91,120 @@
     }
   }
 }
+
+.compact-shape-actions {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+  max-height: calc(100vh - 200px);
+  overflow-y: auto;
+  padding: 0.5rem;
+
+  .compact-action-item {
+    position: relative;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    min-height: 2.5rem;
+
+    --default-button-size: 2rem;
+
+    .compact-action-button {
+      width: 2rem;
+      height: 2rem;
+      border: none;
+      border-radius: var(--border-radius-lg);
+      background: transparent;
+      color: var(--color-on-surface);
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      transition: all 0.2s ease;
+
+      svg {
+        width: 1rem;
+        height: 1rem;
+        flex: 0 0 auto;
+      }
+
+      &:hover {
+        background: var(--button-hover-bg, var(--island-bg-color));
+        border-color: var(
+          --button-hover-border,
+          var(--button-border, var(--default-border-color))
+        );
+      }
+
+      &:active {
+        background: var(--button-active-bg, var(--island-bg-color));
+        border-color: var(--button-active-border, var(--color-primary-darkest));
+      }
+    }
+
+    .compact-popover-content {
+      .popover-section {
+        margin-bottom: 1rem;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+
+        .popover-section-title {
+          font-size: 0.75rem;
+          font-weight: 600;
+          color: var(--color-text-secondary);
+          margin-bottom: 0.5rem;
+          text-transform: uppercase;
+          letter-spacing: 0.5px;
+        }
+
+        .buttonList {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 0.25rem;
+        }
+      }
+    }
+  }
+}
+
+.compact-shape-actions-island {
+  width: fit-content;
+  overflow-x: hidden;
+}
+
+.compact-popover-content {
+  .popover-section {
+    margin-bottom: 1rem;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+
+    .popover-section-title {
+      font-size: 0.75rem;
+      font-weight: 600;
+      color: var(--color-text-secondary);
+      margin-bottom: 0.5rem;
+      text-transform: uppercase;
+      letter-spacing: 0.5px;
+    }
+
+    .buttonList {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 0.25rem;
+    }
+  }
+}
+
+.shape-actions-theme-scope {
+  --button-border: transparent;
+  --button-bg: var(--color-surface-mid);
+}
+
+:root.theme--dark .shape-actions-theme-scope {
+  --button-hover-bg: #363541;
+  --button-bg: var(--color-surface-high);
+}

+ 458 - 2
packages/excalidraw/components/Actions.tsx

@@ -1,5 +1,6 @@
 import clsx from "clsx";
 import { useState } from "react";
+import * as Popover from "@radix-ui/react-popover";
 
 import {
   CLASSES,
@@ -19,6 +20,7 @@ import {
   isImageElement,
   isLinearElement,
   isTextElement,
+  isArrowElement,
 } from "@excalidraw/element";
 
 import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
@@ -46,15 +48,20 @@ import {
   hasStrokeWidth,
 } from "../scene";
 
+import { getFormValue } from "../actions/actionProperties";
+
+import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
+
 import { getToolbarTools } from "./shapes";
 
 import "./Actions.scss";
 
-import { useDevice } from "./App";
+import { useDevice, useExcalidrawContainer } from "./App";
 import Stack from "./Stack";
 import { ToolButton } from "./ToolButton";
 import { Tooltip } from "./Tooltip";
 import DropdownMenu from "./dropdownMenu/DropdownMenu";
+import { PropertiesPopover } from "./PropertiesPopover";
 import {
   EmbedIcon,
   extraToolsIcon,
@@ -63,11 +70,29 @@ import {
   laserPointerToolIcon,
   MagicIcon,
   LassoIcon,
+  sharpArrowIcon,
+  roundArrowIcon,
+  elbowArrowIcon,
+  TextSizeIcon,
+  adjustmentsIcon,
+  DotsHorizontalIcon,
 } from "./icons";
 
-import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
+import type {
+  AppClassProperties,
+  AppProps,
+  UIAppState,
+  Zoom,
+  AppState,
+} from "../types";
 import type { ActionManager } from "../actions/manager";
 
+// Common CSS class combinations
+const PROPERTIES_CLASSES = clsx([
+  CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
+  "properties-content",
+]);
+
 export const canChangeStrokeColor = (
   appState: UIAppState,
   targetElements: ExcalidrawElement[],
@@ -280,6 +305,437 @@ export const SelectedShapeActions = ({
   );
 };
 
+export const CompactShapeActions = ({
+  appState,
+  elementsMap,
+  renderAction,
+  app,
+  setAppState,
+}: {
+  appState: UIAppState;
+  elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
+  renderAction: ActionManager["renderAction"];
+  app: AppClassProperties;
+  setAppState: React.Component<any, AppState>["setState"];
+}) => {
+  const targetElements = getTargetElements(elementsMap, appState);
+  const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
+  const { container } = useExcalidrawContainer();
+
+  const isEditingTextOrNewElement = Boolean(
+    appState.editingTextElement || appState.newElement,
+  );
+
+  const showFillIcons =
+    (hasBackground(appState.activeTool.type) &&
+      !isTransparent(appState.currentItemBackgroundColor)) ||
+    targetElements.some(
+      (element) =>
+        hasBackground(element.type) && !isTransparent(element.backgroundColor),
+    );
+
+  const showLinkIcon = targetElements.length === 1;
+
+  const showLineEditorAction =
+    !appState.selectedLinearElement?.isEditing &&
+    targetElements.length === 1 &&
+    isLinearElement(targetElements[0]) &&
+    !isElbowArrow(targetElements[0]);
+
+  const showCropEditorAction =
+    !appState.croppingElementId &&
+    targetElements.length === 1 &&
+    isImageElement(targetElements[0]);
+
+  const showAlignActions = alignActionsPredicate(appState, app);
+
+  let isSingleElementBoundContainer = false;
+  if (
+    targetElements.length === 2 &&
+    (hasBoundTextElement(targetElements[0]) ||
+      hasBoundTextElement(targetElements[1]))
+  ) {
+    isSingleElementBoundContainer = true;
+  }
+
+  const isRTL = document.documentElement.getAttribute("dir") === "rtl";
+
+  return (
+    <div className="compact-shape-actions">
+      {/* Stroke Color */}
+      {canChangeStrokeColor(appState, targetElements) && (
+        <div className={clsx("compact-action-item")}>
+          {renderAction("changeStrokeColor")}
+        </div>
+      )}
+
+      {/* Background Color */}
+      {canChangeBackgroundColor(appState, targetElements) && (
+        <div className="compact-action-item">
+          {renderAction("changeBackgroundColor")}
+        </div>
+      )}
+
+      {/* Combined Properties (Fill, Stroke, Opacity) */}
+      {(showFillIcons ||
+        hasStrokeWidth(appState.activeTool.type) ||
+        targetElements.some((element) => hasStrokeWidth(element.type)) ||
+        hasStrokeStyle(appState.activeTool.type) ||
+        targetElements.some((element) => hasStrokeStyle(element.type)) ||
+        canChangeRoundness(appState.activeTool.type) ||
+        targetElements.some((element) => canChangeRoundness(element.type))) && (
+        <div className="compact-action-item">
+          <Popover.Root
+            open={appState.openPopup === "compactStrokeStyles"}
+            onOpenChange={(open) => {
+              if (open) {
+                setAppState({ openPopup: "compactStrokeStyles" });
+              } else {
+                setAppState({ openPopup: null });
+              }
+            }}
+          >
+            <Popover.Trigger asChild>
+              <button
+                type="button"
+                className="compact-action-button properties-trigger"
+                title={t("labels.stroke")}
+                onClick={(e) => {
+                  e.preventDefault();
+                  e.stopPropagation();
+
+                  setAppState({
+                    openPopup:
+                      appState.openPopup === "compactStrokeStyles"
+                        ? null
+                        : "compactStrokeStyles",
+                  });
+                }}
+              >
+                {adjustmentsIcon}
+              </button>
+            </Popover.Trigger>
+            {appState.openPopup === "compactStrokeStyles" && (
+              <PropertiesPopover
+                className={PROPERTIES_CLASSES}
+                container={container}
+                style={{ maxWidth: "13rem" }}
+                onClose={() => {}}
+              >
+                <div className="selected-shape-actions">
+                  {showFillIcons && renderAction("changeFillStyle")}
+                  {(hasStrokeWidth(appState.activeTool.type) ||
+                    targetElements.some((element) =>
+                      hasStrokeWidth(element.type),
+                    )) &&
+                    renderAction("changeStrokeWidth")}
+                  {(hasStrokeStyle(appState.activeTool.type) ||
+                    targetElements.some((element) =>
+                      hasStrokeStyle(element.type),
+                    )) && (
+                    <>
+                      {renderAction("changeStrokeStyle")}
+                      {renderAction("changeSloppiness")}
+                    </>
+                  )}
+                  {(canChangeRoundness(appState.activeTool.type) ||
+                    targetElements.some((element) =>
+                      canChangeRoundness(element.type),
+                    )) &&
+                    renderAction("changeRoundness")}
+                  {renderAction("changeOpacity")}
+                </div>
+              </PropertiesPopover>
+            )}
+          </Popover.Root>
+        </div>
+      )}
+
+      {/* Combined Arrow Properties */}
+      {(toolIsArrow(appState.activeTool.type) ||
+        targetElements.some((element) => toolIsArrow(element.type))) && (
+        <div className="compact-action-item">
+          <Popover.Root
+            open={appState.openPopup === "compactArrowProperties"}
+            onOpenChange={(open) => {
+              if (open) {
+                setAppState({ openPopup: "compactArrowProperties" });
+              } else {
+                setAppState({ openPopup: null });
+              }
+            }}
+          >
+            <Popover.Trigger asChild>
+              <button
+                type="button"
+                className="compact-action-button properties-trigger"
+                title={t("labels.arrowtypes")}
+                onClick={(e) => {
+                  e.preventDefault();
+                  e.stopPropagation();
+
+                  setAppState({
+                    openPopup:
+                      appState.openPopup === "compactArrowProperties"
+                        ? null
+                        : "compactArrowProperties",
+                  });
+                }}
+              >
+                {(() => {
+                  // Show an icon based on the current arrow type
+                  const arrowType = getFormValue(
+                    targetElements,
+                    app,
+                    (element) => {
+                      if (isArrowElement(element)) {
+                        return element.elbowed
+                          ? "elbow"
+                          : element.roundness
+                          ? "round"
+                          : "sharp";
+                      }
+                      return null;
+                    },
+                    (element) => isArrowElement(element),
+                    (hasSelection) =>
+                      hasSelection ? null : appState.currentItemArrowType,
+                  );
+
+                  if (arrowType === "elbow") {
+                    return elbowArrowIcon;
+                  }
+                  if (arrowType === "round") {
+                    return roundArrowIcon;
+                  }
+                  return sharpArrowIcon;
+                })()}
+              </button>
+            </Popover.Trigger>
+            {appState.openPopup === "compactArrowProperties" && (
+              <PropertiesPopover
+                container={container}
+                className="properties-content"
+                style={{ maxWidth: "13rem" }}
+                onClose={() => {}}
+              >
+                {renderAction("changeArrowProperties")}
+              </PropertiesPopover>
+            )}
+          </Popover.Root>
+        </div>
+      )}
+
+      {/* Linear Editor */}
+      {showLineEditorAction && (
+        <div className="compact-action-item">
+          {renderAction("toggleLinearEditor")}
+        </div>
+      )}
+
+      {/* Text Properties */}
+      {(appState.activeTool.type === "text" ||
+        targetElements.some(isTextElement)) && (
+        <>
+          <div className="compact-action-item">
+            {renderAction("changeFontFamily")}
+          </div>
+          <div className="compact-action-item">
+            <Popover.Root
+              open={appState.openPopup === "compactTextProperties"}
+              onOpenChange={(open) => {
+                if (open) {
+                  if (appState.editingTextElement) {
+                    saveCaretPosition();
+                  }
+                  setAppState({ openPopup: "compactTextProperties" });
+                } else {
+                  setAppState({ openPopup: null });
+                  if (appState.editingTextElement) {
+                    restoreCaretPosition();
+                  }
+                }
+              }}
+            >
+              <Popover.Trigger asChild>
+                <button
+                  type="button"
+                  className="compact-action-button properties-trigger"
+                  title={t("labels.textAlign")}
+                  onClick={(e) => {
+                    e.preventDefault();
+                    e.stopPropagation();
+
+                    if (appState.openPopup === "compactTextProperties") {
+                      setAppState({ openPopup: null });
+                    } else {
+                      if (appState.editingTextElement) {
+                        saveCaretPosition();
+                      }
+                      setAppState({ openPopup: "compactTextProperties" });
+                    }
+                  }}
+                >
+                  {TextSizeIcon}
+                </button>
+              </Popover.Trigger>
+              {appState.openPopup === "compactTextProperties" && (
+                <PropertiesPopover
+                  className={PROPERTIES_CLASSES}
+                  container={container}
+                  style={{ maxWidth: "13rem" }}
+                  // Improve focus handling for text editing scenarios
+                  preventAutoFocusOnTouch={!!appState.editingTextElement}
+                  onClose={() => {
+                    // Refocus text editor when popover closes with caret restoration
+                    if (appState.editingTextElement) {
+                      restoreCaretPosition();
+                    }
+                  }}
+                >
+                  <div className="selected-shape-actions">
+                    {(appState.activeTool.type === "text" ||
+                      targetElements.some(isTextElement)) &&
+                      renderAction("changeFontSize")}
+                    {(appState.activeTool.type === "text" ||
+                      suppportsHorizontalAlign(targetElements, elementsMap)) &&
+                      renderAction("changeTextAlign")}
+                    {shouldAllowVerticalAlign(targetElements, elementsMap) &&
+                      renderAction("changeVerticalAlign")}
+                  </div>
+                </PropertiesPopover>
+              )}
+            </Popover.Root>
+          </div>
+        </>
+      )}
+
+      {/* Dedicated Copy Button */}
+      {!isEditingTextOrNewElement && targetElements.length > 0 && (
+        <div className="compact-action-item">
+          {renderAction("duplicateSelection")}
+        </div>
+      )}
+
+      {/* Dedicated Delete Button */}
+      {!isEditingTextOrNewElement && targetElements.length > 0 && (
+        <div className="compact-action-item">
+          {renderAction("deleteSelectedElements")}
+        </div>
+      )}
+
+      {/* Combined Other Actions */}
+      {!isEditingTextOrNewElement && targetElements.length > 0 && (
+        <div className="compact-action-item">
+          <Popover.Root
+            open={appState.openPopup === "compactOtherProperties"}
+            onOpenChange={(open) => {
+              if (open) {
+                setAppState({ openPopup: "compactOtherProperties" });
+              } else {
+                setAppState({ openPopup: null });
+              }
+            }}
+          >
+            <Popover.Trigger asChild>
+              <button
+                type="button"
+                className="compact-action-button properties-trigger"
+                title={t("labels.actions")}
+                onClick={(e) => {
+                  e.preventDefault();
+                  e.stopPropagation();
+                  setAppState({
+                    openPopup:
+                      appState.openPopup === "compactOtherProperties"
+                        ? null
+                        : "compactOtherProperties",
+                  });
+                }}
+              >
+                {DotsHorizontalIcon}
+              </button>
+            </Popover.Trigger>
+            {appState.openPopup === "compactOtherProperties" && (
+              <PropertiesPopover
+                className={PROPERTIES_CLASSES}
+                container={container}
+                style={{
+                  maxWidth: "12rem",
+                  // center the popover content
+                  justifyContent: "center",
+                  alignItems: "center",
+                }}
+                onClose={() => {}}
+              >
+                <div className="selected-shape-actions">
+                  <fieldset>
+                    <legend>{t("labels.layers")}</legend>
+                    <div className="buttonList">
+                      {renderAction("sendToBack")}
+                      {renderAction("sendBackward")}
+                      {renderAction("bringForward")}
+                      {renderAction("bringToFront")}
+                    </div>
+                  </fieldset>
+
+                  {showAlignActions && !isSingleElementBoundContainer && (
+                    <fieldset>
+                      <legend>{t("labels.align")}</legend>
+                      <div className="buttonList">
+                        {isRTL ? (
+                          <>
+                            {renderAction("alignRight")}
+                            {renderAction("alignHorizontallyCentered")}
+                            {renderAction("alignLeft")}
+                          </>
+                        ) : (
+                          <>
+                            {renderAction("alignLeft")}
+                            {renderAction("alignHorizontallyCentered")}
+                            {renderAction("alignRight")}
+                          </>
+                        )}
+                        {targetElements.length > 2 &&
+                          renderAction("distributeHorizontally")}
+                        {/* breaks the row ˇˇ */}
+                        <div style={{ flexBasis: "100%", height: 0 }} />
+                        <div
+                          style={{
+                            display: "flex",
+                            flexWrap: "wrap",
+                            gap: ".5rem",
+                            marginTop: "-0.5rem",
+                          }}
+                        >
+                          {renderAction("alignTop")}
+                          {renderAction("alignVerticallyCentered")}
+                          {renderAction("alignBottom")}
+                          {targetElements.length > 2 &&
+                            renderAction("distributeVertically")}
+                        </div>
+                      </div>
+                    </fieldset>
+                  )}
+                  <fieldset>
+                    <legend>{t("labels.actions")}</legend>
+                    <div className="buttonList">
+                      {renderAction("group")}
+                      {renderAction("ungroup")}
+                      {showLinkIcon && renderAction("hyperlink")}
+                      {showCropEditorAction && renderAction("cropEditor")}
+                    </div>
+                  </fieldset>
+                </div>
+              </PropertiesPopover>
+            )}
+          </Popover.Root>
+        </div>
+      )}
+    </div>
+  );
+};
+
 export const ShapesSwitcher = ({
   activeTool,
   appState,

+ 31 - 23
packages/excalidraw/components/App.tsx

@@ -41,9 +41,6 @@ import {
   LINE_CONFIRM_THRESHOLD,
   MAX_ALLOWED_FILE_BYTES,
   MIME_TYPES,
-  MQ_MAX_HEIGHT_LANDSCAPE,
-  MQ_MAX_WIDTH_LANDSCAPE,
-  MQ_MAX_WIDTH_PORTRAIT,
   MQ_RIGHT_SIDEBAR_MIN_WIDTH,
   POINTER_BUTTON,
   ROUNDNESS,
@@ -100,9 +97,14 @@ import {
   randomInteger,
   CLASSES,
   Emitter,
-  isMobile,
   MINIMUM_ARROW_SIZE,
   DOUBLE_TAP_POSITION_THRESHOLD,
+  isMobileOrTablet,
+  MQ_MAX_WIDTH_MOBILE,
+  MQ_MAX_HEIGHT_LANDSCAPE,
+  MQ_MAX_WIDTH_LANDSCAPE,
+  MQ_MIN_TABLET,
+  MQ_MAX_TABLET,
 } from "@excalidraw/common";
 
 import {
@@ -667,7 +669,7 @@ class App extends React.Component<AppProps, AppState> {
   constructor(props: AppProps) {
     super(props);
     const defaultAppState = getDefaultAppState();
-    this.defaultSelectionTool = this.isMobileOrTablet()
+    this.defaultSelectionTool = isMobileOrTablet()
       ? ("lasso" as const)
       : ("selection" as const);
     const {
@@ -2420,23 +2422,20 @@ class App extends React.Component<AppProps, AppState> {
     }
   };
 
-  private isMobileOrTablet = (): boolean => {
-    const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
-    const hasCoarsePointer =
-      "matchMedia" in window &&
-      window?.matchMedia("(pointer: coarse)")?.matches;
-    const isTouchMobile = hasTouch && hasCoarsePointer;
-
-    return isMobile || isTouchMobile;
-  };
-
   private isMobileBreakpoint = (width: number, height: number) => {
     return (
-      width < MQ_MAX_WIDTH_PORTRAIT ||
+      width <= MQ_MAX_WIDTH_MOBILE ||
       (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
     );
   };
 
+  private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => {
+    const minSide = Math.min(editorWidth, editorHeight);
+    const maxSide = Math.max(editorWidth, editorHeight);
+
+    return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET;
+  };
+
   private refreshViewportBreakpoints = () => {
     const container = this.excalidrawContainerRef.current;
     if (!container) {
@@ -2481,6 +2480,17 @@ class App extends React.Component<AppProps, AppState> {
       canFitSidebar: editorWidth > sidebarBreakpoint,
     });
 
+    // also check if we need to update the app state
+    this.setState({
+      stylesPanelMode:
+        // NOTE: we could also remove the isMobileOrTablet check here and
+        // always switch to compact mode when the editor is narrow (e.g. < MQ_MIN_WIDTH_DESKTOP)
+        // but not too narrow (> MQ_MAX_WIDTH_MOBILE)
+        this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet()
+          ? "compact"
+          : "full",
+    });
+
     if (prevEditorState !== nextEditorState) {
       this.device = { ...this.device, editor: nextEditorState };
       return true;
@@ -3147,7 +3157,7 @@ class App extends React.Component<AppProps, AppState> {
       this.addElementsFromPasteOrLibrary({
         elements,
         files: data.files || null,
-        position: this.isMobileOrTablet() ? "center" : "cursor",
+        position: isMobileOrTablet() ? "center" : "cursor",
         retainSeed: isPlainPaste,
       });
       return;
@@ -3172,7 +3182,7 @@ class App extends React.Component<AppProps, AppState> {
         this.addElementsFromPasteOrLibrary({
           elements,
           files,
-          position: this.isMobileOrTablet() ? "center" : "cursor",
+          position: isMobileOrTablet() ? "center" : "cursor",
         });
 
         return;
@@ -6668,8 +6678,6 @@ class App extends React.Component<AppProps, AppState> {
         pointerDownState.hit.element &&
         this.isASelectedElement(pointerDownState.hit.element);
 
-      const isMobileOrTablet = this.isMobileOrTablet();
-
       if (
         !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
         !pointerDownState.resize.handleType &&
@@ -6683,12 +6691,12 @@ class App extends React.Component<AppProps, AppState> {
 
         // block dragging after lasso selection on PCs until the next pointer down
         // (on mobile or tablet, we want to allow user to drag immediately)
-        pointerDownState.drag.blockDragging = !isMobileOrTablet;
+        pointerDownState.drag.blockDragging = !isMobileOrTablet();
       }
 
       // only for mobile or tablet, if we hit an element, select it immediately like normal selection
       if (
-        isMobileOrTablet &&
+        isMobileOrTablet() &&
         pointerDownState.hit.element &&
         !hitSelectedElement
       ) {
@@ -8489,7 +8497,7 @@ class App extends React.Component<AppProps, AppState> {
         if (
           this.state.activeTool.type === "lasso" &&
           this.lassoTrail.hasCurrentTrail &&
-          !(this.isMobileOrTablet() && pointerDownState.hit.element) &&
+          !(isMobileOrTablet() && pointerDownState.hit.element) &&
           !this.state.activeTool.fromSelection
         ) {
           return;

+ 16 - 0
packages/excalidraw/components/ColorPicker/ColorPicker.scss

@@ -22,6 +22,12 @@
     @include isMobile {
       max-width: 11rem;
     }
+
+    &.color-picker-container--no-top-picks {
+      display: flex;
+      justify-content: center;
+      grid-template-columns: unset;
+    }
   }
 
   .color-picker__top-picks {
@@ -80,6 +86,16 @@
       }
     }
 
+    .color-picker__button-background {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      svg {
+        width: 100%;
+        height: 100%;
+      }
+    }
+
     &.active {
       .color-picker__button-outline {
         position: absolute;

+ 139 - 14
packages/excalidraw/components/ColorPicker/ColorPicker.tsx

@@ -1,6 +1,6 @@
 import * as Popover from "@radix-ui/react-popover";
 import clsx from "clsx";
-import { useRef } from "react";
+import { useRef, useEffect } from "react";
 
 import {
   COLOR_OUTLINE_CONTRAST_THRESHOLD,
@@ -18,7 +18,12 @@ import { useExcalidrawContainer } from "../App";
 import { ButtonSeparator } from "../ButtonSeparator";
 import { activeEyeDropperAtom } from "../EyeDropper";
 import { PropertiesPopover } from "../PropertiesPopover";
-import { slashIcon } from "../icons";
+import { backgroundIcon, slashIcon, strokeIcon } from "../icons";
+import {
+  saveCaretPosition,
+  restoreCaretPosition,
+  temporarilyDisableTextEditorBlur,
+} from "../../hooks/useTextEditorFocus";
 
 import { ColorInput } from "./ColorInput";
 import { Picker } from "./Picker";
@@ -67,6 +72,7 @@ interface ColorPickerProps {
   palette?: ColorPaletteCustom | null;
   topPicks?: ColorTuple;
   updateData: (formData?: any) => void;
+  compactMode?: boolean;
 }
 
 const ColorPickerPopupContent = ({
@@ -77,6 +83,8 @@ const ColorPickerPopupContent = ({
   elements,
   palette = COLOR_PALETTE,
   updateData,
+  getOpenPopup,
+  appState,
 }: Pick<
   ColorPickerProps,
   | "type"
@@ -86,7 +94,10 @@ const ColorPickerPopupContent = ({
   | "elements"
   | "palette"
   | "updateData"
->) => {
+  | "appState"
+> & {
+  getOpenPopup: () => AppState["openPopup"];
+}) => {
   const { container } = useExcalidrawContainer();
   const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
 
@@ -117,6 +128,8 @@ const ColorPickerPopupContent = ({
     <PropertiesPopover
       container={container}
       style={{ maxWidth: "13rem" }}
+      // Improve focus handling for text editing scenarios
+      preventAutoFocusOnTouch={!!appState.editingTextElement}
       onFocusOutside={(event) => {
         // refocus due to eye dropper
         focusPickerContent();
@@ -131,8 +144,23 @@ const ColorPickerPopupContent = ({
         }
       }}
       onClose={() => {
-        updateData({ openPopup: null });
+        // only clear if we're still the active popup (avoid racing with switch)
+        if (getOpenPopup() === type) {
+          updateData({ openPopup: null });
+        }
         setActiveColorPickerSection(null);
+
+        // Refocus text editor when popover closes if we were editing text
+        if (appState.editingTextElement) {
+          setTimeout(() => {
+            const textEditor = document.querySelector(
+              ".excalidraw-wysiwyg",
+            ) as HTMLTextAreaElement;
+            if (textEditor) {
+              textEditor.focus();
+            }
+          }, 0);
+        }
       }}
     >
       {palette ? (
@@ -141,7 +169,17 @@ const ColorPickerPopupContent = ({
           palette={palette}
           color={color}
           onChange={(changedColor) => {
+            // Save caret position before color change if editing text
+            const savedSelection = appState.editingTextElement
+              ? saveCaretPosition()
+              : null;
+
             onChange(changedColor);
+
+            // Restore caret position after color change if editing text
+            if (appState.editingTextElement && savedSelection) {
+              restoreCaretPosition(savedSelection);
+            }
           }}
           onEyeDropperToggle={(force) => {
             setEyeDropperState((state) => {
@@ -168,6 +206,7 @@ const ColorPickerPopupContent = ({
             if (eyeDropperState) {
               setEyeDropperState(null);
             } else {
+              // close explicitly on Escape
               updateData({ openPopup: null });
             }
           }}
@@ -188,11 +227,32 @@ const ColorPickerTrigger = ({
   label,
   color,
   type,
+  compactMode = false,
+  mode = "background",
+  onToggle,
+  editingTextElement,
 }: {
   color: string | null;
   label: string;
   type: ColorPickerType;
+  compactMode?: boolean;
+  mode?: "background" | "stroke";
+  onToggle: () => void;
+  editingTextElement?: boolean;
 }) => {
+  const handleClick = (e: React.MouseEvent) => {
+    // use pointerdown so we run before outside-close logic
+    e.preventDefault();
+    e.stopPropagation();
+
+    // If editing text, temporarily disable the wysiwyg blur event
+    if (editingTextElement) {
+      temporarilyDisableTextEditorBlur();
+    }
+
+    onToggle();
+  };
+
   return (
     <Popover.Trigger
       type="button"
@@ -208,8 +268,37 @@ const ColorPickerTrigger = ({
           ? t("labels.showStroke")
           : t("labels.showBackground")
       }
+      data-openpopup={type}
+      onClick={handleClick}
     >
       <div className="color-picker__button-outline">{!color && slashIcon}</div>
+      {compactMode && color && (
+        <div className="color-picker__button-background">
+          {mode === "background" ? (
+            <span
+              style={{
+                color:
+                  color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
+                    ? "#fff"
+                    : "#111",
+              }}
+            >
+              {backgroundIcon}
+            </span>
+          ) : (
+            <span
+              style={{
+                color:
+                  color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
+                    ? "#fff"
+                    : "#111",
+              }}
+            >
+              {strokeIcon}
+            </span>
+          )}
+        </div>
+      )}
     </Popover.Trigger>
   );
 };
@@ -224,25 +313,59 @@ export const ColorPicker = ({
   topPicks,
   updateData,
   appState,
+  compactMode = false,
 }: ColorPickerProps) => {
+  const openRef = useRef(appState.openPopup);
+  useEffect(() => {
+    openRef.current = appState.openPopup;
+  }, [appState.openPopup]);
   return (
     <div>
-      <div role="dialog" aria-modal="true" className="color-picker-container">
-        <TopPicks
-          activeColor={color}
-          onChange={onChange}
-          type={type}
-          topPicks={topPicks}
-        />
-        <ButtonSeparator />
+      <div
+        role="dialog"
+        aria-modal="true"
+        className={clsx("color-picker-container", {
+          "color-picker-container--no-top-picks": compactMode,
+        })}
+      >
+        {!compactMode && (
+          <TopPicks
+            activeColor={color}
+            onChange={onChange}
+            type={type}
+            topPicks={topPicks}
+          />
+        )}
+        {!compactMode && <ButtonSeparator />}
         <Popover.Root
           open={appState.openPopup === type}
           onOpenChange={(open) => {
-            updateData({ openPopup: open ? type : null });
+            if (open) {
+              updateData({ openPopup: type });
+            }
           }}
         >
           {/* serves as an active color indicator as well */}
-          <ColorPickerTrigger color={color} label={label} type={type} />
+          <ColorPickerTrigger
+            color={color}
+            label={label}
+            type={type}
+            compactMode={compactMode}
+            mode={type === "elementStroke" ? "stroke" : "background"}
+            editingTextElement={!!appState.editingTextElement}
+            onToggle={() => {
+              // atomic switch: if another popup is open, close it first, then open this one next tick
+              if (appState.openPopup === type) {
+                // toggle off on same trigger
+                updateData({ openPopup: null });
+              } else if (appState.openPopup) {
+                updateData({ openPopup: type });
+              } else {
+                // open this one
+                updateData({ openPopup: type });
+              }
+            }}
+          />
           {/* popup content */}
           {appState.openPopup === type && (
             <ColorPickerPopupContent
@@ -253,6 +376,8 @@ export const ColorPicker = ({
               elements={elements}
               palette={palette}
               updateData={updateData}
+              getOpenPopup={() => openRef.current}
+              appState={appState}
             />
           )}
         </Popover.Root>

+ 5 - 0
packages/excalidraw/components/FontPicker/FontPicker.scss

@@ -11,5 +11,10 @@
         2rem + 4 * var(--default-button-size)
       ); // 4 gaps + 4 buttons
     }
+
+    &--compact {
+      display: block;
+      grid-template-columns: none;
+    }
   }
 }

+ 25 - 11
packages/excalidraw/components/FontPicker/FontPicker.tsx

@@ -1,4 +1,5 @@
 import * as Popover from "@radix-ui/react-popover";
+import clsx from "clsx";
 import React, { useCallback, useMemo } from "react";
 
 import { FONT_FAMILY } from "@excalidraw/common";
@@ -58,6 +59,7 @@ interface FontPickerProps {
   onHover: (fontFamily: FontFamilyValues) => void;
   onLeave: () => void;
   onPopupChange: (open: boolean) => void;
+  compactMode?: boolean;
 }
 
 export const FontPicker = React.memo(
@@ -69,6 +71,7 @@ export const FontPicker = React.memo(
     onHover,
     onLeave,
     onPopupChange,
+    compactMode = false,
   }: FontPickerProps) => {
     const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
     const onSelectCallback = useCallback(
@@ -81,18 +84,29 @@ export const FontPicker = React.memo(
     );
 
     return (
-      <div role="dialog" aria-modal="true" className="FontPicker__container">
-        <div className="buttonList">
-          <RadioSelection<FontFamilyValues | false>
-            type="button"
-            options={defaultFonts}
-            value={selectedFontFamily}
-            onClick={onSelectCallback}
-          />
-        </div>
-        <ButtonSeparator />
+      <div
+        role="dialog"
+        aria-modal="true"
+        className={clsx("FontPicker__container", {
+          "FontPicker__container--compact": compactMode,
+        })}
+      >
+        {!compactMode && (
+          <div className="buttonList">
+            <RadioSelection<FontFamilyValues | false>
+              type="button"
+              options={defaultFonts}
+              value={selectedFontFamily}
+              onClick={onSelectCallback}
+            />
+          </div>
+        )}
+        {!compactMode && <ButtonSeparator />}
         <Popover.Root open={isOpened} onOpenChange={onPopupChange}>
-          <FontPickerTrigger selectedFontFamily={selectedFontFamily} />
+          <FontPickerTrigger
+            selectedFontFamily={selectedFontFamily}
+            isOpened={isOpened}
+          />
           {isOpened && (
             <FontPickerList
               selectedFontFamily={selectedFontFamily}

+ 57 - 5
packages/excalidraw/components/FontPicker/FontPickerList.tsx

@@ -90,7 +90,8 @@ export const FontPickerList = React.memo(
     onClose,
   }: FontPickerListProps) => {
     const { container } = useExcalidrawContainer();
-    const { fonts } = useApp();
+    const app = useApp();
+    const { fonts } = app;
     const { showDeprecatedFonts } = useAppProps();
 
     const [searchTerm, setSearchTerm] = useState("");
@@ -187,6 +188,42 @@ export const FontPickerList = React.memo(
       onLeave,
     ]);
 
+    // Create a wrapped onSelect function that preserves caret position
+    const wrappedOnSelect = useCallback(
+      (fontFamily: FontFamilyValues) => {
+        // Save caret position before font selection if editing text
+        let savedSelection: { start: number; end: number } | null = null;
+        if (app.state.editingTextElement) {
+          const textEditor = document.querySelector(
+            ".excalidraw-wysiwyg",
+          ) as HTMLTextAreaElement;
+          if (textEditor) {
+            savedSelection = {
+              start: textEditor.selectionStart,
+              end: textEditor.selectionEnd,
+            };
+          }
+        }
+
+        onSelect(fontFamily);
+
+        // Restore caret position after font selection if editing text
+        if (app.state.editingTextElement && savedSelection) {
+          setTimeout(() => {
+            const textEditor = document.querySelector(
+              ".excalidraw-wysiwyg",
+            ) as HTMLTextAreaElement;
+            if (textEditor && savedSelection) {
+              textEditor.focus();
+              textEditor.selectionStart = savedSelection.start;
+              textEditor.selectionEnd = savedSelection.end;
+            }
+          }, 0);
+        }
+      },
+      [onSelect, app.state.editingTextElement],
+    );
+
     const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
       (event) => {
         const handled = fontPickerKeyHandler({
@@ -194,7 +231,7 @@ export const FontPickerList = React.memo(
           inputRef,
           hoveredFont,
           filteredFonts,
-          onSelect,
+          onSelect: wrappedOnSelect,
           onHover,
           onClose,
         });
@@ -204,7 +241,7 @@ export const FontPickerList = React.memo(
           event.stopPropagation();
         }
       },
-      [hoveredFont, filteredFonts, onSelect, onHover, onClose],
+      [hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
     );
 
     useEffect(() => {
@@ -240,7 +277,7 @@ export const FontPickerList = React.memo(
         // allow to tab between search and selected font
         tabIndex={font.value === selectedFontFamily ? 0 : -1}
         onClick={(e) => {
-          onSelect(Number(e.currentTarget.value));
+          wrappedOnSelect(Number(e.currentTarget.value));
         }}
         onMouseMove={() => {
           if (hoveredFont?.value !== font.value) {
@@ -282,9 +319,24 @@ export const FontPickerList = React.memo(
         className="properties-content"
         container={container}
         style={{ width: "15rem" }}
-        onClose={onClose}
+        onClose={() => {
+          onClose();
+
+          // Refocus text editor when font picker closes if we were editing text
+          if (app.state.editingTextElement) {
+            setTimeout(() => {
+              const textEditor = document.querySelector(
+                ".excalidraw-wysiwyg",
+              ) as HTMLTextAreaElement;
+              if (textEditor) {
+                textEditor.focus();
+              }
+            }, 0);
+          }
+        }}
         onPointerLeave={onLeave}
         onKeyDown={onKeyDown}
+        preventAutoFocusOnTouch={!!app.state.editingTextElement}
       >
         <QuickSearch
           ref={inputRef}

+ 15 - 11
packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx

@@ -1,5 +1,4 @@
 import * as Popover from "@radix-ui/react-popover";
-import { useMemo } from "react";
 
 import type { FontFamilyValues } from "@excalidraw/element/types";
 
@@ -7,33 +6,38 @@ import { t } from "../../i18n";
 import { ButtonIcon } from "../ButtonIcon";
 import { TextIcon } from "../icons";
 
-import { isDefaultFont } from "./FontPicker";
+import { useExcalidrawSetAppState } from "../App";
 
 interface FontPickerTriggerProps {
   selectedFontFamily: FontFamilyValues | null;
+  isOpened?: boolean;
 }
 
 export const FontPickerTrigger = ({
   selectedFontFamily,
+  isOpened = false,
 }: FontPickerTriggerProps) => {
-  const isTriggerActive = useMemo(
-    () => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
-    [selectedFontFamily],
-  );
+  const setAppState = useExcalidrawSetAppState();
 
   return (
     <Popover.Trigger asChild>
-      {/* Empty div as trigger so it's stretched 100% due to different button sizes */}
-      <div>
+      <div data-openpopup="fontFamily" className="properties-trigger">
         <ButtonIcon
           standalone
           icon={TextIcon}
           title={t("labels.showFonts")}
           className="properties-trigger"
           testId={"font-family-show-fonts"}
-          active={isTriggerActive}
-          // no-op
-          onClick={() => {}}
+          active={isOpened}
+          onClick={() => {
+            setAppState((appState) => ({
+              openPopup:
+                appState.openPopup === "fontFamily" ? null : appState.openPopup,
+            }));
+          }}
+          style={{
+            border: "none",
+          }}
         />
       </div>
     </Popover.Trigger>

+ 4 - 0
packages/excalidraw/components/LayerUI.scss

@@ -24,6 +24,10 @@
       gap: 0.75rem;
       pointer-events: none !important;
 
+      &--compact {
+        gap: 0.5rem;
+      }
+
       & > * {
         pointer-events: var(--ui-pointerEvents);
       }

+ 97 - 33
packages/excalidraw/components/LayerUI.tsx

@@ -4,6 +4,7 @@ import React from "react";
 import {
   CLASSES,
   DEFAULT_SIDEBAR,
+  MQ_MIN_WIDTH_DESKTOP,
   TOOL_TYPE,
   arrayToMap,
   capitalizeString,
@@ -28,7 +29,11 @@ import { useAtom, useAtomValue } from "../editor-jotai";
 import { t } from "../i18n";
 import { calculateScrollCenter } from "../scene";
 
-import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
+import {
+  SelectedShapeActions,
+  ShapesSwitcher,
+  CompactShapeActions,
+} from "./Actions";
 import { LoadingMessage } from "./LoadingMessage";
 import { LockButton } from "./LockButton";
 import { MobileMenu } from "./MobileMenu";
@@ -157,6 +162,25 @@ const LayerUI = ({
   const device = useDevice();
   const tunnels = useInitializeTunnels();
 
+  const spacing =
+    appState.stylesPanelMode === "compact"
+      ? {
+          menuTopGap: 4,
+          toolbarColGap: 4,
+          toolbarRowGap: 1,
+          toolbarInnerRowGap: 0.5,
+          islandPadding: 1,
+          collabMarginLeft: 8,
+        }
+      : {
+          menuTopGap: 6,
+          toolbarColGap: 4,
+          toolbarRowGap: 1,
+          toolbarInnerRowGap: 1,
+          islandPadding: 1,
+          collabMarginLeft: 8,
+        };
+
   const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
 
   const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
@@ -209,31 +233,55 @@ const LayerUI = ({
     </div>
   );
 
-  const renderSelectedShapeActions = () => (
-    <Section
-      heading="selectedShapeActions"
-      className={clsx("selected-shape-actions zen-mode-transition", {
-        "transition-left": appState.zenModeEnabled,
-      })}
-    >
-      <Island
-        className={CLASSES.SHAPE_ACTIONS_MENU}
-        padding={2}
-        style={{
-          // we want to make sure this doesn't overflow so subtracting the
-          // approximate height of hamburgerMenu + footer
-          maxHeight: `${appState.height - 166}px`,
-        }}
+  const renderSelectedShapeActions = () => {
+    const isCompactMode = appState.stylesPanelMode === "compact";
+
+    return (
+      <Section
+        heading="selectedShapeActions"
+        className={clsx("selected-shape-actions zen-mode-transition", {
+          "transition-left": appState.zenModeEnabled,
+        })}
       >
-        <SelectedShapeActions
-          appState={appState}
-          elementsMap={app.scene.getNonDeletedElementsMap()}
-          renderAction={actionManager.renderAction}
-          app={app}
-        />
-      </Island>
-    </Section>
-  );
+        {isCompactMode ? (
+          <Island
+            className={clsx("compact-shape-actions-island")}
+            padding={0}
+            style={{
+              // we want to make sure this doesn't overflow so subtracting the
+              // approximate height of hamburgerMenu + footer
+              maxHeight: `${appState.height - 166}px`,
+            }}
+          >
+            <CompactShapeActions
+              appState={appState}
+              elementsMap={app.scene.getNonDeletedElementsMap()}
+              renderAction={actionManager.renderAction}
+              app={app}
+              setAppState={setAppState}
+            />
+          </Island>
+        ) : (
+          <Island
+            className={CLASSES.SHAPE_ACTIONS_MENU}
+            padding={2}
+            style={{
+              // we want to make sure this doesn't overflow so subtracting the
+              // approximate height of hamburgerMenu + footer
+              maxHeight: `${appState.height - 166}px`,
+            }}
+          >
+            <SelectedShapeActions
+              appState={appState}
+              elementsMap={app.scene.getNonDeletedElementsMap()}
+              renderAction={actionManager.renderAction}
+              app={app}
+            />
+          </Island>
+        )}
+      </Section>
+    );
+  };
 
   const renderFixedSideContainer = () => {
     const shouldRenderSelectedShapeActions = showSelectedShapeActions(
@@ -250,9 +298,19 @@ const LayerUI = ({
     return (
       <FixedSideContainer side="top">
         <div className="App-menu App-menu_top">
-          <Stack.Col gap={6} className={clsx("App-menu_top__left")}>
+          <Stack.Col
+            gap={spacing.menuTopGap}
+            className={clsx("App-menu_top__left")}
+          >
             {renderCanvasActions()}
-            {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
+            <div
+              className={clsx("selected-shape-actions-container", {
+                "selected-shape-actions-container--compact":
+                  appState.stylesPanelMode === "compact",
+              })}
+            >
+              {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
+            </div>
           </Stack.Col>
           {!appState.viewModeEnabled &&
             appState.openDialog?.name !== "elementLinkSelector" && (
@@ -262,17 +320,19 @@ const LayerUI = ({
                     {renderWelcomeScreen && (
                       <tunnels.WelcomeScreenToolbarHintTunnel.Out />
                     )}
-                    <Stack.Col gap={4} align="start">
+                    <Stack.Col gap={spacing.toolbarColGap} align="start">
                       <Stack.Row
-                        gap={1}
+                        gap={spacing.toolbarRowGap}
                         className={clsx("App-toolbar-container", {
                           "zen-mode": appState.zenModeEnabled,
                         })}
                       >
                         <Island
-                          padding={1}
+                          padding={spacing.islandPadding}
                           className={clsx("App-toolbar", {
                             "zen-mode": appState.zenModeEnabled,
+                            "App-toolbar--compact":
+                              appState.stylesPanelMode === "compact",
                           })}
                         >
                           <HintViewer
@@ -282,7 +342,7 @@ const LayerUI = ({
                             app={app}
                           />
                           {heading}
-                          <Stack.Row gap={1}>
+                          <Stack.Row gap={spacing.toolbarInnerRowGap}>
                             <PenModeButton
                               zenModeEnabled={appState.zenModeEnabled}
                               checked={appState.penMode}
@@ -316,7 +376,7 @@ const LayerUI = ({
                         {isCollaborating && (
                           <Island
                             style={{
-                              marginLeft: 8,
+                              marginLeft: spacing.collabMarginLeft,
                               alignSelf: "center",
                               height: "fit-content",
                             }}
@@ -344,6 +404,8 @@ const LayerUI = ({
               "layer-ui__wrapper__top-right zen-mode-transition",
               {
                 "transition-right": appState.zenModeEnabled,
+                "layer-ui__wrapper__top-right--compact":
+                  appState.stylesPanelMode === "compact",
               },
             )}
           >
@@ -418,7 +480,9 @@ const LayerUI = ({
         }}
         tab={DEFAULT_SIDEBAR.defaultTab}
       >
-        {t("toolBar.library")}
+        {appState.stylesPanelMode === "full" &&
+          appState.width >= MQ_MIN_WIDTH_DESKTOP &&
+          t("toolBar.library")}
       </DefaultSidebar.Trigger>
       <DefaultOverwriteConfirmDialog />
       {appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}

+ 8 - 0
packages/excalidraw/components/PropertiesPopover.tsx

@@ -17,6 +17,7 @@ interface PropertiesPopoverProps {
   onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
   onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"];
   onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"];
+  preventAutoFocusOnTouch?: boolean;
 }
 
 export const PropertiesPopover = React.forwardRef<
@@ -34,6 +35,7 @@ export const PropertiesPopover = React.forwardRef<
       onFocusOutside,
       onPointerLeave,
       onPointerDownOutside,
+      preventAutoFocusOnTouch = false,
     },
     ref,
   ) => {
@@ -64,6 +66,12 @@ export const PropertiesPopover = React.forwardRef<
           onKeyDown={onKeyDown}
           onFocusOutside={onFocusOutside}
           onPointerDownOutside={onPointerDownOutside}
+          onOpenAutoFocus={(e) => {
+            // prevent auto-focus on touch devices to avoid keyboard popup
+            if (preventAutoFocusOnTouch && device.isTouchScreen) {
+              e.preventDefault();
+            }
+          }}
           onCloseAutoFocus={(e) => {
             e.stopPropagation();
             // prevents focusing the trigger

+ 10 - 0
packages/excalidraw/components/Toolbar.scss

@@ -10,6 +10,16 @@
       }
     }
 
+    &--compact {
+      .ToolIcon__keybinding {
+        display: none;
+      }
+
+      .App-toolbar__divider {
+        margin: 0;
+      }
+    }
+
     &__divider {
       width: 1px;
       height: 1.5rem;

+ 69 - 0
packages/excalidraw/components/icons.tsx

@@ -118,6 +118,17 @@ export const DotsIcon = createIcon(
   tablerIconProps,
 );
 
+// tabler-icons: dots-horizontal (horizontal equivalent of dots-vertical)
+export const DotsHorizontalIcon = createIcon(
+  <g strokeWidth="1.5">
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
+    <path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
+    <path d="M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
+  </g>,
+  tablerIconProps,
+);
+
 // tabler-icons: pinned
 export const PinIcon = createIcon(
   <svg strokeWidth="1.5">
@@ -396,6 +407,19 @@ export const TextIcon = createIcon(
   tablerIconProps,
 );
 
+export const TextSizeIcon = createIcon(
+  <g stroke="currentColor" strokeWidth="1.5">
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M3 7v-2h13v2" />
+    <path d="M10 5v14" />
+    <path d="M12 19h-4" />
+    <path d="M15 13v-1h6v1" />
+    <path d="M18 12v7" />
+    <path d="M17 19h2" />
+  </g>,
+  tablerIconProps,
+);
+
 // modified tabler-icons: photo
 export const ImageIcon = createIcon(
   <g strokeWidth="1.25">
@@ -2269,3 +2293,48 @@ export const elementLinkIcon = createIcon(
   </g>,
   tablerIconProps,
 );
+
+export const resizeIcon = createIcon(
+  <g strokeWidth={1.5}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M4 11v8a1 1 0 0 0 1 1h8m-9 -14v-1a1 1 0 0 1 1 -1h1m5 0h2m5 0h1a1 1 0 0 1 1 1v1m0 5v2m0 5v1a1 1 0 0 1 -1 1h-1" />
+    <path d="M4 12h7a1 1 0 0 1 1 1v7" />
+  </g>,
+  tablerIconProps,
+);
+
+export const adjustmentsIcon = createIcon(
+  <g strokeWidth={1.5}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
+    <path d="M4 6l8 0" />
+    <path d="M16 6l4 0" />
+    <path d="M8 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
+    <path d="M4 12l2 0" />
+    <path d="M10 12l10 0" />
+    <path d="M17 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
+    <path d="M4 18l11 0" />
+    <path d="M19 18l1 0" />
+  </g>,
+  tablerIconProps,
+);
+
+export const backgroundIcon = createIcon(
+  <g strokeWidth={1}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M6 10l4 -4" />
+    <path d="M6 14l8 -8" />
+    <path d="M6 18l12 -12" />
+    <path d="M10 18l8 -8" />
+    <path d="M14 18l4 -4" />
+  </g>,
+  tablerIconProps,
+);
+
+export const strokeIcon = createIcon(
+  <g strokeWidth={1}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <rect x="6" y="6" width="12" height="12" fill="none" />
+  </g>,
+  tablerIconProps,
+);

+ 4 - 1
packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx

@@ -1,5 +1,7 @@
 import clsx from "clsx";
 
+import { isMobileOrTablet, MQ_MIN_WIDTH_DESKTOP } from "@excalidraw/common";
+
 import { t } from "../../i18n";
 import { Button } from "../Button";
 import { share } from "../icons";
@@ -17,7 +19,8 @@ const LiveCollaborationTrigger = ({
 } & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
   const appState = useUIAppState();
 
-  const showIconOnly = appState.width < 830;
+  const showIconOnly =
+    isMobileOrTablet() || appState.width < MQ_MIN_WIDTH_DESKTOP;
 
   return (
     <Button

+ 9 - 1
packages/excalidraw/css/styles.scss

@@ -317,7 +317,7 @@ body.excalidraw-cursor-resize * {
 
   .App-menu_top {
     grid-template-columns: 1fr 2fr 1fr;
-    grid-gap: 2rem;
+    grid-gap: 1rem;
     align-items: flex-start;
     cursor: default;
     pointer-events: none !important;
@@ -336,6 +336,14 @@ body.excalidraw-cursor-resize * {
     justify-self: flex-start;
   }
 
+  .selected-shape-actions-container {
+    width: fit-content;
+
+    &--compact {
+      min-width: 48px;
+    }
+  }
+
   .App-menu_top > *:last-child {
     justify-self: flex-end;
   }

+ 112 - 0
packages/excalidraw/hooks/useTextEditorFocus.ts

@@ -0,0 +1,112 @@
+import { useState, useCallback } from "react";
+
+// Utility type for caret position
+export type CaretPosition = {
+  start: number;
+  end: number;
+};
+
+// Utility function to get text editor element
+const getTextEditor = (): HTMLTextAreaElement | null => {
+  return document.querySelector(".excalidraw-wysiwyg") as HTMLTextAreaElement;
+};
+
+// Utility functions for caret position management
+export const saveCaretPosition = (): CaretPosition | null => {
+  const textEditor = getTextEditor();
+  if (textEditor) {
+    return {
+      start: textEditor.selectionStart,
+      end: textEditor.selectionEnd,
+    };
+  }
+  return null;
+};
+
+export const restoreCaretPosition = (position: CaretPosition | null): void => {
+  setTimeout(() => {
+    const textEditor = getTextEditor();
+    if (textEditor) {
+      textEditor.focus();
+      if (position) {
+        textEditor.selectionStart = position.start;
+        textEditor.selectionEnd = position.end;
+      }
+    }
+  }, 0);
+};
+
+export const withCaretPositionPreservation = (
+  callback: () => void,
+  isCompactMode: boolean,
+  isEditingText: boolean,
+  onPreventClose?: () => void,
+): void => {
+  // Prevent popover from closing in compact mode
+  if (isCompactMode && onPreventClose) {
+    onPreventClose();
+  }
+
+  // Save caret position if editing text
+  const savedPosition =
+    isCompactMode && isEditingText ? saveCaretPosition() : null;
+
+  // Execute the callback
+  callback();
+
+  // Restore caret position if needed
+  if (isCompactMode && isEditingText) {
+    restoreCaretPosition(savedPosition);
+  }
+};
+
+// Hook for managing text editor caret position with state
+export const useTextEditorFocus = () => {
+  const [savedCaretPosition, setSavedCaretPosition] =
+    useState<CaretPosition | null>(null);
+
+  const saveCaretPositionToState = useCallback(() => {
+    const position = saveCaretPosition();
+    setSavedCaretPosition(position);
+  }, []);
+
+  const restoreCaretPositionFromState = useCallback(() => {
+    setTimeout(() => {
+      const textEditor = getTextEditor();
+      if (textEditor) {
+        textEditor.focus();
+        if (savedCaretPosition) {
+          textEditor.selectionStart = savedCaretPosition.start;
+          textEditor.selectionEnd = savedCaretPosition.end;
+          setSavedCaretPosition(null);
+        }
+      }
+    }, 0);
+  }, [savedCaretPosition]);
+
+  const clearSavedPosition = useCallback(() => {
+    setSavedCaretPosition(null);
+  }, []);
+
+  return {
+    saveCaretPosition: saveCaretPositionToState,
+    restoreCaretPosition: restoreCaretPositionFromState,
+    clearSavedPosition,
+    hasSavedPosition: !!savedCaretPosition,
+  };
+};
+
+// Utility function to temporarily disable text editor blur
+export const temporarilyDisableTextEditorBlur = (
+  duration: number = 100,
+): void => {
+  const textEditor = getTextEditor();
+  if (textEditor) {
+    const originalOnBlur = textEditor.onblur;
+    textEditor.onblur = null;
+
+    setTimeout(() => {
+      textEditor.onblur = originalOnBlur;
+    }, duration);
+  }
+};

+ 17 - 0
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -981,6 +981,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1172,6 +1173,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": {
@@ -1384,6 +1386,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1713,6 +1716,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2042,6 +2046,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": {
@@ -2252,6 +2257,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2493,6 +2499,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2794,6 +2801,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3159,6 +3167,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": {
@@ -3650,6 +3659,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3971,6 +3981,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4295,6 +4306,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5578,6 +5590,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -6795,6 +6808,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7724,6 +7738,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8721,6 +8736,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9713,6 +9729,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,

+ 1 - 0
packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap

@@ -688,6 +688,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
               aria-haspopup="dialog"
               aria-label="Canvas background"
               class="color-picker__button active-color properties-trigger has-outline"
+              data-openpopup="canvasBackground"
               data-state="closed"
               style="--swatch-color: #ffffff;"
               title="Show background color picker"

+ 63 - 0
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -100,6 +100,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -714,6 +715,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1197,6 +1199,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1559,6 +1562,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1924,6 +1928,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2184,6 +2189,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2625,6 +2631,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2926,6 +2933,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3243,6 +3251,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3535,6 +3544,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3819,6 +3829,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4052,6 +4063,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4307,6 +4319,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4576,6 +4589,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4803,6 +4817,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5030,6 +5045,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5275,6 +5291,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5529,6 +5546,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5784,6 +5802,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -6111,6 +6130,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -6539,6 +6559,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -6917,6 +6938,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7216,6 +7238,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7530,6 +7553,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7758,6 +7782,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8108,6 +8133,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8464,6 +8490,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8862,6 +8889,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9149,6 +9177,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9411,6 +9440,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9674,6 +9704,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9905,6 +9936,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10199,6 +10231,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10544,6 +10577,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10781,6 +10815,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11226,6 +11261,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11482,6 +11518,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11717,6 +11754,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11950,6 +11988,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -12356,6 +12395,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -12561,6 +12601,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -12770,6 +12811,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13066,6 +13108,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13362,6 +13405,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13604,6 +13648,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13839,6 +13884,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -14074,6 +14120,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -14319,6 +14366,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -14649,6 +14697,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -14819,6 +14868,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -15099,6 +15149,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -15360,6 +15411,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -15512,6 +15564,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -15791,6 +15844,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -15952,6 +16006,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -16655,6 +16710,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -17288,6 +17344,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -17919,6 +17976,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -18639,6 +18697,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -19388,6 +19447,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -19868,6 +19928,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -20372,6 +20433,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -20831,6 +20893,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,

+ 52 - 0
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -108,6 +108,7 @@ exports[`given element A and group of elements B and given both are selected whe
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -534,6 +535,7 @@ exports[`given element A and group of elements B and given both are selected whe
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -939,6 +941,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1503,6 +1506,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1713,6 +1717,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2092,6 +2097,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2333,6 +2339,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2513,6 +2520,7 @@ exports[`regression tests > can drag element that covers another element, while
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2834,6 +2842,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3089,6 +3098,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3328,6 +3338,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3562,6 +3573,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3819,6 +3831,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4130,6 +4143,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4591,6 +4605,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4844,6 +4859,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5145,6 +5161,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5323,6 +5340,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5521,6 +5539,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5916,6 +5935,7 @@ exports[`regression tests > drags selected elements from point inside common bou
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -6205,6 +6225,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7064,6 +7085,7 @@ exports[`regression tests > given a group of selected elements with an element t
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7396,6 +7418,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7672,6 +7695,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7905,6 +7929,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8141,6 +8166,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8319,6 +8345,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8497,6 +8524,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8702,6 +8730,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8930,6 +8959,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9127,6 +9157,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9350,6 +9381,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9551,6 +9583,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9756,6 +9789,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9955,6 +9989,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10131,6 +10166,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10327,6 +10363,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10513,6 +10550,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11036,6 +11074,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11310,6 +11349,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11433,6 +11473,7 @@ exports[`regression tests > shift click on selected element should deselect it o
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11635,6 +11676,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11954,6 +11996,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -12385,6 +12428,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13014,6 +13058,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13139,6 +13184,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13797,6 +13843,7 @@ exports[`regression tests > switches from group of selected elements to another
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -14133,6 +14180,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -14363,6 +14411,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -14486,6 +14535,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -14874,6 +14924,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -14998,6 +15049,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,

+ 7 - 0
packages/excalidraw/types.ts

@@ -352,6 +352,10 @@ export interface AppState {
     | "elementBackground"
     | "elementStroke"
     | "fontFamily"
+    | "compactTextProperties"
+    | "compactStrokeStyles"
+    | "compactOtherProperties"
+    | "compactArrowProperties"
     | null;
   openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
   openDialog:
@@ -442,6 +446,9 @@ export interface AppState {
   // as elements are unlocked, we remove the groupId from the elements
   // and also remove groupId from this map
   lockedMultiSelections: { [groupId: string]: true };
+
+  /** properties sidebar mode - determines whether to show compact or complete sidebar */
+  stylesPanelMode: "compact" | "full";
 }
 
 export type SearchMatch = {

+ 25 - 8
packages/excalidraw/wysiwyg/textWysiwyg.tsx

@@ -542,6 +542,7 @@ export const textWysiwyg = ({
     if (isDestroyed) {
       return;
     }
+
     isDestroyed = true;
     // cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
     // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
@@ -625,14 +626,24 @@ export const textWysiwyg = ({
     const isPropertiesTrigger =
       target instanceof HTMLElement &&
       target.classList.contains("properties-trigger");
+    const isPropertiesContent =
+      (target instanceof HTMLElement || target instanceof SVGElement) &&
+      !!(target as Element).closest(".properties-content");
+    const inShapeActionsMenu =
+      (target instanceof HTMLElement || target instanceof SVGElement) &&
+      (!!(target as Element).closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) ||
+        !!(target as Element).closest(".compact-shape-actions-island"));
 
     setTimeout(() => {
-      editable.onblur = handleSubmit;
-
-      // case: clicking on the same property → no change → no update → no focus
-      if (!isPropertiesTrigger) {
-        editable.focus();
+      // If we interacted within shape actions menu or its popovers/triggers,
+      // keep submit disabled and don't steal focus back to textarea.
+      if (inShapeActionsMenu || isPropertiesTrigger || isPropertiesContent) {
+        return;
       }
+
+      // Otherwise, re-enable submit on blur and refocus the editor.
+      editable.onblur = handleSubmit;
+      editable.focus();
     });
   };
 
@@ -655,6 +666,7 @@ export const textWysiwyg = ({
         event.preventDefault();
         app.handleCanvasPanUsingWheelOrSpaceDrag(event);
       }
+
       temporarilyDisableSubmit();
       return;
     }
@@ -662,15 +674,20 @@ export const textWysiwyg = ({
     const isPropertiesTrigger =
       target instanceof HTMLElement &&
       target.classList.contains("properties-trigger");
+    const isPropertiesContent =
+      (target instanceof HTMLElement || target instanceof SVGElement) &&
+      !!(target as Element).closest(".properties-content");
 
     if (
       ((event.target instanceof HTMLElement ||
         event.target instanceof SVGElement) &&
-        event.target.closest(
+        (event.target.closest(
           `.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
-        ) &&
+        ) ||
+          event.target.closest(".compact-shape-actions-island")) &&
         !isWritableElement(event.target)) ||
-      isPropertiesTrigger
+      isPropertiesTrigger ||
+      isPropertiesContent
     ) {
       temporarilyDisableSubmit();
     } else if (

+ 1 - 0
packages/utils/tests/__snapshots__/export.test.ts.snap

@@ -100,6 +100,7 @@ exports[`exportToSvg > with default arguments 1`] = `
     "open": false,
     "panels": 3,
   },
+  "stylesPanelMode": "full",
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,