mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 20:59:22 +08:00
refactor(selection): migrate toolbar and action windows to WindowManager
Replaces the hand-rolled `new BrowserWindow(...)` creation paths and the
manual preloaded-action-window pool with WindowManager-based lifecycles:
- Toolbar is a `singleton` with `show: false`; visibility remains driven
by `showToolbarAtPosition` / `hideToolbar`, but the underlying window is
created and destroyed through the manager. Linux Wayland-specific
`focusable` adjustment stays behind `onWindowCreated` since the Wayland
detection only becomes available after the native module loads.
- Action is a `pooled` window with eager warmup, opened via
`wm.open(WindowType.SelectionAction, { initData: actionItem, options })`.
The renderer consumes the payload through the new `useWindowInitData`
hook, which handles both cold-start (`getInitData`) and pool-recycle
(`WindowManager_Reused`) delivery without unmounting the content tree.
All macOS hide/close focus-dance, hover-clearing `sendInputEvent`, and
`setAlwaysOnTop('screen-saver')` re-application are now declarative via
`WindowTypeMetadata.quirks` (see WindowManager); the former manual
`runMacFocusHackAfterActionClose`, `sendInputEvent`, `setAlwaysOnTop`
calls, and the preloaded-window queue are deleted. Selection-side state
that only existed to gate the focus hack (`activeActionWindowIds`) is
also removed.
The fullscreen-app action-window show sequence (`setVisibleOnAllWorkspaces`
dance + dock restoration + 50 ms settle window) and the non-self-app
`skipTransformProcessType` handling stay in SelectionService — these
depend on `isFullScreen` / `programName` business state and are not
generic enough for the quirks layer.
Renderer `SelectionActionApp` drops the `key={resetKey}` remount pattern
and adds a controlled-content component with a single
`useEffect([action])` per-session reset, so the DOM stays continuous
across pool recycles.
This commit is contained in:
@@ -3,11 +3,11 @@ import { loggerService } from '@logger'
|
||||
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
|
||||
import { isDev, isLinux, isMac, isWin } from '@main/constant'
|
||||
import { type Activatable, BaseService, Injectable, Phase, ServicePhase } from '@main/core/lifecycle'
|
||||
import { WindowType } from '@main/core/window/types'
|
||||
import type { SelectionActionItem } from '@shared/data/preference/preferenceTypes'
|
||||
import { SelectionTriggerMode } from '@shared/data/preference/preferenceTypes'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { app, BrowserWindow, clipboard, screen, systemPreferences } from 'electron'
|
||||
import { join } from 'path'
|
||||
import type {
|
||||
KeyboardEventData,
|
||||
MouseEventData,
|
||||
@@ -60,10 +60,11 @@ export class SelectionService extends BaseService implements Activatable {
|
||||
|
||||
private unsubscriberForChangeListeners: (() => void)[] = []
|
||||
|
||||
// Toolbar window is managed by WindowManager (singleton). We cache the BrowserWindow
|
||||
// reference here because there are ~20 usage sites across this file — the cache avoids
|
||||
// querying WindowManager at each call site. Kept in sync via onWindowCreated/Destroyed.
|
||||
private toolbarWindow: BrowserWindow | null = null
|
||||
private actionWindows = new Set<BrowserWindow>()
|
||||
private preloadedActionWindows: BrowserWindow[] = []
|
||||
private readonly PRELOAD_ACTION_WINDOW_COUNT = 1
|
||||
private toolbarWindowId: string | null = null
|
||||
|
||||
private isHideByMouseKeyListenerActive: boolean = false
|
||||
private isCtrlkeyListenerActive: boolean = false
|
||||
@@ -171,8 +172,17 @@ export class SelectionService extends BaseService implements Activatable {
|
||||
}
|
||||
|
||||
try {
|
||||
this.createToolbarWindow()
|
||||
void this.initPreloadedActionWindows()
|
||||
const wm = application.get('WindowManager')
|
||||
|
||||
// Resume the action pool in case a prior deactivate/activate cycle suspended it.
|
||||
// With registry warmup: 'eager', the pool auto-creates idle windows at app start,
|
||||
// so the first user-triggered action recycles instantly instead of going through
|
||||
// the fresh-path (create + load HTML + wait for React to mount).
|
||||
wm.resumePool(WindowType.SelectionAction)
|
||||
|
||||
// Open the toolbar (singleton) — registry's show: false ensures no auto-show here,
|
||||
// showToolbarAtPosition controls positioning and visibility.
|
||||
this.toolbarWindowId = wm.open(WindowType.SelectionToolbar)
|
||||
|
||||
this.selectionHook!.on('error', (error: { message: string }) => {
|
||||
this.logError('Error in SelectionHook:', error as Error)
|
||||
@@ -202,6 +212,45 @@ export class SelectionService extends BaseService implements Activatable {
|
||||
this.initZoomFactor()
|
||||
this.registerIpcHandlers()
|
||||
|
||||
const wm = application.get('WindowManager')
|
||||
|
||||
// Inject behavior into newly-created Selection windows.
|
||||
// onWindowCreated fires synchronously before content loads, so listeners here attach
|
||||
// before the renderer can start sending IPC messages.
|
||||
this.registerDisposable(
|
||||
wm.onWindowCreated((managed) => {
|
||||
if (managed.type === WindowType.SelectionToolbar) {
|
||||
// Cache the BrowserWindow reference for the ~20 downstream call sites
|
||||
// (showToolbarAtPosition, hideToolbar, processTextSelection, etc.)
|
||||
this.toolbarWindow = managed.window
|
||||
this.setupToolbarBehavior(managed.window)
|
||||
} else if (managed.type === WindowType.SelectionAction) {
|
||||
//remember the action window size
|
||||
managed.window.on('resized', () => {
|
||||
if (managed.window.isDestroyed()) return
|
||||
if (this.isRemeberWinSize) {
|
||||
this.lastActionWindowSize = {
|
||||
width: managed.window.getBounds().width,
|
||||
height: managed.window.getBounds().height
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Destruction: keep the cached toolbar reference in sync.
|
||||
// The macOS focus dance on hide/close is handled by the macRestoreFocusOnHide quirk
|
||||
// (see WindowManager.applyQuirks) — no subscription needed here.
|
||||
this.registerDisposable(
|
||||
wm.onWindowDestroyed((managed) => {
|
||||
if (managed.type === WindowType.SelectionToolbar && managed.id === this.toolbarWindowId) {
|
||||
this.toolbarWindow = null
|
||||
this.toolbarWindowId = null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const preferenceService = application.get('PreferenceService')
|
||||
this.registerDisposable({
|
||||
dispose: preferenceService.subscribeChange('feature.selection.enabled', (enabled: boolean) => {
|
||||
@@ -406,96 +455,47 @@ export class SelectionService extends BaseService implements Activatable {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Attach toolbar-specific runtime behavior to a freshly-created SelectionToolbar window.
|
||||
* Invoked from the WindowManager.onWindowCreated hook registered in onInit().
|
||||
*
|
||||
* Window configuration (frame, transparent, type: 'panel', focusable default, etc.) lives
|
||||
* in windowRegistry.ts alongside the full platform-specific commentary. This method only
|
||||
* handles behavior that depends on runtime state (e.g., Wayland detection) or event wiring.
|
||||
*/
|
||||
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 causes other windows to bring to front together.
|
||||
// type 'panel' conflicts with some settings and triggers the warning
|
||||
// `NSWindow does not support nonactivating panel styleMask 0x80`,
|
||||
// but it still works correctly on fullscreen apps, so we keep it.
|
||||
// [Windows/Linux X11] focusable: false prevents toolbar from stealing focus.
|
||||
// On Linux X11 this also makes the window stop interacting with WM (stays on top).
|
||||
// [Linux Wayland] focusable: true enables blur events for outside-click hiding.
|
||||
// With focusable: false on XWayland, blur never fires and there is no reliable
|
||||
// way to detect outside clicks (selection-hook coordinates use a different
|
||||
// coordinate space than Electron's getBounds on Wayland).
|
||||
...(isMac ? { type: 'panel' } : { type: 'toolbar', focusable: this.isLinuxWaylandDisplay }),
|
||||
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
|
||||
}
|
||||
})
|
||||
private setupToolbarBehavior(window: BrowserWindow): void {
|
||||
// [Linux Wayland] focusable must be true on Wayland to receive blur events for
|
||||
// outside-click hiding. onWindowCreated fires before loadURL(), so setFocusable()
|
||||
// here takes effect before the window is shown. The full platform rationale is
|
||||
// documented in windowRegistry.ts under SelectionToolbar's defaultConfig.
|
||||
if (isLinux) {
|
||||
window.setFocusable(this.isLinuxWaylandDisplay)
|
||||
}
|
||||
|
||||
// Hide when losing focus
|
||||
this.toolbarWindow.on('blur', () => {
|
||||
if (this.toolbarWindow!.isVisible()) {
|
||||
window.on('blur', () => {
|
||||
if (window.isVisible()) {
|
||||
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)
|
||||
window.on('show', () => {
|
||||
window.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true)
|
||||
})
|
||||
|
||||
this.toolbarWindow.on('hide', () => {
|
||||
this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, false)
|
||||
window.on('hide', () => {
|
||||
window.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' })
|
||||
// window.once('ready-to-show', () => {
|
||||
// window.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']) {
|
||||
void this.toolbarWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/selectionToolbar.html')
|
||||
} else {
|
||||
void this.toolbarWindow.loadFile(join(__dirname, '../renderer/selectionToolbar.html'))
|
||||
}
|
||||
// Note: there is no 'closed' listener here — WindowManager fires onWindowDestroyed
|
||||
// which is handled in onInit() to clear toolbarWindowId/toolbarWindow.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -505,9 +505,18 @@ export class SelectionService extends BaseService implements Activatable {
|
||||
*/
|
||||
private showToolbarAtPosition(point: Point, orientation: RelativeOrientation, programName: string): void {
|
||||
if (!this.isToolbarAlive()) {
|
||||
this.createToolbarWindow(() => {
|
||||
this.showToolbarAtPosition(point, orientation, programName)
|
||||
})
|
||||
// Toolbar was destroyed (e.g., crash recovery). Re-open via WindowManager — the
|
||||
// onWindowCreated handler will call setupToolbarBehavior() and update toolbarWindow.
|
||||
// After ready-to-show, retry positioning. If the caller is in a tight loop, the
|
||||
// recursive retry will converge as soon as the renderer finishes loading.
|
||||
const wm = application.get('WindowManager')
|
||||
this.toolbarWindowId = wm.open(WindowType.SelectionToolbar)
|
||||
const newToolbar = wm.getWindow(this.toolbarWindowId)
|
||||
if (newToolbar) {
|
||||
newToolbar.once('ready-to-show', () => {
|
||||
this.showToolbarAtPosition(point, orientation, programName)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -523,9 +532,8 @@ export class SelectionService extends BaseService implements Activatable {
|
||||
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')
|
||||
// setAlwaysOnTop(true, 'screen-saver') is re-applied by the macReapplyAlwaysOnTop
|
||||
// quirk after every show()/showInactive() call (see WindowManager.applyQuirks).
|
||||
|
||||
if (!isMac) {
|
||||
this.toolbarWindow!.show()
|
||||
@@ -586,50 +594,11 @@ export class SelectionService extends BaseService implements Activatable {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On macOS, the toolbar's hide() call is wrapped by WindowManager's applyQuirks:
|
||||
// - macRestoreFocusOnHide guards focus (setFocusable(false) on all visible windows, restored after 50ms)
|
||||
// - macClearHoverOnHide sends a synthetic mouseMove(-1,-1) to clear any residual hover state
|
||||
// so this call site remains a plain .hide() on every platform.
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1158,74 +1127,15 @@ export class SelectionService extends BaseService implements Activatable {
|
||||
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: false,
|
||||
devTools: true
|
||||
}
|
||||
})
|
||||
|
||||
// Load the base URL without action data
|
||||
if (isDev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
void preloadedActionWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/selectionAction.html')
|
||||
} else {
|
||||
void 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all activation-scoped resources.
|
||||
* Uses stop() + removeAllListeners() instead of cleanup() to preserve the native instance
|
||||
* for efficient reactivation. Safe to call even if onActivate() never ran or partially ran.
|
||||
*
|
||||
* Note on action windows: we intentionally DO NOT destroy in-use action windows —
|
||||
* users may still be reading those results. The WindowManager pool is suspended
|
||||
* (idle windows destroyed, no further warmup), but in-use windows stay alive until
|
||||
* the user closes them (suspendPool only destroys idle, never managed).
|
||||
*/
|
||||
private releaseActivationResources(): void {
|
||||
if (this.selectionHook) {
|
||||
@@ -1244,80 +1154,20 @@ export class SelectionService extends BaseService implements Activatable {
|
||||
|
||||
this.isCtrlkeyListenerActive = false
|
||||
this.isHideByMouseKeyListenerActive = false
|
||||
this.lastCtrlkeyDownTime = 0
|
||||
|
||||
if (this.toolbarWindow && !this.toolbarWindow.isDestroyed()) {
|
||||
this.toolbarWindow.close()
|
||||
const wm = application.get('WindowManager')
|
||||
|
||||
// Destroy toolbar (singleton — not pooled)
|
||||
if (this.toolbarWindowId) {
|
||||
wm.destroy(this.toolbarWindowId)
|
||||
// toolbarWindow / toolbarWindowId are cleared by the onWindowDestroyed handler in onInit().
|
||||
}
|
||||
this.toolbarWindow = null
|
||||
|
||||
this.closePreloadedActionWindows()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
// [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 (actionWindow.isDestroyed()) return
|
||||
if (this.isRemeberWinSize) {
|
||||
this.lastActionWindowSize = {
|
||||
width: actionWindow.getBounds().width,
|
||||
height: actionWindow.getBounds().height
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.actionWindows.add(actionWindow)
|
||||
|
||||
// Asynchronously create a new preloaded window
|
||||
void this.pushNewActionWindow()
|
||||
|
||||
return actionWindow
|
||||
// Suspend the action pool — destroys idle windows and disables further warmup until
|
||||
// resumePool() is called on next activate. In-use windows are NOT destroyed here,
|
||||
// preserving user-visible results.
|
||||
wm.suspendPool(WindowType.SelectionAction)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1326,9 +1176,29 @@ export class SelectionService extends BaseService implements Activatable {
|
||||
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
|
||||
*/
|
||||
public processAction(actionItem: SelectionActionItem, isFullScreen: boolean = false): void {
|
||||
const actionWindow = this.popActionWindow()
|
||||
const wm = application.get('WindowManager')
|
||||
|
||||
actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem)
|
||||
// open({ initData }) atomically stores the action payload and, for the
|
||||
// pool-recycle path, emits WindowManager_Reused with the same payload so
|
||||
// the renderer can update in-place. For recycled windows the renderer has
|
||||
// been mounted and its listener registered since warmup, so the DOM is
|
||||
// ready on the next tick. For fresh windows the renderer mounts,
|
||||
// `useWindowInitData` pulls the payload via `getInitData`, and React
|
||||
// paints before the user notices. This mirrors the behavior of the
|
||||
// pre-WindowManager SelectionService: push data, then show immediately.
|
||||
const windowId = wm.open(WindowType.SelectionAction, {
|
||||
initData: actionItem,
|
||||
options: {
|
||||
width: this.isRemeberWinSize ? this.lastActionWindowSize.width : this.ACTION_WINDOW_WIDTH,
|
||||
height: this.isRemeberWinSize ? this.lastActionWindowSize.height : this.ACTION_WINDOW_HEIGHT
|
||||
}
|
||||
})
|
||||
|
||||
const actionWindow = wm.getWindow(windowId)
|
||||
if (!actionWindow) {
|
||||
this.logError(`Failed to get action window ${windowId}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.showActionWindow(actionWindow, isFullScreen)
|
||||
}
|
||||
@@ -1471,9 +1341,22 @@ export class SelectionService extends BaseService implements Activatable {
|
||||
}, 50)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an action window via WindowManager.
|
||||
* For pooled windows, WindowManager intercepts the close and hides the window back
|
||||
* into the idle pool. The macRestoreFocusOnHide quirk applies the macOS focus dance
|
||||
* automatically at the hide/close call site.
|
||||
*/
|
||||
public closeActionWindow(actionWindow: BrowserWindow): void {
|
||||
if (actionWindow.isDestroyed()) return
|
||||
actionWindow.close()
|
||||
const wm = application.get('WindowManager')
|
||||
const windowId = wm.getWindowId(actionWindow)
|
||||
if (windowId) {
|
||||
wm.close(windowId)
|
||||
} else {
|
||||
// Fallback for untracked windows (should not happen in normal flow)
|
||||
actionWindow.close()
|
||||
}
|
||||
}
|
||||
|
||||
public minimizeActionWindow(actionWindow: BrowserWindow): void {
|
||||
@@ -1564,22 +1447,34 @@ export class SelectionService extends BaseService implements Activatable {
|
||||
}
|
||||
)
|
||||
|
||||
// Helper: resolve an action window from an IPC event via WindowManager.
|
||||
// Falls back to BrowserWindow.fromWebContents if the window is not tracked by WM
|
||||
// (e.g., race conditions during deactivate), matching the pre-migration behavior.
|
||||
const resolveActionWindow = (event: Electron.IpcMainInvokeEvent): BrowserWindow | null => {
|
||||
const wm = application.get('WindowManager')
|
||||
const windowId = wm.getWindowIdByWebContents(event.sender)
|
||||
if (windowId) {
|
||||
return wm.getWindow(windowId) ?? null
|
||||
}
|
||||
return BrowserWindow.fromWebContents(event.sender)
|
||||
}
|
||||
|
||||
this.ipcHandle(IpcChannel.Selection_ActionWindowClose, (event) => {
|
||||
const actionWindow = BrowserWindow.fromWebContents(event.sender)
|
||||
const actionWindow = resolveActionWindow(event)
|
||||
if (actionWindow && !actionWindow.isDestroyed()) {
|
||||
this.closeActionWindow(actionWindow)
|
||||
}
|
||||
})
|
||||
|
||||
this.ipcHandle(IpcChannel.Selection_ActionWindowMinimize, (event) => {
|
||||
const actionWindow = BrowserWindow.fromWebContents(event.sender)
|
||||
const actionWindow = resolveActionWindow(event)
|
||||
if (actionWindow && !actionWindow.isDestroyed()) {
|
||||
this.minimizeActionWindow(actionWindow)
|
||||
}
|
||||
})
|
||||
|
||||
this.ipcHandle(IpcChannel.Selection_ActionWindowPin, (event, isPinned: boolean) => {
|
||||
const actionWindow = BrowserWindow.fromWebContents(event.sender)
|
||||
const actionWindow = resolveActionWindow(event)
|
||||
if (actionWindow && !actionWindow.isDestroyed()) {
|
||||
this.pinActionWindow(actionWindow, isPinned)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Button, Tooltip } from '@cherrystudio/ui'
|
||||
import { usePreference } from '@data/hooks/usePreference'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useWindowInitData } from '@renderer/core/hooks/useWindowInitData'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
import type { SelectionActionItem } from '@shared/data/preference/preferenceTypes'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Slider } from 'antd'
|
||||
import { Droplet, Minus, Pin, X } from 'lucide-react'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
@@ -16,14 +16,33 @@ import styled from 'styled-components'
|
||||
import ActionGeneral from './components/ActionGeneral'
|
||||
import ActionTranslate from './components/ActionTranslate'
|
||||
|
||||
/**
|
||||
* Outer shell. Pulls the current action payload via `useWindowInitData`, which
|
||||
* transparently handles both cold-start (pooled warmup / first mount) and
|
||||
* reuse (`WindowManager_Reused` payload on pool recycle). No `key={resetKey}`
|
||||
* remount — `SelectionActionContent` stays mounted across recycles and
|
||||
* receives `action` as a prop. Per-action state is reset in a single
|
||||
* `useEffect([action])` inside the content component.
|
||||
*/
|
||||
const SelectionActionApp: FC = () => {
|
||||
const action = useWindowInitData<SelectionActionItem>()
|
||||
if (!action) return null
|
||||
return <SelectionActionContent action={action} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlled content component. All selection-action UI state lives here;
|
||||
* `action` is supplied by the parent and updated on every pool recycle /
|
||||
* singleton re-use without unmounting. A consolidated `useEffect([action])`
|
||||
* (keyed on the reference, not `.id`) resets per-session state (pin, opacity,
|
||||
* slider, scroll) so old state doesn't bleed into the new session, even when
|
||||
* the next action happens to be the same type as the previous one.
|
||||
*/
|
||||
const SelectionActionContent: FC<{ action: SelectionActionItem }> = ({ action }) => {
|
||||
const [language] = usePreference('app.language')
|
||||
const [customCss] = usePreference('ui.custom_css')
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [action, setAction] = useState<SelectionActionItem | null>(null)
|
||||
const isActionLoaded = useRef(false)
|
||||
|
||||
const [isAutoClose] = usePreference('feature.selection.auto_close')
|
||||
const [isAutoPin] = usePreference('feature.selection.auto_pin')
|
||||
const [actionWindowOpacity] = usePreference('feature.selection.action_window_opacity')
|
||||
@@ -40,19 +59,10 @@ const SelectionActionApp: FC = () => {
|
||||
const lastScrollHeight = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const actionListenRemover = window.electron?.ipcRenderer.on(
|
||||
IpcChannel.Selection_UpdateActionData,
|
||||
(_, actionItem: SelectionActionItem) => {
|
||||
setAction(actionItem)
|
||||
isActionLoaded.current = true
|
||||
}
|
||||
)
|
||||
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
window.addEventListener('blur', handleWindowBlur)
|
||||
|
||||
return () => {
|
||||
actionListenRemover()
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
window.removeEventListener('blur', handleWindowBlur)
|
||||
}
|
||||
@@ -60,11 +70,30 @@ const SelectionActionApp: FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Per-session reset: must fire on EVERY reuse, even when the next action
|
||||
// has the same id as the previous one (e.g. two consecutive `translate`
|
||||
// invocations). The right signal is the `action` reference itself — main
|
||||
// sends a fresh IPC-deserialized object on every Reused push, so
|
||||
// `Object.is`-based effect deps change each time. Using `[action.id]` here
|
||||
// would leak stale pin/opacity/slider/scroll state across same-type reuses.
|
||||
useEffect(() => {
|
||||
setIsPinned(isAutoPin)
|
||||
void window.api.selection.pinActionWindow(isAutoPin)
|
||||
setOpacity(actionWindowOpacity)
|
||||
setShowOpacitySlider(false)
|
||||
isAutoScrollEnabled.current = true
|
||||
contentElementRef.current?.scrollTo({ top: 0 })
|
||||
lastScrollHeight.current = contentElementRef.current?.scrollHeight ?? 0
|
||||
// Only re-run on action change; `isAutoPin` / `actionWindowOpacity` are
|
||||
// handled separately by their own effects when the preference itself moves.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [action])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAutoPin) {
|
||||
void window.api.selection.pinActionWindow(true)
|
||||
setIsPinned(true)
|
||||
} else if (!isActionLoaded.current) {
|
||||
} else {
|
||||
void window.api.selection.pinActionWindow(false)
|
||||
setIsPinned(false)
|
||||
}
|
||||
@@ -93,10 +122,14 @@ const SelectionActionApp: FC = () => {
|
||||
}, [customCss])
|
||||
|
||||
useEffect(() => {
|
||||
// Register the scroll listener exactly once on mount. The content DOM node
|
||||
// does not change across pool reuses (we never unmount), and
|
||||
// `handleUserScroll` only reads from refs, so a single subscription is
|
||||
// sufficient; per-session scrollTop / lastScrollHeight reset lives in the
|
||||
// `[action]` reset effect above.
|
||||
const contentEl = contentElementRef.current
|
||||
if (contentEl) {
|
||||
contentEl.addEventListener('scroll', handleUserScroll)
|
||||
// Initialize the scroll height
|
||||
lastScrollHeight.current = contentEl.scrollHeight
|
||||
}
|
||||
return () => {
|
||||
@@ -104,21 +137,14 @@ const SelectionActionApp: FC = () => {
|
||||
contentEl.removeEventListener('scroll', handleUserScroll)
|
||||
}
|
||||
}
|
||||
//we should rely on action to trigger this effect,
|
||||
// because the contentRef is not available when action is initially null
|
||||
}, [action])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (action) {
|
||||
document.title = `${action.isBuiltIn ? t(action.name) : action.name} - ${t('selection.name')}`
|
||||
}
|
||||
}, [action, t])
|
||||
document.title = `${action.isBuiltIn ? t(action.name) : action.name} - ${t('selection.name')}`
|
||||
}, [action.id, action.isBuiltIn, action.name, t])
|
||||
|
||||
useEffect(() => {
|
||||
//if the action is loaded, we should not set the opacity update from settings
|
||||
if (!isActionLoaded.current) {
|
||||
setOpacity(actionWindowOpacity)
|
||||
}
|
||||
setOpacity(actionWindowOpacity)
|
||||
}, [actionWindowOpacity])
|
||||
|
||||
const handleMinimize = () => {
|
||||
@@ -188,9 +214,6 @@ const SelectionActionApp: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
//we don't need to render the component if action is not set
|
||||
if (!action) return null
|
||||
|
||||
return (
|
||||
<WindowFrame $opacity={opacity / 100}>
|
||||
<TitleBar $isWindowFocus={isWindowFocus} style={isMac ? { paddingLeft: '70px' } : {}}>
|
||||
|
||||
Reference in New Issue
Block a user