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,
} 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>

View File

@@ -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",

View File

@@ -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>

View File

@@ -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

View File

@@ -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}

View File

@@ -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",
}}

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) {
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 (