feat(editor): show cursor hint when switching arrow types (#11608)

This commit is contained in:
David Luzar
2026-07-07 12:55:32 +02:00
committed by GitHub
parent 9357f98a9b
commit aaa14e9df6
5 changed files with 478 additions and 8 deletions
+16 -8
View File
@@ -460,6 +460,7 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases";
import NewElementCanvas from "./canvases/NewElementCanvas";
import { isPointHittingLink } from "./hyperlink/helpers";
import { CursorHint, CursorHints } from "./CursorHint";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { AppStateObserver, type OnStateChange } from "./AppStateObserver";
@@ -747,6 +748,7 @@ class App extends React.Component<AppProps, AppState> {
laserTrails = new LaserTrails(this);
eraserTrail = new EraserTrail(this);
lassoTrail = new LassoTrail(this);
cursorHints = new CursorHints(this);
onChangeEmitter = new Emitter<
[
@@ -2269,6 +2271,7 @@ class App extends React.Component<AppProps, AppState> {
this.eraserTrail,
]}
/>
<CursorHint />
{selectedElements.length === 1 &&
this.state.openDialog?.name !==
"elementLinkSelector" &&
@@ -5310,14 +5313,19 @@ class App extends React.Component<AppProps, AppState> {
);
}
if (shape === "arrow" && this.state.activeTool.type === "arrow") {
this.setState((prevState) => ({
currentItemArrowType:
prevState.currentItemArrowType === ARROW_TYPE.sharp
? ARROW_TYPE.round
: prevState.currentItemArrowType === ARROW_TYPE.round
? ARROW_TYPE.elbow
: ARROW_TYPE.sharp,
}));
const nextArrowType =
this.state.currentItemArrowType === ARROW_TYPE.sharp
? ARROW_TYPE.round
: this.state.currentItemArrowType === ARROW_TYPE.round
? ARROW_TYPE.elbow
: ARROW_TYPE.sharp;
this.setState({ currentItemArrowType: nextArrowType });
this.cursorHints.onArrowTypeCycled(nextArrowType);
} else if (shape === "arrow" || shape === "line") {
this.cursorHints.onToolShortcut(
shape,
/^\d$/.test(event.key) ? "digit" : "letter",
);
}
if (shape === "lasso" && this.state.activeTool.type === "laser") {
@@ -0,0 +1,43 @@
.excalidraw {
.CursorHint {
position: absolute;
top: 0;
left: 0;
z-index: var(--zIndex-cursorHint);
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
// dark-theme island colors, in both themes (keeps the hint high-contrast
// over the mostly-light canvas)
background-color: #232329;
color: #e3e3e8;
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-md);
animation: CursorHint-fade-in 100ms ease-out;
// fade-out duration (keep in sync with CursorHint.tsx)
transition: opacity 100ms ease-out;
&--fade-out {
opacity: 0;
}
svg {
width: 1rem;
height: 1rem;
}
}
@keyframes CursorHint-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
@@ -0,0 +1,208 @@
import clsx from "clsx";
import { useLayoutEffect, useEffect, useRef, useState } from "react";
import { ARROW_TYPE, EVENT } from "@excalidraw/common";
import { atom, useAtom } from "../editor-jotai";
import { useApp, useExcalidrawContainer } from "./App";
import {
LineIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
} from "./icons";
import "./CursorHint.scss";
import type { AppClassProperties, AppState } from "../types";
/** how long the hint stays visible before it starts fading out */
const CURSOR_HINT_DURATION = 700;
/** fade-out duration (keep in sync with CursorHint.scss) */
const CURSOR_HINT_FADE_DURATION = 100;
/** distance from the pointer so the hint isn't covered by the cursor */
const CURSOR_HINT_OFFSET = 16;
/**
* While a recently shown hint is still fresh in memory, tool-switch hints
* are suppressed (repeatedly re-picking a tool you just used doesn't need
* the reminder). Cycling arrow types and numeric shortcuts bypass this.
*/
export const CURSOR_HINT_COOLDOWN = 5 * 60 * 1000;
export const cursorHintAtom = atom<{
content: React.ReactNode;
/** unique per trigger so a re-trigger restarts the hide timer */
nonce: number;
} | null>(null);
const getArrowTypeIcon = (arrowType: AppState["currentItemArrowType"]) =>
arrowType === ARROW_TYPE.elbow
? elbowArrowIcon
: arrowType === ARROW_TYPE.round
? roundArrowIcon
: sharpArrowIcon;
/**
* Owns the cursor-hint policy. App reports semantic interaction events
* (what the user did); all decisions about whether and what to show —
* cooldown, bypasses, hint content — are made here.
*/
export class CursorHints {
private app: AppClassProperties;
private lastShownAt = 0;
constructor(app: AppClassProperties) {
this.app = app;
}
/**
* Shows a transient tooltip next to the cursor, hidden automatically
* after a short delay. Repeated calls replace the content and restart
* the timer.
*/
show = (content: React.ReactNode) => {
// `lastViewportPosition` stays at its initial (0, 0) until the first
// pointermove, so in pointer-less flows (e.g. keyboard-only session so
// far) we don't know where to show the hint — don't show it at all
const { x, y } = this.app.lastViewportPosition;
if (x === 0 && y === 0) {
return;
}
this.lastShownAt = Date.now();
this.app.updateEditorAtom(cursorHintAtom, {
content,
nonce: Math.random(),
});
};
private isOnCooldown = () =>
Date.now() - this.lastShownAt < CURSOR_HINT_COOLDOWN;
/** arrow type cycled via shortcut (arrow tool already active) */
onArrowTypeCycled = (arrowType: AppState["currentItemArrowType"]) => {
// always worth hinting — you need to see what you switched to
this.show(getArrowTypeIcon(arrowType));
};
/** arrow/line tool picked via keyboard shortcut */
onToolShortcut = (tool: "arrow" | "line", source: "letter" | "digit") => {
// digit shortcuts always hint (often pressed blind, without certainty
// which tool the digit maps to); letter shortcuts only after a
// cooldown — re-picking a tool you used moments ago doesn't need the
// reminder
if (source === "digit" || !this.isOnCooldown()) {
this.show(
tool === "line"
? LineIcon
: getArrowTypeIcon(this.app.state.currentItemArrowType),
);
}
};
}
/**
* Transient tooltip shown next to the cursor for added affordance after
* actions that have no other visual feedback near the pointer (e.g. cycling
* arrow types via shortcut). Trigger via `app.cursorHints`.
*/
export const CursorHint = () => {
const [hint, setHint] = useAtom(cursorHintAtom);
const app = useApp();
const { container } = useExcalidrawContainer();
const hintRef = useRef<HTMLDivElement>(null);
const [isFadingOut, setIsFadingOut] = useState(false);
const nonce = hint?.nonce;
useLayoutEffect(() => {
if (nonce == null || !container) {
return;
}
const updatePosition = (clientX: number, clientY: number) => {
const element = hintRef.current;
if (!element) {
return;
}
const rect = container.getBoundingClientRect();
let x = clientX - rect.left + CURSOR_HINT_OFFSET;
let y = clientY - rect.top + CURSOR_HINT_OFFSET;
// flip to the other side of the pointer when overflowing the container
if (x + element.offsetWidth > rect.width) {
x = clientX - rect.left - CURSOR_HINT_OFFSET - element.offsetWidth;
}
if (y + element.offsetHeight > rect.height) {
y = clientY - rect.top - CURSOR_HINT_OFFSET - element.offsetHeight;
}
element.style.transform = `translate(${x}px, ${y}px)`;
};
updatePosition(app.lastViewportPosition.x, app.lastViewportPosition.y);
const onPointerMove = (event: PointerEvent) => {
updatePosition(event.clientX, event.clientY);
};
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
return () => {
window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
};
}, [nonce, container, app]);
// hints are transient — don't let a stale one reappear on remount
useEffect(() => {
return () => {
setHint(null);
};
}, [setHint]);
useEffect(() => {
if (nonce == null) {
return;
}
setIsFadingOut(false);
const fadeTimer = window.setTimeout(() => {
setIsFadingOut(true);
}, CURSOR_HINT_DURATION);
const hideTimer = window.setTimeout(() => {
setHint(null);
}, CURSOR_HINT_DURATION + CURSOR_HINT_FADE_DURATION);
// hide immediately when the user starts interacting (e.g. drawing)
// so the hint doesn't get in the way
const onPointerDown = () => {
setHint(null);
};
window.addEventListener(EVENT.POINTER_DOWN, onPointerDown, {
capture: true,
});
return () => {
window.clearTimeout(fadeTimer);
window.clearTimeout(hideTimer);
window.removeEventListener(EVENT.POINTER_DOWN, onPointerDown, {
capture: true,
});
};
}, [nonce, setHint]);
if (!hint) {
return null;
}
return (
<div
ref={hintRef}
className={clsx("CursorHint", {
"CursorHint--fade-out": isFadingOut,
})}
data-testid="cursor-hint"
>
{hint.content}
</div>
);
};
+1
View File
@@ -11,6 +11,7 @@
--zIndex-eyeDropperBackdrop: 5;
--zIndex-eyeDropperPreview: 6;
--zIndex-hyperlinkContainer: 7;
--zIndex-cursorHint: 8;
--zIndex-ui-bottom: 60;
--zIndex-ui-context-menu: 90;
@@ -0,0 +1,210 @@
import React from "react";
import { ARROW_TYPE, KEYS } from "@excalidraw/common";
import { CURSOR_HINT_COOLDOWN } from "../components/CursorHint";
import { Excalidraw } from "../index";
import { Keyboard, UI } from "./helpers/ui";
import {
GlobalTestState,
fireEvent,
mockBoundingClientRect,
render,
restoreOriginalGetBoundingClientRect,
waitFor,
} from "./test-utils";
const { h } = window;
const getCursorHint = () =>
document.querySelector<HTMLElement>("[data-testid='cursor-hint']");
// dispatch on the canvas so the event propagates to the container's
// keydown handler (`handleKeyboardGlobally` is disabled by default)
const pressKey = (key: string) => {
Keyboard.keyPress(key, GlobalTestState.interactiveCanvas);
};
describe("cursor hint", () => {
beforeEach(async () => {
await render(<Excalidraw />);
// hints are only shown once the pointer position is known
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, {
clientX: 100,
clientY: 50,
});
});
it("shows hint when cycling arrow types via shortcut, and auto-hides it", async () => {
UI.clickTool("arrow");
expect(getCursorHint()).toBeNull();
// default arrow type is `round`, so cycling continues to `elbow`
pressKey(KEYS.A);
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
expect(getCursorHint()).not.toBeNull();
expect(getCursorHint()!.querySelector("svg")).not.toBeNull();
pressKey(KEYS.A);
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.sharp);
expect(getCursorHint()).not.toBeNull();
await waitFor(() => {
expect(getCursorHint()).toBeNull();
});
});
it("shows hint when switching to the arrow tool via shortcut", () => {
UI.clickTool("rectangle");
pressKey(KEYS.A);
expect(h.state.activeTool.type).toBe("arrow");
expect(getCursorHint()).not.toBeNull();
});
it("shows hint when picking the line tool via shortcut", () => {
pressKey(KEYS.L);
expect(h.state.activeTool.type).toBe("line");
expect(getCursorHint()).not.toBeNull();
});
it("shows hint when picking a tool via numeric shortcut", () => {
pressKey("6");
expect(h.state.activeTool.type).toBe("line");
expect(getCursorHint()).not.toBeNull();
});
it("hides hint immediately on pointerdown", () => {
UI.clickTool("arrow");
pressKey(KEYS.A);
expect(getCursorHint()).not.toBeNull();
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 100,
clientY: 100,
});
expect(getCursorHint()).toBeNull();
// complete the gesture so no drag state leaks into other tests
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
clientX: 100,
clientY: 100,
});
});
it("does not show hint when picking a tool via toolbar", () => {
UI.clickTool("arrow");
expect(h.state.activeTool.type).toBe("arrow");
expect(getCursorHint()).toBeNull();
});
describe("cooldown", () => {
// dismiss outside the canvas so we don't start drawing an element
// (which would block subsequent tool-switch shortcuts)
const dismissHint = () => {
fireEvent.pointerDown(document.body);
expect(getCursorHint()).toBeNull();
};
const expireCooldown = () => {
(h.app.cursorHints as any).lastShownAt =
Date.now() - CURSOR_HINT_COOLDOWN;
};
it("suppresses tool-switch hint during cooldown", () => {
pressKey(KEYS.A);
expect(getCursorHint()).not.toBeNull();
dismissHint();
pressKey("v");
pressKey(KEYS.A);
expect(h.state.activeTool.type).toBe("arrow");
expect(getCursorHint()).toBeNull();
});
it("shows tool-switch hint again once cooldown expires", () => {
pressKey(KEYS.A);
dismissHint();
pressKey("v");
expireCooldown();
pressKey(KEYS.A);
expect(getCursorHint()).not.toBeNull();
});
it("cycling arrow types bypasses cooldown", () => {
pressKey(KEYS.A);
dismissHint();
// arrow tool already active -> cycles
pressKey(KEYS.A);
expect(getCursorHint()).not.toBeNull();
});
it("numeric shortcuts bypass cooldown", () => {
pressKey(KEYS.A);
dismissHint();
pressKey("v");
pressKey("6");
expect(h.state.activeTool.type).toBe("line");
expect(getCursorHint()).not.toBeNull();
dismissHint();
pressKey("v");
pressKey("5");
expect(h.state.activeTool.type).toBe("arrow");
expect(getCursorHint()).not.toBeNull();
});
it("letter shortcut to another tool is also suppressed during cooldown", () => {
pressKey(KEYS.A);
dismissHint();
pressKey(KEYS.L);
expect(h.state.activeTool.type).toBe("line");
expect(getCursorHint()).toBeNull();
});
});
it("does not show hint when pointer position is unknown", () => {
// simulate no pointermove having happened yet
h.app.lastViewportPosition.x = 0;
h.app.lastViewportPosition.y = 0;
UI.clickTool("arrow");
pressKey(KEYS.A);
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
expect(getCursorHint()).toBeNull();
});
it("tracks pointer position while visible", () => {
mockBoundingClientRect({ width: 1920, height: 1080 });
try {
UI.clickTool("arrow");
pressKey(KEYS.A);
fireEvent.pointerMove(window, { clientX: 200, clientY: 100 });
// 16px offset from the pointer
expect(getCursorHint()!.style.transform).toBe("translate(216px, 116px)");
} finally {
restoreOriginalGetBoundingClientRect();
}
});
});