Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions openless-all/app/src/components/ShortcutRecorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ export function ShortcutRecorder({
value,
onSave,
alignRecordButton = false,
disabled = false,
}: {
value: ShortcutBinding;
onSave: (binding: ShortcutBinding) => Promise<void>;
alignRecordButton?: boolean;
disabled?: boolean;
}) {
const { t } = useTranslation();
const [recording, setRecording] = useState(false);
Expand Down Expand Up @@ -39,6 +41,12 @@ export function ShortcutRecorder({
};
}, [recording]);

useEffect(() => {
if (!disabled || !recording) return;
setRecording(false);
clearPendingModifier();
}, [disabled, recording]);

const finish = async (binding: ShortcutBinding) => {
try {
await validateShortcutBinding(binding);
Expand All @@ -52,7 +60,7 @@ export function ShortcutRecorder({
};

const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!recording) return;
if (!recording || disabled) return;
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
Expand Down Expand Up @@ -80,7 +88,7 @@ export function ShortcutRecorder({
};

const onKeyUp = (e: KeyboardEvent<HTMLDivElement>) => {
if (!recording || !isModifierKey(e.key)) return;
if (!recording || disabled || !isModifierKey(e.key)) return;
e.preventDefault();
e.stopPropagation();
const primary = modifierPrimaryFromCode(e.code, e.key);
Expand Down Expand Up @@ -113,7 +121,8 @@ export function ShortcutRecorder({
borderRadius: 6,
fontFamily: 'inherit',
fontWeight: 500,
cursor: recording ? 'default' : 'pointer',
cursor: recording || disabled ? 'default' : 'pointer',
opacity: disabled ? 0.68 : 1,
marginLeft: alignRecordButton ? 'auto' : undefined,
};

Expand All @@ -125,11 +134,12 @@ export function ShortcutRecorder({
</span>
<button
onClick={() => {
if (disabled) return;
setRecording(true);
setError(null);
clearPendingModifier();
}}
disabled={recording}
disabled={recording || disabled}
style={recordButtonStyle}
>
{recording ? t('settings.recording.comboRecordHint') : t('settings.recording.comboRecordBtn')}
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,11 @@ export const en: typeof zhCN = {
optionDisabled: 'Disabled',
chordWarning: '',
},
save: {
hotkeyRegisterFailed: 'Failed to register the Selection Ask shortcut. The preference was not saved.',
hotkeySaveFailed: 'Failed to save the Selection Ask shortcut. Please try again.',
historySaveFailed: 'Failed to save the Q&A history setting. Please try again.',
},
history: {
title: 'Save history',
desc: 'When on, every selection + voice question + AI answer is persisted locally (never uploaded). Off by default — closing the panel forgets the exchange entirely, more private.',
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ export const ja: typeof zhCN = {
optionDisabled: '無効',
chordWarning: '',
},
save: {
hotkeyRegisterFailed: '選択追問ショートカットの登録に失敗しました。設定は保存されていません。',
hotkeySaveFailed: '選択追問ショートカットの保存に失敗しました。もう一度お試しください。',
historySaveFailed: 'Q&A 履歴設定の保存に失敗しました。もう一度お試しください。',
},
history: {
title: '履歴を保存',
desc: 'ON にすると、各追問の「選択テキスト + あなたの音声質問 + AI 回答」をローカルに保存(クラウドへは送信しない)。デフォルト OFF では、フロートウィンドウを閉じると問答が消去されプライバシー優先。',
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ export const ko: typeof zhCN = {
optionDisabled: '비활성화',
chordWarning: '',
},
save: {
hotkeyRegisterFailed: '선택 질문 단축키 등록에 실패했습니다. 설정은 저장되지 않았습니다.',
hotkeySaveFailed: '선택 질문 단축키 저장에 실패했습니다. 다시 시도하세요.',
historySaveFailed: 'Q&A 기록 설정 저장에 실패했습니다. 다시 시도하세요.',
},
history: {
title: '기록 저장',
desc: '체크 시 매 후속 질문의 "선택 텍스트 + 음성 질문 + AI 답변"을 로컬 보관(클라우드 비전송). 기본 OFF 일 때는 창을 닫으면 문답이 즉시 사라져 프라이버시 우선.',
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ export const zhCN = {
optionDisabled: '不启用',
chordWarning: '',
},
save: {
hotkeyRegisterFailed: '划词追问快捷键注册失败,未继续保存。',
hotkeySaveFailed: '划词追问快捷键保存失败,请重试。',
historySaveFailed: 'Q&A 历史保存设置保存失败,请重试。',
},
history: {
title: '保存历史',
desc: '勾上则把每次追问的「选中文本 + 你的语音问题 + AI 答案」写入本地存档(不上云)。默认关,关闭时浮窗一关问答即遗忘,更注重隐私。',
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,11 @@ export const zhTW: typeof zhCN = {
optionDisabled: '不啓用',
chordWarning: '',
},
save: {
hotkeyRegisterFailed: '劃詞追問快捷鍵註冊失敗,未繼續保存。',
hotkeySaveFailed: '劃詞追問快捷鍵保存失敗,請重試。',
historySaveFailed: 'Q&A 歷史保存設置保存失敗,請重試。',
},
history: {
title: '保存歷史',
desc: '勾上則把每次追問的「選中文本 + 你的語音問題 + AI 答案」寫入本地存檔(不上雲)。默認關,關閉時浮窗一關問答即遺忘,更注重隱私。',
Expand Down
113 changes: 102 additions & 11 deletions openless-all/app/src/pages/SelectionAsk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,83 @@
// 这一页把原本散在 Settings → 录音 里的两条配置(hotkey 预设 / 保存 Q&A 历史)
// 集中起来 + 加完整使用指南,跟"翻译"页平级。

import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, PageHeader } from './_atoms';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { setQaHotkey } from '../lib/ipc';
import { defaultQaShortcut, formatComboLabel } from '../lib/hotkey';
import { ShortcutRecorder } from '../components/ShortcutRecorder';
import type { QaHotkeyBinding, UserPreferences } from '../lib/types';

type SaveState = 'idle' | 'saving' | 'saved' | 'failed';

export function SelectionAsk() {
const { t } = useTranslation();
const { prefs, updatePrefs: savePrefs } = useHotkeySettings();
const { prefs, refresh, updatePrefs: savePrefs } = useHotkeySettings();
const [saveState, setSaveState] = useState<SaveState>('idle');
const [saveMessage, setSaveMessage] = useState('');
const statusTimer = useRef<number | null>(null);
const defaultQaHotkey = defaultQaShortcut();
const defaultHotkeyLabel = formatComboLabel(defaultQaHotkey);
const recordHotkeyLabel = prefs ? formatComboLabel(prefs.dictationHotkey) : '快捷键';

useEffect(() => () => {
if (statusTimer.current !== null) window.clearTimeout(statusTimer.current);
}, []);

const showSaveStatus = (state: SaveState, message: string, temporary = false) => {
if (statusTimer.current !== null) {
window.clearTimeout(statusTimer.current);
statusTimer.current = null;
}
setSaveState(state);
setSaveMessage(message);
if (temporary) {
statusTimer.current = window.setTimeout(() => {
setSaveState('idle');
setSaveMessage('');
statusTimer.current = null;
}, 1600);
}
};

const persistPrefs = async (
resolveNext: (current: UserPreferences) => UserPreferences,
failureMessage: string,
) => {
try {
await savePrefs(resolveNext);
showSaveStatus('saved', t('common.saved'), true);
return true;
} catch (error) {
console.error('[selection-ask] failed to save preferences', error);
showSaveStatus('failed', failureMessage);
await refresh().catch(refreshError => {
console.warn('[selection-ask] failed to refresh preferences after save error', refreshError);
});
return false;
}
};

const saveQaHotkey = async (nextHotkey: QaHotkeyBinding | null) => {
showSaveStatus('saving', t('common.saving'));
try {
await setQaHotkey(nextHotkey);
} catch (error) {
console.error('[selection-ask] failed to register QA hotkey', error);
showSaveStatus('failed', t('selectionAsk.save.hotkeyRegisterFailed'));
await refresh().catch(refreshError => {
console.warn('[selection-ask] failed to refresh preferences after hotkey error', refreshError);
});
return;
}
await persistPrefs(
current => ({ ...current, qaHotkey: nextHotkey }),
t('selectionAsk.save.hotkeySaveFailed'),
);
};

if (!prefs) {
return (
<>
Expand All @@ -36,11 +100,17 @@ export function SelectionAsk() {
);
}

const onSaveHistoryChange = (qaSaveHistory: boolean) =>
savePrefs({ ...prefs, qaSaveHistory });
const onSaveHistoryChange = (qaSaveHistory: boolean) => {
showSaveStatus('saving', t('common.saving'));
void persistPrefs(
current => ({ ...current, qaSaveHistory }),
t('selectionAsk.save.historySaveFailed'),
);
};

const enabled = prefs.qaHotkey !== null;
const currentLabel = prefs.qaHotkey ? formatComboLabel(prefs.qaHotkey) : defaultHotkeyLabel;
const saving = saveState === 'saving';

return (
<>
Expand All @@ -54,16 +124,34 @@ export function SelectionAsk() {
/>

<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{saveState !== 'idle' && (
<div
role={saveState === 'failed' ? 'alert' : 'status'}
style={{
padding: '8px 12px',
borderRadius: 10,
border: saveState === 'failed'
? '0.5px solid rgba(239,68,68,0.22)'
: '0.5px solid rgba(37,99,235,0.16)',
background: saveState === 'failed' ? 'rgba(239,68,68,0.07)' : 'rgba(37,99,235,0.06)',
color: saveState === 'failed' ? 'var(--ol-red, #ef4444)' : 'var(--ol-blue)',
fontSize: 11.5,
lineHeight: 1.5,
}}
>
{saveMessage}
</div>
)}

{/* 1. 触发快捷键 */}
<Card>
<CardHeaderToggle
title={t('selectionAsk.hotkey.title')}
checked={enabled}
onToggle={async () => {
disabled={saving}
onToggle={() => {
const nextHotkey = enabled ? null : defaultQaHotkey;
await setQaHotkey(nextHotkey);
await savePrefs({ ...prefs, qaHotkey: nextHotkey });
void saveQaHotkey(nextHotkey);
}}
/>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: prefs.qaHotkey ? 12 : 0, lineHeight: 1.55 }}>
Expand All @@ -72,10 +160,8 @@ export function SelectionAsk() {
{prefs.qaHotkey && (
<ShortcutRecorder
value={prefs.qaHotkey}
onSave={async binding => {
await setQaHotkey(binding);
await savePrefs({ ...prefs, qaHotkey: binding });
}}
onSave={saveQaHotkey}
disabled={saving}
/>
)}
</Card>
Expand All @@ -85,6 +171,7 @@ export function SelectionAsk() {
<CardHeaderToggle
title={t('selectionAsk.history.title')}
checked={prefs.qaSaveHistory}
disabled={saving}
onToggle={() => onSaveHistoryChange(!prefs.qaSaveHistory)}
/>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.55 }}>
Expand Down Expand Up @@ -142,10 +229,12 @@ export function SelectionAsk() {
function CardHeaderToggle({
title,
checked,
disabled = false,
onToggle,
}: {
title: string;
checked: boolean;
disabled?: boolean;
onToggle: () => void;
}) {
return (
Expand All @@ -154,14 +243,16 @@ function CardHeaderToggle({
<button
onClick={onToggle}
aria-pressed={checked}
disabled={disabled}
style={{
position: 'relative',
width: 36,
height: 20,
borderRadius: 999,
border: 0,
background: checked ? 'var(--ol-blue)' : 'rgba(0,0,0,0.15)',
cursor: 'default',
cursor: disabled ? 'not-allowed' : 'default',
opacity: disabled ? 0.68 : 1,
transition: 'background 0.16s var(--ol-motion-quick)',
padding: 0,
}}
Expand Down
Loading