From ac2b92bb8c68df8769fbbdc8fda85760aa91e7c1 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Fri, 6 Dec 2024 06:24:05 +0100 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9C=A8=20find=20in=20page=20(global=20ke?= =?UTF-8?q?yboard=20shortcuts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Nuri --- .../__tests__/keyboard-shortcuts.test.js | 21 ++++++++++++------- src/base-window/keyboard-shortcuts.js | 7 +++++++ src/tab-manager/preload.keyboard-shortcuts.js | 4 ---- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/base-window/__tests__/keyboard-shortcuts.test.js b/src/base-window/__tests__/keyboard-shortcuts.test.js index e01f1229..73595baa 100644 --- a/src/base-window/__tests__/keyboard-shortcuts.test.js +++ b/src/base-window/__tests__/keyboard-shortcuts.test.js @@ -28,14 +28,19 @@ describe('Main :: Global Keyboard Shortcuts module test suite', () => { }; }); test.each([ - {key: 'Escape', shift: false, control: false, appEvent: 'appMenuClose'}, - {key: 'Escape', shift: false, control: false, appEvent: 'closeDialog'}, - {key: 'F11', shift: false, control: false, appEvent: 'fullscreenToggle'}, - {key: 'Tab', shift: false, control: true, appEvent: 'tabTraverseNext'}, - {key: 'Tab', shift: true, control: true, appEvent: 'tabTraversePrevious'} - ])('Key "$key" (shift: $shift, ctrl: $control) triggers "$appEvent" app event', - ({key, control, shift, appEvent}) => { - view.listeners['before-input-event'](inputEvent, {key, control, shift}); + {key: 'Escape', shift: false, control: false, meta: false, appEvent: 'appMenuClose'}, + {key: 'Escape', shift: false, control: false, meta: false, appEvent: 'closeDialog'}, + {key: 'Escape', shift: false, control: false, meta: false, appEvent: 'findInPageClose'}, + {key: 'F11', shift: false, control: false, meta: false, appEvent: 'fullscreenToggle'}, + {key: 'Tab', shift: false, control: true, meta: false, appEvent: 'tabTraverseNext'}, + {key: 'Tab', shift: true, control: true, meta: false, appEvent: 'tabTraversePrevious'}, + {key: 'f', shift: false, control: true, meta: false, appEvent: 'findInPageOpen'}, + {key: 'F', shift: false, control: true, meta: false, appEvent: 'findInPageOpen'}, + {key: 'f', shift: false, control: false, meta: true, appEvent: 'findInPageOpen'}, + {key: 'F', shift: false, control: false, meta: true, appEvent: 'findInPageOpen'} + ])('Key "$key" (shift: $shift, ctrl: $control, meta: $meta) triggers "$appEvent" app event', + ({key, shift, control, meta, appEvent}) => { + view.listeners['before-input-event'](inputEvent, {key, control, shift, meta}); expect(electron.ipcMain.emit).toHaveBeenCalledWith(appEvent); }); describe.each([1, 2, 3, 4, 5, 6, 7, 8, 9])('Key "%s"', key => { diff --git a/src/base-window/keyboard-shortcuts.js b/src/base-window/keyboard-shortcuts.js index 25c298b9..aeacbefd 100644 --- a/src/base-window/keyboard-shortcuts.js +++ b/src/base-window/keyboard-shortcuts.js @@ -34,6 +34,7 @@ const EVENTS = new Map(); EVENTS.set(eventKey({key: 'Escape'}), eventAction(() => { eventBus.emit(APP_EVENTS.appMenuClose); eventBus.emit(APP_EVENTS.closeDialog); + eventBus.emit(APP_EVENTS.findInPageClose); }, {preventDefault: false})); EVENTS.set(eventKey({key: 'F11'}), eventAction(() => eventBus.emit(APP_EVENTS.fullscreenToggle))); @@ -51,6 +52,12 @@ EVENTS.set(eventKey({key: 'Tab', control: true}), eventAction(() => EVENTS.set(eventKey({key: 'Tab', shift: true, control: true}), eventAction(() => eventBus.emit(APP_EVENTS.tabTraversePrevious))); +const findInPageOpen = eventAction(() => eventBus.emit(APP_EVENTS.findInPageOpen)); +EVENTS.set(eventKey({key: 'f', meta: true}), findInPageOpen); +EVENTS.set(eventKey({key: 'F', meta: true}), findInPageOpen); +EVENTS.set(eventKey({key: 'f', control: true}), findInPageOpen); +EVENTS.set(eventKey({key: 'F', control: true}), findInPageOpen); + const registerAppShortcuts = (_, webContents) => { webContents.on('before-input-event', (event, {type, key, shift, control, alt, meta}) => { if (type === 'keyUp') { diff --git a/src/tab-manager/preload.keyboard-shortcuts.js b/src/tab-manager/preload.keyboard-shortcuts.js index bc8b5756..f9dc4d75 100644 --- a/src/tab-manager/preload.keyboard-shortcuts.js +++ b/src/tab-manager/preload.keyboard-shortcuts.js @@ -26,10 +26,6 @@ EVENTS.set(eventKey({key: 'R', ctrlKey: true}), () => ipcRenderer.send(APP_EVENT EVENTS.set(eventKey({key: 'r', ctrlKey: true}), () => ipcRenderer.send(APP_EVENTS.reload)); EVENTS.set(eventKey({key: 'R', metaKey: true}), () => ipcRenderer.send(APP_EVENTS.reload)); EVENTS.set(eventKey({key: 'r', metaKey: true}), () => ipcRenderer.send(APP_EVENTS.reload)); -EVENTS.set(eventKey({key: 'f', metaKey: true}), () => ipcRenderer.send(APP_EVENTS.findInPageOpenWindow)); -EVENTS.set(eventKey({key: 'F', metaKey: true}), () => ipcRenderer.send(APP_EVENTS.findInPageOpenWindow)); -EVENTS.set(eventKey({key: 'f', ctrlKey: true}), () => ipcRenderer.send(APP_EVENTS.findInPageOpenWindow)); -EVENTS.set(eventKey({key: 'F', ctrlKey: true}), () => ipcRenderer.send(APP_EVENTS.findInPageOpenWindow)); EVENTS.set(eventKey({key: '+', ctrlKey: true}), () => ipcRenderer.send(APP_EVENTS.zoomIn)); EVENTS.set(eventKey({key: '-', ctrlKey: true}), () => ipcRenderer.send(APP_EVENTS.zoomOut)); EVENTS.set(eventKey({key: '0', ctrlKey: true}), () => ipcRenderer.send(APP_EVENTS.zoomReset)); From d09e3e79dc0e9e6a945d2898f3581dfe0ea8dfba Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Sat, 7 Dec 2024 07:15:26 +0100 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8=20find=20in=20page=20(main)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Nuri --- src/main/__tests__/global-listeners.test.js | 40 ++++++++++++++++----- src/main/__tests__/index.test.js | 2 +- src/main/__tests__/tab-listeners.test.js | 9 ----- src/main/index.js | 37 ++++++++++++------- 4 files changed, 58 insertions(+), 30 deletions(-) diff --git a/src/main/__tests__/global-listeners.test.js b/src/main/__tests__/global-listeners.test.js index 930bdb00..e5f8a330 100644 --- a/src/main/__tests__/global-listeners.test.js +++ b/src/main/__tests__/global-listeners.test.js @@ -20,24 +20,44 @@ describe('Main :: Global listeners test suite', () => { let electron; let main; let baseWindow; + let webContentsViewInstances; let eventBus; beforeEach(() => { jest.resetModules(); + jest.mock('electron', () => require('../../__tests__').mockElectronInstance()); + electron = require('electron'); + baseWindow = electron.baseWindowInstance; + webContentsViewInstances = []; + // Each view should be a separate instance + electron.WebContentsView = jest.fn(() => { + const view = require('../../__tests__').mockWebContentsViewInstance(); + webContentsViewInstances.push(view); + return view; + }); + eventBus = electron.ipcMain; // Always mock settings unless we want to overwrite the real settings file ! jest.mock('../../settings'); require('../../settings').loadSettings.mockImplementation(() => ({ trayEnabled: true })); require('../../settings').openSettingsDialog = jest.requireActual('../../settings').openSettingsDialog; - jest.mock('electron', () => require('../../__tests__').mockElectronInstance()); - electron = require('electron'); - baseWindow = electron.baseWindowInstance; - eventBus = electron.ipcMain; jest.spyOn(require('../../user-agent'), 'initBrowserVersions') .mockImplementation(() => Promise.resolve({})); main = require('../'); main.init(); }); + test.each([ + 'aboutOpenDialog', 'appMenuOpen', 'appMenuClose', 'closeDialog', + 'dictionaryGetAvailable', 'dictionaryGetAvailableNative', 'dictionaryGetEnabled', + 'findInPage', 'findInPageOpen', 'findInPageClose', + 'fullscreenToggle', 'helpOpenDialog', 'quit', 'restore', + 'settingsLoad', 'settingsOpenDialog', 'settingsSave', + 'tabSwitchToPosition', 'tabTraverseNext', 'tabTraversePrevious', + 'trayInit' + ])('should register listener for %s', channel => { + // Then + expect(eventBus.listeners).toHaveProperty(channel); + }); test('appMenuOpen, should show and resize app-menu', () => { // When eventBus.listeners.appMenuOpen(); @@ -45,6 +65,9 @@ describe('Main :: Global listeners test suite', () => { expect(baseWindow.contentView.addChildView).toHaveBeenCalledWith( expect.objectContaining({isAppMenu: true}) ); + expect(baseWindow.contentView.addChildView).not.toHaveBeenCalledWith( + expect.objectContaining({isFindInPage: true}) + ); expect(baseWindow.contentView.addChildView.mock.calls[0][0].setBounds) .toHaveBeenCalledWith(expect.objectContaining({ x: 0, y: 0 @@ -151,8 +174,8 @@ describe('Main :: Global listeners test suite', () => { // When eventBus.listeners.settingsOpenDialog(); // Then - const view = electron.WebContentsView.mock.results - .map(r => r.value).filter(bv => bv.webContents.loadedUrl.endsWith('settings/index.html'))[0]; + const view = webContentsViewInstances + .filter(wcv => wcv.webContents.loadedUrl.endsWith('settings/index.html'))[0]; expect(baseWindow.contentView.addChildView).toHaveBeenCalledWith(view); expect(view.webContents.loadURL) .toHaveBeenCalledWith(expect.stringMatching(/settings\/index.html$/)); @@ -172,11 +195,12 @@ describe('Main :: Global listeners test suite', () => { expect(settingsModule.updateSettings).toHaveBeenCalledTimes(1); }); test('should reload fake dictionary renderer', () => { - // Given // When eventBus.listeners.settingsSave({}, {tabs: [{id: 1337}], enabledDictionaries: []}); // Then - expect(electron.webContentsViewInstance.webContents.loadURL) + const view = webContentsViewInstances + .filter(wcv => wcv.webContents.loadedUrl.endsWith('spell-check/dictionary.renderer/index.html'))[0]; + expect(view.webContents.loadURL) .toHaveBeenCalledWith(expect.stringMatching(/spell-check\/dictionary.renderer\/index.html$/)); }); test('should reset all views', () => { diff --git a/src/main/__tests__/index.test.js b/src/main/__tests__/index.test.js index 30198768..ca1133cc 100644 --- a/src/main/__tests__/index.test.js +++ b/src/main/__tests__/index.test.js @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -describe('Main module test suite', () => { +describe('Main :: Index module test suite', () => { let mockNotification; let electron; let mockBaseWindow; diff --git a/src/main/__tests__/tab-listeners.test.js b/src/main/__tests__/tab-listeners.test.js index 97d8bd33..b0f4aa4f 100644 --- a/src/main/__tests__/tab-listeners.test.js +++ b/src/main/__tests__/tab-listeners.test.js @@ -160,15 +160,6 @@ describe('Main :: Tab listeners test suite', () => { expect(mockBaseWindow.show).toHaveBeenCalledAfter(mockBaseWindow.restore); expect(tabManagerModule.getTab).toHaveBeenCalledWith('validId'); }); - test('findInPageOpenWindow, should loopback to the view', () => { - // Given - const event = {sender: {send: jest.fn()}}; - main.init(); - // When - mockIpc.listeners.findInPageOpenWindow(event); - // Then - expect(event.sender.send).toHaveBeenCalledWith('findInPageOpenWindow'); - }); test('handleReload', () => { const event = {sender: {reloadIgnoringCache: jest.fn()}}; main.init(); diff --git a/src/main/index.js b/src/main/index.js index 27689ef9..be5c5ede 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -22,6 +22,9 @@ const {openAboutDialog} = require('../about'); const {newAppMenu, isNotAppMenu} = require('../app-menu'); const {findDialog} = require('../base-window'); const {TABS_CONTAINER_HEIGHT, newTabContainer, isNotTabContainer} = require('../chrome-tabs'); +const { + FIND_IN_PAGE_HEIGHT, FIND_IN_PAGE_WIDTH, isFindInPage, isNotFindInPage, findInPage, findInPageOpen, findInPageClose +} = require('../find-in-page'); const {openHelpDialog} = require('../help'); const {getPlatform, loadSettings, updateSettings, openSettingsDialog} = require('../settings'); const { @@ -49,6 +52,7 @@ const fixUserDataLocation = () => { }; const resetMainWindow = () => { + eventBus.emit(APP_EVENTS.findInPageClose); const currentViews = mainWindow.contentView.children; currentViews.filter(isNotTabContainer).forEach(view => mainWindow.contentView.removeChildView(view)); if (mainWindow.contentView.children.length === 0) { @@ -80,17 +84,25 @@ const handleMainWindowResize = () => { if (appMenu?.setBounds ?? false) { appMenu.setBounds({x: 0, y: 0, width: contentWidth, height: contentHeight}); } + mainWindow.contentView.children.filter(isFindInPage).forEach(view => { + view.setBounds({ + x: contentWidth - FIND_IN_PAGE_WIDTH, y: 0, width: FIND_IN_PAGE_WIDTH, height: FIND_IN_PAGE_HEIGHT + }); + }); let totalHeight = 0; const isLast = (idx, array) => idx === array.length - 1; - mainWindow.contentView.children.filter(isNotAppMenu).forEach((bv, idx, array) => { - const {x: currentX, y: currentY, height: currentHeight} = bv.getBounds(); - let newHeight = currentHeight; - if (isLast(idx, array)) { - newHeight = contentHeight - totalHeight; - } - bv.setBounds({x: currentX, y: currentY, width: contentWidth, height: newHeight}); - totalHeight += currentHeight; - }); + mainWindow.contentView.children + .filter(isNotAppMenu) + .filter(isNotFindInPage) + .forEach((bv, idx, array) => { + const {x: currentX, y: currentY, height: currentHeight} = bv.getBounds(); + let newHeight = currentHeight; + if (isLast(idx, array)) { + newHeight = contentHeight - totalHeight; + } + bv.setBounds({x: currentX, y: currentY, width: contentWidth, height: newHeight}); + totalHeight += currentHeight; + }); const dialogView = findDialog(mainWindow); if (dialogView) { dialogView.setBounds({x: 0, y: 0, width: contentWidth, height: contentHeight}); @@ -165,9 +177,6 @@ const initTabListener = () => { }); eventBus.on(APP_EVENTS.reload, handleTabReload); eventBus.on(APP_EVENTS.tabReorder, handleTabReorder); - // findInPageOpenWindow is just a loopback from preload.keyboard-shortcuts to preload.find-in-page - eventBus.on(APP_EVENTS.findInPageOpenWindow, - event => event.sender.send(APP_EVENTS.findInPageOpenWindow)); eventBus.on(APP_EVENTS.zoomIn, handleZoomIn); eventBus.on(APP_EVENTS.zoomOut, handleZoomOut); eventBus.on(APP_EVENTS.zoomReset, handleZoomReset); @@ -225,6 +234,7 @@ const saveSettings = (_event, settings) => { nativeTheme.themeSource = settings.theme; closeDialog(); appMenuClose(); + findInPageClose(); mainWindow.contentView.children.forEach(view => { mainWindow.contentView.removeChildView(view); view.webContents.destroy(); @@ -242,6 +252,9 @@ const initGlobalListeners = () => { eventBus.handle(APP_EVENTS.dictionaryGetAvailable, getAvailableDictionaries); eventBus.handle(APP_EVENTS.dictionaryGetAvailableNative, getAvailableNativeDictionaries); eventBus.handle(APP_EVENTS.dictionaryGetEnabled, getEnabledDictionaries); + eventBus.on(APP_EVENTS.findInPage, findInPage(mainWindow)); + eventBus.on(APP_EVENTS.findInPageOpen, findInPageOpen(mainWindow)); + eventBus.on(APP_EVENTS.findInPageClose, findInPageClose(mainWindow)); eventBus.on(APP_EVENTS.fullscreenToggle, fullscreenToggle); eventBus.on(APP_EVENTS.helpOpenDialog, openHelpDialog(mainWindow)); eventBus.on(APP_EVENTS.quit, app.exit); From dc8fdf478f85725b58b28da01144a94953720482 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Sun, 8 Dec 2024 06:21:00 +0100 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9C=A8=20find=20in=20page=20(webpack)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Nuri --- esm/preact.all.mjs | 4 ++-- webpack.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esm/preact.all.mjs b/esm/preact.all.mjs index df2e631e..62241184 100644 --- a/esm/preact.all.mjs +++ b/esm/preact.all.mjs @@ -13,8 +13,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {h, render} from 'preact'; -export {h, render}; +import {h, render, createRef} from 'preact'; +export {h, render, createRef}; export {useLayoutEffect, useReducer, useState} from 'preact/hooks'; import htm from 'htm'; export const html = htm.bind(h); diff --git a/webpack.js b/webpack.js index 93570694..1f3f510e 100755 --- a/webpack.js +++ b/webpack.js @@ -28,6 +28,7 @@ const PRELOAD_ENTRIES = [ 'about', 'app-menu', 'chrome-tabs', + 'find-in-page', 'help', 'settings', 'tab-manager' From c9076fefe0cf9864b7dd5d3b02c9e67355f6892c Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Sun, 8 Dec 2024 06:21:12 +0100 Subject: [PATCH 4/9] =?UTF-8?q?=E2=9C=A8=20find=20in=20page=20(constants)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Nuri --- src/constants/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/constants/index.js b/src/constants/index.js index 03745e77..63f18f1f 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -39,7 +39,11 @@ const APP_EVENTS = { dictionaryGetEnabled: 'dictionaryGetEnabled', dictionaryGetMisspelled: 'dictionaryGetMisspelled', electronimNewVersionAvailable: 'electronimNewVersionAvailable', - findInPageOpenWindow: 'findInPageOpenWindow', + findInPage: 'findInPage', + findInPageFound: 'findInPageFound', + findInPageStop: 'findInPageStop', + findInPageClose: 'findInPageClose', + findInPageOpen: 'findInPageOpen', fullscreenToggle: 'fullscreenToggle', helpOpenDialog: 'helpOpenDialog', notificationClick: 'notificationClick', From d1f48e8f125ca1203ec28693f96385f291afcda3 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Sun, 8 Dec 2024 06:21:35 +0100 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=A8=20find=20in=20page=20(components)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Nuri --- src/components/icon.mjs | 2 ++ src/components/index.mjs | 2 +- src/components/text-field.mjs | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/icon.mjs b/src/components/icon.mjs index dbab4698..047137bb 100644 --- a/src/components/icon.mjs +++ b/src/components/icon.mjs @@ -44,6 +44,8 @@ Icon.expandMore = '\ue5cf'; Icon.help = '\ue887'; Icon.inbox = '\ue156'; Icon.info = '\ue88e'; +Icon.keyboardArrowDown = '\ue313'; +Icon.keyboardArrowUp = '\ue316'; Icon.lock = '\ue88d'; Icon.lockOpen = '\ue898'; Icon.minimize = '\ue931'; diff --git a/src/components/index.mjs b/src/components/index.mjs index 2c6ffeb9..76424c1e 100644 --- a/src/components/index.mjs +++ b/src/components/index.mjs @@ -14,7 +14,7 @@ limitations under the License. */ export {APP_EVENTS, CLOSE_BUTTON_BEHAVIORS, ELECTRONIM_VERSION} from '../../bundles/constants.mjs'; -export {html, render, useLayoutEffect, useReducer, useState} from '../../bundles/preact.mjs'; +export {createRef, html, render, useLayoutEffect, useReducer, useState} from '../../bundles/preact.mjs'; export {Card} from './card.mjs'; export {Checkbox} from './checkbox.mjs'; diff --git a/src/components/text-field.mjs b/src/components/text-field.mjs index da8cabbf..6b21a2a8 100644 --- a/src/components/text-field.mjs +++ b/src/components/text-field.mjs @@ -33,6 +33,7 @@ export const TextField = ({ onInput, onKeyDown, value, + inputProps = {}, hasError = false /* eslint-disable-next-line no-warning-comments */ // TODO disabled state @@ -64,6 +65,7 @@ export const TextField = ({ class='text-field__input' onFocus=${onFocus} onBlur=${onBlur} value=${value} onInput=${onInput} onKeyDown=${onKeyDown} + ...${inputProps} /> `; From 5152417c28e63edb07d46c06d44b6f7e76210c2b Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Sun, 8 Dec 2024 06:22:41 +0100 Subject: [PATCH 6/9] =?UTF-8?q?=E2=9C=A8=20find=20in=20page=20(tab-manager?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Nuri --- src/tab-manager/__tests__/preload.test.js | 2 -- src/tab-manager/index.js | 7 ++++++- src/tab-manager/preload.js | 1 - 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/tab-manager/__tests__/preload.test.js b/src/tab-manager/__tests__/preload.test.js index cdf559d6..dc2d18aa 100644 --- a/src/tab-manager/__tests__/preload.test.js +++ b/src/tab-manager/__tests__/preload.test.js @@ -32,7 +32,6 @@ describe('Tab Manager Module preload test suite', () => { }); describe('preload', () => { beforeEach(() => { - jest.spyOn(require('../preload.find-in-page'), 'initFindInPage'); jest.spyOn(require('../preload.keyboard-shortcuts'), 'initKeyboardShortcuts'); jest.spyOn(require('../preload.spell-check'), 'initSpellChecker'); }); @@ -42,7 +41,6 @@ describe('Tab Manager Module preload test suite', () => { // Then expect(window.Notification).toEqual(expect.any(Function)); expect(window.navigator.mediaDevices.getDisplayMedia).toEqual(expect.any(Function)); - expect(require('../preload.find-in-page').initFindInPage).toHaveBeenCalledTimes(1); expect(require('../preload.keyboard-shortcuts').initKeyboardShortcuts).toHaveBeenCalledTimes(1); await waitFor(() => expect(mockElectron.webFrame.setSpellCheckProvider).toHaveBeenCalledTimes(1)); expect(require('../preload.spell-check').initSpellChecker).toHaveBeenCalledTimes(1); diff --git a/src/tab-manager/index.js b/src/tab-manager/index.js index f71320d4..6d0f40ce 100644 --- a/src/tab-manager/index.js +++ b/src/tab-manager/index.js @@ -175,6 +175,11 @@ const removeAll = () => { const reload = () => Object.values(tabs).forEach(view => view.webContents.reload()); +const stopFindInPage = () => Object.values(tabs).forEach(view => { + view.webContents.stopFindInPage('clearSelection'); + view.webContents.removeAllListeners('found-in-page'); +}); + const canNotify = tabId => { const {tabs: tabsSettings, disableNotificationsGlobally} = loadSettings(); const currentTab = tabsSettings.find(tab => tab.id === tabId); @@ -186,5 +191,5 @@ const canNotify = tabId => { module.exports = { addTabs, sortTabs, getTab, getTabAt, getActiveTab, setActiveTab, getNextTab, getPreviousTab, - canNotify, reload, removeAll + canNotify, reload, removeAll, stopFindInPage }; diff --git a/src/tab-manager/preload.js b/src/tab-manager/preload.js index 3a60cdc9..b69f4087 100644 --- a/src/tab-manager/preload.js +++ b/src/tab-manager/preload.js @@ -14,7 +14,6 @@ limitations under the License. */ require('./preload.notification-shim'); -require('./preload.find-in-page').initFindInPage(); require('./preload.mediadevices-shim'); require('./preload.keyboard-shortcuts').initKeyboardShortcuts(); require('./preload.spell-check').initSpellChecker() From d9161bb7f3caec0fadf0fdfc0559ef61baa8ab69 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Sun, 8 Dec 2024 07:36:45 +0100 Subject: [PATCH 7/9] =?UTF-8?q?=E2=9C=A8=20find=20in=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Nuri --- README.md | 3 +- build-config/electronim.nuspec | 1 + docs/Keyboard-shortcuts.md | 1 + src/__tests__/electron.js | 7 + .../__tests__/find-in-page.browser.test.mjs | 110 +++++++++ .../__tests__/index.html.test.mjs | 34 +++ src/find-in-page/__tests__/index.test.js | 218 ++++++++++++++++++ src/find-in-page/__tests__/preload.test.js | 69 ++++++ src/find-in-page/find-in-page.browser.css | 66 ++++++ src/find-in-page/find-in-page.browser.mjs | 71 ++++++ src/find-in-page/index.html | 29 +++ src/find-in-page/index.js | 107 +++++++++ .../preload.js} | 16 +- src/main/__tests__/index.test.js | 13 ++ 14 files changed, 734 insertions(+), 11 deletions(-) create mode 100644 src/find-in-page/__tests__/find-in-page.browser.test.mjs create mode 100644 src/find-in-page/__tests__/index.html.test.mjs create mode 100644 src/find-in-page/__tests__/index.test.js create mode 100644 src/find-in-page/__tests__/preload.test.js create mode 100644 src/find-in-page/find-in-page.browser.css create mode 100644 src/find-in-page/find-in-page.browser.mjs create mode 100644 src/find-in-page/index.html create mode 100644 src/find-in-page/index.js rename src/{tab-manager/preload.find-in-page.js => find-in-page/preload.js} (66%) diff --git a/README.md b/README.md index e806c16d..92b0fbe8 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,8 @@ electronim - ⌨ Keyboard [shortcuts](docs/Keyboard-shortcuts.md) - 🖥️ Screen sharing - 🌗 Light and Dark themes with system override -- 🗕 System Tray +- 🗕 System Tray +- 🔎 Find in page ## [Screenshot](docs/Screenshots.md) diff --git a/build-config/electronim.nuspec b/build-config/electronim.nuspec index a04fb7af..1af1aa5c 100644 --- a/build-config/electronim.nuspec +++ b/build-config/electronim.nuspec @@ -38,6 +38,7 @@ Improve your productivity by combining all your instant messaging applications ( - 🖥️ Screen sharing - 🌗 Light and Dark themes with system override - 🗕 System Tray +- 🔎 Find in page Check the GitHub repository (https://github.com/manusa/electronim) for documentation and source code. diff --git a/docs/Keyboard-shortcuts.md b/docs/Keyboard-shortcuts.md index 53893c8d..e7edd016 100644 --- a/docs/Keyboard-shortcuts.md +++ b/docs/Keyboard-shortcuts.md @@ -4,6 +4,7 @@ |------------------------------------------------------------|--------------------------------| | `F11` | Toggle full screen. | | `Ctrl+r` `Cmd+r` `F5` | Reload current tab. | +| `Ctrl+f` `Cmd+f` | Find in active tab. | | `Ctrl++` `Cmd++`
`Ctrl+ScrollUp` `Cmd+ScrollUp` | Zoom in. | | `Ctrl+-` `Cmd+-`
`Ctrl+ScrollDown` `Cmd+ScrollDown` | Zoom out. | | `Ctrl+0` `Cmd+0` | Reset zoom. | diff --git a/src/__tests__/electron.js b/src/__tests__/electron.js index 9e85be32..b8b8ac66 100644 --- a/src/__tests__/electron.js +++ b/src/__tests__/electron.js @@ -28,8 +28,13 @@ const mockWebContentsViewInstance = () => { cut: jest.fn(), destroy: jest.fn(), executeJavaScript: jest.fn(async () => {}), + // https://www.electronjs.org/docs/latest/api/web-contents#contentsfindinpagetext-options + // contents.findInPage(text[, options]) + findInPage: jest.fn(), focus: jest.fn(), getURL: jest.fn(), + // https://nodejs.org/api/events.html#emitterlistenerseventname + listeners: jest.fn(eventName => instance.listeners[eventName] || []), loadURL: jest.fn(url => { instance.webContents.loadedUrl = url; }), @@ -40,9 +45,11 @@ const mockWebContentsViewInstance = () => { openDevTools: jest.fn(), paste: jest.fn(), reload: jest.fn(), + removeAllListeners: jest.fn(), send: jest.fn(), session: {}, setWindowOpenHandler: jest.fn(), + stopFindInPage: jest.fn(), userAgent: 'Mozilla/5.0 (X11; Fedora; Linux x86_64) AppleWebKit/1337.36 (KHTML, like Gecko) ElectronIM/13.337.0 Chrome/WillBeReplacedByLatestChromium Electron/0.0.99 Safari/537.36' } }; diff --git a/src/find-in-page/__tests__/find-in-page.browser.test.mjs b/src/find-in-page/__tests__/find-in-page.browser.test.mjs new file mode 100644 index 00000000..98806afd --- /dev/null +++ b/src/find-in-page/__tests__/find-in-page.browser.test.mjs @@ -0,0 +1,110 @@ +/* + Copyright 2024 Marc Nuri San Felix + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import {jest} from '@jest/globals'; +import {loadDOM} from '../../__tests__/index.mjs'; +import {getByTestId, fireEvent, waitFor} from '@testing-library/dom'; + +describe('Find in Page :: in browser test suite', () => { + let onFindInPageCallback; + beforeEach(async () => { + jest.resetModules(); + window.electron = { + close: jest.fn(), + findInPage: jest.fn(), + onFindInPage: jest.fn(callback => { + onFindInPageCallback = callback; + }) + }; + await loadDOM({meta: import.meta, path: ['..', 'index.html']}); + }); + describe.each([ + {testId: 'find-previous', icon: '\ue316', title: 'Previous', expectedFunction: 'findInPage'}, + {testId: 'find-next', icon: '\ue313', title: 'Next', expectedFunction: 'findInPage'}, + {testId: 'close', icon: '\ue5cd', title: 'Close', expectedFunction: 'close'} + ])('Has Icon button $testId entry', ({testId, icon, title, expectedFunction}) => { + let $iconButton; + beforeEach(() => { + $iconButton = getByTestId(document, testId); + }); + test(`should have icon ${icon}`, () => { + expect($iconButton.textContent).toBe(icon); + }); + test(`should have title ${icon}`, () => { + expect($iconButton.getAttribute('title')).toBe(title); + }); + test('click, should invoke function', () => { + // When + fireEvent.click($iconButton); + // Then + expect(window.electron[expectedFunction]).toHaveBeenCalledTimes(1); + }); + }); + describe('Input field', () => { + let $input; + beforeEach(() => { + $input = document.querySelector('.input-wrapper input'); + }); + test('should be focused', () => { + expect(document.activeElement).toBe($input); + }); + test('should call findInPage on Enter', () => { + // Given + $input.value = 'test'; + // When + fireEvent.keyDown($input, {key: 'Enter'}); + // Then + expect(window.electron.findInPage).toHaveBeenCalledTimes(1); + expect(window.electron.findInPage).toHaveBeenCalledWith({text: 'test'}); + }); + test('should close on Escape', () => { + // When + fireEvent.keyDown($input, {key: 'Escape'}); + // Then + expect(window.electron.close).toHaveBeenCalledTimes(1); + }); + test('should not call findInPage on other keys', () => { + // When + fireEvent.keyDown($input, {key: 'a'}); + // Then + expect(window.electron.findInPage).not.toHaveBeenCalled(); + }); + }); + describe('Results', () => { + let $results; + beforeEach(() => { + $results = document.querySelector('.results'); + }); + test('should be hidden when no matches', () => { + // Then + expect($results.style.visibility).toBe('hidden'); + }); + test('should be visible when matches', async () => { + // Given + onFindInPageCallback(null, {matches: 1}); + // Then + await waitFor(() => expect($results.style.visibility).toBe('visible')); + }); + test('should show active match ordinal and total matches', async () => { + // Given + onFindInPageCallback(null, {matches: 2, activeMatchOrdinal: 1}); + // Then + await waitFor(() => expect($results.textContent).toBe('1/2')); + }); + test('should set focus on input', () => { + expect(document.activeElement).toBe(document.querySelector('.input-wrapper input')); + }); + }); +}); diff --git a/src/find-in-page/__tests__/index.html.test.mjs b/src/find-in-page/__tests__/index.html.test.mjs new file mode 100644 index 00000000..1c182f50 --- /dev/null +++ b/src/find-in-page/__tests__/index.html.test.mjs @@ -0,0 +1,34 @@ +/* + Copyright 2024 Marc Nuri San Felix + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import {jest} from '@jest/globals'; +import {loadDOM} from '../../__tests__/index.mjs'; + +describe('Find in Page :: index.html test suite', () => { + beforeEach(async () => { + jest.resetModules(); + window.electron = { + close: jest.fn(), + findInPage: jest.fn(), + onFindInPage: jest.fn() + }; + await loadDOM({meta: import.meta, path: ['..', 'index.html']}); + }); + test('loads required styles', () => { + expect(Array.from(document.querySelectorAll('link[rel=stylesheet]')) + .map(link => link.getAttribute('href'))) + .toEqual(['./find-in-page.browser.css']); + }); +}); diff --git a/src/find-in-page/__tests__/index.test.js b/src/find-in-page/__tests__/index.test.js new file mode 100644 index 00000000..c6881d22 --- /dev/null +++ b/src/find-in-page/__tests__/index.test.js @@ -0,0 +1,218 @@ +/** + * @jest-environment node + */ +/* + Copyright 2024 Marc Nuri San Felix + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +describe('Find in Page :: main test suite', () => { + let electron; + let main; + let baseWindow; + let webContentsViewInstances; + let eventBus; + beforeEach(() => { + jest.resetModules(); + jest.mock('electron', () => require('../../__tests__').mockElectronInstance()); + electron = require('electron'); + baseWindow = electron.baseWindowInstance; + webContentsViewInstances = []; + // Each view should be a separate instance + electron.WebContentsView = jest.fn(() => { + const view = require('../../__tests__').mockWebContentsViewInstance(); + webContentsViewInstances.push(view); + return view; + }); + eventBus = electron.ipcMain; + // Always mock settings unless we want to overwrite the real settings file ! + jest.mock('../../settings'); + require('../../settings').loadSettings.mockImplementation(() => ({ + trayEnabled: true + })); + require('../../settings').openSettingsDialog = jest.requireActual('../../settings').openSettingsDialog; + jest.spyOn(require('../../user-agent'), 'initBrowserVersions') + .mockImplementation(() => Promise.resolve({})); + main = require('../../main'); + main.init(); + }); + describe('findInPageOpen', () => { + test('should return if find-in-page already open', () => { + // Given + baseWindow.contentView.children = [{isFindInPage: true}]; + // When + eventBus.listeners.findInPageOpen(); + // Then + expect(baseWindow.contentView.addChildView).not.toHaveBeenCalled(); + }); + test('should show find-in-page', () => { + // When + eventBus.listeners.findInPageOpen(); + // Then + expect(baseWindow.contentView.addChildView).toHaveBeenCalledWith( + expect.objectContaining({isFindInPage: true}) + ); + expect(baseWindow.contentView.addChildView).not.toHaveBeenCalledWith( + expect.objectContaining({isAppMenu: true}) + ); + }); + test('should resize find-in-page', () => { + // When + eventBus.listeners.findInPageOpen(); + // Then + expect(baseWindow.contentView.addChildView.mock.calls[0][0].setBounds) + .toHaveBeenCalledWith(expect.objectContaining({ + y: 0, height: 60, width: 400 + })); + }); + }); + describe('findInPageClose', () => { + test('should stop find in page in tabManager', () => { + // Given + const tabManager = require('../../tab-manager'); + tabManager.addTabs({send: jest.fn()})([{id: 'A'}]); + // When + eventBus.listeners.findInPageClose(); + // Then + expect(tabManager.getTab('A').webContents.stopFindInPage).toHaveBeenCalledTimes(1); + }); + test('should remove found-in-page listener in tabManager', () => { + // Given + const tabManager = require('../../tab-manager'); + tabManager.addTabs({send: jest.fn()})([{id: 'A'}]); + // When + eventBus.listeners.findInPageClose(); + // Then + expect(tabManager.getTab('A').webContents.removeAllListeners).toHaveBeenCalledWith('found-in-page'); + }); + test('should stop find in page in all child views', () => { + // Given + const childView = new electron.WebContentsView(); + baseWindow.contentView.children = [childView]; + // When + eventBus.listeners.findInPageClose(); + // Then + expect(childView.webContents.stopFindInPage).toHaveBeenCalledWith('clearSelection'); + }); + test('should remove found-in-page listener in all child views', () => { + // Given + const childView = new electron.WebContentsView(); + baseWindow.contentView.children = [childView]; + // When + eventBus.listeners.findInPageClose(); + // Then + expect(childView.webContents.removeAllListeners).toHaveBeenCalledWith('found-in-page'); + }); + test('should remove find-in-page view', () => { + // Given + const findInPageDialog = new electron.WebContentsView(); + findInPageDialog.isFindInPage = true; + baseWindow.contentView.children = [findInPageDialog]; + // When + eventBus.listeners.findInPageClose(); + // Then + expect(baseWindow.contentView.removeChildView).toHaveBeenCalledWith(findInPageDialog); + expect(findInPageDialog.webContents.destroy).toHaveBeenCalledTimes(1); + }); + test('should focus last remaining view', () => { + // Given + const findInPageDialog = new electron.WebContentsView(); + findInPageDialog.isFindInPage = true; + const childView = new electron.WebContentsView(); + baseWindow.contentView.children = [childView, findInPageDialog]; + // When + eventBus.listeners.findInPageClose(); + // Then + expect(childView.webContents.focus).toHaveBeenCalledTimes(1); + }); + }); + describe('findInPage', () => { + let findInPageDialog; + beforeEach(() => { + findInPageDialog = new electron.WebContentsView(); + findInPageDialog.isFindInPage = true; + baseWindow.contentView.children = [findInPageDialog]; + }); + test('should return if no text provided', () => { + // When + eventBus.listeners.findInPage({}, {}); + // Then + expect(findInPageDialog.webContents.send).not.toHaveBeenCalled(); + }); + test('should return if no page/webContents available to search in', () => { + // When + eventBus.listeners.findInPage({}, {text: 'test'}); + // Then + expect(findInPageDialog.webContents.send).not.toHaveBeenCalled(); + }); + describe('with dialog', () => { + let dialog; + beforeEach(() => { + dialog = new electron.WebContentsView(); + dialog.isDialog = true; + baseWindow.contentView.children.push(dialog); + }); + test('should register found-in-page listener', () => { + // When + eventBus.listeners.findInPage({}, {text: 'test'}); + // Then + expect(dialog.webContents.on).toHaveBeenCalledWith('found-in-page', expect.any(Function)); + }); + test('found-in-page listener should send event to find-in-page dialog', () => { + // When + eventBus.listeners.findInPage({}, {text: 'test'}); + dialog.listeners['found-in-page']({}, {activeMatchOrdinal: 13, matches: 37}); + // Then + expect(findInPageDialog.webContents.send).toHaveBeenCalledWith('findInPageFound', { + activeMatchOrdinal: 13, matches: 37 + }); + }); + test('should call findInPage on webContents', () => { + // When + eventBus.listeners.findInPage({}, {text: 'test'}); + // Then + expect(dialog.webContents.findInPage).toHaveBeenCalledWith('test', {forward: true}); + }); + }); + describe('with regular tab', () => { + let tab; + beforeEach(() => { + const tabManager = require('../../tab-manager'); + tabManager.addTabs({send: jest.fn()})([{id: 'A'}]); + tabManager.setActiveTab('A'); + tab = tabManager.getTab('A'); + }); + test('should register found-in-page listener', () => { + // When + eventBus.listeners.findInPage({}, {text: 'test'}); + // Then + expect(tab.webContents.on).toHaveBeenCalledWith('found-in-page', expect.any(Function)); + }); + test('found-in-page listener should send event to find-in-page dialog', () => { + // When + eventBus.listeners.findInPage({}, {text: 'test'}); + tab.listeners['found-in-page']({}, {activeMatchOrdinal: 13, matches: 37}); + // Then + expect(findInPageDialog.webContents.send).toHaveBeenCalledWith('findInPageFound', { + activeMatchOrdinal: 13, matches: 37 + }); + }); + test('should call findInPage on webContents', () => { + // When + eventBus.listeners.findInPage({}, {text: 'test'}); + // Then + expect(tab.webContents.findInPage).toHaveBeenCalledWith('test', {forward: true}); + }); + }); + }); +}); diff --git a/src/find-in-page/__tests__/preload.test.js b/src/find-in-page/__tests__/preload.test.js new file mode 100644 index 00000000..cdd35bfb --- /dev/null +++ b/src/find-in-page/__tests__/preload.test.js @@ -0,0 +1,69 @@ +/* + Copyright 2024 Marc Nuri San Felix + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +describe('Find in Page :: preload test suite', () => { + let electron; + beforeEach(() => { + jest.resetModules(); + jest.mock('electron', () => require('../../__tests__').mockElectronInstance()); + electron = require('electron'); + }); + describe('preload (just for coverage and sanity, see bundle tests)', () => { + beforeEach(() => { + global.APP_EVENTS = require('../../constants').APP_EVENTS; + require('../preload'); + }); + test('creates an API', () => { + expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('electron', { + close: expect.toBeFunction(), + findInPage: expect.toBeFunction(), + onFindInPage: expect.toBeFunction() + }); + }); + describe('API', () => { + let api; + beforeEach(() => { + api = electron.contextBridge.exposeInMainWorld.mock.calls[0][1]; + }); + test.each([ + ['close', 'findInPageClose'] + ])('%s invokes %s', (apiMethod, event) => { + api[apiMethod](); + expect(electron.ipcRenderer.send).toHaveBeenCalledWith(event); + }); + test('findInPage invokes findInPage', () => { + api.findInPage('test'); + expect(electron.ipcRenderer.send).toHaveBeenCalledWith('findInPage', 'test'); + }); + test('onFindInPage invokes onFindInPage to register callback', () => { + const mockFunction = jest.fn(); + api.onFindInPage(mockFunction); + expect(electron.ipcRenderer.on).toHaveBeenCalledWith('findInPageFound', mockFunction); + }); + }); + }); + describe('preload.bundle', () => { + beforeEach(() => { + require('../../../bundles/find-in-page.preload'); + }); + test('creates an API', () => { + expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('electron', { + close: expect.toBeFunction(), + findInPage: expect.toBeFunction(), + onFindInPage: expect.toBeFunction() + }); + }); + }); +}); diff --git a/src/find-in-page/find-in-page.browser.css b/src/find-in-page/find-in-page.browser.css new file mode 100644 index 00000000..09b69ea2 --- /dev/null +++ b/src/find-in-page/find-in-page.browser.css @@ -0,0 +1,66 @@ +/* + Copyright 2024 Marc Nuri San Felix + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +@import '../styles/main.css'; + +:root { + --font-size: 16px; + --results-font-size: 0.8rem; + --material3-icon-button-size: 1.2rem; + --material3-field-height: 2rem; +} + +html.electronim, +.electronim body { + background: transparent; + font-size: var(--font-size); + font-family: var(--md-sys-typescale-body-large-font); +} + +.electronim .find-in-page .dialog { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: white; + padding: 0.5rem; + border-radius: 0.5rem; + box-shadow: 0 0 0.3rem rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + gap: 1rem; +} + +.electronim .find-in-page .input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.electronim .find-in-page .results { + position: absolute; + right: 1rem; + pointer-events: none; + font-size: var(--results-font-size); + color: var(--color-gray); +} + +.electronim .find-in-page .buttons { + display: flex; + align-items: center; +} + +.electronim .find-in-page .buttons .material3.icon-button { + color: var(--color-gray); +} diff --git a/src/find-in-page/find-in-page.browser.mjs b/src/find-in-page/find-in-page.browser.mjs new file mode 100644 index 00000000..72837198 --- /dev/null +++ b/src/find-in-page/find-in-page.browser.mjs @@ -0,0 +1,71 @@ +/* + Copyright 2024 Marc Nuri San Felix + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import {createRef, html, render, useLayoutEffect, useState, Icon, IconButton, TextField} from '../components/index.mjs'; + +const {close, findInPage, onFindInPage} = window.electron; + +const getFindInPage = () => document.querySelector('.find-in-page'); + +const FindInPage = () => { + const inputRef = createRef(); + const [result, setResult] = useState({}); + useLayoutEffect(() => { + onFindInPage((_e, r) => { + setResult(r); + inputRef.current.focus(); + }); + inputRef.current.focus(); + }, [inputRef]); + const noBubbling = func => e => { + e.preventDefault(); + e.stopPropagation(); + func(e); + }; + const onKeyDown = e => { + switch (e.key) { + case 'Enter': + findInPage({text: e.target.value}); + break; + case 'Escape': + close(); + break; + default: + break; + } + }; + const findPrevious = () => findInPage({text: inputRef.current.value, forward: false}); + const findNext = () => findInPage({text: inputRef.current.value}); + return (html` +
+
+ <${TextField} onKeyDown=${onKeyDown} inputProps=${{ref: inputRef}} /> +
= 0 ? 'visible' : 'hidden'}}> + ${result.activeMatchOrdinal}/${result.matches} +
+
+
+ <${IconButton} + icon=${Icon.keyboardArrowUp} onClick=${noBubbling(findPrevious)} title='Previous' data-testid='find-previous' /> + <${IconButton} + icon=${Icon.keyboardArrowDown} onClick=${noBubbling(findNext)} title='Next' data-testid='find-next' /> + <${IconButton} + icon=${Icon.close} onClick=${noBubbling(close)} title='Close' data-testid='close' /> +
+
+ `); +}; + +render(html`<${FindInPage} />`, getFindInPage()); diff --git a/src/find-in-page/index.html b/src/find-in-page/index.html new file mode 100644 index 00000000..fd9654d6 --- /dev/null +++ b/src/find-in-page/index.html @@ -0,0 +1,29 @@ + + + + + + + + ElectronIM find in page dialog + + + +
+ + + diff --git a/src/find-in-page/index.js b/src/find-in-page/index.js new file mode 100644 index 00000000..562ded8a --- /dev/null +++ b/src/find-in-page/index.js @@ -0,0 +1,107 @@ +/* + Copyright 2024 Marc Nuri San Felix + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +const path = require('path'); +const {WebContentsView} = require('electron'); +const tabManager = require('../tab-manager'); +const {findDialog} = require('../base-window'); +const {APP_EVENTS} = require('../constants'); + +const FIND_IN_PAGE_HEIGHT = 60; +const FIND_IN_PAGE_WIDTH = 400; + +const webPreferences = { + transparent: true, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + preload: path.resolve(__dirname, '..', '..', 'bundles', 'find-in-page.preload.js') +}; + +/** + * Creates a new WebContentsView instance with the Find In Page dialog + * @returns {Electron.CrossProcessExports.WebContentsView} + */ +const newFindInPage = () => { + const findInPage = new WebContentsView({webPreferences}); + findInPage.isFindInPage = true; + findInPage.webContents.loadURL(`file://${__dirname}/index.html`); + return findInPage; +}; + +const isFindInPage = bv => bv.isFindInPage === true; + +const isNotFindInPage = bv => !isFindInPage(bv); + +const findInPageOpen = mainWindow => () => { + if (mainWindow.contentView.children.find(isFindInPage)) { + return; + } + const findInPage = newFindInPage(); + const {width} = mainWindow.getContentBounds(); + mainWindow.contentView.addChildView(findInPage); + findInPage.setBounds({x: width - FIND_IN_PAGE_WIDTH, y: 0, width: FIND_IN_PAGE_WIDTH, height: FIND_IN_PAGE_HEIGHT}); +}; + +const findInPageClose = mainWindow => () => { + tabManager.stopFindInPage(); + mainWindow.contentView.children.forEach(cv => { + cv.webContents.stopFindInPage('clearSelection'); + cv.webContents.removeAllListeners('found-in-page'); + }); + const view = mainWindow.contentView.children.find(isFindInPage); + if (!view) { + return; + } + mainWindow.contentView.removeChildView(view); + view.webContents.destroy(); + if (mainWindow.contentView.children.length > 0) { + mainWindow.contentView.children.slice(-1)[0].webContents.focus(); + } +}; + +const findInPage = mainWindow => (event, {text, forward = true}) => { + if (!text) { + return; + } + let webContents = null; + const dialog = findDialog(mainWindow); + if (dialog) { + webContents = dialog.webContents; + } else if (tabManager.getActiveTab() && tabManager.getTab(tabManager.getActiveTab())) { + webContents = tabManager.getTab(tabManager.getActiveTab()).webContents; + } + const findInPageDialog = mainWindow.contentView.children.find(isFindInPage); + if (webContents === null || !findInPageDialog) { + return; + } + if (webContents.listeners('found-in-page').length === 0) { + webContents.on('found-in-page', (_event, result) => { + findInPageDialog.webContents.send(APP_EVENTS.findInPageFound, result); + }); + } + webContents.findInPage(text, {forward}); +}; + +module.exports = { + FIND_IN_PAGE_HEIGHT, + FIND_IN_PAGE_WIDTH, + newFindInPage, + isFindInPage, + isNotFindInPage, + findInPageOpen, + findInPageClose, + findInPage +}; diff --git a/src/tab-manager/preload.find-in-page.js b/src/find-in-page/preload.js similarity index 66% rename from src/tab-manager/preload.find-in-page.js rename to src/find-in-page/preload.js index 9dbb78a2..3fde8aff 100644 --- a/src/tab-manager/preload.find-in-page.js +++ b/src/find-in-page/preload.js @@ -14,14 +14,10 @@ limitations under the License. */ /* eslint-disable no-undef */ -const {ipcRenderer} = require('electron'); +const {contextBridge, ipcRenderer} = require('electron'); - -const openFindWindow = () => { -}; - -const initFindInPage = () => { - ipcRenderer.on(APP_EVENTS.findInPageOpenWindow, openFindWindow); -}; - -module.exports = {initFindInPage}; +contextBridge.exposeInMainWorld('electron', { + close: () => ipcRenderer.send(APP_EVENTS.findInPageClose), + findInPage: args => ipcRenderer.send(APP_EVENTS.findInPage, args), + onFindInPage: func => ipcRenderer.on(APP_EVENTS.findInPageFound, func) +}); diff --git a/src/main/__tests__/index.test.js b/src/main/__tests__/index.test.js index ca1133cc..a14bc2a0 100644 --- a/src/main/__tests__/index.test.js +++ b/src/main/__tests__/index.test.js @@ -41,6 +41,8 @@ describe('Main :: Index module test suite', () => { })); electron = require('electron'); mockBaseWindow = electron.baseWindowInstance; + // Each view should be a separate instance + electron.WebContentsView = jest.fn(() => require('../../__tests__').mockWebContentsViewInstance()); mockIpc = electron.ipcMain; appMenuModule = require('../../app-menu'); jest.spyOn(appMenuModule, 'newAppMenu'); @@ -248,6 +250,17 @@ describe('Main :: Index module test suite', () => { expect(mockBaseWindow.setBounds).not.toHaveBeenCalled(); }); }); + test('find-in-page, should set specific dialog bounds', () => { + // Given + main.init(); + mockIpc.listeners.findInPageOpen(); + const findInPageDialog = mockBaseWindow.contentView.children.find(cv => cv.isFindInPage); + // When + mockBaseWindow.listeners.resize({sender: mockBaseWindow}); + jest.runAllTimers(); + // Then + expect(findInPageDialog.setBounds).toHaveBeenCalledWith({x: -390, y: 0, width: 400, height: 60}); + }); test('single view, should set View to fit window', () => { // Given const singleView = { From ba9b5fff8e3f3e8f8c379af20dd766db5d8995bd Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Sun, 8 Dec 2024 07:43:11 +0100 Subject: [PATCH 8/9] =?UTF-8?q?=E2=9C=A8=20find=20in=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Nuri --- src/find-in-page/__tests__/index.test.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/find-in-page/__tests__/index.test.js b/src/find-in-page/__tests__/index.test.js index c6881d22..957b37ca 100644 --- a/src/find-in-page/__tests__/index.test.js +++ b/src/find-in-page/__tests__/index.test.js @@ -20,7 +20,6 @@ describe('Find in Page :: main test suite', () => { let electron; let main; let baseWindow; - let webContentsViewInstances; let eventBus; beforeEach(() => { jest.resetModules(); @@ -29,11 +28,7 @@ describe('Find in Page :: main test suite', () => { baseWindow = electron.baseWindowInstance; webContentsViewInstances = []; // Each view should be a separate instance - electron.WebContentsView = jest.fn(() => { - const view = require('../../__tests__').mockWebContentsViewInstance(); - webContentsViewInstances.push(view); - return view; - }); + electron.WebContentsView = jest.fn(() => require('../../__tests__').mockWebContentsViewInstance()); eventBus = electron.ipcMain; // Always mock settings unless we want to overwrite the real settings file ! jest.mock('../../settings'); From 8cde58db91455aaa25fc9788b8d9a0d7a47c1517 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Sun, 8 Dec 2024 07:44:55 +0100 Subject: [PATCH 9/9] =?UTF-8?q?=E2=9C=A8=20find=20in=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Nuri --- src/find-in-page/__tests__/index.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/find-in-page/__tests__/index.test.js b/src/find-in-page/__tests__/index.test.js index 957b37ca..ea8ad837 100644 --- a/src/find-in-page/__tests__/index.test.js +++ b/src/find-in-page/__tests__/index.test.js @@ -26,7 +26,6 @@ describe('Find in Page :: main test suite', () => { jest.mock('electron', () => require('../../__tests__').mockElectronInstance()); electron = require('electron'); baseWindow = electron.baseWindowInstance; - webContentsViewInstances = []; // Each view should be a separate instance electron.WebContentsView = jest.fn(() => require('../../__tests__').mockWebContentsViewInstance()); eventBus = electron.ipcMain;