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:
fullex
2026-04-16 08:56:34 -07:00
parent 619b98cc4c
commit 57310618c1
2 changed files with 216 additions and 298 deletions

View File

@@ -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)
}

View File

@@ -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' } : {}}>