Merge pull request #419 from Open-Less/fix/windows-ui-417-418
fix(ui): Windows 原生 chrome / 胶囊缩小 / 自定义下拉 (#417 #418)
This commit is contained in:
16 files changed
+460
-424
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openless-app",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1-1",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
|
||||
@@ -21,14 +21,6 @@ import {
|
||||
type LatestBetaRelease,
|
||||
type UpdateChannel,
|
||||
} from '../lib/ipc';
|
||||
import {
|
||||
FOLLOW_SYSTEM,
|
||||
getLocalePreference,
|
||||
outputPrefsForLocale,
|
||||
setLocalePreference,
|
||||
type SupportedLocale,
|
||||
} from '../i18n';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import type { OS } from './WindowChrome';
|
||||
|
||||
interface SettingsModalProps {
|
||||
@@ -264,9 +256,6 @@ function PersonalizeSection() {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Row label={t('modal.personalize.language')}>
|
||||
<LanguagePicker />
|
||||
</Row>
|
||||
<Row label={t('modal.personalize.font')} desc={t('modal.personalize.fontDesc')}>
|
||||
<div style={{ display: 'flex', gap: 4, padding: 2, background: 'rgba(0,0,0,0.04)', borderRadius: 8 }}>
|
||||
{fontOptions.map(([id, label]) => {
|
||||
@@ -533,46 +522,3 @@ const btnGhost: CSSProperties = {
|
||||
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
|
||||
};
|
||||
|
||||
// 真正可用的语言切换器 —— 用原生 <select>,与 Settings → Language 分区共享同一份 localStorage 偏好。
|
||||
function LanguagePicker() {
|
||||
const { t } = useTranslation();
|
||||
const { updatePrefs } = useHotkeySettings();
|
||||
const [pref, setPref] = useState<SupportedLocale | typeof FOLLOW_SYSTEM>(getLocalePreference());
|
||||
|
||||
const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => {
|
||||
setPref(next);
|
||||
const resolved = await setLocalePreference(next);
|
||||
const localePrefs = outputPrefsForLocale(resolved);
|
||||
await updatePrefs(current => {
|
||||
if (
|
||||
current.chineseScriptPreference === localePrefs.chineseScriptPreference &&
|
||||
current.outputLanguagePreference === localePrefs.outputLanguagePreference
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return { ...current, ...localePrefs };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<select
|
||||
value={pref}
|
||||
onChange={e => apply(e.target.value as SupportedLocale | typeof FOLLOW_SYSTEM)}
|
||||
style={{
|
||||
height: 32, padding: '0 10px',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
borderRadius: 8, fontSize: 12.5,
|
||||
fontFamily: 'inherit', outline: 'none',
|
||||
background: 'var(--ol-surface-2)',
|
||||
minWidth: 200, cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<option value={FOLLOW_SYSTEM}>{t('settings.language.followSystem')}</option>
|
||||
<option value="zh-CN">{t('settings.language.zh')}</option>
|
||||
<option value="zh-TW">{t('settings.language.zhTW')}</option>
|
||||
<option value="en">{t('settings.language.en')}</option>
|
||||
<option value="ja">{t('settings.language.ja')}</option>
|
||||
<option value="ko">{t('settings.language.ko')}</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { type CSSProperties, type ReactNode } from 'react';
|
||||
|
||||
export type OS = 'mac' | 'win' | 'linux';
|
||||
|
||||
@@ -18,21 +16,7 @@ export function detectOS(): OS {
|
||||
|
||||
const MAC_TITLEBAR_HEIGHT = 28;
|
||||
const MAC_SYSTEM_CONTROLS_RESERVED_WIDTH = 76;
|
||||
export const WIN_TITLEBAR_HEIGHT = 36;
|
||||
export const WIN_WINDOW_RADIUS = 10;
|
||||
export const WIN_CONSOLE_RADIUS = 10;
|
||||
const WIN_RESIZE_EDGE = 6;
|
||||
const WIN_RESIZE_CORNER = 14;
|
||||
|
||||
type ResizeDirection =
|
||||
| 'East'
|
||||
| 'North'
|
||||
| 'NorthEast'
|
||||
| 'NorthWest'
|
||||
| 'South'
|
||||
| 'SouthEast'
|
||||
| 'SouthWest'
|
||||
| 'West';
|
||||
const WIN_CONSOLE_RADIUS = 10;
|
||||
|
||||
interface WindowChromeProps {
|
||||
os?: OS;
|
||||
@@ -43,48 +27,46 @@ interface WindowChromeProps {
|
||||
|
||||
export function WindowChrome({
|
||||
os = 'mac',
|
||||
title = 'OpenLess',
|
||||
children,
|
||||
height = 800,
|
||||
}: WindowChromeProps) {
|
||||
const shellRadius = os === 'mac' ? 0 : os === 'win' ? WIN_WINDOW_RADIUS : 14;
|
||||
// Windows 下交还原生外壳(decorations:true):外层不画圆角 / 边框 / 阴影 / 标题栏,
|
||||
// 避免与原生窗口的角和关闭按钮重叠。内层卡片保留 10px 圆角,跟整体设计对齐。
|
||||
const shellRadius = os === 'mac' ? 0 : os === 'win' ? 0 : 14;
|
||||
const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : 14;
|
||||
const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : 0;
|
||||
|
||||
const background = os === 'win'
|
||||
? 'linear-gradient(180deg, rgba(245,245,247,1) 0%, rgba(232,232,236,1) 100%)'
|
||||
: `
|
||||
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.7) 0%, rgba(255,255,255,0) 60%),
|
||||
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
|
||||
linear-gradient(180deg, rgba(245,245,247,0.92) 0%, rgba(232,232,236,0.92) 100%)
|
||||
`;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
'--ol-window-shell-radius': `${shellRadius}px`,
|
||||
'--ol-window-console-radius': `${consoleRadius}px`,
|
||||
'--ol-window-titlebar-height': `${os === 'mac' ? MAC_TITLEBAR_HEIGHT : WIN_TITLEBAR_HEIGHT}px`,
|
||||
'--ol-window-titlebar-height': `${titlebarHeight}px`,
|
||||
width: '100%',
|
||||
height,
|
||||
position: 'relative',
|
||||
borderRadius: 'var(--ol-window-shell-radius)',
|
||||
boxShadow: os === 'win'
|
||||
? '0 22px 54px -28px rgba(15, 17, 22, 0.38), 0 10px 30px -20px rgba(15, 17, 22, 0.22), 0 0 0 0.5px rgba(0, 0, 0, 0.10)'
|
||||
: 'var(--ol-shadow-xl)',
|
||||
boxShadow: os === 'win' ? 'none' : 'var(--ol-shadow-xl)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: os === 'mac'
|
||||
? 'none'
|
||||
: os === 'win'
|
||||
? '1px solid rgba(255,255,255,0.18)'
|
||||
: '0.5px solid rgba(0,0,0,.10)',
|
||||
background: `
|
||||
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.7) 0%, rgba(255,255,255,0) 60%),
|
||||
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
|
||||
linear-gradient(180deg, rgba(245,245,247,0.92) 0%, rgba(232,232,236,0.92) 100%)
|
||||
`,
|
||||
backdropFilter: 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
|
||||
WebkitBackdropFilter: 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
|
||||
border: os === 'win' ? 'none' : os === 'mac' ? 'none' : '0.5px solid rgba(0,0,0,.10)',
|
||||
background,
|
||||
backdropFilter: os === 'win' ? undefined : 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
|
||||
WebkitBackdropFilter: os === 'win' ? undefined : 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
|
||||
animation: os === 'win' ? undefined : 'ol-window-enter 0.42s var(--ol-motion-spring) both',
|
||||
transition: 'box-shadow 0.28s var(--ol-motion-soft), border-color 0.28s var(--ol-motion-soft), backdrop-filter 0.28s var(--ol-motion-soft)',
|
||||
willChange: 'opacity, transform, filter',
|
||||
} as CSSProperties}
|
||||
>
|
||||
{os === 'win' && <WinTitleBar title={title} />}
|
||||
{os === 'win' && <WindowsResizeHandles />}
|
||||
{os === 'mac' && (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
@@ -104,154 +86,3 @@ export function WindowChrome({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WinTitleBarProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
function WinTitleBar({ title }: WinTitleBarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: WIN_TITLEBAR_HEIGHT,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
position: 'relative',
|
||||
zIndex: 70,
|
||||
borderBottom: '0.5px solid rgba(0, 0, 0, 0.08)',
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.20) 0%, rgba(255,255,255,0.08) 100%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '0 14px', gap: 10 }}
|
||||
>
|
||||
<img src="AppIcon.png" alt="" style={{ width: 14, height: 14, borderRadius: 3 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', fontWeight: 500 }}>{title}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', pointerEvents: 'auto' }}>
|
||||
<WinTitleButton title={t('windowChrome.minimize')} action="minimize">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M0 5h10" stroke="currentColor" strokeWidth="1" /></svg>
|
||||
</WinTitleButton>
|
||||
<WinTitleButton title={t('windowChrome.maximize')} action="toggleMaximize">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" strokeWidth="1" fill="none" /></svg>
|
||||
</WinTitleButton>
|
||||
<WinTitleButton title={t('windowChrome.close')} action="close" tone="danger">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M0 0L10 10M10 0L0 10" stroke="currentColor" strokeWidth="1" /></svg>
|
||||
</WinTitleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowsResizeHandles() {
|
||||
const handles: Array<{
|
||||
direction: ResizeDirection;
|
||||
cursor: CSSProperties['cursor'];
|
||||
style: CSSProperties;
|
||||
}> = [
|
||||
{ direction: 'North', cursor: 'ns-resize', style: { top: 0, left: WIN_RESIZE_CORNER, right: WIN_RESIZE_CORNER, height: WIN_RESIZE_EDGE } },
|
||||
{ direction: 'South', cursor: 'ns-resize', style: { bottom: 0, left: WIN_RESIZE_CORNER, right: WIN_RESIZE_CORNER, height: WIN_RESIZE_EDGE } },
|
||||
{ direction: 'West', cursor: 'ew-resize', style: { top: WIN_RESIZE_CORNER, bottom: WIN_RESIZE_CORNER, left: 0, width: WIN_RESIZE_EDGE } },
|
||||
{ direction: 'East', cursor: 'ew-resize', style: { top: WIN_RESIZE_CORNER, bottom: WIN_RESIZE_CORNER, right: 0, width: WIN_RESIZE_EDGE } },
|
||||
{ direction: 'NorthWest', cursor: 'nwse-resize', style: { top: 0, left: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } },
|
||||
{ direction: 'NorthEast', cursor: 'nesw-resize', style: { top: 0, right: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } },
|
||||
{ direction: 'SouthWest', cursor: 'nesw-resize', style: { bottom: 0, left: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } },
|
||||
{ direction: 'SouthEast', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } },
|
||||
];
|
||||
|
||||
return (
|
||||
<div aria-hidden style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 60 }}>
|
||||
{handles.map(handle => (
|
||||
<div
|
||||
key={handle.direction}
|
||||
onMouseDown={event => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void startResizeDragging(handle.direction);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'auto',
|
||||
cursor: handle.cursor,
|
||||
...handle.style,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WinTitleButtonProps {
|
||||
title: string;
|
||||
action: 'minimize' | 'toggleMaximize' | 'close';
|
||||
tone?: 'default' | 'danger';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function WinTitleButton({ title, action, tone = 'default', children }: WinTitleButtonProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [pressed, setPressed] = useState(false);
|
||||
const danger = tone === 'danger';
|
||||
const background = pressed
|
||||
? danger ? '#c42b1c' : 'rgba(0, 0, 0, 0.12)'
|
||||
: hovered ? danger ? '#e81123' : 'rgba(0, 0, 0, 0.08)'
|
||||
: 'transparent';
|
||||
const color = danger && (hovered || pressed) ? '#fff' : 'var(--ol-ink-3)';
|
||||
|
||||
return (
|
||||
<button
|
||||
style={{ ...winBtnStyle, background, color }}
|
||||
title={title}
|
||||
onClick={() => runWindowsWindowAction(action)}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => {
|
||||
setHovered(false);
|
||||
setPressed(false);
|
||||
}}
|
||||
onMouseDown={() => setPressed(true)}
|
||||
onMouseUp={() => setPressed(false)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
async function startResizeDragging(direction: ResizeDirection) {
|
||||
try {
|
||||
await getCurrentWindow().startResizeDragging(direction);
|
||||
} catch (error) {
|
||||
console.warn(`[window] Windows resize ${direction} failed`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function runWindowsWindowAction(action: 'minimize' | 'toggleMaximize' | 'close') {
|
||||
try {
|
||||
const currentWindow = getCurrentWindow();
|
||||
if (action === 'minimize') {
|
||||
await currentWindow.minimize();
|
||||
} else if (action === 'toggleMaximize') {
|
||||
await currentWindow.toggleMaximize();
|
||||
} else {
|
||||
await currentWindow.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[window] Windows title button ${action} failed`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const winBtnStyle: CSSProperties = {
|
||||
width: 46,
|
||||
height: '100%',
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--ol-ink-3)',
|
||||
cursor: 'default',
|
||||
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
|
||||
};
|
||||
@@ -1,24 +1,298 @@
|
||||
// SelectLite — dropdown-styled display used in the Settings modal sub-sections.
|
||||
// SelectLite — 受控的下拉组件,替代 native <select> 以避开 Windows Win32 ComboBox
|
||||
// 弹框的直角丑框(issue #418)。
|
||||
//
|
||||
// 设计:
|
||||
// - 触发器是一个 button(chevron + 当前值标签),样式可被 `style` 覆盖
|
||||
// - popover 用 portal 渲染到 document.body,避开父容器 overflow:hidden
|
||||
// - 键盘:ArrowDown/ArrowUp 切换高亮,Enter 确认,Esc 关闭
|
||||
// - 点击外部 / 滚动 / resize 都会关闭或重定位
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectLiteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
style?: CSSProperties;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function SelectLite({ value }: SelectLiteProps) {
|
||||
const DEFAULT_TRIGGER_STYLE: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
padding: '0 10px',
|
||||
height: 32,
|
||||
fontSize: 12.5,
|
||||
fontFamily: 'inherit',
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background: 'var(--ol-surface-2)',
|
||||
color: 'var(--ol-ink)',
|
||||
cursor: 'default',
|
||||
outline: 'none',
|
||||
textAlign: 'left',
|
||||
minWidth: 160,
|
||||
};
|
||||
|
||||
export function SelectLite({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
style,
|
||||
ariaLabel,
|
||||
}: SelectLiteProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState<number>(-1);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [anchor, setAnchor] = useState<{ left: number; top: number; width: number } | null>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => options.find(opt => opt.value === value),
|
||||
[options, value],
|
||||
);
|
||||
const displayLabel = selected?.label ?? placeholder ?? '';
|
||||
|
||||
const positionPopover = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const popoverRect = popoverRef.current?.getBoundingClientRect();
|
||||
const popoverHeight = popoverRect?.height ?? 280;
|
||||
const popoverWidth = popoverRect?.width ?? rect.width;
|
||||
// 纵向:默认在触发器下方;若下方空间放不下 popover,翻转向上以避免被视口裁剪。
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const flipUp = spaceBelow < popoverHeight + 8 && rect.top > popoverHeight + 8;
|
||||
const top = flipUp ? rect.top - popoverHeight - 4 : rect.bottom + 4;
|
||||
// 横向:在窗口右边的 select 可能让 popover 溢出屏幕;clamp 到 [8, viewport - width - 8] 区间。
|
||||
const minLeft = 8;
|
||||
const maxLeft = Math.max(minLeft, window.innerWidth - popoverWidth - 8);
|
||||
const left = Math.min(Math.max(rect.left, minLeft), maxLeft);
|
||||
setAnchor({ left, top, width: rect.width });
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
positionPopover();
|
||||
const handleReflow = () => positionPopover();
|
||||
window.addEventListener('resize', handleReflow);
|
||||
window.addEventListener('scroll', handleReflow, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleReflow);
|
||||
window.removeEventListener('scroll', handleReflow, true);
|
||||
};
|
||||
}, [open, positionPopover]);
|
||||
|
||||
// 键盘 ArrowUp/Down 改 highlight 后把高亮项 scroll into view —— 长 dropdown 超过
|
||||
// maxHeight 280 时键盘用户能看到当前高亮。{ block: 'nearest' } 避免把已经可见的项
|
||||
// 强行滚到顶部,符合 listbox 滚动惯例。
|
||||
useEffect(() => {
|
||||
if (!open || highlight < 0) return;
|
||||
const target = popoverRef.current?.querySelector(
|
||||
`[data-option-index="${highlight}"]`,
|
||||
) as HTMLElement | null;
|
||||
target?.scrollIntoView({ block: 'nearest' });
|
||||
}, [highlight, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target) return;
|
||||
if (triggerRef.current?.contains(target)) return;
|
||||
if (popoverRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [open]);
|
||||
|
||||
const openMenu = () => {
|
||||
if (disabled) return;
|
||||
const initial = options.findIndex(opt => opt.value === value && !opt.disabled);
|
||||
setHighlight(initial >= 0 ? initial : options.findIndex(opt => !opt.disabled));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
setOpen(false);
|
||||
setHighlight(-1);
|
||||
};
|
||||
|
||||
const selectIndex = (index: number) => {
|
||||
const option = options[index];
|
||||
if (!option || option.disabled) return;
|
||||
onChange(option.value);
|
||||
closeMenu();
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
const moveHighlight = (direction: 1 | -1) => {
|
||||
if (options.length === 0) return;
|
||||
let next = highlight;
|
||||
for (let i = 0; i < options.length; i += 1) {
|
||||
next = (next + direction + options.length) % options.length;
|
||||
if (!options[next]?.disabled) {
|
||||
setHighlight(next);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
if (!open) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openMenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
moveHighlight(1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveHighlight(-1);
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (highlight >= 0) selectIndex(highlight);
|
||||
} else if (event.key === 'Tab') {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const triggerStyle: CSSProperties = {
|
||||
...DEFAULT_TRIGGER_STYLE,
|
||||
...style,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
cursor: disabled ? 'not-allowed' : 'default',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 10px', fontSize: 12.5,
|
||||
borderRadius: 8, border: '0.5px solid var(--ol-line-strong)',
|
||||
background: 'var(--ol-surface-2)',
|
||||
minWidth: 200, justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span>{value}</span>
|
||||
<Icon name="chevDown" size={11} />
|
||||
</div>
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="ol-focus-ring"
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => (open ? closeMenu() : openMenu())}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={triggerStyle}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: selected ? 'var(--ol-ink)' : 'var(--ol-ink-4)',
|
||||
}}
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
<Icon name="chevDown" size={11} />
|
||||
</button>
|
||||
{open && anchor && createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="listbox"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: anchor.left,
|
||||
top: anchor.top,
|
||||
minWidth: anchor.width,
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
padding: 4,
|
||||
borderRadius: 10,
|
||||
border: '0.5px solid rgba(0, 0, 0, 0.10)',
|
||||
background: 'rgba(252, 252, 254, 0.94)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: '0 12px 30px -10px rgba(15, 17, 22, 0.25), 0 0 0 0.5px rgba(0, 0, 0, 0.06)',
|
||||
zIndex: 9999,
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
animation: 'ol-select-pop .14s var(--ol-motion-quick) both',
|
||||
}}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = option.value === value;
|
||||
const isHighlighted = index === highlight;
|
||||
return (
|
||||
<div
|
||||
key={option.value || `__opt_${index}`}
|
||||
data-option-index={index}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
onMouseEnter={() => !option.disabled && setHighlight(index)}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault();
|
||||
selectIndex(index);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '7px 10px',
|
||||
borderRadius: 6,
|
||||
cursor: option.disabled ? 'not-allowed' : 'default',
|
||||
opacity: option.disabled ? 0.45 : 1,
|
||||
background: isHighlighted && !option.disabled
|
||||
? 'rgba(37, 99, 235, 0.10)'
|
||||
: 'transparent',
|
||||
color: isSelected ? 'var(--ol-blue)' : 'var(--ol-ink)',
|
||||
fontWeight: isSelected ? 600 : 500,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
transition: 'background 0.10s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{option.label}
|
||||
</span>
|
||||
{isSelected && <Icon name="check" size={12} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,11 +10,14 @@ export function SwitchLite({ on: initial = false }: SwitchLiteProps) {
|
||||
const [on, setOn] = useState(initial);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="ol-focus-ring"
|
||||
onClick={() => setOn(!on)}
|
||||
style={{
|
||||
position: 'relative', width: 32, height: 18, borderRadius: 999, border: 0,
|
||||
background: on ? 'var(--ol-blue)' : 'rgba(0,0,0,0.18)',
|
||||
cursor: 'default',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -600,7 +600,6 @@ export const en: typeof zhCN = {
|
||||
appearanceSystem: 'Follow system',
|
||||
appearanceLight: 'Light',
|
||||
appearanceDark: 'Dark',
|
||||
language: 'Interface language',
|
||||
font: 'Font size',
|
||||
fontDesc: 'Scale the entire UI font size — applies instantly.',
|
||||
fontSmall: 'Small',
|
||||
|
||||
@@ -602,7 +602,6 @@ export const ja: typeof zhCN = {
|
||||
appearanceSystem: 'システムに従う',
|
||||
appearanceLight: 'ライト',
|
||||
appearanceDark: 'ダーク',
|
||||
language: '表示言語',
|
||||
font: 'フォントサイズ',
|
||||
fontDesc: 'UI のフォントサイズを全体的にスケール。即時反映。',
|
||||
fontSmall: '小',
|
||||
|
||||
@@ -602,7 +602,6 @@ export const ko: typeof zhCN = {
|
||||
appearanceSystem: '시스템 따라가기',
|
||||
appearanceLight: '라이트',
|
||||
appearanceDark: '다크',
|
||||
language: '인터페이스 언어',
|
||||
font: '글꼴 크기',
|
||||
fontDesc: 'UI 글꼴 크기를 전체 스케일. 즉시 반영.',
|
||||
fontSmall: '소',
|
||||
|
||||
@@ -598,7 +598,6 @@ export const zhCN = {
|
||||
appearanceSystem: '跟随系统',
|
||||
appearanceLight: '浅色',
|
||||
appearanceDark: '深色',
|
||||
language: '界面语言',
|
||||
font: '字体大小',
|
||||
fontDesc: '整体缩放界面字号,立即生效。',
|
||||
fontSmall: '小',
|
||||
|
||||
@@ -600,7 +600,6 @@ export const zhTW: typeof zhCN = {
|
||||
appearanceSystem: '跟隨系統',
|
||||
appearanceLight: '淺色',
|
||||
appearanceDark: '深色',
|
||||
language: '界面語言',
|
||||
font: '字體大小',
|
||||
fontDesc: '整體縮放界面字號,立即生效。',
|
||||
fontSmall: '小',
|
||||
|
||||
@@ -26,7 +26,8 @@ export interface CapsuleMessageLayout {
|
||||
export function getCapsulePillMetrics(os: OS): CapsulePillMetrics {
|
||||
if (os === 'win') {
|
||||
// Windows metrics describe the visible outer footprint of the pill.
|
||||
return { width: 196, height: 52, textWidth: 104, boxSizing: 'border-box' };
|
||||
// 与 macOS pill 接近以保持视觉密度一致;保留 ~4-5% 余量适配 Windows 字体 metrics。
|
||||
return { width: 180, height: 44, textWidth: 88, boxSizing: 'border-box' };
|
||||
}
|
||||
|
||||
return { width: 176, height: 42, textWidth: 84, boxSizing: 'border-box' };
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from '../lib/localAsr';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { detectOS } from '../components/WindowChrome';
|
||||
import { SelectLite } from '../components/ui/SelectLite';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
|
||||
// Foundry Local Whisper 后端只在 Windows 编译实体(foundry_local_sdk 仅 Windows),
|
||||
@@ -626,73 +627,52 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{t('localAsr.foundrySelectedModel')}
|
||||
<select
|
||||
<SelectLite
|
||||
value={selectedFoundryAlias}
|
||||
onChange={e => {
|
||||
onChange={next => {
|
||||
foundrySelectionDirty.current = true;
|
||||
setSelectedFoundryAlias(e.target.value as FoundryLocalAsrModelAlias);
|
||||
setSelectedFoundryAlias(next as FoundryLocalAsrModelAlias);
|
||||
}}
|
||||
disabled={foundryBusy !== null}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid rgba(0,0,0,0.12)',
|
||||
background: 'var(--ol-surface)',
|
||||
color: 'var(--ol-ink)',
|
||||
minWidth: 260,
|
||||
}}>
|
||||
{FOUNDRY_LOCAL_ASR_MODELS.map(model => {
|
||||
options={FOUNDRY_LOCAL_ASR_MODELS.map(model => {
|
||||
const catalog = foundryCatalog.find(item => item.alias === model.alias);
|
||||
const sizeMb = formatFoundrySizeMb(catalog?.fileSizeMb);
|
||||
return (
|
||||
<option key={model.alias} value={model.alias}>
|
||||
{t(model.labelKey)}
|
||||
{sizeMb ? ` · ${t('localAsr.foundryApproxSizeMb', { mb: sizeMb })}` : ''}
|
||||
</option>
|
||||
);
|
||||
const sizeSuffix = sizeMb ? ` · ${t('localAsr.foundryApproxSizeMb', { mb: sizeMb })}` : '';
|
||||
return { value: model.alias, label: `${t(model.labelKey)}${sizeSuffix}` };
|
||||
})}
|
||||
</select>
|
||||
ariaLabel={t('localAsr.foundrySelectedModel')}
|
||||
style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 260 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{t('localAsr.foundryRuntimeSourceLabel')}
|
||||
<select
|
||||
<SelectLite
|
||||
value={selectedFoundryRuntimeSource}
|
||||
onChange={e => void handleFoundryRuntimeSourceChange(e.target.value as FoundryRuntimeSource)}
|
||||
onChange={next => void handleFoundryRuntimeSourceChange(next as FoundryRuntimeSource)}
|
||||
disabled={foundryBusy !== null}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid rgba(0,0,0,0.12)',
|
||||
background: 'var(--ol-surface)',
|
||||
color: 'var(--ol-ink)',
|
||||
minWidth: 200,
|
||||
}}>
|
||||
<option value="auto">{t('localAsr.foundryRuntimeSourceAuto')}</option>
|
||||
<option value="nuget">{t('localAsr.foundryRuntimeSourceNuget')}</option>
|
||||
<option value="ort-nightly">{t('localAsr.foundryRuntimeSourceOrtNightly')}</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: 'auto', label: t('localAsr.foundryRuntimeSourceAuto') },
|
||||
{ value: 'nuget', label: t('localAsr.foundryRuntimeSourceNuget') },
|
||||
{ value: 'ort-nightly', label: t('localAsr.foundryRuntimeSourceOrtNightly') },
|
||||
]}
|
||||
ariaLabel={t('localAsr.foundryRuntimeSourceLabel')}
|
||||
style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 200 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{t('localAsr.foundryLanguageLabel')}
|
||||
<select
|
||||
<SelectLite
|
||||
value={selectedFoundryLanguageHint}
|
||||
onChange={e => void handleFoundryLanguageChange(e.target.value as FoundryLocalAsrLanguageHint)}
|
||||
onChange={next => void handleFoundryLanguageChange(next as FoundryLocalAsrLanguageHint)}
|
||||
disabled={foundryBusy !== null}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid rgba(0,0,0,0.12)',
|
||||
background: 'var(--ol-surface)',
|
||||
color: 'var(--ol-ink)',
|
||||
minWidth: 132,
|
||||
}}>
|
||||
<option value="">{t('localAsr.foundryLanguageAuto')}</option>
|
||||
<option value="zh">{t('localAsr.foundryLanguageZh')}</option>
|
||||
<option value="en">{t('localAsr.foundryLanguageEn')}</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: '', label: t('localAsr.foundryLanguageAuto') },
|
||||
{ value: 'zh', label: t('localAsr.foundryLanguageZh') },
|
||||
{ value: 'en', label: t('localAsr.foundryLanguageEn') },
|
||||
]}
|
||||
ariaLabel={t('localAsr.foundryLanguageLabel')}
|
||||
style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 132 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -805,21 +785,16 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
{t('localAsr.mirrorDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
<SelectLite
|
||||
value={settings?.mirror ?? 'huggingface'}
|
||||
onChange={e => void handleMirrorChange(e.target.value)}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid rgba(0,0,0,0.12)',
|
||||
background: 'var(--ol-surface)',
|
||||
color: 'var(--ol-ink)',
|
||||
minWidth: 200,
|
||||
}}>
|
||||
<option value="huggingface">{t('localAsr.mirrorHuggingface')}</option>
|
||||
<option value="hf-mirror">{t('localAsr.mirrorHfMirror')}</option>
|
||||
</select>
|
||||
onChange={next => void handleMirrorChange(next)}
|
||||
options={[
|
||||
{ value: 'huggingface', label: t('localAsr.mirrorHuggingface') },
|
||||
{ value: 'hf-mirror', label: t('localAsr.mirrorHfMirror') },
|
||||
]}
|
||||
ariaLabel={t('localAsr.mirrorLabel')}
|
||||
style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 200 }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -859,24 +834,19 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
{t('localAsr.keepLoadedDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={engineStatus?.keepLoadedSecs ?? 300}
|
||||
onChange={e => void handleKeepLoadedChange(Number(e.target.value))}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid rgba(0,0,0,0.12)',
|
||||
background: 'var(--ol-surface)',
|
||||
color: 'var(--ol-ink)',
|
||||
minWidth: 200,
|
||||
}}>
|
||||
<option value={0}>{t('localAsr.keepImmediate')}</option>
|
||||
<option value={60}>{t('localAsr.keep1min')}</option>
|
||||
<option value={300}>{t('localAsr.keep5min')}</option>
|
||||
<option value={1800}>{t('localAsr.keep30min')}</option>
|
||||
<option value={86400}>{t('localAsr.keepForever')}</option>
|
||||
</select>
|
||||
<SelectLite
|
||||
value={String(engineStatus?.keepLoadedSecs ?? 300)}
|
||||
onChange={next => void handleKeepLoadedChange(Number(next))}
|
||||
options={[
|
||||
{ value: '0', label: t('localAsr.keepImmediate') },
|
||||
{ value: '60', label: t('localAsr.keep1min') },
|
||||
{ value: '300', label: t('localAsr.keep5min') },
|
||||
{ value: '1800', label: t('localAsr.keep30min') },
|
||||
{ value: '86400', label: t('localAsr.keepForever') },
|
||||
]}
|
||||
ariaLabel={t('localAsr.keepLoadedLabel')}
|
||||
style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 200 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
} from '../lib/types';
|
||||
import { emitSaved } from '../lib/savedEvent';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { SelectLite } from '../components/ui/SelectLite';
|
||||
import { Btn, Card, Collapsible, PageHeader, Pill } from './_atoms';
|
||||
import {
|
||||
deleteLocalAsrModel,
|
||||
@@ -454,15 +455,17 @@ function RecordingSection() {
|
||||
label={t('settings.recording.pasteShortcutLabel')}
|
||||
desc={t('settings.recording.pasteShortcutDesc')}
|
||||
>
|
||||
<select
|
||||
<SelectLite
|
||||
value={prefs.pasteShortcut}
|
||||
onChange={e => onPasteShortcutChange(e.target.value as PasteShortcut)}
|
||||
onChange={next => onPasteShortcutChange(next as PasteShortcut)}
|
||||
options={[
|
||||
{ value: 'ctrlV', label: t('settings.recording.pasteShortcutCtrlV') },
|
||||
{ value: 'ctrlShiftV', label: t('settings.recording.pasteShortcutCtrlShiftV') },
|
||||
{ value: 'shiftInsert', label: t('settings.recording.pasteShortcutShiftInsert') },
|
||||
]}
|
||||
ariaLabel={t('settings.recording.pasteShortcutLabel')}
|
||||
style={{ ...inputStyle, maxWidth: 220 }}
|
||||
>
|
||||
<option value="ctrlV">{t('settings.recording.pasteShortcutCtrlV')}</option>
|
||||
<option value="ctrlShiftV">{t('settings.recording.pasteShortcutCtrlShiftV')}</option>
|
||||
<option value="shiftInsert">{t('settings.recording.pasteShortcutShiftInsert')}</option>
|
||||
</select>
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{capability.adapter === 'windowsLowLevel' && (
|
||||
@@ -1386,15 +1389,16 @@ function ProvidersSection() {
|
||||
{/* desc 已去掉——'选择后将自动填入 Base URL 默认值' 在 180px label 列必换行成两行,
|
||||
视觉上 label 区出现"字体单独占一行"。下拉自身已经表达了"切换"含义,desc 冗余。 */}
|
||||
<SettingRow label={t('settings.providers.providerLabel')}>
|
||||
<select
|
||||
<SelectLite
|
||||
value={llmProvider}
|
||||
onChange={e => onLlmProviderChange(e.target.value as LlmPresetId)}
|
||||
onChange={next => onLlmProviderChange(next as LlmPresetId)}
|
||||
options={LLM_PRESETS.map(p => ({
|
||||
value: p.id,
|
||||
label: t(`settings.providers.presets.${p.nameKey}`),
|
||||
}))}
|
||||
ariaLabel={t('settings.providers.providerLabel')}
|
||||
style={{ ...inputStyle, maxWidth: 200 }}
|
||||
>
|
||||
{LLM_PRESETS.map(p => (
|
||||
<option key={p.id} value={p.id}>{t(`settings.providers.presets.${p.nameKey}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</SettingRow>
|
||||
{codexOAuthSelected ? (
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, margin: '2px 0 10px' }}>
|
||||
@@ -1455,30 +1459,33 @@ function ProvidersSection() {
|
||||
: null;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start', minWidth: 0 }}>
|
||||
<select
|
||||
<SelectLite
|
||||
value={selectedValue}
|
||||
disabled={isLocked}
|
||||
onChange={e => onAsrProviderChange(e.target.value as AsrPresetId)}
|
||||
style={{
|
||||
...inputStyle,
|
||||
maxWidth: 200,
|
||||
...(isLocked ? { opacity: 0.65, cursor: 'not-allowed' } : {}),
|
||||
}}
|
||||
>
|
||||
{visibleAsrPresets.map(p => (
|
||||
<option key={p.id} value={p.id}>{t(`settings.providers.presets.${p.nameKey}`)}</option>
|
||||
))}
|
||||
{platformLocalAsr && platformLocalNameKey && (
|
||||
<option key={platformLocalAsr} value={platformLocalAsr} disabled>
|
||||
{t(`settings.providers.presets.${platformLocalNameKey}`)}
|
||||
</option>
|
||||
)}
|
||||
{anomalousLocal && anomalousNameKey && (
|
||||
<option key={anomalousLocal} value={anomalousLocal} disabled>
|
||||
{t(`settings.providers.presets.${anomalousNameKey}`)}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
onChange={next => onAsrProviderChange(next as AsrPresetId)}
|
||||
options={[
|
||||
...visibleAsrPresets.map(p => ({
|
||||
value: p.id,
|
||||
label: t(`settings.providers.presets.${p.nameKey}`),
|
||||
})),
|
||||
...(platformLocalAsr && platformLocalNameKey
|
||||
? [{
|
||||
value: platformLocalAsr,
|
||||
label: t(`settings.providers.presets.${platformLocalNameKey}`),
|
||||
disabled: true,
|
||||
}]
|
||||
: []),
|
||||
...(anomalousLocal && anomalousNameKey
|
||||
? [{
|
||||
value: anomalousLocal,
|
||||
label: t(`settings.providers.presets.${anomalousNameKey}`),
|
||||
disabled: true,
|
||||
}]
|
||||
: []),
|
||||
]}
|
||||
ariaLabel={t('settings.providers.providerLabel')}
|
||||
style={{ ...inputStyle, maxWidth: 200 }}
|
||||
/>
|
||||
{platformLocalAsr && platformLocalNameKey && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', lineHeight: 1.5, maxWidth: 320 }}>
|
||||
{t('settings.providers.localAsrTakeoverHint', {
|
||||
@@ -1623,15 +1630,15 @@ function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' |
|
||||
<button onClick={validate} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.validate')}</button>
|
||||
<button onClick={loadModels} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.fetchModels')}</button>
|
||||
{models.length > 0 && (
|
||||
<select
|
||||
<SelectLite
|
||||
value={selectedModel}
|
||||
onChange={e => applyModel(e.target.value)}
|
||||
onChange={applyModel}
|
||||
disabled={status === 'loading'}
|
||||
options={models.map(model => ({ value: model, label: model }))}
|
||||
placeholder={t('settings.providers.selectModel')}
|
||||
ariaLabel={t('settings.providers.selectModel')}
|
||||
style={{ ...inputStyle, maxWidth: 220 }}
|
||||
>
|
||||
<option value="" disabled>{t('settings.providers.selectModel')}</option>
|
||||
{models.map(model => <option key={model} value={model}>{model}</option>)}
|
||||
</select>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{message && (
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
// - 选一个翻译目标语言(单选;选"不启用"则 Shift 不触发翻译)
|
||||
// - 看完整使用说明(怎么触发、按钮位置、胶囊显示)
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, PageHeader } from './_atoms';
|
||||
import { SavedToast } from '../components/SavedToast';
|
||||
import { SelectLite } from '../components/ui/SelectLite';
|
||||
import { SUPPORTED_LANGUAGES } from '../lib/types';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { formatComboLabel } from '../lib/hotkey';
|
||||
@@ -120,6 +121,11 @@ export function Translation() {
|
||||
const translationHotkeyLabel = formatComboLabel(prefs.translationHotkey);
|
||||
const enabled = prefs.translationTargetLanguage.trim() !== '';
|
||||
|
||||
const targetOptions = useMemo(() => ([
|
||||
{ value: '', label: t('translation.target.disabled') },
|
||||
...SUPPORTED_LANGUAGES.map(lang => ({ value: lang, label: lang })),
|
||||
]), [t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t('translation.title')} />
|
||||
@@ -216,28 +222,14 @@ export function Translation() {
|
||||
{enabled ? t('translation.statusEnabled') : t('translation.statusDisabled')}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
<SelectLite
|
||||
value={prefs.translationTargetLanguage}
|
||||
onChange={e => onTargetChange(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
height: 32,
|
||||
padding: '0 10px',
|
||||
fontSize: 13,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
borderRadius: 8,
|
||||
background: '#fff',
|
||||
color: 'var(--ol-ink)',
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<option value="">{t('translation.target.disabled')}</option>
|
||||
{SUPPORTED_LANGUAGES.map(lang => (
|
||||
<option key={lang} value={lang}>{lang}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={onTargetChange}
|
||||
options={targetOptions}
|
||||
placeholder={t('translation.target.disabled')}
|
||||
ariaLabel={t('translation.target.title')}
|
||||
style={{ width: '100%', maxWidth: 360, fontSize: 13, background: '#fff' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 3. 使用方法 */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 语言切换面板:跟随系统 / 简中 / 繁中 / 英文 / 日文 (Beta) / 韩文 (Beta)。
|
||||
// 切换语言同时把对应的 outputPrefs(中文偏好、输出语言)合并进 prefs。
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
||||
import {
|
||||
@@ -11,14 +11,24 @@ import {
|
||||
setLocalePreference,
|
||||
type SupportedLocale,
|
||||
} from '../../i18n';
|
||||
import { SelectLite } from '../../components/ui/SelectLite';
|
||||
import { Card } from '../_atoms';
|
||||
import { SettingRow, inputStyle } from './shared';
|
||||
import { SettingRow } from './shared';
|
||||
|
||||
export function LanguageSection() {
|
||||
const { t } = useTranslation();
|
||||
const { updatePrefs } = useHotkeySettings();
|
||||
const [pref, setPref] = useState<SupportedLocale | typeof FOLLOW_SYSTEM>(getLocalePreference());
|
||||
|
||||
const options = useMemo(() => ([
|
||||
{ value: FOLLOW_SYSTEM, label: t('settings.language.followSystem') },
|
||||
{ value: 'zh-CN', label: t('settings.language.zh') },
|
||||
{ value: 'zh-TW', label: t('settings.language.zhTW') },
|
||||
{ value: 'en', label: t('settings.language.en') },
|
||||
{ value: 'ja', label: t('settings.language.ja') },
|
||||
{ value: 'ko', label: t('settings.language.ko') },
|
||||
]), [t]);
|
||||
|
||||
const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => {
|
||||
setPref(next);
|
||||
const resolved = await setLocalePreference(next);
|
||||
@@ -39,18 +49,13 @@ export function LanguageSection() {
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('settings.language.title')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 6 }}>{t('settings.language.desc')}</div>
|
||||
<SettingRow label={t('settings.language.label')} desc={t('settings.language.labelDesc')}>
|
||||
<select
|
||||
<SelectLite
|
||||
value={pref}
|
||||
onChange={e => apply(e.target.value as SupportedLocale | typeof FOLLOW_SYSTEM)}
|
||||
style={{ ...inputStyle, maxWidth: 220 }}
|
||||
>
|
||||
<option value={FOLLOW_SYSTEM}>{t('settings.language.followSystem')}</option>
|
||||
<option value="zh-CN">{t('settings.language.zh')}</option>
|
||||
<option value="zh-TW">{t('settings.language.zhTW')}</option>
|
||||
<option value="en">{t('settings.language.en')}</option>
|
||||
<option value="ja">{t('settings.language.ja')}</option>
|
||||
<option value="ko">{t('settings.language.ko')}</option>
|
||||
</select>
|
||||
onChange={next => apply(next as SupportedLocale | typeof FOLLOW_SYSTEM)}
|
||||
options={options}
|
||||
ariaLabel={t('settings.language.label')}
|
||||
style={{ maxWidth: 220, minWidth: 200 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 8, lineHeight: 1.6 }}>
|
||||
{t('settings.language.restartHint')}
|
||||
|
||||
@@ -27,6 +27,18 @@ button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@keyframes ol-select-pop {
|
||||
from { opacity: 0; transform: translateY(-4px) scale(.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* 键盘焦点指示:鼠标点击 (:focus) 不显示,键盘 Tab (:focus-visible) 才显示。
|
||||
用 box-shadow 而非 outline 是因为组件内联 style 可能用 outline:none
|
||||
把默认 ring 干掉,box-shadow 不受影响仍能上色。配合 pr-agent #407 的 a11y 要求。 */
|
||||
.ol-focus-ring:focus-visible {
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.45);
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
font-family: inherit;
|
||||
user-select: text;
|
||||
|
||||
Reference in new issue
Block a user