cherry-studio/src/main/services/SelectionService.ts
fullex bc00c11a00
fix(windows): add manual window resize for SelectionAction window (#11766)
Implement custom window resize functionality for the SelectionAction window
on Windows only. This is a workaround for an Electron bug where native
window resize doesn't work with frame: false + transparent: true.

- Add IPC channel and API for window resize
- Implement resize handler in SelectionService
- Add 8 resize handles (4 edges + 4 corners) in SelectionActionApp
- Only enable on Windows, other platforms use native resize

Bug reference: https://github.com/electron/electron/issues/42738
All workaround code is documented and can be removed once the bug is fixed.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-09 09:03:35 +08:00

1620 lines
52 KiB
TypeScript

import { loggerService } from '@logger'
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
import { isDev, isMac, isWin } from '@main/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { app, BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
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'
import storeSyncService from './StoreSyncService'
const logger = loggerService.withContext('SelectionService')
const isSupportedOS = isWin || isMac
let SelectionHook: SelectionHookConstructor | null = null
try {
//since selection-hook v1.0.0, it supports macOS
if (isSupportedOS) {
SelectionHook = require('selection-hook')
}
} catch (error) {
logger.error('Failed to load selection-hook:', error as Error)
}
// Type definitions
type Point = { x: number; y: number }
type RelativeOrientation =
| 'topLeft'
| 'topRight'
| 'topMiddle'
| 'bottomLeft'
| 'bottomRight'
| 'bottomMiddle'
| 'middleLeft'
| 'middleRight'
| 'center'
enum TriggerMode {
Selected = 'selected',
Ctrlkey = 'ctrlkey',
Shortcut = 'shortcut'
}
/** 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 = TriggerMode.Selected
private isFollowToolbar = true
private isRemeberWinSize = false
private filterMode = 'default'
private filterList: string[] = []
private toolbarWindow: BrowserWindow | null = null
private actionWindows = new Set<BrowserWindow>()
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 lastActionWindowSize: { width: number; height: number } = {
width: this.ACTION_WINDOW_WIDTH,
height: this.ACTION_WINDOW_HEIGHT
}
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 (!isSupportedOS) 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(): void {
const zoomFactor = configManager.getZoomFactor()
if (zoomFactor) {
this.setZoomFactor(zoomFactor)
}
configManager.subscribe('ZoomFactor', this.setZoomFactor)
}
public setZoomFactor = (zoomFactor: number) => {
this.zoomFactor = zoomFactor
}
private initConfig(): void {
this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode
this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar()
this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize()
this.filterMode = configManager.getSelectionAssistantFilterMode()
this.filterList = configManager.getSelectionAssistantFilterList()
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
this.setHookFineTunedList()
configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: TriggerMode) => {
const oldTriggerMode = this.triggerMode
this.triggerMode = triggerMode
this.processTriggerMode()
//trigger mode changed, need to update the filter list
if (oldTriggerMode !== triggerMode) {
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
}
})
configManager.subscribe(ConfigKeys.SelectionAssistantFollowToolbar, (isFollowToolbar: boolean) => {
this.isFollowToolbar = isFollowToolbar
})
configManager.subscribe(ConfigKeys.SelectionAssistantRemeberWinSize, (isRemeberWinSize: boolean) => {
this.isRemeberWinSize = isRemeberWinSize
//when off, reset the last action window size to default
if (!this.isRemeberWinSize) {
this.lastActionWindowSize = {
width: this.ACTION_WINDOW_WIDTH,
height: this.ACTION_WINDOW_HEIGHT
}
}
})
configManager.subscribe(ConfigKeys.SelectionAssistantFilterMode, (filterMode: string) => {
this.filterMode = filterMode
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
})
configManager.subscribe(ConfigKeys.SelectionAssistantFilterList, (filterList: string[]) => {
this.filterList = filterList
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
})
}
/**
* Set the global filter mode for the selection-hook
* @param mode - The mode to set, either 'default', 'whitelist', or 'blacklist'
* @param list - An array of strings representing the list of items to include or exclude
*/
private setHookGlobalFilterMode(mode: string, list: string[]): void {
if (!this.selectionHook) return
const modeMap = {
default: SelectionHook!.FilterMode.DEFAULT,
whitelist: SelectionHook!.FilterMode.INCLUDE_LIST,
blacklist: SelectionHook!.FilterMode.EXCLUDE_LIST
}
const predefinedBlacklist = isWin ? SELECTION_PREDEFINED_BLACKLIST.WINDOWS : SELECTION_PREDEFINED_BLACKLIST.MAC
let combinedList: string[] = list
let combinedMode = mode
//only the selected mode need to combine the predefined blacklist with the user-defined blacklist
if (this.triggerMode === TriggerMode.Selected) {
switch (mode) {
case 'blacklist':
//combine the predefined blacklist with the user-defined blacklist
combinedList = [...new Set([...list, ...predefinedBlacklist])]
break
case 'whitelist':
combinedList = [...list]
break
case 'default':
default:
//use the predefined blacklist as the default filter list
combinedList = [...predefinedBlacklist]
combinedMode = 'blacklist'
break
}
}
if (!this.selectionHook.setGlobalFilterMode(modeMap[combinedMode], combinedList)) {
this.logError('Failed to set selection-hook global filter mode')
}
}
private setHookFineTunedList(): void {
if (!this.selectionHook) return
const excludeClipboardCursorDetectList = isWin
? SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS
: SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.MAC
const includeClipboardDelayReadList = isWin
? SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS
: SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.MAC
this.selectionHook.setFineTunedList(
SelectionHook!.FineTunedListType.EXCLUDE_CLIPBOARD_CURSOR_DETECT,
excludeClipboardCursorDetectList
)
this.selectionHook.setFineTunedList(
SelectionHook!.FineTunedListType.INCLUDE_CLIPBOARD_DELAY_READ,
includeClipboardDelayReadList
)
}
/**
* Start the selection service and initialize required windows
* @returns {boolean} Success status of service start
*/
public start(): boolean {
if (!isSupportedOS) {
this.logError('SelectionService start(): not supported on this OS')
return false
}
if (!this.selectionHook) {
this.logError('SelectionService start(): instance is null')
return false
}
if (this.started) {
this.logError('SelectionService start(): already started')
return false
}
//On macOS, we need to check if the process is trusted
if (isMac) {
if (!systemPreferences.isTrustedAccessibilityClient(false)) {
this.logError(
'SelectionSerice not started: process is not trusted on macOS, please turn on the Accessibility permission'
)
return false
}
}
try {
//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 basic configs
this.initConfig()
//init trigger mode configs
this.processTriggerMode()
this.started = true
this.logInfo('SelectionService Started', true)
return true
}
this.logError('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() //already remove all listeners
//reset the listener states
this.isCtrlkeyListenerActive = false
this.isHideByMouseKeyListenerActive = false
if (this.toolbarWindow) {
this.toolbarWindow.close()
this.toolbarWindow = null
}
this.closePreloadedActionWindows()
this.started = false
this.logInfo('SelectionService Stopped', true)
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', true)
}
/**
* Toggle the enabled state of the selection service
* Will sync the new enabled store to all renderer windows
*/
public toggleEnabled(enabled: boolean | undefined = undefined): void {
if (!this.selectionHook) return
const newEnabled = enabled === undefined ? !configManager.getSelectionAssistantEnabled() : enabled
configManager.setSelectionAssistantEnabled(newEnabled)
//sync the new enabled state to all renderer windows
storeSyncService.syncToRenderer('selectionStore/setSelectionEnabled', newEnabled)
}
/**
* 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): void {
if (this.isToolbarAlive()) return
const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
this.toolbarWindow = new BrowserWindow({
width: toolbarWidth,
height: toolbarHeight,
show: false,
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
autoHideMenuBar: true,
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false, // [macOS] must be false
movable: true,
hasShadow: false,
thickFrame: false,
roundedCorners: true,
// Platform specific settings
// [macOS] DO NOT set focusable to false, it will make other windows bring to front together
// [macOS] `panel` conflicts with other settings ,
// and log will show `NSWindow does not support nonactivating panel styleMask 0x80`
// but it seems still work on fullscreen apps, so we set this anyway
...(isWin ? { type: 'toolbar', focusable: false } : { type: 'panel' }),
hiddenInMissionControl: true, // [macOS only]
acceptFirstMouse: true, // [macOS only]
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', () => {
if (this.toolbarWindow!.isVisible()) {
this.hideToolbar()
}
})
// Clean up when closed
this.toolbarWindow.on('closed', () => {
if (!this.toolbarWindow?.isDestroyed()) {
this.toolbarWindow?.destroy()
}
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, programName: string): void {
if (!this.isToolbarAlive()) {
this.createToolbarWindow(() => {
this.showToolbarAtPosition(point, orientation, programName)
})
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
})
//set the window to always on top (highest level)
//should set every time the window is shown
this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver')
if (!isMac) {
this.toolbarWindow!.show()
/**
* [Windows]
* In Windows 10, setOpacity(1) will make the window completely transparent
* It's a strange behavior, so we don't use it for compatibility
*/
// this.toolbarWindow!.setOpacity(1)
this.startHideByMouseKeyListener()
return
}
/************************************************
* [macOS] the following code is only for macOS
*
* WARNING:
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
*************************************************/
// [macOS] a hacky way
// when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing
// so we just don't set `skipTransformProcessType: true` when in self app
const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName)
if (!isSelf) {
// [macOS] an ugly hacky way
// `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces`
// so we set `focusable: true` before showing, and then set false after showing
this.toolbarWindow!.setFocusable(false)
// [macOS]
// force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again
// set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces`
this.toolbarWindow!.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
skipTransformProcessType: true
})
}
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false`
this.toolbarWindow!.showInactive()
// [macOS] restore the focusable status
this.toolbarWindow!.setFocusable(true)
this.startHideByMouseKeyListener()
return
}
/**
* Hide the toolbar window and cleanup listeners
*/
public hideToolbar(): void {
if (!this.isToolbarAlive()) return
this.stopHideByMouseKeyListener()
// [Windows] just hide the toolbar window is enough
if (!isMac) {
this.toolbarWindow!.hide()
return
}
/************************************************
* [macOS] the following code is only for macOS
*************************************************/
// [macOS] a HACKY way
// make sure other windows do not bring to front when toolbar is hidden
// get all focusable windows and set them to not focusable
const focusableWindows: BrowserWindow[] = []
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed() && window.isVisible()) {
if (window.isFocusable()) {
focusableWindows.push(window)
window.setFocusable(false)
}
}
}
this.toolbarWindow!.hide()
// set them back to focusable after 50ms
setTimeout(() => {
for (const window of focusableWindows) {
if (!window.isDestroyed()) {
window.setFocusable(true)
}
}
}, 50)
// [macOS] hacky way
// Because toolbar is not a FOCUSED window, so the hover status will remain when next time show
// so we just send mouseMove event to the toolbar window to make the hover status disappear
this.toolbarWindow!.webContents.sendInputEvent({
type: 'mouseMove',
x: -1,
y: -1
})
return
}
/**
* Check if toolbar window exists and is not destroyed
* @returns {boolean} Toolbar window status
*/
private isToolbarAlive(): boolean {
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): void {
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(): { toolbarWidth: number; toolbarHeight: number } {
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 refPoint Reference point for positioning, must be INTEGER
* @param orientation Preferred position relative to reference point
* @returns Calculated screen coordinates for toolbar, INTEGER
*/
private calculateToolbarPosition(refPoint: Point, orientation: RelativeOrientation): Point {
// Calculate initial position based on the specified anchor
const posPoint: Point = { x: 0, y: 0 }
const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
switch (orientation) {
case 'topLeft':
posPoint.x = refPoint.x - toolbarWidth
posPoint.y = refPoint.y - toolbarHeight
break
case 'topRight':
posPoint.x = refPoint.x
posPoint.y = refPoint.y - toolbarHeight
break
case 'topMiddle':
posPoint.x = refPoint.x - toolbarWidth / 2
posPoint.y = refPoint.y - toolbarHeight
break
case 'bottomLeft':
posPoint.x = refPoint.x - toolbarWidth
posPoint.y = refPoint.y
break
case 'bottomRight':
posPoint.x = refPoint.x
posPoint.y = refPoint.y
break
case 'bottomMiddle':
posPoint.x = refPoint.x - toolbarWidth / 2
posPoint.y = refPoint.y
break
case 'middleLeft':
posPoint.x = refPoint.x - toolbarWidth
posPoint.y = refPoint.y - toolbarHeight / 2
break
case 'middleRight':
posPoint.x = refPoint.x
posPoint.y = refPoint.y - toolbarHeight / 2
break
case 'center':
posPoint.x = refPoint.x - toolbarWidth / 2
posPoint.y = refPoint.y - toolbarHeight / 2
break
default:
// Default to 'topMiddle' if invalid position
posPoint.x = refPoint.x - toolbarWidth / 2
posPoint.y = refPoint.y - toolbarHeight / 2
}
//use original point to get the display
const display = screen.getDisplayNearestPoint(refPoint)
//check if the toolbar exceeds the top or bottom of the screen
const exceedsTop = posPoint.y < display.workArea.y
const exceedsBottom = posPoint.y > display.workArea.y + display.workArea.height - toolbarHeight
// Ensure toolbar stays within screen boundaries
posPoint.x = Math.round(
Math.max(display.workArea.x, Math.min(posPoint.x, display.workArea.x + display.workArea.width - toolbarWidth))
)
posPoint.y = Math.round(
Math.max(display.workArea.y, Math.min(posPoint.y, display.workArea.y + display.workArea.height - toolbarHeight))
)
//adjust the toolbar position if it exceeds the top or bottom of the screen
if (exceedsTop) {
posPoint.y = posPoint.y + 32
}
if (exceedsBottom) {
posPoint.y = posPoint.y - 32
}
return posPoint
}
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
}
/**
* Get the user selected text and process it (trigger by shortcut)
*
* it's a public method used by shortcut service
*/
public processSelectTextByShortcut(): void {
if (!this.selectionHook || !this.started || this.triggerMode !== TriggerMode.Shortcut) return
const selectionData = this.selectionHook.getCurrentSelection()
if (selectionData) {
this.processTextSelection(selectionData)
}
}
/**
* Determine if the text selection should be processed by filter mode&list
* @param selectionData Text selection information and coordinates
* @returns {boolean} True if the selection should be processed, false otherwise
*/
private shouldProcessTextSelection(selectionData: TextSelectionData): boolean {
if (selectionData.programName === '' || this.filterMode === 'default') {
return true
}
const programName = selectionData.programName.toLowerCase()
//items in filterList are already in lower case
const isFound = this.filterList.some((item) => programName.includes(item))
switch (this.filterMode) {
case 'whitelist':
return isFound
case 'blacklist':
return !isFound
}
return false
}
/**
* Process text selection data and show toolbar
* Handles different selection scenarios:
* - 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
}
if (!this.shouldProcessTextSelection(selectionData)) {
return
}
// Determine reference point and position for toolbar
let refPoint: { x: number; y: number } = { x: 0, y: 0 }
let isLogical = false
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
)
// Note: shift key + mouse click == DoubleClick
//double click to select a word
if (isDoubleClick && isSameLine) {
refOrientation = 'bottomMiddle'
refPoint = { x: selectionData.mousePosEnd.x, y: selectionData.endBottom.y + 4 }
break
}
// below: isDoubleClick || isSameLine
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
}
// below: !isDoubleClick && !isSameLine
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) {
// [macOS] don't need to convert by screenToDipPoint
if (!isMac) {
refPoint = screen.screenToDipPoint(refPoint)
}
//screenToDipPoint can be float, so we need to round it
refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
}
// [macOS] isFullscreen is only available on macOS
this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName)
this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
}
/**
* Global Mouse Event Handling
*/
// Start monitoring global mouse clicks
private startHideByMouseKeyListener(): void {
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(): void {
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(only for windows/linux)
const mousePoint = isMac ? { x: data.x, y: data.y } : 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 === TriggerMode.Ctrlkey && this.isCtrlkey(data.vkCode)) {
return
}
//dont hide toolbar when shiftkey or altkey is pressed, because it's used for selection
if (this.isShiftkey(data.vkCode) || this.isAltkey(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()
//add the mouse-wheel&mouse-down listener, detect if user is zooming in/out or multi-selecting
this.selectionHook!.on('mouse-wheel', this.handleMouseWheelCtrlkeyMode)
this.selectionHook!.on('mouse-down', this.handleMouseDownCtrlkeyMode)
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
//remove the mouse-wheel&mouse-down listener
this.selectionHook!.off('mouse-wheel', this.handleMouseWheelCtrlkeyMode)
this.selectionHook!.off('mouse-down', this.handleMouseDownCtrlkeyMode)
this.lastCtrlkeyDownTime = 0
}
/**
* Handle mouse wheel events in ctrlkey trigger mode
* ignore CtrlKey pressing when mouse wheel is used
* because user is zooming in/out
*/
private handleMouseWheelCtrlkeyMode = () => {
this.lastCtrlkeyDownTime = -1
}
/**
* Handle mouse down events in ctrlkey trigger mode
* ignore CtrlKey pressing when mouse down is used
* because user is multi-selecting
*/
private handleMouseDownCtrlkeyMode = () => {
this.lastCtrlkeyDownTime = -1
}
//check if the key is ctrl key
private isCtrlkey(vkCode: number) {
return vkCode === 162 || vkCode === 163
}
//check if the key is shift key
private isShiftkey(vkCode: number) {
return vkCode === 160 || vkCode === 161
}
//check if the key is alt key
private isAltkey(vkCode: number) {
return vkCode === 164 || vkCode === 165
}
/**
* 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.isRemeberWinSize ? this.lastActionWindowSize.width : this.ACTION_WINDOW_WIDTH,
height: this.isRemeberWinSize ? this.lastActionWindowSize.height : this.ACTION_WINDOW_HEIGHT,
minWidth: 300,
minHeight: 200,
frame: false,
transparent: true,
autoHideMenuBar: true,
titleBarStyle: 'hidden', // [macOS]
trafficLightPosition: { x: 12, y: 9 }, // [macOS]
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(): Promise<void> {
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)
}
}
/**
* Close all preloaded action windows
*/
private closePreloadedActionWindows(): void {
for (const actionWindow of this.preloadedActionWindows) {
if (!actionWindow.isDestroyed()) {
actionWindow.destroy()
}
}
}
/**
* Preload a new action window asynchronously
* This method is called after popping a window to ensure we always have windows ready
*/
private async pushNewActionWindow(): Promise<void> {
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(): BrowserWindow {
// 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()
}
// [macOS] a HACKY way
// make sure other windows do not bring to front when action window is closed
if (isMac) {
const focusableWindows: BrowserWindow[] = []
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed() && window.isVisible()) {
if (window.isFocusable()) {
focusableWindows.push(window)
window.setFocusable(false)
}
}
}
setTimeout(() => {
for (const window of focusableWindows) {
if (!window.isDestroyed()) {
window.setFocusable(true)
}
}
}, 50)
}
})
//remember the action window size
actionWindow.on('resized', () => {
if (this.isRemeberWinSize) {
this.lastActionWindowSize = {
width: actionWindow.getBounds().width,
height: actionWindow.getBounds().height
}
}
})
this.actionWindows.add(actionWindow)
// Asynchronously create a new preloaded window
this.pushNewActionWindow()
return actionWindow
}
/**
* Process action item
* @param actionItem Action item to process
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
*/
public processAction(actionItem: ActionItem, isFullScreen: boolean = false): void {
const actionWindow = this.popActionWindow()
actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem)
this.showActionWindow(actionWindow, isFullScreen)
}
/**
* Show action window with proper positioning relative to toolbar
* Ensures window stays within screen boundaries
* @param actionWindow Window to position and show
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
*/
private showActionWindow(actionWindow: BrowserWindow, isFullScreen: boolean = false): void {
let actionWindowWidth = this.ACTION_WINDOW_WIDTH
let actionWindowHeight = this.ACTION_WINDOW_HEIGHT
//if remember win size is true, use the last remembered size
if (this.isRemeberWinSize) {
actionWindowWidth = this.lastActionWindowSize.width
actionWindowHeight = this.lastActionWindowSize.height
}
/********************************************
* Setting the position of the action window
********************************************/
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const workArea = display.workArea
// Center of the screen
if (!this.isFollowToolbar || !this.toolbarWindow) {
const centerX = Math.round(workArea.x + (workArea.width - actionWindowWidth) / 2)
const centerY = Math.round(workArea.y + (workArea.height - actionWindowHeight) / 2)
actionWindow.setPosition(centerX, centerY, false)
actionWindow.setBounds({
width: actionWindowWidth,
height: actionWindowHeight,
x: centerX,
y: centerY
})
} else {
// Follow toolbar position
const toolbarBounds = this.toolbarWindow!.getBounds()
const GAP = 6 // 6px gap from screen edges
//make sure action window is inside screen
if (actionWindowWidth > workArea.width - 2 * GAP) {
actionWindowWidth = workArea.width - 2 * GAP
}
if (actionWindowHeight > workArea.height - 2 * GAP) {
actionWindowHeight = workArea.height - 2 * GAP
}
// Calculate initial position to center action window horizontally below toolbar
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
let posY = Math.round(toolbarBounds.y)
// Ensure action window stays within screen boundaries with a small gap
if (posX + actionWindowWidth > workArea.x + workArea.width) {
posX = workArea.x + workArea.width - actionWindowWidth - GAP
} else if (posX < workArea.x) {
posX = workArea.x + GAP
}
if (posY + actionWindowHeight > workArea.y + workArea.height) {
// If window would go below screen, try to position it above toolbar
posY = workArea.y + workArea.height - actionWindowHeight - GAP
} else if (posY < workArea.y) {
posY = workArea.y + GAP
}
actionWindow.setPosition(posX, posY, false)
//KEY to make window not resize
actionWindow.setBounds({
width: actionWindowWidth,
height: actionWindowHeight,
x: posX,
y: posY
})
}
if (!isMac) {
actionWindow.show()
return
}
/************************************************
* [macOS] the following code is only for macOS
*
* WARNING:
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
*************************************************/
// act normally when the app is not in fullscreen mode
if (!isFullScreen) {
actionWindow.show()
return
}
// [macOS] an UGLY HACKY way for fullscreen override settings
// FIXME sometimes the dock will be shown when the action window is shown
// FIXME if actionWindow show on the fullscreen app, switch to other space will cause the mainWindow to be shown
// FIXME When setVisibleOnAllWorkspaces is true, docker icon disappeared when the first action window is shown on the fullscreen app
// use app.dock.show() to show the dock again will cause the action window to be closed when auto hide on blur is enabled
// setFocusable(false) to prevent the action window hide when blur (if auto hide on blur is enabled)
actionWindow.setFocusable(false)
actionWindow.setAlwaysOnTop(true, 'floating')
// `setVisibleOnAllWorkspaces(true)` will cause the dock icon disappeared
// just store the dock icon status, and show it again
const isDockShown = app.dock?.isVisible()
// DO NOT set `skipTransformProcessType: true`,
// it will cause the action window to be shown on other space
actionWindow.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true
})
actionWindow.showInactive()
// show the dock again if last time it was shown
// do not put it after `actionWindow.focus()`, will cause the action window to be closed when auto hide on blur is enabled
if (!app.dock?.isVisible() && isDockShown) {
app.dock?.show()
}
// unset everything
setTimeout(() => {
actionWindow.setVisibleOnAllWorkspaces(false, {
visibleOnFullScreen: true,
skipTransformProcessType: true
})
actionWindow.setAlwaysOnTop(false)
actionWindow.setFocusable(true)
// regain the focus when all the works done
actionWindow.focus()
}, 50)
}
public closeActionWindow(actionWindow: BrowserWindow): void {
actionWindow.close()
}
public minimizeActionWindow(actionWindow: BrowserWindow): void {
actionWindow.minimize()
}
public pinActionWindow(actionWindow: BrowserWindow, isPinned: boolean): void {
actionWindow.setAlwaysOnTop(isPinned)
}
/**
* [Windows only] Manual window resize handler
*
* ELECTRON BUG WORKAROUND:
* In Electron, when using `frame: false` + `transparent: true`, the native window
* resize functionality is broken on Windows. This is a known Electron bug.
* See: https://github.com/electron/electron/issues/48554
*
* This method can be removed once the Electron bug is fixed.
*/
public resizeActionWindow(actionWindow: BrowserWindow, deltaX: number, deltaY: number, direction: string): void {
const bounds = actionWindow.getBounds()
const minWidth = 300
const minHeight = 200
let { x, y, width, height } = bounds
// Handle horizontal resize
if (direction.includes('e')) {
width = Math.max(minWidth, width + deltaX)
}
if (direction.includes('w')) {
const newWidth = Math.max(minWidth, width - deltaX)
if (newWidth !== width) {
x = x + (width - newWidth)
width = newWidth
}
}
// Handle vertical resize
if (direction.includes('s')) {
height = Math.max(minHeight, height + deltaY)
}
if (direction.includes('n')) {
const newHeight = Math.max(minHeight, height - deltaY)
if (newHeight !== height) {
y = y + (height - newHeight)
height = newHeight
}
}
actionWindow.setBounds({ x, y, width, height })
}
/**
* Update trigger mode behavior
* Switches between selection-based and alt-key based triggering
* Manages appropriate event listeners for each mode
*/
private processTriggerMode(): void {
if (!this.selectionHook) return
switch (this.triggerMode) {
case TriggerMode.Selected:
if (this.isCtrlkeyListenerActive) {
this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode)
this.isCtrlkeyListenerActive = false
}
this.selectionHook.setSelectionPassiveMode(false)
break
case TriggerMode.Ctrlkey:
if (!this.isCtrlkeyListenerActive) {
this.selectionHook.on('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook.on('key-up', this.handleKeyUpCtrlkeyMode)
this.isCtrlkeyListenerActive = true
}
this.selectionHook.setSelectionPassiveMode(true)
break
case TriggerMode.Shortcut:
//remove the ctrlkey listener, don't need any key listener for shortcut mode
if (this.isCtrlkeyListenerActive) {
this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode)
this.isCtrlkeyListenerActive = false
}
this.selectionHook.setSelectionPassiveMode(true)
break
}
}
public writeToClipboard(text: string): boolean {
if (!this.selectionHook || !this.started) return false
return this.selectionHook.writeToClipboard(text)
}
/**
* 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): boolean => {
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_SetRemeberWinSize, (_, isRemeberWinSize: boolean) => {
configManager.setSelectionAssistantRemeberWinSize(isRemeberWinSize)
})
ipcMain.handle(IpcChannel.Selection_SetFilterMode, (_, filterMode: string) => {
configManager.setSelectionAssistantFilterMode(filterMode)
})
ipcMain.handle(IpcChannel.Selection_SetFilterList, (_, filterList: string[]) => {
configManager.setSelectionAssistantFilterList(filterList)
})
// [macOS] only macOS has the available isFullscreen mode
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem, isFullScreen: boolean = false) => {
selectionService?.processAction(actionItem, isFullScreen)
})
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)
}
})
// [Windows only] Electron bug workaround - can be removed once fixed
// See: https://github.com/electron/electron/issues/48554
ipcMain.handle(
IpcChannel.Selection_ActionWindowResize,
(event, deltaX: number, deltaY: number, direction: string) => {
const actionWindow = BrowserWindow.fromWebContents(event.sender)
if (actionWindow) {
selectionService?.resizeActionWindow(actionWindow, deltaX, deltaY, direction)
}
}
)
this.isIpcHandlerRegistered = true
}
private logInfo(message: string, forceShow: boolean = false): void {
if (isDev || forceShow) {
logger.info(message)
}
}
private logError(message: string, error?: Error): void {
logger.error(message, error)
}
}
/**
* 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 (!isSupportedOS) return false
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean): void => {
//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