mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
feat(editor): autoshape text + line improvements (#11752)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user