fix(SelectionAssistant): overall bug fix from v1.4.8 (#7834)

* feat(SelectionService): enable toolbar visibility on all workspaces

* feat: update selection-hook to v1.0.5

* fix: show toolbar over fullscreen apps

* fix(SelectionService): adjust macOS window type handling for fullscreen apps
This commit is contained in:
fullex 2025-07-06 23:41:20 +08:00 committed by GitHub
parent 7f8ad88c06
commit 40519b48c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 90 additions and 58 deletions

View File

@ -68,7 +68,7 @@
"notion-helper": "^1.3.22", "notion-helper": "^1.3.22",
"os-proxy-config": "^1.1.2", "os-proxy-config": "^1.1.2",
"pdfjs-dist": "4.10.38", "pdfjs-dist": "4.10.38",
"selection-hook": "^1.0.4", "selection-hook": "^1.0.5",
"turndown": "7.2.0" "turndown": "7.2.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -141,7 +141,7 @@ export class SelectionService {
* Initialize zoom factor from config and subscribe to changes * Initialize zoom factor from config and subscribe to changes
* Ensures UI elements scale properly with system DPI settings * Ensures UI elements scale properly with system DPI settings
*/ */
private initZoomFactor() { private initZoomFactor(): void {
const zoomFactor = configManager.getZoomFactor() const zoomFactor = configManager.getZoomFactor()
if (zoomFactor) { if (zoomFactor) {
this.setZoomFactor(zoomFactor) this.setZoomFactor(zoomFactor)
@ -154,7 +154,7 @@ export class SelectionService {
this.zoomFactor = zoomFactor this.zoomFactor = zoomFactor
} }
private initConfig() { private initConfig(): void {
this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode
this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar() this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar()
this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize() this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize()
@ -207,7 +207,7 @@ export class SelectionService {
* @param mode - The mode to set, either 'default', 'whitelist', or 'blacklist' * @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 * @param list - An array of strings representing the list of items to include or exclude
*/ */
private setHookGlobalFilterMode(mode: string, list: string[]) { private setHookGlobalFilterMode(mode: string, list: string[]): void {
if (!this.selectionHook) return if (!this.selectionHook) return
const modeMap = { const modeMap = {
@ -245,7 +245,7 @@ export class SelectionService {
} }
} }
private setHookFineTunedList() { private setHookFineTunedList(): void {
if (!this.selectionHook) return if (!this.selectionHook) return
const excludeClipboardCursorDetectList = isWin const excludeClipboardCursorDetectList = isWin
@ -271,6 +271,11 @@ export class SelectionService {
* @returns {boolean} Success status of service start * @returns {boolean} Success status of service start
*/ */
public start(): boolean { public start(): boolean {
if (!isSupportedOS) {
this.logError(new Error('SelectionService start(): not supported on this OS'))
return false
}
if (!this.selectionHook) { if (!this.selectionHook) {
this.logError(new Error('SelectionService start(): instance is null')) this.logError(new Error('SelectionService start(): instance is null'))
return false return false
@ -373,7 +378,7 @@ export class SelectionService {
* Toggle the enabled state of the selection service * Toggle the enabled state of the selection service
* Will sync the new enabled store to all renderer windows * Will sync the new enabled store to all renderer windows
*/ */
public toggleEnabled(enabled: boolean | undefined = undefined) { public toggleEnabled(enabled: boolean | undefined = undefined): void {
if (!this.selectionHook) return if (!this.selectionHook) return
const newEnabled = enabled === undefined ? !configManager.getSelectionAssistantEnabled() : enabled const newEnabled = enabled === undefined ? !configManager.getSelectionAssistantEnabled() : enabled
@ -389,7 +394,7 @@ export class SelectionService {
* Sets up window properties, event handlers, and loads the toolbar UI * Sets up window properties, event handlers, and loads the toolbar UI
* @param readyCallback Optional callback when window is ready to show * @param readyCallback Optional callback when window is ready to show
*/ */
private createToolbarWindow(readyCallback?: () => void) { private createToolbarWindow(readyCallback?: () => void): void {
if (this.isToolbarAlive()) return if (this.isToolbarAlive()) return
const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
@ -414,9 +419,11 @@ export class SelectionService {
backgroundMaterial: 'none', backgroundMaterial: 'none',
// Platform specific settings // Platform specific settings
// [macOS] DO NOT set type to 'panel', it will not work because it conflicts with other settings
// [macOS] DO NOT set focusable to false, it will make other windows bring to front together // [macOS] DO NOT set focusable to false, it will make other windows bring to front together
...(isWin ? { type: 'toolbar', focusable: false } : {}), // [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] hiddenInMissionControl: true, // [macOS only]
acceptFirstMouse: true, // [macOS only] acceptFirstMouse: true, // [macOS only]
@ -447,13 +454,6 @@ export class SelectionService {
// Add show/hide event listeners // Add show/hide event listeners
this.toolbarWindow.on('show', () => { this.toolbarWindow.on('show', () => {
this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true) this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true)
// [macOS] force the toolbar window to be visible on current desktop
// but it will make docker icon flash. And we found that it's not necessary now.
// will remove after testing
// if (isMac) {
// this.toolbarWindow!.setVisibleOnAllWorkspaces(false)
// }
}) })
this.toolbarWindow.on('hide', () => { this.toolbarWindow.on('hide', () => {
@ -485,10 +485,10 @@ export class SelectionService {
* @param point Reference point for positioning, logical coordinates * @param point Reference point for positioning, logical coordinates
* @param orientation Preferred position relative to reference point * @param orientation Preferred position relative to reference point
*/ */
private showToolbarAtPosition(point: Point, orientation: RelativeOrientation) { private showToolbarAtPosition(point: Point, orientation: RelativeOrientation, programName: string): void {
if (!this.isToolbarAlive()) { if (!this.isToolbarAlive()) {
this.createToolbarWindow(() => { this.createToolbarWindow(() => {
this.showToolbarAtPosition(point, orientation) this.showToolbarAtPosition(point, orientation, programName)
}) })
return return
} }
@ -509,16 +509,45 @@ export class SelectionService {
//should set every time the window is shown //should set every time the window is shown
this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver') this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver')
// [macOS] force the toolbar window to be visible on current desktop // [macOS] a series of hacky ways only for macOS
// but it will make docker icon flash. And we found that it's not necessary now. if (isMac) {
// will remove after testing // [macOS] a hacky way
// if (isMac) { // when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing
// this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) // so we just don't set `skipTransformProcessType: true` when in self app
// } const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName)
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together if (!isSelf) {
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false` // [macOS] an ugly hacky way
this.toolbarWindow!.showInactive() // `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
}
/**
* The following is for Windows
*/
this.toolbarWindow!.show()
/** /**
* [Windows] * [Windows]
@ -588,8 +617,8 @@ export class SelectionService {
* Check if toolbar window exists and is not destroyed * Check if toolbar window exists and is not destroyed
* @returns {boolean} Toolbar window status * @returns {boolean} Toolbar window status
*/ */
private isToolbarAlive() { private isToolbarAlive(): boolean {
return this.toolbarWindow && !this.toolbarWindow.isDestroyed() return !!(this.toolbarWindow && !this.toolbarWindow.isDestroyed())
} }
/** /**
@ -598,7 +627,7 @@ export class SelectionService {
* @param width New toolbar width * @param width New toolbar width
* @param height New toolbar height * @param height New toolbar height
*/ */
public determineToolbarSize(width: number, height: number) { public determineToolbarSize(width: number, height: number): void {
const toolbarWidth = Math.ceil(width) const toolbarWidth = Math.ceil(width)
// only update toolbar width if it's changed // only update toolbar width if it's changed
@ -611,7 +640,7 @@ export class SelectionService {
* Get actual toolbar dimensions accounting for zoom factor * Get actual toolbar dimensions accounting for zoom factor
* @returns Object containing toolbar width and height * @returns Object containing toolbar width and height
*/ */
private getToolbarRealSize() { private getToolbarRealSize(): { toolbarWidth: number; toolbarHeight: number } {
return { return {
toolbarWidth: this.TOOLBAR_WIDTH * this.zoomFactor, toolbarWidth: this.TOOLBAR_WIDTH * this.zoomFactor,
toolbarHeight: this.TOOLBAR_HEIGHT * this.zoomFactor toolbarHeight: this.TOOLBAR_HEIGHT * this.zoomFactor
@ -882,8 +911,8 @@ export class SelectionService {
refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) } refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
} }
this.showToolbarAtPosition(refPoint, refOrientation) this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName)
this.toolbarWindow?.webContents.send(IpcChannel.Selection_TextSelected, selectionData) this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
} }
/** /**
@ -891,7 +920,7 @@ export class SelectionService {
*/ */
// Start monitoring global mouse clicks // Start monitoring global mouse clicks
private startHideByMouseKeyListener() { private startHideByMouseKeyListener(): void {
try { try {
// Register event handlers // Register event handlers
this.selectionHook!.on('mouse-down', this.handleMouseDownHide) this.selectionHook!.on('mouse-down', this.handleMouseDownHide)
@ -904,7 +933,7 @@ export class SelectionService {
} }
// Stop monitoring global mouse clicks // Stop monitoring global mouse clicks
private stopHideByMouseKeyListener() { private stopHideByMouseKeyListener(): void {
if (!this.isHideByMouseKeyListenerActive) return if (!this.isHideByMouseKeyListenerActive) return
try { try {
@ -1098,7 +1127,7 @@ export class SelectionService {
* Initialize preloaded action windows * Initialize preloaded action windows
* Creates a pool of windows at startup for faster response * Creates a pool of windows at startup for faster response
*/ */
private async initPreloadedActionWindows() { private async initPreloadedActionWindows(): Promise<void> {
try { try {
// Create initial pool of preloaded windows // Create initial pool of preloaded windows
for (let i = 0; i < this.PRELOAD_ACTION_WINDOW_COUNT; i++) { for (let i = 0; i < this.PRELOAD_ACTION_WINDOW_COUNT; i++) {
@ -1112,7 +1141,7 @@ export class SelectionService {
/** /**
* Close all preloaded action windows * Close all preloaded action windows
*/ */
private closePreloadedActionWindows() { private closePreloadedActionWindows(): void {
for (const actionWindow of this.preloadedActionWindows) { for (const actionWindow of this.preloadedActionWindows) {
if (!actionWindow.isDestroyed()) { if (!actionWindow.isDestroyed()) {
actionWindow.destroy() actionWindow.destroy()
@ -1124,7 +1153,7 @@ export class SelectionService {
* Preload a new action window asynchronously * Preload a new action window asynchronously
* This method is called after popping a window to ensure we always have windows ready * This method is called after popping a window to ensure we always have windows ready
*/ */
private async pushNewActionWindow() { private async pushNewActionWindow(): Promise<void> {
try { try {
const actionWindow = this.createPreloadedActionWindow() const actionWindow = this.createPreloadedActionWindow()
this.preloadedActionWindows.push(actionWindow) this.preloadedActionWindows.push(actionWindow)
@ -1138,7 +1167,7 @@ export class SelectionService {
* Immediately returns a window and asynchronously creates a new one * Immediately returns a window and asynchronously creates a new one
* @returns {BrowserWindow} The action window * @returns {BrowserWindow} The action window
*/ */
private popActionWindow() { private popActionWindow(): BrowserWindow {
// Get a window from the preloaded queue or create a new one if empty // Get a window from the preloaded queue or create a new one if empty
const actionWindow = this.preloadedActionWindows.pop() || this.createPreloadedActionWindow() const actionWindow = this.preloadedActionWindows.pop() || this.createPreloadedActionWindow()
@ -1202,7 +1231,7 @@ export class SelectionService {
* Ensures window stays within screen boundaries * Ensures window stays within screen boundaries
* @param actionWindow Window to position and show * @param actionWindow Window to position and show
*/ */
private showActionWindow(actionWindow: BrowserWindow) { private showActionWindow(actionWindow: BrowserWindow): void {
let actionWindowWidth = this.ACTION_WINDOW_WIDTH let actionWindowWidth = this.ACTION_WINDOW_WIDTH
let actionWindowHeight = this.ACTION_WINDOW_HEIGHT let actionWindowHeight = this.ACTION_WINDOW_HEIGHT
@ -1228,6 +1257,7 @@ export class SelectionService {
}) })
actionWindow.show() actionWindow.show()
return return
} }
@ -1292,38 +1322,40 @@ export class SelectionService {
* Switches between selection-based and alt-key based triggering * Switches between selection-based and alt-key based triggering
* Manages appropriate event listeners for each mode * Manages appropriate event listeners for each mode
*/ */
private processTriggerMode() { private processTriggerMode(): void {
if (!this.selectionHook) return
switch (this.triggerMode) { switch (this.triggerMode) {
case TriggerMode.Selected: case TriggerMode.Selected:
if (this.isCtrlkeyListenerActive) { if (this.isCtrlkeyListenerActive) {
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode) this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode) this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode)
this.isCtrlkeyListenerActive = false this.isCtrlkeyListenerActive = false
} }
this.selectionHook!.setSelectionPassiveMode(false) this.selectionHook.setSelectionPassiveMode(false)
break break
case TriggerMode.Ctrlkey: case TriggerMode.Ctrlkey:
if (!this.isCtrlkeyListenerActive) { if (!this.isCtrlkeyListenerActive) {
this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode) this.selectionHook.on('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode) this.selectionHook.on('key-up', this.handleKeyUpCtrlkeyMode)
this.isCtrlkeyListenerActive = true this.isCtrlkeyListenerActive = true
} }
this.selectionHook!.setSelectionPassiveMode(true) this.selectionHook.setSelectionPassiveMode(true)
break break
case TriggerMode.Shortcut: case TriggerMode.Shortcut:
//remove the ctrlkey listener, don't need any key listener for shortcut mode //remove the ctrlkey listener, don't need any key listener for shortcut mode
if (this.isCtrlkeyListenerActive) { if (this.isCtrlkeyListenerActive) {
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode) this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode) this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode)
this.isCtrlkeyListenerActive = false this.isCtrlkeyListenerActive = false
} }
this.selectionHook!.setSelectionPassiveMode(true) this.selectionHook.setSelectionPassiveMode(true)
break break
} }
} }
@ -1404,13 +1436,13 @@ export class SelectionService {
this.isIpcHandlerRegistered = true this.isIpcHandlerRegistered = true
} }
private logInfo(message: string, forceShow: boolean = false) { private logInfo(message: string, forceShow: boolean = false): void {
if (isDev || forceShow) { if (isDev || forceShow) {
Logger.info('[SelectionService] Info: ', message) Logger.info('[SelectionService] Info: ', message)
} }
} }
private logError(...args: [...string[], Error]) { private logError(...args: [...string[], Error]): void {
Logger.error('[SelectionService] Error: ', ...args) Logger.error('[SelectionService] Error: ', ...args)
} }
} }
@ -1423,7 +1455,7 @@ export class SelectionService {
export function initSelectionService(): boolean { export function initSelectionService(): boolean {
if (!isSupportedOS) return false if (!isSupportedOS) return false
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => { configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean): void => {
//avoid closure //avoid closure
const ss = SelectionService.getInstance() const ss = SelectionService.getInstance()
if (!ss) { if (!ss) {

View File

@ -5960,7 +5960,7 @@ __metadata:
remove-markdown: "npm:^0.6.2" remove-markdown: "npm:^0.6.2"
rollup-plugin-visualizer: "npm:^5.12.0" rollup-plugin-visualizer: "npm:^5.12.0"
sass: "npm:^1.88.0" sass: "npm:^1.88.0"
selection-hook: "npm:^1.0.4" selection-hook: "npm:^1.0.5"
shiki: "npm:^3.7.0" shiki: "npm:^3.7.0"
string-width: "npm:^7.2.0" string-width: "npm:^7.2.0"
styled-components: "npm:^6.1.11" styled-components: "npm:^6.1.11"
@ -16928,14 +16928,14 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"selection-hook@npm:^1.0.4": "selection-hook@npm:^1.0.5":
version: 1.0.4 version: 1.0.5
resolution: "selection-hook@npm:1.0.4" resolution: "selection-hook@npm:1.0.5"
dependencies: dependencies:
node-addon-api: "npm:^8.4.0" node-addon-api: "npm:^8.4.0"
node-gyp: "npm:latest" node-gyp: "npm:latest"
node-gyp-build: "npm:^4.8.4" node-gyp-build: "npm:^4.8.4"
checksum: 10c0/8c694cf7bb82159ec8aa2079e9fe74149d8cf5679720e67203b7b56a591f67d9d25e43bb5ed4242c5d6fa8be61ba64863d3b81f6a2fc5dbad7861a5273f65061 checksum: 10c0/d188e2bafa6d820779e57a721bd2480dc1fde3f9daa2e3f92f1b69712637079e5fd9443575bc8624c98a057608f867d82fb2abf2d0796777db1f18ea50ea0028
languageName: node languageName: node
linkType: hard linkType: hard