diff --git a/src/main/events.ts b/src/main/events.ts index 1d959c698..6c0f2bde4 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -230,6 +230,7 @@ export const FLOATING_BUTTON_EVENTS = { VISIBILITY_CHANGED: 'floating-button:visibility-changed', // 悬浮按钮显示状态改变 POSITION_CHANGED: 'floating-button:position-changed', // 悬浮按钮位置改变 ENABLED_CHANGED: 'floating-button:enabled-changed', // 悬浮按钮启用状态改变 + HOVER_STATE_CHANGED: 'floating-button:hover-state-changed', SNAPSHOT_REQUEST: 'floating-button:snapshot-request', SNAPSHOT_UPDATED: 'floating-button:snapshot-updated', LANGUAGE_REQUEST: 'floating-button:language-request', diff --git a/src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts b/src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts index b98617b70..9255e7325 100644 --- a/src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts +++ b/src/main/presenter/floatingButtonPresenter/FloatingButtonWindow.ts @@ -10,8 +10,6 @@ import { } from './layout' import windowStateManager from 'electron-window-state' -const FLOATING_WIDGET_WINDOW_OPACITY = 1 - export class FloatingButtonWindow { private window: BrowserWindow | null = null private config: FloatingButtonConfig @@ -83,7 +81,7 @@ export class FloatingButtonWindow { this.windowState.manage(this.window) this.window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) this.window.setAlwaysOnTop(this.config.alwaysOnTop, 'floating') - this.window.setOpacity(FLOATING_WIDGET_WINDOW_OPACITY) + this.window.setOpacity(1) this.setBounds(initialBounds) if (isDev) { @@ -135,7 +133,7 @@ export class FloatingButtonWindow { return } - this.window.setOpacity(FLOATING_WIDGET_WINDOW_OPACITY) + this.window.setOpacity(1) if (config.alwaysOnTop !== undefined) { this.window.setAlwaysOnTop(this.config.alwaysOnTop, 'floating') @@ -176,6 +174,14 @@ export class FloatingButtonWindow { this.state.bounds = { ...bounds } } + public setOpacity(opacity: number): void { + if (!this.window || this.window.isDestroyed()) { + return + } + + this.window.setOpacity(opacity) + } + public getDockSide(): FloatingWidgetDockSide { return this.dockSide } diff --git a/src/main/presenter/floatingButtonPresenter/index.ts b/src/main/presenter/floatingButtonPresenter/index.ts index 7132f9ed2..f22aec505 100644 --- a/src/main/presenter/floatingButtonPresenter/index.ts +++ b/src/main/presenter/floatingButtonPresenter/index.ts @@ -2,6 +2,7 @@ import { FloatingButtonWindow } from './FloatingButtonWindow' import { FloatingButtonConfig, FloatingButtonState, DEFAULT_FLOATING_BUTTON_CONFIG } from './types' import { buildFloatingWidgetSnapshot, + getPeekedCollapsedBounds, getWidgetSizeForSnapshot, repositionWidgetForResize, snapWidgetBoundsToEdge, @@ -23,6 +24,9 @@ const EMPTY_SNAPSHOT: FloatingWidgetSnapshot = { const WIDGET_LAYOUT_ANIMATION_DURATION_MS = 360 const WIDGET_LAYOUT_ANIMATION_INTERVAL_MS = 16 +const COLLAPSE_REVEAL_LOCK_MS = WIDGET_LAYOUT_ANIMATION_DURATION_MS + 120 +const COLLAPSED_WIDGET_INACTIVE_OPACITY = 0.5 +const ACTIVE_WIDGET_OPACITY = 1 type DragRuntimeState = { startX: number @@ -39,7 +43,10 @@ export class FloatingButtonPresenter { private configPresenter: IConfigPresenter private snapshot: FloatingWidgetSnapshot = { ...EMPTY_SNAPSHOT } private layoutAnimationTimer: ReturnType | null = null + private collapseRevealTimer: ReturnType | null = null private isDragging = false + private isHovered = false + private collapseRevealLock = false private pendingLayoutSync = false constructor(configPresenter: IConfigPresenter) { @@ -80,6 +87,8 @@ export class FloatingButtonPresenter { this.config.enabled = false this.snapshot = { ...EMPTY_SNAPSHOT } this.isDragging = false + this.isHovered = false + this.clearCollapseRevealLock() this.pendingLayoutSync = false this.stopLayoutAnimation() @@ -88,6 +97,7 @@ export class FloatingButtonPresenter { ipcMain.removeHandler(FLOATING_BUTTON_EVENTS.THEME_REQUEST) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.CLICKED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.RIGHT_CLICKED) + ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.HOVER_STATE_CHANGED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.TOGGLE_EXPANDED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.SET_EXPANDED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.OPEN_SESSION) @@ -111,10 +121,10 @@ export class FloatingButtonPresenter { this.config.enabled = true if (this.floatingWindow) { - this.floatingWindow.show() await this.refreshWidgetState() this.refreshLanguage() await this.refreshTheme() + this.floatingWindow.show() return } @@ -190,10 +200,10 @@ export class FloatingButtonPresenter { await this.floatingWindow.create() } - this.floatingWindow.show() await this.refreshWidgetState() this.refreshLanguage() await this.refreshTheme() + this.floatingWindow.show() } private registerIpcHandlers(): void { @@ -202,6 +212,7 @@ export class FloatingButtonPresenter { ipcMain.removeHandler(FLOATING_BUTTON_EVENTS.THEME_REQUEST) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.CLICKED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.RIGHT_CLICKED) + ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.HOVER_STATE_CHANGED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.TOGGLE_EXPANDED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.SET_EXPANDED) ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.OPEN_SESSION) @@ -234,6 +245,10 @@ export class FloatingButtonPresenter { this.showContextMenu() }) + ipcMain.on(FLOATING_BUTTON_EVENTS.HOVER_STATE_CHANGED, (_event, hovering: boolean) => { + this.setHovering(Boolean(hovering)) + }) + ipcMain.on(FLOATING_BUTTON_EVENTS.TOGGLE_EXPANDED, () => { this.toggleExpanded() }) @@ -257,9 +272,11 @@ export class FloatingButtonPresenter { } this.stopLayoutAnimation() + this.clearCollapseRevealLock() + this.isDragging = true const stableBounds = this.getSnapshotBounds(bounds) this.floatingWindow.setBounds(stableBounds) - this.isDragging = true + this.floatingWindow.setOpacity(this.resolveWindowOpacity()) dragState = { startX: x, @@ -313,7 +330,12 @@ export class FloatingButtonPresenter { this.floatingWindow.setBounds(snapped) this.isDragging = false dragState = null + this.floatingWindow.setOpacity(this.resolveWindowOpacity()) + const hadPendingLayoutSync = this.pendingLayoutSync this.flushPendingLayoutSync() + if (!hadPendingLayoutSync) { + this.applyWindowLayout() + } }) } @@ -322,10 +344,20 @@ export class FloatingButtonPresenter { return } + const wasExpanded = this.snapshot.expanded + if (expanded) { + this.clearCollapseRevealLock() + } + this.snapshot = { ...this.snapshot, expanded } + + if (wasExpanded && !expanded) { + this.engageCollapseRevealLock() + } + this.applyWindowLayout(true) this.pushSnapshotToRenderer() } @@ -334,6 +366,20 @@ export class FloatingButtonPresenter { this.setExpanded(!this.snapshot.expanded) } + private setHovering(hovering: boolean): void { + if (this.isHovered === hovering) { + return + } + + this.isHovered = hovering + + if (!this.snapshot.expanded && this.collapseRevealLock) { + return + } + + this.applyWindowLayout(true) + } + private applyWindowLayout(animate = false): void { if (!this.floatingWindow?.exists()) { return @@ -350,6 +396,7 @@ export class FloatingButtonPresenter { } const nextBounds = this.getSnapshotBounds(bounds) + this.floatingWindow.setOpacity(this.resolveWindowOpacity()) if (!animate || this.areBoundsEqual(bounds, nextBounds)) { this.stopLayoutAnimation() @@ -411,6 +458,29 @@ export class FloatingButtonPresenter { } } + private clearCollapseRevealLock(): void { + this.collapseRevealLock = false + + if (this.collapseRevealTimer) { + clearTimeout(this.collapseRevealTimer) + this.collapseRevealTimer = null + } + } + + private engageCollapseRevealLock(): void { + this.collapseRevealLock = true + + if (this.collapseRevealTimer) { + clearTimeout(this.collapseRevealTimer) + } + + this.collapseRevealTimer = setTimeout(() => { + this.collapseRevealTimer = null + this.collapseRevealLock = false + this.applyWindowLayout(true) + }, COLLAPSE_REVEAL_LOCK_MS) + } + private flushPendingLayoutSync(): void { if (!this.pendingLayoutSync) { return @@ -426,12 +496,32 @@ export class FloatingButtonPresenter { } const currentDisplay = screen.getDisplayMatching(bounds) - return repositionWidgetForResize( + const resizedBounds = repositionWidgetForResize( bounds, getWidgetSizeForSnapshot(this.snapshot), currentDisplay.workArea, this.floatingWindow.getDockSide() ) + + if (!this.snapshot.expanded && !this.shouldRevealCollapsedWidget()) { + return getPeekedCollapsedBounds( + resizedBounds, + currentDisplay.workArea, + this.floatingWindow.getDockSide() + ) + } + + return resizedBounds + } + + private shouldRevealCollapsedWidget(): boolean { + return this.snapshot.expanded || this.isHovered || this.isDragging || this.collapseRevealLock + } + + private resolveWindowOpacity(): number { + return this.shouldRevealCollapsedWidget() + ? ACTIVE_WIDGET_OPACITY + : COLLAPSED_WIDGET_INACTIVE_OPACITY } private easeInOutCubic(progress: number): number { diff --git a/src/main/presenter/floatingButtonPresenter/layout.ts b/src/main/presenter/floatingButtonPresenter/layout.ts index b8a9ea358..f6fe2a99b 100644 --- a/src/main/presenter/floatingButtonPresenter/layout.ts +++ b/src/main/presenter/floatingButtonPresenter/layout.ts @@ -15,8 +15,8 @@ export interface WidgetRect { } export const FLOATING_WIDGET_LAYOUT = { - collapsedIdle: { width: 64, height: 64 }, - collapsedBusy: { width: 64, height: 64 }, + collapsedIdle: { width: 50, height: 50 }, + collapsedBusy: { width: 50, height: 50 }, expandedWidth: 388, expandedMinHeight: 168, expandedMaxHeight: 392, @@ -147,6 +147,25 @@ export function repositionWidgetForResize( } } +export function getPeekedCollapsedBounds( + bounds: WidgetRect, + workArea: WidgetRect, + dockSide: FloatingWidgetDockSide +): WidgetRect { + const hiddenWidth = Math.round(bounds.width / 2) + const x = + dockSide === 'left' + ? workArea.x - hiddenWidth + : workArea.x + workArea.width - bounds.width + hiddenWidth + + return { + x: Math.round(x), + y: clampWidgetY(bounds.y, bounds.height, workArea), + width: bounds.width, + height: bounds.height + } +} + export function snapWidgetBoundsToEdge( bounds: WidgetRect, workArea: WidgetRect diff --git a/src/preload/floating-preload.ts b/src/preload/floating-preload.ts index f418acac4..a643f8e0b 100644 --- a/src/preload/floating-preload.ts +++ b/src/preload/floating-preload.ts @@ -5,6 +5,7 @@ import type { FloatingWidgetSnapshot } from '@shared/types/floating-widget' const FLOATING_BUTTON_EVENTS = { CLICKED: 'floating-button:clicked', RIGHT_CLICKED: 'floating-button:right-clicked', + HOVER_STATE_CHANGED: 'floating-button:hover-state-changed', SNAPSHOT_REQUEST: 'floating-button:snapshot-request', SNAPSHOT_UPDATED: 'floating-button:snapshot-updated', LANGUAGE_REQUEST: 'floating-button:language-request', @@ -58,6 +59,10 @@ const floatingButtonAPI = { ipcRenderer.send(FLOATING_BUTTON_EVENTS.SET_EXPANDED, expanded) }, + setHovering: (hovering: boolean) => { + ipcRenderer.send(FLOATING_BUTTON_EVENTS.HOVER_STATE_CHANGED, hovering) + }, + openSession: (sessionId: string) => { ipcRenderer.send(FLOATING_BUTTON_EVENTS.OPEN_SESSION, sessionId) }, diff --git a/src/renderer/floating/FloatingButton.vue b/src/renderer/floating/FloatingButton.vue index 22a292192..daa23cdf4 100644 --- a/src/renderer/floating/FloatingButton.vue +++ b/src/renderer/floating/FloatingButton.vue @@ -25,16 +25,21 @@ const props = defineProps<{ const DRAG_DELAY = 180 const DRAG_THRESHOLD = 4 +const CLOSE_MOTION_SETTLE_MS = 240 const { t } = useI18n() const isDragging = ref(false) +const isHovering = ref(false) +const isClosing = ref(false) const snapshot = ref({ expanded: false, activeCount: 0, sessions: [] }) +let closingTimer: number | null = null + const dragState = ref({ isDragging: false, isMouseDown: false, @@ -61,11 +66,39 @@ const clearDragTimer = () => { } } +const clearClosingTimer = () => { + if (closingTimer) { + clearTimeout(closingTimer) + closingTimer = null + } +} + +const syncCloseMotionState = (nextExpanded: boolean) => { + if (nextExpanded) { + clearClosingTimer() + isClosing.value = false + return + } + + if (!snapshot.value.expanded) { + return + } + + clearClosingTimer() + isClosing.value = true + closingTimer = window.setTimeout(() => { + isClosing.value = false + closingTimer = null + }, CLOSE_MOTION_SETTLE_MS) +} + const handleSnapshotUpdate = (nextSnapshot: FloatingWidgetSnapshot) => { + syncCloseMotionState(nextSnapshot.expanded) snapshot.value = nextSnapshot } const setExpanded = (expanded: boolean) => { + syncCloseMotionState(expanded) snapshot.value = { ...snapshot.value, expanded @@ -77,12 +110,33 @@ const toggleExpanded = () => { setExpanded(!snapshot.value.expanded) } +const setHovering = (hovering: boolean) => { + if (isHovering.value === hovering) { + return + } + + isHovering.value = hovering + window.floatingButtonAPI.setHovering(hovering) +} + const startDragging = () => { dragState.value.isDragging = true isDragging.value = true window.floatingButtonAPI.onDragStart(dragState.value.startScreenX, dragState.value.startScreenY) } +const handleMouseEnter = () => { + setHovering(true) +} + +const handleMouseLeave = () => { + if (dragState.value.isDragging) { + return + } + + setHovering(false) +} + const handleMouseDown = (event: MouseEvent) => { if (event.button !== 0) { return @@ -187,6 +241,8 @@ onMounted(async () => { onUnmounted(() => { clearDragTimer() + clearClosingTimer() + setHovering(false) document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) window.removeEventListener('blur', handleWindowBlur) @@ -198,6 +254,7 @@ onUnmounted(() => {
{ snapshot.expanded ? 'cursor-grab' : 'cursor-pointer', isDragging ? 'cursor-grabbing' : '' ]" + @mouseenter="handleMouseEnter" + @mouseleave="handleMouseLeave" @mousedown="handleMouseDown" @contextmenu="handleRightClick" > -
+