fix(SelectionAssistant): [macOS] show actionWindow on fullscreen app (#8004)

* feat(SelectionService): enhance action window handling for macOS fullscreen mode

- Updated processAction and showActionWindow methods to support fullscreen mode on macOS.
- Added isFullScreen parameter to manage action window visibility and positioning.
- Improved action window positioning logic to ensure it remains within screen boundaries.
- Adjusted IPC channel to pass fullscreen state from the renderer to the service.
- Updated SelectionToolbar to track fullscreen state and pass it to the action processing function.

* chore(deps): update selection-hook to version 1.0.6 in package.json and yarn.lock

* fix(SelectionService): improve macOS fullscreen handling and action window focus

- Added app import to manage dock visibility on macOS.
- Enhanced fullscreen handling logic to ensure the dock icon is restored correctly.
- Updated action window focus behavior to prevent unintended hiding when blurred.
- Refactored SelectionActionApp to streamline auto pinning logic and remove redundant useEffect.
- Cleaned up SelectionToolbar by removing unnecessary window size updates when demo is false.

* refactor(SelectionService): remove commented-out code for clarity

* refactor(SelectionService): streamline macOS handling and improve code clarity
This commit is contained in:
fullex 2025-07-10 20:41:01 +08:00 committed by GitHub
parent 855499681f
commit 7c6db809bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 177 additions and 103 deletions

View File

@ -71,7 +71,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.5", "selection-hook": "^1.0.6",
"turndown": "7.2.0" "turndown": "7.2.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,7 +1,7 @@
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig' import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
import { isDev, isMac, isWin } from '@main/constant' import { isDev, isMac, isWin } from '@main/constant'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import { BrowserWindow, ipcMain, screen, systemPreferences } from 'electron' import { app, BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
import Logger from 'electron-log' import Logger from 'electron-log'
import { join } from 'path' import { join } from 'path'
import type { import type {
@ -509,54 +509,55 @@ 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] a series of hacky ways only for macOS if (!isMac) {
if (isMac) { this.toolbarWindow!.show()
// [macOS] a hacky way /**
// when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing * [Windows]
// so we just don't set `skipTransformProcessType: true` when in self app * In Windows 10, setOpacity(1) will make the window completely transparent
const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName) * It's a strange behavior, so we don't use it for compatibility
*/
if (!isSelf) { // this.toolbarWindow!.setOpacity(1)
// [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() this.startHideByMouseKeyListener()
return return
} }
/** /************************************************
* The following is for Windows * [macOS] the following code is only for macOS
*/ *
* WARNING:
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
*************************************************/
this.toolbarWindow!.show() // [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) {
* [Windows] // [macOS] an ugly hacky way
* In Windows 10, setOpacity(1) will make the window completely transparent // `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces`
* It's a strange behavior, so we don't use it for compatibility // so we set `focusable: true` before showing, and then set false after showing
*/ this.toolbarWindow!.setFocusable(false)
// this.toolbarWindow!.setOpacity(1)
// [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() this.startHideByMouseKeyListener()
return
} }
/** /**
@ -911,6 +912,7 @@ 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) }
} }
// [macOS] isFullscreen is only available on macOS
this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName) this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName)
this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData) this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
} }
@ -1218,20 +1220,26 @@ export class SelectionService {
return actionWindow return actionWindow
} }
public processAction(actionItem: ActionItem): void { /**
* 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() const actionWindow = this.popActionWindow()
actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem) actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem)
this.showActionWindow(actionWindow) this.showActionWindow(actionWindow, isFullScreen)
} }
/** /**
* Show action window with proper positioning relative to toolbar * Show action window with proper positioning relative to toolbar
* 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
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
*/ */
private showActionWindow(actionWindow: BrowserWindow): void { private showActionWindow(actionWindow: BrowserWindow, isFullScreen: boolean = false): 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
@ -1241,11 +1249,14 @@ export class SelectionService {
actionWindowHeight = this.lastActionWindowSize.height actionWindowHeight = this.lastActionWindowSize.height
} }
//center way /********************************************
if (!this.isFollowToolbar || !this.toolbarWindow) { * Setting the position of the action window
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) ********************************************/
const workArea = display.workArea const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const workArea = display.workArea
// Center of the screen
if (!this.isFollowToolbar || !this.toolbarWindow) {
const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2 const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2
const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2 const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2
@ -1255,54 +1266,107 @@ export class SelectionService {
x: Math.round(centerX), x: Math.round(centerX),
y: Math.round(centerY) y: Math.round(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() actionWindow.show()
return return
} }
//follow toolbar /************************************************
const toolbarBounds = this.toolbarWindow!.getBounds() * [macOS] the following code is only for macOS
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) *
const workArea = display.workArea * WARNING:
const GAP = 6 // 6px gap from screen edges * DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
*************************************************/
//make sure action window is inside screen // act normally when the app is not in fullscreen mode
if (actionWindowWidth > workArea.width - 2 * GAP) { if (!isFullScreen) {
actionWindowWidth = workArea.width - 2 * GAP actionWindow.show()
return
} }
if (actionWindowHeight > workArea.height - 2 * GAP) { // [macOS] an UGLY HACKY way for fullscreen override settings
actionWindowHeight = workArea.height - 2 * GAP
}
// Calculate initial position to center action window horizontally below toolbar // FIXME sometimes the dock will be shown when the action window is shown
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2) // FIXME if actionWindow show on the fullscreen app, switch to other space will cause the mainWindow to be shown
let posY = Math.round(toolbarBounds.y) // 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
// Ensure action window stays within screen boundaries with a small gap // setFocusable(false) to prevent the action window hide when blur (if auto hide on blur is enabled)
if (posX + actionWindowWidth > workArea.x + workArea.width) { actionWindow.setFocusable(false)
posX = workArea.x + workArea.width - actionWindowWidth - GAP actionWindow.setAlwaysOnTop(true, 'floating')
} 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) // `setVisibleOnAllWorkspaces(true)` will cause the dock icon disappeared
//KEY to make window not resize // just store the dock icon status, and show it again
actionWindow.setBounds({ const isDockShown = app.dock?.isVisible()
width: actionWindowWidth,
height: actionWindowHeight, // DO NOT set `skipTransformProcessType: true`,
x: posX, // it will cause the action window to be shown on other space
y: posY actionWindow.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true
}) })
actionWindow.show() 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 { public closeActionWindow(actionWindow: BrowserWindow): void {
@ -1408,8 +1472,9 @@ export class SelectionService {
configManager.setSelectionAssistantFilterList(filterList) configManager.setSelectionAssistantFilterList(filterList)
}) })
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => { // [macOS] only macOS has the available isFullscreen mode
selectionService?.processAction(actionItem) ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem, isFullScreen: boolean = false) => {
selectionService?.processAction(actionItem, isFullScreen)
}) })
ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => { ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => {

View File

@ -309,7 +309,8 @@ const api = {
ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize), ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize),
setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode), setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode),
setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList), setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList),
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem), processAction: (actionItem: ActionItem, isFullScreen: boolean = false) =>
ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem, isFullScreen),
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose), closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize), minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned) pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)

View File

@ -36,10 +36,6 @@ const SelectionActionApp: FC = () => {
const lastScrollHeight = useRef(0) const lastScrollHeight = useRef(0)
useEffect(() => { useEffect(() => {
if (isAutoPin) {
window.api.selection.pinActionWindow(true)
}
const actionListenRemover = window.electron?.ipcRenderer.on( const actionListenRemover = window.electron?.ipcRenderer.on(
IpcChannel.Selection_UpdateActionData, IpcChannel.Selection_UpdateActionData,
(_, actionItem: ActionItem) => { (_, actionItem: ActionItem) => {
@ -60,6 +56,20 @@ const SelectionActionApp: FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
useEffect(() => {
if (isAutoPin) {
window.api.selection.pinActionWindow(true)
setIsPinned(true)
} else if (!isActionLoaded.current) {
window.api.selection.pinActionWindow(false)
setIsPinned(false)
}
}, [isAutoPin])
useEffect(() => {
shouldCloseWhenBlur.current = isAutoClose && !isPinned
}, [isAutoClose, isPinned])
useEffect(() => { useEffect(() => {
i18n.changeLanguage(language || navigator.language || defaultLanguage) i18n.changeLanguage(language || navigator.language || defaultLanguage)
}, [language]) }, [language])
@ -100,10 +110,6 @@ const SelectionActionApp: FC = () => {
} }
}, [action, t]) }, [action, t])
useEffect(() => {
shouldCloseWhenBlur.current = isAutoClose && !isPinned
}, [isAutoClose, isPinned])
useEffect(() => { useEffect(() => {
//if the action is loaded, we should not set the opacity update from settings //if the action is loaded, we should not set the opacity update from settings
if (!isActionLoaded.current) { if (!isActionLoaded.current) {

View File

@ -107,6 +107,8 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
}, [actionItems]) }, [actionItems])
const selectedText = useRef('') const selectedText = useRef('')
// [macOS] only macOS has the fullscreen mode
const isFullScreen = useRef(false)
// listen to selectionService events // listen to selectionService events
useEffect(() => { useEffect(() => {
@ -115,6 +117,7 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
IpcChannel.Selection_TextSelected, IpcChannel.Selection_TextSelected,
(_, selectionData: TextSelectionData) => { (_, selectionData: TextSelectionData) => {
selectedText.current = selectionData.text selectedText.current = selectionData.text
isFullScreen.current = selectionData.isFullscreen ?? false
setTimeout(() => { setTimeout(() => {
//make sure the animation is active //make sure the animation is active
setAnimateKey((prev) => prev + 1) setAnimateKey((prev) => prev + 1)
@ -133,8 +136,6 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
} }
) )
if (!demo) updateWindowSize()
return () => { return () => {
textSelectionListenRemover() textSelectionListenRemover()
toolbarVisibilityChangeListenRemover() toolbarVisibilityChangeListenRemover()
@ -234,7 +235,8 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
} }
const handleDefaultAction = (action: ActionItem) => { const handleDefaultAction = (action: ActionItem) => {
window.api?.selection.processAction(action) // [macOS] only macOS has the available isFullscreen mode
window.api?.selection.processAction(action, isFullScreen.current)
window.api?.selection.hideToolbar() window.api?.selection.hideToolbar()
} }

View File

@ -7235,7 +7235,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.5" selection-hook: "npm:^1.0.6"
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"
@ -18229,14 +18229,14 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"selection-hook@npm:^1.0.5": "selection-hook@npm:^1.0.6":
version: 1.0.5 version: 1.0.6
resolution: "selection-hook@npm:1.0.5" resolution: "selection-hook@npm:1.0.6"
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/d188e2bafa6d820779e57a721bd2480dc1fde3f9daa2e3f92f1b69712637079e5fd9443575bc8624c98a057608f867d82fb2abf2d0796777db1f18ea50ea0028 checksum: 10c0/c7d28db51fc16b5648530344cbe1d5b72a7469cfb7edbb9c56d7be4bea2d93ddd01993fb27b344e44865f9eb0f3211b1be638caaacd0f9165b2bc03bada7c360
languageName: node languageName: node
linkType: hard linkType: hard