diff --git a/openless-all/app/src/components/Tooltip.tsx b/openless-all/app/src/components/Tooltip.tsx index b4e44ea7..c5729707 100644 --- a/openless-all/app/src/components/Tooltip.tsx +++ b/openless-all/app/src/components/Tooltip.tsx @@ -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; + +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(null); + const bubbleRef = useRef(null); const timerRef = useRef(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(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) => { + 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 保持锚点尺寸与子元素一致,同时避免引入额外布局差异。 {children} {pos != null && createPortal( - + {content} , document.body, @@ -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, @@ -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'); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 200bd48f..b071f3ae 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -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.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 77142167..722a64f8 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -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 でログインしてください。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 699966d4..7d6b04e6 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -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으로 로그인하세요.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 04b5a8df..3e7226c1 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -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 登录后再使用。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 0e316d29..fede84e2 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -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 登入後再使用。', diff --git a/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx b/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx index ee0ef786..db351824 100644 --- a/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx +++ b/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx @@ -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' @@ -150,7 +151,8 @@ export function ClaudeConsoleSection() { return ( - + + {t('settings.codingConsole.desc')} {expanded && ( diff --git a/openless-all/app/src/pages/settings/CodingAgentSection.tsx b/openless-all/app/src/pages/settings/CodingAgentSection.tsx index d54e83ce..0414a04e 100644 --- a/openless-all/app/src/pages/settings/CodingAgentSection.tsx +++ b/openless-all/app/src/pages/settings/CodingAgentSection.tsx @@ -55,7 +55,7 @@ export function CodingAgentSection() { return ( - {t('settings.codingAgent.title')} + {t('settings.codingAgent.title')} {t('settings.codingAgent.desc')} diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index e6281eb1..af909536 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -26,7 +26,7 @@ import type { import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { SelectLite } from '../../components/ui/SelectLite'; import { Card, Collapsible } from '../_atoms'; -import { SettingRow, Toggle, inputStyle, segmentedTrackStyle } from './shared'; +import { SectionTitle, SettingRow, Toggle, inputStyle, segmentedTrackStyle } from './shared'; import { MicrophoneSelect } from './MicrophoneSelect'; import { detectOS } from '../../components/WindowChrome'; @@ -182,11 +182,9 @@ export function RecordingInputSection() { return ( <> -
-
- {t('settings.recording.title')} -
-
+ + {t('settings.recording.title')} + {isHotkeyModeMigrationNoticeActive() && showDesktopHotkey && (
)} {showDesktopHotkey && ( - +
{choices.map(([v, l]) => (