mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
feat: Support find in desktop client (#10723)
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -21,7 +21,9 @@ const mockApp = {
|
||||
}
|
||||
return `/mock/${name}`
|
||||
}),
|
||||
getName: jest.fn(() => 'TestApp')
|
||||
getName: jest.fn(() => 'TestApp'),
|
||||
isPackaged: true,
|
||||
getAppPath: jest.fn(() => '/mock/appPath')
|
||||
}
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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 type { WebContents } from 'electron'
|
||||
import { IpcMessage } from '../../ui/ipcMessages'
|
||||
|
||||
type IpcHandlerFn = (...args: any[]) => any
|
||||
|
||||
/** Captured ipcMain.handle / ipcMain.on callbacks for assertions. */
|
||||
const ipcHandlers = new Map<string, IpcHandlerFn>()
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: jest.fn((channel: string, handler: (...args: any[]) => any) => {
|
||||
ipcHandlers.set(channel, handler)
|
||||
}),
|
||||
on: jest.fn((channel: string, handler: (...args: any[]) => any) => {
|
||||
ipcHandlers.set(`on:${channel}`, handler)
|
||||
})
|
||||
},
|
||||
BrowserWindow: {
|
||||
fromWebContents: jest.fn()
|
||||
},
|
||||
BrowserView: jest.fn()
|
||||
}))
|
||||
|
||||
function makeWebContents (partial: {
|
||||
id: number
|
||||
destroyed?: boolean
|
||||
findInPage?: jest.Mock
|
||||
stopFindInPage?: jest.Mock
|
||||
send?: jest.Mock
|
||||
}): WebContents {
|
||||
return {
|
||||
id: partial.id,
|
||||
isDestroyed: () => partial.destroyed ?? false,
|
||||
findInPage: partial.findInPage ?? jest.fn(),
|
||||
stopFindInPage: partial.stopFindInPage ?? jest.fn(),
|
||||
send: partial.send ?? jest.fn()
|
||||
} as unknown as WebContents
|
||||
}
|
||||
|
||||
describe('findInPage main IPC', () => {
|
||||
let originalConsoleError: typeof console.error
|
||||
let registerFindInPageIpcHandlers: () => void
|
||||
let registerFindInPageTarget: (overlayWc: WebContents, pageWc: WebContents) => void
|
||||
|
||||
beforeEach(async () => {
|
||||
ipcHandlers.clear()
|
||||
jest.resetModules()
|
||||
originalConsoleError = console.error
|
||||
console.error = jest.fn()
|
||||
const m = await import('../../main/findInPage')
|
||||
registerFindInPageIpcHandlers = m.registerFindInPageIpcHandlers
|
||||
registerFindInPageTarget = m.registerFindInPageTarget
|
||||
registerFindInPageIpcHandlers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
|
||||
test('FindInPage clears selection and returns -1 for empty text', async () => {
|
||||
const stopFindInPage = jest.fn()
|
||||
const findInPage = jest.fn()
|
||||
const sender = makeWebContents({ id: 10, stopFindInPage, findInPage })
|
||||
const handler = ipcHandlers.get(IpcMessage.FindInPage)
|
||||
expect(handler).toBeDefined()
|
||||
const result = await handler?.({ sender }, '', {})
|
||||
expect(result).toBe(-1)
|
||||
expect(stopFindInPage).toHaveBeenCalledWith('clearSelection')
|
||||
expect(findInPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('FindInPage runs on page webContents when overlay is registered as invoker', async () => {
|
||||
const pageFindInPage = jest.fn().mockResolvedValue(7)
|
||||
const pageStop = jest.fn()
|
||||
const pageWc = makeWebContents({ id: 1, findInPage: pageFindInPage, stopFindInPage: pageStop })
|
||||
const overlayWc = makeWebContents({ id: 2 })
|
||||
registerFindInPageTarget(overlayWc, pageWc)
|
||||
|
||||
const handler = ipcHandlers.get(IpcMessage.FindInPage)
|
||||
const result = await handler?.({ sender: overlayWc }, 'needle', { forward: true, findNext: false })
|
||||
expect(result).toBe(7)
|
||||
expect(pageFindInPage).toHaveBeenCalledWith('needle', { forward: true, findNext: false })
|
||||
})
|
||||
|
||||
test('FindInPage returns -1 when target webContents is destroyed', async () => {
|
||||
const findInPage = jest.fn()
|
||||
const sender = makeWebContents({ id: 20, destroyed: true, findInPage })
|
||||
const handler = ipcHandlers.get(IpcMessage.FindInPage)
|
||||
const result = await handler?.({ sender }, 'x', {})
|
||||
expect(result).toBe(-1)
|
||||
expect(findInPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('FindInPage returns -1 when findInPage throws', async () => {
|
||||
const findInPage = jest.fn().mockImplementation(() => {
|
||||
throw new Error('find failed')
|
||||
})
|
||||
const sender = makeWebContents({ id: 30, findInPage })
|
||||
const handler = ipcHandlers.get(IpcMessage.FindInPage)
|
||||
const result = await handler?.({ sender }, 'x', {})
|
||||
expect(result).toBe(-1)
|
||||
})
|
||||
|
||||
test('StopFindInPage no-ops when webContents is destroyed', async () => {
|
||||
const stopFindInPage = jest.fn()
|
||||
const sender = makeWebContents({ id: 40, destroyed: true, stopFindInPage })
|
||||
const handler = ipcHandlers.get(IpcMessage.StopFindInPage)
|
||||
await handler?.({ sender }, 'clearSelection')
|
||||
expect(stopFindInPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('StopFindInPage forwards to resolveFindTarget', async () => {
|
||||
const stopFindInPage = jest.fn()
|
||||
const sender = makeWebContents({ id: 50, stopFindInPage })
|
||||
const handler = ipcHandlers.get(IpcMessage.StopFindInPage)
|
||||
await handler?.({ sender }, 'keepSelection')
|
||||
expect(stopFindInPage).toHaveBeenCalledWith('keepSelection')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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 * as nodePath from 'path'
|
||||
import { getBundledUiDistPath, getFileInPublicBundledFolder } from '../../main/path'
|
||||
|
||||
const mockGetAppPath = jest.fn(() => '/mock/appPath')
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
getAppPath: (): string => mockGetAppPath()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('path (bundled UI)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAppPath.mockReturnValue('/mock/appPath')
|
||||
})
|
||||
|
||||
test('getBundledUiDistPath joins getAppPath with dist/ui', () => {
|
||||
mockGetAppPath.mockReturnValue('/Applications/Huly.app/Contents/Resources/app.asar')
|
||||
expect(getBundledUiDistPath()).toBe(
|
||||
nodePath.join('/Applications/Huly.app/Contents/Resources/app.asar', 'dist', 'ui')
|
||||
)
|
||||
})
|
||||
|
||||
test('getFileInPublicBundledFolder nests under public', () => {
|
||||
mockGetAppPath.mockReturnValue('/repo/desktop')
|
||||
expect(getFileInPublicBundledFolder('AppIcon.png')).toBe(
|
||||
nodePath.join('/repo/desktop', 'dist', 'ui', 'public', 'AppIcon.png')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import { OptionValues, program } from 'commander'
|
||||
program
|
||||
.name('Huly')
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments(true)
|
||||
.option('-s, --server <url>', 'Remote server URL (front). E.g. https://huly.app')
|
||||
|
||||
let opts: OptionValues | null = null
|
||||
|
||||
@@ -28,12 +28,23 @@ export interface PackedConfig {
|
||||
function readConfigFile (filePath: string): PackedConfig | undefined {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as PackedConfig
|
||||
} catch (err) {
|
||||
console.error(`Failed to read config from ${filePath}:`, err)
|
||||
} catch (err: unknown) {
|
||||
const code = err != null && typeof err === 'object' && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined
|
||||
if (code !== 'ENOENT') {
|
||||
console.error(`Failed to read config from ${filePath}:`, err)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Packaged app: extraResources `config/config.json`. Dev: webpack `public/` → `dist/ui/public/`. */
|
||||
function getBundledResourcesConfigPath (): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'config', 'config.json')
|
||||
}
|
||||
return path.join(app.getAppPath(), 'dist', 'ui', 'public', 'config', 'config.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a JSON config file, logging errors.
|
||||
*/
|
||||
@@ -55,7 +66,7 @@ function writeConfigFile (filePath: string, config: PackedConfig): boolean {
|
||||
function migrateConfigIfNeeded (): void {
|
||||
try {
|
||||
const userDataConfigPath = path.join(app.getPath('userData'), 'config.json')
|
||||
const resourcesConfigPath = path.join(process.resourcesPath, 'config', 'config.json')
|
||||
const resourcesConfigPath = getBundledResourcesConfigPath()
|
||||
|
||||
const userDataDir = app.getPath('userData')
|
||||
if (!fs.existsSync(userDataDir)) {
|
||||
@@ -109,6 +120,5 @@ export function readPackedConfig (): PackedConfig | undefined {
|
||||
}
|
||||
|
||||
// Fallback to bundled config if userData config doesn't exist
|
||||
const resourcesConfigPath = path.join(process.resourcesPath, 'config', 'config.json')
|
||||
return readConfigFile(resourcesConfigPath)
|
||||
return readConfigFile(getBundledResourcesConfigPath())
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { MenuBarAction, CommandLogout, CommandSelectWorkspace, CommandOpenSettings } from '../ui/types'
|
||||
import { OsIntegration } from './osIntegration'
|
||||
import { IpcMessage } from '../ui/ipcMessages'
|
||||
import { OsIntegration } from './osIntegration'
|
||||
import { openFindInPageBar } from './findInPage'
|
||||
|
||||
export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, action: MenuBarAction, os: OsIntegration | undefined): void {
|
||||
if (mainWindow == null) {
|
||||
@@ -67,6 +68,9 @@ export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, ac
|
||||
case 'select-all':
|
||||
mainWindow.webContents.selectAll()
|
||||
break
|
||||
case 'find':
|
||||
openFindInPageBar(mainWindow)
|
||||
break
|
||||
case 'reload':
|
||||
mainWindow?.reload()
|
||||
break
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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 { BrowserView, BrowserWindow, ipcMain, WebContents } from 'electron'
|
||||
import { IpcMessage } from '../ui/ipcMessages'
|
||||
|
||||
let ipcHandlersRegistered = false
|
||||
|
||||
/** Overlay invoker webContents id → page webContents to run `findInPage` on. */
|
||||
const findPageTargetByInvokerId = new Map<number, WebContents>()
|
||||
/** Page webContents id → overlay that should receive `found-in-page` IPC. */
|
||||
const findResultRecipientByPageId = new Map<number, WebContents>()
|
||||
const pagesWithFoundInPageListener = new WeakSet<WebContents>()
|
||||
|
||||
const overlayViewsByWindowId = new Map<number, BrowserView>()
|
||||
const overlayVisibleByWindowId = new Map<number, boolean>()
|
||||
|
||||
const OVERLAY_WIDTH = 392
|
||||
const OVERLAY_HEIGHT = 64
|
||||
|
||||
function resolveFindTarget (sender: WebContents): WebContents {
|
||||
return findPageTargetByInvokerId.get(sender.id) ?? sender
|
||||
}
|
||||
|
||||
function webContentsIfAlive (wc: WebContents): WebContents | null {
|
||||
return wc.isDestroyed() ? null : wc
|
||||
}
|
||||
|
||||
export function registerFindInPageTarget (overlayWc: WebContents, pageWc: WebContents): void {
|
||||
findPageTargetByInvokerId.set(overlayWc.id, pageWc)
|
||||
}
|
||||
|
||||
export function unregisterFindInPageTarget (overlayWc: WebContents): void {
|
||||
findPageTargetByInvokerId.delete(overlayWc.id)
|
||||
}
|
||||
|
||||
export function unregisterFindInPageForwarding (pageWc: WebContents): void {
|
||||
findResultRecipientByPageId.delete(pageWc.id)
|
||||
}
|
||||
|
||||
export function attachFindInPageResultForwarding (pageWc: WebContents, overlayWc: WebContents): void {
|
||||
findResultRecipientByPageId.set(pageWc.id, overlayWc)
|
||||
if (pagesWithFoundInPageListener.has(pageWc)) {
|
||||
return
|
||||
}
|
||||
pagesWithFoundInPageListener.add(pageWc)
|
||||
pageWc.on('found-in-page', (_event, result) => {
|
||||
const recipient = findResultRecipientByPageId.get(pageWc.id)
|
||||
if (recipient == null || recipient.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
recipient.send(IpcMessage.FindInPageResult, result)
|
||||
} catch (err) {
|
||||
console.error('[find] forward found-in-page failed:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function layoutFindOverlayBounds (win: BrowserWindow, view: BrowserView, visible: boolean): void {
|
||||
overlayVisibleByWindowId.set(win.id, visible)
|
||||
if (view.webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (!visible || win.isDestroyed()) {
|
||||
view.setBounds({ x: 0, y: 0, width: 0, height: 0 })
|
||||
return
|
||||
}
|
||||
const b = win.getContentBounds()
|
||||
view.setBounds({
|
||||
x: Math.max(0, b.width - OVERLAY_WIDTH - 12),
|
||||
y: 12,
|
||||
width: OVERLAY_WIDTH,
|
||||
height: OVERLAY_HEIGHT
|
||||
})
|
||||
}
|
||||
|
||||
export function registerFindOverlayView (win: BrowserWindow, view: BrowserView): void {
|
||||
overlayViewsByWindowId.set(win.id, view)
|
||||
}
|
||||
|
||||
export function unregisterFindOverlayView (win: BrowserWindow): void {
|
||||
overlayViewsByWindowId.delete(win.id)
|
||||
overlayVisibleByWindowId.delete(win.id)
|
||||
}
|
||||
|
||||
export function openFindInPageBar (win: BrowserWindow | undefined): void {
|
||||
if (win == null || win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const view = overlayViewsByWindowId.get(win.id)
|
||||
if (view == null || view.webContents.isDestroyed()) {
|
||||
try {
|
||||
if (!win.webContents.isDestroyed()) {
|
||||
win.webContents.send(IpcMessage.OpenFindBar)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[find] openFindBar fallback send failed:', err)
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
layoutFindOverlayBounds(win, view, true)
|
||||
view.webContents.send(IpcMessage.OpenFindBar)
|
||||
} catch (err) {
|
||||
console.error('[find] openFindBar overlay send failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function attachResizeRelayoutFindOverlay (win: BrowserWindow): void {
|
||||
const onResize = (): void => {
|
||||
if (win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (overlayVisibleByWindowId.get(win.id) !== true) {
|
||||
return
|
||||
}
|
||||
const view = overlayViewsByWindowId.get(win.id)
|
||||
if (view == null || view.webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
layoutFindOverlayBounds(win, view, true)
|
||||
}
|
||||
win.on('resize', onResize)
|
||||
}
|
||||
|
||||
export function registerFindInPageIpcHandlers (): void {
|
||||
if (ipcHandlersRegistered) {
|
||||
return
|
||||
}
|
||||
ipcHandlersRegistered = true
|
||||
|
||||
ipcMain.handle(IpcMessage.FindInPage, async (event, text: string, options?: { forward?: boolean, findNext?: boolean, matchCase?: boolean, wordStart?: boolean, medialCapitalAsWordStart?: boolean }) => {
|
||||
try {
|
||||
const wc = webContentsIfAlive(resolveFindTarget(event.sender))
|
||||
if (wc == null) {
|
||||
return -1
|
||||
}
|
||||
if (text === '') {
|
||||
wc.stopFindInPage('clearSelection')
|
||||
return -1
|
||||
}
|
||||
return await Promise.resolve(wc.findInPage(text, options ?? {}))
|
||||
} catch (err) {
|
||||
console.error('[find] findInPage handler failed:', err)
|
||||
return -1
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcMessage.StopFindInPage, async (event, action: 'clearSelection' | 'keepSelection' | 'activateSelection') => {
|
||||
try {
|
||||
const wc = webContentsIfAlive(resolveFindTarget(event.sender))
|
||||
if (wc == null) {
|
||||
return
|
||||
}
|
||||
wc.stopFindInPage(action)
|
||||
} catch (err) {
|
||||
console.error('[find] stopFindInPage handler failed:', err)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on(IpcMessage.FindOverlayLayout, (event, visible: boolean) => {
|
||||
try {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (win == null || win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const view = overlayViewsByWindowId.get(win.id)
|
||||
if (view == null || view.webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
layoutFindOverlayBounds(win, view, visible)
|
||||
} catch (err) {
|
||||
console.error('[find] FindOverlayLayout handler failed:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function attachFindShortcutToWebContents (wc: WebContents, openFindBar: () => void): void {
|
||||
wc.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') {
|
||||
return
|
||||
}
|
||||
const mod = input.control || input.meta
|
||||
if (!mod || input.alt) {
|
||||
return
|
||||
}
|
||||
const key = input.key.toLowerCase()
|
||||
if (key !== 'f' || input.shift) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
openFindBar()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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 { BrowserView, BrowserWindow } from 'electron'
|
||||
import path from 'path'
|
||||
import {
|
||||
attachFindInPageResultForwarding,
|
||||
attachFindShortcutToWebContents,
|
||||
attachResizeRelayoutFindOverlay,
|
||||
openFindInPageBar,
|
||||
registerFindInPageTarget,
|
||||
registerFindOverlayView,
|
||||
unregisterFindInPageForwarding,
|
||||
unregisterFindInPageTarget,
|
||||
unregisterFindOverlayView
|
||||
} from './findInPage'
|
||||
import { getBundledUiDistPath } from './path'
|
||||
|
||||
function destroyBrowserViewSafe (view: BrowserView): void {
|
||||
try {
|
||||
if (view.webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const destroy = (view.webContents as { destroy?: () => void }).destroy
|
||||
destroy?.()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the find UI in a separate BrowserView so `webContents.findInPage` on the
|
||||
* main page does not match the query typed in the find field.
|
||||
*
|
||||
* If the overlay cannot be loaded, the window still works: Cmd/Ctrl+F becomes a
|
||||
* no-op for the overlay (main page may still receive `OpenFindBar` if anything listens).
|
||||
*/
|
||||
export async function setupFindInPageOverlayForWindow (win: BrowserWindow, sessionPartition: string, preloadScriptPath: string): Promise<void> {
|
||||
const pageWc = win.webContents
|
||||
const overlayHtmlPath = path.join(getBundledUiDistPath(), 'find-in-page-overlay.html')
|
||||
|
||||
const view = new BrowserView({
|
||||
webPreferences: {
|
||||
devTools: true,
|
||||
sandbox: false,
|
||||
nodeIntegration: true,
|
||||
partition: sessionPartition,
|
||||
preload: preloadScriptPath
|
||||
}
|
||||
})
|
||||
|
||||
const openFind = (): void => {
|
||||
openFindInPageBar(win)
|
||||
}
|
||||
|
||||
try {
|
||||
await view.webContents.loadFile(overlayHtmlPath)
|
||||
} catch (err) {
|
||||
console.error('[find] Overlay failed to load; find bar disabled for this window:', overlayHtmlPath, err)
|
||||
destroyBrowserViewSafe(view)
|
||||
attachFindShortcutToWebContents(pageWc, openFind)
|
||||
return
|
||||
}
|
||||
|
||||
if (win.isDestroyed() || pageWc.isDestroyed()) {
|
||||
destroyBrowserViewSafe(view)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
win.addBrowserView(view)
|
||||
view.setBounds({ x: 0, y: 0, width: 0, height: 0 })
|
||||
registerFindOverlayView(win, view)
|
||||
attachResizeRelayoutFindOverlay(win)
|
||||
registerFindInPageTarget(view.webContents, pageWc)
|
||||
attachFindInPageResultForwarding(pageWc, view.webContents)
|
||||
attachFindShortcutToWebContents(pageWc, openFind)
|
||||
attachFindShortcutToWebContents(view.webContents, openFind)
|
||||
|
||||
win.on('close', () => {
|
||||
unregisterFindOverlayView(win)
|
||||
if (!view.webContents.isDestroyed()) {
|
||||
unregisterFindInPageTarget(view.webContents)
|
||||
}
|
||||
unregisterFindInPageForwarding(pageWc)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[find] Overlay registration failed; find bar disabled for this window:', err)
|
||||
try {
|
||||
win.removeBrowserView(view)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
unregisterFindOverlayView(win)
|
||||
destroyBrowserViewSafe(view)
|
||||
attachFindShortcutToWebContents(pageWc, openFind)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,12 @@
|
||||
//
|
||||
|
||||
import { app } from 'electron'
|
||||
import path from 'path'
|
||||
import * as nodePath from 'path'
|
||||
|
||||
export function getBundledUiDistPath (): string {
|
||||
return nodePath.join(app.getAppPath(), 'dist', 'ui')
|
||||
}
|
||||
|
||||
export function getFileInPublicBundledFolder (fileName: string): string {
|
||||
return path.join(app.getAppPath(), 'dist', 'ui', 'public', fileName)
|
||||
return nodePath.join(getBundledUiDistPath(), 'public', fileName)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Menu, MenuItemConstructorOptions } from 'electron'
|
||||
import { BrowserWindow, Menu, MenuItemConstructorOptions } from 'electron'
|
||||
import { Command, CommandOpenSettings, CommandSelectWorkspace, CommandLogout } from '../ui/types'
|
||||
import { openFindInPageBar } from './findInPage'
|
||||
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isLinux = process.platform === 'linux'
|
||||
@@ -40,6 +41,17 @@ export const addMenus = (sendCommand: (cmd: Command, ...args: any[]) => void): v
|
||||
{ role: isMac ? 'close' : 'quit' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Search',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Find…',
|
||||
click: (_item, browserWindow) => {
|
||||
openFindInPageBar(browserWindow as BrowserWindow | undefined)
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{ role: 'editMenu' },
|
||||
{ role: 'viewMenu' },
|
||||
{ role: 'windowMenu' }
|
||||
|
||||
@@ -26,6 +26,8 @@ import { Config, MenuBarAction, NotificationParams, JumpListSpares, CommandClose
|
||||
import { getOptions } from './args'
|
||||
import { addMenus } from './standardMenu'
|
||||
import { dispatchMenuBarAction } from './customMenu'
|
||||
import { registerFindInPageIpcHandlers } from './findInPage'
|
||||
import { setupFindInPageOverlayForWindow } from './findInPageOverlayHost'
|
||||
import { addPermissionHandlers } from './permissions'
|
||||
import autoUpdater from './updater'
|
||||
import { generateId } from '@hcengineering/core'
|
||||
@@ -163,6 +165,11 @@ function runTheApp (): void {
|
||||
}
|
||||
setupWindowTitleBar(windowOptions)
|
||||
const childWindow = new BrowserWindow(windowOptions)
|
||||
try {
|
||||
await setupFindInPageOverlayForWindow(childWindow, sessionPartition, preloadScriptPath)
|
||||
} catch (err) {
|
||||
log.error('Find overlay setup failed (child window)', err)
|
||||
}
|
||||
await childWindow.loadFile(containerPagePath)
|
||||
hookOpenWindow(childWindow)
|
||||
})()
|
||||
@@ -263,6 +270,11 @@ function runTheApp (): void {
|
||||
}
|
||||
setupWindowTitleBar(windowOptions)
|
||||
mainWindow = new BrowserWindow(windowOptions)
|
||||
try {
|
||||
await setupFindInPageOverlayForWindow(mainWindow, sessionPartition, preloadScriptPath)
|
||||
} catch (err) {
|
||||
log.error('Find overlay setup failed (main window)', err)
|
||||
}
|
||||
app.dock?.setIcon(nativeImage.createFromPath(iconKey))
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools()
|
||||
@@ -382,6 +394,8 @@ function runTheApp (): void {
|
||||
showSelectAll: false
|
||||
})
|
||||
|
||||
registerFindInPageIpcHandlers()
|
||||
|
||||
ipcMain.on(IpcMessage.SetBadge, (_event: any, badge: number) => {
|
||||
app.dock?.setBadge(badge > 0 ? `${badge}` : '')
|
||||
app.badgeCount = badge
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title></title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,427 @@
|
||||
//
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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 type { IPCMainExposed } from './types'
|
||||
|
||||
const DEBOUNCE_MS = 300
|
||||
|
||||
/**
|
||||
* Find UI is loaded in a separate BrowserView (see `findInPageOverlayHost.ts`) so
|
||||
* `findInPage` on the page webContents does not search the query in this input.
|
||||
* Shadow DOM keeps chrome text minimal. Match counts are not shown.
|
||||
*
|
||||
* Chromium moves focus to the matched text in the **page** webContents after
|
||||
* `findInPage`, so the overlay stops receiving keystrokes unless we put focus back
|
||||
* on the field after each search (caret is restored from before the call; we do not
|
||||
* steal focus on arbitrary blur — only after our own `findInPage`).
|
||||
*/
|
||||
export function setupDesktopFindInPageBar (electronApi: IPCMainExposed): void {
|
||||
const host = document.createElement('div')
|
||||
host.id = 'desktop-find-bar-host'
|
||||
host.style.display = 'none'
|
||||
|
||||
const shadow = host.attachShadow({ mode: 'open' })
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.textContent = shadowStyles
|
||||
|
||||
const root = document.createElement('div')
|
||||
root.className = 'bar'
|
||||
root.setAttribute('role', 'search')
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.className = 'field'
|
||||
input.type = 'text'
|
||||
input.placeholder = ''
|
||||
input.autocomplete = 'off'
|
||||
input.spellcheck = false
|
||||
input.setAttribute('aria-label', 'Find in page')
|
||||
|
||||
const nav = document.createElement('div')
|
||||
nav.className = 'nav'
|
||||
nav.setAttribute('role', 'group')
|
||||
nav.setAttribute('aria-label', 'Find matches')
|
||||
|
||||
const prevBtn = document.createElement('button')
|
||||
prevBtn.type = 'button'
|
||||
prevBtn.className = 'icon-btn prev'
|
||||
prevBtn.setAttribute('aria-label', 'Previous match')
|
||||
prevBtn.title = 'Previous (Shift+Enter)'
|
||||
|
||||
const nextBtn = document.createElement('button')
|
||||
nextBtn.type = 'button'
|
||||
nextBtn.className = 'icon-btn next'
|
||||
nextBtn.setAttribute('aria-label', 'Next match')
|
||||
nextBtn.title = 'Next (Enter)'
|
||||
|
||||
nav.appendChild(prevBtn)
|
||||
nav.appendChild(nextBtn)
|
||||
|
||||
const closeBtn = document.createElement('button')
|
||||
closeBtn.type = 'button'
|
||||
closeBtn.className = 'icon-btn close-x'
|
||||
closeBtn.setAttribute('aria-label', 'Close')
|
||||
closeBtn.title = 'Close'
|
||||
|
||||
root.appendChild(input)
|
||||
root.appendChild(nav)
|
||||
root.appendChild(closeBtn)
|
||||
shadow.appendChild(style)
|
||||
shadow.appendChild(root)
|
||||
document.body.appendChild(host)
|
||||
|
||||
let visible = false
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function getQuery (): string {
|
||||
return input.value.trim()
|
||||
}
|
||||
|
||||
function updateNavState (): void {
|
||||
const q = getQuery()
|
||||
prevBtn.disabled = q === ''
|
||||
nextBtn.disabled = q === ''
|
||||
}
|
||||
|
||||
function show (): void {
|
||||
const openingFromHidden = !visible
|
||||
visible = true
|
||||
host.style.display = 'block'
|
||||
try {
|
||||
electronApi.notifyFindOverlayLayout(true)
|
||||
} catch {
|
||||
/* ignore — overlay hit area may be wrong but avoid breaking the window */
|
||||
}
|
||||
input.focus()
|
||||
if (openingFromHidden) {
|
||||
input.select()
|
||||
}
|
||||
updateNavState()
|
||||
}
|
||||
|
||||
function hide (): void {
|
||||
visible = false
|
||||
host.style.display = 'none'
|
||||
prevBtn.disabled = true
|
||||
nextBtn.disabled = true
|
||||
try {
|
||||
electronApi.notifyFindOverlayLayout(false)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
void electronApi.stopFindInPage('clearSelection').catch(() => {})
|
||||
}
|
||||
|
||||
async function find (text: string, options?: { findNext?: boolean, forward?: boolean }): Promise<void> {
|
||||
const selStart = input.selectionStart ?? input.value.length
|
||||
const selEnd = input.selectionEnd ?? input.value.length
|
||||
try {
|
||||
await electronApi.findInPage(text, {
|
||||
forward: options?.forward ?? true,
|
||||
findNext: options?.findNext ?? false
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
// Page view steals focus when a match is highlighted; without this, further typing
|
||||
// never reaches the input and scheduleFind appears to "stop working".
|
||||
input.focus({ preventScroll: true })
|
||||
const max = input.value.length
|
||||
try {
|
||||
input.setSelectionRange(Math.min(selStart, max), Math.min(selEnd, max))
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
}
|
||||
|
||||
function runFindNext (forward: boolean): void {
|
||||
const q = getQuery()
|
||||
if (q === '') {
|
||||
return
|
||||
}
|
||||
void find(q, { findNext: true, forward })
|
||||
}
|
||||
|
||||
function scheduleFind (): void {
|
||||
if (debounceTimer !== undefined) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = undefined
|
||||
const q = getQuery()
|
||||
if (q === '') {
|
||||
void electronApi.stopFindInPage('clearSelection').catch(() => {})
|
||||
updateNavState()
|
||||
return
|
||||
}
|
||||
void find(q, { findNext: false, forward: true })
|
||||
}, DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
electronApi.onOpenFindBar(() => {
|
||||
show()
|
||||
if (getQuery() !== '') {
|
||||
scheduleFind()
|
||||
}
|
||||
})
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
scheduleFind()
|
||||
updateNavState()
|
||||
})
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (getQuery() === '') {
|
||||
return
|
||||
}
|
||||
runFindNext(!e.shiftKey)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
hide()
|
||||
}
|
||||
})
|
||||
|
||||
prevBtn.addEventListener('click', () => {
|
||||
runFindNext(false)
|
||||
})
|
||||
|
||||
nextBtn.addEventListener('click', () => {
|
||||
runFindNext(true)
|
||||
})
|
||||
|
||||
closeBtn.addEventListener('click', () => {
|
||||
hide()
|
||||
})
|
||||
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
(e) => {
|
||||
if (!visible || e.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
const t = e.target as Node
|
||||
if (!root.contains(t)) {
|
||||
return
|
||||
}
|
||||
e.stopPropagation()
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
/** Left-pointing chevron; `.next::after` mirrors with `scaleX(-1)` for identical vertical alignment. */
|
||||
const chevronMaskUrl =
|
||||
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M15.41 16.59 10.83 12l4.58-4.59L14 6l-6 6 6 6 1.41-1.41z'/%3E%3C/svg%3E\")"
|
||||
|
||||
/** Close (×); same mask + `currentColor` treatment as `.prev` / `.next`. */
|
||||
const closeIconMaskUrl =
|
||||
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E\")"
|
||||
|
||||
const shadowStyles = `
|
||||
:host {
|
||||
all: initial;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2147483647;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
color: #1a1a1a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bar {
|
||||
background: rgba(42, 42, 46, 0.96);
|
||||
box-shadow: 0 2px 24px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
color: #e8e8e8;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([data-theme='theme-dark']) .bar {
|
||||
background: rgba(42, 42, 46, 0.96);
|
||||
box-shadow: 0 2px 24px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 200px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.field {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([data-theme='theme-dark']) .field {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
border-color: color-mix(in srgb, var(--accent-color, #0b74da) 42%, rgba(0, 0, 0, 0.2));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-color, #0b74da) 35%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.field:focus {
|
||||
border-color: color-mix(in srgb, var(--accent-color, #0b74da) 50%, rgba(255, 255, 255, 0.22));
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([data-theme='theme-dark']) .field:focus {
|
||||
border-color: color-mix(in srgb, var(--accent-color, #0b74da) 50%, rgba(255, 255, 255, 0.22));
|
||||
}
|
||||
|
||||
.field::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-left: 4px;
|
||||
margin-left: 2px;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nav {
|
||||
border-left-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([data-theme='theme-dark']) .nav {
|
||||
border-left-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.72;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn.prev,
|
||||
.icon-btn.next,
|
||||
.icon-btn.close-x {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([data-theme='theme-dark']) .icon-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.28;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.prev::after,
|
||||
.next::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: currentColor;
|
||||
-webkit-mask-image: ${chevronMaskUrl};
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-image: ${chevronMaskUrl};
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.next::after {
|
||||
transform: translate(-50%, -50%) scaleX(-1);
|
||||
}
|
||||
|
||||
.close-x::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: currentColor;
|
||||
-webkit-mask-image: ${closeIconMaskUrl};
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-image: ${closeIconMaskUrl};
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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 { setupDesktopFindInPageBar } from './findInPageBar'
|
||||
import { ipcMainExposed } from './typesUtils'
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
setupDesktopFindInPageBar(ipcMainExposed())
|
||||
} catch (err) {
|
||||
console.error('[find] Overlay UI failed to initialize:', err)
|
||||
}
|
||||
})
|
||||
@@ -43,5 +43,10 @@ export const IpcMessage = {
|
||||
OnDeepLinkHandler: 'on-deep-link-handler',
|
||||
HandleNotificationNavigation: 'handle-notification-navigation',
|
||||
HandleUpdateDownloadProgress: 'handle-update-download-progress',
|
||||
HandleAuth: 'handle-auth'
|
||||
HandleAuth: 'handle-auth',
|
||||
OpenFindBar: 'open-find-bar',
|
||||
FindInPage: 'find-in-page',
|
||||
StopFindInPage: 'stop-find-in-page',
|
||||
FindInPageResult: 'find-in-page-result',
|
||||
FindOverlayLayout: 'find-overlay-layout'
|
||||
} as const
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { BrandingMap, Config, IPCMainExposed, JumpListSpares, MenuBarAction, NotificationParams } from './types'
|
||||
import { BrandingMap, Config, DesktopFoundInPageResult, IPCMainExposed, JumpListSpares, MenuBarAction, NotificationParams } from './types'
|
||||
import { IpcMessage } from './ipcMessages'
|
||||
|
||||
export function concatLink (host: string, path: string): string {
|
||||
@@ -201,6 +201,32 @@ const expose: IPCMainExposed = {
|
||||
},
|
||||
onAutoLaunchSettingChanged: (callback: (enabled: boolean) => void) => {
|
||||
ipcRenderer.on(IpcMessage.AutoLaunchSettingChanged, (_event, enabled: boolean) => { callback(enabled) })
|
||||
},
|
||||
|
||||
onOpenFindBar: (callback: () => void) => {
|
||||
ipcRenderer.removeAllListeners(IpcMessage.OpenFindBar)
|
||||
ipcRenderer.on(IpcMessage.OpenFindBar, () => {
|
||||
callback()
|
||||
})
|
||||
},
|
||||
|
||||
findInPage: async (text, options) => {
|
||||
return await ipcRenderer.invoke(IpcMessage.FindInPage, text, options ?? {})
|
||||
},
|
||||
|
||||
stopFindInPage: async (action) => {
|
||||
await ipcRenderer.invoke(IpcMessage.StopFindInPage, action)
|
||||
},
|
||||
|
||||
onFindInPageResult: (callback: (result: DesktopFoundInPageResult) => void) => {
|
||||
ipcRenderer.removeAllListeners(IpcMessage.FindInPageResult)
|
||||
ipcRenderer.on(IpcMessage.FindInPageResult, (_event, result: DesktopFoundInPageResult) => {
|
||||
callback(result)
|
||||
})
|
||||
},
|
||||
|
||||
notifyFindOverlayLayout: (visible: boolean) => {
|
||||
ipcRenderer.send(IpcMessage.FindOverlayLayout, visible)
|
||||
}
|
||||
}
|
||||
contextBridge.exposeInMainWorld('electron', expose)
|
||||
|
||||
@@ -111,6 +111,7 @@ export function buildHulyApplicationMenu (minimizeToTrayEnabled: boolean, autoLa
|
||||
.addMenuItem(MenuEditIndex, 'Paste', 'paste', 'Ctrl+V', 'p')
|
||||
.addMenuItem(MenuEditIndex, 'Delete', 'delete', 'Delete', 'd')
|
||||
.addSeparator(MenuEditIndex)
|
||||
.addMenuItem(MenuEditIndex, 'Find', 'find', 'Ctrl+F', 'f')
|
||||
.addMenuItem(MenuEditIndex, 'Select All', 'select-all', 'Ctrl+A', 'a')
|
||||
|
||||
const MenuViewIndex = 2
|
||||
|
||||
@@ -140,6 +140,7 @@ export const MenuBarActions = [
|
||||
'copy',
|
||||
'paste',
|
||||
'delete',
|
||||
'find',
|
||||
'select-all',
|
||||
'reload',
|
||||
'force-reload',
|
||||
@@ -200,7 +201,31 @@ export interface IPCMainExposed {
|
||||
onMinimizeToTraySettingChanged: (callback: (enabled: boolean) => void) => void
|
||||
isAutoLaunchEnabled: () => Promise<boolean>
|
||||
onAutoLaunchSettingChanged: (callback: (enabled: boolean) => void) => void
|
||||
|
||||
onOpenFindBar: (callback: () => void) => void
|
||||
findInPage: (text: string, options?: DesktopFindInPageOptions) => Promise<number>
|
||||
stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => Promise<void>
|
||||
onFindInPageResult: (callback: (result: DesktopFoundInPageResult) => void) => void
|
||||
/** Resize the find BrowserView hit target (main process); overlay document only. */
|
||||
notifyFindOverlayLayout: (visible: boolean) => void
|
||||
}
|
||||
|
||||
export type SendCommandDelegate = (cmd: Command, ...args: any[]) => void
|
||||
export type WindowAction = () => void
|
||||
|
||||
/** Options passed to `webContents.findInPage` from the renderer. */
|
||||
export interface DesktopFindInPageOptions {
|
||||
forward?: boolean
|
||||
findNext?: boolean
|
||||
matchCase?: boolean
|
||||
wordStart?: boolean
|
||||
medialCapitalAsWordStart?: boolean
|
||||
}
|
||||
|
||||
/** Payload mirrored from Electron `found-in-page` (subset used by the find bar UI). */
|
||||
export interface DesktopFoundInPageResult {
|
||||
requestId: number
|
||||
activeMatchOrdinal: number
|
||||
matches: number
|
||||
finalUpdate: boolean
|
||||
}
|
||||
|
||||
@@ -126,7 +126,8 @@ module.exports = [
|
||||
{
|
||||
entry: {
|
||||
bundle: ['@hcengineering/theme/styles/global.scss', ...['./src/ui/index.ts']],
|
||||
'recorder-worker': '@hcengineering/recorder-resources/src/recorder-worker.ts'
|
||||
'recorder-worker': '@hcengineering/recorder-resources/src/recorder-worker.ts',
|
||||
findInPageOverlay: './src/ui/findInPageOverlay.ts'
|
||||
},
|
||||
ignoreWarnings: [
|
||||
{
|
||||
@@ -340,6 +341,14 @@ module.exports = [
|
||||
isWindows: true
|
||||
}
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: './src/ui/find-in-page-overlay.ejs',
|
||||
filename: 'find-in-page-overlay.html',
|
||||
chunks: ['findInPageOverlay'],
|
||||
inject: 'body',
|
||||
publicPath: '',
|
||||
scriptLoading: 'blocking'
|
||||
}),
|
||||
...(!dev ? [new CompressionPlugin()] : []),
|
||||
// new MiniCssExtractPlugin({
|
||||
// filename: '[name].[id][contenthash].css'
|
||||
|
||||
Reference in New Issue
Block a user