feat(SelectionAssistant): App Filter / 应用筛选 (#6519)

feat: add filter mode and list functionality to selection assistant

- Introduced new filter mode options (default, whitelist, blacklist) for the selection assistant.
- Added methods to set and get filter mode and filter list in ConfigManager.
- Enhanced SelectionService to manage filter mode and list, affecting text selection processing.
- Updated UI components to allow users to configure filter settings.
- Localized new filter settings in multiple languages.
This commit is contained in:
fullex 2025-05-28 16:25:21 +08:00 committed by GitHub
parent 94e6ba759e
commit f83d9fc03c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 347 additions and 8 deletions

View File

@ -186,6 +186,8 @@ export enum IpcChannel {
Selection_WriteToClipboard = 'selection:write-to-clipboard',
Selection_SetEnabled = 'selection:set-enabled',
Selection_SetTriggerMode = 'selection:set-trigger-mode',
Selection_SetFilterMode = 'selection:set-filter-mode',
Selection_SetFilterList = 'selection:set-filter-list',
Selection_SetFollowToolbar = 'selection:set-follow-toolbar',
Selection_ActionWindowClose = 'selection:action-window-close',
Selection_ActionWindowMinimize = 'selection:action-window-minimize',

View File

@ -19,7 +19,9 @@ export enum ConfigKeys {
EnableDataCollection = 'enableDataCollection',
SelectionAssistantEnabled = 'selectionAssistantEnabled',
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar'
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar',
SelectionAssistantFilterMode = 'selectionAssistantFilterMode',
SelectionAssistantFilterList = 'selectionAssistantFilterList'
}
export class ConfigManager {
@ -173,6 +175,22 @@ export class ConfigManager {
this.setAndNotify(ConfigKeys.SelectionAssistantFollowToolbar, value)
}
getSelectionAssistantFilterMode(): string {
return this.get<string>(ConfigKeys.SelectionAssistantFilterMode, 'default')
}
setSelectionAssistantFilterMode(value: string) {
this.setAndNotify(ConfigKeys.SelectionAssistantFilterMode, value)
}
getSelectionAssistantFilterList(): string[] {
return this.get<string[]>(ConfigKeys.SelectionAssistantFilterList, [])
}
setSelectionAssistantFilterList(value: string[]) {
this.setAndNotify(ConfigKeys.SelectionAssistantFilterList, value)
}
setAndNotify(key: string, value: unknown) {
this.set(key, value, true)
}

View File

@ -60,6 +60,8 @@ export class SelectionService {
private triggerMode = 'selected'
private isFollowToolbar = true
private filterMode = 'default'
private filterList: string[] = []
private toolbarWindow: BrowserWindow | null = null
private actionWindows = new Set<BrowserWindow>()
@ -138,6 +140,10 @@ export class SelectionService {
private initConfig() {
this.triggerMode = configManager.getSelectionAssistantTriggerMode()
this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar()
this.filterMode = configManager.getSelectionAssistantFilterMode()
this.filterList = configManager.getSelectionAssistantFilterList()
this.setHookClipboardMode(this.filterMode, this.filterList)
configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: string) => {
this.triggerMode = triggerMode
@ -147,6 +153,34 @@ export class SelectionService {
configManager.subscribe(ConfigKeys.SelectionAssistantFollowToolbar, (isFollowToolbar: boolean) => {
this.isFollowToolbar = isFollowToolbar
})
configManager.subscribe(ConfigKeys.SelectionAssistantFilterMode, (filterMode: string) => {
this.filterMode = filterMode
this.setHookClipboardMode(this.filterMode, this.filterList)
})
configManager.subscribe(ConfigKeys.SelectionAssistantFilterList, (filterList: string[]) => {
this.filterList = filterList
this.setHookClipboardMode(this.filterMode, this.filterList)
})
}
/**
* Set the clipboard mode for the selection-hook
* @param mode - The mode to set, either 'default', 'whitelist', or 'blacklist'
* @param list - An array of strings representing the list of items to include or exclude
*/
private setHookClipboardMode(mode: string, list: string[]) {
if (!this.selectionHook) return
const modeMap = {
default: 0,
whitelist: 1,
blacklist: 2
}
if (!this.selectionHook.setClipboardMode(modeMap[mode], list)) {
this.logError(new Error('Failed to set selection-hook clipboard mode'))
}
}
/**
@ -454,6 +488,30 @@ export class SelectionService {
return startTop.y === endTop.y && startBottom.y === endBottom.y
}
/**
* Determine if the text selection should be processed by filter mode&list
* @param selectionData Text selection information and coordinates
* @returns {boolean} True if the selection should be processed, false otherwise
*/
private shouldProcessTextSelection(selectionData: TextSelectionData): boolean {
if (selectionData.programName === '' || this.filterMode === 'default') {
return true
}
const programName = selectionData.programName.toLowerCase()
//items in filterList are already in lower case
const isFound = this.filterList.some((item) => programName.includes(item))
switch (this.filterMode) {
case 'whitelist':
return isFound
case 'blacklist':
return !isFound
}
return false
}
/**
* Process text selection data and show toolbar
* Handles different selection scenarios:
@ -468,6 +526,10 @@ export class SelectionService {
return
}
if (!this.shouldProcessTextSelection(selectionData)) {
return
}
// Determine reference point and position for toolbar
let refPoint: { x: number; y: number } = { x: 0, y: 0 }
let isLogical = false
@ -946,6 +1008,14 @@ export class SelectionService {
configManager.setSelectionAssistantFollowToolbar(isFollowToolbar)
})
ipcMain.handle(IpcChannel.Selection_SetFilterMode, (_, filterMode: string) => {
configManager.setSelectionAssistantFilterMode(filterMode)
})
ipcMain.handle(IpcChannel.Selection_SetFilterList, (_, filterList: string[]) => {
configManager.setSelectionAssistantFilterList(filterList)
})
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => {
selectionService?.processAction(actionItem)
})

View File

@ -218,6 +218,8 @@ const api = {
setTriggerMode: (triggerMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetTriggerMode, triggerMode),
setFollowToolbar: (isFollowToolbar: boolean) =>
ipcRenderer.invoke(IpcChannel.Selection_SetFollowToolbar, isFollowToolbar),
setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode),
setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList),
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),

View File

@ -2,6 +2,8 @@ import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
setActionItems,
setActionWindowOpacity,
setFilterList,
setFilterMode,
setIsAutoClose,
setIsAutoPin,
setIsCompact,
@ -9,7 +11,7 @@ import {
setSelectionEnabled,
setTriggerMode
} from '@renderer/store/selectionStore'
import { ActionItem, TriggerMode } from '@renderer/types/selectionTypes'
import { ActionItem, FilterMode, TriggerMode } from '@renderer/types/selectionTypes'
export function useSelectionAssistant() {
const dispatch = useAppDispatch()
@ -38,6 +40,14 @@ export function useSelectionAssistant() {
dispatch(setIsFollowToolbar(isFollowToolbar))
window.api.selection.setFollowToolbar(isFollowToolbar)
},
setFilterMode: (mode: FilterMode) => {
dispatch(setFilterMode(mode))
window.api.selection.setFilterMode(mode)
},
setFilterList: (list: string[]) => {
dispatch(setFilterList(list))
window.api.selection.setFilterList(list)
},
setActionWindowOpacity: (opacity: number) => {
dispatch(setActionWindowOpacity(opacity))
},

View File

@ -1908,6 +1908,20 @@
"delete_confirm": "Are you sure you want to delete this custom action?",
"drag_hint": "Drag to reorder. Move above to enable action ({{enabled}}/{{max}})"
},
"advanced": {
"title": "Advanced",
"filter_mode": {
"title": "Application Filter",
"description": "Can limit the selection assistant to only work in specific applications (whitelist) or not work (blacklist)",
"default": "Off",
"whitelist": "Whitelist",
"blacklist": "Blacklist"
},
"filter_list": {
"title": "Filter List",
"description": "Advanced feature, recommended for users with experience"
}
},
"user_modal": {
"title": {
"add": "Add Custom Action",
@ -1964,6 +1978,10 @@
},
"test": "Test"
}
},
"filter_modal": {
"title": "Application Filter List",
"user_tips": "Please enter the executable file name of the application, one per line, case insensitive, can be fuzzy matched. For example: chrome.exe, weixin.exe, Cherry Studio.exe, etc."
}
}
}

View File

@ -1908,6 +1908,20 @@
"delete_confirm": "このカスタム機能を削除しますか?",
"drag_hint": "ドラッグで並べ替え (有効{{enabled}}/最大{{max}})"
},
"advanced": {
"title": "進階",
"filter_mode": {
"title": "アプリケーションフィルター",
"description": "特定のアプリケーションでのみ選択ツールを有効にするか、無効にするかを選択できます。",
"default": "オフ",
"whitelist": "ホワイトリスト",
"blacklist": "ブラックリスト"
},
"filter_list": {
"title": "フィルターリスト",
"description": "進階機能です。経験豊富なユーザー向けです。"
}
},
"user_modal": {
"title": {
"add": "カスタム機能追加",
@ -1964,6 +1978,10 @@
},
"test": "テスト"
}
},
"filter_modal": {
"title": "アプリケーションフィルターリスト",
"user_tips": "アプリケーションの実行ファイル名を1行ずつ入力してください。大文字小文字は区別しません。例: chrome.exe, weixin.exe, Cherry Studio.exe, など。"
}
}
}

View File

@ -1909,6 +1909,20 @@
"delete_confirm": "Удалить это действие?",
"drag_hint": "Перетащите для сортировки. Включено: {{enabled}}/{{max}}"
},
"advanced": {
"title": "Расширенные",
"filter_mode": {
"title": "Режим фильтрации",
"description": "Можно ограничить выборку по определенным приложениям (белый список) или исключить их (черный список)",
"default": "Выключено",
"whitelist": "Белый список",
"blacklist": "Черный список"
},
"filter_list": {
"title": "Список фильтрации",
"description": "Расширенная функция, рекомендуется для пользователей с опытом"
}
},
"user_modal": {
"title": {
"add": "Добавить действие",
@ -1965,6 +1979,10 @@
},
"test": "Тест"
}
},
"filter_modal": {
"title": "Список фильтрации",
"user_tips": "Введите имя исполняемого файла приложения, один на строку, не учитывая регистр, можно использовать подстановку *"
}
}
}

View File

@ -1908,6 +1908,20 @@
"delete_confirm": "确定要删除这个自定义功能吗?",
"drag_hint": "拖拽排序,移动到上方以启用功能 ({{enabled}}/{{max}})"
},
"advanced": {
"title": "高级",
"filter_mode": {
"title": "应用筛选",
"description": "可以限制划词助手只在特定应用中生效(白名单)或不生效(黑名单)",
"default": "关闭",
"whitelist": "白名单",
"blacklist": "黑名单"
},
"filter_list": {
"title": "筛选名单",
"description": "高级功能,建议有经验的用户在了解的情况下再进行设置"
}
},
"user_modal": {
"title": {
"add": "添加自定义功能",
@ -1964,6 +1978,10 @@
},
"test": "测试"
}
},
"filter_modal": {
"title": "应用筛选名单",
"user_tips": "请输入应用的执行文件名每行一个不区分大小写可以模糊匹配。例如chrome.exe、weixin.exe、Cherry Studio.exe等"
}
}
}

View File

@ -1909,6 +1909,20 @@
"delete_confirm": "確定要刪除這個自訂功能嗎?",
"drag_hint": "拖曳排序,移動到上方以啟用功能 ({{enabled}}/{{max}})"
},
"advanced": {
"title": "進階",
"filter_mode": {
"title": "應用篩選",
"description": "可以限制劃詞助手只在特定應用中生效(白名單)或不生效(黑名單)",
"default": "關閉",
"whitelist": "白名單",
"blacklist": "黑名單"
},
"filter_list": {
"title": "篩選名單",
"description": "進階功能,建議有經驗的用戶在了解情況下再進行設置"
}
},
"user_modal": {
"title": {
"add": "新增自訂功能",
@ -1965,6 +1979,10 @@
},
"test": "測試"
}
},
"filter_modal": {
"title": "應用篩選名單",
"user_tips": "請輸入應用的執行檔名稱每行一個不區分大小寫可以模糊匹配。例如chrome.exe、weixin.exe、Cherry Studio.exe等"
}
}
}

View File

@ -1,11 +1,11 @@
import { isWindows } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant'
import { TriggerMode } from '@renderer/types/selectionTypes'
import { FilterMode, TriggerMode } from '@renderer/types/selectionTypes'
import SelectionToolbar from '@renderer/windows/selection/toolbar/SelectionToolbar'
import { Button, Radio, Row, Slider, Switch, Tooltip } from 'antd'
import { CircleHelp } from 'lucide-react'
import { FC, useEffect } from 'react'
import { CircleHelp, Edit2 } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -19,6 +19,7 @@ import {
SettingTitle
} from '..'
import SelectionActionsList from './SelectionActionsList'
import SelectionFilterListModal from './SelectionFilterListModal'
const SelectionAssistantSettings: FC = () => {
const { theme } = useTheme()
@ -32,6 +33,8 @@ const SelectionAssistantSettings: FC = () => {
isFollowToolbar,
actionItems,
actionWindowOpacity,
filterMode,
filterList,
setSelectionEnabled,
setTriggerMode,
setIsCompact,
@ -39,8 +42,11 @@ const SelectionAssistantSettings: FC = () => {
setIsAutoPin,
setIsFollowToolbar,
setActionWindowOpacity,
setActionItems
setActionItems,
setFilterMode,
setFilterList
} = useSelectionAssistant()
const [isFilterListModalOpen, setIsFilterListModalOpen] = useState(false)
// force disable selection assistant on non-windows systems
useEffect(() => {
@ -86,6 +92,7 @@ const SelectionAssistantSettings: FC = () => {
<>
<SettingGroup>
<SettingTitle>{t('selection.settings.toolbar.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
@ -106,7 +113,9 @@ const SelectionAssistantSettings: FC = () => {
<Radio.Button value="ctrlkey">{t('selection.settings.toolbar.trigger_mode.ctrlkey')}</Radio.Button>
</Radio.Group>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>{t('selection.settings.toolbar.compact_mode.title')}</SettingRowTitle>
@ -118,6 +127,7 @@ const SelectionAssistantSettings: FC = () => {
<SettingGroup>
<SettingTitle>{t('selection.settings.window.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
@ -127,7 +137,9 @@ const SelectionAssistantSettings: FC = () => {
</SettingLabel>
<Switch checked={isFollowToolbar} onChange={(checked) => setIsFollowToolbar(checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>{t('selection.settings.window.auto_close.title')}</SettingRowTitle>
@ -135,7 +147,9 @@ const SelectionAssistantSettings: FC = () => {
</SettingLabel>
<Switch checked={isAutoClose} onChange={(checked) => setIsAutoClose(checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>{t('selection.settings.window.auto_pin.title')}</SettingRowTitle>
@ -143,7 +157,9 @@ const SelectionAssistantSettings: FC = () => {
</SettingLabel>
<Switch checked={isAutoPin} onChange={(checked) => setIsAutoPin(checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>{t('selection.settings.window.opacity.title')}</SettingRowTitle>
@ -163,6 +179,49 @@ const SelectionAssistantSettings: FC = () => {
</SettingGroup>
<SelectionActionsList actionItems={actionItems} setActionItems={setActionItems} />
<SettingGroup>
<SettingTitle></SettingTitle>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>{t('selection.settings.advanced.filter_mode.title')}</SettingRowTitle>
<SettingDescription>{t('selection.settings.advanced.filter_mode.description')}</SettingDescription>
</SettingLabel>
<Radio.Group
value={filterMode}
onChange={(e) => setFilterMode(e.target.value as FilterMode)}
buttonStyle="solid">
<Radio.Button value="default">{t('selection.settings.advanced.filter_mode.default')}</Radio.Button>
<Radio.Button value="whitelist">{t('selection.settings.advanced.filter_mode.whitelist')}</Radio.Button>
<Radio.Button value="blacklist">{t('selection.settings.advanced.filter_mode.blacklist')}</Radio.Button>
</Radio.Group>
</SettingRow>
{filterMode !== 'default' && (
<>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>{t('selection.settings.advanced.filter_list.title')}</SettingRowTitle>
<SettingDescription>{t('selection.settings.advanced.filter_list.description')}</SettingDescription>
</SettingLabel>
<Button icon={<Edit2 size={14} />} onClick={() => setIsFilterListModalOpen(true)}>
{t('common.edit')}
</Button>
</SettingRow>
<SelectionFilterListModal
open={isFilterListModalOpen}
onClose={() => setIsFilterListModalOpen(false)}
filterList={filterList}
onSave={setFilterList}
/>
</>
)}
</SettingGroup>
</>
)}
</SettingContainer>

View File

@ -0,0 +1,76 @@
import { Button, Form, Input, Modal } from 'antd'
import { FC, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface SelectionFilterListModalProps {
open: boolean
onClose: () => void
filterList?: string[]
onSave: (list: string[]) => void
}
const SelectionFilterListModal: FC<SelectionFilterListModalProps> = ({ open, onClose, filterList = [], onSave }) => {
const { t } = useTranslation()
const [form] = Form.useForm()
useEffect(() => {
if (open) {
form.setFieldsValue({
filterList: (filterList || []).join('\n')
})
}
}, [open, filterList, form])
const handleSave = async () => {
try {
const values = await form.validateFields()
const newList = values.filterList
.trim()
.toLowerCase()
.split('\n')
.map((line: string) => line.trim())
.filter((line: string) => line.length > 0)
onSave(newList)
onClose()
} catch (error) {
// validation failed
}
}
return (
<Modal
title={t('selection.settings.filter_modal.title')}
open={open}
onCancel={onClose}
maskClosable={false}
keyboard={true}
destroyOnClose={true}
footer={[
<Button key="modal-cancel" onClick={onClose}>
{t('common.cancel')}
</Button>,
<Button key="modal-save" type="primary" onClick={handleSave}>
{t('common.save')}
</Button>
]}>
<UserTip>{t('selection.settings.filter_modal.user_tips')}</UserTip>
<Form form={form} layout="vertical" initialValues={{ filterList: '' }}>
<Form.Item name="filterList" noStyle>
<StyledTextArea autoSize={{ minRows: 6, maxRows: 16 }} autoFocus />
</Form.Item>
</Form>
</Modal>
)
}
const StyledTextArea = styled(Input.TextArea)`
margin-top: 16px;
width: 100%;
`
const UserTip = styled.div`
font-size: 14px;
`
export default SelectionFilterListModal

View File

@ -1,5 +1,5 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { ActionItem, SelectionState, TriggerMode } from '@renderer/types/selectionTypes'
import { ActionItem, FilterMode, SelectionState, TriggerMode } from '@renderer/types/selectionTypes'
export const defaultActionItems: ActionItem[] = [
{ id: 'translate', name: 'selection.action.builtin.translate', enabled: true, isBuiltIn: true, icon: 'languages' },
@ -24,6 +24,8 @@ export const initialState: SelectionState = {
isAutoClose: false,
isAutoPin: false,
isFollowToolbar: true,
filterMode: 'default',
filterList: [],
actionWindowOpacity: 100,
actionItems: defaultActionItems
}
@ -50,6 +52,12 @@ const selectionSlice = createSlice({
setIsFollowToolbar: (state, action: PayloadAction<boolean>) => {
state.isFollowToolbar = action.payload
},
setFilterMode: (state, action: PayloadAction<FilterMode>) => {
state.filterMode = action.payload
},
setFilterList: (state, action: PayloadAction<string[]>) => {
state.filterList = action.payload
},
setActionWindowOpacity: (state, action: PayloadAction<number>) => {
state.actionWindowOpacity = action.payload
},
@ -66,6 +74,8 @@ export const {
setIsAutoClose,
setIsAutoPin,
setIsFollowToolbar,
setFilterMode,
setFilterList,
setActionWindowOpacity,
setActionItems
} = selectionSlice.actions

View File

@ -1,5 +1,5 @@
export type TriggerMode = 'selected' | 'ctrlkey'
export type FilterMode = 'default' | 'whitelist' | 'blacklist'
export interface ActionItem {
id: string
name: string
@ -19,6 +19,8 @@ export interface SelectionState {
isAutoClose: boolean
isAutoPin: boolean
isFollowToolbar: boolean
filterMode: FilterMode
filterList: string[]
actionWindowOpacity: number
actionItems: ActionItem[]
}