feat(editor): put caret at pointer coords when clicking on selected text element (#10970)

This commit is contained in:
David Luzar
2026-03-18 19:14:44 +01:00
committed by GitHub
parent 2b0e4c9623
commit e8b4620a96
7 changed files with 679 additions and 63 deletions
+134 -18
View File
@@ -684,6 +684,11 @@ class App extends React.Component<AppProps, AppState> {
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null;
// TODO this is a hack and we should ideally unify touch and pointer events
// and implement our own double click handling end-to-end (currently we're
// using a mix of native browser for click events and manual for touch -
// and browser doubleClick sucks to begin with)
lastPointerUpIsDoubleClick: boolean = false;
lastPointerMoveEvent: PointerEvent | null = null;
/** current frame pointer cords */
lastPointerMoveCoords: { x: number; y: number } | null = null;
@@ -1438,6 +1443,21 @@ class App extends React.Component<AppProps, AppState> {
return true;
}
private isDoubleClick = (
lastPointerEvent:
| PointerEvent
| React.PointerEvent<HTMLElement>
| undefined
| null,
currentPointerEvent: PointerEvent | React.PointerEvent<HTMLElement>,
) => {
return (
lastPointerEvent != null &&
currentPointerEvent.timeStamp - lastPointerEvent.timeStamp <=
TAP_TWICE_TIMEOUT
);
};
private isIframeLikeElementCenter(
el: ExcalidrawIframeLikeElement | null,
event: React.PointerEvent<HTMLElement> | PointerEvent,
@@ -5617,8 +5637,14 @@ class App extends React.Component<AppProps, AppState> {
element: ExcalidrawTextElement,
{
isExistingElement = false,
initialCaretSceneCoords = null,
}: {
isExistingElement?: boolean;
/**
* supply null if no caret positioning is desired, and instead
* text should be auto-selected
*/
initialCaretSceneCoords?: { x: number; y: number } | null;
},
) {
const elementsMap = this.scene.getElementsMapIncludingDeleted();
@@ -5721,6 +5747,7 @@ class App extends React.Component<AppProps, AppState> {
element,
excalidrawContainer: this.excalidrawContainerRef.current,
app: this,
initialCaretSceneCoords,
// when text is selected, it's hard (at least on iOS) to re-position the
// caret (i.e. deselect). There's not much use for always selecting
// the text on edit anyway (and users can select-all from contextmenu
@@ -5744,6 +5771,68 @@ class App extends React.Component<AppProps, AppState> {
});
}
private getSelectedTextElement(
container?: ExcalidrawTextContainer | null,
): NonDeleted<ExcalidrawTextElement> | null {
const selectedElements = this.scene.getSelectedElements(this.state);
if (selectedElements.length !== 1) {
return null;
}
const selectedElement = selectedElements[0]!;
if (isTextElement(selectedElement)) {
return selectedElement;
}
if (!container) {
return null;
}
return getBoundTextElement(
selectedElement,
this.scene.getNonDeletedElementsMap(),
);
}
private getSelectedTextEditingContainerAtPosition(
hitElement: NonDeletedExcalidrawElement | null,
sceneCoords: { x: number; y: number },
): ExcalidrawTextContainer | null | undefined {
const selectedElements = this.scene.getSelectedElements(this.state);
if (
selectedElements.length !== 1 ||
!hitElement ||
hitElement.id !== selectedElements[0]!.id
) {
return null;
}
const selectedElement = selectedElements[0]!;
if (isTextElement(selectedElement)) {
return null;
}
if (!isValidTextContainer(selectedElement)) {
return undefined;
}
const textElement = this.getSelectedTextElement(selectedElement);
const hitTextElement = this.getTextElementAtPosition(
sceneCoords.x,
sceneCoords.y,
);
if (!textElement || hitTextElement?.id !== textElement.id) {
return undefined;
}
return selectedElement;
}
private getTextElementAtPosition(
x: number,
y: number,
@@ -5969,6 +6058,7 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter = true,
container,
autoEdit = true,
initialCaretSceneCoords,
}: {
/** X position to insert text at */
sceneX: number;
@@ -5978,6 +6068,7 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter?: boolean;
container?: ExcalidrawTextContainer | null;
autoEdit?: boolean;
initialCaretSceneCoords?: { x: number; y: number };
}) => {
let shouldBindToContainer = false;
@@ -5998,24 +6089,9 @@ class App extends React.Component<AppProps, AppState> {
shouldBindToContainer = true;
}
}
let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null;
const selectedElements = this.scene.getSelectedElements(this.state);
if (selectedElements.length === 1) {
if (isTextElement(selectedElements[0])) {
existingTextElement = selectedElements[0];
} else if (container) {
existingTextElement = getBoundTextElement(
selectedElements[0],
this.scene.getNonDeletedElementsMap(),
);
} else {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
}
} else {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
}
const existingTextElement =
this.getSelectedTextElement(container) ||
this.getTextElementAtPosition(sceneX, sceneY);
const fontFamily =
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
@@ -6116,6 +6192,9 @@ class App extends React.Component<AppProps, AppState> {
if (autoEdit || existingTextElement || container) {
this.handleTextWysiwyg(element, {
isExistingElement: !!existingTextElement,
initialCaretSceneCoords: existingTextElement
? initialCaretSceneCoords
: null,
});
} else {
this.setState({
@@ -6144,6 +6223,9 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasDoubleClick = (
event: React.MouseEvent<HTMLCanvasElement>,
) => {
if (this.state.editingTextElement) {
return;
}
// case: double-clicking with arrow/line tool selected would both create
// text and enter multiElement mode
if (this.state.multiElement) {
@@ -7680,6 +7762,10 @@ class App extends React.Component<AppProps, AppState> {
}
this.removePointer(event);
this.lastPointerUpIsDoubleClick = this.isDoubleClick(
this.lastPointerUpEvent,
event,
);
this.lastPointerUpEvent = event;
if (!event.ctrlKey) {
@@ -8516,6 +8602,7 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter: !event.altKey,
container,
autoEdit: false,
initialCaretSceneCoords: { x: sceneX, y: sceneY },
});
resetCursor(this.interactiveCanvas);
@@ -11001,6 +11088,35 @@ class App extends React.Component<AppProps, AppState> {
return;
}
const selectedTextEditingContainer =
this.getSelectedTextEditingContainerAtPosition(hitElement, sceneCoords);
if (
activeTool.type === this.state.preferredSelectionTool.type &&
!this.state.editingTextElement &&
!pointerDownState.drag.hasOccurred &&
!pointerDownState.hit.wasAddedToSelection &&
!childEvent.shiftKey &&
!childEvent[KEYS.CTRL_OR_CMD] &&
!childEvent.altKey &&
childEvent.pointerType !== "touch" &&
hitElement &&
((isTextElement(hitElement) &&
this.state.selectedElementIds[hitElement.id] &&
this.scene.getSelectedElements(this.state).length === 1) ||
selectedTextEditingContainer)
) {
this.startTextEditing({
sceneX: sceneCoords.x,
sceneY: sceneCoords.y,
container: selectedTextEditingContainer,
initialCaretSceneCoords: this.lastPointerUpIsDoubleClick
? undefined
: sceneCoords,
});
return;
}
if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) {
this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds(
@@ -361,12 +361,10 @@ describe("stats for a non-generic element", () => {
mouse.clickAt(20, 30);
const editor = await getTextEditor();
updateTextEditor(editor, "Hello!");
act(() => {
editor.blur();
});
Keyboard.exitTextEditor(editor);
const text = h.elements[0] as ExcalidrawTextElement;
mouse.clickOn(text);
API.setSelectedElements([text]);
elementStats = stats?.querySelector("#elementStats");