fix(capsule): 切换样式立即生效 + 设置动画/视觉统一(Windows 样式切换修复) (#912)
* fix(capsule): 切换样式立即生效——设置保存即同步样式原子 + prefs:changed 即时下发 修复 Windows/Linux 上「切到默认风格仍显示流光 Siri」:样式原子此前只靠 emit_capsule 主线程闭包 ~30Hz 同步,入场帧 payload 又是同步前克隆的旧值, Windows 主线程拥塞时闭包延迟会让整场录音下发旧样式。现在 set_settings 保存 时直接同步原子,任何平台下次录音从入场帧起就携带最新样式;胶囊 webview 另 监听 prefs:changed 广播,录音中切换也当帧换肤,并重置胶囊瞬态(退出动画/ 插入字数/操作态) * feat(settings): 录音方式滑动指示 + 静音自动停止展开/收起动画 + 下拉视觉统一 - 录音方式分段选框改为滑动指示块(left/width 过渡),并修复首次进入设置时 因 prefs/platformCaps 异步加载时序导致的高亮块永不定位 - 「静音后自动停止」整组随录音方式切换从下方拉出/收回(grid 0fr→1fr), 不再突然跳出;秒数行随开关展开/收起,开关行不再因内联下拉抽搐 - 静音时长下拉改用 SelectLite 默认底色、固定 200 宽,与麦克风下拉同视觉、 右边缘对齐 - SelectLite 触发器选中值切换动画(ol-select-value-in) - UpdateDialog 全屏遮罩 0.18→0.05:不再把设置页白色选项行盖成灰色 * test(types): capsuleStyle 配置持久化 roundtrip 单测(默认 Siri + camelCase wire 键) * fix(settings): 折叠态静音自动停止控件 inert 防键盘误触 + 滑动指示块随重排重测 pr_agent #912 评审两项: - 录音方式非切换式/开关关闭时,折叠容器内 Toggle 与秒数下拉仍在 tab 顺序 与 a11y 树里,键盘用户可 Tab 到不可见控件 —— 收起时加 inert + aria-hidden (与 Collapsible 同款方案) - 语言切换/窗口缩放导致按钮重排时滑动指示块停在旧位置 —— ResizeObserver 监听 track 尺寸变化兜底重测 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
1 parent
12de016c32
commit
1fd9b43da5
8 files changed
+232
-33
No files matched your search
@@ -405,6 +405,10 @@ pub fn set_settings(
|
||||
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
|
||||
persist_settings(&*coord, prefs)?;
|
||||
let prefs = coord.prefs().get();
|
||||
// 保存即同步胶囊样式原子:下一次录音的入场帧就携带新样式,不依赖 emit_capsule
|
||||
// 主线程闭包的 ~30Hz 同步(Windows 主线程拥塞时闭包延迟 → 整场显示旧样式)。
|
||||
// 前端也会通过 prefs:changed 广播收到新样式,录音中切换即时换肤。
|
||||
coord.sync_capsule_style_from_preferences();
|
||||
// 系统代理开关变化时立即重建客户端连接池(issue #869)。
|
||||
if remote_prev.use_system_proxy != prefs.use_system_proxy {
|
||||
crate::net::set_use_system_proxy(prefs.use_system_proxy);
|
||||
@@ -453,6 +457,8 @@ pub fn set_settings(
|
||||
prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized();
|
||||
persist_settings(&*coord, prefs)?;
|
||||
let prefs = coord.prefs().get();
|
||||
// 保存即同步胶囊样式原子(Android 通知胶囊 payload 同源,见 emit_capsule)。
|
||||
coord.sync_capsule_style_from_preferences();
|
||||
// 系统代理开关变化时立即重建客户端连接池(issue #869)。
|
||||
if previous.use_system_proxy != prefs.use_system_proxy {
|
||||
crate::net::set_use_system_proxy(prefs.use_system_proxy);
|
||||
|
||||
@@ -1593,6 +1593,20 @@ impl Coordinator {
|
||||
pub fn prefs(&self) -> &PreferencesStore {
|
||||
&self.inner.prefs
|
||||
}
|
||||
/// 设置保存后立即把胶囊样式同步进 Inner 原子缓存(0=Siri,1=Classic)。
|
||||
/// emit_capsule 的 ~30Hz 主线程闭包本来也会同步,但入场帧的 payload 是在闭包
|
||||
/// 同步之前克隆的(会带一帧旧样式),且 Windows 上主线程拥塞时闭包可能延迟
|
||||
/// 执行——用户反馈「切换成默认风格后仍显示流光 Siri」。在保存路径直接同步后,
|
||||
/// 任何平台的下一次录音从入场帧起就携带最新样式,不再依赖 emit 闭包的时序。
|
||||
pub fn sync_capsule_style_from_preferences(&self) {
|
||||
let classic = matches!(
|
||||
self.inner.prefs.get().capsule_style,
|
||||
CapsuleStyle::Classic
|
||||
);
|
||||
self.inner
|
||||
.capsule_style
|
||||
.store(if classic { 1 } else { 0 }, Ordering::Relaxed);
|
||||
}
|
||||
pub fn sync_active_asr_provider_from_preferences(&self) -> Result<(), String> {
|
||||
let provider = self.inner.prefs.get().active_asr_provider;
|
||||
self.sync_active_asr_provider_to_vault(&provider)
|
||||
@@ -3044,6 +3058,32 @@ mod tests {
|
||||
std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_capsule_style_from_preferences_updates_atomic_immediately() {
|
||||
// 设置保存路径会调 sync_capsule_style_from_preferences:原子缓存必须立即反映
|
||||
// 用户选择,让下一次录音的入场帧就携带新样式——不依赖 emit_capsule 主线程
|
||||
// 闭包的 ~30Hz 同步(Windows 主线程拥塞时闭包延迟 → 整场显示旧样式)。
|
||||
let coordinator = Coordinator::new();
|
||||
coordinator.sync_capsule_style_from_preferences();
|
||||
assert_eq!(coordinator.inner.capsule_style.load(Ordering::Relaxed), 0);
|
||||
|
||||
{
|
||||
let mut prefs = coordinator.inner.prefs.get();
|
||||
prefs.capsule_style = CapsuleStyle::Classic;
|
||||
coordinator.inner.prefs.set(prefs).unwrap();
|
||||
}
|
||||
coordinator.sync_capsule_style_from_preferences();
|
||||
assert_eq!(coordinator.inner.capsule_style.load(Ordering::Relaxed), 1);
|
||||
|
||||
{
|
||||
let mut prefs = coordinator.inner.prefs.get();
|
||||
prefs.capsule_style = CapsuleStyle::Siri;
|
||||
coordinator.inner.prefs.set(prefs).unwrap();
|
||||
}
|
||||
coordinator.sync_capsule_style_from_preferences();
|
||||
assert_eq!(coordinator.inner.capsule_style.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() {
|
||||
let _guard = ENV_LOCK.lock().await;
|
||||
|
||||
@@ -3242,6 +3242,24 @@ mod tests {
|
||||
assert!(prefs.audio_cue_on_record);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capsule_style_pref_defaults_to_siri_and_round_trips_wire_key() {
|
||||
// 老用户的 preferences.json 没有 capsuleStyle 字段 → 回落默认 Siri。
|
||||
let prefs: UserPreferences = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(prefs.capsule_style, CapsuleStyle::Siri);
|
||||
|
||||
// 设置里切到 Classic 后:set_settings 存盘(camelCase wire 键)→ 重启
|
||||
// get_settings 读回,必须保持 Classic(配置文件持久化 roundtrip)。
|
||||
let classic = UserPreferences {
|
||||
capsule_style: CapsuleStyle::Classic,
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&classic).unwrap();
|
||||
assert!(json.contains(r#""capsuleStyle":"classic""#));
|
||||
let restored: UserPreferences = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored.capsule_style, CapsuleStyle::Classic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_cue_on_record_pref_round_trips_explicit_false() {
|
||||
// 用户在设置里关掉后,set_settings → 存盘 → get_settings 必须保住 false,
|
||||
|
||||
@@ -302,7 +302,10 @@ export function UpdateDialog({
|
||||
const installError = status === 'installError';
|
||||
const androidInstalled = isAndroid() && status === 'downloaded';
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.18)', display: 'grid', placeItems: 'center', zIndex: 40 }}>
|
||||
// 遮罩刻意极淡(0.05):用户反馈 0.18 会把设置页白底卡片盖成灰色
|
||||
//(「通用下面的内容变灰、存在断层感」)——侧边栏/顶部渐变是深色看不出,
|
||||
// 白色选项行最明显。保留极淡遮罩维持模态语义 + 点击外部关闭。
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.05)', display: 'grid', placeItems: 'center', zIndex: 40 }}>
|
||||
<div style={{ width: 360, borderRadius: 16, background: 'var(--ol-surface)', border: '0.5px solid var(--ol-line-strong)', boxShadow: 'var(--ol-shadow-lg)', padding: 18 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>{t(`settings.about.updateDialog.${status}.title`)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6, marginBottom: 14, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
|
||||
@@ -741,7 +741,8 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
|
||||
const [message, setMessage] = useState<string | undefined>(preview.message);
|
||||
const [translation, setTranslation] = useState<boolean>(preview.translation);
|
||||
const [selectionPolish, setSelectionPolish] = useState<boolean>(preview.selectionPolish);
|
||||
// 胶囊样式(siri / classic):随 capsule:state payload 下发,设置里切换后下一次录音生效。
|
||||
// 胶囊样式(siri / classic):随 capsule:state payload 下发;设置里切换后还会经
|
||||
// prefs:changed 广播即时到达(见下方监听),无需等下一次录音。
|
||||
const [capsuleStyle, setCapsuleStyle] = useState<CapsuleStyle>(preview.style);
|
||||
const isClassic = capsuleStyle === 'classic';
|
||||
// 预备态:麦克风尚未吐第一帧 PCM。true 时录音光条走「待命」呼吸形态(见 SiriGL warming)。
|
||||
@@ -820,6 +821,39 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 设置里切换胶囊样式「立即生效」:prefs:changed 由 Rust 广播到所有 webview(含胶囊
|
||||
// 窗口,见 set_settings 的 app.emit),不用等下一次录音的 capsule:state payload。
|
||||
// idle 时组件常驻(只渲染 0 尺寸 div),监听不丢;录音中切换则当帧换肤。
|
||||
// capsule:state 仍作为兜底(payload.capsuleStyle),两路幂等。
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
const handle = await listen<{ capsuleStyle?: CapsuleStyle }>('prefs:changed', event => {
|
||||
const next = event.payload?.capsuleStyle;
|
||||
if (next === 'siri' || next === 'classic') setCapsuleStyle(next);
|
||||
});
|
||||
if (cancelled) handle();
|
||||
else unlisten = handle;
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 切换样式时重置胶囊瞬态(「切换完成后重置并重新初始化相关配置」):
|
||||
// 中止进行中的退出动画(避免用旧时长收尾)、清掉上一会话残留的插入字数/操作态
|
||||
// 与预备态计时,让换肤后的首帧从干净状态重新初始化。
|
||||
useEffect(() => {
|
||||
setLeaving(false);
|
||||
insertedCharsRef.current = 0;
|
||||
operatingRef.current = false;
|
||||
warmStartRef.current = null;
|
||||
}, [capsuleStyle]);
|
||||
|
||||
// 退出动画调度:在 state 真正进入 idle 时,先用 capsule-out 播放
|
||||
// EXIT_ANIM_MS_SIRI / EXIT_ANIM_MS_CLASSIC(按当前样式,经 exitMsRef 读取),再卸载。
|
||||
// 设计要点:
|
||||
|
||||
@@ -285,6 +285,7 @@ export function SelectLite({
|
||||
style={triggerStyle}
|
||||
>
|
||||
<span
|
||||
key={displayLabel}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
@@ -292,6 +293,9 @@ export function SelectLite({
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: selected ? 'var(--ol-ink)' : 'var(--ol-ink-4)',
|
||||
// 值切换动画:key 变化让 span 重挂载,重放 ol-select-value-in
|
||||
//(global.css)。仅选中值变化时触发一次,不是常驻动画。
|
||||
animation: 'ol-select-value-in .16s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
{displayLabel}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 外加「插入与剪贴板」「启动」两个折叠组。流式输入也并到这里(属于插入行为)。
|
||||
// 自 Settings.tsx 的 RecordingSection 拆出,录音相关逻辑零改动。
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
|
||||
import { playRecordStartCue } from '../../lib/audioCue';
|
||||
@@ -186,6 +186,34 @@ export function RecordingInputSection() {
|
||||
const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) =>
|
||||
savePrefs({ ...prefs, autoUpdateCheck });
|
||||
|
||||
// 录音方式(按住说话 / 自动等)横向选框的滑动指示块:跟随选中项移动,
|
||||
// left/width 过渡就是切换动画。按钮的 offsetParent 就是 track(position:relative),
|
||||
// offsetLeft 与绝对定位 thumb 的 containing block 同原点(track padding 边),
|
||||
// 直接赋值即可;useLayoutEffect 在 paint 前定位,首帧无闪动。
|
||||
// 依赖必须含 showDesktopHotkey:prefs 与 platformCaps 异步加载,首次进入时可能
|
||||
// 出现「activeMode 已定但按钮未挂载」的时序(先 prefs 后 caps),effect 提前
|
||||
// return 后按钮才挂载——不加这个依赖 thumb 就永远不定位(用户反馈:首次进入
|
||||
// 设置不显示当前选择的录音方式)。
|
||||
const modeTrackRef = useRef<HTMLDivElement | null>(null);
|
||||
const modeButtonsRef = useRef(new Map<HotkeyMode, HTMLButtonElement>());
|
||||
const [modeThumb, setModeThumb] = useState<{ left: number; width: number } | null>(null);
|
||||
const activeMode = prefs?.hotkey.mode;
|
||||
useLayoutEffect(() => {
|
||||
const track = modeTrackRef.current;
|
||||
if (!track) return;
|
||||
const measure = () => {
|
||||
const active = activeMode ? modeButtonsRef.current.get(activeMode) : undefined;
|
||||
if (!active) return;
|
||||
setModeThumb({ left: active.offsetLeft, width: active.offsetWidth });
|
||||
};
|
||||
measure();
|
||||
// 语言切换(按钮文案重排)/ 窗口缩放都会改变按钮宽高,ResizeObserver 兜底
|
||||
// 重测,避免 thumb 停在旧位置(pr_agent #912 反馈)。
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(track);
|
||||
return () => observer.disconnect();
|
||||
}, [activeMode, showDesktopHotkey]);
|
||||
|
||||
const choices: Array<[HotkeyMode, string]> = [
|
||||
['toggle', t('settings.recording.modeToggle')],
|
||||
['hold', t('settings.recording.modeHold')],
|
||||
@@ -247,19 +275,38 @@ export function RecordingInputSection() {
|
||||
)}
|
||||
{showDesktopHotkey && (
|
||||
<SettingRow label={t('settings.recording.modeLabel')} desc={t('settings.recording.modeDesc')}>
|
||||
<div style={segmentedTrackStyle}>
|
||||
<div ref={modeTrackRef} style={{ ...segmentedTrackStyle, position: 'relative' }}>
|
||||
{/* 滑动指示块:选中态背景/投影移到这上面,切换时 left/width 平滑过渡;
|
||||
按钮本身只变文字颜色。measure 前(首帧 layout effect 前)不渲染,
|
||||
paint 前已定位,无闪动。 */}
|
||||
{modeThumb && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2, bottom: 2,
|
||||
left: modeThumb.left,
|
||||
width: modeThumb.width,
|
||||
borderRadius: 6,
|
||||
background: 'var(--ol-segmented-active-bg)',
|
||||
boxShadow: 'var(--ol-segmented-active-shadow)',
|
||||
transition: 'left 0.18s var(--ol-motion-soft), width 0.18s var(--ol-motion-soft)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{choices.map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
ref={el => { if (el) modeButtonsRef.current.set(v, el); }}
|
||||
onClick={() => onModeChange(v)}
|
||||
style={{
|
||||
padding: '5px 14px', fontSize: 12, fontWeight: 500,
|
||||
border: 0, borderRadius: 6, fontFamily: 'inherit',
|
||||
background: prefs.hotkey.mode === v ? 'var(--ol-segmented-active-bg)' : 'transparent',
|
||||
position: 'relative', zIndex: 1,
|
||||
background: 'transparent',
|
||||
color: prefs.hotkey.mode === v ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
|
||||
boxShadow: prefs.hotkey.mode === v ? 'var(--ol-segmented-active-shadow)' : 'none',
|
||||
cursor: 'default',
|
||||
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
|
||||
transition: 'color 0.16s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
@@ -268,34 +315,71 @@ export function RecordingInputSection() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
{showDesktopHotkey && prefs.hotkey.mode === 'toggle' && (
|
||||
<SettingRow
|
||||
label={t('settings.recording.silenceAutoStopLabel')}
|
||||
desc={t('settings.recording.silenceAutoStopDesc')}
|
||||
{showDesktopHotkey && (
|
||||
// 「静音后自动停止」只在切换式(toggle)模式下可用。外层容器恒渲染,
|
||||
// 录音方式从按住/自动切到切换式时整组从下方拉出、切走时收回——与开关
|
||||
// 控制秒数行的动画同款(grid 0fr→1fr),不再「突然跳出」。收起时内容
|
||||
// 仍在 DOM 里(overflow hidden),动画结束高度归 0、不占布局;inert
|
||||
// 把折叠态控件移出 tab 顺序与 a11y 树(与 Collapsible 同款,pr_agent 反馈)。
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: prefs.hotkey.mode === 'toggle' ? '1fr' : '0fr',
|
||||
transition:
|
||||
'grid-template-rows 0.22s var(--ol-motion-soft), opacity 0.18s var(--ol-motion-quick)',
|
||||
opacity: prefs.hotkey.mode === 'toggle' ? 1 : 0,
|
||||
}}
|
||||
{...(prefs.hotkey.mode !== 'toggle' ? { inert: '' } : {})}
|
||||
aria-hidden={prefs.hotkey.mode !== 'toggle'}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Toggle
|
||||
on={prefs.silenceAutoStopEnabled}
|
||||
onToggle={next => savePrefs({ ...prefs, silenceAutoStopEnabled: next })}
|
||||
/>
|
||||
{prefs.silenceAutoStopEnabled && (
|
||||
<SelectLite
|
||||
value={String(
|
||||
silenceAutoStopOptions.includes(prefs.silenceAutoStopSeconds)
|
||||
? prefs.silenceAutoStopSeconds
|
||||
: 3,
|
||||
)}
|
||||
onChange={next => savePrefs({ ...prefs, silenceAutoStopSeconds: Number(next) })}
|
||||
options={silenceAutoStopOptions.map(value => ({
|
||||
value: String(value),
|
||||
label: t('settings.recording.silenceAutoStopSecondsValue', { value }),
|
||||
}))}
|
||||
ariaLabel={t('settings.recording.silenceAutoStopSecondsLabel')}
|
||||
style={{ ...inputStyle, maxWidth: 120 }}
|
||||
<div style={{ overflow: 'hidden', minHeight: 0 }}>
|
||||
<SettingRow
|
||||
label={t('settings.recording.silenceAutoStopLabel')}
|
||||
desc={t('settings.recording.silenceAutoStopDesc')}
|
||||
>
|
||||
{/* 开关行只放 Toggle:秒数下拉移出本行,避免开关时行内内容变宽导致抽搐。 */}
|
||||
<Toggle
|
||||
on={prefs.silenceAutoStopEnabled}
|
||||
onToggle={next => savePrefs({ ...prefs, silenceAutoStopEnabled: next })}
|
||||
/>
|
||||
)}
|
||||
</SettingRow>
|
||||
{/* 开启后在本行下方展开「静音时长」选择行。展开/收起走 grid 0fr→1fr
|
||||
过渡(嵌套在外层容器内,随外层一起拉出/收回)。终点高度固定
|
||||
(SettingRow 自身高度),反复开关不会跳动。 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: prefs.silenceAutoStopEnabled ? '1fr' : '0fr',
|
||||
transition:
|
||||
'grid-template-rows 0.22s var(--ol-motion-soft), opacity 0.18s var(--ol-motion-quick)',
|
||||
opacity: prefs.silenceAutoStopEnabled ? 1 : 0,
|
||||
}}
|
||||
{...(!prefs.silenceAutoStopEnabled ? { inert: '' } : {})}
|
||||
aria-hidden={!prefs.silenceAutoStopEnabled}
|
||||
>
|
||||
<div style={{ overflow: 'hidden', minHeight: 0 }}>
|
||||
<SettingRow label={t('settings.recording.silenceAutoStopSecondsLabel')}>
|
||||
{/* 与麦克风下拉同款视觉:SelectLite 默认触发器底色 + 固定 200 宽 →
|
||||
右边缘与「MacBook Air 麦克风」对齐(control 区域同起点同宽)。 */}
|
||||
<SelectLite
|
||||
value={String(
|
||||
silenceAutoStopOptions.includes(prefs.silenceAutoStopSeconds)
|
||||
? prefs.silenceAutoStopSeconds
|
||||
: 3,
|
||||
)}
|
||||
onChange={next => savePrefs({ ...prefs, silenceAutoStopSeconds: Number(next) })}
|
||||
options={silenceAutoStopOptions.map(value => ({
|
||||
value: String(value),
|
||||
label: t('settings.recording.silenceAutoStopSecondsValue', { value }),
|
||||
}))}
|
||||
ariaLabel={t('settings.recording.silenceAutoStopSecondsLabel')}
|
||||
style={{ width: 200, maxWidth: '100%', minWidth: 0 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
)}
|
||||
<SettingRow label={t('settings.recording.microphoneLabel')} desc={t('settings.recording.microphoneDesc')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
@@ -327,7 +411,10 @@ export function RecordingInputSection() {
|
||||
{ value: 'classic', label: t('settings.recording.capsuleStyleClassic') },
|
||||
]}
|
||||
ariaLabel={t('settings.recording.capsuleStyleLabel')}
|
||||
style={{ ...inputStyle, maxWidth: 220 }}
|
||||
// 与麦克风/语言等下拉同款视觉:走 SelectLite 默认触发器底色
|
||||
//(--ol-select-trigger-bg),而不是 inputStyle 的 surface-2;宽度
|
||||
// 220 与同区粘贴快捷键行、语言区保持一致(minWidth 200 防缩窄)。
|
||||
style={{ maxWidth: 220, minWidth: 200 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
@@ -65,6 +65,13 @@ button {
|
||||
to { opacity: 0; transform: translateY(-4px) scale(.98); }
|
||||
}
|
||||
|
||||
/* SelectLite 触发器选中值切换动画:值变化时 span 以 key 重挂载,重放这段
|
||||
淡入微滑移 —— 录音方式/胶囊样式/粘贴快捷键等横向选框的「切换」反馈。 */
|
||||
@keyframes ol-select-value-in {
|
||||
from { opacity: 0.3; transform: translateY(3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 键盘焦点指示:鼠标点击 (:focus) 不显示,键盘 Tab (:focus-visible) 才显示。
|
||||
用 box-shadow 而非 outline 是因为组件内联 style 可能用 outline:none
|
||||
把默认 ring 干掉,box-shadow 不受影响仍能上色。配合 pr-agent #407 的 a11y 要求。 */
|
||||
|
||||
Reference in new issue
Block a user