feat(editor): autoshape text + line improvements (#11752)

This commit is contained in:
David Luzar
2026-07-24 16:10:00 +02:00
committed by GitHub
parent f179f7ffd2
commit b2e81e38a6
3 changed files with 245 additions and 28 deletions
+15 -16
View File
@@ -46,7 +46,11 @@ import type {
} from "@excalidraw/element/types";
import { getFrameLikeElements } from "./frame";
import { isLinearElement, isUsingAdaptiveRadius } from "./typeChecks";
import {
isLinearElement,
isLineElement,
isUsingAdaptiveRadius,
} from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor";
declare global {
@@ -590,20 +594,11 @@ export const convertToShape = (
case "arrow": {
const [arrowX, arrowY] = recognizedShape.points[0];
let globalEndX: number;
let globalEndY: number;
if (previousElement && previousElement.type === "line") {
const prevEndLocal = previousElement.points[1];
// No need for rotation handling
globalEndX = prevEndLocal[0] + previousElement.x;
globalEndY = prevEndLocal[1] + previousElement.y;
} else {
[globalEndX, globalEndY] = getArrowEndpoint(
recognizedShape.points,
recognizedShape.boundingBox,
recognizedShape.points[0],
);
}
const [globalEndX, globalEndY] = getArrowEndpoint(
recognizedShape.points,
recognizedShape.boundingBox,
recognizedShape.points[0],
);
const endPoint: LocalPoint = pointFrom<LocalPoint>(
globalEndX - arrowX,
globalEndY - arrowY,
@@ -787,7 +782,11 @@ export const convertToShapeHandlePointerMoveFromPointerDown = (
app.scene.getNonDeletedFramesLikes(),
) as ExcalidrawNonSelectionElement | undefined;
if (shapePreview) {
// Lines get no live preview — nearly every stroke reads as a line at
// some early point, so previewing them is mostly flicker. Releasing
// still commits the line: finalize() re-recognizes from the trail when
// no preview exists.
if (shapePreview && !isLineElement(shapePreview)) {
const prevPreview = app.state.newElement;
const nextPreview = {
...shapePreview,
+30 -12
View File
@@ -6212,9 +6212,14 @@ class App extends React.Component<AppProps, AppState> {
// keyboard-submit keeps focus on the edited object. For bound text, keep
// the container selected even if the text becomes empty and is deleted.
const elementIdToSelect = viaKeyboard
? element.containerId || (!isDeleted ? element.id : null)
: null;
// The autoshape tool stays active through the editing session and never
// selects anything — don't fight the finalize action's selection reset.
const elementIdToSelect =
viaKeyboard &&
!this.isToolLocked() &&
this.state.activeTool.type !== "autoshape"
? element.containerId || (!isDeleted ? element.id : null)
: null;
if (elementIdToSelect) {
// needed to ensure state is updated before "finalize" action
@@ -6251,7 +6256,9 @@ class App extends React.Component<AppProps, AppState> {
});
});
if (this.isToolLocked()) {
// tools that survive the submit (locked, or autoshape's
// double-click-to-type flow) need their cursor back
if (this.isToolLocked() || this.state.activeTool.type === "autoshape") {
this.cursor.applyForTool();
}
@@ -6925,8 +6932,14 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.multiElement) {
return;
}
// we should only be able to double click when mode is selection
if (this.state.activeTool.type !== this.state.preferredSelectionTool.type) {
// double click only creates/edits text in selection mode, or with the
// autoshape tool (double-click-to-type without leaving the tool; all the
// selection-dependent branches below are inert there since autoshape
// never selects anything)
if (
this.state.activeTool.type !== this.state.preferredSelectionTool.type &&
this.state.activeTool.type !== "autoshape"
) {
return;
}
@@ -7082,7 +7095,7 @@ class App extends React.Component<AppProps, AppState> {
}
this.cursor.reset();
if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) {
if (!this.state.viewModeEnabled) {
const hitElement = this.getElementAtPosition(sceneX, sceneY);
if (isIframeLikeElement(hitElement)) {
@@ -7095,13 +7108,18 @@ class App extends React.Component<AppProps, AppState> {
// shouldn't edit/create text when inside line editor (often false positive)
if (!this.state.selectedLinearElement?.isEditing) {
const container = this.getTextBindableContainerAtPosition(
sceneX,
sceneY,
);
const container =
// skip binding to container on dblclick when holding ctrl
!event[KEYS.CTRL_OR_CMD] &&
this.getTextBindableContainerAtPosition(sceneX, sceneY);
if (container) {
if (
// with autoshape, any hit inside a bindable shape means "type in
// this shape" — unlike selection mode, a transparent unfilled
// container binds even when its stroke wasn't hit (Alt keeps the
// free-text-at-point escape hatch)
this.state.activeTool.type === "autoshape" ||
hasBoundTextElement(container) ||
!isTransparent(container.backgroundColor) ||
hitElementItself({
@@ -7126,7 +7144,7 @@ class App extends React.Component<AppProps, AppState> {
sceneX,
sceneY,
insertAtParentCenter: !event.altKey,
container,
container: container || null,
});
}
}
@@ -2,6 +2,8 @@ import { KEYS } from "@excalidraw/common";
import { getTargetElements, isArrowElement } from "@excalidraw/element";
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
import { Excalidraw } from "../index";
import { actionFinalize } from "../actions";
@@ -9,6 +11,7 @@ import { getShapeActionPredicates } from "../components/shapeActionPredicates";
import { API } from "./helpers/api";
import { Keyboard, Pointer } from "./helpers/ui";
import { getTextEditor, updateTextEditor } from "./queries/dom";
import { GlobalTestState, act, fireEvent, render, waitFor } from "./test-utils";
const { h } = window;
@@ -404,6 +407,23 @@ describe("autoshape styles panel & selection (preview path)", () => {
expect(h.state.selectedElementIds).toEqual({});
});
it("shows no preview for lines (noise), yet still commits them on release", () => {
const linePoints = seg([100, 100], [400, 400], 30);
mouse.downAt(100, 100);
act(() => {
for (const [x, y] of linePoints.slice(1)) {
h.app.drawShape.trail.addPointToPath(x, y);
h.app.drawShape.handlePointerMove({ x, y });
}
});
// the stroke reads as a line mid-gesture — no preview element
expect(h.state.newElement).toBeNull();
mouse.upAt(400, 400);
expect(h.elements.map((element) => element.type)).toEqual(["line"]);
});
it("upgrades a line between two bindable shapes identically with a live preview", () => {
const start = API.createElement({
type: "rectangle",
@@ -568,6 +588,186 @@ describe("autoshape finalize funnel", () => {
});
});
describe("autoshape double-click to type", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
act(() => {
h.app.setActiveTool({ type: "autoshape" });
});
});
it("creates free text on blank canvas and stays on the tool after submit", async () => {
mouse.doubleClickAt(200, 200);
expect(h.state.editingTextElement).not.toBeNull();
expect(h.state.activeTool.type).toBe("autoshape");
const editor = await getTextEditor();
updateTextEditor(editor, "hello");
Keyboard.exitTextEditor(editor);
const text = h.elements[0] as ExcalidrawTextElement;
expect(h.elements).toHaveLength(1);
expect(text.type).toBe("text");
expect(text.containerId).toBeNull();
expect(h.state.editingTextElement).toBeNull();
expect(h.state.activeTool.type).toBe("autoshape");
expect(API.getSelectedElements()).toHaveLength(0);
});
it("binds text to a transparent container even off-center (unlike selection mode)", async () => {
const rectangle = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
backgroundColor: "transparent",
});
API.setElements([rectangle]);
// interior hit far from both the stroke and the center
mouse.doubleClickAt(140, 120);
const editor = await getTextEditor();
expect(h.state.editingTextElement?.containerId).toBe(rectangle.id);
updateTextEditor(editor, "label");
Keyboard.exitTextEditor(editor);
const text = h.elements.find(
(element) => element.type === "text",
) as ExcalidrawTextElement;
expect(text.containerId).toBe(rectangle.id);
expect(h.elements[0].boundElements).toEqual([
{ type: "text", id: text.id },
]);
expect(h.state.activeTool.type).toBe("autoshape");
});
it("edits the existing bound text instead of creating another", async () => {
const rectangle = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
});
// positioned at the container center, as bound text always is
const boundText = API.createElement({
type: "text",
text: "ola",
x: 190,
y: 145,
width: 20,
height: 10,
containerId: rectangle.id,
});
API.updateElement(rectangle, {
boundElements: [{ type: "text", id: boundText.id }],
});
API.setElements([rectangle, boundText]);
mouse.doubleClickAt(150, 130);
await getTextEditor();
expect(h.state.editingTextElement?.id).toBe(boundText.id);
expect(h.elements).toHaveLength(2);
});
it("creates free text at the pointer on Alt+double-click inside a container", async () => {
const rectangle = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
});
API.setElements([rectangle]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.doubleClickAt(140, 120);
});
await getTextEditor();
expect(h.state.editingTextElement?.containerId ?? null).toBeNull();
});
it("deletes the text and unbinds on empty submit", async () => {
const rectangle = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
});
API.setElements([rectangle]);
mouse.doubleClickAt(150, 130);
const editor = await getTextEditor();
Keyboard.exitTextEditor(editor);
expect(h.elements.filter((element) => !element.isDeleted)).toHaveLength(1);
expect(h.elements[0].boundElements ?? []).toHaveLength(0);
expect(h.state.activeTool.type).toBe("autoshape");
});
it("undo after submit removes only the text", async () => {
sketch(rectanglePath(100, 100, 200, 120));
expect(h.elements).toHaveLength(1);
mouse.doubleClickAt(400, 400);
const editor = await getTextEditor();
updateTextEditor(editor, "note");
Keyboard.exitTextEditor(editor);
expect(h.elements.filter((element) => !element.isDeleted)).toHaveLength(2);
Keyboard.undo();
expect(
h.elements.filter((element) => !element.isDeleted).map((el) => el.type),
).toEqual(["rectangle"]);
expect(h.state.activeTool.type).toBe("autoshape");
});
it("allows to start a sketch on pointerdown while the editor is open", async () => {
mouse.doubleClickAt(200, 200);
await getTextEditor();
const editor = await getTextEditor();
updateTextEditor(editor, "note");
mouse.downAt(400, 400);
expect(h.app.drawShape.hasPendingGesture()).toBe(true);
mouse.upAt(400, 500);
// no shape got inserted by the click-away
expect(
h.elements.filter(
(element) => element.type === "text" && !element.isDeleted,
),
).toHaveLength(1);
});
it("double-tap (touch) creates text through the same path", async () => {
act(() => {
// @ts-ignore private method — the touch double-tap detector funnels here
h.app.handleCanvasDoubleClick({
clientX: 200,
clientY: 200,
type: "touch",
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
});
});
expect(h.state.editingTextElement).not.toBeNull();
expect(h.state.activeTool.type).toBe("autoshape");
});
});
describe("autoshape tool activation", () => {
beforeEach(async () => {
localStorage.clear();