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:
10 files changed
+271
-87
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 でログインしてください。',
|
||||
|
||||
@@ -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으로 로그인하세요.',
|
||||
|
||||
@@ -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 登录后再使用。',
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
Reference in new issue
Block a user