diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx
index cffb8e7f45..825f4a88ea 100644
--- a/packages/excalidraw/actions/actionProperties.tsx
+++ b/packages/excalidraw/actions/actionProperties.tsx
@@ -137,6 +137,11 @@ import {
isSomeElementSelected,
} from "../scene";
+import {
+ withCaretPositionPreservation,
+ restoreCaretPosition,
+} from "../hooks/useTextEditorFocus";
+
import { register } from "./register";
import type { AppClassProperties, AppState, Primitive } from "../types";
@@ -701,7 +706,7 @@ export const actionChangeFontSize = register({
perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, () => value, value);
},
- PanelComponent: ({ elements, appState, updateData, app }) => (
+ PanelComponent: ({ elements, appState, updateData, app, data }) => (
@@ -1105,14 +1117,19 @@ export const actionChangeFontFamily = register({
hoveredFontFamily={appState.currentHoveredFontFamily}
compactMode={data?.compactMode}
onSelect={(fontFamily) => {
- setBatchedData({
- openPopup: null,
- currentHoveredFontFamily: null,
- currentItemFontFamily: fontFamily,
- });
-
- // defensive clear so immediate close won't abuse the cached elements
- cachedElementsRef.current.clear();
+ withCaretPositionPreservation(
+ () => {
+ setBatchedData({
+ openPopup: null,
+ currentHoveredFontFamily: null,
+ currentItemFontFamily: fontFamily,
+ });
+ // defensive clear so immediate close won't abuse the cached elements
+ cachedElementsRef.current.clear();
+ },
+ !!data?.compactMode,
+ !!appState.editingTextElement,
+ );
}}
onHover={(fontFamily) => {
setBatchedData({
@@ -1172,7 +1189,7 @@ export const actionChangeFontFamily = register({
setBatchedData({});
} else {
// close: clear openPopup if we're still the active popup
- const data = {
+ const fontFamilyData = {
openPopup:
appState.openPopup === "fontFamily"
? null
@@ -1183,9 +1200,14 @@ export const actionChangeFontFamily = register({
} as ChangeFontFamilyData;
// apply immediately to avoid racing with other popovers opening
- updateData({ ...batchedData, ...data });
+ updateData({ ...batchedData, ...fontFamilyData });
setBatchedData({});
cachedElementsRef.current.clear();
+
+ // Refocus text editor when font picker closes if we were editing text
+ if (data?.compactMode && appState.editingTextElement) {
+ restoreCaretPosition(null); // Just refocus without saved position
+ }
}
}}
/>
@@ -1228,8 +1250,9 @@ export const actionChangeTextAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
- PanelComponent: ({ elements, appState, updateData, app }) => {
+ PanelComponent: ({ elements, appState, updateData, app, data }) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
+
return (
@@ -1320,7 +1350,7 @@ export const actionChangeVerticalAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
- PanelComponent: ({ elements, appState, updateData, app }) => {
+ PanelComponent: ({ elements, appState, updateData, app, data }) => {
return (
diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx
index aa3382d4d0..6596022e12 100644
--- a/packages/excalidraw/components/Actions.tsx
+++ b/packages/excalidraw/components/Actions.tsx
@@ -50,6 +50,8 @@ import {
import { getFormValue } from "../actions/actionProperties";
+import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
+
import { getToolbarTools } from "./shapes";
import "./Actions.scss";
@@ -85,6 +87,12 @@ import type {
} from "../types";
import type { ActionManager } from "../actions/manager";
+// Common CSS class combinations
+const PROPERTIES_CLASSES = clsx([
+ CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
+ "properties-content",
+]);
+
export const canChangeStrokeColor = (
appState: UIAppState,
targetElements: ExcalidrawElement[],
@@ -313,6 +321,8 @@ export const CompactShapeActions = ({
const targetElements = getTargetElements(elementsMap, appState);
const [strokePopoverOpen, setStrokePopoverOpen] = useState(false);
const [otherActionsPopoverOpen, setOtherActionsPopoverOpen] = useState(false);
+ const [preventTextAlignClose, setPreventTextAlignClose] = useState(false);
+ const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
const { container } = useExcalidrawContainer();
const isEditingTextOrNewElement = Boolean(
@@ -364,12 +374,11 @@ export const CompactShapeActions = ({
return (
{/* Stroke Color */}
- {canChangeStrokeColor(appState, targetElements) &&
- !appState.editingTextElement && (
-
- {renderAction("changeStrokeColor", { compactMode: true })}
-
- )}
+ {canChangeStrokeColor(appState, targetElements) && (
+
+ {renderAction("changeStrokeColor", { compactMode: true })}
+
+ )}
{/* Background Color */}
{canChangeBackgroundColor(appState, targetElements) && (
@@ -400,7 +409,7 @@ export const CompactShapeActions = ({
{strokePopoverOpen && (
setStrokePopoverOpen(false)}
@@ -474,7 +483,7 @@ export const CompactShapeActions = ({
)}
{!compactMode && }
- {}}>
+
{
- // suppress default to avoid double toggle
- if (e && e.preventDefault) {
- e.preventDefault();
- }
-
- if (isOpened) {
- onPopupChange(false);
- } else {
- onPopupChange(true);
- }
- }}
+ onToggle={() => {}} // No longer needed, Radix handles it
+ isOpened={isOpened}
/>
{isOpened && (
{
const { container } = useExcalidrawContainer();
- const { fonts } = useApp();
+ const app = useApp();
+ const { fonts } = app;
const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState("");
@@ -187,6 +188,42 @@ export const FontPickerList = React.memo(
onLeave,
]);
+ // Create a wrapped onSelect function that preserves caret position
+ const wrappedOnSelect = useCallback(
+ (fontFamily: FontFamilyValues) => {
+ // Save caret position before font selection if editing text
+ let savedSelection: { start: number; end: number } | null = null;
+ if (app.state.editingTextElement) {
+ const textEditor = document.querySelector(
+ ".excalidraw-wysiwyg",
+ ) as HTMLTextAreaElement;
+ if (textEditor) {
+ savedSelection = {
+ start: textEditor.selectionStart,
+ end: textEditor.selectionEnd,
+ };
+ }
+ }
+
+ onSelect(fontFamily);
+
+ // Restore caret position after font selection if editing text
+ if (app.state.editingTextElement && savedSelection) {
+ setTimeout(() => {
+ const textEditor = document.querySelector(
+ ".excalidraw-wysiwyg",
+ ) as HTMLTextAreaElement;
+ if (textEditor && savedSelection) {
+ textEditor.focus();
+ textEditor.selectionStart = savedSelection.start;
+ textEditor.selectionEnd = savedSelection.end;
+ }
+ }, 0);
+ }
+ },
+ [onSelect, app.state.editingTextElement],
+ );
+
const onKeyDown = useCallback>(
(event) => {
const handled = fontPickerKeyHandler({
@@ -194,7 +231,7 @@ export const FontPickerList = React.memo(
inputRef,
hoveredFont,
filteredFonts,
- onSelect,
+ onSelect: wrappedOnSelect,
onHover,
onClose,
});
@@ -204,7 +241,7 @@ export const FontPickerList = React.memo(
event.stopPropagation();
}
},
- [hoveredFont, filteredFonts, onSelect, onHover, onClose],
+ [hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
);
useEffect(() => {
@@ -240,7 +277,7 @@ export const FontPickerList = React.memo(
// allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => {
- onSelect(Number(e.currentTarget.value));
+ wrappedOnSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
@@ -282,10 +319,24 @@ export const FontPickerList = React.memo(
className="properties-content"
container={container}
style={{ width: "15rem" }}
- onClose={onClose}
+ onClose={() => {
+ onClose();
+
+ // Refocus text editor when font picker closes if we were editing text
+ if (app.state.editingTextElement) {
+ setTimeout(() => {
+ const textEditor = document.querySelector(
+ ".excalidraw-wysiwyg",
+ ) as HTMLTextAreaElement;
+ if (textEditor) {
+ textEditor.focus();
+ }
+ }, 0);
+ }
+ }}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
- preventAutoFocusOnTouch={true}
+ preventAutoFocusOnTouch={!!app.state.editingTextElement}
>
void;
+ onToggle: () => void;
+ isOpened?: boolean;
}
export const FontPickerTrigger = ({
selectedFontFamily,
- onTrigger,
+ onToggle,
+ isOpened = false,
}: FontPickerTriggerProps) => {
+ const appState = useExcalidrawAppState();
const isTriggerActive = useMemo(
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily],
@@ -25,16 +29,14 @@ export const FontPickerTrigger = ({
return (
- {/* Empty div as trigger so it's stretched 100% due to different button sizes */}
{
- e.preventDefault();
- onTrigger?.(e);
- }}
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
+ // Prevent default behavior that might dismiss keyboard on mobile when editing text
+ if (appState.editingTextElement) {
+ e.preventDefault();
+ }
}}
>
{
- e.preventDefault();
- e.stopPropagation();
- }}
+ active={isTriggerActive || isOpened}
+ onClick={() => {}} // Let Radix handle the toggle
style={{
border: "none",
}}
diff --git a/packages/excalidraw/hooks/useTextEditorFocus.ts b/packages/excalidraw/hooks/useTextEditorFocus.ts
new file mode 100644
index 0000000000..25c6147d4a
--- /dev/null
+++ b/packages/excalidraw/hooks/useTextEditorFocus.ts
@@ -0,0 +1,112 @@
+import { useState, useCallback } from "react";
+
+// Utility type for caret position
+export type CaretPosition = {
+ start: number;
+ end: number;
+};
+
+// Utility function to get text editor element
+const getTextEditor = (): HTMLTextAreaElement | null => {
+ return document.querySelector(".excalidraw-wysiwyg") as HTMLTextAreaElement;
+};
+
+// Utility functions for caret position management
+export const saveCaretPosition = (): CaretPosition | null => {
+ const textEditor = getTextEditor();
+ if (textEditor) {
+ return {
+ start: textEditor.selectionStart,
+ end: textEditor.selectionEnd,
+ };
+ }
+ return null;
+};
+
+export const restoreCaretPosition = (position: CaretPosition | null): void => {
+ setTimeout(() => {
+ const textEditor = getTextEditor();
+ if (textEditor) {
+ textEditor.focus();
+ if (position) {
+ textEditor.selectionStart = position.start;
+ textEditor.selectionEnd = position.end;
+ }
+ }
+ }, 0);
+};
+
+export const withCaretPositionPreservation = (
+ callback: () => void,
+ isCompactMode: boolean,
+ isEditingText: boolean,
+ onPreventClose?: () => void,
+): void => {
+ // Prevent popover from closing in compact mode
+ if (isCompactMode && onPreventClose) {
+ onPreventClose();
+ }
+
+ // Save caret position if editing text
+ const savedPosition =
+ isCompactMode && isEditingText ? saveCaretPosition() : null;
+
+ // Execute the callback
+ callback();
+
+ // Restore caret position if needed
+ if (isCompactMode && isEditingText) {
+ restoreCaretPosition(savedPosition);
+ }
+};
+
+// Hook for managing text editor caret position with state
+export const useTextEditorFocus = () => {
+ const [savedCaretPosition, setSavedCaretPosition] =
+ useState(null);
+
+ const saveCaretPositionToState = useCallback(() => {
+ const position = saveCaretPosition();
+ setSavedCaretPosition(position);
+ }, []);
+
+ const restoreCaretPositionFromState = useCallback(() => {
+ setTimeout(() => {
+ const textEditor = getTextEditor();
+ if (textEditor) {
+ textEditor.focus();
+ if (savedCaretPosition) {
+ textEditor.selectionStart = savedCaretPosition.start;
+ textEditor.selectionEnd = savedCaretPosition.end;
+ setSavedCaretPosition(null);
+ }
+ }
+ }, 0);
+ }, [savedCaretPosition]);
+
+ const clearSavedPosition = useCallback(() => {
+ setSavedCaretPosition(null);
+ }, []);
+
+ return {
+ saveCaretPosition: saveCaretPositionToState,
+ restoreCaretPosition: restoreCaretPositionFromState,
+ clearSavedPosition,
+ hasSavedPosition: !!savedCaretPosition,
+ };
+};
+
+// Utility function to temporarily disable text editor blur
+export const temporarilyDisableTextEditorBlur = (
+ duration: number = 100,
+): void => {
+ const textEditor = getTextEditor();
+ if (textEditor) {
+ const originalOnBlur = textEditor.onblur;
+ textEditor.onblur = null;
+
+ setTimeout(() => {
+ textEditor.onblur = originalOnBlur;
+ }, duration);
+ }
+};
diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx
index 2255d8a5a2..13e7965a90 100644
--- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx
+++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx
@@ -538,6 +538,7 @@ export const textWysiwyg = ({
if (isDestroyed) {
return;
}
+
isDestroyed = true;
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
@@ -621,14 +622,24 @@ export const textWysiwyg = ({
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
+ const isPropertiesContent =
+ (target instanceof HTMLElement || target instanceof SVGElement) &&
+ !!(target as Element).closest(".properties-content");
+ const inShapeActionsMenu =
+ (target instanceof HTMLElement || target instanceof SVGElement) &&
+ (!!(target as Element).closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) ||
+ !!(target as Element).closest(".compact-shape-actions-island"));
setTimeout(() => {
- editable.onblur = handleSubmit;
-
- // case: clicking on the same property → no change → no update → no focus
- if (!isPropertiesTrigger) {
- editable.focus();
+ // If we interacted within shape actions menu or its popovers/triggers,
+ // keep submit disabled and don't steal focus back to textarea.
+ if (inShapeActionsMenu || isPropertiesTrigger || isPropertiesContent) {
+ return;
}
+
+ // Otherwise, re-enable submit on blur and refocus the editor.
+ editable.onblur = handleSubmit;
+ editable.focus();
});
};
@@ -651,6 +662,7 @@ export const textWysiwyg = ({
event.preventDefault();
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
}
+
temporarilyDisableSubmit();
return;
}
@@ -658,15 +670,20 @@ export const textWysiwyg = ({
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
+ const isPropertiesContent =
+ (target instanceof HTMLElement || target instanceof SVGElement) &&
+ !!(target as Element).closest(".properties-content");
if (
((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
- event.target.closest(
+ (event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
- ) &&
+ ) ||
+ event.target.closest(".compact-shape-actions-island")) &&
!isWritableElement(event.target)) ||
- isPropertiesTrigger
+ isPropertiesTrigger ||
+ isPropertiesContent
) {
temporarilyDisableSubmit();
} else if (