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 }) => (
{t("labels.fontSize")}
@@ -760,7 +765,14 @@ export const actionChangeFontSize = register({ ? null : appState.currentItemFontSize || DEFAULT_FONT_SIZE, )} - onChange={(value) => updateData(value)} + onChange={(value) => { + withCaretPositionPreservation( + () => updateData(value), + !!data?.compactMode, + !!appState.editingTextElement, + data?.onPreventClose, + ); + }} />
@@ -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 (
{t("labels.textAlign")} @@ -1278,7 +1301,14 @@ export const actionChangeTextAlign = register({ (hasSelection) => hasSelection ? null : appState.currentItemTextAlign, )} - onChange={(value) => updateData(value)} + onChange={(value) => { + withCaretPositionPreservation( + () => updateData(value), + !!data?.compactMode, + !!appState.editingTextElement, + data?.onPreventClose, + ); + }} />
@@ -1320,7 +1350,7 @@ export const actionChangeVerticalAlign = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => { + PanelComponent: ({ elements, appState, updateData, app, data }) => { return (
@@ -1370,7 +1400,14 @@ export const actionChangeVerticalAlign = register({ ) !== null, (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE), )} - onChange={(value) => updateData(value)} + onChange={(value) => { + withCaretPositionPreservation( + () => updateData(value), + !!data?.compactMode, + !!appState.editingTextElement, + data?.onPreventClose, + ); + }} />
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 = ({ - - {appState.openPopup === "textAlign" && ( - { - setAppState((prev: AppState) => - prev.openPopup === "textAlign" - ? { openPopup: null } - : null, - ); - }} - > -
- {(appState.activeTool.type === "text" || - suppportsHorizontalAlign( - targetElements, - elementsMap, - )) && - renderAction("changeTextAlign")} - {shouldAllowVerticalAlign(targetElements, elementsMap) && - renderAction("changeVerticalAlign")} - {(appState.activeTool.type === "text" || - targetElements.some(isTextElement)) && - renderAction("changeFontSize")} -
-
- )} - -
- - )} + } + }} + onClick={() => {}} + > + {TextSizeIcon} + + + {appState.openPopup === "textAlign" && ( + { + setAppState({ openPopup: null }); + // Refocus text editor when popover closes with caret restoration + if (appState.editingTextElement) { + restoreCaretPosition(); + } + }} + > +
+ {(appState.activeTool.type === "text" || + suppportsHorizontalAlign(targetElements, elementsMap)) && + renderAction("changeTextAlign", { + compactMode: true, + onPreventClose: () => setPreventTextAlignClose(true), + })} + {shouldAllowVerticalAlign(targetElements, elementsMap) && + renderAction("changeVerticalAlign", { + compactMode: true, + onPreventClose: () => setPreventTextAlignClose(true), + })} + {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && + renderAction("changeFontSize", { + compactMode: true, + onPreventClose: () => setPreventTextAlignClose(true), + })} +
+
+ )} + + + + )} {/* Dedicated Copy Button */} {!isEditingTextOrNewElement && targetElements.length > 0 && ( @@ -654,7 +683,7 @@ export const CompactShapeActions = ({