Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 204 additions & 45 deletions openless-all/app/src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,87 +1,225 @@
// Tooltip.tsx — 统一的悬浮提示,macOS 菜单式「预热」行为:
//
// - 首次 hover:延迟 600ms 才出现 —— 日常操作扫过按钮不触发一堆提示;
// - 预热窗口内(某个 tooltip 正显示、或刚关闭 <300ms)再 hover 相邻目标:
// 0 延迟即时切换 —— 想连续查看各菜单功能说明时不用每个都等。
//
// 预热状态是模块级共享的,所有 Tooltip 实例天然联动。渲染用 portal + fixed
// 定位,不受祖先 overflow / transform / backdrop-filter 包含块影响。

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
/**
* 统一的悬浮提示,支持 hover、键盘 focus 和触摸触发。
*
* Tooltip 使用 portal + fixed 定位,不受父级 overflow、transform 或 backdrop-filter 影响。
*/
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type PointerEvent,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';

/** 首次 hover 到出现的延迟。 */
const WARM_DELAY_MS = 600;
/** tooltip 关闭后预热状态的存续窗口:这段时间内 hover 相邻目标即时显示。 */
/** tooltip 关闭后的预热窗口。 */
const WARM_LINGER_MS = 300;

let warmUntil = 0;

interface TooltipProps {
content: ReactNode;
/** 提示出现在锚点的哪一侧,默认 right(适合左侧栏菜单)。 */
/** 提示出现在锚点的哪一侧,默认 right。 */
placement?: 'right' | 'top' | 'bottom';
/** 整句说明允许换行,默认 nowrap。 */
wrap?: boolean;
/** 让标签本身成为可聚焦的 Tooltip 触发器。 */
focusable?: boolean;
children: ReactNode;
}

export function Tooltip({ content, placement = 'right', children }: TooltipProps) {
type TooltipPlacement = NonNullable<TooltipProps['placement']>;

interface AnchorRect {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
}

interface TooltipPosition {
anchor: AnchorRect;
placement: TooltipPlacement;
offsetX: number;
offsetY: number;
}

export function Tooltip({
content,
placement = 'right',
wrap = false,
focusable = false,
children,
}: TooltipProps) {
const anchorRef = useRef<HTMLSpanElement>(null);
const bubbleRef = useRef<HTMLSpanElement>(null);
const timerRef = useRef<number | null>(null);
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
const hoveredRef = useRef(false);
const focusedRef = useRef(false);
const tooltipId = useId();
const [pos, setPos] = useState<TooltipPosition | null>(null);

const show = () => {
const readAnchorRect = (): AnchorRect | null => {
const anchor = anchorRef.current;
if (!anchor) return;
if (!anchor) return null;
const rect = anchor.getBoundingClientRect();
if (placement === 'right') {
setPos({ x: rect.right + 10, y: rect.top + rect.height / 2 });
} else if (placement === 'top') {
setPos({ x: rect.left + rect.width / 2, y: rect.top - 8 });
} else {
setPos({ x: rect.left + rect.width / 2, y: rect.bottom + 8 });
return {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
};
};

const show = () => {
const anchor = readAnchorRect();
if (!anchor) return;
setPos({ anchor, placement, offsetX: 0, offsetY: 0 });
};

const clearTimer = () => {
if (timerRef.current == null) return;
window.clearTimeout(timerRef.current);
timerRef.current = null;
};

const scheduleShow = () => {
clearTimer();
if (Date.now() < warmUntil) {
show();
return;
}
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
show();
}, WARM_DELAY_MS);
};

const hide = () => {
if (timerRef.current != null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
clearTimer();
setPos(prev => {
if (prev) warmUntil = Date.now() + WARM_LINGER_MS;
return null;
});
};

const hideIfInactive = () => {
if (!hoveredRef.current && !focusedRef.current) hide();
};

const onEnter = () => {
if (Date.now() < warmUntil) {
hoveredRef.current = true;
scheduleShow();
};

const onLeave = () => {
hoveredRef.current = false;
hideIfInactive();
};

const onFocus = () => {
focusedRef.current = true;
clearTimer();
show();
};

const onBlur = () => {
focusedRef.current = false;
hideIfInactive();
};

const onPointerDown = (event: PointerEvent<HTMLSpanElement>) => {
if (event.pointerType === 'touch') {
clearTimer();
show();
return;
}
timerRef.current = window.setTimeout(show, WARM_DELAY_MS);
hide();
};

useLayoutEffect(() => {
const bubble = bubbleRef.current;
if (!bubble || !pos) return;

const margin = 8;
const bubbleRect = bubble.getBoundingClientRect();
let nextPlacement = pos.placement;

if (
pos.placement === 'bottom'
&& bubbleRect.bottom > window.innerHeight - margin
&& pos.anchor.top - 8 - bubbleRect.height >= margin
) {
nextPlacement = 'top';
} else if (
pos.placement === 'top'
&& bubbleRect.top < margin
&& pos.anchor.bottom + 8 + bubbleRect.height <= window.innerHeight - margin
) {
nextPlacement = 'bottom';
}

if (nextPlacement !== pos.placement) {
setPos({ ...pos, placement: nextPlacement, offsetX: 0, offsetY: 0 });
return;
}

const offsetX = Math.max(margin - bubbleRect.left, 0)
- Math.max(bubbleRect.right - (window.innerWidth - margin), 0);
const offsetY = Math.max(margin - bubbleRect.top, 0)
- Math.max(bubbleRect.bottom - (window.innerHeight - margin), 0);

if (Math.abs(offsetX) > 0.5 || Math.abs(offsetY) > 0.5) {
setPos({
...pos,
offsetX: pos.offsetX + offsetX,
offsetY: pos.offsetY + offsetY,
});
}
}, [pos]);

useEffect(
() => () => {
if (timerRef.current != null) window.clearTimeout(timerRef.current);
clearTimer();
},
[],
);

return (
// display:grid 单格包装:span 尺寸与子元素一致(display:contents 的 rect 恒为 0
// 无法定位),又不给 flex/grid 父容器引入额外的布局盒差异。
// display:grid 保持锚点尺寸与子元素一致,同时避免引入额外布局差异。
<span
ref={anchorRef}
onMouseEnter={onEnter}
onMouseLeave={hide}
onMouseDown={hide}
onMouseLeave={onLeave}
onFocus={onFocus}
onBlur={onBlur}
onPointerDown={onPointerDown}
tabIndex={focusable ? 0 : undefined}
aria-describedby={focusable && pos != null ? tooltipId : undefined}
style={{ display: 'grid' }}
>
{children}
{pos != null &&
createPortal(
<span role="tooltip" style={{ ...bubbleStyle, ...placementStyle(placement, pos) }}>
<span
ref={bubbleRef}
id={tooltipId}
role="tooltip"
style={{
...bubbleStyle,
...(wrap ? wrapBubbleStyle : null),
...placementStyle(pos),
}}
>
{content}
</span>,
document.body,
Expand All @@ -90,20 +228,33 @@ export function Tooltip({ content, placement = 'right', children }: TooltipProps
);
}

function placementStyle(placement: 'right' | 'top' | 'bottom', pos: { x: number; y: number }): CSSProperties {
function placementStyle(pos: TooltipPosition): CSSProperties {
const { anchor, placement, offsetX, offsetY } = pos;
if (placement === 'right') {
return { left: pos.x, top: pos.y, transform: 'translateY(-50%)' };
return {
left: anchor.right + 10 + offsetX,
top: anchor.top + anchor.height / 2 + offsetY,
transform: 'translateY(-50%)',
};
}
if (placement === 'top') {
return { left: pos.x, top: pos.y, transform: 'translate(-50%, -100%)' };
return {
left: anchor.left + anchor.width / 2 + offsetX,
top: anchor.top - 8 + offsetY,
transform: 'translate(-50%, -100%)',
};
}
return { left: pos.x, top: pos.y, transform: 'translateX(-50%)' };
return {
left: anchor.left + anchor.width / 2 + offsetX,
top: anchor.bottom + 8 + offsetY,
transform: 'translateX(-50%)',
};
}

const bubbleStyle: CSSProperties = {
position: 'fixed',
zIndex: 1000,
maxWidth: 260,
maxWidth: 'min(260px, calc(100vw - 16px))',
padding: '5px 9px',
borderRadius: 8,
fontSize: 11.5,
Expand All @@ -120,12 +271,20 @@ const bubbleStyle: CSSProperties = {
animation: 'ol-tooltip-in 0.12s ease-out both',
};

const TOOLTIP_KEYFRAMES = `
@keyframes ol-tooltip-in {
from { opacity: 0; scale: .96; }
to { opacity: 1; scale: 1; }
}
`;
const wrapBubbleStyle: CSSProperties = {
whiteSpace: 'normal',
width: 'max-content',
maxWidth: 'min(280px, calc(100vw - 16px))',
maxHeight: 'calc(100vh - 16px)',
overflowY: 'auto',
};

const TOOLTIP_KEYFRAMES = [
'@keyframes ol-tooltip-in {',
' from { opacity: 0; scale: .96; }',
' to { opacity: 1; scale: 1; }',
'}',
].join('\n');

if (typeof document !== 'undefined' && !document.getElementById('ol-tooltip-style')) {
const tag = document.createElement('style');
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@ export const en: typeof zhCN = {
comingSoonNote: 'Config is saved now; hotkey triggering and the execution flow land in a later version.',
hotkeyHint: 'When enabled, hold the shortcut below to talk; release it and Claude shows the result in the capsule.',
voiceHotkey: 'Hold-to-talk key',
voiceHotkeyDesc: 'Hold to talk, release to run. Supports Ctrl/Option/Fn single keys.',
voiceHotkeyDesc: 'Hold to talk, release to run. Supports Ctrl/Option/Fn single keys. See the Advanced settings page for what it does.',
provider: 'Agent backend',
opencodeReady: 'OpenCode v{{version}} detected.',
opencodeMissing: 'opencode command not found. Install it (npm i -g opencode-ai) and sign in with opencode auth login before use.',
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ export const ja: typeof zhCN = {
comingSoonNote: '設定はすぐ保存されます。ホットキー起動と実行フローは今後のバージョンで対応。',
hotkeyHint: '有効にすると、下のショートカットを押しながら話し、離すと Claude の結果がカプセルに表示されます。',
voiceHotkey: '押しながら話すキー',
voiceHotkeyDesc: '押して話す、離して実行。Ctrl/Option/Fn などの単キー対応。',
voiceHotkeyDesc: '押して話す、離して実行。Ctrl/Option/Fn などの単キー対応。機能の説明は「詳細」設定ページを参照。',
provider: 'Agent バックエンド',
opencodeReady: 'OpenCode v{{version}} を検出しました。',
opencodeMissing: 'opencode コマンドが見つかりません。先にインストール(npm i -g opencode-ai)して opencode auth login でログインしてください。',
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ export const ko: typeof zhCN = {
comingSoonNote: '설정은 즉시 저장됩니다. 단축키 트리거와 실행 흐름은 이후 버전에서 제공됩니다.',
hotkeyHint: '켜면 아래 단축키를 누른 채 말하고, 놓으면 Claude 결과가 캡슐에 표시됩니다.',
voiceHotkey: '누르고 말하기 키',
voiceHotkeyDesc: '누르고 말하고 놓으면 실행. Ctrl/Option/Fn 단일 키 지원.',
voiceHotkeyDesc: '누르고 말하고 놓으면 실행. Ctrl/Option/Fn 단일 키 지원. 기능 설명은 「고급」 설정 페이지 참조.',
provider: 'Agent 백엔드',
opencodeReady: 'OpenCode v{{version}} 감지됨.',
opencodeMissing: 'opencode 명령을 찾을 수 없습니다. 먼저 설치(npm i -g opencode-ai)하고 opencode auth login으로 로그인하세요.',
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@ export const zhCN = {
comingSoonNote: '配置即时保存;热键触发与执行链路随后续版本生效。',
hotkeyHint: '开启后,按住下方快捷键说话,松开后 Claude 处理并把结果显示在胶囊里。',
voiceHotkey: '按住说话键',
voiceHotkeyDesc: '按住说话、松开执行。支持 Ctrl/Option/Fn 等单键。',
voiceHotkeyDesc: '按住说话、松开执行。支持 Ctrl/Option/Fn 等单键。功能说明参见「高级」设置页。',
provider: 'Agent 后端',
opencodeReady: '已检测到 OpenCode v{{version}}。',
opencodeMissing: '未检测到 opencode 命令。请先安装(npm i -g opencode-ai)并用 opencode auth login 登录后再使用。',
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@ export const zhTW: typeof zhCN = {
comingSoonNote: '設定即時儲存;熱鍵觸發與執行鏈路隨後續版本生效。',
hotkeyHint: '開啟後,按住下方快捷鍵說話,放開後 Claude 處理並把結果顯示在膠囊裡。',
voiceHotkey: '按住說話鍵',
voiceHotkeyDesc: '按住說話、放開執行。支援 Ctrl/Option/Fn 等單鍵。',
voiceHotkeyDesc: '按住說話、放開執行。支援 Ctrl/Option/Fn 等單鍵。功能說明參見「進階」設定頁。',
provider: 'Agent 後端',
opencodeReady: '已偵測到 OpenCode v{{version}}。',
opencodeMissing: '未偵測到 opencode 指令。請先安裝(npm i -g opencode-ai)並用 opencode auth login 登入後再使用。',
Expand Down
7 changes: 5 additions & 2 deletions openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type CodingAgentEvent,
type CodingAgentPermissionMode,
} from '../../lib/ipc'
import { Tooltip } from '../../components/Tooltip'
import { Btn, Card } from '../_atoms'
import { SectionDesc, SectionTitle, SettingRow, inputStyle } from './shared'

Expand Down Expand Up @@ -150,15 +151,17 @@ export function ClaudeConsoleSection() {

return (
<Card>
<button
<Tooltip content={t('settings.codingConsole.desc')} wrap placement="bottom">
<button
onClick={() => setExpanded(v => !v)}
style={{ all: 'unset', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}
>
<SectionTitle>{t('settings.codingConsole.title')}</SectionTitle>
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--ol-ink-4)' }}>
{expanded ? `${t('common.hide')} ▴` : `${t('common.show')} ▾`}
</span>
</button>
</button>
</Tooltip>
<SectionDesc>{t('settings.codingConsole.desc')}</SectionDesc>

{expanded && (
Expand Down
Loading
Loading