diff --git a/electron-builder.yml b/electron-builder.yml index 12c9da99cb..5e1f97f001 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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}' diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7364285e7b..291870d879 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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') } } } diff --git a/package.json b/package.json index ac7809e3ed..ed3ac99837 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index e8bd965065..528b64c4e4 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -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' } diff --git a/src/main/index.ts b/src/main/index.ts index 12b1c9c16f..d67a8f0189 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 () => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 842b952da3..9c75b514c1 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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() } diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 6242709385..996b976f0c 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -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(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(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(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) } diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts new file mode 100644 index 0000000000..ee000954bf --- /dev/null +++ b/src/main/services/SelectionService.ts @@ -0,0 +1,1024 @@ +import { isDev, isWin } from '@main/constant' +import { IpcChannel } from '@shared/IpcChannel' +import { BrowserWindow, ipcMain, screen } from 'electron' +import Logger from 'electron-log' +import { join } from 'path' +import type { + KeyboardEventData, + MouseEventData, + SelectionHookConstructor, + SelectionHookInstance, + TextSelectionData +} from 'selection-hook' + +import type { ActionItem } from '../../renderer/src/types/selectionTypes' +import { ConfigKeys, configManager } from './ConfigManager' + +let SelectionHook: SelectionHookConstructor | null = null +try { + if (isWin) { + SelectionHook = require('selection-hook') + } +} catch (error) { + Logger.error('Failed to load selection-hook:', error) +} + +// Type definitions +type Point = { x: number; y: number } +type RelativeOrientation = + | 'topLeft' + | 'topRight' + | 'topMiddle' + | 'bottomLeft' + | 'bottomRight' + | 'bottomMiddle' + | 'middleLeft' + | 'middleRight' + | 'center' + +/** SelectionService is a singleton class that manages the selection hook and the toolbar window + * + * Features: + * - Text selection detection and processing + * - Floating toolbar management + * - Action window handling + * - Multiple trigger modes (selection/alt-key) + * - Screen boundary-aware positioning + * + * Usage: + * import selectionService from '/src/main/services/SelectionService' + * selectionService?.start() + */ +export class SelectionService { + private static instance: SelectionService | null = null + private selectionHook: SelectionHookInstance | null = null + + private static isIpcHandlerRegistered = false + + private initStatus: boolean = false + private started: boolean = false + + private triggerMode = 'selected' + private isFollowToolbar = true + + private toolbarWindow: BrowserWindow | null = null + private actionWindows = new Set() + private preloadedActionWindows: BrowserWindow[] = [] + private readonly PRELOAD_ACTION_WINDOW_COUNT = 1 + + private isHideByMouseKeyListenerActive: boolean = false + private isCtrlkeyListenerActive: boolean = false + /** + * Ctrlkey action states: + * 0 - Ready to monitor ctrlkey action + * >0 - Currently monitoring ctrlkey action + * -1 - Ctrlkey action triggered, no need to process again + */ + private lastCtrlkeyDownTime: number = 0 + + private zoomFactor: number = 1 + + private TOOLBAR_WIDTH = 350 + private TOOLBAR_HEIGHT = 43 + + private readonly ACTION_WINDOW_WIDTH = 500 + private readonly ACTION_WINDOW_HEIGHT = 400 + + private constructor() { + try { + if (!SelectionHook) { + throw new Error('module selection-hook not exists') + } + + this.selectionHook = new SelectionHook() + if (this.selectionHook) { + this.initZoomFactor() + + this.initStatus = true + } + } catch (error) { + this.logError('Failed to initialize SelectionService:', error as Error) + } + } + + public static getInstance(): SelectionService | null { + if (!isWin) return null + + if (!SelectionService.instance) { + SelectionService.instance = new SelectionService() + } + + if (SelectionService.instance.initStatus) { + return SelectionService.instance + } + return null + } + + public getSelectionHook(): SelectionHookInstance | null { + return this.selectionHook + } + + /** + * Initialize zoom factor from config and subscribe to changes + * Ensures UI elements scale properly with system DPI settings + */ + private initZoomFactor() { + const zoomFactor = configManager.getZoomFactor() + if (zoomFactor) { + this.setZoomFactor(zoomFactor) + } + + configManager.subscribe('ZoomFactor', this.setZoomFactor) + } + + public setZoomFactor = (zoomFactor: number) => { + this.zoomFactor = zoomFactor + } + + private initConfig() { + this.triggerMode = configManager.getSelectionAssistantTriggerMode() + this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar() + + configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: string) => { + this.triggerMode = triggerMode + this.processTriggerMode() + }) + + configManager.subscribe(ConfigKeys.SelectionAssistantFollowToolbar, (isFollowToolbar: boolean) => { + this.isFollowToolbar = isFollowToolbar + }) + } + + /** + * Start the selection service and initialize required windows + * @returns {boolean} Success status of service start + */ + public start(): boolean { + if (!this.selectionHook || this.started) { + this.logError(new Error('SelectionService start(): instance is null or already started')) + return false + } + + try { + //init basic configs + this.initConfig() + //make sure the toolbar window is ready + this.createToolbarWindow() + // Initialize preloaded windows + this.initPreloadedActionWindows() + // Handle errors + this.selectionHook.on('error', (error: { message: string }) => { + this.logError('Error in SelectionHook:', error as Error) + }) + // Handle text selection events + this.selectionHook.on('text-selection', this.processTextSelection) + + // Start the hook + if (this.selectionHook.start({ debug: isDev })) { + //init trigger mode configs + this.processTriggerMode() + + this.started = true + this.logInfo('SelectionService Started') + return true + } + + this.logError(new Error('Failed to start text selection hook.')) + return false + } catch (error) { + this.logError('Failed to set up text selection hook:', error as Error) + return false + } + } + + /** + * Stop the selection service and cleanup resources + * Called when user disables selection assistant + * @returns {boolean} Success status of service stop + */ + public stop(): boolean { + if (!this.selectionHook) return false + + this.selectionHook.stop() + this.selectionHook.cleanup() + if (this.toolbarWindow) { + this.toolbarWindow.close() + this.toolbarWindow = null + } + this.started = false + this.logInfo('SelectionService Stopped') + return true + } + + /** + * Completely quit the selection service + * Called when the app is closing + */ + public quit(): void { + if (!this.selectionHook) return + + this.stop() + + this.selectionHook = null + this.initStatus = false + SelectionService.instance = null + this.logInfo('SelectionService Quitted') + } + + /** + * Create and configure the toolbar window + * Sets up window properties, event handlers, and loads the toolbar UI + * @param readyCallback Optional callback when window is ready to show + */ + private createToolbarWindow(readyCallback?: () => void) { + if (this.isToolbarAlive()) return + + const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() + + this.toolbarWindow = new BrowserWindow({ + width: toolbarWidth, + height: toolbarHeight, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + minimizable: false, + maximizable: false, + movable: true, + focusable: false, + hasShadow: false, + thickFrame: false, + roundedCorners: true, + backgroundMaterial: 'none', + type: 'toolbar', + show: false, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + devTools: isDev ? true : false + } + }) + + // Hide when losing focus + this.toolbarWindow.on('blur', () => { + this.hideToolbar() + }) + + // Clean up when closed + this.toolbarWindow.on('closed', () => { + this.toolbarWindow = null + }) + + // Add show/hide event listeners + this.toolbarWindow.on('show', () => { + this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true) + }) + + this.toolbarWindow.on('hide', () => { + this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, false) + }) + + /** uncomment to open dev tools in dev mode */ + // if (isDev) { + // this.toolbarWindow.once('ready-to-show', () => { + // this.toolbarWindow!.webContents.openDevTools({ mode: 'detach' }) + // }) + // } + + if (readyCallback) { + this.toolbarWindow.once('ready-to-show', readyCallback) + } + + /** get ready to load the toolbar window */ + + if (isDev && process.env['ELECTRON_RENDERER_URL']) { + this.toolbarWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/selectionToolbar.html') + } else { + this.toolbarWindow.loadFile(join(__dirname, '../renderer/selectionToolbar.html')) + } + } + + /** + * Show toolbar at specified position with given orientation + * @param point Reference point for positioning, logical coordinates + * @param orientation Preferred position relative to reference point + */ + private showToolbarAtPosition(point: Point, orientation: RelativeOrientation) { + if (!this.isToolbarAlive()) { + this.createToolbarWindow(() => { + this.showToolbarAtPosition(point, orientation) + }) + return + } + + const { x: posX, y: posY } = this.calculateToolbarPosition(point, orientation) + + const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() + this.toolbarWindow!.setPosition(posX, posY, false) + // Prevent window resize + this.toolbarWindow!.setBounds({ + width: toolbarWidth, + height: toolbarHeight, + x: posX, + y: posY + }) + this.toolbarWindow!.show() + this.toolbarWindow!.setOpacity(1) + this.startHideByMouseKeyListener() + } + + /** + * Hide the toolbar window and cleanup listeners + */ + public hideToolbar(): void { + if (!this.isToolbarAlive()) return + + this.toolbarWindow!.setOpacity(0) + this.toolbarWindow!.hide() + + this.stopHideByMouseKeyListener() + } + + /** + * Check if toolbar window exists and is not destroyed + * @returns {boolean} Toolbar window status + */ + private isToolbarAlive() { + return this.toolbarWindow && !this.toolbarWindow.isDestroyed() + } + + /** + * Update toolbar size based on renderer feedback + * Only updates width if it has changed + * @param width New toolbar width + * @param height New toolbar height + */ + public determineToolbarSize(width: number, height: number) { + const toolbarWidth = Math.ceil(width) + + // only update toolbar width if it's changed + if (toolbarWidth > 0 && toolbarWidth !== this.TOOLBAR_WIDTH && height > 0) { + this.TOOLBAR_WIDTH = toolbarWidth + } + } + + /** + * Get actual toolbar dimensions accounting for zoom factor + * @returns Object containing toolbar width and height + */ + private getToolbarRealSize() { + return { + toolbarWidth: this.TOOLBAR_WIDTH * this.zoomFactor, + toolbarHeight: this.TOOLBAR_HEIGHT * this.zoomFactor + } + } + + /** + * Calculate optimal toolbar position based on selection context + * Ensures toolbar stays within screen boundaries and follows selection direction + * @param point Reference point for positioning, must be INTEGER + * @param orientation Preferred position relative to reference point + * @returns Calculated screen coordinates for toolbar, INTEGER + */ + private calculateToolbarPosition(point: Point, orientation: RelativeOrientation): Point { + // Calculate initial position based on the specified anchor + let posX: number, posY: number + + const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() + + switch (orientation) { + case 'topLeft': + posX = point.x - toolbarWidth + posY = point.y - toolbarHeight + break + case 'topRight': + posX = point.x + posY = point.y - toolbarHeight + break + case 'topMiddle': + posX = point.x - toolbarWidth / 2 + posY = point.y - toolbarHeight + break + case 'bottomLeft': + posX = point.x - toolbarWidth + posY = point.y + break + case 'bottomRight': + posX = point.x + posY = point.y + break + case 'bottomMiddle': + posX = point.x - toolbarWidth / 2 + posY = point.y + break + case 'middleLeft': + posX = point.x - toolbarWidth + posY = point.y - toolbarHeight / 2 + break + case 'middleRight': + posX = point.x + posY = point.y - toolbarHeight / 2 + break + case 'center': + posX = point.x - toolbarWidth / 2 + posY = point.y - toolbarHeight / 2 + break + default: + // Default to 'topMiddle' if invalid position + posX = point.x - toolbarWidth / 2 + posY = point.y - toolbarHeight / 2 + } + + //use original point to get the display + const display = screen.getDisplayNearestPoint({ x: point.x, y: point.y }) + + // Ensure toolbar stays within screen boundaries + posX = Math.round( + Math.max(display.workArea.x, Math.min(posX, display.workArea.x + display.workArea.width - toolbarWidth)) + ) + posY = Math.round( + Math.max(display.workArea.y, Math.min(posY, display.workArea.y + display.workArea.height - toolbarHeight)) + ) + + return { x: posX, y: posY } + } + + private isSamePoint(point1: Point, point2: Point): boolean { + return point1.x === point2.x && point1.y === point2.y + } + + private isSameLineWithRectPoint(startTop: Point, startBottom: Point, endTop: Point, endBottom: Point): boolean { + return startTop.y === endTop.y && startBottom.y === endBottom.y + } + + /** + * Process text selection data and show toolbar + * Handles different selection scenarios: + * - Single click (cursor position) + * - Mouse selection (single/double line) + * - Keyboard selection (full/detailed) + * @param selectionData Text selection information and coordinates + */ + private processTextSelection = (selectionData: TextSelectionData) => { + // Skip if no text or toolbar already visible + if (!selectionData.text || (this.isToolbarAlive() && this.toolbarWindow!.isVisible())) { + return + } + + // Determine reference point and position for toolbar + let refPoint: { x: number; y: number } = { x: 0, y: 0 } + let isLogical = false + let refOrientation: RelativeOrientation = 'bottomRight' + + switch (selectionData.posLevel) { + case SelectionHook?.PositionLevel.NONE: + { + const cursorPoint = screen.getCursorScreenPoint() + refPoint = { x: cursorPoint.x, y: cursorPoint.y } + refOrientation = 'bottomMiddle' + isLogical = true + } + break + case SelectionHook?.PositionLevel.MOUSE_SINGLE: + { + refOrientation = 'bottomMiddle' + refPoint = { x: selectionData.mousePosEnd.x, y: selectionData.mousePosEnd.y + 16 } + } + break + case SelectionHook?.PositionLevel.MOUSE_DUAL: + { + const yDistance = selectionData.mousePosEnd.y - selectionData.mousePosStart.y + const xDistance = selectionData.mousePosEnd.x - selectionData.mousePosStart.x + + // not in the same line + if (Math.abs(yDistance) > 14) { + if (yDistance > 0) { + refOrientation = 'bottomLeft' + refPoint = { + x: selectionData.mousePosEnd.x, + y: selectionData.mousePosEnd.y + 16 + } + } else { + refOrientation = 'topRight' + refPoint = { + x: selectionData.mousePosEnd.x, + y: selectionData.mousePosEnd.y - 16 + } + } + } else { + // in the same line + if (xDistance > 0) { + refOrientation = 'bottomLeft' + refPoint = { + x: selectionData.mousePosEnd.x, + y: Math.max(selectionData.mousePosEnd.y, selectionData.mousePosStart.y) + 16 + } + } else { + refOrientation = 'bottomRight' + refPoint = { + x: selectionData.mousePosEnd.x, + y: Math.min(selectionData.mousePosEnd.y, selectionData.mousePosStart.y) + 16 + } + } + } + } + break + case SelectionHook?.PositionLevel.SEL_FULL: + case SelectionHook?.PositionLevel.SEL_DETAILED: + { + //some case may not have mouse position, so use the endBottom point as reference + const isNoMouse = + selectionData.mousePosStart.x === 0 && + selectionData.mousePosStart.y === 0 && + selectionData.mousePosEnd.x === 0 && + selectionData.mousePosEnd.y === 0 + + if (isNoMouse) { + refOrientation = 'bottomLeft' + refPoint = { x: selectionData.endBottom.x, y: selectionData.endBottom.y + 4 } + break + } + + const isDoubleClick = this.isSamePoint(selectionData.mousePosStart, selectionData.mousePosEnd) + + const isSameLine = this.isSameLineWithRectPoint( + selectionData.startTop, + selectionData.startBottom, + selectionData.endTop, + selectionData.endBottom + ) + + if (isDoubleClick && isSameLine) { + refOrientation = 'bottomMiddle' + refPoint = { x: selectionData.mousePosEnd.x, y: selectionData.endBottom.y + 4 } + break + } + + if (isSameLine) { + const direction = selectionData.mousePosEnd.x - selectionData.mousePosStart.x + + if (direction > 0) { + refOrientation = 'bottomLeft' + refPoint = { x: selectionData.endBottom.x, y: selectionData.endBottom.y + 4 } + } else { + refOrientation = 'bottomRight' + refPoint = { x: selectionData.startBottom.x, y: selectionData.startBottom.y + 4 } + } + break + } + + const direction = selectionData.mousePosEnd.y - selectionData.mousePosStart.y + + if (direction > 0) { + refOrientation = 'bottomLeft' + refPoint = { x: selectionData.endBottom.x, y: selectionData.endBottom.y + 4 } + } else { + refOrientation = 'topRight' + refPoint = { x: selectionData.startTop.x, y: selectionData.startTop.y - 4 } + } + } + break + } + + if (!isLogical) { + //screenToDipPoint can be float, so we need to round it + refPoint = screen.screenToDipPoint(refPoint) + refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) } + } + + this.showToolbarAtPosition(refPoint, refOrientation) + this.toolbarWindow?.webContents.send(IpcChannel.Selection_TextSelected, selectionData) + } + + /** + * Global Mouse Event Handling + */ + + // Start monitoring global mouse clicks + private startHideByMouseKeyListener() { + try { + // Register event handlers + this.selectionHook!.on('mouse-down', this.handleMouseDownHide) + this.selectionHook!.on('mouse-wheel', this.handleMouseWheelHide) + this.selectionHook!.on('key-down', this.handleKeyDownHide) + this.isHideByMouseKeyListenerActive = true + } catch (error) { + this.logError('Failed to start global mouse event listener:', error as Error) + } + } + + // Stop monitoring global mouse clicks + private stopHideByMouseKeyListener() { + if (!this.isHideByMouseKeyListenerActive) return + + try { + this.selectionHook!.off('mouse-down', this.handleMouseDownHide) + this.selectionHook!.off('mouse-wheel', this.handleMouseWheelHide) + this.selectionHook!.off('key-down', this.handleKeyDownHide) + this.isHideByMouseKeyListenerActive = false + } catch (error) { + this.logError('Failed to stop global mouse event listener:', error as Error) + } + } + + /** + * Handle mouse wheel events to hide toolbar + * Hides toolbar when user scrolls + * @param data Mouse wheel event data + */ + private handleMouseWheelHide = () => { + this.hideToolbar() + } + + /** + * Handle mouse down events to hide toolbar + * Hides toolbar when clicking outside of it + * @param data Mouse event data + */ + private handleMouseDownHide = (data: MouseEventData) => { + if (!this.isToolbarAlive()) { + return + } + + //data point is physical coordinates, convert to logical coordinates + const mousePoint = screen.screenToDipPoint({ x: data.x, y: data.y }) + + const bounds = this.toolbarWindow!.getBounds() + + // Check if click is outside toolbar + const isInsideToolbar = + mousePoint.x >= bounds.x && + mousePoint.x <= bounds.x + bounds.width && + mousePoint.y >= bounds.y && + mousePoint.y <= bounds.y + bounds.height + + if (!isInsideToolbar) { + this.hideToolbar() + } + } + + /** + * Handle key down events to hide toolbar + * Hides toolbar on any key press except alt key in ctrlkey mode + * @param data Keyboard event data + */ + private handleKeyDownHide = (data: KeyboardEventData) => { + //dont hide toolbar when ctrlkey is pressed + if (this.triggerMode === 'ctrlkey' && this.isCtrlkey(data.vkCode)) { + return + } + + this.hideToolbar() + } + + /** + * Handle key down events in ctrlkey trigger mode + * Processes alt key presses to trigger selection toolbar + * @param data Keyboard event data + */ + private handleKeyDownCtrlkeyMode = (data: KeyboardEventData) => { + if (!this.isCtrlkey(data.vkCode)) { + // reset the lastCtrlkeyDownTime if any other key is pressed + if (this.lastCtrlkeyDownTime > 0) { + this.lastCtrlkeyDownTime = -1 + } + return + } + + if (this.lastCtrlkeyDownTime === -1) { + return + } + + //ctrlkey pressed + if (this.lastCtrlkeyDownTime === 0) { + this.lastCtrlkeyDownTime = Date.now() + return + } + + if (Date.now() - this.lastCtrlkeyDownTime < 350) { + return + } + + this.lastCtrlkeyDownTime = -1 + + const selectionData = this.selectionHook!.getCurrentSelection() + + if (selectionData) { + this.processTextSelection(selectionData) + } + } + + /** + * Handle key up events in ctrlkey trigger mode + * Resets alt key state when key is released + * @param data Keyboard event data + */ + private handleKeyUpCtrlkeyMode = (data: KeyboardEventData) => { + if (!this.isCtrlkey(data.vkCode)) return + this.lastCtrlkeyDownTime = 0 + } + + //check if the key is ctrl key + private isCtrlkey(vkCode: number) { + return vkCode === 162 || vkCode === 163 + } + + /** + * Create a preloaded action window for quick response + * Action windows handle specific operations on selected text + * @returns Configured BrowserWindow instance + */ + private createPreloadedActionWindow(): BrowserWindow { + const preloadedActionWindow = new BrowserWindow({ + width: this.ACTION_WINDOW_WIDTH, + height: this.ACTION_WINDOW_HEIGHT, + minWidth: 300, + minHeight: 200, + frame: false, + transparent: true, + autoHideMenuBar: true, + titleBarStyle: 'hidden', + hasShadow: false, + thickFrame: false, + show: false, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + devTools: true + } + }) + + // Load the base URL without action data + if (isDev && process.env['ELECTRON_RENDERER_URL']) { + preloadedActionWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/selectionAction.html') + } else { + preloadedActionWindow.loadFile(join(__dirname, '../renderer/selectionAction.html')) + } + + return preloadedActionWindow + } + + /** + * Initialize preloaded action windows + * Creates a pool of windows at startup for faster response + */ + private async initPreloadedActionWindows() { + try { + // Create initial pool of preloaded windows + for (let i = 0; i < this.PRELOAD_ACTION_WINDOW_COUNT; i++) { + await this.pushNewActionWindow() + } + } catch (error) { + this.logError('Failed to initialize preloaded windows:', error as Error) + } + } + + /** + * Preload a new action window asynchronously + * This method is called after popping a window to ensure we always have windows ready + */ + private async pushNewActionWindow() { + try { + const actionWindow = this.createPreloadedActionWindow() + this.preloadedActionWindows.push(actionWindow) + } catch (error) { + this.logError('Failed to push new action window:', error as Error) + } + } + + /** + * Pop an action window from the preloadedActionWindows queue + * Immediately returns a window and asynchronously creates a new one + * @returns {BrowserWindow} The action window + */ + private popActionWindow() { + // Get a window from the preloaded queue or create a new one if empty + const actionWindow = this.preloadedActionWindows.pop() || this.createPreloadedActionWindow() + + // Set up event listeners for this instance + actionWindow.on('closed', () => { + this.actionWindows.delete(actionWindow) + if (!actionWindow.isDestroyed()) { + actionWindow.destroy() + } + }) + + this.actionWindows.add(actionWindow) + + // Asynchronously create a new preloaded window + this.pushNewActionWindow() + + return actionWindow + } + + public processAction(actionItem: ActionItem): void { + console.log('processAction', this.preloadedActionWindows.length, this.actionWindows.size) + + const actionWindow = this.popActionWindow() + + actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem) + + this.showActionWindow(actionWindow) + } + + /** + * Show action window with proper positioning relative to toolbar + * Ensures window stays within screen boundaries + * @param actionWindow Window to position and show + */ + private showActionWindow(actionWindow: BrowserWindow) { + if (!this.isFollowToolbar || !this.toolbarWindow) { + actionWindow.show() + this.hideToolbar() + return + } + + const toolbarBounds = this.toolbarWindow!.getBounds() + const display = screen.getDisplayNearestPoint({ x: toolbarBounds.x, y: toolbarBounds.y }) + const workArea = display.workArea + const GAP = 6 // 6px gap from screen edges + + // Calculate initial position to center action window horizontally below toolbar + let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - this.ACTION_WINDOW_WIDTH) / 2) + let posY = Math.round(toolbarBounds.y) + + // Ensure action window stays within screen boundaries with a small gap + if (posX + this.ACTION_WINDOW_WIDTH > workArea.x + workArea.width) { + posX = workArea.x + workArea.width - this.ACTION_WINDOW_WIDTH - GAP + } else if (posX < workArea.x) { + posX = workArea.x + GAP + } + if (posY + this.ACTION_WINDOW_HEIGHT > workArea.y + workArea.height) { + // If window would go below screen, try to position it above toolbar + posY = workArea.y + workArea.height - this.ACTION_WINDOW_HEIGHT - GAP + } else if (posY < workArea.y) { + posY = workArea.y + GAP + } + + actionWindow.setPosition(posX, posY, false) + //KEY to make window not resize + actionWindow.setBounds({ + width: this.ACTION_WINDOW_WIDTH, + height: this.ACTION_WINDOW_HEIGHT, + x: posX, + y: posY + }) + + actionWindow.show() + } + + public closeActionWindow(actionWindow: BrowserWindow): void { + actionWindow.close() + } + + public minimizeActionWindow(actionWindow: BrowserWindow): void { + actionWindow.minimize() + } + + public pinActionWindow(actionWindow: BrowserWindow, isPinned: boolean): void { + actionWindow.setAlwaysOnTop(isPinned) + } + + /** + * Update trigger mode behavior + * Switches between selection-based and alt-key based triggering + * Manages appropriate event listeners for each mode + */ + private processTriggerMode() { + if (this.triggerMode === 'selected') { + if (this.isCtrlkeyListenerActive) { + this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode) + this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode) + + this.isCtrlkeyListenerActive = false + } + + this.selectionHook!.enableClipboard() + this.selectionHook!.setSelectionPassiveMode(false) + } else if (this.triggerMode === 'ctrlkey') { + if (!this.isCtrlkeyListenerActive) { + this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode) + this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode) + + this.isCtrlkeyListenerActive = true + } + + this.selectionHook!.disableClipboard() + this.selectionHook!.setSelectionPassiveMode(true) + } + } + + public writeToClipboard(text: string): boolean { + return this.selectionHook?.writeToClipboard(text) ?? false + } + + /** + * Register IPC handlers for communication with renderer process + * Handles toolbar, action window, and selection-related commands + */ + public static registerIpcHandler(): void { + if (this.isIpcHandlerRegistered) return + + ipcMain.handle(IpcChannel.Selection_ToolbarHide, () => { + selectionService?.hideToolbar() + }) + + ipcMain.handle(IpcChannel.Selection_WriteToClipboard, (_, text: string) => { + return selectionService?.writeToClipboard(text) ?? false + }) + + ipcMain.handle(IpcChannel.Selection_ToolbarDetermineSize, (_, width: number, height: number) => { + selectionService?.determineToolbarSize(width, height) + }) + + ipcMain.handle(IpcChannel.Selection_SetEnabled, (_, enabled: boolean) => { + configManager.setSelectionAssistantEnabled(enabled) + }) + + ipcMain.handle(IpcChannel.Selection_SetTriggerMode, (_, triggerMode: string) => { + configManager.setSelectionAssistantTriggerMode(triggerMode) + }) + + ipcMain.handle(IpcChannel.Selection_SetFollowToolbar, (_, isFollowToolbar: boolean) => { + configManager.setSelectionAssistantFollowToolbar(isFollowToolbar) + }) + + ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => { + selectionService?.processAction(actionItem) + }) + + ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => { + const actionWindow = BrowserWindow.fromWebContents(event.sender) + if (actionWindow) { + selectionService?.closeActionWindow(actionWindow) + } + }) + + ipcMain.handle(IpcChannel.Selection_ActionWindowMinimize, (event) => { + const actionWindow = BrowserWindow.fromWebContents(event.sender) + if (actionWindow) { + selectionService?.minimizeActionWindow(actionWindow) + } + }) + + ipcMain.handle(IpcChannel.Selection_ActionWindowPin, (event, isPinned: boolean) => { + const actionWindow = BrowserWindow.fromWebContents(event.sender) + if (actionWindow) { + selectionService?.pinActionWindow(actionWindow, isPinned) + } + }) + + this.isIpcHandlerRegistered = true + } + + private logInfo(message: string) { + isDev && console.log('[SelectionService] Info: ', message) + } + + private logError(...args: [...string[], Error]) { + Logger.error('[SelectionService] Error: ', ...args) + } +} + +/** + * Initialize selection service when app starts + * Sets up config subscription and starts service if enabled + * @returns {boolean} Success status of initialization + */ +export function initSelectionService(): boolean { + if (!isWin) return false + + configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => { + //avoid closure + const ss = SelectionService.getInstance() + if (!ss) { + Logger.error('SelectionService not initialized: instance is null') + return + } + + if (enabled) { + ss.start() + } else { + ss.stop() + } + }) + + if (!configManager.getSelectionAssistantEnabled()) return false + + const ss = SelectionService.getInstance() + if (!ss) { + Logger.error('SelectionService not initialized: instance is null') + return false + } + + return ss.start() +} + +const selectionService = SelectionService.getInstance() + +export default selectionService diff --git a/src/preload/index.ts b/src/preload/index.ts index 70aae1b18e..2a69ae908e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) } } diff --git a/src/renderer/selectionAction.html b/src/renderer/selectionAction.html new file mode 100644 index 0000000000..1dd3fa616c --- /dev/null +++ b/src/renderer/selectionAction.html @@ -0,0 +1,41 @@ + + + + + + + + Cherry Studio Selection Assistant + + + + +
+ + + + + \ No newline at end of file diff --git a/src/renderer/selectionToolbar.html b/src/renderer/selectionToolbar.html new file mode 100644 index 0000000000..1a219f6472 --- /dev/null +++ b/src/renderer/selectionToolbar.html @@ -0,0 +1,43 @@ + + + + + + + + Cherry Studio Selection Toolbar + + + + +
+ + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/styles/selection-toolbar.scss b/src/renderer/src/assets/styles/selection-toolbar.scss new file mode 100644 index 0000000000..dfbb6bbd59 --- /dev/null +++ b/src/renderer/src/assets/styles/selection-toolbar.scss @@ -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); +} diff --git a/src/renderer/src/components/CopyButton.tsx b/src/renderer/src/components/CopyButton.tsx new file mode 100644 index 0000000000..bdc34a0675 --- /dev/null +++ b/src/renderer/src/components/CopyButton.tsx @@ -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 = ({ + 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 = ( + + + {label && {label}} + + ) + + if (tooltip) { + return {button} + } + + return button +} + +const ButtonContainer = styled.div` + 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 diff --git a/src/renderer/src/hooks/useSelectionAssistant.ts b/src/renderer/src/hooks/useSelectionAssistant.ts new file mode 100644 index 0000000000..ef168a799c --- /dev/null +++ b/src/renderer/src/hooks/useSelectionAssistant.ts @@ -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)) + } + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index cf3efe3dca..7ec3bdaa02 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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" + } + } + } } } } diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index d2a7c50ca9..a46c13267e 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -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": "テスト" + } + } + } } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 3d7d925dce..c8959a4a05 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -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": "Тест" + } + } + } } } } diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index bd842b58c4..5d08e7a616 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -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": "测试" + } + } + } } } } diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 9003d65fb0..95b710dab2 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -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": "測試" + } + } + } } } } diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionSearchModal.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionSearchModal.tsx new file mode 100644 index 0000000000..8dfd77d2b8 --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionSearchModal.tsx @@ -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 ( + + + + ) +} +export const LogoBaidu = (props) => { + return ( + + + + ) +} + +export const LogoGoogle = (props) => { + return ( + + + + ) +} + +export const DEFAULT_SEARCH_ENGINES: SearchEngineOption[] = [ + { + label: 'Google', + value: 'Google', + searchEngine: 'Google|https://www.google.com/search?q={{queryString}}', + icon: + }, + { + label: 'Baidu', + value: 'Baidu', + searchEngine: 'Baidu|https://www.baidu.com/s?wd={{queryString}}', + icon: + }, + { + label: 'Bing', + value: 'Bing', + searchEngine: 'Bing|https://www.bing.com/search?q={{queryString}}', + icon: + }, + { + label: '', + value: 'custom', + searchEngine: '', + icon: + } +] + +const EXAMPLE_URL = 'https://example.com/search?q={{queryString}}' + +interface SelectionActionSearchModalProps { + isModalOpen: boolean + onOk: (searchEngine: string) => void + onCancel: () => void + currentAction?: ActionItem +} + +const SelectionActionSearchModal: FC = ({ + 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 ( + +
+ + + + + { + if (value && !value.includes('{{queryString}}')) { + return Promise.reject(t('selection.settings.search_modal.custom.url.missing_placeholder')) + } + return Promise.resolve() + } + } + ]}> + + {t('selection.settings.search_modal.custom.test')} + + } + /> + + + ) : null + } + +
+
+ ) +} + +export default SelectionActionSearchModal diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionUserModal.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionUserModal.tsx new file mode 100644 index 0000000000..e1673e7cf4 --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionUserModal.tsx @@ -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 = ({ + isModalOpen, + editingAction, + onOk, + onCancel +}) => { + const { t } = useTranslation() + const { assistants: userPredefinedAssistants } = useAssistants() + const { defaultAssistant } = useDefaultAssistant() + + const [formData, setFormData] = useState>({}) + const [errors, setErrors] = useState>>({}) + + useEffect(() => { + if (isModalOpen) { + // 如果是编辑模式,使用现有数据;否则使用空数据 + setFormData( + editingAction || { + name: '', + prompt: '', + icon: '', + assistantId: '' + } + ) + setErrors({}) + } + }, [isModalOpen, editingAction]) + + const validateForm = (): boolean => { + const newErrors: Partial> = {} + + 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 ( + + + +
+ + + {t('selection.settings.user_modal.name.label')} + + handleInputChange('name', e.target.value)} + maxLength={16} + status={errors.name ? 'error' : ''} + /> + {errors.name && {errors.name}} + + + + {t('selection.settings.user_modal.icon.label')} + + + + + + {t('selection.settings.user_modal.icon.view_all')} + + + { + const randomIcon = iconNames[Math.floor(Math.random() * iconNames.length)] + handleInputChange('icon', randomIcon) + }}> + + + + + + handleInputChange('icon', e.target.value)} + style={{ width: '100%' }} + status={errors.icon ? 'error' : ''} + /> + + {formData.icon && + (iconNames.includes(formData.icon as any) ? ( + + ) : ( + + ))} + + + {errors.icon && {errors.icon}} + +
+
+ + + + + {t('selection.settings.user_modal.model.label')} + + + + + + + handleInputChange('assistantId', e.target.value === 'default' ? '' : defaultAssistant.id) + } + buttonStyle="solid"> + {t('selection.settings.user_modal.model.default')} + {t('selection.settings.user_modal.model.assistant')} + + + + + {formData.assistantId && ( + + + {t('selection.settings.user_modal.assistant.label')} + + + + )} + + + {t('selection.settings.user_modal.prompt.label')} + + + + +
+ {t('selection.settings.user_modal.prompt.placeholder_text')} {'{{text}}'} + +
+
+ handleInputChange('prompt', e.target.value)} + rows={4} + style={{ resize: 'none' }} + /> +
+
+
+ ) +} + +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 diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionsList.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionsList.tsx new file mode 100644 index 0000000000..91106f762d --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionsList.tsx @@ -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 = ({ 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 ( + + + + + + + + + + + + + + + + + + + + + + setIsUserModalOpen(false)} + /> + + setIsSearchModalOpen(false)} + currentAction={actionItems?.find((item) => item.id === 'search')} + /> + + ) +} + +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 diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx new file mode 100644 index 0000000000..c1303496eb --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx @@ -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 ( + + + + {t('selection.name')} + + {t('selection.settings.experimental')} + + + + + {t('selection.settings.enable.title')} + {!isWindows && {t('selection.settings.enable.description')}} + + setSelectionEnabled(checked)} + disabled={!isWindows} + /> + + + {!selectionEnabled && ( + + + + )} + + {selectionEnabled && ( + <> + + {t('selection.settings.toolbar.title')} + + + + + +
{t('selection.settings.toolbar.trigger_mode.title')}
+ + + +
+ {t('selection.settings.toolbar.trigger_mode.description')} +
+ setTriggerMode(e.target.value as TriggerMode)} + buttonStyle="solid"> + {t('selection.settings.toolbar.trigger_mode.selected')} + {t('selection.settings.toolbar.trigger_mode.ctrlkey')} + +
+ + + + {t('selection.settings.toolbar.compact_mode.title')} + {t('selection.settings.toolbar.compact_mode.description')} + + setIsCompact(checked)} /> + +
+ + + {t('selection.settings.window.title')} + + + + + {t('selection.settings.window.follow_toolbar.title')} + {t('selection.settings.window.follow_toolbar.description')} + + setIsFollowToolbar(checked)} /> + + + + + {t('selection.settings.window.auto_close.title')} + {t('selection.settings.window.auto_close.description')} + + setIsAutoClose(checked)} /> + + + + + {t('selection.settings.window.auto_pin.title')} + {t('selection.settings.window.auto_pin.description')} + + setIsAutoPin(checked)} /> + + + + + {t('selection.settings.window.opacity.title')} + {t('selection.settings.window.opacity.description')} + +
{actionWindowOpacity}%
+ +
+
+ + + + )} +
+ ) +} + +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 diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsList.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsList.tsx new file mode 100644 index 0000000000..7532232b4c --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsList.tsx @@ -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 ( + + {(provided: DroppableProvided) => ( + + + {items.map((item, index) => ( + + {(provided) => ( + + )} + + ))} + {provided.placeholder} + + + )} + + ) + } +) + +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 diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListDivider.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListDivider.tsx new file mode 100644 index 0000000000..4940f2c3fb --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListDivider.tsx @@ -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 ( + + + {t('selection.settings.actions.drag_hint', { enabled: enabledCount, max: maxEnabled })} + + + ) +}) + +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 diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListItem.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListItem.tsx new file mode 100644 index 0000000000..64f11f447e --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/ActionsListItem.tsx @@ -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.isBuiltIn ? t(item.name) : item.name} + {item.id === 'search' && item.searchEngine && ( + + {getSearchEngineInfo(item.searchEngine)?.icon} + {getSearchEngineInfo(item.searchEngine)?.name} + + )} + + + + + ) + } +) + +interface ActionOperationsProps { + item: ActionItemType + onEdit: (item: ActionItemType) => void + onDelete: (id: string) => void +} + +const ActionOperations = memo(({ item, onEdit, onDelete }: ActionOperationsProps) => { + if (!item.isBuiltIn) { + return ( + + + + + ) + } + + if (item.isBuiltIn && item.id === 'search') { + return ( + + + + ) + } + + 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 diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/components/SettingsActionsListHeader.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/SettingsActionsListHeader.tsx new file mode 100644 index 0000000000..69017b36df --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/SettingsActionsListHeader.tsx @@ -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 ( + + {t('selection.settings.actions.title')} + + + + {t('selection.settings.actions.reset.button')} + + + +