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 { positionElementBesideCursor } from "./positionElementBesideCursor"; 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_GAP = 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) => { // `viewport.lastPosition` 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.viewport.lastPosition; 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(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 { left, top } = positionElementBesideCursor({ cursor: { x: clientX, y: clientY }, element: { width: element.offsetWidth, height: element.offsetHeight, }, container: container.getBoundingClientRect(), gap: CURSOR_HINT_GAP, }); element.style.transform = `translate(${left}px, ${top}px)`; }; updatePosition(app.viewport.lastPosition.x, app.viewport.lastPosition.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 (
{hint.content}
); };