diff --git a/README.md b/README.md
index 0bfcd39..6d84d16 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# OpenFlow
-Current version: `1.3.044`
+Current version: `1.3.045`
OpenFlow is a desktop voice dictation app for Windows and macOS built with Electron on the UI layer and Faster-Whisper for local transcription.
diff --git a/package-lock.json b/package-lock.json
index 9b42b5a..4259a16 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "openflow",
- "version": "1.3.044",
+ "version": "1.3.045",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openflow",
- "version": "1.3.044",
+ "version": "1.3.045",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"dotenv": "^17.3.1",
diff --git a/package.json b/package.json
index b0d566b..7267136 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "openflow",
"productName": "OpenFlow",
- "version": "1.3.044",
+ "version": "1.3.045",
"description": "Cross-platform desktop dictation app OpenFlow using Electron and Faster-Whisper.",
"main": "src/main/main.js",
"scripts": {
diff --git a/src/main/main.js b/src/main/main.js
index 753ea2c..22615dd 100644
--- a/src/main/main.js
+++ b/src/main/main.js
@@ -8,6 +8,7 @@ const {
Menu,
Tray,
clipboard,
+ dialog,
globalShortcut,
ipcMain,
nativeImage,
@@ -28,6 +29,7 @@ const DEFAULT_INTERFACE_LANGUAGE = 'en';
const DEFAULT_SHOW_OVERLAY_BAR = true;
const DEFAULT_SOUND_EFFECTS_ENABLED = true;
const DEFAULT_LAUNCH_AT_LOGIN = false;
+const DEFAULT_START_HIDDEN = false;
const DEFAULT_KEEP_ALL_TRANSCRIPTIONS = false;
const DEFAULT_TRANSCRIBE_CANCELLED_RECORDINGS = false;
const DEFAULT_AUTO_ENABLE_HANDS_FREE_MODE = false;
@@ -1080,6 +1082,7 @@ function getDefaultsFromEnv() {
transcribeCancelledRecordings: DEFAULT_TRANSCRIBE_CANCELLED_RECORDINGS,
autoEnableHandsFreeMode: DEFAULT_AUTO_ENABLE_HANDS_FREE_MODE,
duckAudioEnabled: DEFAULT_DUCK_AUDIO,
+ startHidden: DEFAULT_START_HIDDEN,
overlayOpacity: DEFAULT_OVERLAY_OPACITY,
overlayScale: DEFAULT_OVERLAY_SCALE,
overlayDynamicSize: DEFAULT_OVERLAY_DYNAMIC_SIZE,
@@ -1140,6 +1143,7 @@ const state = {
transcribeCancelledRecordings: defaults.transcribeCancelledRecordings,
autoEnableHandsFreeMode: defaults.autoEnableHandsFreeMode,
duckAudioEnabled: defaults.duckAudioEnabled,
+ startHidden: defaults.startHidden,
overlayOpacity: defaults.overlayOpacity,
overlayScale: defaults.overlayScale,
overlayDynamicSize: defaults.overlayDynamicSize,
@@ -1384,6 +1388,7 @@ function createEmptyPersistedState() {
transcribeCancelledRecordings: defaults.transcribeCancelledRecordings,
autoEnableHandsFreeMode: defaults.autoEnableHandsFreeMode,
duckAudioEnabled: defaults.duckAudioEnabled,
+ startHidden: defaults.startHidden,
overlayOpacity: defaults.overlayOpacity,
overlayScale: defaults.overlayScale,
overlayDynamicSize: defaults.overlayDynamicSize,
@@ -1456,6 +1461,7 @@ function normalizePersistedState(payload) {
preferencesSource.duckAudioEnabled,
defaults.duckAudioEnabled,
),
+ startHidden: normalizeBooleanPreference(preferencesSource.startHidden, defaults.startHidden),
overlayOpacity: normalizeOverlayOpacity(preferencesSource.overlayOpacity),
overlayScale: normalizeOverlayScale(preferencesSource.overlayScale),
overlayDynamicSize: normalizeBooleanPreference(
@@ -1851,6 +1857,7 @@ function savePersistentState() {
transcribeCancelledRecordings: state.transcribeCancelledRecordings,
autoEnableHandsFreeMode: state.autoEnableHandsFreeMode,
duckAudioEnabled: state.duckAudioEnabled,
+ startHidden: state.startHidden,
overlayOpacity: state.overlayOpacity,
overlayScale: state.overlayScale,
overlayDynamicSize: state.overlayDynamicSize,
@@ -4377,6 +4384,8 @@ async function applySettings(patch) {
: state.pasteLastShortcut;
const nextDuckAudioEnabled =
typeof patch.duckAudioEnabled === 'boolean' ? patch.duckAudioEnabled : state.duckAudioEnabled;
+ const nextStartHidden =
+ typeof patch.startHidden === 'boolean' ? patch.startHidden : state.startHidden;
const nextOverlayOpacity = Object.prototype.hasOwnProperty.call(patch, 'overlayOpacity')
? normalizeOverlayOpacity(patch.overlayOpacity)
: state.overlayOpacity;
@@ -4532,6 +4541,7 @@ async function applySettings(patch) {
transcribeCancelledRecordings: nextTranscribeCancelledRecordings,
autoEnableHandsFreeMode: nextAutoEnableHandsFreeMode,
duckAudioEnabled: nextDuckAudioEnabled,
+ startHidden: nextStartHidden,
overlayOpacity: nextOverlayOpacity,
overlayScale: nextOverlayScale,
overlayDynamicSize: nextOverlayDynamicSize,
@@ -5152,8 +5162,145 @@ function scheduleStartupUpdateCheck() {
}, 8000);
}
+function deleteHistoryEntry(entryKey) {
+ const key = entryKey && typeof entryKey === 'object' ? entryKey : {};
+ const timestamp = String(key.timestamp || '');
+ const text = String(key.text || '');
+ const index = state.history.findIndex(
+ (entry) => entry.timestamp === timestamp && entry.text === text,
+ );
+ if (index === -1) {
+ return snapshotState();
+ }
+
+ const nextHistory = [...state.history];
+ nextHistory.splice(index, 1);
+ setState({ history: nextHistory });
+ savePersistentState();
+ return snapshotState();
+}
+
+function formatHistoryExportText(entries) {
+ return entries
+ .map((entry) => {
+ const timestamp = new Date(entry.timestamp);
+ const stamp = Number.isNaN(timestamp.getTime())
+ ? String(entry.timestamp || '')
+ : timestamp.toLocaleString();
+ return `[${stamp}] ${entry.text}`;
+ })
+ .join('\n\n');
+}
+
+async function exportHistoryToFile() {
+ const entries = Array.isArray(state.history) ? state.history : [];
+ if (entries.length === 0) {
+ return { status: 'empty' };
+ }
+
+ const dateSuffix = new Date().toISOString().slice(0, 10);
+ const result = await dialog.showSaveDialog(mainWindow, {
+ defaultPath: `openflow-history-${dateSuffix}.txt`,
+ filters: [
+ { name: 'Text', extensions: ['txt'] },
+ { name: 'JSON', extensions: ['json'] },
+ ],
+ });
+ if (result.canceled || !result.filePath) {
+ return { status: 'canceled' };
+ }
+
+ const isJson = path.extname(result.filePath).toLowerCase() === '.json';
+ const contents = isJson
+ ? `${JSON.stringify(entries, null, 2)}\n`
+ : `${formatHistoryExportText(entries)}\n`;
+ try {
+ fs.writeFileSync(result.filePath, contents, 'utf8');
+ } catch (error) {
+ return { status: 'error', message: error?.message || String(error) };
+ }
+
+ return { status: 'saved', filePath: result.filePath, count: entries.length };
+}
+
+async function exportDictionaryToFile() {
+ const entries = Array.isArray(state.dictionaryEntries) ? state.dictionaryEntries : [];
+ if (entries.length === 0) {
+ return { status: 'empty' };
+ }
+
+ const dateSuffix = new Date().toISOString().slice(0, 10);
+ const result = await dialog.showSaveDialog(mainWindow, {
+ defaultPath: `openflow-dictionary-${dateSuffix}.json`,
+ filters: [{ name: 'JSON', extensions: ['json'] }],
+ });
+ if (result.canceled || !result.filePath) {
+ return { status: 'canceled' };
+ }
+
+ try {
+ fs.writeFileSync(
+ result.filePath,
+ `${JSON.stringify({ dictionaryEntries: entries }, null, 2)}\n`,
+ 'utf8',
+ );
+ } catch (error) {
+ return { status: 'error', message: error?.message || String(error) };
+ }
+
+ return { status: 'saved', filePath: result.filePath, count: entries.length };
+}
+
+async function importDictionaryFromFile() {
+ const result = await dialog.showOpenDialog(mainWindow, {
+ properties: ['openFile'],
+ filters: [{ name: 'JSON', extensions: ['json'] }],
+ });
+ if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
+ return { status: 'canceled' };
+ }
+
+ let payload = null;
+ try {
+ payload = JSON.parse(fs.readFileSync(result.filePaths[0], 'utf8'));
+ } catch (error) {
+ return { status: 'invalid', message: error?.message || String(error) };
+ }
+
+ const rawEntries = Array.isArray(payload) ? payload : payload?.dictionaryEntries;
+ if (!Array.isArray(rawEntries)) {
+ return { status: 'invalid' };
+ }
+
+ // Drop imported ids so merged rules always get fresh ids and cannot collide
+ // with existing ones. normalizeDictionaryEntries dedupes identical rules.
+ const imported = rawEntries.map((entry) =>
+ entry && typeof entry === 'object' ? { ...entry, id: undefined } : entry,
+ );
+ const before = state.dictionaryEntries.length;
+ const merged = normalizeDictionaryEntries([...state.dictionaryEntries, ...imported]);
+ const added = merged.length - before;
+ if (added <= 0) {
+ return { status: 'none' };
+ }
+
+ await applySettings({ dictionaryEntries: merged });
+ return { status: 'imported', count: added };
+}
+
+function resetOverlayPosition() {
+ state.overlayPosition = null;
+ positionOverlayWindow(null, true);
+ return snapshotState();
+}
+
ipcMain.handle('get-state', async () => snapshotState());
ipcMain.handle('update-settings', async (_event, patch) => applySettings(patch || {}));
+ipcMain.handle('delete-history-entry', async (_event, entryKey) => deleteHistoryEntry(entryKey));
+ipcMain.handle('export-history', async () => exportHistoryToFile());
+ipcMain.handle('export-dictionary', async () => exportDictionaryToFile());
+ipcMain.handle('import-dictionary', async () => importDictionaryFromFile());
+ipcMain.handle('reset-overlay-position', async () => resetOverlayPosition());
ipcMain.handle('reset-model-stats', async () => resetModelStats());
ipcMain.handle('save-openrouter-api-key', async (_event, apiKey) => saveOpenRouterApiKey(apiKey));
ipcMain.handle('clear-openrouter-api-key', async () => clearOpenRouterApiKey());
@@ -5223,6 +5370,7 @@ app.whenReady().then(() => {
transcribeCancelledRecordings: persistedState.preferences.transcribeCancelledRecordings,
autoEnableHandsFreeMode: persistedState.preferences.autoEnableHandsFreeMode,
duckAudioEnabled: persistedState.preferences.duckAudioEnabled,
+ startHidden: persistedState.preferences.startHidden,
overlayOpacity: persistedState.preferences.overlayOpacity,
overlayScale: persistedState.preferences.overlayScale,
overlayDynamicSize: persistedState.preferences.overlayDynamicSize,
@@ -5239,7 +5387,9 @@ app.whenReady().then(() => {
savePersistentState();
}
shouldStartHiddenOnLaunch =
- shouldStartHiddenOnLaunch || Boolean(app.getLoginItemSettings().wasOpenedAsHidden);
+ shouldStartHiddenOnLaunch ||
+ Boolean(app.getLoginItemSettings().wasOpenedAsHidden) ||
+ Boolean(persistedState.preferences.startHidden);
rebuildDictionaryReplacementIndex(persistedState.preferences.dictionaryEntries);
syncLaunchAtLoginSetting();
registerPasteLastShortcut();
diff --git a/src/main/preload.js b/src/main/preload.js
index d4e89c4..b73af77 100644
--- a/src/main/preload.js
+++ b/src/main/preload.js
@@ -10,6 +10,11 @@ contextBridge.exposeInMainWorld('flowLocal', {
previewOverlayStyle: (patch) => ipcRenderer.send('preview-overlay-style', patch),
resetModelStats: () => ipcRenderer.invoke('reset-model-stats'),
copyText: (text) => ipcRenderer.invoke('copy-text', text),
+ deleteHistoryEntry: (entryKey) => ipcRenderer.invoke('delete-history-entry', entryKey),
+ exportHistory: () => ipcRenderer.invoke('export-history'),
+ exportDictionary: () => ipcRenderer.invoke('export-dictionary'),
+ importDictionary: () => ipcRenderer.invoke('import-dictionary'),
+ resetOverlayPosition: () => ipcRenderer.invoke('reset-overlay-position'),
onStateUpdate: (callback) => {
const listener = (_event, state) => callback(state);
ipcRenderer.on('app-state', listener);
diff --git a/src/renderer/index.html b/src/renderer/index.html
index dff18e1..abb55a6 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -84,7 +84,7 @@
Latest transcriptions
-
@@ -220,6 +229,18 @@
Floating bar
+
+
+
Start minimized to tray
+
+ Keep the main window hidden when OpenFlow starts. Use the tray icon to open it.
+
+
+
+
+
+
+
Sound feedback
@@ -431,6 +452,14 @@
Floating bar appearance
+
+
+ Moved the pill somewhere awkward? Bring it back to the default spot.
+
+
+
@@ -685,6 +714,15 @@
Your rules
autocomplete="off"
/>
+
+
+
+
+
diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js
index 2c0fe84..32523ac 100644
--- a/src/renderer/renderer.js
+++ b/src/renderer/renderer.js
@@ -146,6 +146,24 @@ const TRANSLATIONS = {
fillRuleError: 'Add at least one variation to replace.',
copy: 'Copy',
copied: 'Copied',
+ deleteEntry: 'Delete',
+ exportHistory: 'Export',
+ historyExported: 'History exported ({count} messages).',
+ historyExportEmpty: 'No transcriptions to export.',
+ exportFailed: 'Export failed: {message}',
+ confirmReset: 'Click again to confirm',
+ startHidden: 'Start minimized to tray',
+ startHiddenCopy: 'Keep the main window hidden when OpenFlow starts. Use the tray icon to open it.',
+ resetOverlayPosition: 'Reset position',
+ resetOverlayPositionCopy: 'Moved the pill somewhere awkward? Bring it back to the default spot.',
+ overlayPositionReset: 'Floating bar is back at the default position.',
+ exportDictionary: 'Export',
+ importDictionary: 'Import',
+ dictionaryExported: 'Dictionary exported ({count} rules).',
+ dictionaryExportEmpty: 'No rules to export.',
+ dictionaryImported: '{count} rule(s) imported.',
+ dictionaryImportNone: 'No new rules to import.',
+ dictionaryImportInvalid: 'This file is not a valid dictionary export.',
loadingStatusTitle: 'Loading model',
loadingStatusDetail: '',
loadingStatusHint: '',
@@ -304,6 +322,24 @@ const TRANSLATIONS = {
fillRuleError: 'Adicione pelo menos uma variação para substituir.',
copy: 'Copiar',
copied: 'Copiado',
+ deleteEntry: 'Excluir',
+ exportHistory: 'Exportar',
+ historyExported: 'Histórico exportado ({count} mensagens).',
+ historyExportEmpty: 'Nenhuma transcrição para exportar.',
+ exportFailed: 'Falha ao exportar: {message}',
+ confirmReset: 'Clique de novo para confirmar',
+ startHidden: 'Iniciar minimizado na bandeja',
+ startHiddenCopy: 'Mantém a janela principal oculta quando o OpenFlow abre. Use o ícone da bandeja para abrir.',
+ resetOverlayPosition: 'Restaurar posição',
+ resetOverlayPositionCopy: 'Moveu a barra para um lugar ruim? Traga ela de volta para o lugar padrão.',
+ overlayPositionReset: 'Barra flutuante de volta à posição padrão.',
+ exportDictionary: 'Exportar',
+ importDictionary: 'Importar',
+ dictionaryExported: 'Dicionário exportado ({count} regras).',
+ dictionaryExportEmpty: 'Nenhuma regra para exportar.',
+ dictionaryImported: '{count} regra(s) importada(s).',
+ dictionaryImportNone: 'Nenhuma regra nova para importar.',
+ dictionaryImportInvalid: 'Este arquivo não é um dicionário exportado válido.',
loadingStatusTitle: 'Carregando o modelo',
loadingStatusDetail: '',
loadingStatusHint: '',
@@ -600,6 +636,8 @@ const els = {
historyList: document.getElementById('history-list'),
historyCount: document.getElementById('history-count'),
historySearch: document.getElementById('history-search'),
+ historySearchClear: document.getElementById('history-search-clear'),
+ exportHistory: document.getElementById('export-history'),
historyCopy: document.getElementById('history-copy'),
shortcutLabel: document.getElementById('shortcut-label'),
pasteShortcutLabel: document.getElementById('paste-shortcut-label'),
@@ -655,6 +693,8 @@ const els = {
dictionaryList: document.getElementById('dictionary-list'),
dictionaryCount: document.getElementById('dictionary-count'),
dictionarySearch: document.getElementById('dictionary-search'),
+ exportDictionary: document.getElementById('export-dictionary'),
+ importDictionary: document.getElementById('import-dictionary'),
cancelDictionaryEdit: document.getElementById('cancel-dictionary-edit'),
submitDictionaryRule: document.getElementById('submit-dictionary-rule'),
openSettings: document.getElementById('open-settings'),
@@ -670,6 +710,8 @@ const els = {
shortcutCaptureKeys: document.getElementById('shortcut-capture-keys'),
pasteShortcutCaptureKeys: document.getElementById('paste-shortcut-capture-keys'),
duckAudio: document.getElementById('duck-audio'),
+ startHidden: document.getElementById('start-hidden'),
+ resetOverlayPosition: document.getElementById('reset-overlay-position'),
overlayOpacity: document.getElementById('overlay-opacity'),
overlayOpacityValue: document.getElementById('overlay-opacity-value'),
overlayScale: document.getElementById('overlay-scale'),
@@ -1173,6 +1215,33 @@ function renderInterfaceLanguages() {
.join('');
}
+function highlightMatch(text, query) {
+ const value = String(text ?? '');
+ if (!query) {
+ return esc(value);
+ }
+
+ const needle = query.toLocaleLowerCase(locale());
+ const haystack = value.toLocaleLowerCase(locale());
+ if (haystack.length !== value.length) {
+ // Case folding changed string length; offsets would be unreliable.
+ return esc(value);
+ }
+
+ let result = '';
+ let index = 0;
+ while (index <= value.length) {
+ const found = haystack.indexOf(needle, index);
+ if (found === -1) {
+ result += esc(value.slice(index));
+ break;
+ }
+ result += `${esc(value.slice(index, found))}${esc(value.slice(found, found + needle.length))}`;
+ index = found + needle.length;
+ }
+ return result;
+}
+
function renderHistory(history, total) {
const list = Array.isArray(history) ? history : [];
const signature = [
@@ -1230,7 +1299,7 @@ function renderHistory(history, total) {
${timestamp.toLocaleDateString(locale(), { day: '2-digit', month: '2-digit' })}
-
${esc(entry.text)}
+
${highlightMatch(entry.text, query)}
${esc(modelLabel(entry.model))}
${esc(capitalizeLanguageLabel(langName(entry.language || 'en')))}
@@ -1239,7 +1308,10 @@ function renderHistory(history, total) {
${intFmt(entry.wordCount || 0)} ${wordsLabel}
-
+
+
+
+
`;
})
@@ -1673,6 +1745,7 @@ function renderState(state) {
els.transcribeCancelledRecordings.checked = Boolean(state.transcribeCancelledRecordings);
els.autoEnableHandsFreeMode.checked = Boolean(state.autoEnableHandsFreeMode);
els.duckAudio.checked = Boolean(state.duckAudioEnabled);
+ els.startHidden.checked = Boolean(state.startHidden);
els.overlayDynamicSize.checked = Boolean(state.overlayDynamicSize);
// Don't fight the user while they are dragging a slider or recording a shortcut.
@@ -2079,7 +2152,23 @@ function setupHandlers() {
}
});
+ let resetStatsConfirmTimer = null;
+ const restoreResetStatsButton = () => {
+ if (resetStatsConfirmTimer) {
+ window.clearTimeout(resetStatsConfirmTimer);
+ resetStatsConfirmTimer = null;
+ }
+ delete els.resetStats.dataset.confirming;
+ els.resetStats.textContent = t('resetAverage');
+ };
els.resetStats.addEventListener('click', async () => {
+ if (els.resetStats.dataset.confirming !== 'true') {
+ els.resetStats.dataset.confirming = 'true';
+ els.resetStats.textContent = t('confirmReset');
+ resetStatsConfirmTimer = window.setTimeout(restoreResetStatsButton, 3000);
+ return;
+ }
+ restoreResetStatsButton();
renderState(await window.flowLocal.resetModelStats());
});
@@ -2291,6 +2380,13 @@ function setupHandlers() {
els.duckAudio.addEventListener('change', async () => {
renderState(await window.flowLocal.updateSettings({ duckAudioEnabled: els.duckAudio.checked }));
});
+ els.startHidden.addEventListener('change', async () => {
+ renderState(await window.flowLocal.updateSettings({ startHidden: els.startHidden.checked }));
+ });
+ els.resetOverlayPosition.addEventListener('click', async () => {
+ renderState(await window.flowLocal.resetOverlayPosition());
+ showToast(t('overlayPositionReset'));
+ });
els.overlayOpacity.addEventListener('input', () => {
els.overlayOpacityValue.textContent = `${els.overlayOpacity.value}%`;
@@ -2327,12 +2423,47 @@ function setupHandlers() {
true,
);
- els.historySearch.addEventListener('input', () => {
+ const applyHistoryFilter = () => {
historyFilter = els.historySearch.value || '';
+ els.historySearchClear.classList.toggle('hidden', !historyFilter);
if (lastState) renderHistory(lastState.history, lastState.historyTotal);
+ };
+ let historySearchDebounce = null;
+ els.historySearch.addEventListener('input', () => {
+ els.historySearchClear.classList.toggle('hidden', !els.historySearch.value);
+ // Debounced: re-rendering the full list on every keystroke gets slow once
+ // "keep all transcriptions" grows the history into the thousands.
+ if (historySearchDebounce) window.clearTimeout(historySearchDebounce);
+ historySearchDebounce = window.setTimeout(applyHistoryFilter, 150);
+ });
+ els.historySearchClear.addEventListener('click', () => {
+ els.historySearch.value = '';
+ applyHistoryFilter();
+ els.historySearch.focus();
+ });
+
+ els.exportHistory.addEventListener('click', async () => {
+ const result = await window.flowLocal.exportHistory();
+ if (result?.status === 'saved') {
+ showToast(template('historyExported', { count: result.count }));
+ } else if (result?.status === 'empty') {
+ showToast(t('historyExportEmpty'));
+ } else if (result?.status === 'error') {
+ showToast(template('exportFailed', { message: result.message || '' }));
+ }
});
els.historyList.addEventListener('click', async (event) => {
+ const deleteButton = event.target.closest('[data-history-delete]');
+ if (deleteButton) {
+ const entry = renderedHistory[Number(deleteButton.getAttribute('data-history-delete'))];
+ if (!entry) return;
+ renderState(
+ await window.flowLocal.deleteHistoryEntry({ timestamp: entry.timestamp, text: entry.text }),
+ );
+ return;
+ }
+
const button = event.target.closest('[data-history-index]');
if (!button) return;
const entry = renderedHistory[Number(button.getAttribute('data-history-index'))];
@@ -2418,7 +2549,41 @@ function setupHandlers() {
resetDictionaryForm(getDictionaryFallbackLanguages());
});
+ els.exportDictionary.addEventListener('click', async () => {
+ const result = await window.flowLocal.exportDictionary();
+ if (result?.status === 'saved') {
+ showToast(template('dictionaryExported', { count: result.count }));
+ } else if (result?.status === 'empty') {
+ showToast(t('dictionaryExportEmpty'));
+ } else if (result?.status === 'error') {
+ showToast(template('exportFailed', { message: result.message || '' }));
+ }
+ });
+
+ els.importDictionary.addEventListener('click', async () => {
+ const result = await window.flowLocal.importDictionary();
+ if (result?.status === 'imported') {
+ renderState(await window.flowLocal.getState());
+ showToast(template('dictionaryImported', { count: result.count }));
+ } else if (result?.status === 'none') {
+ showToast(t('dictionaryImportNone'));
+ } else if (result?.status === 'invalid') {
+ showToast(t('dictionaryImportInvalid'));
+ }
+ });
+
window.addEventListener('keydown', (event) => {
+ if ((event.ctrlKey || event.metaKey) && String(event.key).toLowerCase() === 'f') {
+ event.preventDefault();
+ if (dictionaryOpen) {
+ els.dictionarySearch.focus();
+ els.dictionarySearch.select();
+ } else if (!settingsOpen && !advancedOpen) {
+ els.historySearch.focus();
+ els.historySearch.select();
+ }
+ return;
+ }
if (event.key !== 'Escape') {
return;
}
diff --git a/src/renderer/styles.css b/src/renderer/styles.css
index bf09c15..c9a09f2 100644
--- a/src/renderer/styles.css
+++ b/src/renderer/styles.css
@@ -391,12 +391,60 @@ h1, h2, h3 {
.history-toolbar {
margin-bottom: 16px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.history-toolbar .search-field {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.history-toolbar .secondary-button {
+ flex: 0 0 auto;
}
.search-field {
display: block;
}
+.search-field--clearable {
+ position: relative;
+}
+
+.search-field--clearable input {
+ padding-right: 38px;
+}
+
+.search-clear {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 24px;
+ height: 24px;
+ border: none;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--muted);
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.search-clear:hover {
+ background: var(--panel-bg-subtle);
+ color: var(--text);
+}
+
+.search-clear.hidden {
+ display: none;
+}
+
.search-field--settings {
margin-bottom: 14px;
}
@@ -482,6 +530,31 @@ h1, h2, h3 {
color: var(--muted);
}
+.history-item__body mark {
+ /* Translucent amber keeps readable contrast in both dark and light themes. */
+ background: rgba(245, 158, 11, 0.35);
+ color: inherit;
+ border-radius: 3px;
+ padding: 0 1px;
+}
+
+.history-item__actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: 0 0 auto;
+}
+
+.history-delete {
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+.history-item:hover .history-delete,
+.history-delete:focus-visible {
+ opacity: 1;
+}
+
.copy-button {
height: 32px;
padding: 0 12px;
@@ -891,6 +964,27 @@ h1, h2, h3 {
font-size: 1.05rem;
}
+.dictionary-toolbar__actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 0 0 auto;
+}
+
+.reset-position-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-top: 12px;
+}
+
+.reset-position-row p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
.dictionary-search {
position: relative;
flex: 0 1 260px;