From 204e06b77bf7b592e29891160b775df33399ff4d Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 12 Sep 2025 10:18:31 +1000 Subject: [PATCH] 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 <5153846+dwelle@users.noreply.github.com> --- packages/common/src/constants.ts | 13 +- packages/common/src/utils.ts | 58 +++ packages/excalidraw/actions/actionCanvas.tsx | 3 +- .../excalidraw/actions/actionLinearEditor.tsx | 4 + .../excalidraw/actions/actionProperties.tsx | 141 ++++-- packages/excalidraw/actions/index.ts | 1 + packages/excalidraw/actions/types.ts | 1 + packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/Actions.scss | 117 +++++ packages/excalidraw/components/Actions.tsx | 460 +++++++++++++++++- packages/excalidraw/components/App.tsx | 54 +- .../components/ColorPicker/ColorPicker.scss | 16 + .../components/ColorPicker/ColorPicker.tsx | 153 +++++- .../components/FontPicker/FontPicker.scss | 5 + .../components/FontPicker/FontPicker.tsx | 36 +- .../components/FontPicker/FontPickerList.tsx | 62 ++- .../FontPicker/FontPickerTrigger.tsx | 26 +- packages/excalidraw/components/LayerUI.scss | 4 + packages/excalidraw/components/LayerUI.tsx | 130 +++-- .../components/PropertiesPopover.tsx | 8 + packages/excalidraw/components/Toolbar.scss | 10 + packages/excalidraw/components/icons.tsx | 69 +++ .../LiveCollaborationTrigger.tsx | 5 +- packages/excalidraw/css/styles.scss | 10 +- .../excalidraw/hooks/useTextEditorFocus.ts | 112 +++++ .../__snapshots__/contextmenu.test.tsx.snap | 17 + .../__snapshots__/excalidraw.test.tsx.snap | 1 + .../tests/__snapshots__/history.test.tsx.snap | 63 +++ .../regressionTests.test.tsx.snap | 52 ++ packages/excalidraw/types.ts | 7 + packages/excalidraw/wysiwyg/textWysiwyg.tsx | 33 +- .../tests/__snapshots__/export.test.ts.snap | 1 + 32 files changed, 1527 insertions(+), 147 deletions(-) create mode 100644 packages/excalidraw/hooks/useTextEditorFocus.ts diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 88a102772..0366e0910 100644 --- a/packages/common/src/constants.ts +++ b/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; // ----------------------------------------------------------------------------- diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 105496065..8130482db 100644 --- a/packages/common/src/utils.ts +++ b/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 = ( 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; +}; diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 535d96c7d..d0039d1c2 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/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 ( ); }, diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx index 9b18c64de..8437ece8b 100644 --- a/packages/excalidraw/actions/actionLinearEditor.tsx +++ b/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" diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 63cfe7672..c03309e9c 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/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 }) => ( <> - + {appState.stylesPanelMode === "full" && ( + + )} ), @@ -398,9 +406,11 @@ export const actionChangeBackgroundColor = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => ( <> - + {appState.stylesPanelMode === "full" && ( + + )} ), @@ -518,9 +529,11 @@ export const actionChangeStrokeWidth = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
- {t("labels.strokeWidth")} + {appState.stylesPanelMode === "full" && ( + {t("labels.strokeWidth")} + )}
( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
- {t("labels.sloppiness")} + {appState.stylesPanelMode === "full" && ( + {t("labels.sloppiness")} + )}
( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
- {t("labels.strokeStyle")} + {appState.stylesPanelMode === "full" && ( + {t("labels.strokeStyle")} + )}
{ return changeFontSize(elements, appState, app, () => value, value); }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
{t("labels.fontSize")}
@@ -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, + ); + }} />
@@ -1016,7 +1040,7 @@ export const actionChangeFontFamily = register({ return result; }, - PanelComponent: ({ elements, appState, app, updateData }) => { + PanelComponent: ({ elements, appState, app, updateData, data }) => { const cachedElementsRef = useRef(new Map()); const prevSelectedFontFamilyRef = useRef(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 (
- {t("labels.fontFamily")} + {appState.stylesPanelMode === "full" && ( + {t("labels.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 (
{t("labels.textAlign")} @@ -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, + ); + }} />
@@ -1317,7 +1360,7 @@ export const actionChangeVerticalAlign = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => { + PanelComponent: ({ elements, appState, updateData, app, data }) => { return (
@@ -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, + ); + }} />
@@ -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 ( +
+ {renderAction("changeArrowType")} + {renderAction("changeArrowhead")} +
+ ); + }, +}); + export const actionChangeArrowType = register({ name: "changeArrowType", label: "Change arrow types", diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index f37747aeb..2719a5d0a 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -18,6 +18,7 @@ export { actionChangeFontFamily, actionChangeTextAlign, actionChangeVerticalAlign, + actionChangeArrowProperties, } from "./actionProperties"; export { diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index e6f363126..302a76fb4 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -69,6 +69,7 @@ export type ActionName = | "changeStrokeStyle" | "changeArrowhead" | "changeArrowType" + | "changeArrowProperties" | "changeOpacity" | "changeFontSize" | "toggleCanvasMenu" diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 6c4a97116..2a37b138d 100644 --- a/packages/excalidraw/appState.ts +++ b/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 = < diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss index 5826628de..93b5ef7c3 100644 --- a/packages/excalidraw/components/Actions.scss +++ b/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); +} diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 91bef0e05..f43a4925d 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/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["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 ( +
+ {/* Stroke Color */} + {canChangeStrokeColor(appState, targetElements) && ( +
+ {renderAction("changeStrokeColor")} +
+ )} + + {/* Background Color */} + {canChangeBackgroundColor(appState, targetElements) && ( +
+ {renderAction("changeBackgroundColor")} +
+ )} + + {/* 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))) && ( +
+ { + if (open) { + setAppState({ openPopup: "compactStrokeStyles" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {appState.openPopup === "compactStrokeStyles" && ( + {}} + > +
+ {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")} +
+
+ )} +
+
+ )} + + {/* Combined Arrow Properties */} + {(toolIsArrow(appState.activeTool.type) || + targetElements.some((element) => toolIsArrow(element.type))) && ( +
+ { + if (open) { + setAppState({ openPopup: "compactArrowProperties" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {appState.openPopup === "compactArrowProperties" && ( + {}} + > + {renderAction("changeArrowProperties")} + + )} + +
+ )} + + {/* Linear Editor */} + {showLineEditorAction && ( +
+ {renderAction("toggleLinearEditor")} +
+ )} + + {/* Text Properties */} + {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && ( + <> +
+ {renderAction("changeFontFamily")} +
+
+ { + if (open) { + if (appState.editingTextElement) { + saveCaretPosition(); + } + setAppState({ openPopup: "compactTextProperties" }); + } else { + setAppState({ openPopup: null }); + if (appState.editingTextElement) { + restoreCaretPosition(); + } + } + }} + > + + + + {appState.openPopup === "compactTextProperties" && ( + { + // Refocus text editor when popover closes with caret restoration + if (appState.editingTextElement) { + restoreCaretPosition(); + } + }} + > +
+ {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && + renderAction("changeFontSize")} + {(appState.activeTool.type === "text" || + suppportsHorizontalAlign(targetElements, elementsMap)) && + renderAction("changeTextAlign")} + {shouldAllowVerticalAlign(targetElements, elementsMap) && + renderAction("changeVerticalAlign")} +
+
+ )} +
+
+ + )} + + {/* Dedicated Copy Button */} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( +
+ {renderAction("duplicateSelection")} +
+ )} + + {/* Dedicated Delete Button */} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( +
+ {renderAction("deleteSelectedElements")} +
+ )} + + {/* Combined Other Actions */} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( +
+ { + if (open) { + setAppState({ openPopup: "compactOtherProperties" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {appState.openPopup === "compactOtherProperties" && ( + {}} + > +
+
+ {t("labels.layers")} +
+ {renderAction("sendToBack")} + {renderAction("sendBackward")} + {renderAction("bringForward")} + {renderAction("bringToFront")} +
+
+ + {showAlignActions && !isSingleElementBoundContainer && ( +
+ {t("labels.align")} +
+ {isRTL ? ( + <> + {renderAction("alignRight")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignLeft")} + + ) : ( + <> + {renderAction("alignLeft")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignRight")} + + )} + {targetElements.length > 2 && + renderAction("distributeHorizontally")} + {/* breaks the row ˇˇ */} +
+
+ {renderAction("alignTop")} + {renderAction("alignVerticallyCentered")} + {renderAction("alignBottom")} + {targetElements.length > 2 && + renderAction("distributeVertically")} +
+
+
+ )} +
+ {t("labels.actions")} +
+ {renderAction("group")} + {renderAction("ungroup")} + {showLinkIcon && renderAction("hyperlink")} + {showCropEditorAction && renderAction("cropEditor")} +
+
+
+
+ )} +
+
+ )} +
+ ); +}; + export const ShapesSwitcher = ({ activeTool, appState, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 788600749..3bbfdca6e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/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 { 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 { } }; - 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 { 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 { 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 { this.addElementsFromPasteOrLibrary({ elements, files, - position: this.isMobileOrTablet() ? "center" : "cursor", + position: isMobileOrTablet() ? "center" : "cursor", }); return; @@ -6668,8 +6678,6 @@ class App extends React.Component { 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 { // 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 { if ( this.state.activeTool.type === "lasso" && this.lassoTrail.hasCurrentTrail && - !(this.isMobileOrTablet() && pointerDownState.hit.element) && + !(isMobileOrTablet() && pointerDownState.hit.element) && !this.state.activeTool.fromSelection ) { return; diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.scss b/packages/excalidraw/components/ColorPicker/ColorPicker.scss index 7a78395d6..0e3768dcc 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.scss +++ b/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; diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index 270d61f4c..51c7bbd2c 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/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 = ({ { // 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 (
{!color && slashIcon}
+ {compactMode && color && ( +
+ {mode === "background" ? ( + + {backgroundIcon} + + ) : ( + + {strokeIcon} + + )} +
+ )}
); }; @@ -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 (
-
- - +
+ {!compactMode && ( + + )} + {!compactMode && } { - updateData({ openPopup: open ? type : null }); + if (open) { + updateData({ openPopup: type }); + } }} > {/* serves as an active color indicator as well */} - + { + // 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 && ( openRef.current} + appState={appState} /> )} diff --git a/packages/excalidraw/components/FontPicker/FontPicker.scss b/packages/excalidraw/components/FontPicker/FontPicker.scss index 5a572585e..70859e809 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.scss +++ b/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; + } } } diff --git a/packages/excalidraw/components/FontPicker/FontPicker.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx index 118c6fac3..891ae49ef 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.tsx +++ b/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 ( -
-
- - type="button" - options={defaultFonts} - value={selectedFontFamily} - onClick={onSelectCallback} - /> -
- +
+ {!compactMode && ( +
+ + type="button" + options={defaultFonts} + value={selectedFontFamily} + onClick={onSelectCallback} + /> +
+ )} + {!compactMode && } - + {isOpened && ( { 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>( (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} > { - const isTriggerActive = useMemo( - () => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)), - [selectedFontFamily], - ); + const setAppState = useExcalidrawSetAppState(); return ( - {/* Empty div as trigger so it's stretched 100% due to different button sizes */} -
+
{}} + active={isOpened} + onClick={() => { + setAppState((appState) => ({ + openPopup: + appState.openPopup === "fontFamily" ? null : appState.openPopup, + })); + }} + style={{ + border: "none", + }} />
diff --git a/packages/excalidraw/components/LayerUI.scss b/packages/excalidraw/components/LayerUI.scss index 36153d72b..5c202a067 100644 --- a/packages/excalidraw/components/LayerUI.scss +++ b/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); } diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index d216f1d46..674809532 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/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 = ({
); - const renderSelectedShapeActions = () => ( -
- { + const isCompactMode = appState.stylesPanelMode === "compact"; + + return ( +
- - -
- ); + {isCompactMode ? ( + + + + ) : ( + + + + )} +
+ ); + }; const renderFixedSideContainer = () => { const shouldRenderSelectedShapeActions = showSelectedShapeActions( @@ -250,9 +298,19 @@ const LayerUI = ({ return (
- + {renderCanvasActions()} - {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} +
+ {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} +
{!appState.viewModeEnabled && appState.openDialog?.name !== "elementLinkSelector" && ( @@ -262,17 +320,19 @@ const LayerUI = ({ {renderWelcomeScreen && ( )} - + {heading} - + @@ -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")} {appState.openDialog?.name === "ttd" && } diff --git a/packages/excalidraw/components/PropertiesPopover.tsx b/packages/excalidraw/components/PropertiesPopover.tsx index d8372ea27..d4437b385 100644 --- a/packages/excalidraw/components/PropertiesPopover.tsx +++ b/packages/excalidraw/components/PropertiesPopover.tsx @@ -17,6 +17,7 @@ interface PropertiesPopoverProps { onPointerLeave?: React.PointerEventHandler; 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 diff --git a/packages/excalidraw/components/Toolbar.scss b/packages/excalidraw/components/Toolbar.scss index 1565120ac..14c4cc174 100644 --- a/packages/excalidraw/components/Toolbar.scss +++ b/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; diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 29bdc6d3c..33e59380c 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/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( + + + + + + , + tablerIconProps, +); + // tabler-icons: pinned export const PinIcon = createIcon( @@ -396,6 +407,19 @@ export const TextIcon = createIcon( tablerIconProps, ); +export const TextSizeIcon = createIcon( + + + + + + + + + , + tablerIconProps, +); + // modified tabler-icons: photo export const ImageIcon = createIcon( @@ -2269,3 +2293,48 @@ export const elementLinkIcon = createIcon( , tablerIconProps, ); + +export const resizeIcon = createIcon( + + + + + , + tablerIconProps, +); + +export const adjustmentsIcon = createIcon( + + + + + + + + + + + + , + tablerIconProps, +); + +export const backgroundIcon = createIcon( + + + + + + + + , + tablerIconProps, +); + +export const strokeIcon = createIcon( + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx b/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx index 1aa187167..0efadcfc6 100644 --- a/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx +++ b/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) => { const appState = useUIAppState(); - const showIconOnly = appState.width < 830; + const showIconOnly = + isMobileOrTablet() || appState.width < MQ_MIN_WIDTH_DESKTOP; return (