mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 06:30:10 +08:00
feat: Selection Assistant / 划词助手 (#5900)
* feat(selection): implement selection assistant with toolbar and action management - Added selection assistant functionality including a toolbar for actions. - Introduced new settings for enabling/disabling the selection assistant and configuring its behavior. - Implemented action items for built-in functionalities like translate, explain, and copy. - Integrated selection service to manage selection events and actions. - Updated localization files to support new selection assistant features in multiple languages. - Added new components for action management and user interaction within the selection assistant. * chore: update selection-hook to version 0.9.10 and exclude prebuilds from packaging * fix: toolbar hiding * feat: enhance error handling and service management in main index * fix: improve logical coordinate handling in SelectionService * fix: update URL loading and coordinate conversion in SelectionService * fix: replace console.error with Logger for error handling in SelectionService * refactor(SelectionService): enhance preloaded action window management * chore(electron-builder): add filter for .node build files in configuration * fix: toolbar position calculating for multi monitor * fix: update selection assistant configuration and improve error handling in SelectionService * fix: SelectionActionUserModal layout * feat: add hints for custom search URL in multiple languages * fix: update calculateToolbarPosition to ensure integer return type and round position values * feat: add action window opacity setting and update related UI components refactor: SelectionActionsList * chore: enhance tooltip for trigger mode settings * fix: console.log * chore: update selection-hook to version 0.9.12 * fix: integrate language settings into selection components * fix: filter out default assistant from user predefined assistants in selection modal * chore: update selection-hook package version to 0.9.13 * chore: update selection-hook package version to 0.9.14
This commit is contained in:
parent
665a62080b
commit
2ba4e51e93
@ -43,6 +43,8 @@ files:
|
||||
- '!node_modules/@tavily/core/node_modules/js-tiktoken'
|
||||
- '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}'
|
||||
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
|
||||
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
|
||||
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # filter .node build files
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- '**/*.{metal,exp,lib}'
|
||||
|
||||
@ -89,7 +89,9 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html'),
|
||||
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html')
|
||||
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'),
|
||||
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,6 +93,7 @@
|
||||
"officeparser": "^4.1.1",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"selection-hook": "^0.9.14",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
|
||||
@ -176,5 +176,20 @@ export enum IpcChannel {
|
||||
StoreSync_BroadcastSync = 'store-sync:broadcast-sync',
|
||||
|
||||
// Provider
|
||||
Provider_AddKey = 'provider:add-key'
|
||||
Provider_AddKey = 'provider:add-key',
|
||||
|
||||
//Selection Assistant
|
||||
Selection_TextSelected = 'selection:text-selected',
|
||||
Selection_ToolbarHide = 'selection:toolbar-hide',
|
||||
Selection_ToolbarVisibilityChange = 'selection:toolbar-visibility-change',
|
||||
Selection_ToolbarDetermineSize = 'selection:toolbar-determine-size',
|
||||
Selection_WriteToClipboard = 'selection:write-to-clipboard',
|
||||
Selection_SetEnabled = 'selection:set-enabled',
|
||||
Selection_SetTriggerMode = 'selection:set-trigger-mode',
|
||||
Selection_SetFollowToolbar = 'selection:set-follow-toolbar',
|
||||
Selection_ActionWindowClose = 'selection:action-window-close',
|
||||
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
|
||||
Selection_ActionWindowPin = 'selection:action-window-pin',
|
||||
Selection_ProcessAction = 'selection:process-action',
|
||||
Selection_UpdateActionData = 'selection:update-action-data'
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
registerProtocolClient,
|
||||
setupAppImageDeepLink
|
||||
} from './services/ProtocolClient'
|
||||
import selectionService, { initSelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
@ -84,6 +85,9 @@ if (!app.requestSingleInstanceLock()) {
|
||||
.then((name) => console.log(`Added Extension: ${name}`))
|
||||
.catch((err) => console.log('An error occurred: ', err))
|
||||
}
|
||||
|
||||
//start selection assistant service
|
||||
initSelectionService()
|
||||
})
|
||||
|
||||
registerProtocolClient(app)
|
||||
@ -110,6 +114,11 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
app.on('before-quit', () => {
|
||||
app.isQuitting = true
|
||||
|
||||
// quit selection service
|
||||
if (selectionService) {
|
||||
selectionService.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('will-quit', async () => {
|
||||
|
||||
@ -26,6 +26,7 @@ import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
@ -379,4 +380,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// store sync
|
||||
storeSyncService.registerIpcHandler()
|
||||
|
||||
// selection assistant
|
||||
SelectionService.registerIpcHandler()
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import Store from 'electron-store'
|
||||
|
||||
import { locales } from '../utils/locales'
|
||||
|
||||
enum ConfigKeys {
|
||||
export enum ConfigKeys {
|
||||
Language = 'language',
|
||||
Theme = 'theme',
|
||||
LaunchToTray = 'launchToTray',
|
||||
@ -16,7 +16,10 @@ enum ConfigKeys {
|
||||
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
|
||||
EnableQuickAssistant = 'enableQuickAssistant',
|
||||
AutoUpdate = 'autoUpdate',
|
||||
EnableDataCollection = 'enableDataCollection'
|
||||
EnableDataCollection = 'enableDataCollection',
|
||||
SelectionAssistantEnabled = 'selectionAssistantEnabled',
|
||||
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
|
||||
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar'
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
@ -146,6 +149,36 @@ export class ConfigManager {
|
||||
this.set(ConfigKeys.EnableDataCollection, value)
|
||||
}
|
||||
|
||||
// Selection Assistant: is enabled the selection assistant
|
||||
getSelectionAssistantEnabled(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantEnabled, true)
|
||||
}
|
||||
|
||||
setSelectionAssistantEnabled(value: boolean) {
|
||||
this.set(ConfigKeys.SelectionAssistantEnabled, value)
|
||||
this.notifySubscribers(ConfigKeys.SelectionAssistantEnabled, value)
|
||||
}
|
||||
|
||||
// Selection Assistant: trigger mode (selected, ctrlkey)
|
||||
getSelectionAssistantTriggerMode(): string {
|
||||
return this.get<string>(ConfigKeys.SelectionAssistantTriggerMode, 'selected')
|
||||
}
|
||||
|
||||
setSelectionAssistantTriggerMode(value: string) {
|
||||
this.set(ConfigKeys.SelectionAssistantTriggerMode, value)
|
||||
this.notifySubscribers(ConfigKeys.SelectionAssistantTriggerMode, value)
|
||||
}
|
||||
|
||||
// Selection Assistant: if action window position follow toolbar
|
||||
getSelectionAssistantFollowToolbar(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantFollowToolbar, true)
|
||||
}
|
||||
|
||||
setSelectionAssistantFollowToolbar(value: boolean) {
|
||||
this.set(ConfigKeys.SelectionAssistantFollowToolbar, value)
|
||||
this.notifySubscribers(ConfigKeys.SelectionAssistantFollowToolbar, value)
|
||||
}
|
||||
|
||||
set(key: string, value: unknown) {
|
||||
this.store.set(key, value)
|
||||
}
|
||||
|
||||
1024
src/main/services/SelectionService.ts
Normal file
1024
src/main/services/SelectionService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,8 @@ import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from '
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
import type { ActionItem } from '../renderer/src/types/selectionTypes'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
|
||||
@ -204,6 +206,20 @@ const api = {
|
||||
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
|
||||
unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe),
|
||||
onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action)
|
||||
},
|
||||
selection: {
|
||||
hideToolbar: () => ipcRenderer.invoke(IpcChannel.Selection_ToolbarHide),
|
||||
writeToClipboard: (text: string) => ipcRenderer.invoke(IpcChannel.Selection_WriteToClipboard, text),
|
||||
determineToolbarSize: (width: number, height: number) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_ToolbarDetermineSize, width, height),
|
||||
setEnabled: (enabled: boolean) => ipcRenderer.invoke(IpcChannel.Selection_SetEnabled, enabled),
|
||||
setTriggerMode: (triggerMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetTriggerMode, triggerMode),
|
||||
setFollowToolbar: (isFollowToolbar: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_SetFollowToolbar, isFollowToolbar),
|
||||
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
|
||||
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
|
||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
src/renderer/selectionAction.html
Normal file
41
src/renderer/selectionAction.html
Normal file
@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Assistant</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/action/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
43
src/renderer/selectionToolbar.html
Normal file
43
src/renderer/selectionToolbar.html
Normal file
@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Toolbar</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: max-content !important;
|
||||
height: fit-content !important;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
26
src/renderer/src/assets/styles/selection-toolbar.scss
Normal file
26
src/renderer/src/assets/styles/selection-toolbar.scss
Normal file
@ -0,0 +1,26 @@
|
||||
@use './font.scss';
|
||||
|
||||
html {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-selection-toolbar-background: rgba(20, 20, 20, 0.95);
|
||||
--color-selection-toolbar-border: rgba(55, 55, 55, 0.5);
|
||||
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
|
||||
|
||||
--color-selection-toolbar-text: rgba(255, 255, 245, 0.9);
|
||||
--color-selection-toolbar-hover-bg: #222222;
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-error: #f44336;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
--color-selection-toolbar-background: rgba(245, 245, 245, 0.95);
|
||||
--color-selection-toolbar-border: rgba(200, 200, 200, 0.5);
|
||||
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
|
||||
|
||||
--color-selection-toolbar-text: rgba(0, 0, 0, 1);
|
||||
--color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
83
src/renderer/src/components/CopyButton.tsx
Normal file
83
src/renderer/src/components/CopyButton.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { Tooltip } from 'antd'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CopyButtonProps {
|
||||
tooltip?: string
|
||||
textToCopy: string
|
||||
label?: string
|
||||
color?: string
|
||||
hoverColor?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface ButtonContainerProps {
|
||||
$color: string
|
||||
$hoverColor: string
|
||||
}
|
||||
|
||||
const CopyButton: FC<CopyButtonProps> = ({
|
||||
tooltip,
|
||||
textToCopy,
|
||||
label,
|
||||
color = 'var(--color-text-2)',
|
||||
hoverColor = 'var(--color-primary)',
|
||||
size = 14
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard
|
||||
.writeText(textToCopy)
|
||||
.then(() => {
|
||||
window.message?.success(t('message.copy.success'))
|
||||
})
|
||||
.catch(() => {
|
||||
window.message?.error(t('message.copy.failed'))
|
||||
})
|
||||
}
|
||||
|
||||
const button = (
|
||||
<ButtonContainer $color={color} $hoverColor={hoverColor} onClick={handleCopy}>
|
||||
<Copy size={size} className="copy-icon" />
|
||||
{label && <RightText size={size}>{label}</RightText>}
|
||||
</ButtonContainer>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return <Tooltip title={tooltip}>{button}</Tooltip>
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
const ButtonContainer = styled.div<ButtonContainerProps>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.$color};
|
||||
transition: color 0.2s;
|
||||
|
||||
.copy-icon {
|
||||
color: ${(props) => props.$color};
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.$hoverColor};
|
||||
|
||||
.copy-icon {
|
||||
color: ${(props) => props.$hoverColor};
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const RightText = styled.span<{ size: number }>`
|
||||
font-size: ${(props) => props.size}px;
|
||||
`
|
||||
|
||||
export default CopyButton
|
||||
48
src/renderer/src/hooks/useSelectionAssistant.ts
Normal file
48
src/renderer/src/hooks/useSelectionAssistant.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
setActionItems,
|
||||
setActionWindowOpacity,
|
||||
setIsAutoClose,
|
||||
setIsAutoPin,
|
||||
setIsCompact,
|
||||
setIsFollowToolbar,
|
||||
setSelectionEnabled,
|
||||
setTriggerMode
|
||||
} from '@renderer/store/selectionStore'
|
||||
import { ActionItem, TriggerMode } from '@renderer/types/selectionTypes'
|
||||
|
||||
export function useSelectionAssistant() {
|
||||
const dispatch = useAppDispatch()
|
||||
const selectionStore = useAppSelector((state) => state.selectionStore)
|
||||
|
||||
return {
|
||||
...selectionStore,
|
||||
setSelectionEnabled: (enabled: boolean) => {
|
||||
dispatch(setSelectionEnabled(enabled))
|
||||
window.api.selection.setEnabled(enabled)
|
||||
},
|
||||
setTriggerMode: (mode: TriggerMode) => {
|
||||
dispatch(setTriggerMode(mode))
|
||||
window.api.selection.setTriggerMode(mode)
|
||||
},
|
||||
setIsCompact: (isCompact: boolean) => {
|
||||
dispatch(setIsCompact(isCompact))
|
||||
},
|
||||
setIsAutoClose: (isAutoClose: boolean) => {
|
||||
dispatch(setIsAutoClose(isAutoClose))
|
||||
},
|
||||
setIsAutoPin: (isAutoPin: boolean) => {
|
||||
dispatch(setIsAutoPin(isAutoPin))
|
||||
},
|
||||
setIsFollowToolbar: (isFollowToolbar: boolean) => {
|
||||
dispatch(setIsFollowToolbar(isFollowToolbar))
|
||||
window.api.selection.setFollowToolbar(isFollowToolbar)
|
||||
},
|
||||
setActionWindowOpacity: (opacity: number) => {
|
||||
dispatch(setActionWindowOpacity(opacity))
|
||||
},
|
||||
setActionItems: (items: ActionItem[]) => {
|
||||
dispatch(setActionItems(items))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1779,6 +1779,141 @@
|
||||
"quit": "Quit",
|
||||
"show_window": "Show Window",
|
||||
"visualization": "Visualization"
|
||||
},
|
||||
"selection": {
|
||||
"name": "Selection Assistant",
|
||||
"action": {
|
||||
"builtin": {
|
||||
"translate": "Translate",
|
||||
"explain": "Explain",
|
||||
"summary": "Summarize",
|
||||
"search": "Search",
|
||||
"refine": "Refine",
|
||||
"copy": "Copy"
|
||||
},
|
||||
"window": {
|
||||
"pin": "Pin",
|
||||
"pinned": "Pinned",
|
||||
"opacity": "Window Opacity",
|
||||
"original_show": "Show Original",
|
||||
"original_hide": "Hide Original",
|
||||
"original_copy": "Copy Original",
|
||||
"esc_close": "Esc to Close",
|
||||
"esc_stop": "Esc to Stop",
|
||||
"c_copy": "C to Copy"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"experimental": "Experimental Features",
|
||||
"enable": {
|
||||
"title": "Enable",
|
||||
"description": "Currently only supported on Windows systems"
|
||||
},
|
||||
"toolbar": {
|
||||
"title": "Toolbar",
|
||||
"trigger_mode": {
|
||||
"title": "Trigger Mode",
|
||||
"description": "Show toolbar immediately when text is selected, or show only when Ctrl key is held after selection.",
|
||||
"description_note": "The Ctrl key may not work in some apps. If you use AHK or other tools to remap the Ctrl key, it may not work.",
|
||||
"selected": "Selection",
|
||||
"ctrlkey": "Ctrl Key"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "Compact Mode",
|
||||
"description": "In compact mode, only icons are displayed without text"
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"title": "Action Window",
|
||||
"follow_toolbar": {
|
||||
"title": "Follow Toolbar",
|
||||
"description": "Window position will follow the toolbar. When disabled, it will always be centered."
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "Auto Close",
|
||||
"description": "Automatically close the window when it's not pinned and loses focus"
|
||||
},
|
||||
"auto_pin": {
|
||||
"title": "Auto Pin",
|
||||
"description": "Pin the window by default"
|
||||
},
|
||||
"opacity": {
|
||||
"title": "Opacity",
|
||||
"description": "Set the default opacity of the window, 100% is fully opaque"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"title": "Actions",
|
||||
"reset": {
|
||||
"button": "Reset",
|
||||
"tooltip": "Reset to default actions. Custom actions will not be deleted.",
|
||||
"confirm": "Are you sure you want to reset to default actions? Custom actions will not be deleted."
|
||||
},
|
||||
"add_tooltip": {
|
||||
"enabled": "Add Custom Action",
|
||||
"disabled": "Maximum number of custom actions reached ({{max}})"
|
||||
},
|
||||
"delete_confirm": "Are you sure you want to delete this custom action?",
|
||||
"drag_hint": "Drag to reorder. Move above to enable action ({{enabled}}/{{max}})"
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "Add Custom Action",
|
||||
"edit": "Edit Custom Action"
|
||||
},
|
||||
"name": {
|
||||
"label": "Name",
|
||||
"hint": "Please enter action name"
|
||||
},
|
||||
"icon": {
|
||||
"label": "Icon",
|
||||
"placeholder": "Enter Lucide icon name",
|
||||
"error": "Invalid icon name, please check your input",
|
||||
"tooltip": "Lucide icon names are lowercase, e.g. arrow-right",
|
||||
"view_all": "View All Icons",
|
||||
"random": "Random Icon"
|
||||
},
|
||||
"model": {
|
||||
"label": "Model",
|
||||
"tooltip": "Using Assistant: Will use both the assistant's system prompt and model parameters",
|
||||
"default": "Default Model",
|
||||
"assistant": "Use Assistant"
|
||||
},
|
||||
"assistant": {
|
||||
"label": "Select Assistant",
|
||||
"default": "Default"
|
||||
},
|
||||
"prompt": {
|
||||
"label": "User Prompt",
|
||||
"tooltip": "User prompt serves as a supplement to user input and won't override the assistant's system prompt",
|
||||
"placeholder": "Use placeholder {{text}} to represent selected text. When empty, selected text will be appended to this prompt",
|
||||
"placeholder_text": "Placeholder",
|
||||
"copy_placeholder": "Copy Placeholder"
|
||||
}
|
||||
},
|
||||
"search_modal": {
|
||||
"title": "Set Search Engine",
|
||||
"engine": {
|
||||
"label": "Search Engine",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"custom": {
|
||||
"name": {
|
||||
"label": "Custom Name",
|
||||
"hint": "Please enter search engine name",
|
||||
"max_length": "Name cannot exceed 16 characters"
|
||||
},
|
||||
"url": {
|
||||
"label": "Custom Search URL",
|
||||
"hint": "Use {{queryString}} to represent the search term",
|
||||
"required": "Please enter search URL",
|
||||
"invalid_format": "Please enter a valid URL starting with http:// or https://",
|
||||
"missing_placeholder": "URL must contain {{queryString}} placeholder"
|
||||
},
|
||||
"test": "Test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1779,6 +1779,141 @@
|
||||
"quit": "終了",
|
||||
"show_window": "ウィンドウを表示",
|
||||
"visualization": "可視化"
|
||||
},
|
||||
"selection": {
|
||||
"name": "テキスト選択ツール",
|
||||
"action": {
|
||||
"builtin": {
|
||||
"translate": "翻訳",
|
||||
"explain": "解説",
|
||||
"summary": "要約",
|
||||
"search": "検索",
|
||||
"refine": "最適化",
|
||||
"copy": "コピー"
|
||||
},
|
||||
"window": {
|
||||
"pin": "最前面に固定",
|
||||
"pinned": "固定中",
|
||||
"opacity": "ウィンドウの透過度",
|
||||
"original_show": "原文を表示",
|
||||
"original_hide": "原文を非表示",
|
||||
"original_copy": "原文をコピー",
|
||||
"esc_close": "Escで閉じる",
|
||||
"esc_stop": "Escで停止",
|
||||
"c_copy": "Cでコピー"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"experimental": "実験的機能",
|
||||
"enable": {
|
||||
"title": "有効化",
|
||||
"description": "現在Windowsのみ対応"
|
||||
},
|
||||
"toolbar": {
|
||||
"title": "ツールバー",
|
||||
"trigger_mode": {
|
||||
"title": "表示方法",
|
||||
"description": "テキスト選択時に即時表示、またはCtrlキー押下時のみ表示",
|
||||
"description_note": "一部のアプリはCtrlキーでのテキスト選択に対応していません。AHKなどでCtrlキーをリマップすると、選択できなくなる場合があります。",
|
||||
"selected": "選択時",
|
||||
"ctrlkey": "Ctrlキー"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "コンパクトモード",
|
||||
"description": "アイコンのみ表示(テキスト非表示)"
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"title": "機能ウィンドウ",
|
||||
"follow_toolbar": {
|
||||
"title": "ツールバーに追従",
|
||||
"description": "ウィンドウ位置をツールバーに連動(無効時は中央表示)"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "自動閉じる",
|
||||
"description": "最前面固定されていない場合、フォーカス喪失時に自動閉じる"
|
||||
},
|
||||
"auto_pin": {
|
||||
"title": "自動で最前面に固定",
|
||||
"description": "デフォルトで最前面表示"
|
||||
},
|
||||
"opacity": {
|
||||
"title": "透明度",
|
||||
"description": "デフォルトの透明度を設定(100%は完全不透明)"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"title": "機能設定",
|
||||
"reset": {
|
||||
"button": "リセット",
|
||||
"tooltip": "デフォルト機能にリセット(カスタム機能は保持)",
|
||||
"confirm": "デフォルト機能にリセットしますか?\nカスタム機能は削除されません"
|
||||
},
|
||||
"add_tooltip": {
|
||||
"enabled": "カスタム機能を追加",
|
||||
"disabled": "カスタム機能の上限に達しました (最大{{max}}個)"
|
||||
},
|
||||
"delete_confirm": "このカスタム機能を削除しますか?",
|
||||
"drag_hint": "ドラッグで並べ替え (有効{{enabled}}/最大{{max}})"
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "カスタム機能追加",
|
||||
"edit": "カスタム機能編集"
|
||||
},
|
||||
"name": {
|
||||
"label": "機能名",
|
||||
"hint": "機能名を入力"
|
||||
},
|
||||
"icon": {
|
||||
"label": "アイコン",
|
||||
"placeholder": "Lucideアイコン名を入力",
|
||||
"error": "無効なアイコン名です",
|
||||
"tooltip": "例: arrow-right(小文字で入力)",
|
||||
"view_all": "全アイコンを表示",
|
||||
"random": "ランダム選択"
|
||||
},
|
||||
"model": {
|
||||
"label": "モデル",
|
||||
"tooltip": "アシスタント使用時はシステムプロンプトとモデルパラメータも適用",
|
||||
"default": "デフォルトモデル",
|
||||
"assistant": "アシスタントを使用"
|
||||
},
|
||||
"assistant": {
|
||||
"label": "アシスタント選択",
|
||||
"default": "デフォルト"
|
||||
},
|
||||
"prompt": {
|
||||
"label": "ユーザープロンプト",
|
||||
"tooltip": "アシスタントのシステムプロンプトを上書きせず、入力補助として機能",
|
||||
"placeholder": "{{text}}で選択テキストを参照(未入力時は末尾に追加)",
|
||||
"placeholder_text": "プレースホルダー",
|
||||
"copy_placeholder": "プレースホルダーをコピー"
|
||||
}
|
||||
},
|
||||
"search_modal": {
|
||||
"title": "検索エンジン設定",
|
||||
"engine": {
|
||||
"label": "検索エンジン",
|
||||
"custom": "カスタム"
|
||||
},
|
||||
"custom": {
|
||||
"name": {
|
||||
"label": "表示名",
|
||||
"hint": "検索エンジン名(16文字以内)",
|
||||
"max_length": "16文字以内で入力"
|
||||
},
|
||||
"url": {
|
||||
"label": "検索URL",
|
||||
"hint": "{{queryString}}で検索語を表す",
|
||||
"required": "URLを入力してください",
|
||||
"invalid_format": "http:// または https:// で始まるURLを入力",
|
||||
"missing_placeholder": "{{queryString}}を含めてください"
|
||||
},
|
||||
"test": "テスト"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1780,6 +1780,141 @@
|
||||
"quit": "Выйти",
|
||||
"show_window": "Показать окно",
|
||||
"visualization": "Визуализация"
|
||||
},
|
||||
"selection": {
|
||||
"name": "Помощник выбора",
|
||||
"action": {
|
||||
"builtin": {
|
||||
"translate": "Перевести",
|
||||
"explain": "Объяснить",
|
||||
"summary": "Суммаризировать",
|
||||
"search": "Поиск",
|
||||
"refine": "Уточнить",
|
||||
"copy": "Копировать"
|
||||
},
|
||||
"window": {
|
||||
"pin": "Закрепить",
|
||||
"pinned": "Закреплено",
|
||||
"opacity": "Прозрачность окна",
|
||||
"original_show": "Показать оригинал",
|
||||
"original_hide": "Скрыть оригинал",
|
||||
"original_copy": "Копировать оригинал",
|
||||
"esc_close": "Esc - закрыть",
|
||||
"esc_stop": "Esc - остановить",
|
||||
"c_copy": "C - копировать"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"experimental": "Экспериментальные функции",
|
||||
"enable": {
|
||||
"title": "Включить",
|
||||
"description": "Поддерживается только в Windows"
|
||||
},
|
||||
"toolbar": {
|
||||
"title": "Панель инструментов",
|
||||
"trigger_mode": {
|
||||
"title": "Режим активации",
|
||||
"description": "Показывать панель сразу при выделении или только при удержании Ctrl.",
|
||||
"description_note": "В некоторых приложениях Ctrl может не работать. Если вы используете AHK или другие инструменты для переназначения Ctrl, это может привести к тому, что некоторые приложения не смогут выделить текст.",
|
||||
"selected": "При выделении",
|
||||
"ctrlkey": "По Ctrl"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "Компактный режим",
|
||||
"description": "Отображать только иконки без текста"
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"title": "Окно действий",
|
||||
"follow_toolbar": {
|
||||
"title": "Следовать за панелью",
|
||||
"description": "Окно будет следовать за панелью. Иначе - по центру."
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "Автозакрытие",
|
||||
"description": "Закрывать окно при потере фокуса (если не закреплено)"
|
||||
},
|
||||
"auto_pin": {
|
||||
"title": "Автозакрепление",
|
||||
"description": "Закреплять окно по умолчанию"
|
||||
},
|
||||
"opacity": {
|
||||
"title": "Прозрачность",
|
||||
"description": "Установить прозрачность окна по умолчанию"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"title": "Действия",
|
||||
"reset": {
|
||||
"button": "Сбросить",
|
||||
"tooltip": "Сбросить стандартные действия. Пользовательские останутся.",
|
||||
"confirm": "Сбросить стандартные действия? Пользовательские останутся."
|
||||
},
|
||||
"add_tooltip": {
|
||||
"enabled": "Добавить действие",
|
||||
"disabled": "Достигнут лимит ({{max}})"
|
||||
},
|
||||
"delete_confirm": "Удалить это действие?",
|
||||
"drag_hint": "Перетащите для сортировки. Включено: {{enabled}}/{{max}}"
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "Добавить действие",
|
||||
"edit": "Редактировать действие"
|
||||
},
|
||||
"name": {
|
||||
"label": "Название",
|
||||
"hint": "Введите название"
|
||||
},
|
||||
"icon": {
|
||||
"label": "Иконка",
|
||||
"placeholder": "Название иконки Lucide",
|
||||
"error": "Некорректное название",
|
||||
"tooltip": "Названия в lowercase, например arrow-right",
|
||||
"view_all": "Все иконки",
|
||||
"random": "Случайная"
|
||||
},
|
||||
"model": {
|
||||
"label": "Модель",
|
||||
"tooltip": "Использовать ассистента: будут применены его системные настройки",
|
||||
"default": "По умолчанию",
|
||||
"assistant": "Ассистент"
|
||||
},
|
||||
"assistant": {
|
||||
"label": "Ассистент",
|
||||
"default": "По умолчанию"
|
||||
},
|
||||
"prompt": {
|
||||
"label": "Промпт",
|
||||
"tooltip": "Дополняет ввод пользователя, не заменяя системный промпт ассистента",
|
||||
"placeholder": "Используйте {{text}} для выделенного текста. Если пусто - текст будет добавлен",
|
||||
"placeholder_text": "Плейсхолдер",
|
||||
"copy_placeholder": "Копировать плейсхолдер"
|
||||
}
|
||||
},
|
||||
"search_modal": {
|
||||
"title": "Поисковая система",
|
||||
"engine": {
|
||||
"label": "Поисковик",
|
||||
"custom": "Свой"
|
||||
},
|
||||
"custom": {
|
||||
"name": {
|
||||
"label": "Название",
|
||||
"hint": "Название поисковика",
|
||||
"max_length": "Не более 16 символов"
|
||||
},
|
||||
"url": {
|
||||
"label": "URL поиска",
|
||||
"hint": "Используйте {{queryString}} для представления поискового запроса",
|
||||
"required": "Введите URL",
|
||||
"invalid_format": "URL должен начинаться с http:// или https://",
|
||||
"missing_placeholder": "Должен содержать {{queryString}}"
|
||||
},
|
||||
"test": "Тест"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1779,6 +1779,141 @@
|
||||
"quit": "退出",
|
||||
"show_window": "显示窗口",
|
||||
"visualization": "可视化"
|
||||
},
|
||||
"selection": {
|
||||
"name": "划词助手",
|
||||
"action": {
|
||||
"builtin": {
|
||||
"translate": "翻译",
|
||||
"explain": "解释",
|
||||
"summary": "总结",
|
||||
"search": "搜索",
|
||||
"refine": "优化",
|
||||
"copy": "复制"
|
||||
},
|
||||
"window": {
|
||||
"pin": "置顶",
|
||||
"pinned": "已置顶",
|
||||
"opacity": "窗口透明度",
|
||||
"original_show": "显示原文",
|
||||
"original_hide": "隐藏原文",
|
||||
"original_copy": "复制原文",
|
||||
"esc_close": "Esc 关闭",
|
||||
"esc_stop": "Esc 停止",
|
||||
"c_copy": "C 复制"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"experimental": "实验性功能",
|
||||
"enable": {
|
||||
"title": "启用",
|
||||
"description": "当前仅支持 Windows 系统"
|
||||
},
|
||||
"toolbar": {
|
||||
"title": "工具栏",
|
||||
"trigger_mode": {
|
||||
"title": "触发方式",
|
||||
"description": "划词立即显示工具栏,或者划词后按住 Ctrl 键才显示工具栏。",
|
||||
"description_note": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。",
|
||||
"selected": "划词",
|
||||
"ctrlkey": "Ctrl 键"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "紧凑模式",
|
||||
"description": "紧凑模式下,只显示图标,不显示文字"
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"title": "功能窗口",
|
||||
"follow_toolbar": {
|
||||
"title": "跟随工具栏",
|
||||
"description": "窗口位置将跟随工具栏显示,禁用后则始终居中显示"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "自动关闭",
|
||||
"description": "当窗口未置顶且失去焦点时,将自动关闭该窗口"
|
||||
},
|
||||
"auto_pin": {
|
||||
"title": "自动置顶",
|
||||
"description": "默认将窗口置于顶部"
|
||||
},
|
||||
"opacity": {
|
||||
"title": "透明度",
|
||||
"description": "设置窗口的默认透明度,100%为完全不透明"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"title": "功能",
|
||||
"reset": {
|
||||
"button": "重置",
|
||||
"tooltip": "重置为默认功能,自定义功能不会被删除",
|
||||
"confirm": "确定要重置为默认功能吗?自定义功能不会被删除。"
|
||||
},
|
||||
"add_tooltip": {
|
||||
"enabled": "添加自定义功能",
|
||||
"disabled": "自定义功能已达上限 ({{max}}个)"
|
||||
},
|
||||
"delete_confirm": "确定要删除这个自定义功能吗?",
|
||||
"drag_hint": "拖拽排序,移动到上方以启用功能 ({{enabled}}/{{max}})"
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "添加自定义功能",
|
||||
"edit": "编辑自定义功能"
|
||||
},
|
||||
"name": {
|
||||
"label": "名称",
|
||||
"hint": "请输入功能名称"
|
||||
},
|
||||
"icon": {
|
||||
"label": "图标",
|
||||
"placeholder": "输入 Lucide 图标名称",
|
||||
"error": "无效的图标名称,请检查输入",
|
||||
"tooltip": "Lucide图标名称为小写,如 arrow-right",
|
||||
"view_all": "查看所有图标",
|
||||
"random": "随机图标"
|
||||
},
|
||||
"model": {
|
||||
"label": "模型",
|
||||
"tooltip": "使用助手:会同时使用助手的系统提示词和模型参数",
|
||||
"default": "默认模型",
|
||||
"assistant": "使用助手"
|
||||
},
|
||||
"assistant": {
|
||||
"label": "选择助手",
|
||||
"default": "默认"
|
||||
},
|
||||
"prompt": {
|
||||
"label": "用户提示词(Prompt)",
|
||||
"tooltip": "用户提示词,作为用户输入的补充,不会覆盖助手的系统提示词",
|
||||
"placeholder": "使用占位符{{text}}代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾",
|
||||
"placeholder_text": "占位符",
|
||||
"copy_placeholder": "复制占位符"
|
||||
}
|
||||
},
|
||||
"search_modal": {
|
||||
"title": "设置搜索引擎",
|
||||
"engine": {
|
||||
"label": "搜索引擎",
|
||||
"custom": "自定义"
|
||||
},
|
||||
"custom": {
|
||||
"name": {
|
||||
"label": "自定义名称",
|
||||
"hint": "请输入搜索引擎名称",
|
||||
"max_length": "名称不能超过16个字符"
|
||||
},
|
||||
"url": {
|
||||
"label": "自定义搜索 URL",
|
||||
"hint": "用 {{queryString}} 代表搜索词",
|
||||
"required": "请输入搜索 URL",
|
||||
"invalid_format": "请输入以 http:// 或 https:// 开头的有效 URL",
|
||||
"missing_placeholder": "URL 必须包含 {{queryString}} 占位符"
|
||||
},
|
||||
"test": "测试"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1780,6 +1780,141 @@
|
||||
"quit": "結束",
|
||||
"show_window": "顯示視窗",
|
||||
"visualization": "視覺化"
|
||||
},
|
||||
"selection": {
|
||||
"name": "劃詞助手",
|
||||
"action": {
|
||||
"builtin": {
|
||||
"translate": "翻譯",
|
||||
"explain": "解釋",
|
||||
"summary": "總結",
|
||||
"search": "搜尋",
|
||||
"refine": "優化",
|
||||
"copy": "複製"
|
||||
},
|
||||
"window": {
|
||||
"pin": "置頂",
|
||||
"pinned": "已置頂",
|
||||
"opacity": "視窗透明度",
|
||||
"original_show": "顯示原文",
|
||||
"original_hide": "隱藏原文",
|
||||
"original_copy": "複製原文",
|
||||
"esc_close": "Esc 關閉",
|
||||
"esc_stop": "Esc 停止",
|
||||
"c_copy": "C 複製"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"experimental": "實驗性功能",
|
||||
"enable": {
|
||||
"title": "啟用",
|
||||
"description": "目前僅支援 Windows 系統"
|
||||
},
|
||||
"toolbar": {
|
||||
"title": "工具列",
|
||||
"trigger_mode": {
|
||||
"title": "觸發方式",
|
||||
"description": "劃詞立即顯示工具列,或者劃詞後按住 Ctrl 鍵才顯示工具列。",
|
||||
"description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了AHK等工具對Ctrl鍵進行了重新對應,可能導致部分應用程式無法劃詞。",
|
||||
"selected": "劃詞",
|
||||
"ctrlkey": "Ctrl 鍵"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "緊湊模式",
|
||||
"description": "緊湊模式下,只顯示圖示,不顯示文字"
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"title": "功能視窗",
|
||||
"follow_toolbar": {
|
||||
"title": "跟隨工具列",
|
||||
"description": "視窗位置將跟隨工具列顯示,停用後則始終置中顯示"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "自動關閉",
|
||||
"description": "當視窗未置頂且失去焦點時,將自動關閉該視窗"
|
||||
},
|
||||
"auto_pin": {
|
||||
"title": "自動置頂",
|
||||
"description": "預設將視窗置於頂部"
|
||||
},
|
||||
"opacity": {
|
||||
"title": "透明度",
|
||||
"description": "設置視窗的默認透明度,100%為完全不透明"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"title": "功能",
|
||||
"reset": {
|
||||
"button": "重設",
|
||||
"tooltip": "重設為預設功能,自訂功能不會被刪除",
|
||||
"confirm": "確定要重設為預設功能嗎?自訂功能不會被刪除。"
|
||||
},
|
||||
"add_tooltip": {
|
||||
"enabled": "新增自訂功能",
|
||||
"disabled": "自訂功能已達上限 ({{max}}個)"
|
||||
},
|
||||
"delete_confirm": "確定要刪除這個自訂功能嗎?",
|
||||
"drag_hint": "拖曳排序,移動到上方以啟用功能 ({{enabled}}/{{max}})"
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "新增自訂功能",
|
||||
"edit": "編輯自訂功能"
|
||||
},
|
||||
"name": {
|
||||
"label": "名稱",
|
||||
"hint": "請輸入功能名稱"
|
||||
},
|
||||
"icon": {
|
||||
"label": "圖示",
|
||||
"placeholder": "輸入 Lucide 圖示名稱",
|
||||
"error": "無效的圖示名稱,請檢查輸入",
|
||||
"tooltip": "Lucide圖示名稱為小寫,如 arrow-right",
|
||||
"view_all": "檢視所有圖示",
|
||||
"random": "隨機圖示"
|
||||
},
|
||||
"model": {
|
||||
"label": "模型",
|
||||
"tooltip": "使用助手:會同時使用助手的系統提示詞和模型參數",
|
||||
"default": "預設模型",
|
||||
"assistant": "使用助手"
|
||||
},
|
||||
"assistant": {
|
||||
"label": "選擇助手",
|
||||
"default": "預設"
|
||||
},
|
||||
"prompt": {
|
||||
"label": "使用者提示詞(Prompt)",
|
||||
"tooltip": "使用者提示詞,作為使用者輸入的補充,不會覆蓋助手的系統提示詞",
|
||||
"placeholder": "使用佔位符{{text}}代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾",
|
||||
"placeholder_text": "佔位符",
|
||||
"copy_placeholder": "複製佔位符"
|
||||
}
|
||||
},
|
||||
"search_modal": {
|
||||
"title": "設定搜尋引擎",
|
||||
"engine": {
|
||||
"label": "搜尋引擎",
|
||||
"custom": "自訂"
|
||||
},
|
||||
"custom": {
|
||||
"name": {
|
||||
"label": "自訂名稱",
|
||||
"hint": "請輸入搜尋引擎名稱",
|
||||
"max_length": "名稱不能超過16個字元"
|
||||
},
|
||||
"url": {
|
||||
"label": "自訂搜尋 URL",
|
||||
"hint": "使用 {{queryString}} 代表搜尋詞",
|
||||
"required": "請輸入搜尋 URL",
|
||||
"invalid_format": "請輸入以 http:// 或 https:// 開頭的有效 URL",
|
||||
"missing_placeholder": "URL 必須包含 {{queryString}} 佔位符"
|
||||
},
|
||||
"test": "測試"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,232 @@
|
||||
import type { ActionItem } from '@renderer/types/selectionTypes'
|
||||
import { Button, Form, Input, Modal, Select } from 'antd'
|
||||
import { Globe } from 'lucide-react'
|
||||
import { FC, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface SearchEngineOption {
|
||||
label: string
|
||||
value: string
|
||||
searchEngine: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
export const LogoBing = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11.501 3v8.5h-8.5V3zm0 18h-8.5v-8.5h8.5zm1-18h8.5v8.5h-8.5zm8.5 9.5V21h-8.5v-8.5z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const LogoBaidu = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.926 12.497c2.063-.444 1.782-2.909 1.72-3.448c-.1-.83-1.078-2.282-2.404-2.167c-1.67.15-1.914 2.561-1.914 2.561c-.226 1.115.54 3.497 2.598 3.053m2.191 4.288c-.06.173-.195.616-.079 1.002c.23.866.982.905.982.905h1.08v-2.64H8.944c-.52.154-.77.559-.827.733m1.638-8.422c1.14 0 2.06-1.312 2.06-2.933s-.92-2.93-2.06-2.93c-1.138 0-2.06 1.31-2.06 2.93s.923 2.933 2.06 2.933m4.907.193c1.523.198 2.502-1.427 2.697-2.659c.198-1.23-.784-2.658-1.862-2.904c-1.08-.248-2.43 1.483-2.552 2.61c-.147 1.38.197 2.758 1.717 2.953m0 3.448c-1.865-2.905-4.513-1.723-5.399-.245c-.882 1.477-2.256 2.41-2.452 2.658c-.198.244-2.846 1.673-2.258 4.284c.588 2.609 2.653 2.56 2.653 2.56s1.521.15 3.286-.246c1.766-.391 3.286.098 3.286.098s4.124 1.38 5.253-1.278c1.127-2.66-.638-4.038-.638-4.038s-2.356-1.823-3.731-3.793m-6.007 7.75c-1.158-.231-1.62-1.021-1.677-1.156c-.057-.137-.386-.772-.212-1.853c.5-1.619 1.927-1.735 1.927-1.735h1.427v-1.755l1.216.02v6.479zm4.59-.019c-1.196-.308-1.252-1.158-1.252-1.158v-3.412l1.252-.02v3.066c.076.328.482.387.482.387H15v-3.433h1.331v4.57zm7.453-9.11c0-.59-.49-2.364-2.305-2.364c-1.818 0-2.061 1.675-2.061 2.859c0 1.13.095 2.707 2.354 2.657s2.012-2.56 2.012-3.152"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const LogoGoogle = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3.064 7.51A10 10 0 0 1 12 2c2.695 0 4.959.991 6.69 2.605l-2.867 2.868C14.786 6.482 13.468 5.977 12 5.977c-2.605 0-4.81 1.76-5.595 4.123c-.2.6-.314 1.24-.314 1.9s.114 1.3.314 1.9c.786 2.364 2.99 4.123 5.595 4.123c1.345 0 2.49-.355 3.386-.955a4.6 4.6 0 0 0 1.996-3.018H12v-3.868h9.418c.118.654.182 1.336.182 2.045c0 3.046-1.09 5.61-2.982 7.35C16.964 21.105 14.7 22 12 22A9.996 9.996 0 0 1 2 12c0-1.614.386-3.14 1.064-4.49"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const DEFAULT_SEARCH_ENGINES: SearchEngineOption[] = [
|
||||
{
|
||||
label: 'Google',
|
||||
value: 'Google',
|
||||
searchEngine: 'Google|https://www.google.com/search?q={{queryString}}',
|
||||
icon: <LogoGoogle style={{ fontSize: '14px', color: 'var(--color-text-2)' }} />
|
||||
},
|
||||
{
|
||||
label: 'Baidu',
|
||||
value: 'Baidu',
|
||||
searchEngine: 'Baidu|https://www.baidu.com/s?wd={{queryString}}',
|
||||
icon: <LogoBaidu style={{ fontSize: '14px', color: 'var(--color-text-2)' }} />
|
||||
},
|
||||
{
|
||||
label: 'Bing',
|
||||
value: 'Bing',
|
||||
searchEngine: 'Bing|https://www.bing.com/search?q={{queryString}}',
|
||||
icon: <LogoBing style={{ fontSize: '14px', color: 'var(--color-text-2)' }} />
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
value: 'custom',
|
||||
searchEngine: '',
|
||||
icon: <Globe size={14} color="var(--color-text-2)" />
|
||||
}
|
||||
]
|
||||
|
||||
const EXAMPLE_URL = 'https://example.com/search?q={{queryString}}'
|
||||
|
||||
interface SelectionActionSearchModalProps {
|
||||
isModalOpen: boolean
|
||||
onOk: (searchEngine: string) => void
|
||||
onCancel: () => void
|
||||
currentAction?: ActionItem
|
||||
}
|
||||
|
||||
const SelectionActionSearchModal: FC<SelectionActionSearchModalProps> = ({
|
||||
isModalOpen,
|
||||
onOk,
|
||||
onCancel,
|
||||
currentAction
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen && currentAction?.searchEngine) {
|
||||
form.resetFields()
|
||||
|
||||
const [engine, url] = currentAction.searchEngine.split('|')
|
||||
const defaultEngine = DEFAULT_SEARCH_ENGINES.find((e) => e.value === engine)
|
||||
|
||||
if (defaultEngine) {
|
||||
form.setFieldsValue({
|
||||
engine: defaultEngine.value,
|
||||
customName: '',
|
||||
customUrl: ''
|
||||
})
|
||||
} else {
|
||||
// Handle custom search engine
|
||||
form.setFieldsValue({
|
||||
engine: 'custom',
|
||||
customName: engine,
|
||||
customUrl: url
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isModalOpen, currentAction, form])
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const selectedEngine = DEFAULT_SEARCH_ENGINES.find((e) => e.value === values.engine)
|
||||
|
||||
const searchEngine =
|
||||
selectedEngine?.value === 'custom'
|
||||
? `${values.customName}|${values.customUrl}`
|
||||
: selectedEngine?.searchEngine || ''
|
||||
|
||||
onOk(searchEngine)
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel()
|
||||
}
|
||||
|
||||
const handleTest = () => {
|
||||
const values = form.getFieldsValue()
|
||||
if (values.customUrl) {
|
||||
const testUrl = values.customUrl.replace('{{queryString}}', 'cherry studio')
|
||||
window.api.openWebsite(testUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('selection.settings.search_modal.title')}
|
||||
open={isModalOpen}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
destroyOnClose
|
||||
centered>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
engine: 'Google',
|
||||
customName: '',
|
||||
customUrl: ''
|
||||
}}>
|
||||
<Form.Item name="engine" label={t('selection.settings.search_modal.engine.label')}>
|
||||
<Select
|
||||
options={DEFAULT_SEARCH_ENGINES.map((engine) => ({
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{engine.icon}
|
||||
<span>{engine.label || t('selection.settings.search_modal.engine.custom')}</span>
|
||||
</div>
|
||||
),
|
||||
value: engine.value
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
if (value === 'custom') {
|
||||
form.setFieldsValue({
|
||||
customName: '',
|
||||
customUrl: EXAMPLE_URL
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.engine !== currentValues.engine}>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('engine') === 'custom' ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="customName"
|
||||
label={t('selection.settings.search_modal.custom.name.label')}
|
||||
rules={[
|
||||
{ required: true, message: t('selection.settings.search_modal.custom.name.hint') },
|
||||
{ max: 16, message: t('selection.settings.search_modal.custom.name.max_length') }
|
||||
]}>
|
||||
<Input placeholder={t('selection.settings.search_modal.custom.name.hint')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="customUrl"
|
||||
label={t('selection.settings.search_modal.custom.url.label')}
|
||||
tooltip={t('selection.settings.search_modal.custom.url.hint')}
|
||||
rules={[
|
||||
{ required: true, message: t('selection.settings.search_modal.custom.url.required') },
|
||||
{
|
||||
pattern: /^https?:\/\/.+$/,
|
||||
message: t('selection.settings.search_modal.custom.url.invalid_format')
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (value && !value.includes('{{queryString}}')) {
|
||||
return Promise.reject(t('selection.settings.search_modal.custom.url.missing_placeholder'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<Input
|
||||
placeholder={EXAMPLE_URL}
|
||||
suffix={
|
||||
<Button type="link" size="small" onClick={handleTest} style={{ padding: 0, height: 'auto' }}>
|
||||
{t('selection.settings.search_modal.custom.test')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectionActionSearchModal
|
||||
@ -0,0 +1,337 @@
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import CopyButton from '@renderer/components/CopyButton'
|
||||
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import type { ActionItem } from '@renderer/types/selectionTypes'
|
||||
import { Col, Input, Modal, Radio, Row, Select, Space, Tooltip } from 'antd'
|
||||
import { CircleHelp, Dices, OctagonX } from 'lucide-react'
|
||||
import { DynamicIcon, iconNames } from 'lucide-react/dynamic'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface SelectionActionUserModalProps {
|
||||
isModalOpen: boolean
|
||||
editingAction: ActionItem | null
|
||||
onOk: (data: ActionItem) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const SelectionActionUserModal: FC<SelectionActionUserModalProps> = ({
|
||||
isModalOpen,
|
||||
editingAction,
|
||||
onOk,
|
||||
onCancel
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { assistants: userPredefinedAssistants } = useAssistants()
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
|
||||
const [formData, setFormData] = useState<Partial<ActionItem>>({})
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof ActionItem, string>>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
// 如果是编辑模式,使用现有数据;否则使用空数据
|
||||
setFormData(
|
||||
editingAction || {
|
||||
name: '',
|
||||
prompt: '',
|
||||
icon: '',
|
||||
assistantId: ''
|
||||
}
|
||||
)
|
||||
setErrors({})
|
||||
}
|
||||
}, [isModalOpen, editingAction])
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<Record<keyof ActionItem, string>> = {}
|
||||
|
||||
if (!formData.name?.trim()) {
|
||||
newErrors.name = t('selection.settings.user_modal.name.hint')
|
||||
}
|
||||
|
||||
if (formData.icon && !iconNames.includes(formData.icon as any)) {
|
||||
newErrors.icon = t('selection.settings.user_modal.icon.error')
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleOk = () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 构建完整的 ActionItem
|
||||
const actionItem: ActionItem = {
|
||||
id: editingAction?.id || `user-${Date.now()}`,
|
||||
name: formData.name || 'USER',
|
||||
enabled: editingAction?.enabled || false,
|
||||
isBuiltIn: editingAction?.isBuiltIn || false,
|
||||
icon: formData.icon,
|
||||
prompt: formData.prompt,
|
||||
assistantId: formData.assistantId
|
||||
}
|
||||
|
||||
onOk(actionItem)
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof ActionItem, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
editingAction ? t('selection.settings.user_modal.title.edit') : t('selection.settings.user_modal.title.add')
|
||||
}
|
||||
open={isModalOpen}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
width={520}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<ModalSection>
|
||||
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||
<Col flex="auto" style={{ paddingRight: '16px', width: '70%' }}>
|
||||
<ModalSectionTitle>
|
||||
<ModalSectionTitleLabel>{t('selection.settings.user_modal.name.label')}</ModalSectionTitleLabel>
|
||||
</ModalSectionTitle>
|
||||
<Input
|
||||
placeholder={t('selection.settings.user_modal.name.hint')}
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
maxLength={16}
|
||||
status={errors.name ? 'error' : ''}
|
||||
/>
|
||||
{errors.name && <ErrorText>{errors.name}</ErrorText>}
|
||||
</Col>
|
||||
<Col>
|
||||
<ModalSectionTitle>
|
||||
<ModalSectionTitleLabel>{t('selection.settings.user_modal.icon.label')}</ModalSectionTitleLabel>
|
||||
<Tooltip placement="top" title={t('selection.settings.user_modal.icon.tooltip')} arrow>
|
||||
<QuestionIcon size={14} />
|
||||
</Tooltip>
|
||||
<Spacer />
|
||||
<a
|
||||
href="https://lucide.dev/icons/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '12px', color: 'var(--color-primary)' }}>
|
||||
{t('selection.settings.user_modal.icon.view_all')}
|
||||
</a>
|
||||
<Tooltip title={t('selection.settings.user_modal.icon.random')}>
|
||||
<DiceButton
|
||||
onClick={() => {
|
||||
const randomIcon = iconNames[Math.floor(Math.random() * iconNames.length)]
|
||||
handleInputChange('icon', randomIcon)
|
||||
}}>
|
||||
<Dices size={14} className="btn-icon" />
|
||||
</DiceButton>
|
||||
</Tooltip>
|
||||
</ModalSectionTitle>
|
||||
<Space>
|
||||
<Input
|
||||
placeholder={t('selection.settings.user_modal.icon.placeholder')}
|
||||
value={formData.icon || ''}
|
||||
onChange={(e) => handleInputChange('icon', e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
status={errors.icon ? 'error' : ''}
|
||||
/>
|
||||
<IconPreview>
|
||||
{formData.icon &&
|
||||
(iconNames.includes(formData.icon as any) ? (
|
||||
<DynamicIcon name={formData.icon as any} size={18} />
|
||||
) : (
|
||||
<OctagonX size={18} color="var(--color-error)" />
|
||||
))}
|
||||
</IconPreview>
|
||||
</Space>
|
||||
{errors.icon && <ErrorText>{errors.icon}</ErrorText>}
|
||||
</Col>
|
||||
</div>
|
||||
</ModalSection>
|
||||
<ModalSection>
|
||||
<Row>
|
||||
<Col flex="auto" style={{ paddingRight: '16px' }}>
|
||||
<ModalSectionTitle>
|
||||
<ModalSectionTitleLabel>{t('selection.settings.user_modal.model.label')}</ModalSectionTitleLabel>
|
||||
<Tooltip placement="top" title={t('selection.settings.user_modal.model.tooltip')} arrow>
|
||||
<QuestionIcon size={14} />
|
||||
</Tooltip>
|
||||
</ModalSectionTitle>
|
||||
</Col>
|
||||
<Radio.Group
|
||||
value={formData.assistantId ? 'assistant' : 'default'}
|
||||
onChange={(e) =>
|
||||
handleInputChange('assistantId', e.target.value === 'default' ? '' : defaultAssistant.id)
|
||||
}
|
||||
buttonStyle="solid">
|
||||
<Radio.Button value="default">{t('selection.settings.user_modal.model.default')}</Radio.Button>
|
||||
<Radio.Button value="assistant">{t('selection.settings.user_modal.model.assistant')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Row>
|
||||
</ModalSection>
|
||||
|
||||
{formData.assistantId && (
|
||||
<ModalSection>
|
||||
<ModalSectionTitle>
|
||||
<ModalSectionTitleLabel>{t('selection.settings.user_modal.assistant.label')}</ModalSectionTitleLabel>
|
||||
</ModalSectionTitle>
|
||||
<Select
|
||||
value={formData.assistantId || defaultAssistant.id}
|
||||
onChange={(value) => handleInputChange('assistantId', value)}
|
||||
style={{ width: '100%' }}
|
||||
dropdownRender={(menu) => menu}>
|
||||
<Select.Option key={defaultAssistant.id} value={defaultAssistant.id}>
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={defaultAssistant.model || getDefaultModel()} size={18} />
|
||||
<AssistantName>{defaultAssistant.name}</AssistantName>
|
||||
<Spacer />
|
||||
<CurrentTag isCurrent={true}>{t('selection.settings.user_modal.assistant.default')}</CurrentTag>
|
||||
</AssistantItem>
|
||||
</Select.Option>
|
||||
{userPredefinedAssistants
|
||||
.filter((a) => a.id !== defaultAssistant.id)
|
||||
.map((a) => (
|
||||
<Select.Option key={a.id} value={a.id}>
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={a.model || getDefaultModel()} size={18} />
|
||||
<AssistantName>{a.name}</AssistantName>
|
||||
<Spacer />
|
||||
</AssistantItem>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</ModalSection>
|
||||
)}
|
||||
<ModalSection>
|
||||
<ModalSectionTitle>
|
||||
<ModalSectionTitleLabel>{t('selection.settings.user_modal.prompt.label')}</ModalSectionTitleLabel>
|
||||
<Tooltip placement="top" title={t('selection.settings.user_modal.prompt.tooltip')} arrow>
|
||||
<QuestionIcon size={14} />
|
||||
</Tooltip>
|
||||
<Spacer />
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
userSelect: 'text',
|
||||
color: 'var(--color-text-2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{t('selection.settings.user_modal.prompt.placeholder_text')} {'{{text}}'}
|
||||
<CopyButton tooltip={t('selection.settings.user_modal.prompt.copy_placeholder')} textToCopy="{{text}}" />
|
||||
</div>
|
||||
</ModalSectionTitle>
|
||||
<Input.TextArea
|
||||
placeholder={t('selection.settings.user_modal.prompt.placeholder')}
|
||||
value={formData.prompt || ''}
|
||||
onChange={(e) => handleInputChange('prompt', e.target.value)}
|
||||
rows={4}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
</ModalSection>
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const ModalSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 16px;
|
||||
`
|
||||
|
||||
const ModalSectionTitle = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
|
||||
const ModalSectionTitleLabel = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
const QuestionIcon = styled(CircleHelp)`
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const ErrorText = styled.div`
|
||||
color: var(--color-error);
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
const Spacer = styled.div`
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const IconPreview = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
`
|
||||
|
||||
const AssistantItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
`
|
||||
|
||||
const AssistantName = styled.span`
|
||||
max-width: calc(100% - 60px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const CurrentTag = styled.span<{ isCurrent: boolean }>`
|
||||
color: ${(props) => (props.isCurrent ? 'var(--color-primary)' : 'var(--color-text-3)')};
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
`
|
||||
|
||||
const DiceButton = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-left: 4px;
|
||||
|
||||
.btn-icon {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.btn-icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: rotate(720deg);
|
||||
}
|
||||
`
|
||||
|
||||
export default SelectionActionUserModal
|
||||
@ -0,0 +1,126 @@
|
||||
import { DragDropContext } from '@hello-pangea/dnd'
|
||||
import { defaultActionItems } from '@renderer/store/selectionStore'
|
||||
import type { ActionItem } from '@renderer/types/selectionTypes'
|
||||
import SelectionToolbar from '@renderer/windows/selection/toolbar/SelectionToolbar'
|
||||
import { Row } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingDivider, SettingGroup } from '..'
|
||||
import ActionsList from './components/ActionsList'
|
||||
import ActionsListDivider from './components/ActionsListDivider'
|
||||
import SettingsActionsListHeader from './components/SettingsActionsListHeader'
|
||||
import { useActionItems } from './hooks/useSettingsActionsList'
|
||||
import SelectionActionSearchModal from './SelectionActionSearchModal'
|
||||
import SelectionActionUserModal from './SelectionActionUserModal'
|
||||
|
||||
// Component for managing selection actions in settings
|
||||
// Handles drag-and-drop reordering, enabling/disabling actions, and custom action management
|
||||
|
||||
// Props for the main component
|
||||
interface SelectionActionsListProps {
|
||||
actionItems: ActionItem[] | undefined // List of all available actions
|
||||
setActionItems: (items: ActionItem[]) => void // Function to update action items
|
||||
}
|
||||
|
||||
const SelectionActionsList: FC<SelectionActionsListProps> = ({ actionItems, setActionItems }) => {
|
||||
const {
|
||||
enabledItems,
|
||||
disabledItems,
|
||||
customItemsCount,
|
||||
isUserModalOpen,
|
||||
isSearchModalOpen,
|
||||
userEditingAction,
|
||||
setIsUserModalOpen,
|
||||
setIsSearchModalOpen,
|
||||
handleEditActionItem,
|
||||
handleAddNewAction,
|
||||
handleUserModalOk,
|
||||
handleSearchModalOk,
|
||||
handleDeleteActionItem,
|
||||
handleReset,
|
||||
onDragEnd,
|
||||
getSearchEngineInfo,
|
||||
MAX_CUSTOM_ITEMS,
|
||||
MAX_ENABLED_ITEMS
|
||||
} = useActionItems(actionItems, setActionItems)
|
||||
|
||||
if (!actionItems || actionItems.length === 0) {
|
||||
setActionItems(defaultActionItems)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup>
|
||||
<SettingsActionsListHeader
|
||||
customItemsCount={customItemsCount}
|
||||
maxCustomItems={MAX_CUSTOM_ITEMS}
|
||||
onReset={handleReset}
|
||||
onAdd={handleAddNewAction}
|
||||
/>
|
||||
|
||||
<SettingDivider />
|
||||
|
||||
<DemoSection>
|
||||
<SelectionToolbar demo />
|
||||
</DemoSection>
|
||||
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<ActionsListSection>
|
||||
<ActionColumn>
|
||||
<ActionsList
|
||||
droppableId="enabled"
|
||||
items={enabledItems}
|
||||
isLastEnabledItem={enabledItems.length === 1}
|
||||
onEdit={handleEditActionItem}
|
||||
onDelete={handleDeleteActionItem}
|
||||
getSearchEngineInfo={getSearchEngineInfo}
|
||||
/>
|
||||
|
||||
<ActionsListDivider enabledCount={enabledItems.length} maxEnabled={MAX_ENABLED_ITEMS} />
|
||||
|
||||
<ActionsList
|
||||
droppableId="disabled"
|
||||
items={disabledItems}
|
||||
isLastEnabledItem={false}
|
||||
onEdit={handleEditActionItem}
|
||||
onDelete={handleDeleteActionItem}
|
||||
getSearchEngineInfo={getSearchEngineInfo}
|
||||
/>
|
||||
</ActionColumn>
|
||||
</ActionsListSection>
|
||||
</DragDropContext>
|
||||
|
||||
<SelectionActionUserModal
|
||||
isModalOpen={isUserModalOpen}
|
||||
editingAction={userEditingAction}
|
||||
onOk={handleUserModalOk}
|
||||
onCancel={() => setIsUserModalOpen(false)}
|
||||
/>
|
||||
|
||||
<SelectionActionSearchModal
|
||||
isModalOpen={isSearchModalOpen}
|
||||
onOk={handleSearchModalOk}
|
||||
onCancel={() => setIsSearchModalOpen(false)}
|
||||
currentAction={actionItems?.find((item) => item.id === 'search')}
|
||||
/>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
const ActionsListSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
const ActionColumn = styled.div`
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const DemoSection = styled(Row)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 24px 0;
|
||||
`
|
||||
|
||||
export default SelectionActionsList
|
||||
@ -0,0 +1,191 @@
|
||||
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 SelectionToolbar from '@renderer/windows/selection/toolbar/SelectionToolbar'
|
||||
import { Radio, Row, Slider, Switch, Tooltip } from 'antd'
|
||||
import { CircleHelp } from 'lucide-react'
|
||||
import { FC, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import {
|
||||
SettingContainer,
|
||||
SettingDescription,
|
||||
SettingDivider,
|
||||
SettingGroup,
|
||||
SettingRow,
|
||||
SettingRowTitle,
|
||||
SettingTitle
|
||||
} from '..'
|
||||
import SelectionActionsList from './SelectionActionsList'
|
||||
|
||||
const SelectionAssistantSettings: FC = () => {
|
||||
const { theme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
selectionEnabled,
|
||||
triggerMode,
|
||||
isCompact,
|
||||
isAutoClose,
|
||||
isAutoPin,
|
||||
isFollowToolbar,
|
||||
actionItems,
|
||||
actionWindowOpacity,
|
||||
setSelectionEnabled,
|
||||
setTriggerMode,
|
||||
setIsCompact,
|
||||
setIsAutoClose,
|
||||
setIsAutoPin,
|
||||
setIsFollowToolbar,
|
||||
setActionWindowOpacity,
|
||||
setActionItems
|
||||
} = useSelectionAssistant()
|
||||
|
||||
// force disable selection assistant on non-windows systems
|
||||
useEffect(() => {
|
||||
if (!isWindows && selectionEnabled) {
|
||||
setSelectionEnabled(false)
|
||||
}
|
||||
}, [selectionEnabled, setSelectionEnabled])
|
||||
|
||||
return (
|
||||
<SettingContainer theme={theme}>
|
||||
<SettingGroup>
|
||||
<Row>
|
||||
<SettingTitle>{t('selection.name')}</SettingTitle>
|
||||
<Spacer />
|
||||
<ExperimentalText>{t('selection.settings.experimental')}</ExperimentalText>
|
||||
</Row>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingLabel>
|
||||
<SettingRowTitle>{t('selection.settings.enable.title')}</SettingRowTitle>
|
||||
{!isWindows && <SettingDescription>{t('selection.settings.enable.description')}</SettingDescription>}
|
||||
</SettingLabel>
|
||||
<Switch
|
||||
checked={isWindows && selectionEnabled}
|
||||
onChange={(checked) => setSelectionEnabled(checked)}
|
||||
disabled={!isWindows}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{!selectionEnabled && (
|
||||
<DemoContainer>
|
||||
<SelectionToolbar demo />
|
||||
</DemoContainer>
|
||||
)}
|
||||
</SettingGroup>
|
||||
{selectionEnabled && (
|
||||
<>
|
||||
<SettingGroup>
|
||||
<SettingTitle>{t('selection.settings.toolbar.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
|
||||
<SettingRow>
|
||||
<SettingLabel>
|
||||
<SettingRowTitle>
|
||||
<div style={{ marginRight: '4px' }}>{t('selection.settings.toolbar.trigger_mode.title')}</div>
|
||||
<Tooltip placement="top" title={t('selection.settings.toolbar.trigger_mode.description_note')} arrow>
|
||||
<QuestionIcon size={14} />
|
||||
</Tooltip>
|
||||
</SettingRowTitle>
|
||||
<SettingDescription>{t('selection.settings.toolbar.trigger_mode.description')}</SettingDescription>
|
||||
</SettingLabel>
|
||||
<Radio.Group
|
||||
value={triggerMode}
|
||||
onChange={(e) => setTriggerMode(e.target.value as TriggerMode)}
|
||||
buttonStyle="solid">
|
||||
<Radio.Button value="selected">{t('selection.settings.toolbar.trigger_mode.selected')}</Radio.Button>
|
||||
<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>
|
||||
<SettingDescription>{t('selection.settings.toolbar.compact_mode.description')}</SettingDescription>
|
||||
</SettingLabel>
|
||||
<Switch checked={isCompact} onChange={(checked) => setIsCompact(checked)} />
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup>
|
||||
<SettingTitle>{t('selection.settings.window.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
|
||||
<SettingRow>
|
||||
<SettingLabel>
|
||||
<SettingRowTitle>{t('selection.settings.window.follow_toolbar.title')}</SettingRowTitle>
|
||||
<SettingDescription>{t('selection.settings.window.follow_toolbar.description')}</SettingDescription>
|
||||
</SettingLabel>
|
||||
<Switch checked={isFollowToolbar} onChange={(checked) => setIsFollowToolbar(checked)} />
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingLabel>
|
||||
<SettingRowTitle>{t('selection.settings.window.auto_close.title')}</SettingRowTitle>
|
||||
<SettingDescription>{t('selection.settings.window.auto_close.description')}</SettingDescription>
|
||||
</SettingLabel>
|
||||
<Switch checked={isAutoClose} onChange={(checked) => setIsAutoClose(checked)} />
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingLabel>
|
||||
<SettingRowTitle>{t('selection.settings.window.auto_pin.title')}</SettingRowTitle>
|
||||
<SettingDescription>{t('selection.settings.window.auto_pin.description')}</SettingDescription>
|
||||
</SettingLabel>
|
||||
<Switch checked={isAutoPin} onChange={(checked) => setIsAutoPin(checked)} />
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingLabel>
|
||||
<SettingRowTitle>{t('selection.settings.window.opacity.title')}</SettingRowTitle>
|
||||
<SettingDescription>{t('selection.settings.window.opacity.description')}</SettingDescription>
|
||||
</SettingLabel>
|
||||
<div style={{ marginRight: '16px' }}>{actionWindowOpacity}%</div>
|
||||
<Slider
|
||||
style={{ width: 100 }}
|
||||
min={20}
|
||||
max={100}
|
||||
reverse
|
||||
value={actionWindowOpacity}
|
||||
onChange={setActionWindowOpacity}
|
||||
tooltip={{ open: false }}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SelectionActionsList actionItems={actionItems} setActionItems={setActionItems} />
|
||||
</>
|
||||
)}
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const Spacer = styled.div`
|
||||
flex: 1;
|
||||
`
|
||||
const SettingLabel = styled.div`
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const ExperimentalText = styled.div`
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
const DemoContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 5px;
|
||||
`
|
||||
|
||||
const QuestionIcon = styled(CircleHelp)`
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
export default SelectionAssistantSettings
|
||||
@ -0,0 +1,60 @@
|
||||
import type { DroppableProvided } from '@hello-pangea/dnd'
|
||||
import { Draggable, Droppable } from '@hello-pangea/dnd'
|
||||
import type { ActionItem as ActionItemType } from '@renderer/types/selectionTypes'
|
||||
import { memo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import ActionsListItemComponent from './ActionsListItem'
|
||||
|
||||
interface ActionListProps {
|
||||
droppableId: 'enabled' | 'disabled'
|
||||
items: ActionItemType[]
|
||||
isLastEnabledItem: boolean
|
||||
onEdit: (item: ActionItemType) => void
|
||||
onDelete: (id: string) => void
|
||||
getSearchEngineInfo: (engine: string) => { icon: any; name: string } | null
|
||||
}
|
||||
|
||||
const ActionsList = memo(
|
||||
({ droppableId, items, isLastEnabledItem, onEdit, onDelete, getSearchEngineInfo }: ActionListProps) => {
|
||||
return (
|
||||
<Droppable droppableId={droppableId}>
|
||||
{(provided: DroppableProvided) => (
|
||||
<List ref={provided.innerRef} {...provided.droppableProps}>
|
||||
<ActionsListContent>
|
||||
{items.map((item, index) => (
|
||||
<Draggable key={item.id} draggableId={item.id} index={index}>
|
||||
{(provided) => (
|
||||
<ActionsListItemComponent
|
||||
item={item}
|
||||
provided={provided}
|
||||
listType={droppableId}
|
||||
isLastEnabledItem={isLastEnabledItem}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
getSearchEngineInfo={getSearchEngineInfo}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</ActionsListContent>
|
||||
</List>
|
||||
)}
|
||||
</Droppable>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const List = styled.div`
|
||||
background: var(--color-bg-1);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 1px;
|
||||
`
|
||||
|
||||
const ActionsListContent = styled.div`
|
||||
padding: 10px;
|
||||
`
|
||||
|
||||
export default ActionsList
|
||||
@ -0,0 +1,41 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface DividerProps {
|
||||
enabledCount: number
|
||||
maxEnabled: number
|
||||
}
|
||||
|
||||
const ActionsListDivider = memo(({ enabledCount, maxEnabled }: DividerProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<DividerContainer>
|
||||
<DividerLine />
|
||||
<DividerText>{t('selection.settings.actions.drag_hint', { enabled: enabledCount, max: maxEnabled })}</DividerText>
|
||||
<DividerLine />
|
||||
</DividerContainer>
|
||||
)
|
||||
})
|
||||
|
||||
const DividerContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin: 16px 12px;
|
||||
`
|
||||
|
||||
const DividerLine = styled.div`
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: var(--color-border);
|
||||
`
|
||||
|
||||
const DividerText = styled.span`
|
||||
margin: 0 16px;
|
||||
`
|
||||
|
||||
export default ActionsListDivider
|
||||
@ -0,0 +1,163 @@
|
||||
import type { DraggableProvided } from '@hello-pangea/dnd'
|
||||
import type { ActionItem as ActionItemType } from '@renderer/types/selectionTypes'
|
||||
import { Button } from 'antd'
|
||||
import { Pencil, Settings2, Trash } from 'lucide-react'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ActionItemProps {
|
||||
item: ActionItemType
|
||||
provided: DraggableProvided
|
||||
listType: 'enabled' | 'disabled'
|
||||
isLastEnabledItem: boolean
|
||||
onEdit: (item: ActionItemType) => void
|
||||
onDelete: (id: string) => void
|
||||
getSearchEngineInfo: (engine: string) => { icon: any; name: string } | null
|
||||
}
|
||||
|
||||
const ActionsListItem = memo(
|
||||
({ item, provided, listType, isLastEnabledItem, onEdit, onDelete, getSearchEngineInfo }: ActionItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const isEnabled = listType === 'enabled'
|
||||
|
||||
return (
|
||||
<Item
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...(isLastEnabledItem ? {} : provided.dragHandleProps)}
|
||||
disabled={!isEnabled}
|
||||
className={isLastEnabledItem ? 'non-draggable' : ''}>
|
||||
<ItemLeft>
|
||||
<ItemIcon disabled={!isEnabled}>
|
||||
<DynamicIcon name={item.icon as any} size={16} fallback={() => <div style={{ width: 16, height: 16 }} />} />
|
||||
</ItemIcon>
|
||||
<ItemName disabled={!isEnabled}>{item.isBuiltIn ? t(item.name) : item.name}</ItemName>
|
||||
{item.id === 'search' && item.searchEngine && (
|
||||
<ItemDescription>
|
||||
{getSearchEngineInfo(item.searchEngine)?.icon}
|
||||
<span>{getSearchEngineInfo(item.searchEngine)?.name}</span>
|
||||
</ItemDescription>
|
||||
)}
|
||||
</ItemLeft>
|
||||
|
||||
<ActionOperations item={item} onEdit={onEdit} onDelete={onDelete} />
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
interface ActionOperationsProps {
|
||||
item: ActionItemType
|
||||
onEdit: (item: ActionItemType) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
const ActionOperations = memo(({ item, onEdit, onDelete }: ActionOperationsProps) => {
|
||||
if (!item.isBuiltIn) {
|
||||
return (
|
||||
<UserActionOpSection>
|
||||
<Button type="link" size="small" onClick={() => onEdit(item)}>
|
||||
<Pencil size={16} className="btn-icon-edit" />
|
||||
</Button>
|
||||
<Button type="link" size="small" danger onClick={() => onDelete(item.id)}>
|
||||
<Trash size={16} className="btn-icon-delete" />
|
||||
</Button>
|
||||
</UserActionOpSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.isBuiltIn && item.id === 'search') {
|
||||
return (
|
||||
<UserActionOpSection>
|
||||
<Button type="link" size="small" onClick={() => onEdit(item)}>
|
||||
<Settings2 size={16} className="btn-icon-edit" />
|
||||
</Button>
|
||||
</UserActionOpSection>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const Item = styled.div<{ disabled: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
background-color: var(--color-bg-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
cursor: move;
|
||||
opacity: ${(props) => (props.disabled ? 0.8 : 1)};
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-2);
|
||||
}
|
||||
|
||||
&.non-draggable {
|
||||
cursor: default;
|
||||
background-color: var(--color-bg-2);
|
||||
position: relative;
|
||||
}
|
||||
`
|
||||
|
||||
const ItemLeft = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const ItemName = styled.span<{ disabled: boolean }>`
|
||||
margin-left: 8px;
|
||||
color: ${(props) => (props.disabled ? 'var(--color-text-3)' : 'var(--color-text-1)')};
|
||||
`
|
||||
|
||||
const ItemIcon = styled.div<{ disabled: boolean }>`
|
||||
margin: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => (props.disabled ? 'var(--color-text-3)' : 'var(--color-primary)')};
|
||||
`
|
||||
|
||||
const ItemDescription = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
opacity: 0.8;
|
||||
`
|
||||
|
||||
const UserActionOpSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.btn-icon-edit {
|
||||
color: var(--color-text-3);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
.btn-icon-delete {
|
||||
color: var(--color-text-3);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default ActionsListItem
|
||||
@ -0,0 +1,53 @@
|
||||
import { Button, Row, Tooltip } from 'antd'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingTitle } from '../..'
|
||||
|
||||
interface HeaderSectionProps {
|
||||
customItemsCount: number
|
||||
maxCustomItems: number
|
||||
onReset: () => void
|
||||
onAdd: () => void
|
||||
}
|
||||
|
||||
const SettingsActionsListHeader = memo(({ customItemsCount, maxCustomItems, onReset, onAdd }: HeaderSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const isCustomItemLimitReached = customItemsCount >= maxCustomItems
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<SettingTitle>{t('selection.settings.actions.title')}</SettingTitle>
|
||||
<Spacer />
|
||||
<Tooltip title={t('selection.settings.actions.reset.tooltip')}>
|
||||
<ResetButton type="text" onClick={onReset}>
|
||||
{t('selection.settings.actions.reset.button')}
|
||||
</ResetButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={
|
||||
isCustomItemLimitReached
|
||||
? t('selection.settings.actions.add_tooltip.disabled', { max: maxCustomItems })
|
||||
: t('selection.settings.actions.add_tooltip.enabled')
|
||||
}>
|
||||
<Button type="primary" icon={<Plus size={16} />} onClick={onAdd} disabled={isCustomItemLimitReached} />
|
||||
</Tooltip>
|
||||
</Row>
|
||||
)
|
||||
})
|
||||
|
||||
const Spacer = styled.div`
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const ResetButton = styled(Button)`
|
||||
margin: 0 8px;
|
||||
color: var(--color-text-3);
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
export default SettingsActionsListHeader
|
||||
@ -0,0 +1,178 @@
|
||||
import { DropResult } from '@hello-pangea/dnd'
|
||||
import { defaultActionItems } from '@renderer/store/selectionStore'
|
||||
import type { ActionItem } from '@renderer/types/selectionTypes'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DEFAULT_SEARCH_ENGINES } from '../SelectionActionSearchModal'
|
||||
|
||||
const MAX_CUSTOM_ITEMS = 8
|
||||
const MAX_ENABLED_ITEMS = 6
|
||||
|
||||
export const useActionItems = (
|
||||
initialItems: ActionItem[] | undefined,
|
||||
setActionItems: (items: ActionItem[]) => void
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const [isUserModalOpen, setIsUserModalOpen] = useState(false)
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false)
|
||||
const [userEditingAction, setUserEditingAction] = useState<ActionItem | null>(null)
|
||||
|
||||
const enabledItems = useMemo(() => initialItems?.filter((item) => item.enabled) ?? [], [initialItems])
|
||||
const disabledItems = useMemo(() => initialItems?.filter((item) => !item.enabled) ?? [], [initialItems])
|
||||
const customItemsCount = useMemo(() => initialItems?.filter((item) => !item.isBuiltIn).length ?? 0, [initialItems])
|
||||
|
||||
const handleEditActionItem = (item: ActionItem) => {
|
||||
if (item.isBuiltIn) {
|
||||
if (item.id === 'search') {
|
||||
setIsSearchModalOpen(true)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
setUserEditingAction(item)
|
||||
setIsUserModalOpen(true)
|
||||
}
|
||||
|
||||
const handleAddNewAction = () => {
|
||||
if (customItemsCount >= MAX_CUSTOM_ITEMS) return
|
||||
setUserEditingAction(null)
|
||||
setIsUserModalOpen(true)
|
||||
}
|
||||
|
||||
const handleUserModalOk = (actionItem: ActionItem) => {
|
||||
if (userEditingAction && initialItems) {
|
||||
const updatedItems = initialItems.map((item) => (item.id === userEditingAction.id ? actionItem : item))
|
||||
setActionItems(updatedItems)
|
||||
} else {
|
||||
try {
|
||||
const currentItems = initialItems || []
|
||||
setActionItems([...currentItems, actionItem])
|
||||
} catch (error) {
|
||||
console.error('Error adding item:', error)
|
||||
}
|
||||
}
|
||||
setIsUserModalOpen(false)
|
||||
}
|
||||
|
||||
const handleSearchModalOk = (searchEngine: string) => {
|
||||
if (!initialItems) return
|
||||
const updatedItems = initialItems.map((item) => (item.id === 'search' ? { ...item, searchEngine } : item))
|
||||
setActionItems(updatedItems)
|
||||
setIsSearchModalOpen(false)
|
||||
}
|
||||
|
||||
const handleDeleteActionItem = (id: string) => {
|
||||
if (!initialItems) return
|
||||
window.modal.confirm({
|
||||
centered: true,
|
||||
content: t('selection.settings.actions.delete_confirm'),
|
||||
onOk: () => {
|
||||
setActionItems(initialItems.filter((item) => item.id !== id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (!initialItems) return
|
||||
window.modal.confirm({
|
||||
centered: true,
|
||||
content: t('selection.settings.actions.reset.confirm'),
|
||||
onOk: () => {
|
||||
const userItems = initialItems.filter((item) => !item.isBuiltIn).map((item) => ({ ...item, enabled: false }))
|
||||
setActionItems([...defaultActionItems, ...userItems])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination || !initialItems) return
|
||||
|
||||
const { source, destination } = result
|
||||
|
||||
if (source.droppableId === 'enabled' && destination.droppableId === 'disabled' && enabledItems.length === 1) {
|
||||
return
|
||||
}
|
||||
|
||||
if (source.droppableId === destination.droppableId) {
|
||||
const list = source.droppableId === 'enabled' ? [...enabledItems] : [...disabledItems]
|
||||
const [removed] = list.splice(source.index, 1)
|
||||
list.splice(destination.index, 0, removed)
|
||||
|
||||
if (source.droppableId === 'enabled') {
|
||||
const limitedEnabledItems = list.slice(0, MAX_ENABLED_ITEMS)
|
||||
const overflowItems = list.length > MAX_ENABLED_ITEMS ? list.slice(MAX_ENABLED_ITEMS) : []
|
||||
|
||||
const updatedItems = [
|
||||
...limitedEnabledItems.map((item) => ({ ...item, enabled: true })),
|
||||
...disabledItems,
|
||||
...overflowItems.map((item) => ({ ...item, enabled: false }))
|
||||
]
|
||||
|
||||
setActionItems(updatedItems)
|
||||
} else {
|
||||
const updatedItems = [...enabledItems, ...list]
|
||||
setActionItems(updatedItems)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const sourceList = source.droppableId === 'enabled' ? [...enabledItems] : [...disabledItems]
|
||||
const destList = destination.droppableId === 'enabled' ? [...enabledItems] : [...disabledItems]
|
||||
|
||||
const [removed] = sourceList.splice(source.index, 1)
|
||||
const updatedItem = { ...removed, enabled: destination.droppableId === 'enabled' }
|
||||
|
||||
const filteredDestList = destList.filter((item) => item.id !== updatedItem.id)
|
||||
filteredDestList.splice(destination.index, 0, updatedItem)
|
||||
|
||||
let newEnabledItems = destination.droppableId === 'enabled' ? filteredDestList : sourceList
|
||||
let newDisabledItems = destination.droppableId === 'disabled' ? filteredDestList : sourceList
|
||||
|
||||
if (newEnabledItems.length > MAX_ENABLED_ITEMS) {
|
||||
const overflowItems = newEnabledItems.slice(MAX_ENABLED_ITEMS).map((item) => ({ ...item, enabled: false }))
|
||||
newEnabledItems = newEnabledItems.slice(0, MAX_ENABLED_ITEMS)
|
||||
newDisabledItems = [...newDisabledItems, ...overflowItems]
|
||||
}
|
||||
|
||||
const updatedItems = [
|
||||
...newEnabledItems.map((item) => ({ ...item, enabled: true })),
|
||||
...newDisabledItems.map((item) => ({ ...item, enabled: false }))
|
||||
]
|
||||
|
||||
setActionItems(updatedItems)
|
||||
}
|
||||
|
||||
const getSearchEngineInfo = (searchEngine: string) => {
|
||||
if (!searchEngine) return null
|
||||
const [engine] = searchEngine.split('|')
|
||||
const defaultEngine = DEFAULT_SEARCH_ENGINES.find((e) => e.value === engine)
|
||||
if (defaultEngine) {
|
||||
return { icon: defaultEngine.icon, name: defaultEngine.label }
|
||||
}
|
||||
const customEngine = DEFAULT_SEARCH_ENGINES.find((e) => e.value === 'custom')
|
||||
return { icon: customEngine?.icon, name: engine }
|
||||
}
|
||||
|
||||
return {
|
||||
enabledItems,
|
||||
disabledItems,
|
||||
customItemsCount,
|
||||
isUserModalOpen,
|
||||
isSearchModalOpen,
|
||||
userEditingAction,
|
||||
setIsUserModalOpen,
|
||||
setIsSearchModalOpen,
|
||||
setUserEditingAction,
|
||||
handleEditActionItem,
|
||||
handleAddNewAction,
|
||||
handleUserModalOk,
|
||||
handleSearchModalOk,
|
||||
handleDeleteActionItem,
|
||||
handleReset,
|
||||
onDragEnd,
|
||||
getSearchEngineInfo,
|
||||
MAX_CUSTOM_ITEMS,
|
||||
MAX_ENABLED_ITEMS
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ import {
|
||||
Rocket,
|
||||
Settings2,
|
||||
SquareTerminal,
|
||||
TextCursorInput,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
// 导入useAppSelector
|
||||
@ -31,6 +32,7 @@ import MiniAppSettings from './MiniappSettings/MiniAppSettings'
|
||||
import ProvidersList from './ProviderSettings'
|
||||
import QuickAssistantSettings from './QuickAssistantSettings'
|
||||
import QuickPhraseSettings from './QuickPhraseSettings'
|
||||
import SelectionAssistantSettings from './SelectionAssistantSettings/SelectionAssistantSettings'
|
||||
import ShortcutSettings from './ShortcutSettings'
|
||||
import WebSearchSettings from './WebSearchSettings'
|
||||
|
||||
@ -106,6 +108,12 @@ const SettingsPage: FC = () => {
|
||||
{t('settings.quickAssistant.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/selectionAssistant">
|
||||
<MenuItem className={isRoute('/settings/selectionAssistant')}>
|
||||
<TextCursorInput size={18} />
|
||||
{t('selection.name')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/quickPhrase">
|
||||
<MenuItem className={isRoute('/settings/quickPhrase')}>
|
||||
<Zap size={18} />
|
||||
@ -136,6 +144,7 @@ const SettingsPage: FC = () => {
|
||||
{showMiniAppSettings && <Route path="miniapps" element={<MiniAppSettings />} />}
|
||||
<Route path="shortcut" element={<ShortcutSettings />} />
|
||||
<Route path="quickAssistant" element={<QuickAssistantSettings />} />
|
||||
<Route path="selectionAssistant" element={<SelectionAssistantSettings />} />
|
||||
<Route path="data" element={<DataSettings />} />
|
||||
<Route path="about" element={<AboutSettings />} />
|
||||
<Route path="quickPhrase" element={<QuickPhraseSettings />} />
|
||||
|
||||
@ -283,6 +283,8 @@ export async function fetchChatCompletion({
|
||||
// TODO
|
||||
// onChunkStatus: (status: 'searching' | 'processing' | 'success' | 'error') => void
|
||||
}) {
|
||||
console.log('fetchChatCompletion', messages, assistant)
|
||||
|
||||
const provider = getAssistantProvider(assistant)
|
||||
const AI = new AiProvider(provider)
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ import newMessagesReducer from './newMessage'
|
||||
import nutstore from './nutstore'
|
||||
import paintings from './paintings'
|
||||
import runtime from './runtime'
|
||||
import selectionStore from './selectionStore'
|
||||
import settings from './settings'
|
||||
import shortcuts from './shortcuts'
|
||||
import websearch from './websearch'
|
||||
@ -38,6 +39,7 @@ const rootReducer = combineReducers({
|
||||
websearch,
|
||||
mcp,
|
||||
copilot,
|
||||
selectionStore,
|
||||
// messages: messagesReducer,
|
||||
messages: newMessagesReducer,
|
||||
messageBlocks: messageBlocksReducer,
|
||||
@ -67,7 +69,7 @@ const persistedReducer = persistReducer(
|
||||
* Call storeSyncService.subscribe() in the window's entryPoint.tsx
|
||||
*/
|
||||
storeSyncService.setOptions({
|
||||
syncList: ['assistants/', 'settings/', 'llm/']
|
||||
syncList: ['assistants/', 'settings/', 'llm/', 'selectionStore/']
|
||||
})
|
||||
|
||||
const store = configureStore({
|
||||
|
||||
73
src/renderer/src/store/selectionStore.ts
Normal file
73
src/renderer/src/store/selectionStore.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { ActionItem, SelectionState, TriggerMode } from '@renderer/types/selectionTypes'
|
||||
|
||||
export const defaultActionItems: ActionItem[] = [
|
||||
{ id: 'translate', name: 'selection.action.builtin.translate', enabled: true, isBuiltIn: true, icon: 'languages' },
|
||||
{ id: 'explain', name: 'selection.action.builtin.explain', enabled: true, isBuiltIn: true, icon: 'file-question' },
|
||||
{ id: 'summary', name: 'selection.action.builtin.summary', enabled: true, isBuiltIn: true, icon: 'scan-text' },
|
||||
{
|
||||
id: 'search',
|
||||
name: 'selection.action.builtin.search',
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
icon: 'search',
|
||||
searchEngine: 'Google|https://www.google.com/search?q={{queryString}}'
|
||||
},
|
||||
{ id: 'copy', name: 'selection.action.builtin.copy', enabled: true, isBuiltIn: true, icon: 'clipboard-copy' },
|
||||
{ id: 'refine', name: 'selection.action.builtin.refine', enabled: false, isBuiltIn: true, icon: 'wand-sparkles' }
|
||||
]
|
||||
|
||||
export const initialState: SelectionState = {
|
||||
selectionEnabled: true,
|
||||
triggerMode: 'selected',
|
||||
isCompact: false,
|
||||
isAutoClose: false,
|
||||
isAutoPin: false,
|
||||
isFollowToolbar: true,
|
||||
actionWindowOpacity: 100,
|
||||
actionItems: defaultActionItems
|
||||
}
|
||||
|
||||
const selectionSlice = createSlice({
|
||||
name: 'selectionStore',
|
||||
initialState,
|
||||
reducers: {
|
||||
setSelectionEnabled: (state, action: PayloadAction<boolean>) => {
|
||||
state.selectionEnabled = action.payload
|
||||
},
|
||||
setTriggerMode: (state, action: PayloadAction<TriggerMode>) => {
|
||||
state.triggerMode = action.payload
|
||||
},
|
||||
setIsCompact: (state, action: PayloadAction<boolean>) => {
|
||||
state.isCompact = action.payload
|
||||
},
|
||||
setIsAutoClose: (state, action: PayloadAction<boolean>) => {
|
||||
state.isAutoClose = action.payload
|
||||
},
|
||||
setIsAutoPin: (state, action: PayloadAction<boolean>) => {
|
||||
state.isAutoPin = action.payload
|
||||
},
|
||||
setIsFollowToolbar: (state, action: PayloadAction<boolean>) => {
|
||||
state.isFollowToolbar = action.payload
|
||||
},
|
||||
setActionWindowOpacity: (state, action: PayloadAction<number>) => {
|
||||
state.actionWindowOpacity = action.payload
|
||||
},
|
||||
setActionItems: (state, action: PayloadAction<ActionItem[]>) => {
|
||||
state.actionItems = action.payload
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const {
|
||||
setSelectionEnabled,
|
||||
setTriggerMode,
|
||||
setIsCompact,
|
||||
setIsAutoClose,
|
||||
setIsAutoPin,
|
||||
setIsFollowToolbar,
|
||||
setActionWindowOpacity,
|
||||
setActionItems
|
||||
} = selectionSlice.actions
|
||||
|
||||
export default selectionSlice.reducer
|
||||
24
src/renderer/src/types/selectionTypes.d.ts
vendored
Normal file
24
src/renderer/src/types/selectionTypes.d.ts
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
export type TriggerMode = 'selected' | 'ctrlkey'
|
||||
|
||||
export interface ActionItem {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
isBuiltIn: boolean
|
||||
icon?: string
|
||||
prompt?: string
|
||||
assistantId?: string
|
||||
selectedText?: string
|
||||
searchEngine?: string
|
||||
}
|
||||
|
||||
export interface SelectionState {
|
||||
selectionEnabled: boolean
|
||||
triggerMode: TriggerMode
|
||||
isCompact: boolean
|
||||
isAutoClose: boolean
|
||||
isAutoPin: boolean
|
||||
isFollowToolbar: boolean
|
||||
actionWindowOpacity: number
|
||||
actionItems: ActionItem[]
|
||||
}
|
||||
383
src/renderer/src/windows/selection/action/SelectionActionApp.tsx
Normal file
383
src/renderer/src/windows/selection/action/SelectionActionApp.tsx
Normal file
@ -0,0 +1,383 @@
|
||||
import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import type { ActionItem } from '@renderer/types/selectionTypes'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Button, Slider, Tooltip } from 'antd'
|
||||
import { Droplet, Minus, Pin, X } from 'lucide-react'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import ActionGeneral from './components/ActionGeneral'
|
||||
import ActionTranslate from './components/ActionTranslate'
|
||||
|
||||
const SelectionActionApp: FC = () => {
|
||||
const { language } = useSettings()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [action, setAction] = useState<ActionItem | null>(null)
|
||||
const isActionLoaded = useRef(false)
|
||||
|
||||
const { isAutoClose, isAutoPin, actionWindowOpacity } = useSelectionAssistant()
|
||||
const [isPinned, setIsPinned] = useState(isAutoPin)
|
||||
const [isWindowFocus, setIsWindowFocus] = useState(true)
|
||||
|
||||
const [showOpacitySlider, setShowOpacitySlider] = useState(false)
|
||||
const [opacity, setOpacity] = useState(actionWindowOpacity)
|
||||
|
||||
const contentElementRef = useRef<HTMLDivElement>(null)
|
||||
const isAutoScrollEnabled = useRef(true)
|
||||
const shouldCloseWhenBlur = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAutoPin) {
|
||||
window.api.selection.pinActionWindow(true)
|
||||
}
|
||||
|
||||
const actionListenRemover = window.electron?.ipcRenderer.on(
|
||||
IpcChannel.Selection_UpdateActionData,
|
||||
(_, actionItem: ActionItem) => {
|
||||
setAction(actionItem)
|
||||
isActionLoaded.current = true
|
||||
}
|
||||
)
|
||||
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
window.addEventListener('blur', handleWindowBlur)
|
||||
|
||||
return () => {
|
||||
actionListenRemover()
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
window.removeEventListener('blur', handleWindowBlur)
|
||||
}
|
||||
// don't need any dependencies
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
const contentEl = contentElementRef.current
|
||||
if (contentEl) {
|
||||
contentEl.addEventListener('scroll', handleUserScroll)
|
||||
}
|
||||
return () => {
|
||||
if (contentEl) {
|
||||
contentEl.removeEventListener('scroll', handleUserScroll)
|
||||
}
|
||||
}
|
||||
//we should rely on action to trigger this effect,
|
||||
// because the contentRef is not available when action is initially null
|
||||
}, [action])
|
||||
|
||||
useEffect(() => {
|
||||
if (action) {
|
||||
document.title = `${action.isBuiltIn ? t(action.name) : action.name} - ${t('selection.name')}`
|
||||
}
|
||||
}, [action, t])
|
||||
|
||||
useEffect(() => {
|
||||
shouldCloseWhenBlur.current = isAutoClose && !isPinned
|
||||
}, [isAutoClose, isPinned])
|
||||
|
||||
useEffect(() => {
|
||||
//if the action is loaded, we should not set the opacity update from settings
|
||||
if (!isActionLoaded.current) {
|
||||
setOpacity(actionWindowOpacity)
|
||||
}
|
||||
}, [actionWindowOpacity])
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.api.selection.minimizeActionWindow()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
window.api.selection.closeActionWindow()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param pinned - if undefined, toggle the pinned state, otherwise force set the pinned state
|
||||
*/
|
||||
const togglePin = () => {
|
||||
setIsPinned(!isPinned)
|
||||
window.api.selection.pinActionWindow(!isPinned)
|
||||
}
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
setIsWindowFocus(true)
|
||||
}
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
if (shouldCloseWhenBlur.current) {
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
setIsWindowFocus(false)
|
||||
}
|
||||
|
||||
const handleOpacityChange = (value: number) => {
|
||||
setOpacity(value)
|
||||
}
|
||||
|
||||
const handleScrollToBottom = () => {
|
||||
if (contentElementRef.current && isAutoScrollEnabled.current) {
|
||||
contentElementRef.current.scrollTo({
|
||||
top: contentElementRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserScroll = () => {
|
||||
if (!contentElementRef.current) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = contentElementRef.current
|
||||
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 24
|
||||
|
||||
// Only update isAutoScrollEnabled if user is at bottom
|
||||
if (isAtBottom) {
|
||||
isAutoScrollEnabled.current = true
|
||||
} else {
|
||||
isAutoScrollEnabled.current = false
|
||||
}
|
||||
}
|
||||
|
||||
//we don't need to render the component if action is not set
|
||||
if (!action) return null
|
||||
|
||||
return (
|
||||
<WindowFrame $opacity={opacity / 100}>
|
||||
<TitleBar $isWindowFocus={isWindowFocus}>
|
||||
{action.icon && (
|
||||
<TitleBarIcon>
|
||||
<DynamicIcon
|
||||
name={action.icon as any}
|
||||
size={16}
|
||||
style={{ color: 'var(--color-text-1)' }}
|
||||
fallback={() => {}}
|
||||
/>
|
||||
</TitleBarIcon>
|
||||
)}
|
||||
<TitleBarCaption>{action.isBuiltIn ? t(action.name) : action.name}</TitleBarCaption>
|
||||
<TitleBarButtons>
|
||||
<Tooltip
|
||||
title={isPinned ? t('selection.action.window.pinned') : t('selection.action.window.pin')}
|
||||
placement="bottom">
|
||||
<WinButton
|
||||
type="text"
|
||||
icon={<Pin size={14} className={isPinned ? 'pinned' : ''} />}
|
||||
onClick={togglePin}
|
||||
className={isPinned ? 'pinned' : ''}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={t('selection.action.window.opacity')}
|
||||
placement="bottom"
|
||||
{...(showOpacitySlider ? { open: false } : {})}>
|
||||
<WinButton
|
||||
type="text"
|
||||
icon={<Droplet size={14} />}
|
||||
onClick={() => setShowOpacitySlider(!showOpacitySlider)}
|
||||
className={showOpacitySlider ? 'active' : ''}
|
||||
style={{ paddingBottom: '2px' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
{showOpacitySlider && (
|
||||
<OpacitySlider>
|
||||
<Slider
|
||||
vertical
|
||||
min={20}
|
||||
max={100}
|
||||
value={opacity}
|
||||
onChange={handleOpacityChange}
|
||||
onChangeComplete={() => setShowOpacitySlider(false)}
|
||||
tooltip={{ formatter: (value) => `${value}%` }}
|
||||
/>
|
||||
</OpacitySlider>
|
||||
)}
|
||||
|
||||
<WinButton type="text" icon={<Minus size={16} />} onClick={handleMinimize} />
|
||||
<WinButton type="text" icon={<X size={16} />} onClick={handleClose} className="close" />
|
||||
</TitleBarButtons>
|
||||
</TitleBar>
|
||||
<Content ref={contentElementRef}>
|
||||
{action.id == 'translate' && <ActionTranslate action={action} scrollToBottom={handleScrollToBottom} />}
|
||||
{action.id != 'translate' && <ActionGeneral action={action} scrollToBottom={handleScrollToBottom} />}
|
||||
</Content>
|
||||
</WindowFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const WindowFrame = styled.div<{ $opacity: number }>`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: calc(100% - 6px);
|
||||
height: calc(100% - 6px);
|
||||
margin: 2px;
|
||||
background-color: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0px 0px 2px var(--color-text-3);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
opacity: ${(props) => props.$opacity};
|
||||
`
|
||||
|
||||
const TitleBar = styled.div<{ $isWindowFocus: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
background-color: ${(props) =>
|
||||
props.$isWindowFocus ? 'var(--color-background-mute)' : 'var(--color-background-soft)'};
|
||||
transition: background-color 0.3s ease;
|
||||
-webkit-app-region: drag;
|
||||
`
|
||||
|
||||
const TitleBarIcon = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 4px;
|
||||
`
|
||||
|
||||
const TitleBarCaption = styled.div`
|
||||
margin-left: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const TitleBarButtons = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
position: relative;
|
||||
|
||||
.lucide {
|
||||
&.pinned {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const WinButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
color: var(--color-icon);
|
||||
|
||||
.anticon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
svg {
|
||||
stroke-width: 2;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&.pinned {
|
||||
svg {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-mute) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.close {
|
||||
&:hover {
|
||||
background-color: var(--color-error) !important;
|
||||
color: var(--color-white) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-primary-mute) !important;
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-hover) !important;
|
||||
color: var(--color-icon-white) !important;
|
||||
}
|
||||
`
|
||||
|
||||
const Content = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 14px;
|
||||
-webkit-app-region: none;
|
||||
user-select: text;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const OpacitySlider = styled.div`
|
||||
position: absolute;
|
||||
left: 42px;
|
||||
top: 100%;
|
||||
margin-top: 8px;
|
||||
background-color: var(--color-background-mute);
|
||||
padding: 16px 8px 12px 8px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
|
||||
height: 120px;
|
||||
/* display: flex; */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
opacity: 1 !important;
|
||||
|
||||
.ant-slider {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-slider-rail {
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
.ant-slider-track {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.ant-slider-handle {
|
||||
border-color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.ant-slider-handle-active {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-mute);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default SelectionActionApp
|
||||
@ -0,0 +1,334 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import CopyButton from '@renderer/components/CopyButton'
|
||||
import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import MessageContent from '@renderer/pages/home/Messages/MessageContent'
|
||||
import { fetchChatCompletion } from '@renderer/services/ApiService'
|
||||
import {
|
||||
getAssistantById,
|
||||
getDefaultAssistant,
|
||||
getDefaultModel,
|
||||
getDefaultTopic
|
||||
} from '@renderer/services/AssistantService'
|
||||
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
|
||||
import store from '@renderer/store'
|
||||
import { updateOneBlock, upsertManyBlocks, upsertOneBlock } from '@renderer/store/messageBlock'
|
||||
import { newMessagesActions } from '@renderer/store/newMessage'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Chunk, ChunkType } from '@renderer/types/chunk'
|
||||
import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import type { ActionItem } from '@renderer/types/selectionTypes'
|
||||
import { abortCompletion } from '@renderer/utils/abortController'
|
||||
import { isAbortError } from '@renderer/utils/error'
|
||||
import { createMainTextBlock } from '@renderer/utils/messageUtils/create'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import WindowFooter from './WindowFooter'
|
||||
|
||||
interface Props {
|
||||
action: ActionItem
|
||||
scrollToBottom?: () => void
|
||||
}
|
||||
|
||||
const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => {
|
||||
const { t } = useTranslation()
|
||||
const { language } = useSettings()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showOriginal, setShowOriginal] = useState(false)
|
||||
const [isContented, setIsContented] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [contentToCopy, setContentToCopy] = useState('')
|
||||
const initialized = useRef(false)
|
||||
|
||||
// Use useRef for values that shouldn't trigger re-renders
|
||||
const assistantRef = useRef<Assistant | null>(null)
|
||||
const topicRef = useRef<Topic | null>(null)
|
||||
const promptContentRef = useRef('')
|
||||
const askId = useRef('')
|
||||
|
||||
// Initialize values only once when action changes
|
||||
useEffect(() => {
|
||||
if (initialized.current) return
|
||||
initialized.current = true
|
||||
|
||||
// Initialize assistant
|
||||
const currentAssistant = action.assistantId
|
||||
? getAssistantById(action.assistantId) || getDefaultAssistant()
|
||||
: getDefaultAssistant()
|
||||
|
||||
assistantRef.current = {
|
||||
...currentAssistant,
|
||||
model: currentAssistant.model || getDefaultModel()
|
||||
}
|
||||
|
||||
// Initialize topic
|
||||
topicRef.current = getDefaultTopic(currentAssistant.id)
|
||||
|
||||
// Initialize prompt content
|
||||
let userContent = ''
|
||||
switch (action.id) {
|
||||
case 'summary':
|
||||
userContent =
|
||||
`请总结下面的内容。要求:使用 ${language} 语言进行回复;请不要包含对本提示词的任何解释,直接给出回复: \n\n` +
|
||||
action.selectedText
|
||||
break
|
||||
case 'explain':
|
||||
userContent =
|
||||
`请解释下面的内容。要求:使用 ${language} 语言进行回复;请不要包含对本提示词的任何解释,直接给出回复: \n\n` +
|
||||
action.selectedText
|
||||
break
|
||||
case 'refine':
|
||||
userContent =
|
||||
`请根据下面的内容进行优化或润色,并保持原内容的含义和完整性。要求:使用原语言进行回复;请不要包含对本提示词的任何解释,直接给出回复: \n\n` +
|
||||
action.selectedText
|
||||
break
|
||||
default:
|
||||
if (!action.prompt) {
|
||||
userContent = action.selectedText || ''
|
||||
break
|
||||
}
|
||||
|
||||
if (action.prompt.includes('{{text}}')) {
|
||||
userContent = action.prompt.replaceAll('{{text}}', action.selectedText!)
|
||||
break
|
||||
}
|
||||
|
||||
userContent = action.prompt + '\n\n' + action.selectedText
|
||||
}
|
||||
promptContentRef.current = userContent
|
||||
}, [action, language])
|
||||
|
||||
const allMessages = useTopicMessages(topicRef.current?.id || '')
|
||||
|
||||
const fetchResult = useCallback(async () => {
|
||||
if (!assistantRef.current || !topicRef.current) return
|
||||
|
||||
try {
|
||||
const { message: userMessage, blocks: userBlocks } = getUserMessage({
|
||||
assistant: assistantRef.current,
|
||||
topic: topicRef.current,
|
||||
content: promptContentRef.current
|
||||
})
|
||||
|
||||
askId.current = userMessage.id
|
||||
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId: topicRef.current.id, message: userMessage }))
|
||||
store.dispatch(upsertManyBlocks(userBlocks))
|
||||
|
||||
let blockId: string | null = null
|
||||
let blockContent: string = ''
|
||||
|
||||
const assistantMessage = getAssistantMessage({
|
||||
assistant: assistantRef.current,
|
||||
topic: topicRef.current
|
||||
})
|
||||
store.dispatch(
|
||||
newMessagesActions.addMessage({
|
||||
topicId: topicRef.current.id,
|
||||
message: assistantMessage
|
||||
})
|
||||
)
|
||||
|
||||
await fetchChatCompletion({
|
||||
messages: [userMessage],
|
||||
assistant: assistantRef.current,
|
||||
onChunkReceived: (chunk: Chunk) => {
|
||||
switch (chunk.type) {
|
||||
case ChunkType.THINKING_DELTA:
|
||||
case ChunkType.THINKING_COMPLETE:
|
||||
//TODO
|
||||
break
|
||||
case ChunkType.TEXT_DELTA:
|
||||
{
|
||||
setIsContented(true)
|
||||
blockContent += chunk.text
|
||||
if (!blockId) {
|
||||
const block = createMainTextBlock(assistantMessage.id, chunk.text, {
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
blockId = block.id
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId: topicRef.current!.id,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { blockInstruction: { id: block.id } }
|
||||
})
|
||||
)
|
||||
store.dispatch(upsertOneBlock(block))
|
||||
} else {
|
||||
store.dispatch(updateOneBlock({ id: blockId, changes: { content: blockContent } }))
|
||||
}
|
||||
|
||||
scrollToBottom?.()
|
||||
}
|
||||
break
|
||||
case ChunkType.TEXT_COMPLETE:
|
||||
{
|
||||
blockId &&
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
id: blockId,
|
||||
changes: { status: MessageBlockStatus.SUCCESS }
|
||||
})
|
||||
)
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId: topicRef.current!.id,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { status: AssistantMessageStatus.SUCCESS }
|
||||
})
|
||||
)
|
||||
setContentToCopy(chunk.text)
|
||||
}
|
||||
break
|
||||
case ChunkType.BLOCK_COMPLETE:
|
||||
case ChunkType.ERROR:
|
||||
setIsLoading(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setIsLoading(false)
|
||||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||
console.error('Error fetching result:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (assistantRef.current && topicRef.current) {
|
||||
fetchResult()
|
||||
}
|
||||
}, [fetchResult])
|
||||
|
||||
// Memoize the messages to prevent unnecessary re-renders
|
||||
const messageContent = useMemo(() => {
|
||||
const assistantMessages = allMessages.filter((message) => message.role === 'assistant')
|
||||
const lastAssistantMessage = assistantMessages[assistantMessages.length - 1]
|
||||
return lastAssistantMessage ? <MessageContent key={lastAssistantMessage.id} message={lastAssistantMessage} /> : null
|
||||
}, [allMessages])
|
||||
|
||||
const handlePause = () => {
|
||||
if (askId.current) {
|
||||
abortCompletion(askId.current)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<MenuContainer>
|
||||
<OriginalHeader onClick={() => setShowOriginal(!showOriginal)}>
|
||||
<span>
|
||||
{showOriginal ? t('selection.action.window.original_hide') : t('selection.action.window.original_show')}
|
||||
</span>
|
||||
<ChevronDown size={14} className={showOriginal ? 'expanded' : ''} />
|
||||
</OriginalHeader>
|
||||
</MenuContainer>
|
||||
{showOriginal && (
|
||||
<OriginalContent>
|
||||
{action.selectedText}
|
||||
<OriginalContentCopyWrapper>
|
||||
<CopyButton
|
||||
textToCopy={action.selectedText!}
|
||||
tooltip={t('selection.action.window.original_copy')}
|
||||
size={12}
|
||||
/>
|
||||
</OriginalContentCopyWrapper>
|
||||
</OriginalContent>
|
||||
)}
|
||||
<Result>
|
||||
{!isContented && isLoading && <LoadingOutlined style={{ fontSize: 16 }} spin />}
|
||||
{messageContent}
|
||||
</Result>
|
||||
{error && <ErrorMsg>{error}</ErrorMsg>}
|
||||
</Container>
|
||||
<FooterPadding />
|
||||
<WindowFooter loading={isLoading} onPause={handlePause} content={contentToCopy} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const Result = styled.div`
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
`
|
||||
|
||||
const MenuContainer = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
const OriginalHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.lucide {
|
||||
transition: transform 0.2s ease;
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const OriginalContent = styled.div`
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 12px;
|
||||
background-color: var(--color-background-soft);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
`
|
||||
|
||||
const OriginalContentCopyWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
const FooterPadding = styled.div`
|
||||
min-height: 32px;
|
||||
`
|
||||
|
||||
const ErrorMsg = styled.div`
|
||||
color: var(--color-error);
|
||||
background: rgba(255, 0, 0, 0.15);
|
||||
border: 1px solid var(--color-error);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
`
|
||||
|
||||
export default ActionGeneral
|
||||
@ -0,0 +1,223 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import CopyButton from '@renderer/components/CopyButton'
|
||||
import { TranslateLanguageOptions } from '@renderer/config/translate'
|
||||
import db from '@renderer/databases'
|
||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { fetchTranslate } from '@renderer/services/ApiService'
|
||||
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import type { ActionItem } from '@renderer/types/selectionTypes'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { Select, Space } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import WindowFooter from './WindowFooter'
|
||||
|
||||
interface Props {
|
||||
action: ActionItem
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
let _targetLanguage = 'chinese'
|
||||
|
||||
const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [targetLanguage, setTargetLanguage] = useState(_targetLanguage)
|
||||
const { translateModel } = useDefaultModel()
|
||||
|
||||
const [isLangSelectDisabled, setIsLangSelectDisabled] = useState(false)
|
||||
const [showOriginal, setShowOriginal] = useState(false)
|
||||
|
||||
const [result, setResult] = useState('')
|
||||
const [contentToCopy, setContentToCopy] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const translatingRef = useRef(false)
|
||||
|
||||
_targetLanguage = targetLanguage
|
||||
|
||||
const translate = useCallback(async () => {
|
||||
if (!action.selectedText || !action.selectedText.trim() || !translateModel) return
|
||||
|
||||
if (translatingRef.current) return
|
||||
|
||||
try {
|
||||
translatingRef.current = true
|
||||
setError('')
|
||||
|
||||
const targetLang = await db.settings.get({ id: 'translate:target:language' })
|
||||
const assistant: Assistant = getDefaultTranslateAssistant(
|
||||
targetLang?.value || targetLanguage,
|
||||
action.selectedText
|
||||
)
|
||||
|
||||
const onResult = (text: string, isComplete: boolean) => {
|
||||
setResult(text)
|
||||
scrollToBottom()
|
||||
|
||||
if (isComplete) {
|
||||
setContentToCopy(text)
|
||||
setIsLangSelectDisabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLangSelectDisabled(true)
|
||||
await fetchTranslate({ content: action.selectedText || '', assistant, onResponse: onResult })
|
||||
|
||||
translatingRef.current = false
|
||||
} catch (error: any) {
|
||||
setError(error?.message || t('error.unknown'))
|
||||
console.error(error)
|
||||
} finally {
|
||||
translatingRef.current = false
|
||||
}
|
||||
}, [action, targetLanguage, translateModel])
|
||||
|
||||
useEffect(() => {
|
||||
runAsyncFunction(async () => {
|
||||
const targetLang = await db.settings.get({ id: 'translate:target:language' })
|
||||
targetLang && setTargetLanguage(targetLang.value)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
translate()
|
||||
}, [translate])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<MenuContainer>
|
||||
<Select
|
||||
value={targetLanguage}
|
||||
style={{ maxWidth: 200, minWidth: 130, flex: 1 }}
|
||||
listHeight={160}
|
||||
optionFilterProp="label"
|
||||
options={TranslateLanguageOptions}
|
||||
onChange={async (value) => {
|
||||
await db.settings.put({ id: 'translate:target:language', value })
|
||||
setTargetLanguage(value)
|
||||
}}
|
||||
disabled={isLangSelectDisabled}
|
||||
optionRender={(option) => (
|
||||
<Space>
|
||||
<span role="img" aria-label={option.data.label}>
|
||||
{option.data.emoji}
|
||||
</span>
|
||||
{option.label}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
<OriginalHeader onClick={() => setShowOriginal(!showOriginal)}>
|
||||
<span>
|
||||
{showOriginal ? t('selection.action.window.original_hide') : t('selection.action.window.original_show')}
|
||||
</span>
|
||||
<ChevronDown size={14} className={showOriginal ? 'expanded' : ''} />
|
||||
</OriginalHeader>
|
||||
</MenuContainer>
|
||||
{showOriginal && (
|
||||
<OriginalContent>
|
||||
{action.selectedText}{' '}
|
||||
<OriginalContentCopyWrapper>
|
||||
<CopyButton
|
||||
textToCopy={action.selectedText!}
|
||||
tooltip={t('selection.action.window.original_copy')}
|
||||
size={12}
|
||||
/>
|
||||
</OriginalContentCopyWrapper>
|
||||
</OriginalContent>
|
||||
)}
|
||||
<Result>{isEmpty(result) ? <LoadingOutlined style={{ fontSize: 16 }} spin /> : result}</Result>
|
||||
{error && <ErrorMsg>{error}</ErrorMsg>}
|
||||
</Container>
|
||||
<FooterPadding />
|
||||
<WindowFooter content={contentToCopy} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const Result = styled.div`
|
||||
margin-top: 16px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
`
|
||||
|
||||
const MenuContainer = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 960px;
|
||||
`
|
||||
|
||||
const OriginalHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.lucide {
|
||||
transition: transform 0.2s ease;
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const OriginalContent = styled.div`
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background-color: var(--color-background-soft);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
`
|
||||
|
||||
const OriginalContentCopyWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
const FooterPadding = styled.div`
|
||||
min-height: 32px;
|
||||
`
|
||||
|
||||
const ErrorMsg = styled.div`
|
||||
color: var(--color-error);
|
||||
background: rgba(255, 0, 0, 0.15);
|
||||
border: 1px solid var(--color-error);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
`
|
||||
|
||||
export default ActionTranslate
|
||||
@ -0,0 +1,176 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { CircleX, Copy, Pause } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
interface FooterProps {
|
||||
content?: string
|
||||
loading?: boolean
|
||||
onPause?: () => void
|
||||
}
|
||||
|
||||
const WindowFooter: FC<FooterProps> = ({ content = '', loading = false, onPause = () => {} }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isWindowFocus, setIsWindowFocus] = useState(true)
|
||||
const [isCopyHovered, setIsCopyHovered] = useState(false)
|
||||
const [isEscHovered, setIsEscHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
window.addEventListener('blur', handleWindowBlur)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
window.removeEventListener('blur', handleWindowBlur)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useHotkeys('c', () => {
|
||||
handleCopy()
|
||||
})
|
||||
|
||||
useHotkeys('esc', () => {
|
||||
handleEsc()
|
||||
})
|
||||
|
||||
const handleEsc = () => {
|
||||
setIsEscHovered(true)
|
||||
setTimeout(() => {
|
||||
setIsEscHovered(false)
|
||||
}, 200)
|
||||
|
||||
if (loading && onPause) {
|
||||
onPause()
|
||||
} else {
|
||||
window.api.selection.closeActionWindow()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!content) return
|
||||
|
||||
navigator.clipboard
|
||||
.writeText(content)
|
||||
.then(() => {
|
||||
window.message.success(t('message.copy.success'))
|
||||
setIsCopyHovered(true)
|
||||
setTimeout(() => {
|
||||
setIsCopyHovered(false)
|
||||
}, 200)
|
||||
})
|
||||
.catch(() => {
|
||||
window.message.error(t('message.copy.failed'))
|
||||
})
|
||||
}
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
setIsWindowFocus(true)
|
||||
}
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
setIsWindowFocus(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<OpButtonWrapper>
|
||||
<OpButton onClick={handleEsc} $isWindowFocus={isWindowFocus} data-hovered={isEscHovered}>
|
||||
{loading ? (
|
||||
<>
|
||||
<LoadingIconWrapper>
|
||||
<Pause size={14} className="btn-icon loading-icon" style={{ position: 'absolute', left: 1, top: 1 }} />
|
||||
<LoadingOutlined
|
||||
style={{ fontSize: 16, position: 'absolute', left: 0, top: 0 }}
|
||||
className="btn-icon loading-icon"
|
||||
spin
|
||||
/>
|
||||
</LoadingIconWrapper>
|
||||
{t('selection.action.window.esc_stop')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CircleX size={14} className="btn-icon" />
|
||||
{t('selection.action.window.esc_close')}
|
||||
</>
|
||||
)}
|
||||
</OpButton>
|
||||
<OpButton onClick={handleCopy} $isWindowFocus={isWindowFocus && !!content} data-hovered={isCopyHovered}>
|
||||
<Copy size={14} className="btn-icon" />
|
||||
{t('selection.action.window.c_copy')}
|
||||
</OpButton>
|
||||
</OpButtonWrapper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 0;
|
||||
height: 32px;
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 8px;
|
||||
`
|
||||
|
||||
const OpButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const OpButton = styled.div<{ $isWindowFocus: boolean; $isHovered?: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-background-mute);
|
||||
color: var(--color-text-secondary);
|
||||
height: 22px;
|
||||
opacity: ${(props) => (props.$isWindowFocus ? 1 : 0.2)};
|
||||
transition: opacity 0.3s ease;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
.btn-icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-hovered='true'] {
|
||||
color: var(--color-primary) !important;
|
||||
|
||||
.btn-icon {
|
||||
color: var(--color-primary) !important;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const LoadingIconWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
`
|
||||
|
||||
export default WindowFooter
|
||||
54
src/renderer/src/windows/selection/action/entryPoint.tsx
Normal file
54
src/renderer/src/windows/selection/action/entryPoint.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import '@renderer/assets/styles/index.scss'
|
||||
import '@ant-design/v5-patch-for-react-19'
|
||||
|
||||
import KeyvStorage from '@kangfenmao/keyv-storage'
|
||||
import AntdProvider from '@renderer/context/AntdProvider'
|
||||
import { CodeStyleProvider } from '@renderer/context/CodeStyleProvider'
|
||||
import { ThemeProvider } from '@renderer/context/ThemeProvider'
|
||||
import storeSyncService from '@renderer/services/StoreSyncService'
|
||||
import store, { persistor } from '@renderer/store'
|
||||
import { message } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Provider } from 'react-redux'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
|
||||
import SelectionActionApp from './SelectionActionApp'
|
||||
|
||||
/**
|
||||
* fetchChatCompletion depends on this,
|
||||
* which is not a good design, but we have to add it for now
|
||||
*/
|
||||
function initKeyv() {
|
||||
window.keyv = new KeyvStorage()
|
||||
window.keyv.init()
|
||||
}
|
||||
|
||||
initKeyv()
|
||||
|
||||
//subscribe to store sync
|
||||
storeSyncService.subscribe()
|
||||
|
||||
const App: FC = () => {
|
||||
//actionWindow should register its own message component
|
||||
const [messageApi, messageContextHolder] = message.useMessage()
|
||||
window.message = messageApi
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ThemeProvider>
|
||||
<AntdProvider>
|
||||
<CodeStyleProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
{messageContextHolder}
|
||||
<SelectionActionApp />
|
||||
</PersistGate>
|
||||
</CodeStyleProvider>
|
||||
</AntdProvider>
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const root = createRoot(document.getElementById('root') as HTMLElement)
|
||||
root.render(<App />)
|
||||
405
src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx
Normal file
405
src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx
Normal file
@ -0,0 +1,405 @@
|
||||
import '@renderer/assets/styles/selection-toolbar.scss'
|
||||
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import type { ActionItem } from '@renderer/types/selectionTypes'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Avatar } from 'antd'
|
||||
import { ClipboardCheck, ClipboardCopy, ClipboardX, MessageSquareHeart } from 'lucide-react'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TextSelectionData } from 'selection-hook'
|
||||
import styled from 'styled-components'
|
||||
|
||||
//tell main the actual size of the content
|
||||
const updateWindowSize = () => {
|
||||
const rootElement = document.getElementById('root')
|
||||
if (!rootElement) {
|
||||
console.error('SelectionToolbar: Root element not found')
|
||||
return
|
||||
}
|
||||
window.api?.selection.determineToolbarSize(rootElement.scrollWidth, rootElement.scrollHeight)
|
||||
}
|
||||
|
||||
/**
|
||||
* ActionIcons is a component that renders the action icons
|
||||
*/
|
||||
const ActionIcons: FC<{
|
||||
actionItems: ActionItem[]
|
||||
isCompact: boolean
|
||||
handleAction: (action: ActionItem) => void
|
||||
copyIconStatus: 'normal' | 'success' | 'fail'
|
||||
copyIconAnimation: 'none' | 'enter' | 'exit'
|
||||
}> = memo(({ actionItems, isCompact, handleAction, copyIconStatus, copyIconAnimation }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const renderCopyIcon = useCallback(() => {
|
||||
return (
|
||||
<>
|
||||
<ClipboardCopy
|
||||
className={`btn-icon ${
|
||||
copyIconAnimation === 'enter' ? 'icon-scale-out' : copyIconAnimation === 'exit' ? 'icon-fade-in' : ''
|
||||
}`}
|
||||
/>
|
||||
{copyIconStatus === 'success' && (
|
||||
<ClipboardCheck
|
||||
className={`btn-icon icon-success ${
|
||||
copyIconAnimation === 'enter' ? 'icon-scale-in' : copyIconAnimation === 'exit' ? 'icon-fade-out' : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{copyIconStatus === 'fail' && (
|
||||
<ClipboardX
|
||||
className={`btn-icon icon-fail ${
|
||||
copyIconAnimation === 'enter' ? 'icon-scale-in' : copyIconAnimation === 'exit' ? 'icon-fade-out' : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}, [copyIconStatus, copyIconAnimation])
|
||||
|
||||
const renderActionButton = useCallback(
|
||||
(action: ActionItem) => {
|
||||
return (
|
||||
<ActionButton key={action.id} onClick={() => handleAction(action)}>
|
||||
<ActionIcon>
|
||||
{action.id === 'copy' ? (
|
||||
renderCopyIcon()
|
||||
) : (
|
||||
<DynamicIcon
|
||||
key={action.id}
|
||||
name={action.icon as any}
|
||||
className="btn-icon"
|
||||
fallback={() => <MessageSquareHeart className="btn-icon" />}
|
||||
/>
|
||||
)}
|
||||
</ActionIcon>
|
||||
{!isCompact && (
|
||||
<ActionTitle className="btn-title">{action.isBuiltIn ? t(action.name) : action.name}</ActionTitle>
|
||||
)}
|
||||
</ActionButton>
|
||||
)
|
||||
},
|
||||
[handleAction, isCompact, t, renderCopyIcon]
|
||||
)
|
||||
|
||||
return <>{actionItems?.map(renderActionButton)}</>
|
||||
})
|
||||
|
||||
/**
|
||||
* demo is used in the settings page
|
||||
*/
|
||||
const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
|
||||
const { language } = useSettings()
|
||||
const { isCompact, actionItems } = useSelectionAssistant()
|
||||
const [animateKey, setAnimateKey] = useState(0)
|
||||
const [copyIconStatus, setCopyIconStatus] = useState<'normal' | 'success' | 'fail'>('normal')
|
||||
const [copyIconAnimation, setCopyIconAnimation] = useState<'none' | 'enter' | 'exit'>('none')
|
||||
const copyIconTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
|
||||
const realActionItems = useMemo(() => {
|
||||
return actionItems?.filter((item) => item.enabled)
|
||||
}, [actionItems])
|
||||
|
||||
const selectedText = useRef('')
|
||||
|
||||
// listen to selectionService events
|
||||
useEffect(() => {
|
||||
// TextSelection
|
||||
const textSelectionListenRemover = window.electron?.ipcRenderer.on(
|
||||
IpcChannel.Selection_TextSelected,
|
||||
(_, selectionData: TextSelectionData) => {
|
||||
selectedText.current = selectionData.text
|
||||
setTimeout(() => {
|
||||
//make sure the animation is active
|
||||
setAnimateKey((prev) => prev + 1)
|
||||
}, 400)
|
||||
}
|
||||
)
|
||||
|
||||
// ToolbarVisibilityChange
|
||||
const toolbarVisibilityChangeListenRemover = window.electron?.ipcRenderer.on(
|
||||
IpcChannel.Selection_ToolbarVisibilityChange,
|
||||
(_, isVisible: boolean) => {
|
||||
if (!isVisible) {
|
||||
if (!demo) updateWindowSize()
|
||||
onHideCleanUp()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!demo) updateWindowSize()
|
||||
|
||||
return () => {
|
||||
textSelectionListenRemover()
|
||||
toolbarVisibilityChangeListenRemover()
|
||||
}
|
||||
}, [demo])
|
||||
|
||||
//make sure the toolbar size is updated when the compact mode/actionItems is changed
|
||||
useEffect(() => {
|
||||
if (!demo) updateWindowSize()
|
||||
}, [demo, isCompact, actionItems])
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
|
||||
const onHideCleanUp = () => {
|
||||
setCopyIconStatus('normal')
|
||||
setCopyIconAnimation('none')
|
||||
clearTimeout(copyIconTimeoutRef.current)
|
||||
}
|
||||
|
||||
const handleAction = useCallback(
|
||||
(action: ActionItem) => {
|
||||
if (demo) return
|
||||
|
||||
/** avoid mutating the original action, it will cause syncing issue */
|
||||
const newAction = { ...action, selectedText: selectedText.current }
|
||||
|
||||
switch (action.id) {
|
||||
case 'copy':
|
||||
handleCopy()
|
||||
break
|
||||
case 'search':
|
||||
handleSearch(newAction)
|
||||
break
|
||||
default:
|
||||
handleDefaultAction(newAction)
|
||||
break
|
||||
}
|
||||
},
|
||||
[demo]
|
||||
)
|
||||
|
||||
// copy selected text to clipboard
|
||||
const handleCopy = async () => {
|
||||
if (selectedText.current) {
|
||||
const result = await window.api?.selection.writeToClipboard(selectedText.current)
|
||||
|
||||
setCopyIconStatus(result ? 'success' : 'fail')
|
||||
setCopyIconAnimation('enter')
|
||||
copyIconTimeoutRef.current = setTimeout(() => {
|
||||
setCopyIconAnimation('exit')
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (action: ActionItem) => {
|
||||
if (!action.searchEngine) return
|
||||
|
||||
const customUrl = action.searchEngine.split('|')[1]
|
||||
if (!customUrl) return
|
||||
|
||||
const searchUrl = customUrl.replace('{{queryString}}', encodeURIComponent(action.selectedText || ''))
|
||||
window.api?.openWebsite(searchUrl)
|
||||
window.api?.selection.hideToolbar()
|
||||
}
|
||||
|
||||
const handleDefaultAction = (action: ActionItem) => {
|
||||
window.api?.selection.processAction(action)
|
||||
window.api?.selection.hideToolbar()
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<LogoWrapper>
|
||||
<Logo src={AppLogo} key={animateKey} className="animate" draggable={false} />
|
||||
</LogoWrapper>
|
||||
<ActionWrapper>
|
||||
<ActionIcons
|
||||
actionItems={realActionItems}
|
||||
isCompact={isCompact}
|
||||
handleAction={handleAction}
|
||||
copyIconStatus={copyIconStatus}
|
||||
copyIconAnimation={copyIconAnimation}
|
||||
/>
|
||||
</ActionWrapper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
background-color: var(--color-selection-toolbar-background);
|
||||
border-color: var(--color-selection-toolbar-border);
|
||||
box-shadow: 0px 2px 3px var(--color-selection-toolbar-shadow);
|
||||
padding: 2px;
|
||||
margin: 2px 3px 5px 3px;
|
||||
user-select: none;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
height: 36px;
|
||||
padding-right: 4px;
|
||||
box-sizing: border-box;
|
||||
`
|
||||
|
||||
const LogoWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-app-region: drag;
|
||||
margin-left: 5px;
|
||||
`
|
||||
|
||||
const Logo = styled(Avatar)`
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
&.animate {
|
||||
animation: rotate 1s ease;
|
||||
}
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-15deg) scale(1.05);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(15deg) scale(1.05);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const ActionWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 3px;
|
||||
`
|
||||
const ActionButton = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
.btn-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--color-selection-toolbar-text);
|
||||
}
|
||||
.btn-title {
|
||||
color: var(--color-selection-toolbar-text);
|
||||
--font-size: 14px;
|
||||
}
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
.btn-icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-title {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
background-color: var(--color-selection-toolbar-hover-bg);
|
||||
}
|
||||
`
|
||||
const ActionIcon = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* margin-right: 3px; */
|
||||
position: relative;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
|
||||
.btn-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.btn-icon:nth-child(2) {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.icon-fail {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.icon-scale-in {
|
||||
animation: scaleIn 0.5s forwards;
|
||||
}
|
||||
|
||||
.icon-scale-out {
|
||||
animation: scaleOut 0.5s forwards;
|
||||
}
|
||||
|
||||
.icon-fade-in {
|
||||
animation: fadeIn 0.3s forwards;
|
||||
}
|
||||
|
||||
.icon-fade-out {
|
||||
animation: fadeOut 0.3s forwards;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleOut {
|
||||
from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`
|
||||
const ActionTitle = styled.span`
|
||||
font-size: 14px;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-left: 3px;
|
||||
`
|
||||
|
||||
export default SelectionToolbar
|
||||
29
src/renderer/src/windows/selection/toolbar/entryPoint.tsx
Normal file
29
src/renderer/src/windows/selection/toolbar/entryPoint.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import '@ant-design/v5-patch-for-react-19'
|
||||
|
||||
import { ThemeProvider } from '@renderer/context/ThemeProvider'
|
||||
import storeSyncService from '@renderer/services/StoreSyncService'
|
||||
import store, { persistor } from '@renderer/store'
|
||||
import { FC } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Provider } from 'react-redux'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
|
||||
import SelectionToolbar from './SelectionToolbar'
|
||||
|
||||
//subscribe to store sync
|
||||
storeSyncService.subscribe()
|
||||
|
||||
const App: FC = () => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ThemeProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<SelectionToolbar />
|
||||
</PersistGate>
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const root = createRoot(document.getElementById('root') as HTMLElement)
|
||||
root.render(<App />)
|
||||
22
yarn.lock
22
yarn.lock
@ -6056,6 +6056,7 @@ __metadata:
|
||||
remark-math: "npm:^6.0.0"
|
||||
rollup-plugin-visualizer: "npm:^5.12.0"
|
||||
sass: "npm:^1.88.0"
|
||||
selection-hook: "npm:^0.9.14"
|
||||
shiki: "npm:^3.4.2"
|
||||
string-width: "npm:^7.2.0"
|
||||
styled-components: "npm:^6.1.11"
|
||||
@ -14737,6 +14738,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-gyp-build@npm:^4.8.4":
|
||||
version: 4.8.4
|
||||
resolution: "node-gyp-build@npm:4.8.4"
|
||||
bin:
|
||||
node-gyp-build: bin.js
|
||||
node-gyp-build-optional: optional.js
|
||||
node-gyp-build-test: build-test.js
|
||||
checksum: 10c0/444e189907ece2081fe60e75368784f7782cfddb554b60123743dfb89509df89f1f29c03bbfa16b3a3e0be3f48799a4783f487da6203245fa5bed239ba7407e1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-gyp@npm:^9.1.0":
|
||||
version: 9.4.1
|
||||
resolution: "node-gyp@npm:9.4.1"
|
||||
@ -17698,6 +17710,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"selection-hook@npm:^0.9.14":
|
||||
version: 0.9.14
|
||||
resolution: "selection-hook@npm:0.9.14"
|
||||
dependencies:
|
||||
node-gyp: "npm:latest"
|
||||
node-gyp-build: "npm:^4.8.4"
|
||||
checksum: 10c0/114a5c28d3753c215450a23b3999d7782c11f2868e41555a0034d49e33d90b1fe35f0a15dc240bca859f6fc186e09859bff5e3213fc8385b501f1f05ba9233f8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"semver-compare@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "semver-compare@npm:1.0.0"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user