change properties for a new or editing text

This commit is contained in:
Ryan Di
2025-08-29 18:04:19 +10:00
parent 5e395f2027
commit fc8eb8f407
8 changed files with 424 additions and 146 deletions

View File

@@ -137,6 +137,11 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import {
withCaretPositionPreservation,
restoreCaretPosition,
} from "../hooks/useTextEditorFocus";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState, Primitive } from "../types"; import type { AppClassProperties, AppState, Primitive } from "../types";
@@ -701,7 +706,7 @@ export const actionChangeFontSize = register({
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, () => value, value); return changeFontSize(elements, appState, app, () => value, value);
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
<legend>{t("labels.fontSize")}</legend> <legend>{t("labels.fontSize")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -760,7 +765,14 @@ export const actionChangeFontSize = register({
? null ? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE, : appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
!!data?.compactMode,
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1105,14 +1117,19 @@ export const actionChangeFontFamily = register({
hoveredFontFamily={appState.currentHoveredFontFamily} hoveredFontFamily={appState.currentHoveredFontFamily}
compactMode={data?.compactMode} compactMode={data?.compactMode}
onSelect={(fontFamily) => { onSelect={(fontFamily) => {
setBatchedData({ withCaretPositionPreservation(
openPopup: null, () => {
currentHoveredFontFamily: null, setBatchedData({
currentItemFontFamily: fontFamily, openPopup: null,
}); currentHoveredFontFamily: null,
currentItemFontFamily: fontFamily,
// defensive clear so immediate close won't abuse the cached elements });
cachedElementsRef.current.clear(); // defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
},
!!data?.compactMode,
!!appState.editingTextElement,
);
}} }}
onHover={(fontFamily) => { onHover={(fontFamily) => {
setBatchedData({ setBatchedData({
@@ -1172,7 +1189,7 @@ export const actionChangeFontFamily = register({
setBatchedData({}); setBatchedData({});
} else { } else {
// close: clear openPopup if we're still the active popup // close: clear openPopup if we're still the active popup
const data = { const fontFamilyData = {
openPopup: openPopup:
appState.openPopup === "fontFamily" appState.openPopup === "fontFamily"
? null ? null
@@ -1183,9 +1200,14 @@ export const actionChangeFontFamily = register({
} as ChangeFontFamilyData; } as ChangeFontFamilyData;
// apply immediately to avoid racing with other popovers opening // apply immediately to avoid racing with other popovers opening
updateData({ ...batchedData, ...data }); updateData({ ...batchedData, ...fontFamilyData });
setBatchedData({}); setBatchedData({});
cachedElementsRef.current.clear(); 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, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app, data }) => {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.textAlign")}</legend> <legend>{t("labels.textAlign")}</legend>
@@ -1278,7 +1301,14 @@ export const actionChangeTextAlign = register({
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign, hasSelection ? null : appState.currentItemTextAlign,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
!!data?.compactMode,
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1320,7 +1350,7 @@ export const actionChangeVerticalAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app, data }) => {
return ( return (
<fieldset> <fieldset>
<div className="buttonList"> <div className="buttonList">
@@ -1370,7 +1400,14 @@ export const actionChangeVerticalAlign = register({
) !== null, ) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE), (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)} )}
onChange={(value) => updateData(value)} onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
!!data?.compactMode,
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>

View File

@@ -50,6 +50,8 @@ import {
import { getFormValue } from "../actions/actionProperties"; import { getFormValue } from "../actions/actionProperties";
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
import { getToolbarTools } from "./shapes"; import { getToolbarTools } from "./shapes";
import "./Actions.scss"; import "./Actions.scss";
@@ -85,6 +87,12 @@ import type {
} from "../types"; } from "../types";
import type { ActionManager } from "../actions/manager"; import type { ActionManager } from "../actions/manager";
// Common CSS class combinations
const PROPERTIES_CLASSES = clsx([
CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
"properties-content",
]);
export const canChangeStrokeColor = ( export const canChangeStrokeColor = (
appState: UIAppState, appState: UIAppState,
targetElements: ExcalidrawElement[], targetElements: ExcalidrawElement[],
@@ -313,6 +321,8 @@ export const CompactShapeActions = ({
const targetElements = getTargetElements(elementsMap, appState); const targetElements = getTargetElements(elementsMap, appState);
const [strokePopoverOpen, setStrokePopoverOpen] = useState(false); const [strokePopoverOpen, setStrokePopoverOpen] = useState(false);
const [otherActionsPopoverOpen, setOtherActionsPopoverOpen] = useState(false); const [otherActionsPopoverOpen, setOtherActionsPopoverOpen] = useState(false);
const [preventTextAlignClose, setPreventTextAlignClose] = useState(false);
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const isEditingTextOrNewElement = Boolean( const isEditingTextOrNewElement = Boolean(
@@ -364,12 +374,11 @@ export const CompactShapeActions = ({
return ( return (
<div className="compact-shape-actions"> <div className="compact-shape-actions">
{/* Stroke Color */} {/* Stroke Color */}
{canChangeStrokeColor(appState, targetElements) && {canChangeStrokeColor(appState, targetElements) && (
!appState.editingTextElement && ( <div className={clsx("compact-action-item")}>
<div className={clsx("compact-action-item")}> {renderAction("changeStrokeColor", { compactMode: true })}
{renderAction("changeStrokeColor", { compactMode: true })} </div>
</div> )}
)}
{/* Background Color */} {/* Background Color */}
{canChangeBackgroundColor(appState, targetElements) && ( {canChangeBackgroundColor(appState, targetElements) && (
@@ -400,7 +409,7 @@ export const CompactShapeActions = ({
<Popover.Trigger asChild> <Popover.Trigger asChild>
<button <button
type="button" type="button"
className="compact-action-button" className="compact-action-button properties-trigger"
title={t("labels.stroke")} title={t("labels.stroke")}
onPointerDown={(e) => { onPointerDown={(e) => {
e.preventDefault(); e.preventDefault();
@@ -423,7 +432,7 @@ export const CompactShapeActions = ({
</Popover.Trigger> </Popover.Trigger>
{strokePopoverOpen && ( {strokePopoverOpen && (
<PropertiesPopover <PropertiesPopover
className={CLASSES.SHAPE_ACTIONS_THEME_SCOPE} className={PROPERTIES_CLASSES}
container={container} container={container}
style={{ maxWidth: "13rem" }} style={{ maxWidth: "13rem" }}
onClose={() => setStrokePopoverOpen(false)} onClose={() => setStrokePopoverOpen(false)}
@@ -474,7 +483,7 @@ export const CompactShapeActions = ({
<Popover.Trigger asChild> <Popover.Trigger asChild>
<button <button
type="button" type="button"
className="compact-action-button" className="compact-action-button properties-trigger"
title={t("labels.arrowtypes")} title={t("labels.arrowtypes")}
onPointerDown={(e) => { onPointerDown={(e) => {
e.preventDefault(); e.preventDefault();
@@ -524,6 +533,7 @@ export const CompactShapeActions = ({
{appState.openPopup === "arrowProperties" && ( {appState.openPopup === "arrowProperties" && (
<PropertiesPopover <PropertiesPopover
container={container} container={container}
className="properties-content"
style={{ maxWidth: "13rem" }} style={{ maxWidth: "13rem" }}
onClose={() => { onClose={() => {
setAppState((prev: AppState) => setAppState((prev: AppState) =>
@@ -549,80 +559,99 @@ export const CompactShapeActions = ({
{/* Text Properties */} {/* Text Properties */}
{(appState.activeTool.type === "text" || {(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && targetElements.some(isTextElement)) && (
!appState.editingTextElement && ( <>
<> <div className="compact-action-item">
<div className="compact-action-item"> {renderAction("changeFontFamily", {
{renderAction("changeFontFamily", { compactMode: true,
compactMode: true, onPreventClose: () => setPreventTextAlignClose(true),
})} })}
</div> </div>
<div className="compact-action-item"> <div className="compact-action-item">
<Popover.Root <Popover.Root
open={appState.openPopup === "textAlign"} open={appState.openPopup === "textAlign"}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) { if (!open) {
setAppState({ openPopup: "textAlign" }); // If we're preventing close due to selection, ignore this close event
setStrokePopoverOpen(false); if (preventTextAlignClose) {
setOtherActionsPopoverOpen(false); setPreventTextAlignClose(false);
return;
} }
}}
> setAppState({ openPopup: null });
<Popover.Trigger asChild>
<button // Refocus text editor if it was being edited and restore caret position
type="button" if (appState.editingTextElement) {
className="compact-action-button" restoreCaretPosition();
title={t("labels.textAlign")} }
onPointerDown={(e) => { } else {
// Save current caret position before opening popover
if (appState.editingTextElement) {
saveCaretPosition();
}
setAppState({ openPopup: "textAlign" });
setStrokePopoverOpen(false);
setOtherActionsPopoverOpen(false);
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.textAlign")}
onPointerDown={(e) => {
// Prevent default behavior that might dismiss keyboard on mobile
if (appState.editingTextElement) {
e.preventDefault(); e.preventDefault();
if (appState.openPopup === "textAlign") { }
setAppState({ openPopup: null }); }}
} else { onClick={() => {}}
setStrokePopoverOpen(false); >
setOtherActionsPopoverOpen(false); {TextSizeIcon}
setAppState({ openPopup: "textAlign" }); </button>
} </Popover.Trigger>
}} {appState.openPopup === "textAlign" && (
onClick={(e) => { <PropertiesPopover
e.preventDefault(); className={PROPERTIES_CLASSES}
e.stopPropagation(); container={container}
}} style={{ maxWidth: "13rem" }}
> // Improve focus handling for text editing scenarios
{TextSizeIcon} preventAutoFocusOnTouch={!!appState.editingTextElement}
</button> onClose={() => {
</Popover.Trigger> setAppState({ openPopup: null });
{appState.openPopup === "textAlign" && ( // Refocus text editor when popover closes with caret restoration
<PropertiesPopover if (appState.editingTextElement) {
className={CLASSES.SHAPE_ACTIONS_THEME_SCOPE} restoreCaretPosition();
container={container} }
style={{ maxWidth: "13rem" }} }}
onClose={() => { >
setAppState((prev: AppState) => <div className="selected-shape-actions">
prev.openPopup === "textAlign" {(appState.activeTool.type === "text" ||
? { openPopup: null } suppportsHorizontalAlign(targetElements, elementsMap)) &&
: null, renderAction("changeTextAlign", {
); compactMode: true,
}} onPreventClose: () => setPreventTextAlignClose(true),
> })}
<div className="selected-shape-actions"> {shouldAllowVerticalAlign(targetElements, elementsMap) &&
{(appState.activeTool.type === "text" || renderAction("changeVerticalAlign", {
suppportsHorizontalAlign( compactMode: true,
targetElements, onPreventClose: () => setPreventTextAlignClose(true),
elementsMap, })}
)) && {(appState.activeTool.type === "text" ||
renderAction("changeTextAlign")} targetElements.some(isTextElement)) &&
{shouldAllowVerticalAlign(targetElements, elementsMap) && renderAction("changeFontSize", {
renderAction("changeVerticalAlign")} compactMode: true,
{(appState.activeTool.type === "text" || onPreventClose: () => setPreventTextAlignClose(true),
targetElements.some(isTextElement)) && })}
renderAction("changeFontSize")} </div>
</div> </PropertiesPopover>
</PropertiesPopover> )}
)} </Popover.Root>
</Popover.Root> </div>
</div> </>
</> )}
)}
{/* Dedicated Copy Button */} {/* Dedicated Copy Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && ( {!isEditingTextOrNewElement && targetElements.length > 0 && (
@@ -654,7 +683,7 @@ export const CompactShapeActions = ({
<Popover.Trigger asChild> <Popover.Trigger asChild>
<button <button
type="button" type="button"
className="compact-action-button" className="compact-action-button properties-trigger"
title={t("labels.actions")} title={t("labels.actions")}
onPointerDown={(e) => { onPointerDown={(e) => {
e.preventDefault(); e.preventDefault();
@@ -677,7 +706,7 @@ export const CompactShapeActions = ({
</Popover.Trigger> </Popover.Trigger>
{otherActionsPopoverOpen && ( {otherActionsPopoverOpen && (
<PropertiesPopover <PropertiesPopover
className={CLASSES.SHAPE_ACTIONS_THEME_SCOPE} className={PROPERTIES_CLASSES}
container={container} container={container}
style={{ style={{
maxWidth: "12rem", maxWidth: "12rem",

View File

@@ -19,6 +19,11 @@ import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover"; import { PropertiesPopover } from "../PropertiesPopover";
import { backgroundIcon, slashIcon, strokeIcon } from "../icons"; import { backgroundIcon, slashIcon, strokeIcon } from "../icons";
import {
saveCaretPosition,
restoreCaretPosition,
temporarilyDisableTextEditorBlur,
} from "../../hooks/useTextEditorFocus";
import { ColorInput } from "./ColorInput"; import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
@@ -79,6 +84,7 @@ const ColorPickerPopupContent = ({
palette = COLOR_PALETTE, palette = COLOR_PALETTE,
updateData, updateData,
getOpenPopup, getOpenPopup,
appState,
}: Pick< }: Pick<
ColorPickerProps, ColorPickerProps,
| "type" | "type"
@@ -88,6 +94,7 @@ const ColorPickerPopupContent = ({
| "elements" | "elements"
| "palette" | "palette"
| "updateData" | "updateData"
| "appState"
> & { > & {
getOpenPopup: () => AppState["openPopup"]; getOpenPopup: () => AppState["openPopup"];
}) => { }) => {
@@ -121,6 +128,8 @@ const ColorPickerPopupContent = ({
<PropertiesPopover <PropertiesPopover
container={container} container={container}
style={{ maxWidth: "13rem" }} style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onFocusOutside={(event) => { onFocusOutside={(event) => {
// refocus due to eye dropper // refocus due to eye dropper
focusPickerContent(); focusPickerContent();
@@ -140,6 +149,18 @@ const ColorPickerPopupContent = ({
updateData({ openPopup: null }); updateData({ openPopup: null });
} }
setActiveColorPickerSection(null); setActiveColorPickerSection(null);
// Refocus text editor when popover closes if we were editing text
if (appState.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}} }}
> >
{palette ? ( {palette ? (
@@ -148,7 +169,17 @@ const ColorPickerPopupContent = ({
palette={palette} palette={palette}
color={color} color={color}
onChange={(changedColor) => { onChange={(changedColor) => {
// Save caret position before color change if editing text
const savedSelection = appState.editingTextElement
? saveCaretPosition()
: null;
onChange(changedColor); onChange(changedColor);
// Restore caret position after color change if editing text
if (appState.editingTextElement && savedSelection) {
restoreCaretPosition(savedSelection);
}
}} }}
onEyeDropperToggle={(force) => { onEyeDropperToggle={(force) => {
setEyeDropperState((state) => { setEyeDropperState((state) => {
@@ -199,6 +230,7 @@ const ColorPickerTrigger = ({
compactMode = false, compactMode = false,
mode = "background", mode = "background",
onToggle, onToggle,
editingTextElement,
}: { }: {
color: string | null; color: string | null;
label: string; label: string;
@@ -206,7 +238,21 @@ const ColorPickerTrigger = ({
compactMode?: boolean; compactMode?: boolean;
mode?: "background" | "stroke"; mode?: "background" | "stroke";
onToggle: () => void; onToggle: () => void;
editingTextElement?: boolean;
}) => { }) => {
const handlePointerDown = (e: React.PointerEvent) => {
// use pointerdown so we run before outside-close logic
e.preventDefault();
e.stopPropagation();
// If editing text, temporarily disable the wysiwyg blur event
if (editingTextElement) {
temporarilyDisableTextEditorBlur();
}
onToggle();
};
return ( return (
<Popover.Trigger <Popover.Trigger
type="button" type="button"
@@ -223,12 +269,7 @@ const ColorPickerTrigger = ({
: t("labels.showBackground") : t("labels.showBackground")
} }
data-openpopup={type} data-openpopup={type}
onPointerDown={(e) => { onPointerDown={handlePointerDown}
// use pointerdown so we run before outside-close logic
e.preventDefault();
e.stopPropagation();
onToggle();
}}
onClick={(e) => { onClick={(e) => {
// suppress Radix default toggle to avoid double-toggle flicker // suppress Radix default toggle to avoid double-toggle flicker
e.preventDefault(); e.preventDefault();
@@ -316,6 +357,7 @@ export const ColorPicker = ({
type={type} type={type}
compactMode={compactMode} compactMode={compactMode}
mode={type === "elementStroke" ? "stroke" : "background"} mode={type === "elementStroke" ? "stroke" : "background"}
editingTextElement={!!appState.editingTextElement}
onToggle={() => { onToggle={() => {
// atomic switch: if another popup is open, close it first, then open this one next tick // atomic switch: if another popup is open, close it first, then open this one next tick
if (appState.openPopup === type) { if (appState.openPopup === type) {
@@ -342,6 +384,7 @@ export const ColorPicker = ({
palette={palette} palette={palette}
updateData={updateData} updateData={updateData}
getOpenPopup={() => openRef.current} getOpenPopup={() => openRef.current}
appState={appState}
/> />
)} )}
</Popover.Root> </Popover.Root>

View File

@@ -102,21 +102,11 @@ export const FontPicker = React.memo(
</div> </div>
)} )}
{!compactMode && <ButtonSeparator />} {!compactMode && <ButtonSeparator />}
<Popover.Root open={isOpened} onOpenChange={() => {}}> <Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger <FontPickerTrigger
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
onTrigger={(e) => { onToggle={() => {}} // No longer needed, Radix handles it
// suppress default to avoid double toggle isOpened={isOpened}
if (e && e.preventDefault) {
e.preventDefault();
}
if (isOpened) {
onPopupChange(false);
} else {
onPopupChange(true);
}
}}
/> />
{isOpened && ( {isOpened && (
<FontPickerList <FontPickerList

View File

@@ -90,7 +90,8 @@ export const FontPickerList = React.memo(
onClose, onClose,
}: FontPickerListProps) => { }: FontPickerListProps) => {
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const { fonts } = useApp(); const app = useApp();
const { fonts } = app;
const { showDeprecatedFonts } = useAppProps(); const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -187,6 +188,42 @@ export const FontPickerList = React.memo(
onLeave, 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<KeyboardEventHandler<HTMLDivElement>>( const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => { (event) => {
const handled = fontPickerKeyHandler({ const handled = fontPickerKeyHandler({
@@ -194,7 +231,7 @@ export const FontPickerList = React.memo(
inputRef, inputRef,
hoveredFont, hoveredFont,
filteredFonts, filteredFonts,
onSelect, onSelect: wrappedOnSelect,
onHover, onHover,
onClose, onClose,
}); });
@@ -204,7 +241,7 @@ export const FontPickerList = React.memo(
event.stopPropagation(); event.stopPropagation();
} }
}, },
[hoveredFont, filteredFonts, onSelect, onHover, onClose], [hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
); );
useEffect(() => { useEffect(() => {
@@ -240,7 +277,7 @@ export const FontPickerList = React.memo(
// allow to tab between search and selected font // allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1} tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => { onClick={(e) => {
onSelect(Number(e.currentTarget.value)); wrappedOnSelect(Number(e.currentTarget.value));
}} }}
onMouseMove={() => { onMouseMove={() => {
if (hoveredFont?.value !== font.value) { if (hoveredFont?.value !== font.value) {
@@ -282,10 +319,24 @@ export const FontPickerList = React.memo(
className="properties-content" className="properties-content"
container={container} container={container}
style={{ width: "15rem" }} 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} onPointerLeave={onLeave}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
preventAutoFocusOnTouch={true} preventAutoFocusOnTouch={!!app.state.editingTextElement}
> >
<QuickSearch <QuickSearch
ref={inputRef} ref={inputRef}

View File

@@ -6,18 +6,22 @@ import type { FontFamilyValues } from "@excalidraw/element/types";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { ButtonIcon } from "../ButtonIcon"; import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons"; import { TextIcon } from "../icons";
import { useExcalidrawAppState } from "../App";
import { isDefaultFont } from "./FontPicker"; import { isDefaultFont } from "./FontPicker";
interface FontPickerTriggerProps { interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null; selectedFontFamily: FontFamilyValues | null;
onTrigger?: (event: React.SyntheticEvent) => void; onToggle: () => void;
isOpened?: boolean;
} }
export const FontPickerTrigger = ({ export const FontPickerTrigger = ({
selectedFontFamily, selectedFontFamily,
onTrigger, onToggle,
isOpened = false,
}: FontPickerTriggerProps) => { }: FontPickerTriggerProps) => {
const appState = useExcalidrawAppState();
const isTriggerActive = useMemo( const isTriggerActive = useMemo(
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)), () => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily], [selectedFontFamily],
@@ -25,16 +29,14 @@ export const FontPickerTrigger = ({
return ( return (
<Popover.Trigger asChild> <Popover.Trigger asChild>
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
<div <div
data-openpopup="fontFamily" data-openpopup="fontFamily"
className="properties-trigger"
onPointerDown={(e) => { onPointerDown={(e) => {
e.preventDefault(); // Prevent default behavior that might dismiss keyboard on mobile when editing text
onTrigger?.(e); if (appState.editingTextElement) {
}} e.preventDefault();
onClick={(e) => { }
e.preventDefault();
e.stopPropagation();
}} }}
> >
<ButtonIcon <ButtonIcon
@@ -43,11 +45,8 @@ export const FontPickerTrigger = ({
title={t("labels.showFonts")} title={t("labels.showFonts")}
className="properties-trigger" className="properties-trigger"
testId={"font-family-show-fonts"} testId={"font-family-show-fonts"}
active={isTriggerActive} active={isTriggerActive || isOpened}
onClick={(e) => { onClick={() => {}} // Let Radix handle the toggle
e.preventDefault();
e.stopPropagation();
}}
style={{ style={{
border: "none", border: "none",
}} }}

View File

@@ -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<CaretPosition | null>(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);
}
};

View File

@@ -538,6 +538,7 @@ export const textWysiwyg = ({
if (isDestroyed) { if (isDestroyed) {
return; return;
} }
isDestroyed = true; isDestroyed = true;
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg // 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 // 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 = const isPropertiesTrigger =
target instanceof HTMLElement && target instanceof HTMLElement &&
target.classList.contains("properties-trigger"); 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(() => { setTimeout(() => {
editable.onblur = handleSubmit; // If we interacted within shape actions menu or its popovers/triggers,
// keep submit disabled and don't steal focus back to textarea.
// case: clicking on the same property → no change → no update → no focus if (inShapeActionsMenu || isPropertiesTrigger || isPropertiesContent) {
if (!isPropertiesTrigger) { return;
editable.focus();
} }
// Otherwise, re-enable submit on blur and refocus the editor.
editable.onblur = handleSubmit;
editable.focus();
}); });
}; };
@@ -651,6 +662,7 @@ export const textWysiwyg = ({
event.preventDefault(); event.preventDefault();
app.handleCanvasPanUsingWheelOrSpaceDrag(event); app.handleCanvasPanUsingWheelOrSpaceDrag(event);
} }
temporarilyDisableSubmit(); temporarilyDisableSubmit();
return; return;
} }
@@ -658,15 +670,20 @@ export const textWysiwyg = ({
const isPropertiesTrigger = const isPropertiesTrigger =
target instanceof HTMLElement && target instanceof HTMLElement &&
target.classList.contains("properties-trigger"); target.classList.contains("properties-trigger");
const isPropertiesContent =
(target instanceof HTMLElement || target instanceof SVGElement) &&
!!(target as Element).closest(".properties-content");
if ( if (
((event.target instanceof HTMLElement || ((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) && event.target instanceof SVGElement) &&
event.target.closest( (event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`, `.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
) && ) ||
event.target.closest(".compact-shape-actions-island")) &&
!isWritableElement(event.target)) || !isWritableElement(event.target)) ||
isPropertiesTrigger isPropertiesTrigger ||
isPropertiesContent
) { ) {
temporarilyDisableSubmit(); temporarilyDisableSubmit();
} else if ( } else if (