Skip to content

Commit df6bad6

Browse files
authored
feat(shell): overlay tooltips for AppBar (#1183)
1 parent a28fd27 commit df6bad6

File tree

18 files changed

+413
-14
lines changed

18 files changed

+413
-14
lines changed

electron.vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export default defineConfig({
102102
rollupOptions: {
103103
input: {
104104
shell: resolve('src/renderer/shell/index.html'),
105+
shellTooltipOverlay: resolve('src/renderer/shell/tooltip-overlay/index.html'),
105106
index: resolve('src/renderer/index.html'),
106107
floating: resolve('src/renderer/floating/index.html'),
107108
splash: resolve('src/renderer/splash/index.html'),

src/main/presenter/windowPresenter/index.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export class WindowPresenter implements IWindowPresenter {
4040
>()
4141
private floatingChatWindow: FloatingChatWindow | null = null
4242
private settingsWindow: BrowserWindow | null = null
43+
private tooltipOverlayWindows = new Map<number, BrowserWindow>()
44+
private pendingTooltipPayload = new Map<number, { x: number; y: number; text: string }>()
4345

4446
constructor(configPresenter: IConfigPresenter) {
4547
this.windows = new Map()
@@ -66,6 +68,44 @@ export class WindowPresenter implements IWindowPresenter {
6668
}
6769
})
6870

71+
ipcMain.on(
72+
'shell-tooltip:show',
73+
(event, payload: { x: number; y: number; text: string } | undefined) => {
74+
if (!payload) return
75+
76+
const parentWindow = BrowserWindow.fromWebContents(event.sender)
77+
if (!parentWindow || parentWindow.isDestroyed()) return
78+
79+
const overlay = this.getOrCreateTooltipOverlay(parentWindow)
80+
if (!overlay) return
81+
82+
this.pendingTooltipPayload.set(parentWindow.id, payload)
83+
84+
if (!overlay.webContents.isLoadingMainFrame()) {
85+
overlay.webContents.send('shell-tooltip-overlay:show', payload)
86+
return
87+
}
88+
89+
overlay.webContents.once('did-finish-load', () => {
90+
const pending = this.pendingTooltipPayload.get(parentWindow.id)
91+
if (!pending) return
92+
if (overlay.isDestroyed()) return
93+
overlay.webContents.send('shell-tooltip-overlay:show', pending)
94+
})
95+
}
96+
)
97+
98+
ipcMain.on('shell-tooltip:hide', (event) => {
99+
const parentWindow = BrowserWindow.fromWebContents(event.sender)
100+
if (!parentWindow || parentWindow.isDestroyed()) return
101+
102+
const overlay = this.tooltipOverlayWindows.get(parentWindow.id)
103+
if (!overlay || overlay.isDestroyed()) return
104+
105+
this.pendingTooltipPayload.delete(parentWindow.id)
106+
overlay.webContents.send('shell-tooltip-overlay:hide')
107+
})
108+
69109
// Listen for shortcut event: create new window
70110
eventBus.on(SHORTCUT_EVENTS.CREATE_NEW_WINDOW, () => {
71111
console.log('Creating new shell window via shortcut.')
@@ -734,6 +774,7 @@ export class WindowPresenter implements IWindowPresenter {
734774
if (!shellWindow.isDestroyed()) {
735775
shellWindow.webContents.send('window-blurred', windowId)
736776
}
777+
this.clearTooltipOverlay(windowId)
737778
})
738779

739780
// 窗口最大化
@@ -881,6 +922,7 @@ export class WindowPresenter implements IWindowPresenter {
881922
this.windowFocusStates.delete(windowIdBeingClosed)
882923
shellWindowState.unmanage() // 停止管理窗口状态
883924
eventBus.sendToMain(WINDOW_EVENTS.WINDOW_CLOSED, windowIdBeingClosed)
925+
this.destroyTooltipOverlay(windowIdBeingClosed)
884926
console.log(
885927
`Window ${windowIdBeingClosed} closed event handled. Map size AFTER delete: ${this.windows.size}`
886928
)
@@ -911,6 +953,12 @@ export class WindowPresenter implements IWindowPresenter {
911953
shellWindow.loadFile(join(__dirname, '../renderer/shell/index.html'))
912954
}
913955

956+
// Pre-create tooltip overlay so first hover is instant
957+
shellWindow.webContents.once('did-finish-load', () => {
958+
if (shellWindow.isDestroyed()) return
959+
this.getOrCreateTooltipOverlay(shellWindow)
960+
})
961+
914962
// --- 处理初始标签页创建或激活 ---
915963

916964
// 如果提供了 options?.initialTab,等待窗口加载完成,然后创建新标签页
@@ -982,6 +1030,117 @@ export class WindowPresenter implements IWindowPresenter {
9821030
return windowId // 返回新创建窗口的 ID
9831031
}
9841032

1033+
private getOrCreateTooltipOverlay(parentWindow: BrowserWindow): BrowserWindow | null {
1034+
if (parentWindow.isDestroyed()) return null
1035+
1036+
const existing = this.tooltipOverlayWindows.get(parentWindow.id)
1037+
if (existing && !existing.isDestroyed()) {
1038+
this.syncTooltipOverlayBounds(parentWindow, existing)
1039+
if (!existing.isVisible()) {
1040+
existing.showInactive()
1041+
}
1042+
return existing
1043+
}
1044+
1045+
const bounds = parentWindow.getContentBounds()
1046+
1047+
const overlay = new BrowserWindow({
1048+
x: bounds.x,
1049+
y: bounds.y,
1050+
width: bounds.width,
1051+
height: bounds.height,
1052+
parent: parentWindow,
1053+
show: false,
1054+
frame: false,
1055+
transparent: true,
1056+
backgroundColor: '#00000000',
1057+
resizable: false,
1058+
movable: false,
1059+
minimizable: false,
1060+
maximizable: false,
1061+
closable: false,
1062+
hasShadow: false,
1063+
focusable: false,
1064+
skipTaskbar: true,
1065+
autoHideMenuBar: true,
1066+
webPreferences: {
1067+
preload: join(__dirname, '../preload/index.mjs'),
1068+
sandbox: false,
1069+
devTools: is.dev
1070+
}
1071+
})
1072+
1073+
overlay.setIgnoreMouseEvents(true, { forward: true })
1074+
1075+
const sync = () => {
1076+
const current = this.tooltipOverlayWindows.get(parentWindow.id)
1077+
if (!current || current.isDestroyed() || parentWindow.isDestroyed()) return
1078+
this.syncTooltipOverlayBounds(parentWindow, current)
1079+
}
1080+
1081+
parentWindow.on('move', sync)
1082+
parentWindow.on('resize', sync)
1083+
parentWindow.on('show', () => {
1084+
if (!overlay.isDestroyed()) overlay.showInactive()
1085+
})
1086+
parentWindow.on('hide', () => {
1087+
if (!overlay.isDestroyed()) overlay.hide()
1088+
})
1089+
parentWindow.on('minimize', () => {
1090+
if (!overlay.isDestroyed()) overlay.hide()
1091+
})
1092+
parentWindow.on('restore', () => {
1093+
if (!overlay.isDestroyed()) overlay.showInactive()
1094+
})
1095+
1096+
overlay.on('closed', () => {
1097+
this.tooltipOverlayWindows.delete(parentWindow.id)
1098+
this.pendingTooltipPayload.delete(parentWindow.id)
1099+
})
1100+
1101+
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
1102+
overlay.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/shell/tooltip-overlay/index.html')
1103+
} else {
1104+
overlay.loadFile(join(__dirname, '../renderer/shell/tooltip-overlay/index.html'))
1105+
}
1106+
1107+
overlay.webContents.once('did-finish-load', () => {
1108+
if (overlay.isDestroyed()) return
1109+
overlay.showInactive()
1110+
overlay.webContents.send('shell-tooltip-overlay:clear')
1111+
1112+
const pending = this.pendingTooltipPayload.get(parentWindow.id)
1113+
if (pending) {
1114+
overlay.webContents.send('shell-tooltip-overlay:show', pending)
1115+
}
1116+
})
1117+
1118+
this.tooltipOverlayWindows.set(parentWindow.id, overlay)
1119+
return overlay
1120+
}
1121+
1122+
private syncTooltipOverlayBounds(parentWindow: BrowserWindow, overlay: BrowserWindow): void {
1123+
if (parentWindow.isDestroyed() || overlay.isDestroyed()) return
1124+
const bounds = parentWindow.getContentBounds()
1125+
overlay.setBounds(bounds)
1126+
}
1127+
1128+
private clearTooltipOverlay(windowId: number): void {
1129+
const overlay = this.tooltipOverlayWindows.get(windowId)
1130+
if (!overlay || overlay.isDestroyed()) return
1131+
this.pendingTooltipPayload.delete(windowId)
1132+
overlay.webContents.send('shell-tooltip-overlay:hide')
1133+
}
1134+
1135+
private destroyTooltipOverlay(windowId: number): void {
1136+
const overlay = this.tooltipOverlayWindows.get(windowId)
1137+
if (overlay && !overlay.isDestroyed()) {
1138+
overlay.destroy()
1139+
}
1140+
this.tooltipOverlayWindows.delete(windowId)
1141+
this.pendingTooltipPayload.delete(windowId)
1142+
}
1143+
9851144
/**
9861145
* 更新指定窗口的内容保护设置。
9871146
* @param window BrowserWindow 实例。

src/renderer/shell/components/AppBar.vue

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
variant="ghost"
1919
class="window-no-drag-region shrink-0 w-10 bg-transparent shadow-none rounded-none hover:bg-card/80 text-xs font-medium text-foreground flex items-center justify-center transition-all duration-200 group border-r border-border"
2020
@click="scrollTabContainer('left')"
21+
@mouseenter="onOverlayMouseEnter('scroll-left', t('common.scrollLeft'), $event)"
22+
@mouseleave="onOverlayMouseLeave('scroll-left')"
2123
>
2224
<Icon icon="lucide:chevron-left" class="w-4 h-4" />
2325
</Button>
@@ -26,6 +28,8 @@
2628
variant="ghost"
2729
class="window-no-drag-region shrink-0 w-10 bg-transparent shadow-none rounded-none hover:bg-card/80 text-xs font-medium text-foreground flex items-center justify-center transition-all duration-200 group border-border"
2830
@click="scrollTabContainer('right')"
31+
@mouseenter="onOverlayMouseEnter('scroll-right', t('common.scrollRight'), $event)"
32+
@mouseleave="onOverlayMouseLeave('scroll-right')"
2933
>
3034
<Icon icon="lucide:chevron-right" class="w-4 h-4" />
3135
</Button>
@@ -51,6 +55,8 @@
5155
@close="tabStore.removeTab(tab.id)"
5256
@dragstart="onTabDragStart(tab.id, $event)"
5357
@dragover="onTabItemDragOver(idx, $event)"
58+
@mouseenter="onTabMouseEnter(tab, $event)"
59+
@mouseleave="onTabMouseLeave(tab.id)"
5460
>
5561
<img src="@/assets/logo.png" class="w-4 h-4 mr-2 rounded-sm" />
5662
<span class="truncate">{{ tab.title ?? 'DeepChat' }}</span>
@@ -69,6 +75,8 @@
6975
size="icon"
7076
class="window-no-drag-region shrink-0 w-10 bg-transparent shadow-none rounded-none hover:bg-card/80 text-xs font-medium text-foreground flex items-center justify-center transition-all duration-200 group"
7177
@click="onNewTabClick"
78+
@mouseenter="onOverlayMouseEnter('new-tab', t('common.newTab'), $event)"
79+
@mouseleave="onOverlayMouseLeave('new-tab')"
7280
>
7381
<Icon icon="lucide:plus" class="w-4 h-4" />
7482
</Button>
@@ -78,27 +86,41 @@
7886
size="icon"
7987
class="window-no-drag-region shrink-0 w-10 bg-transparent shadow-none rounded-none hover:bg-card/80 text-xs font-medium text-foreground flex items-center justify-center transition-all duration-200 group border-l"
8088
@click="onHistoryClick"
89+
@mouseenter="onOverlayMouseEnter('history', t('common.history'), $event)"
90+
@mouseleave="onOverlayMouseLeave('history')"
8191
>
8292
<Icon icon="lucide:history" class="w-4 h-4" />
8393
</Button>
8494
<Button
8595
size="icon"
8696
class="window-no-drag-region shrink-0 w-10 bg-transparent shadow-none rounded-none hover:bg-card/80 text-xs font-medium text-foreground flex items-center justify-center transition-all duration-200 group border-l"
8797
@click="openSettings"
98+
@mouseenter="onOverlayMouseEnter('settings', t('routes.settings'), $event)"
99+
@mouseleave="onOverlayMouseLeave('settings')"
88100
>
89101
<Icon icon="lucide:ellipsis" class="w-4 h-4" />
90102
</Button>
91103
<Button
92104
v-if="!isMacOS"
93105
class="window-no-drag-region shrink-0 w-12 bg-transparent shadow-none rounded-none hover:bg-card/80 text-xs font-medium text-foreground flex items-center justify-center transition-all duration-200 group border-l"
94106
@click="minimizeWindow"
107+
@mouseenter="onOverlayMouseEnter('minimize', t('common.minimize'), $event)"
108+
@mouseleave="onOverlayMouseLeave('minimize')"
95109
>
96110
<MinimizeIcon class="h-3! w-3!" />
97111
</Button>
98112
<Button
99113
v-if="!isMacOS"
100114
class="window-no-drag-region shrink-0 w-12 bg-transparent shadow-none rounded-none hover:bg-card/80 text-xs font-medium text-foreground flex items-center justify-center transition-all duration-200 group"
101115
@click="toggleMaximize"
116+
@mouseenter="
117+
onOverlayMouseEnter(
118+
'toggle-maximize',
119+
isMaximized ? t('common.restore') : t('common.maximize'),
120+
$event
121+
)
122+
"
123+
@mouseleave="onOverlayMouseLeave('toggle-maximize')"
102124
>
103125
<MaximizeIcon v-if="!isMaximized" class="h-3! w-3!" />
104126
<RestoreIcon v-else class="h-3! w-3!" />
@@ -107,6 +129,8 @@
107129
v-if="!isMacOS"
108130
class="window-no-drag-region shrink-0 w-12 bg-transparent shadow-none rounded-none hover:bg-red-700/80 hover:text-white text-xs font-medium text-foreground flex items-center justify-center transition-all duration-200 group"
109131
@click="closeWindow"
132+
@mouseenter="onOverlayMouseEnter('close-window', t('common.close'), $event)"
133+
@mouseleave="onOverlayMouseLeave('close-window')"
110134
>
111135
<CloseIcon class="h-3! w-3!" />
112136
</Button>
@@ -123,7 +147,7 @@
123147
</template>
124148

125149
<script setup lang="ts">
126-
import { ref, onMounted, nextTick, computed } from 'vue'
150+
import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
127151
import MaximizeIcon from './icons/MaximizeIcon.vue'
128152
import RestoreIcon from './icons/RestoreIcon.vue'
129153
import { usePresenter } from '@/composables/usePresenter'
@@ -160,13 +184,76 @@ let draggedTabId: number | null = null
160184
const dragInsertIndex = ref(-1)
161185
const dragInsertPosition = ref(0)
162186
187+
type TooltipHoverTarget = { key: string; text: string; el: HTMLElement }
188+
189+
const hoveredTarget = ref<TooltipHoverTarget | null>(null)
190+
const isTooltipOpen = ref(false)
191+
let tooltipTimer: number | null = null
192+
163193
const tabContainerWrapperSize = useElementSize(tabContainerWrapper)
164194
const tabContainerSize = useElementSize(tabContainer)
165195
const tabContainerWrapperScrollLeft = ref(0)
166196
197+
const sendTooltipShow = (el: HTMLElement, text: string) => {
198+
const rect = el.getBoundingClientRect()
199+
ipcRenderer.send('shell-tooltip:show', {
200+
x: rect.left + rect.width / 2,
201+
y: rect.bottom + 8,
202+
text
203+
})
204+
}
205+
206+
const hideTooltip = () => {
207+
isTooltipOpen.value = false
208+
ipcRenderer.send('shell-tooltip:hide')
209+
}
210+
211+
const updateTooltipPosition = () => {
212+
if (!isTooltipOpen.value) return
213+
const current = hoveredTarget.value
214+
if (!current) return
215+
sendTooltipShow(current.el, current.text)
216+
}
217+
218+
const onOverlayMouseEnter = (key: string, text: string, event: MouseEvent) => {
219+
if (tooltipTimer != null) window.clearTimeout(tooltipTimer)
220+
221+
const el = event.currentTarget as HTMLElement | null
222+
if (!el) return
223+
224+
hoveredTarget.value = { key, el, text }
225+
226+
tooltipTimer = window.setTimeout(() => {
227+
if (!hoveredTarget.value || hoveredTarget.value.key !== key) return
228+
isTooltipOpen.value = true
229+
updateTooltipPosition()
230+
}, 200)
231+
}
232+
233+
const onOverlayMouseLeave = (key: string) => {
234+
if (tooltipTimer != null) {
235+
window.clearTimeout(tooltipTimer)
236+
tooltipTimer = null
237+
}
238+
239+
if (hoveredTarget.value && hoveredTarget.value.key === key) {
240+
hoveredTarget.value = null
241+
hideTooltip()
242+
}
243+
}
244+
245+
const onTabMouseEnter = (tab: { id: number; title?: string | null }, event: MouseEvent) => {
246+
onOverlayMouseEnter(`tab:${tab.id}`, tab.title ?? 'DeepChat', event)
247+
}
248+
249+
const onTabMouseLeave = (tabId: number) => {
250+
onOverlayMouseLeave(`tab:${tabId}`)
251+
}
252+
167253
const onTabContainerWrapperScroll = () => {
168254
requestAnimationFrame(() => {
169255
tabContainerWrapperScrollLeft.value = tabContainerWrapper.value?.scrollLeft ?? 0
256+
updateTooltipPosition()
170257
})
171258
}
172259
@@ -486,6 +573,12 @@ onMounted(() => {
486573
487574
window.addEventListener('dragover', handleDragOver)
488575
window.addEventListener('dragend', handleDragEnd)
576+
window.addEventListener('resize', updateTooltipPosition)
577+
})
578+
579+
onBeforeUnmount(() => {
580+
window.removeEventListener('resize', updateTooltipPosition)
581+
hideTooltip()
489582
})
490583
491584
const isPlaygroundEnabled = import.meta.env.VITE_ENABLE_PLAYGROUND === 'true'

0 commit comments

Comments
 (0)