feat(settings): 设置项悬停显示功能说明,降低 Less Computer / 录音胶囊等的理解门槛 (#777)

* feat(settings): 设置项悬停显示功能说明,降低 Less Computer / 录音胶囊等的理解门槛

页面瘦身后设置页描述小字全部隐藏,Less Computer、录音胶囊这类光看名字
猜不出用途的功能只剩一个标签,理解成本高。本次把已有的 desc 文案以
Tooltip 形式挂回标签文字上:

- Tooltip 新增 wrap 模式,整句说明允许换行(默认 nowrap 不变)
- SettingRow 的 desc 恢复生效:悬停标签文字弹出说明,虚线下划线 +
  help 光标做「可悬停」暗示,布局保持紧凑
- SectionTitle 新增 hint,Less Computer / Claude 控制台 / 录音与输入
  三个板块标题接上已有的 desc 文案
- 录音与输入里录音胶囊 / 录音方式 / 首选麦克风 / 录音时静音 /
  插入剪贴板等行补传现成的 i18n desc

文案全部复用现有 i18n key,五种语言无需新增翻译。

* feat(settings): Less Computer 快捷键提示补一句「功能说明参见高级设置页」

通用页 Less Computer 那行原提示只讲快捷键用法,补一句指向「高级」设置页
的功能说明入口,降低理解门槛。5 语言同步。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(settings): make hints accessible and viewport-safe

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-07-12 21:44:09 +08:00
1 parent 695813074f
commit 414acf0dc7
10 files changed
+271 -87

No files matched your search

+204 -45
View File
@@ -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 readAnchorRect = (): AnchorRect | null => {
const anchor = anchorRef.current;
if (!anchor) return null;
const rect = anchor.getBoundingClientRect();
return {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
};
};
const show = () => {
const anchor = anchorRef.current;
const anchor = readAnchorRect();
if (!anchor) return;
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 });
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,
@@ -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');
+1 -1
View File
@@ -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.',
+1 -1
View File
@@ -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 でログインしてください。',
+1 -1
View File
@@ -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으로 로그인하세요.',
+1 -1
View File
@@ -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 登录后再使用。',
+1 -1
View File
@@ -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 登入後再使用。',
@@ -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 (
<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%' }}
>
@@ -158,7 +160,8 @@ export function ClaudeConsoleSection() {
<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 && (
@@ -55,7 +55,7 @@ export function CodingAgentSection() {
return (
<Card>
<SectionTitle>{t('settings.codingAgent.title')}</SectionTitle>
<SectionTitle hint={t('settings.codingAgent.desc')}>{t('settings.codingAgent.title')}</SectionTitle>
<SectionDesc>{t('settings.codingAgent.desc')}</SectionDesc>
<SettingRow label={t('settings.codingAgent.enable')} desc={t('settings.codingAgent.hotkeyHint')}>
@@ -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';
@@ -183,11 +183,9 @@ export function RecordingInputSection() {
return (
<>
<Card>
<div style={{ marginBottom: 6 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)', letterSpacing: '-0.01em' }}>
{t('settings.recording.title')}
</div>
</div>
<SectionTitle hint={t('settings.recording.desc')}>
{t('settings.recording.title')}
</SectionTitle>
{isHotkeyModeMigrationNoticeActive() && showDesktopHotkey && (
<div
style={{
@@ -221,7 +219,7 @@ export function RecordingInputSection() {
</SettingRow>
)}
{showDesktopHotkey && (
<SettingRow label={t('settings.recording.modeLabel')}>
<SettingRow label={t('settings.recording.modeLabel')} desc={t('settings.recording.modeDesc')}>
<div style={segmentedTrackStyle}>
{choices.map(([v, l]) => (
<button
@@ -243,7 +241,7 @@ export function RecordingInputSection() {
</div>
</SettingRow>
)}
<SettingRow label={t('settings.recording.microphoneLabel')}>
<SettingRow label={t('settings.recording.microphoneLabel')} desc={t('settings.recording.microphoneDesc')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<MicrophoneSelect
devices={microphoneDevices}
@@ -259,11 +257,11 @@ export function RecordingInputSection() {
</div>
</SettingRow>
{os !== 'linux' && !isAndroid && (
<SettingRow label={t('settings.recording.capsuleLabel')}>
<SettingRow label={t('settings.recording.capsuleLabel')} desc={t('settings.recording.capsuleDesc')}>
<Toggle on={prefs.showCapsule} onToggle={onShowCapsuleChange} />
</SettingRow>
)}
<SettingRow label={t('settings.recording.muteDuringRecordingLabel')}>
<SettingRow label={t('settings.recording.muteDuringRecordingLabel')} desc={t('settings.recording.muteDuringRecordingDesc')}>
<Toggle on={prefs.muteDuringRecording} onToggle={onMuteDuringRecordingChange} />
</SettingRow>
<SettingRow
@@ -305,11 +303,11 @@ export function RecordingInputSection() {
{/* ─── 插入与剪贴板(折叠,仅 macOS / Windows) ──────────────── */}
{showDesktopInsert && (
<Collapsible title={t('settings.recording.insertGroupTitle')}>
<SettingRow label={t('settings.recording.restoreClipboardLabel')}>
<SettingRow label={t('settings.recording.restoreClipboardLabel')} desc={t('settings.recording.restoreClipboardDesc')}>
<Toggle on={prefs.restoreClipboardAfterPaste} onToggle={onRestoreClipboardChange} />
</SettingRow>
{capability.adapter !== 'macEventTap' && (
<SettingRow label={t('settings.recording.pasteShortcutLabel')}>
<SettingRow label={t('settings.recording.pasteShortcutLabel')} desc={t('settings.recording.pasteShortcutDesc')}>
<SelectLite
value={prefs.pasteShortcut}
onChange={next => onPasteShortcutChange(next as PasteShortcut)}
+46 -22
View File
@@ -2,27 +2,44 @@
// AsrPresetId 也放在这里,让 settings/ 下各 section 都从同一处来源拿。
import type { CSSProperties, ReactNode } from "react"
import { Tooltip } from "../../components/Tooltip"
import { useMobileLayout } from "../../lib/useMobileLayout"
// 带说明的文字统一加虚线下划线 + help 光标,暗示「悬停可看解释」。
const hintableTextStyle: CSSProperties = {
cursor: "help",
textDecoration: "underline dotted",
textDecorationColor: "var(--ol-ink-4)",
textUnderlineOffset: 3,
}
export function SectionTitle({
children,
hint,
style,
}: {
children: ReactNode
/** 悬停在标题文字上时的功能说明,给 Less Computer 这类光看名字猜不出用途的板块。 */
hint?: string
style?: CSSProperties
}) {
const titleStyle: CSSProperties = {
fontSize: 14,
fontWeight: 600,
color: "var(--ol-ink)",
marginBottom: 6,
letterSpacing: "-0.01em",
...style,
}
if (!hint) {
return <div style={titleStyle}>{children}</div>
}
return (
<div
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--ol-ink)",
marginBottom: 6,
letterSpacing: "-0.01em",
...style,
}}
>
{children}
// display:flex 让 Tooltip 的锚点收缩到标题文字本身,提示贴着文字弹出。
<div style={{ ...titleStyle, display: "flex" }}>
<Tooltip content={hint} wrap placement="bottom" focusable>
<span style={hintableTextStyle}>{children}</span>
</Tooltip>
</div>
)
}
@@ -42,13 +59,21 @@ interface SettingRowProps {
controlWidth?: number | string
}
// 页面瘦身:不再渲染每行的描述小字(desc 仍保留在 props 里,调用点无需改、便于恢复)。
// 页面瘦身后描述小字不再常驻展示;desc 改为悬停在标签文字上时以 Tooltip 弹出,
// 布局保持紧凑的同时不牺牲可理解性。
export function SettingRow({
label,
desc,
children,
controlWidth,
}: SettingRowProps) {
const mobile = useMobileLayout()
const labelStyle: CSSProperties = {
fontSize: 13,
fontWeight: 500,
color: "var(--ol-ink)",
minWidth: 0,
}
return (
<div
style={{
@@ -60,16 +85,15 @@ export function SettingRow({
alignItems: "center",
}}
>
<div style={{ minWidth: 0, alignSelf: "center" }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--ol-ink)",
}}
>
{label}
</div>
{/* display:flex 让 Tooltip 锚点收缩到文字宽度,提示贴着文字弹出。 */}
<div style={{ minWidth: 0, alignSelf: "center", display: "flex" }}>
{desc ? (
<Tooltip content={desc} wrap placement="bottom" focusable>
<span style={{ ...labelStyle, ...hintableTextStyle }}>{label}</span>
</Tooltip>
) : (
<div style={labelStyle}>{label}</div>
)}
</div>
<div
style={{