This commit is contained in:
dwelle 2025-01-02 16:45:02 +01:00
parent 55be6de5ec
commit e5a9786af3
18 changed files with 167 additions and 68 deletions

View File

@ -25,7 +25,7 @@ VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false
# The port the run the dev server
VITE_APP_PORT=3000
VITE_APP_PORT=3001
#Debug flags

View File

@ -169,7 +169,7 @@ export default defineConfig(({ mode }) => {
},
],
start_url: "/",
id:"excalidraw",
id: "excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",

View File

@ -4845,6 +4845,12 @@ class App extends React.Component<AppProps, AppState> {
) {
const elementsMap = this.scene.getElementsMapIncludingDeleted();
// flushSync(() => {
// this.setState({
// editingTextElement: element,
// });
// });
const updateElement = (nextOriginalText: string, isDeleted: boolean) => {
this.scene.replaceAllElements([
// Not sure why we include deleted elements as well hence using deleted elements map
@ -6278,6 +6284,11 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>,
) => {
console.log("(1)", document.activeElement);
console.time();
this.focusContainer();
console.timeEnd();
console.log("(2)", document.activeElement);
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser();
@ -6722,17 +6733,16 @@ class App extends React.Component<AppProps, AppState> {
}
isPanning = true;
// due to event.preventDefault below, container wouldn't get focus
// automatically
this.focusContainer();
// preventing defualt while text editing messes with cursor/focus
if (!this.state.editingTextElement) {
// necessary to prevent browser from scrolling the page if excalidraw
// not full-page #4489
//
// as such, the above is broken when panning canvas while in wysiwyg
// note, this fix won't work when panning canvas while in wysiwyg since
// we don't execute it while in wysiwyg
event.preventDefault();
// focus explicitly due to the event.preventDefault above
this.focusContainer();
}
let nextPastePrevented = false;

View File

@ -19,6 +19,7 @@ import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import { CLASSES } from "../../constants";
import "./ColorPicker.scss";
@ -186,9 +187,13 @@ const ColorPickerTrigger = ({
return (
<Popover.Trigger
type="button"
className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": color === "transparent" || !color,
})}
className={clsx(
"color-picker__button active-color",
CLASSES.PROPERTIES_POPOVER_TRIGGER,
{
"is-transparent": color === "transparent" || !color,
},
)}
aria-label={label}
style={color ? { "--swatch-color": color } : undefined}
title={

View File

@ -1,6 +1,7 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useEffect, useRef } from "react";
import { useUIAppState } from "../../context/ui-appState";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
@ -17,6 +18,7 @@ export const CustomColorList = ({
onChange,
label,
}: CustomColorListProps) => {
const appState = useUIAppState();
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
@ -54,7 +56,9 @@ export const CustomColorList = ({
key={i}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
{!appState.editingTextElement && (
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
)}
</button>
);
})}

View File

@ -10,6 +10,7 @@ import HotkeyLabel from "./HotkeyLabel";
import type { ColorPaletteCustom } from "../../colors";
import type { TranslationKeys } from "../../i18n";
import { t } from "../../i18n";
import { useUIAppState } from "../../context/ui-appState";
interface PickerColorListProps {
palette: ColorPaletteCustom;
@ -26,6 +27,8 @@ const PickerColorList = ({
label,
activeShade,
}: PickerColorListProps) => {
const appState = useUIAppState();
const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent",
palette,
@ -80,7 +83,9 @@ const PickerColorList = ({
key={key}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={keybinding} />
{!appState.editingTextElement && (
<HotkeyLabel color={color} keyLabel={keybinding} />
)}
</button>
);
})}

View File

@ -8,6 +8,7 @@ import {
import HotkeyLabel from "./HotkeyLabel";
import { t } from "../../i18n";
import type { ColorPaletteCustom } from "../../colors";
import { useUIAppState } from "../../context/ui-appState";
interface ShadeListProps {
hex: string;
@ -16,6 +17,8 @@ interface ShadeListProps {
}
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
const appState = useUIAppState();
const colorObj = getColorNameAndShadeFromColor({
color: hex || "transparent",
palette,
@ -31,7 +34,7 @@ export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
if (btnRef.current && activeColorPickerSection === "shades") {
btnRef.current.focus();
}
}, [colorObj, activeColorPickerSection]);
}, [colorObj?.colorName, colorObj?.shade, activeColorPickerSection]);
if (colorObj) {
const { colorName, shade } = colorObj;
@ -64,7 +67,9 @@ export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
}}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
{!appState.editingTextElement && (
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
)}
</button>
))}
</div>

View File

@ -56,6 +56,7 @@ export const TopPicks = ({
title={color}
onClick={() => onChange(color)}
data-testid={`color-top-pick-${color}`}
tabIndex={-1}
>
<div className="color-picker__button-outline" />
</button>

View File

@ -250,6 +250,10 @@ export const FontPickerList = React.memo(
onClose={onClose}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
onFocusOutside={(event) => {
// so we don't close when refocusing wysiwyg while editing
event.preventDefault();
}}
>
<QuickSearch
ref={inputRef}

View File

@ -5,6 +5,7 @@ import { TextIcon } from "../icons";
import type { FontFamilyValues } from "../../element/types";
import { t } from "../../i18n";
import { isDefaultFont } from "./FontPicker";
import { CLASSES } from "../../constants";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
@ -26,7 +27,7 @@ export const FontPickerTrigger = ({
standalone
icon={TextIcon}
title={t("labels.showFonts")}
className="properties-trigger"
className={CLASSES.PROPERTIES_POPOVER_TRIGGER}
testId={"font-family-show-fonts"}
active={isTriggerActive}
// no-op

View File

@ -5,6 +5,7 @@ import * as Popover from "@radix-ui/react-popover";
import { useDevice } from "./App";
import { Island } from "./Island";
import { isInteractive } from "../utils";
import { CLASSES } from "../constants";
interface PropertiesPopoverProps {
className?: string;
@ -42,7 +43,11 @@ export const PropertiesPopover = React.forwardRef<
<Popover.Portal container={container}>
<Popover.Content
ref={ref}
className={clsx("focus-visible-none", className)}
className={clsx(
"focus-visible-none",
CLASSES.PROPERTIES_POPOVER,
className,
)}
data-prevent-outside-click
side={
device.editor.isMobile && !device.viewport.isLandscape

View File

@ -115,6 +115,8 @@ export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
PROPERTIES_POPOVER: "properties-popover",
PROPERTIES_POPOVER_TRIGGER: "properties-popover-trigger",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";

View File

@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, POINTER_BUTTON } from "../constants";
import { CLASSES, EVENT, isSafari, POINTER_BUTTON } from "../constants";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -50,6 +50,8 @@ import {
originalContainerCache,
updateOriginalContainerCache,
} from "./containerCache";
import { activeEyeDropperAtom } from "../components/EyeDropper";
import { jotaiStore } from "../jotai";
const getTransform = (
width: number,
@ -524,6 +526,7 @@ export const textWysiwyg = ({
// so that we don't need to create separate a callback for event handlers
let submittedViaKeyboard = false;
const handleSubmit = () => {
console.warn("handleSubmit");
// prevent double submit
if (isDestroyed) {
return;
@ -581,62 +584,96 @@ export const textWysiwyg = ({
});
};
const onBlur = () => {
console.warn("onBlur", document.activeElement);
const isColorPicking = jotaiStore.get(activeEyeDropperAtom);
if (isColorPicking) {
focusEditable(null);
} else if (document.activeElement !== editable) {
handleSubmit();
}
};
const cleanup = () => {
// remove events to ensure they don't late-fire
editable.onblur = null;
editable.oninput = null;
editable.onkeydown = null;
editable.onpointerdown = null;
if (observer) {
observer.disconnect();
}
window.removeEventListener("resize", updateWysiwygStyle);
window.removeEventListener("wheel", stopEvent, true);
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", bindBlurEvent);
window.removeEventListener("blur", handleSubmit);
window.removeEventListener("beforeunload", handleSubmit);
window.removeEventListener(EVENT.RESIZE, updateWysiwygStyle);
window.removeEventListener(EVENT.WHEEL, stopEvent, true);
window.removeEventListener(EVENT.POINTER_DOWN, onPointerDown, {
capture: true,
});
window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
window.removeEventListener(EVENT.BLUR, onBlur);
window.removeEventListener(EVENT.BEFORE_UNLOAD, handleSubmit);
unbindUpdate();
unbindOnScroll();
editable.remove();
};
const bindBlurEvent = (event?: MouseEvent) => {
window.removeEventListener("pointerup", bindBlurEvent);
// Deferred so that the pointerdown that initiates the wysiwyg doesn't
// trigger the blur on ensuing pointerup.
// Also to handle cases such as picking a color which would trigger a blur
// in that same tick.
const focusEditable = (event: MouseEvent | FocusEvent | null) => {
const target = event?.target;
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
const shouldSkipRefocus =
target &&
// don't steal focus if user is focusing an input such as HEX input
((isWritableElement(target) && document.activeElement !== editable) ||
// refocusing while clicking on popver breaks safari
(isSafari &&
target instanceof HTMLElement &&
target.classList.contains(CLASSES.PROPERTIES_POPOVER_TRIGGER)));
setTimeout(() => {
editable.onblur = handleSubmit;
// case: clicking on the same property → no change → no update → no focus
if (!isPropertiesTrigger) {
editable.focus();
}
});
if (!shouldSkipRefocus) {
// Deferred so that the pointerdown that initiates the wysiwyg doesn't
// trigger the blur on ensuing pointerup.
// Also to handle cases such as picking a color which would trigger a blur
// in that same tick.
setTimeout(() => {
// double deferred because on onUpdate/color picker shennanings
setTimeout(() => {
editable.focus();
});
});
}
};
const temporarilyDisableSubmit = () => {
const onPointerUp = (event: PointerEvent | FocusEvent) => {
window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
window.removeEventListener(EVENT.FOCUS, onPointerUp);
// needs to be deferred due to Safari
setTimeout(() => {
editable.onblur = onBlur;
});
focusEditable(event);
};
const disableBlurUntilNextPointerUp = () => {
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
// handle edge-case where pointerup doesn't fire e.g. due to user
// alt-tabbing away
window.addEventListener("blur", handleSubmit);
window.addEventListener(EVENT.FOCUS, onPointerUp);
};
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
const target = event?.target;
// ugly hack to close popups such as color picker when clicking back
// into the wysiwyg editor (it won't autoclose as blur won't trigger
// since we perpetually keep focus inside the wysiwyg)
if (target === editable && app.state.openPopup) {
app.setState({ openPopup: null });
}
// panning canvas
if (event.button === POINTER_BUTTON.WHEEL) {
// trying to pan by clicking inside text area itself -> handle here
@ -644,24 +681,18 @@ export const textWysiwyg = ({
event.preventDefault();
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
}
temporarilyDisableSubmit();
disableBlurUntilNextPointerUp();
return;
}
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
if (
((event.target instanceof HTMLElement ||
(event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
) &&
!isWritableElement(event.target)) ||
isPropertiesTrigger
event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}, .${CLASSES.PROPERTIES_POPOVER}`,
)
) {
temporarilyDisableSubmit();
disableBlurUntilNextPointerUp();
} else if (
event.target instanceof HTMLCanvasElement &&
// Vitest simply ignores stopPropagation, capture-mode, or rAF
@ -684,9 +715,11 @@ export const textWysiwyg = ({
const unbindUpdate = app.scene.onUpdate(() => {
updateWysiwygStyle();
const isPopupOpened = !!document.activeElement?.closest(
".properties-content",
CLASSES.PROPERTIES_POPOVER,
);
if (!isPopupOpened) {
// we need to keep this code path for safari (iPadOS) bs reasons
// (also Vitest)
editable.focus();
}
});
@ -704,8 +737,11 @@ export const textWysiwyg = ({
// because we need it to happen *after* the blur event from `pointerdown`)
editable.select();
}
bindBlurEvent();
focusEditable(null);
setTimeout(() => {
editable.onblur = onBlur;
});
console.log(">>>>>>>>", app.state.editingTextElement);
// reposition wysiwyg in case of canvas is resized. Using ResizeObserver
// is preferred so we catch changes from host, where window may not resize.
let observer: ResizeObserver | null = null;
@ -715,7 +751,7 @@ export const textWysiwyg = ({
});
observer.observe(canvas);
} else {
window.addEventListener("resize", updateWysiwygStyle);
window.addEventListener(EVENT.RESIZE, updateWysiwygStyle);
}
editable.onpointerdown = (event) => event.stopPropagation();
@ -723,9 +759,11 @@ export const textWysiwyg = ({
// rAF (+ capture to by doubly sure) so we don't catch te pointerdown that
// triggered the wysiwyg
requestAnimationFrame(() => {
window.addEventListener("pointerdown", onPointerDown, { capture: true });
window.addEventListener(EVENT.POINTER_DOWN, onPointerDown, {
capture: true,
});
});
window.addEventListener("beforeunload", handleSubmit);
window.addEventListener(EVENT.BEFORE_UNLOAD, handleSubmit);
excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);

View File

@ -635,7 +635,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-expanded="false"
aria-haspopup="dialog"
aria-label="Canvas background"
class="color-picker__button active-color properties-trigger"
class="color-picker__button active-color properties-popover-trigger"
data-state="closed"
style="--swatch-color: #ffffff;"
title="Show background color picker"

View File

@ -17,6 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
placeholder=" "
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;"
tabindex="0"
wrap="off"

View File

@ -38,6 +38,8 @@ import { pointFrom, pointRotateRads } from "../../../math";
import { cropElement } from "../../element/cropElement";
import type { ToolType } from "../../types";
const TEXT_EDITOR_SELECTOR = ".excalidraw-textEditorContainer > textarea";
// so that window.h is available when App.tsx is not imported as well.
createTestHook();
@ -477,12 +479,20 @@ export class UI {
pointFrom(0, 0),
pointFrom(width, height),
];
UI.clickTool(type);
if (type === "text") {
mouse.reset();
mouse.click(x, y);
const openedEditor =
document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
// NOTE this is a hack to make sure the editor is focused on edit
// which for some reason doesn't work in tests after latest changes.
// This means that a regression in wysiwyg editor might not be caught
// tests.
openedEditor?.focus();
} else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => {
mouse.reset();
@ -518,20 +528,25 @@ export class UI {
static async editText<
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
>(element: T, text: string) {
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const openedEditor =
document.querySelector<HTMLTextAreaElement>(textEditorSelector);
document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
if (!openedEditor) {
mouse.select(element);
Keyboard.keyPress(KEYS.ENTER);
}
const editor = await getTextEditor(textEditorSelector);
const editor = await getTextEditor();
if (!editor) {
throw new Error("Can't find wysiwyg text editor in the dom");
}
// NOTE this is a hack to make sure the editor is focused on edit
// which for some reason doesn't work in tests after latest changes.
// This means that a regression in wysiwyg editor might not be caught
// tests.
editor.focus();
fireEvent.input(editor, { target: { value: text } });
act(() => {
editor.blur();

View File

@ -1,7 +1,10 @@
import { waitFor } from "@testing-library/dom";
import { fireEvent } from "@testing-library/react";
export const getTextEditor = async (selector: string, waitForEditor = true) => {
export const getTextEditor = async (
selector = ".excalidraw-textEditorContainer > textarea",
waitForEditor = true,
) => {
const query = () => document.querySelector(selector) as HTMLTextAreaElement;
if (waitForEditor) {
await waitFor(() => expect(query()).not.toBe(null));

View File

@ -1126,7 +1126,7 @@ describe("multiple selection", () => {
expect(bottomArrowLabel.fontSize).toBeCloseTo(28 * scale);
});
it("resizes with text elements", async () => {
it.only("resizes with text elements", async () => {
const topText = UI.createElement("text", { position: 0 });
await UI.editText(topText, "lorem ipsum");