fix: improve line creation ux on touch screens (#9740)

* fix: awkward point adding and removing on touch device

* feat: move finalize to next to last point

* feat: on touch screen, click would create a default line/arrow

* fix: make default adaptive to zoom

* fix: increase padding to avoid cutoffs

* refactor: simplify

* fix: only use bigger padding when needed

* center arrow horizontally on pointer

* increase min drag distance before we start 2-point-arrow-drag-creating

* do not render 0-width arrow while creating

* dead code

* fix tests

* fix: remove redundant code

* do not enter line editor on creation

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di 2025-07-23 18:49:56 +10:00 committed by GitHub
parent 8492b144b0
commit e5e07260c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 113 additions and 102 deletions

View File

@ -36,6 +36,7 @@ export const APP_NAME = "Excalidraw";
// (happens a lot with fast clicks with the text tool) // (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
export const DRAGGING_THRESHOLD = 10; // px export const DRAGGING_THRESHOLD = 10; // px
export const MINIMUM_ARROW_SIZE = 20; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1; export const ELEMENT_TRANSLATE_AMOUNT = 1;

View File

@ -106,6 +106,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
return element.strokeWidth * 12; return element.strokeWidth * 12;
case "text": case "text":
return element.fontSize / 2; return element.fontSize / 2;
case "arrow":
if (element.endArrowhead || element.endArrowhead) {
return 40;
}
return 20;
default: default:
return 20; return 20;
} }

View File

@ -154,11 +154,7 @@ export const actionFinalize = register({
if (element) { if (element) {
// pen and mouse have hover // pen and mouse have hover
if ( if (appState.multiElement && element.type !== "freedraw") {
appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points, lastCommittedPoint } = element; const { points, lastCommittedPoint } = element;
if ( if (
!lastCommittedPoint || !lastCommittedPoint ||

View File

@ -505,15 +505,3 @@ export const ExitZenModeAction = ({
{t("buttons.exitZenMode")} {t("buttons.exitZenMode")}
</button> </button>
); );
export const FinalizeAction = ({
renderAction,
className,
}: {
renderAction: ActionManager["renderAction"];
className?: string;
}) => (
<div className={`finalize-button ${className}`}>
{renderAction("finalize", { size: "small" })}
</div>
);

View File

@ -100,6 +100,7 @@ import {
randomInteger, randomInteger,
CLASSES, CLASSES,
Emitter, Emitter,
MINIMUM_ARROW_SIZE,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -8162,7 +8163,9 @@ class App extends React.Component<AppProps, AppState> {
pointDistance( pointDistance(
pointFrom(pointerCoords.x, pointerCoords.y), pointFrom(pointerCoords.x, pointerCoords.y),
pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
) < DRAGGING_THRESHOLD ) *
this.state.zoom.value <
MINIMUM_ARROW_SIZE
) { ) {
return; return;
} }
@ -9113,25 +9116,54 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
); );
if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) { const dragDistance =
this.scene.mutateElement( pointDistance(
newElement, pointFrom(pointerCoords.x, pointerCoords.y),
{ pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
points: [ ) * this.state.zoom.value;
...newElement.points,
pointFrom<LocalPoint>(
pointerCoords.x - newElement.x,
pointerCoords.y - newElement.y,
),
],
},
{ informMutation: false, isDragging: false },
);
this.setState({ if (
multiElement: newElement, (!pointerDownState.drag.hasOccurred ||
newElement, dragDistance < MINIMUM_ARROW_SIZE) &&
}); newElement &&
!multiElement
) {
if (this.device.isTouchScreen) {
const FIXED_DELTA_X = Math.min(
(this.state.width * 0.7) / this.state.zoom.value,
100,
);
this.scene.mutateElement(
newElement,
{
x: newElement.x - FIXED_DELTA_X / 2,
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(FIXED_DELTA_X, 0),
],
},
{ informMutation: false, isDragging: false },
);
this.actionManager.executeAction(actionFinalize);
} else {
const dx = pointerCoords.x - newElement.x;
const dy = pointerCoords.y - newElement.y;
this.scene.mutateElement(
newElement,
{
points: [...newElement.points, pointFrom<LocalPoint>(dx, dy)],
},
{ informMutation: false, isDragging: false },
);
this.setState({
multiElement: newElement,
newElement,
});
}
} else if (pointerDownState.drag.hasOccurred && !multiElement) { } else if (pointerDownState.drag.hasOccurred && !multiElement) {
if ( if (
isBindingEnabled(this.state) && isBindingEnabled(this.state) &&

View File

@ -2,13 +2,7 @@ import clsx from "clsx";
import { actionShortcuts } from "../../actions"; import { actionShortcuts } from "../../actions";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions";
ExitZenModeAction,
FinalizeAction,
UndoRedoActions,
ZoomActions,
} from "../Actions";
import { useDevice } from "../App";
import { HelpButton } from "../HelpButton"; import { HelpButton } from "../HelpButton";
import { Section } from "../Section"; import { Section } from "../Section";
import Stack from "../Stack"; import Stack from "../Stack";
@ -29,10 +23,6 @@ const Footer = ({
}) => { }) => {
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels(); const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
const device = useDevice();
const showFinalize =
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
return ( return (
<footer <footer
role="contentinfo" role="contentinfo"
@ -60,15 +50,6 @@ const Footer = ({
})} })}
/> />
)} )}
{showFinalize && (
<FinalizeAction
renderAction={actionManager.renderAction}
className={clsx("zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
/>
)}
</Section> </Section>
</Stack.Col> </Stack.Col>
</div> </div>

View File

@ -1,6 +1,6 @@
import { throttleRAF } from "@excalidraw/common"; import { throttleRAF } from "@excalidraw/common";
import { renderElement } from "@excalidraw/element"; import { isInvisiblySmallElement, renderElement } from "@excalidraw/element";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers"; import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
@ -34,6 +34,14 @@ const _renderNewElementScene = ({
context.scale(appState.zoom.value, appState.zoom.value); context.scale(appState.zoom.value, appState.zoom.value);
if (newElement && newElement.type !== "selection") { if (newElement && newElement.type !== "selection") {
// e.g. when creating arrows and we're still below the arrow drag distance
// threshold
// (for now we skip render only with elements while we're creating to be
// safe)
if (isInvisiblySmallElement(newElement)) {
return;
}
renderElement( renderElement(
newElement, newElement,
elementsMap, elementsMap,

View File

@ -8190,7 +8190,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] undo st
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@ -8203,7 +8203,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] undo st
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@ -8369,7 +8369,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] undo stac
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@ -8382,7 +8382,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] undo stac
"strokeWidth": 2, "strokeWidth": 2,
"type": "diamond", "type": "diamond",
"version": 3, "version": 3,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@ -8548,7 +8548,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] undo stac
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@ -8561,7 +8561,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] undo stac
"strokeWidth": 2, "strokeWidth": 2,
"type": "ellipse", "type": "ellipse",
"version": 3, "version": 3,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@ -8758,7 +8758,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@ -8771,8 +8771,8 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
0, 0,
], ],
[ [
10, 30,
10, 30,
], ],
], ],
"roughness": 1, "roughness": 1,
@ -8786,7 +8786,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 4, "version": 4,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@ -8982,7 +8982,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@ -8995,8 +8995,8 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
0, 0,
], ],
[ [
10, 30,
10, 30,
], ],
], ],
"polygon": false, "polygon": false,
@ -9009,7 +9009,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
"strokeWidth": 2, "strokeWidth": 2,
"type": "line", "type": "line",
"version": 4, "version": 4,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@ -9167,12 +9167,12 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": [ "lastCommittedPoint": [
10, 30,
10, 30,
], ],
"link": null, "link": null,
"locked": false, "locked": false,
@ -9183,12 +9183,12 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
0, 0,
], ],
[ [
10, 30,
10, 30,
], ],
[ [
10, 30,
10, 30,
], ],
], ],
"pressures": [ "pressures": [
@ -9204,7 +9204,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
"strokeWidth": 2, "strokeWidth": 2,
"type": "freedraw", "type": "freedraw",
"version": 4, "version": 4,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@ -9401,7 +9401,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@ -9414,8 +9414,8 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
0, 0,
], ],
[ [
10, 30,
10, 30,
], ],
], ],
"roughness": 1, "roughness": 1,
@ -9429,7 +9429,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 4, "version": 4,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@ -9595,7 +9595,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] undo stac
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@ -9608,7 +9608,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] undo stac
"strokeWidth": 2, "strokeWidth": 2,
"type": "diamond", "type": "diamond",
"version": 3, "version": 3,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@ -9804,7 +9804,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@ -9817,8 +9817,8 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
0, 0,
], ],
[ [
10, 30,
10, 30,
], ],
], ],
"polygon": false, "polygon": false,
@ -9831,7 +9831,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
"strokeWidth": 2, "strokeWidth": 2,
"type": "line", "type": "line",
"version": 4, "version": 4,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@ -9997,7 +9997,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] undo stac
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@ -10010,7 +10010,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] undo stac
"strokeWidth": 2, "strokeWidth": 2,
"type": "ellipse", "type": "ellipse",
"version": 3, "version": 3,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@ -10168,12 +10168,12 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": [ "lastCommittedPoint": [
10, 30,
10, 30,
], ],
"link": null, "link": null,
"locked": false, "locked": false,
@ -10184,12 +10184,12 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
0, 0,
], ],
[ [
10, 30,
10, 30,
], ],
[ [
10, 30,
10, 30,
], ],
], ],
"pressures": [ "pressures": [
@ -10205,7 +10205,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
"strokeWidth": 2, "strokeWidth": 2,
"type": "freedraw", "type": "freedraw",
"version": 4, "version": 4,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@ -10371,7 +10371,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] undo st
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 30,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@ -10384,7 +10384,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] undo st
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },

View File

@ -150,7 +150,7 @@ describe("regression tests", () => {
expect(h.state.activeTool.type).toBe(shape); expect(h.state.activeTool.type).toBe(shape);
mouse.down(10, 10); mouse.down(10, 10);
mouse.up(10, 10); mouse.up(30, 30);
if (shouldSelect) { if (shouldSelect) {
expect(API.getSelectedElement().type).toBe(shape); expect(API.getSelectedElement().type).toBe(shape);