diff --git a/package-lock.json b/package-lock.json index 6c5be65..578850b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@ecromaneli/search-engine": "^3.0.0", "auto-launch": "^5.0.6", + "delta-move": "^1.0.0", "electron-context-menu": "^4.1.2", "electron-draggable": "^1.6.1", "electron-findbar": "^3.7.0", @@ -3237,6 +3238,15 @@ "node": ">=0.4.0" } }, + "node_modules/delta-move": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delta-move/-/delta-move-1.0.0.tgz", + "integrity": "sha512-UGhLJbs3Wd9OwgXLpEbW98qYSxmOvXvWuJmDKEjGfUDpVwksxQ557tuhToZQSwpQ8cvvM1z/ZxdPvBiEgj7Fiw==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", diff --git a/package.json b/package.json index 47ee99b..ded4db4 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "dependencies": { "@ecromaneli/search-engine": "^3.0.0", "auto-launch": "^5.0.6", + "delta-move": "^1.0.0", "electron-context-menu": "^4.1.2", "electron-draggable": "^1.6.1", "electron-findbar": "^3.7.0", @@ -59,4 +60,4 @@ "electron-updater": "^6.8.3", "vue": "^3.5.32" } -} \ No newline at end of file +} diff --git a/src/data/Constants.ts b/src/data/Constants.ts index 994aa9c..c4c2196 100644 --- a/src/data/Constants.ts +++ b/src/data/Constants.ts @@ -52,7 +52,7 @@ const Positions = { // eslint-disable-next-line @typescript-eslint/no-explicit-any const DefaultSettings: Record = {}; -DefaultSettings[Settings.SHOW_FRAME] = true; +DefaultSettings[Settings.SHOW_FRAME] = 'always'; DefaultSettings[Settings.BACKGROUND_COLOR] = '#171717'; DefaultSettings[Settings.FOCUS_OPACITY] = 100; DefaultSettings[Settings.BLUR_OPACITY] = 90; diff --git a/src/locale/de.ts b/src/locale/de.ts index b7053c5..cc60b7d 100644 --- a/src/locale/de.ts +++ b/src/locale/de.ts @@ -223,6 +223,10 @@ export const de: Strings = { clipboardUrlSessionDesc: 'Legt die Sitzung fest, die für die Zwischenablage-URL-Seite verwendet wird. Leer lassen, um die Standardsitzung zu verwenden.', showFrame: 'Titelleiste anzeigen', + showFrameDesc: 'Steuert die Sichtbarkeit der Navigationsleiste.', + showFrameAlways: 'Immer', + showFrameNever: 'Nie', + showFrameOnHover: 'Beim Hovern', backgroundColor: 'Hintergrundfarbe', backgroundColorDesc: 'Hintergrundfarbe für ladende Fenster.', focusOpacity: 'Deckkraft bei Fokus', diff --git a/src/locale/en.ts b/src/locale/en.ts index 09f519a..2c00249 100644 --- a/src/locale/en.ts +++ b/src/locale/en.ts @@ -222,6 +222,10 @@ export const en = { clipboardUrlSessionDesc: 'Set the session used by the Clipboard URL page. Leave empty to use the default session.', showFrame: 'Show frame', + showFrameDesc: 'Controls the visibility of the navigation bar.', + showFrameAlways: 'Always', + showFrameNever: 'Never', + showFrameOnHover: 'On hover', backgroundColor: 'Background color', backgroundColorDesc: 'Background color for loading windows.', focusOpacity: 'Opacity when focused', diff --git a/src/locale/es.ts b/src/locale/es.ts index 4d0dee4..92d8227 100644 --- a/src/locale/es.ts +++ b/src/locale/es.ts @@ -223,6 +223,10 @@ export const es: Strings = { clipboardUrlSessionDesc: 'Define la sesión utilizada por la página de URL del portapapeles. Dejar vacío para usar la sesión predeterminada.', showFrame: 'Mostrar barra de título', + showFrameDesc: 'Controla la visibilidad de la barra de navegación.', + showFrameAlways: 'Siempre', + showFrameNever: 'Nunca', + showFrameOnHover: 'Al pasar el ratón', backgroundColor: 'Color de fondo', backgroundColorDesc: 'Color de fondo para ventanas en carga.', focusOpacity: 'Opacidad cuando está enfocado', diff --git a/src/locale/fr.ts b/src/locale/fr.ts index 22b4282..f27d324 100644 --- a/src/locale/fr.ts +++ b/src/locale/fr.ts @@ -223,6 +223,10 @@ export const fr: Strings = { clipboardUrlSessionDesc: 'Définit la session utilisée par la page URL du presse-papiers. Laisser vide pour utiliser la session par défaut.', showFrame: 'Afficher la barre de titre', + showFrameDesc: 'Contrôle la visibilité de la barre de navigation.', + showFrameAlways: 'Toujours', + showFrameNever: 'Jamais', + showFrameOnHover: 'Au survol', backgroundColor: 'Couleur d\'arrière-plan', backgroundColorDesc: 'Couleur d\'arrière-plan pour les fenêtres en chargement.', focusOpacity: 'Opacité quand focalisé', diff --git a/src/locale/it.ts b/src/locale/it.ts index c05143b..cbc9df5 100644 --- a/src/locale/it.ts +++ b/src/locale/it.ts @@ -223,6 +223,10 @@ export const it: Strings = { clipboardUrlSessionDesc: 'Imposta la sessione utilizzata dalla pagina URL degli appunti. Lasciare vuoto per utilizzare la sessione predefinita.', showFrame: 'Mostra barra del titolo', + showFrameDesc: 'Controlla la visibilità della barra di navigazione.', + showFrameAlways: 'Sempre', + showFrameNever: 'Mai', + showFrameOnHover: 'Al passaggio del mouse', backgroundColor: 'Colore di sfondo', backgroundColorDesc: 'Colore di sfondo per le finestre in caricamento.', focusOpacity: 'Opacità con focus', diff --git a/src/locale/pt-BR.ts b/src/locale/pt-BR.ts index 38fe5e2..594dff6 100644 --- a/src/locale/pt-BR.ts +++ b/src/locale/pt-BR.ts @@ -223,6 +223,10 @@ export const ptBR: Strings = { clipboardUrlSessionDesc: 'Define a sessão utilizada pela página de URL da área de transferência. Deixe vazio para usar a sessão padrão.', showFrame: 'Mostrar barra de título', + showFrameDesc: 'Controla a visibilidade da barra de navegação.', + showFrameAlways: 'Sempre', + showFrameNever: 'Nunca', + showFrameOnHover: 'Ao passar o mouse', backgroundColor: 'Cor de fundo', backgroundColorDesc: 'Cor de fundo para janelas em carregamento.', focusOpacity: 'Opacidade quando focado', diff --git a/src/locale/pt-PT.ts b/src/locale/pt-PT.ts index 0913cff..8569eb8 100644 --- a/src/locale/pt-PT.ts +++ b/src/locale/pt-PT.ts @@ -223,6 +223,10 @@ export const ptPT: Strings = { clipboardUrlSessionDesc: 'Define a sessão utilizada pela página de URL da área de transferência. Deixe vazio para utilizar a sessão predefinida.', showFrame: 'Mostrar barra de título', + showFrameDesc: 'Controla a visibilidade da barra de navegação.', + showFrameAlways: 'Sempre', + showFrameNever: 'Nunca', + showFrameOnHover: 'Ao passar o rato', backgroundColor: 'Cor de fundo', backgroundColorDesc: 'Cor de fundo para janelas em carregamento.', focusOpacity: 'Opacidade com foco', diff --git a/src/locale/ru.ts b/src/locale/ru.ts index de94271..b6d4fe6 100644 --- a/src/locale/ru.ts +++ b/src/locale/ru.ts @@ -223,6 +223,10 @@ export const ru: Strings = { clipboardUrlSessionDesc: 'Укажите сессию, используемую страницей URL из буфера обмена. Оставьте пустым для использования сессии по умолчанию.', showFrame: 'Показывать заголовок окна', + showFrameDesc: 'Управляет видимостью панели навигации.', + showFrameAlways: 'Всегда', + showFrameNever: 'Никогда', + showFrameOnHover: 'При наведении', backgroundColor: 'Цвет фона', backgroundColorDesc: 'Цвет фона для загружающихся окон.', focusOpacity: 'Прозрачность в фокусе', diff --git a/src/propagator/ViewPropagator.ts b/src/propagator/ViewPropagator.ts index df58ce7..d623ee0 100644 --- a/src/propagator/ViewPropagator.ts +++ b/src/propagator/ViewPropagator.ts @@ -12,6 +12,14 @@ export class ViewPropagator extends Propagator { if (!(inp.control || inp.alt || inp.meta || inp.shift)) { return; } this.emitCurrentEvent(wc, 'before-special-keydown', e, inp); }); + // @ts-expect-error Electron v41+ before-mouse-event + wc.on('before-mouse-event', (_e: unknown, mouseEvent: { type: string }) => { + if (mouseEvent.type === 'mouseMove') { + this.emitCurrentEvent(wc, 'mouse-enter'); + } else if (mouseEvent.type === 'mouseLeave') { + this.emitCurrentEvent(wc, 'mouse-leave'); + } + }); wc.on('did-navigate-in-page', () => { this.emitCurrentEvent(wc, 'did-navigate-in-page'); }); wc.on('did-start-loading', () => { this.emitCurrentEvent(wc, 'did-start-loading'); }); wc.on('did-stop-loading', () => { this.emitCurrentEvent(wc, 'did-stop-loading'); }); diff --git a/src/service/FrameService.ts b/src/service/FrameService.ts index 310b215..773a42d 100644 --- a/src/service/FrameService.ts +++ b/src/service/FrameService.ts @@ -12,6 +12,7 @@ import { getAcceleratorByEvent } from '@/util/EventKeyCapture'; import { BaseWindow, BaseWindowConstructorOptions, Event, Input, WebContents, WebContentsView, screen } from 'electron'; import { Draggable } from 'electron-draggable'; import Findbar from 'electron-findbar'; +import DeltaMove from 'delta-move'; import { EventEmitter } from 'node:stream'; class FrameService { @@ -28,6 +29,26 @@ class FrameService { autoHideMenuBar: true, }; + private static readonly NAVBAR_HIDE_DELAY_MS = 100; + private static readonly NAVBAR_ANIMATION_DURATION_MS = 200; + + private navbarVisible = false; + private hoverLeaveTimeout: NodeJS.Timeout | undefined; + + private getShowFrameMode(): string { + const value = Storage.getSettings(Settings.SHOW_FRAME); + if (value === true) { return 'always'; } + if (value === false) { return 'never'; } + return value as string; + } + + private clearHoverTimeout(): void { + if (this.hoverLeaveTimeout) { + clearTimeout(this.hoverLeaveTimeout); + this.hoverLeaveTimeout = undefined; + } + } + constructor() { this.registerStateListeners(); this.registerInstanceEvents(); @@ -37,6 +58,9 @@ class FrameService { private registerCustomViewEvents(): void { FramePropagator.on('show', () => { ViewService.getCurrentView()!.emit('show'); }); FramePropagator.on('hide', () => { ViewService.getCurrentView()!.emit('hide'); }); + + ViewPropagator.onCurrentView('mouse-enter', () => this.onMouseEnterFrame()); + ViewPropagator.onCurrentView('mouse-leave', () => this.onMouseLeaveFrame()); } private getFrameOptions(): BaseWindowConstructorOptions { @@ -150,8 +174,15 @@ class FrameService { if (navbar && !frame.isFullScreen()) { const navbarHeight = NavbarService.NAVBAR_HEIGHT; - navbar.setBounds({ x: 0, y: 0, width: size[0] - rightMargin, height: navbarHeight }); - view.setBounds({ x: 0, y: navbarHeight, width: size[0] - rightMargin, height: size[1] - navbarHeight }); + const showFrame = this.getShowFrameMode(); + + if (showFrame === 'hover' && !this.navbarVisible) { + navbar.setBounds({ x: 0, y: -navbarHeight, width: size[0] - rightMargin, height: navbarHeight }); + view.setBounds({ x: 0, y: 0, width: size[0] - rightMargin, height: size[1] }); + } else { + navbar.setBounds({ x: 0, y: 0, width: size[0] - rightMargin, height: navbarHeight }); + view.setBounds({ x: 0, y: navbarHeight, width: size[0] - rightMargin, height: size[1] - navbarHeight }); + } } else { view.setBounds({ x: 0, y: 0, width: size[0] - rightMargin, height: size[1] }); } @@ -336,16 +367,83 @@ class FrameService { } public setupNavbarForCurrentPage(frame = this.getFrame()!): void { - if (!Storage.getSettings(Settings.SHOW_FRAME)) { + const showFrame = this.getShowFrameMode(); + + if (showFrame === 'never') { if (NavbarService.hasView()) { frame.contentView.removeChildView(NavbarService.getView()!); } NavbarService.close(); + this.navbarVisible = false; return; } NavbarService.hasView() || NavbarService.createView(); NavbarService.onLoadChangeView(); + + if (showFrame === 'hover') { + this.navbarVisible = false; + this.registerNavbarMouseEvents(); + } else { + this.navbarVisible = true; + } + } + + private registerNavbarMouseEvents(): void { + const navbar = NavbarService.getView(); + if (!navbar) { return; } + + // @ts-expect-error Electron v41+ before-mouse-event + navbar.webContents.on('before-mouse-event', (_e: unknown, mouseEvent: { type: string }) => { + if (mouseEvent.type === 'mouseMove') { + this.onMouseEnterFrame(); + } else if (mouseEvent.type === 'mouseLeave') { + this.onMouseLeaveFrame(); + } + }); + } + + private onMouseEnterFrame(): void { + if (this.getShowFrameMode() !== 'hover') { return; } + this.clearHoverTimeout(); + if (!this.navbarVisible) { this.animateNavbar(true); } + } + + private onMouseLeaveFrame(): void { + if (this.getShowFrameMode() !== 'hover') { return; } + this.clearHoverTimeout(); + this.hoverLeaveTimeout = setTimeout(() => { + this.hoverLeaveTimeout = undefined; + if (this.navbarVisible) { this.animateNavbar(false); } + }, FrameService.NAVBAR_HIDE_DELAY_MS); + } + + private animateNavbar(show: boolean): void { + const frame = this.getFrame(); + const navbar = NavbarService.getView(); + const view = ViewService.getCurrentView(); + if (!frame || frame.isDestroyed() || !navbar || !view) { return; } + + const navbarHeight = NavbarService.NAVBAR_HEIGHT; + const size = frame.getSize(); + const rightMargin = OS.IS_WIN32 && frame.isMaximized() + ? Storage.getSettings(Settings.RIGHT_MARGIN_WHEN_MAXIMIZED) + : 0; + const width = size[0] - rightMargin; + const height = size[1]; + + this.navbarVisible = show; + + const range: [number, number] = show ? [0, navbarHeight] : [navbarHeight, 0]; + + DeltaMove.animate((y) => { + if (frame.isDestroyed()) { return; } + navbar.setBounds({ x: 0, y: y - navbarHeight, width, height: navbarHeight }); + view.setBounds({ x: 0, y, width, height: height - y }); + }, { + id: 'navbar-hover', duration: FrameService.NAVBAR_ANIMATION_DURATION_MS, + range, effect: 'ease-out', + }).catch(() => { /* animation cancelled or replaced */ }); } public isFocused(): boolean { diff --git a/web/preferences/components/settings/Settings.js b/web/preferences/components/settings/Settings.js index 139a610..95e9fdb 100644 --- a/web/preferences/components/settings/Settings.js +++ b/web/preferences/components/settings/Settings.js @@ -51,6 +51,9 @@ app.component('Settings', { options.push({ label: s.screenPositions[value], value: value }) }) + const showFrameRaw = await storage.getSettings(this.$const.Settings.SHOW_FRAME) + const showFrameValue = showFrameRaw === true ? 'always' : showFrameRaw === false ? 'never' : showFrameRaw + this.inputs = { [s.general]: [ { @@ -155,7 +158,15 @@ app.component('Settings', { { id: this.$const.Settings.SHOW_FRAME, label: s.showFrame, - data: { type: 'bool', value: await storage.getSettings(this.$const.Settings.SHOW_FRAME) } + description: s.showFrameDesc, + data: { + type: 'select', value: showFrameValue, + options: [ + { label: s.showFrameAlways, value: 'always' }, + { label: s.showFrameOnHover, value: 'hover' }, + { label: s.showFrameNever, value: 'never' } + ] + } }, { id: this.$const.Settings.BACKGROUND_COLOR,