From 2a82821ec5970691199e1ffc6a49ac31f311ab59 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:59:10 +0200 Subject: [PATCH] fix(editor): tweak freedraw settings and tablet UI/UX (#11551) * fix(editor): align constant freedraw stroke width more with generic shapes * reduce streamline for non-mouse * do not change `currentItemStrokeVariability` unintentionally * render penMode button under compact styles panel on tablets * show stroke variability as standalone button in compact actions menu * improve toolbar clicking UX with pen * change streamline defaults * change to `variable` stroke if toggling penMode for the first time --- packages/common/src/constants.ts | 2 +- packages/element/src/shape.ts | 8 +-- packages/excalidraw/actions/actionCanvas.tsx | 3 - .../excalidraw/actions/actionProperties.tsx | 20 +++++++ packages/excalidraw/components/Actions.tsx | 55 ++++++++++++++----- packages/excalidraw/components/App.tsx | 6 +- packages/excalidraw/components/LayerUI.scss | 18 ++++++ packages/excalidraw/components/LayerUI.tsx | 40 ++++++++++---- 8 files changed, 116 insertions(+), 36 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index d13006f446..d4e4bb6548 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -552,4 +552,4 @@ export const MOBILE_ACTION_BUTTON_BG = { } as const; export const DEFAULT_STROKE_STREAMLINE = 0.5; -export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.3; +export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.2; diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts index a0e5f42c69..337437a1c3 100644 --- a/packages/element/src/shape.ts +++ b/packages/element/src/shape.ts @@ -26,7 +26,6 @@ import { LINE_POLYGON_POINT_MERGE_DISTANCE, applyDarkModeFilter, DEFAULT_STROKE_STREAMLINE, - DEFAULT_STROKE_STREAMLINE_PRECISE, } from "@excalidraw/common"; import { RoughGenerator } from "roughjs/bin/generator"; @@ -1186,20 +1185,15 @@ const VARIABLE_WIDTH_FREEDRAW = { SIZE_FACTOR: 4.25, THINNING: 0.6, SMOOTHING: 0.5, - STREAMLINE: DEFAULT_STROKE_STREAMLINE, } as const; const CONSTANT_WIDTH_FREEDRAW = { /** Stroke size relative to `strokeWidth` for uniform (laser) strokes. */ SIZE_FACTOR: 1.4, - STREAMLINE: DEFAULT_STROKE_STREAMLINE_PRECISE, } as const; const getFreedrawStreamline = (element: ExcalidrawFreeDrawElement) => - element.strokeOptions?.streamline ?? - (element.strokeOptions?.variability === "constant" - ? CONSTANT_WIDTH_FREEDRAW.STREAMLINE - : VARIABLE_WIDTH_FREEDRAW.STREAMLINE); + element.strokeOptions?.streamline ?? DEFAULT_STROKE_STREAMLINE; /** * Pressure-sensitive (variable width) freedraw outline, rendered with diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index d1bce4aee6..9c8fea322f 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -112,9 +112,6 @@ export const actionClearCanvas = register({ theme: appState.theme, penMode: appState.penMode, penDetected: appState.penDetected, - currentItemStrokeVariability: appState.penDetected - ? "variable" - : "constant", exportBackground: appState.exportBackground, exportEmbedScene: appState.exportEmbedScene, gridSize: appState.gridSize, diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 09e7bb7369..c8341dfdfa 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -87,6 +87,7 @@ import type { CaptureUpdateActionType } from "@excalidraw/element"; import { trackEvent } from "../analytics"; import { RadioSelection } from "../components/RadioSelection"; +import { ToolButton } from "../components/ToolButton"; import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { FontPicker } from "../components/FontPicker/FontPicker"; import { IconPicker } from "../components/IconPicker"; @@ -718,6 +719,25 @@ export const actionChangeFreedrawMode = register({ hasSelection ? null : appState.currentItemStrokeVariability, ) ?? appState.currentItemStrokeVariability; + // in the compact UI the pressure setting is rendered as a single button + // that cycles between the two variability modes on click + if (data?.cycle) { + const isVariable = strokeVariability === "variable"; + return ( + updateData(isVariable ? "constant" : "variable")} + /> + ); + } + return (
{t("labels.pressure")} diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 8e3f86e6c4..20ae6c8fa9 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -395,11 +395,17 @@ const CombinedShapeProperties = ({ hasStrokeWidth(element.type), )) && renderAction("changeStrokeWidth")} - {(hasFreedrawMode(appState.activeTool.type) || - targetElements.some((element) => - hasFreedrawMode(element.type), - )) && - renderAction("changeFreedrawMode")} + { + /* in compact UI the freedraw pressure setting is rendered as a + standalone cycle button in the compact actions list; we render + it in the combined properties popup as well for clarity + */ + (hasFreedrawMode(appState.activeTool.type) || + targetElements.some((element) => + hasFreedrawMode(element.type), + )) && + renderAction("changeFreedrawMode") + } {(hasStrokeStyle(appState.activeTool.type) || targetElements.some((element) => hasStrokeStyle(element.type), @@ -832,6 +838,14 @@ export const CompactShapeActions = ({ )} + {/* Freedraw pressure: standalone button cycling the variability mode */} + {(hasFreedrawMode(appState.activeTool.type) || + targetElements.some((element) => hasFreedrawMode(element.type))) && ( +
+ {renderAction("changeFreedrawMode", { cycle: true })} +
+ )} + { + // Detect the pen here (pointerType is reliable on pointer-down) + // but DON'T enable pen mode yet: calling setState mid-gesture + // re-renders the controlled radio and, on iOS/iPadOS, aborts + // the ensuing click so the tool isn't selected on the first pen + // tap. Defer it until the tap's `change` has committed (below). if (!app.state.penDetected && pointerType === "pen") { - app.togglePenMode(true); + pendingPenDetectionRef.current = true; } if (value === "selection") { @@ -1170,16 +1194,21 @@ export const ShapesSwitcher = ({ } } }} - onChange={({ pointerType }) => { + onChange={() => { if (app.state.activeTool.type !== value) { trackEvent("toolbar", value, "ui"); } - if (value === "image") { - app.setActiveTool({ - type: value, - }); - } else { - app.setActiveTool({ type: value }); + app.setActiveTool({ type: value }); + + // Apply the pen detection captured on pointer-down now that the + // tool is selected. rAF keeps the resulting re-render out of the + // `change` event itself. We rely on the pointer-down detection + // rather than this handler's pointerType because the latter is + // unreliable on iOS (its backing ref is cleared before the + // delayed click fires). + if (pendingPenDetectionRef.current) { + pendingPenDetectionRef.current = false; + requestAnimationFrame(() => app.togglePenMode(true)); } }} /> diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 38b88a5806..d19019ccae 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -4308,7 +4308,9 @@ class App extends React.Component { return { penMode: force ?? !prevState.penMode, penDetected: true, - currentItemStrokeVariability: "variable", + currentItemStrokeVariability: !prevState.penDetected + ? "variable" + : prevState.currentItemStrokeVariability, }; }); }; @@ -9015,7 +9017,7 @@ class App extends React.Component { strokeOptions: { variability: strokeVariability, streamline: - strokeVariability === "constant" && event.pointerType !== "mouse" + event.pointerType !== "mouse" ? DEFAULT_STROKE_STREAMLINE_PRECISE : DEFAULT_STROKE_STREAMLINE, }, diff --git a/packages/excalidraw/components/LayerUI.scss b/packages/excalidraw/components/LayerUI.scss index 5c76a1ee28..ea1ae68391 100644 --- a/packages/excalidraw/components/LayerUI.scss +++ b/packages/excalidraw/components/LayerUI.scss @@ -120,6 +120,24 @@ } } + // on tablet, the pen mode button is rendered as a separate floating button + // below the compact actions menu (see LayerUI.tsx) + .App-menu_top__left > .ToolIcon__penMode { + justify-self: center; + + .ToolIcon__icon { + width: var(--lg-button-size); + height: var(--lg-button-size); + background-color: var(--island-bg-color); + box-shadow: var(--shadow-island); + } + + // no shadow while pen mode is active (the active fill is enough) + .ToolIcon_type_checkbox:checked + .ToolIcon__icon { + box-shadow: none; + } + } + .disable-view-mode { display: flex; justify-content: center; diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index bbf1f40f9c..0b61910007 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -235,8 +235,6 @@ const LayerUI = ({ ); const renderSelectedShapeActions = () => { - const isCompactMode = isCompactStylesPanel; - return (
- {isCompactMode ? ( + {isCompactStylesPanel ? ( {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} + {/* in compact UI the pen mode button lives outside the toolbar, as + a separate floating button below the compact actions menu + (same as we render it on mobile); shown alongside the compact + actions island, i.e. when a drawing tool or elements are + selected */} + {isCompactStylesPanel && + !appState.viewModeEnabled && + shouldRenderSelectedShapeActions && ( + onPenModeToggle(null)} + title={t("toolBar.penMode")} + isMobile + penDetected={appState.penDetected} + /> + )} {!appState.viewModeEnabled && appState.openDialog?.name !== "elementLinkSelector" && ( @@ -343,13 +358,18 @@ const LayerUI = ({ /> {heading} - onPenModeToggle(null)} - title={t("toolBar.penMode")} - penDetected={appState.penDetected} - /> + {/* in compact UI the pen mode button is rendered + as a separate floating button below the compact + actions menu */} + {!isCompactStylesPanel && ( + onPenModeToggle(null)} + title={t("toolBar.penMode")} + penDetected={appState.penDetected} + /> + )}