UNPKG

electron-findbar

Version:
412 lines (362 loc) 14.8 kB
const { BaseWindow, BrowserWindow, WebContents, BrowserWindowConstructorOptions, Rectangle } = require('electron') /** * Chrome-like findbar for Electron applications. */ class Findbar { /** @type {BaseWindow} */ #parent /** @type {BrowserWindow} */ #window /** @type {WebContents} */ #findableContents /** @type { { active: number, total: number } } */ #matches /** @type {(findbarWindow: BrowserWindow) => void} */ #windowHandler /** @type {{parentBounds: Rectangle, findbarBounds: Rectangle} => Rectangle} */ #boundsHandler = Findbar.#setDefaultPosition /** @type {BrowserWindowConstructorOptions} */ #customOptions /** @type {string} */ #lastText = '' /** @type {boolean} */ #matchCase = false /** @type {boolean} */ #isMovable = false /** * Workaround to fix "findInPage" bug - double-click to loop * @type {boolean | null} */ #fixMove = null /** * Configure the findbar and link to the web contents. * * @overload * @param {BrowserWindow} browserWindow Parent window. * @param {WebContents} [customWebContents] Custom findable web contents. If not provided, the web contents of the BrowserWindow will be used. * @returns {Findbar} The findbar instance if it exists. * * @overload * @param {BaseWindow} baseWindow Parent window. * @param {WebContents} webContents Findable web contents. * @returns {Findbar} The findbar instance if it exists. * @throws {Error} If no webContents is provided. * * * @overload * @param {WebContents} webContents Findable web contents. The parent window will be undefined. * @returns {Findbar} The findbar instance if it exists. * @throws {Error} If no webContents is provided. */ constructor (parent, webContents) { if (isFindable(parent)) { this.#parent = void 0 this.#findableContents = parent } else { this.#parent = parent this.#findableContents = webContents ?? parent.webContents } if (!this.#findableContents) { throw new Error('There are no searchable web contents.') } this.#findableContents._findbar = this } /** * Open the findbar. If the findbar is already opened, focus the input text. * @returns {void} */ open() { if (this.#window) { this.#focusWindowAndHighlightInput() return } const options = Findbar.#mergeStandardOptions(this.#customOptions, this.#parent) this.#isMovable = options.movable this.#window = new BrowserWindow(options) this.#window.webContents._findbar = this this.#registerListeners() this.#windowHandler && this.#windowHandler(this.#window) this.#window.loadFile(`${__dirname}/web/findbar.html`) } /** * Close the findbar. * @returns {void} */ close() { if (!this.#window || this.#window.isDestroyed()) { return } if (!this.#window.isVisible()) { this.#window.close(); return } this.#window.on('hide', () => { this.#window.close() }) this.#window.hide() } /** * Get the last state of the findbar. * @returns {{ text: string, matchCase: boolean, movable: boolean }} Last state of the findbar. */ getLastState() { return { text: this.#lastText, matchCase: this.#matchCase, movable: this.#isMovable } } /** * Starts a request to find all matches for the text in the page. * @param {string} text - Value to find in page. * @param {boolean} [skipRendererEvent=false] - Skip update renderer event. * @returns {void} */ startFind(text, skipRendererEvent) { skipRendererEvent || this.#window?.webContents.send('electron-findbar/text-change', text) if (this.#lastText = text) { this.isOpen() && this.#findInContent({ findNext: true }) } else { this.stopFind() } } /** * Whether the search should be case-sensitive. If not set, the search will be case-insensitive. * @param {boolean} status - Whether the search should be case-sensitive. Default is false. * @param {boolean} [skipRendererEvent=false] - Skip update renderer event. * @returns {void} */ matchCase(status, skipRendererEvent) { if (this.#matchCase === status) { return } this.#matchCase = status skipRendererEvent || this.#window?.webContents.send('electron-findbar/match-case-change', this.#matchCase) this.#stopFindInContent() this.startFind(this.#lastText, skipRendererEvent) } /** * Select previous match if any. * @returns {void} */ findPrevious() { this.#matches.active === 1 && (this.#fixMove = false) this.isOpen() && this.#findInContent({ forward: false }) } /** * Select next match if any. * @returns {void} */ findNext() { this.#matches.active === this.#matches.total && (this.#fixMove = true) this.isOpen() && this.#findInContent({ forward: true }) } /** * Stops the find request and clears selection. * @returns {void} */ stopFind() { this.isOpen() && this.#sendMatchesCount(0, 0) this.#findableContents.isDestroyed() || this.#stopFindInContent() } /** * Whether the findbar is opened. * @returns {boolean} True if the findbar is open, otherwise false. */ isOpen() { return !!this.#window } /** * Whether the findbar is focused. If the findbar is closed, false will be returned. * @returns {boolean} True if the findbar is focused, otherwise false. */ isFocused() { return !!this.#window?.isFocused() } /** * Whether the findbar is visible to the user in the foreground of the app. * If the findbar is closed, false will be returned. * @returns {boolean} True if the findbar is visible, otherwise false. */ isVisible() { return !!this.#window?.isVisible() } /** * Provides a customized set of options to findbar window before open. Note * that the options below are necessary for the correct functioning and cannot * be overridden: * - options.parent (value: parentWindow) * - options.frame (value: false) * - options.transparent (value: true) * - options.maximizable (value: false) * - options.minimizable (value: false) * - options.skipTaskbar (value: true) * - options.fullscreenable (value: false) * - options.webPreferences.nodeIntegration (value: true) * - options.webPreferences.contextIsolation (value: false) * * @param {BrowserWindowConstructorOptions} customOptions - Custom window options. * @returns {void} */ setWindowOptions(customOptions) { this.#customOptions = customOptions } /** * Set a window handler capable of changing the findbar window settings after opening. * @param {(findbarWindow: BrowserWindow) => void} windowHandler - Window handler function. * @returns {void} */ setWindowHandler(windowHandler) { this.#windowHandler = windowHandler } /** * Set a bounds handler to calculate the findbar bounds when the parent window resizes. If width and/or height are not provided, the current value will be used. * @param {(parentBounds: Rectangle, findbarBounds: Rectangle) => Rectangle} boundsHandler - Bounds handler function. * @returns {void} */ setBoundsHandler(boundsHandler) { this.#boundsHandler = boundsHandler } /** * @param {Electron.FindInPageOptions} options */ #findInContent(options) { options.matchCase = this.#matchCase this.#findableContents.findInPage(this.#lastText, options) } #stopFindInContent() { this.#findableContents.stopFindInPage('clearSelection') } /** * Register all event listeners. */ #registerListeners() { const showCascade = () => this.#window.isVisible() || this.#window.show() const hideCascade = () => this.#window.isVisible() && this.#window.hide() const boundsHandler = () => { const currentBounds = this.#window.getBounds() const newBounds = this.#boundsHandler(this.#parent.getBounds(), currentBounds) if (!newBounds.width) { newBounds.width = currentBounds.width } if (!newBounds.height) { newBounds.height = currentBounds.height } this.#window.setBounds(newBounds, false) } if (this.#parent && !this.#parent.isDestroyed()) { boundsHandler() this.#parent.prependListener('show', showCascade) this.#parent.prependListener('hide', hideCascade) this.#parent.prependListener('resize', boundsHandler) this.#parent.prependListener('move', boundsHandler) } this.#window.once('close', () => { if (this.#parent && !this.#parent.isDestroyed()) { this.#parent.off('show', showCascade) this.#parent.off('hide', hideCascade) this.#parent.off('resize', boundsHandler) this.#parent.off('move', boundsHandler) } this.#window = null this.stopFind() }) this.#window.prependOnceListener('ready-to-show', () => { this.#window.show() }) this.#findableContents.prependOnceListener('destroyed', () => { this.close() }) this.#findableContents.prependListener('found-in-page', (_e, result) => { this.#sendMatchesCount(result.activeMatchOrdinal, result.matches) }) } /** * Send to renderer the active match and the total. * @param {number} active Active match. * @param {number} total Total matches. */ #sendMatchesCount(active, total) { if (this.#fixMove !== null) { this.#fixMove ? this.findNext() : this.findPrevious() this.#fixMove = null } this.#matches = { active, total } this.#window.webContents.send('electron-findbar/matches', this.#matches) } /** * Focus the findbar and highlight the input text. */ #focusWindowAndHighlightInput() { this.#window.focus() this.#window.webContents.send('electron-findbar/input-focus') } /** * Set default findbar position. * @param {Rectangle} parentBounds * @param {Rectangle} findbarBounds * @returns {x: number, y: number} position. */ static #setDefaultPosition(parentBounds, findbarBounds) { return { x: parentBounds.x + parentBounds.width - findbarBounds.width - 20, y: parentBounds.y - ((findbarBounds.height / 4) | 0) } } /** * Merge custom, defaults, and fixed options. * @param {Electron.BrowserWindowConstructorOptions} options Custom options. * @param {BaseWindow | void} parent Parent window, if any. * @returns {Electron.BrowserWindowConstructorOptions} Merged options. */ static #mergeStandardOptions(options, parent) { if (!options) { options = {} } options.width = options.width ?? 372 options.height = options.height ?? 52 options.resizable = options.resizable ?? false options.movable = options.movable ?? false options.acceptFirstMouse = options.acceptFirstMouse ?? true options.parent = parent options.show = false options.frame = false options.roundedCorners = true options.transparent = process.platform === 'linux' options.maximizable = false options.minimizable = false options.skipTaskbar = true options.fullscreenable = false options.autoHideMenuBar = true if (!options.webPreferences) { options.webPreferences = {} } options.webPreferences.nodeIntegration = false options.webPreferences.contextIsolation = true options.webPreferences.preload = options.webPreferences.preload ?? `${__dirname}/web/preload.js` return options } /** * Get the findbar instance for a given BrowserWindow or WebContents. * If no findbar instance exists, it will return a new one linked to the web contents. * * @overload * @param {BrowserWindow} browserWindow Parent window. * @param {WebContents} [customWebContents] Custom findable web contents. If not provided, the web contents of the BrowserWindow will be used. * @returns {Findbar} The findbar instance if it exists. * * @overload * @param {WebContents} webContents Findable web contents. The parent window will be undefined. * @returns {Findbar} The findbar instance if it exists. * @throws {Error} If no webContents is provided. * * @overload * @param {BaseWindow} baseWindow Parent window. * @param {WebContents} webContents Findable web contents. * @returns {Findbar} The findbar instance if it exists. * @throws {Error} If no webContents is provided. */ static from(windowOrWebContents, customWebContents) { let webContents = isFindable(windowOrWebContents) ? windowOrWebContents : customWebContents ?? windowOrWebContents.webContents return webContents._findbar || new Findbar(windowOrWebContents, customWebContents) } /** * Get the findbar instance for a given BrowserWindow or WebContents. * @param {BrowserWindow | WebContents} windowOrWebContents * @returns {Findbar | undefined} The findbar instance if it exists, otherwise undefined. */ static fromIfExists(windowOrWebContents) { return (isFindable(windowOrWebContents) ? windowOrWebContents : windowOrWebContents.webContents)._findbar } } const isFindable = (obj) => obj && typeof obj.findInPage === 'function' && typeof obj.stopFindInPage === 'function'; /** * Define IPC events. */ (ipc => { ipc.handle('electron-findbar/last-state', e => e.sender._findbar.getLastState()) ipc.on('electron-findbar/input-change', (e, text, skip) => e.sender._findbar.startFind(text, skip)) ipc.on('electron-findbar/match-case', (e, status, skip) => e.sender._findbar.matchCase(status, skip)) ipc.on('electron-findbar/previous', e => e.sender._findbar.findPrevious()) ipc.on('electron-findbar/next', e => e.sender._findbar.findNext()) ipc.on('electron-findbar/open', e => e.sender._findbar.open()) ipc.on('electron-findbar/close', e => { const findbar = e.sender._findbar findbar.stopFind() findbar.close() }) }) (require('electron').ipcMain) module.exports = Findbar