mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-07 09:36:58 +02:00
change properties for a new or editing text
This commit is contained in:
@@ -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 }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontSize")}</legend>
|
||||
<div className="buttonList">
|
||||
@@ -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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -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 (
|
||||
<fieldset>
|
||||
<legend>{t("labels.textAlign")}</legend>
|
||||
@@ -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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -1320,7 +1350,7 @@ export const actionChangeVerticalAlign = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||
return (
|
||||
<fieldset>
|
||||
<div className="buttonList">
|
||||
@@ -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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
@@ -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 (
|
||||
<div className="compact-shape-actions">
|
||||
{/* Stroke Color */}
|
||||
{canChangeStrokeColor(appState, targetElements) &&
|
||||
!appState.editingTextElement && (
|
||||
<div className={clsx("compact-action-item")}>
|
||||
{renderAction("changeStrokeColor", { compactMode: true })}
|
||||
</div>
|
||||
)}
|
||||
{canChangeStrokeColor(appState, targetElements) && (
|
||||
<div className={clsx("compact-action-item")}>
|
||||
{renderAction("changeStrokeColor", { compactMode: true })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Color */}
|
||||
{canChangeBackgroundColor(appState, targetElements) && (
|
||||
@@ -400,7 +409,7 @@ export const CompactShapeActions = ({
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="compact-action-button"
|
||||
className="compact-action-button properties-trigger"
|
||||
title={t("labels.stroke")}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -423,7 +432,7 @@ export const CompactShapeActions = ({
|
||||
</Popover.Trigger>
|
||||
{strokePopoverOpen && (
|
||||
<PropertiesPopover
|
||||
className={CLASSES.SHAPE_ACTIONS_THEME_SCOPE}
|
||||
className={PROPERTIES_CLASSES}
|
||||
container={container}
|
||||
style={{ maxWidth: "13rem" }}
|
||||
onClose={() => setStrokePopoverOpen(false)}
|
||||
@@ -474,7 +483,7 @@ export const CompactShapeActions = ({
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="compact-action-button"
|
||||
className="compact-action-button properties-trigger"
|
||||
title={t("labels.arrowtypes")}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -524,6 +533,7 @@ export const CompactShapeActions = ({
|
||||
{appState.openPopup === "arrowProperties" && (
|
||||
<PropertiesPopover
|
||||
container={container}
|
||||
className="properties-content"
|
||||
style={{ maxWidth: "13rem" }}
|
||||
onClose={() => {
|
||||
setAppState((prev: AppState) =>
|
||||
@@ -549,80 +559,99 @@ export const CompactShapeActions = ({
|
||||
|
||||
{/* Text Properties */}
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) &&
|
||||
!appState.editingTextElement && (
|
||||
<>
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeFontFamily", {
|
||||
compactMode: true,
|
||||
})}
|
||||
</div>
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "textAlign"}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setAppState({ openPopup: "textAlign" });
|
||||
setStrokePopoverOpen(false);
|
||||
setOtherActionsPopoverOpen(false);
|
||||
targetElements.some(isTextElement)) && (
|
||||
<>
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeFontFamily", {
|
||||
compactMode: true,
|
||||
onPreventClose: () => setPreventTextAlignClose(true),
|
||||
})}
|
||||
</div>
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "textAlign"}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// If we're preventing close due to selection, ignore this close event
|
||||
if (preventTextAlignClose) {
|
||||
setPreventTextAlignClose(false);
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="compact-action-button"
|
||||
title={t("labels.textAlign")}
|
||||
onPointerDown={(e) => {
|
||||
|
||||
setAppState({ openPopup: null });
|
||||
|
||||
// Refocus text editor if it was being edited and restore caret position
|
||||
if (appState.editingTextElement) {
|
||||
restoreCaretPosition();
|
||||
}
|
||||
} 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();
|
||||
if (appState.openPopup === "textAlign") {
|
||||
setAppState({ openPopup: null });
|
||||
} else {
|
||||
setStrokePopoverOpen(false);
|
||||
setOtherActionsPopoverOpen(false);
|
||||
setAppState({ openPopup: "textAlign" });
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{TextSizeIcon}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
{appState.openPopup === "textAlign" && (
|
||||
<PropertiesPopover
|
||||
className={CLASSES.SHAPE_ACTIONS_THEME_SCOPE}
|
||||
container={container}
|
||||
style={{ maxWidth: "13rem" }}
|
||||
onClose={() => {
|
||||
setAppState((prev: AppState) =>
|
||||
prev.openPopup === "textAlign"
|
||||
? { openPopup: null }
|
||||
: null,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="selected-shape-actions">
|
||||
{(appState.activeTool.type === "text" ||
|
||||
suppportsHorizontalAlign(
|
||||
targetElements,
|
||||
elementsMap,
|
||||
)) &&
|
||||
renderAction("changeTextAlign")}
|
||||
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
|
||||
renderAction("changeVerticalAlign")}
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) &&
|
||||
renderAction("changeFontSize")}
|
||||
</div>
|
||||
</PropertiesPopover>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
}}
|
||||
onClick={() => {}}
|
||||
>
|
||||
{TextSizeIcon}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
{appState.openPopup === "textAlign" && (
|
||||
<PropertiesPopover
|
||||
className={PROPERTIES_CLASSES}
|
||||
container={container}
|
||||
style={{ maxWidth: "13rem" }}
|
||||
// Improve focus handling for text editing scenarios
|
||||
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||
onClose={() => {
|
||||
setAppState({ openPopup: null });
|
||||
// Refocus text editor when popover closes with caret restoration
|
||||
if (appState.editingTextElement) {
|
||||
restoreCaretPosition();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="selected-shape-actions">
|
||||
{(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),
|
||||
})}
|
||||
</div>
|
||||
</PropertiesPopover>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dedicated Copy Button */}
|
||||
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||
@@ -654,7 +683,7 @@ export const CompactShapeActions = ({
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="compact-action-button"
|
||||
className="compact-action-button properties-trigger"
|
||||
title={t("labels.actions")}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -677,7 +706,7 @@ export const CompactShapeActions = ({
|
||||
</Popover.Trigger>
|
||||
{otherActionsPopoverOpen && (
|
||||
<PropertiesPopover
|
||||
className={CLASSES.SHAPE_ACTIONS_THEME_SCOPE}
|
||||
className={PROPERTIES_CLASSES}
|
||||
container={container}
|
||||
style={{
|
||||
maxWidth: "12rem",
|
||||
|
@@ -19,6 +19,11 @@ import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
import { backgroundIcon, slashIcon, strokeIcon } from "../icons";
|
||||
import {
|
||||
saveCaretPosition,
|
||||
restoreCaretPosition,
|
||||
temporarilyDisableTextEditorBlur,
|
||||
} from "../../hooks/useTextEditorFocus";
|
||||
|
||||
import { ColorInput } from "./ColorInput";
|
||||
import { Picker } from "./Picker";
|
||||
@@ -79,6 +84,7 @@ const ColorPickerPopupContent = ({
|
||||
palette = COLOR_PALETTE,
|
||||
updateData,
|
||||
getOpenPopup,
|
||||
appState,
|
||||
}: Pick<
|
||||
ColorPickerProps,
|
||||
| "type"
|
||||
@@ -88,6 +94,7 @@ const ColorPickerPopupContent = ({
|
||||
| "elements"
|
||||
| "palette"
|
||||
| "updateData"
|
||||
| "appState"
|
||||
> & {
|
||||
getOpenPopup: () => AppState["openPopup"];
|
||||
}) => {
|
||||
@@ -121,6 +128,8 @@ const ColorPickerPopupContent = ({
|
||||
<PropertiesPopover
|
||||
container={container}
|
||||
style={{ maxWidth: "13rem" }}
|
||||
// Improve focus handling for text editing scenarios
|
||||
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||
onFocusOutside={(event) => {
|
||||
// refocus due to eye dropper
|
||||
focusPickerContent();
|
||||
@@ -140,6 +149,18 @@ const ColorPickerPopupContent = ({
|
||||
updateData({ openPopup: 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 ? (
|
||||
@@ -148,7 +169,17 @@ const ColorPickerPopupContent = ({
|
||||
palette={palette}
|
||||
color={color}
|
||||
onChange={(changedColor) => {
|
||||
// Save caret position before color change if editing text
|
||||
const savedSelection = appState.editingTextElement
|
||||
? saveCaretPosition()
|
||||
: null;
|
||||
|
||||
onChange(changedColor);
|
||||
|
||||
// Restore caret position after color change if editing text
|
||||
if (appState.editingTextElement && savedSelection) {
|
||||
restoreCaretPosition(savedSelection);
|
||||
}
|
||||
}}
|
||||
onEyeDropperToggle={(force) => {
|
||||
setEyeDropperState((state) => {
|
||||
@@ -199,6 +230,7 @@ const ColorPickerTrigger = ({
|
||||
compactMode = false,
|
||||
mode = "background",
|
||||
onToggle,
|
||||
editingTextElement,
|
||||
}: {
|
||||
color: string | null;
|
||||
label: string;
|
||||
@@ -206,7 +238,21 @@ const ColorPickerTrigger = ({
|
||||
compactMode?: boolean;
|
||||
mode?: "background" | "stroke";
|
||||
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 (
|
||||
<Popover.Trigger
|
||||
type="button"
|
||||
@@ -223,12 +269,7 @@ const ColorPickerTrigger = ({
|
||||
: t("labels.showBackground")
|
||||
}
|
||||
data-openpopup={type}
|
||||
onPointerDown={(e) => {
|
||||
// use pointerdown so we run before outside-close logic
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onClick={(e) => {
|
||||
// suppress Radix default toggle to avoid double-toggle flicker
|
||||
e.preventDefault();
|
||||
@@ -316,6 +357,7 @@ export const ColorPicker = ({
|
||||
type={type}
|
||||
compactMode={compactMode}
|
||||
mode={type === "elementStroke" ? "stroke" : "background"}
|
||||
editingTextElement={!!appState.editingTextElement}
|
||||
onToggle={() => {
|
||||
// atomic switch: if another popup is open, close it first, then open this one next tick
|
||||
if (appState.openPopup === type) {
|
||||
@@ -342,6 +384,7 @@ export const ColorPicker = ({
|
||||
palette={palette}
|
||||
updateData={updateData}
|
||||
getOpenPopup={() => openRef.current}
|
||||
appState={appState}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
|
@@ -102,21 +102,11 @@ export const FontPicker = React.memo(
|
||||
</div>
|
||||
)}
|
||||
{!compactMode && <ButtonSeparator />}
|
||||
<Popover.Root open={isOpened} onOpenChange={() => {}}>
|
||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||
<FontPickerTrigger
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
onTrigger={(e) => {
|
||||
// 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 && (
|
||||
<FontPickerList
|
||||
|
@@ -90,7 +90,8 @@ export const FontPickerList = React.memo(
|
||||
onClose,
|
||||
}: FontPickerListProps) => {
|
||||
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<KeyboardEventHandler<HTMLDivElement>>(
|
||||
(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}
|
||||
>
|
||||
<QuickSearch
|
||||
ref={inputRef}
|
||||
|
@@ -6,18 +6,22 @@ import type { FontFamilyValues } from "@excalidraw/element/types";
|
||||
import { t } from "../../i18n";
|
||||
import { ButtonIcon } from "../ButtonIcon";
|
||||
import { TextIcon } from "../icons";
|
||||
import { useExcalidrawAppState } from "../App";
|
||||
|
||||
import { isDefaultFont } from "./FontPicker";
|
||||
|
||||
interface FontPickerTriggerProps {
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
onTrigger?: (event: React.SyntheticEvent) => 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 (
|
||||
<Popover.Trigger asChild>
|
||||
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
|
||||
<div
|
||||
data-openpopup="fontFamily"
|
||||
className="properties-trigger"
|
||||
onPointerDown={(e) => {
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ButtonIcon
|
||||
@@ -43,11 +45,8 @@ export const FontPickerTrigger = ({
|
||||
title={t("labels.showFonts")}
|
||||
className="properties-trigger"
|
||||
testId={"font-family-show-fonts"}
|
||||
active={isTriggerActive}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
active={isTriggerActive || isOpened}
|
||||
onClick={() => {}} // Let Radix handle the toggle
|
||||
style={{
|
||||
border: "none",
|
||||
}}
|
||||
|
112
packages/excalidraw/hooks/useTextEditorFocus.ts
Normal file
112
packages/excalidraw/hooks/useTextEditorFocus.ts
Normal 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);
|
||||
}
|
||||
};
|
@@ -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 (
|
||||
|
Reference in New Issue
Block a user