Файли
openidea/src/ui/Tooltip.tsx
T
l0ng-ai 76d33f0306 feat(ui): IDEA-style message dialogs, project widget popup and Chinese UI text
Message dialogs: `Dialog` gets an `icon` (question / info / warning / error)
that renders IDEA's alert layout: no title bar, a 32px icon, bold title,
text, checkbox under it. Every confirmation helper (prompt, VCS, services,
project tree, keymap, LSP choices, run, database, Replace All) uses it. Open
Project matches IDEA: "This Window" is the default and has focus. The Code
Analysis and TODO commit checks default to Review.

Project widget: the title bar project button opens a popup like IDEA's
ProjectWidget, with Open… / Clone Repository…, the projects open in other
windows (new `window_open_projects` command) and recent projects, filtered by
typing.

Chinese UI: UI text stays written in English and is translated through a
source-text catalog (`locales/text/zh.json`, about 2300 strings). The UI
primitives (buttons, checkboxes, selects, tabs, dialogs, menus, tooltips,
toasts, settings labels and hints) translate the strings they are given.
Action titles and categories are localised in the registry, and static JSX
text uses `<T>`, so text follows a locale switch without a reload. The
Settings window now syncs the locale too. Settings search matches both
languages.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-23 22:18:50 +08:00

142 рядки
4.5 KiB
TypeScript

/**
* Tooltips are attribute-driven: any element with `data-tooltip` (and optional
* `data-tooltip-shortcut`) gets one, shown by the single `<TooltipHost/>` after
* 600ms of hover. Hidden on scroll, key press, pointer down or leave.
*/
import { cloneElement, isValidElement, useEffect, useRef, useState, type ReactElement } from "react";
import { createPortal } from "react-dom";
import { computePosition, flip, offset, shift } from "@floating-ui/react-dom";
import styles from "./Tooltip.module.css";
import { tx } from "@/platform/i18n";
export const TOOLTIP_DELAY = 600;
const WARM_MS = 400;
export function tooltipAttrs(text: string | undefined, shortcut?: string): Record<string, string> {
if (!text) return {};
const attrs: Record<string, string> = { "data-tooltip": text };
if (shortcut) attrs["data-tooltip-shortcut"] = shortcut;
return attrs;
}
export interface TooltipProps {
content: string;
shortcut?: string;
children: ReactElement;
}
/** Attaches a tooltip to its single child element. */
export function Tooltip({ content, shortcut, children }: TooltipProps) {
if (!isValidElement(children)) return children;
return cloneElement(children, tooltipAttrs(content, shortcut));
}
interface Shown {
text: string;
shortcut?: string;
x: number;
y: number;
placed: boolean;
}
export function TooltipHost() {
const [shown, setShown] = useState<Shown | null>(null);
const tipRef = useRef<HTMLDivElement>(null);
const anchorRef = useRef<HTMLElement | null>(null);
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined;
let lastHidden = 0;
let visible = false;
const hide = () => {
clearTimeout(timer);
if (visible) lastHidden = performance.now();
visible = false;
anchorRef.current = null;
setShown(null);
};
const show = (el: HTMLElement) => {
const text = tx(el.dataset.tooltip);
if (!text || !el.isConnected) return;
visible = true;
anchorRef.current = el;
setShown({ text, shortcut: el.dataset.tooltipShortcut, x: -9999, y: -9999, placed: false });
};
const onOver = (e: PointerEvent) => {
const el = (e.target as Element | null)?.closest?.<HTMLElement>("[data-tooltip]");
if (!el) return;
if (el === anchorRef.current) return;
clearTimeout(timer);
if (visible) {
show(el);
return;
}
const warm = performance.now() - lastHidden < WARM_MS;
timer = setTimeout(() => show(el), warm ? 0 : TOOLTIP_DELAY);
anchorRef.current = el;
};
const onOut = (e: PointerEvent) => {
const el = anchorRef.current;
if (!el) return;
const to = e.relatedTarget as Node | null;
if (to && el.contains(to)) return;
hide();
};
document.addEventListener("pointerover", onOver);
document.addEventListener("pointerout", onOut);
document.addEventListener("pointerdown", hide, true);
document.addEventListener("keydown", hide, true);
document.addEventListener("scroll", hide, true);
document.addEventListener("wheel", hide, { capture: true, passive: true });
window.addEventListener("blur", hide);
return () => {
clearTimeout(timer);
document.removeEventListener("pointerover", onOver);
document.removeEventListener("pointerout", onOut);
document.removeEventListener("pointerdown", hide, true);
document.removeEventListener("keydown", hide, true);
document.removeEventListener("scroll", hide, true);
document.removeEventListener("wheel", hide, true);
window.removeEventListener("blur", hide);
};
}, []);
useEffect(() => {
if (!shown || shown.placed) return;
const anchor = anchorRef.current;
const tip = tipRef.current;
if (!anchor || !tip) return;
let cancelled = false;
void computePosition(anchor, tip, {
placement: "bottom",
strategy: "fixed",
middleware: [offset(6), flip({ padding: 6 }), shift({ padding: 6 })],
}).then(({ x, y }) => {
if (!cancelled) setShown((s) => (s ? { ...s, x, y, placed: true } : s));
});
return () => {
cancelled = true;
};
}, [shown]);
if (!shown) return null;
return createPortal(
<div
ref={tipRef}
role="tooltip"
className={styles.tooltip}
data-placed={shown.placed || undefined}
style={{ left: shown.x, top: shown.y }}
>
<span>{shown.text}</span>
{shown.shortcut && <span className={styles.shortcut}>{shown.shortcut}</span>}
</div>,
document.body,
);
}