feat(stage-tamagotchi): bring back pass through on hover, with intelligence hover detection

This commit is contained in:
Neko Ayaka
2025-10-23 16:45:39 +08:00
parent b097857f93
commit 2850925716
10 changed files with 301 additions and 65 deletions
@@ -24,4 +24,5 @@ export function createWindowService(params: { context: ReturnType<typeof createC
defineInvokeHandler(params.context, startLoopGetBounds, () => start())
defineInvokeHandler(params.context, electron.window.getBounds, () => params.window.getBounds())
defineInvokeHandler(params.context, electron.window.setIgnoreMouseEvents, invokeOpts => params.window.setIgnoreMouseEvents(invokeOpts[0], invokeOpts[1]))
}
@@ -0,0 +1,6 @@
export { useElectronEventaContext, useElectronEventaInvoke } from './use-electron-eventa-context'
export { useElectronMouse, useElectronMouseEventTarget } from './use-electron-mouse'
export type { UseMouseInElementReturn } from './use-electron-mouse-in-element'
export { useElectronMouseInElement } from './use-electron-mouse-in-element'
export { useElectronRelativeMouse } from './use-electron-relative-mouse'
export { useElectronWindowBounds } from './use-electron-window-bounds'
@@ -0,0 +1,15 @@
import type { InvokeEventa } from '@unbird/eventa'
import { defineInvoke } from '@unbird/eventa'
import { createContext } from '@unbird/eventa/adapters/electron/renderer'
import { ref } from 'vue'
export function useElectronEventaContext() {
const context = ref(createContext(window.electron.ipcRenderer).context)
return context
}
export function useElectronEventaInvoke<Res, Req = undefined, ResErr = Error, ReqErr = Error>(invoke: InvokeEventa<Res, Req, ResErr, ReqErr>, context?: ReturnType<typeof createContext>['context']) {
return defineInvoke(context ?? useElectronEventaContext().value, invoke)
}
@@ -0,0 +1,159 @@
import type { MaybeElementRef, UseMouseOptions } from '@vueuse/core'
import { defaultWindow, tryOnMounted, unrefElement, useEventListener, useMutationObserver, useResizeObserver } from '@vueuse/core'
import { shallowRef, watch } from 'vue'
import { useElectronRelativeMouse } from '../use-electron-relative-mouse'
export interface MouseInElementOptions extends UseMouseOptions {
/**
* Whether to handle mouse events when the cursor is outside the target element.
* When enabled, mouse position will continue to be tracked even when outside the element bounds.
*
* @default true
*/
handleOutside?: boolean
/**
* Listen to window resize event
*
* @default true
*/
windowScroll?: boolean
/**
* Listen to window scroll event
*
* @default true
*/
windowResize?: boolean
}
/**
* Reactive mouse position related to an element.
*
* @see https://vueuse.org/useMouseInElement
* @param target
* @param options
*/
export function useElectronMouseInElement(
target?: MaybeElementRef,
options: MouseInElementOptions = {},
) {
const {
windowResize = true,
windowScroll = true,
handleOutside = true,
window = defaultWindow,
} = options
const type = options.type || 'page'
const { x, y, sourceType } = useElectronRelativeMouse(options)
const targetRef = shallowRef(target ?? window?.document.body)
const elementX = shallowRef(0)
const elementY = shallowRef(0)
const elementPositionX = shallowRef(0)
const elementPositionY = shallowRef(0)
const elementHeight = shallowRef(0)
const elementWidth = shallowRef(0)
const isOutside = shallowRef(true)
function update() {
if (!window)
return
const el = unrefElement(targetRef)
if (!el || !(el instanceof Element))
return
const {
left,
top,
width,
height,
} = el.getBoundingClientRect()
elementPositionX.value = left + (type === 'page' ? window.pageXOffset : 0)
elementPositionY.value = top + (type === 'page' ? window.pageYOffset : 0)
elementHeight.value = height
elementWidth.value = width
const elX = x.value - elementPositionX.value
const elY = y.value - elementPositionY.value
isOutside.value = width === 0 || height === 0
|| elX < 0 || elY < 0
|| elX > width || elY > height
if (handleOutside || !isOutside.value) {
elementX.value = elX
elementY.value = elY
}
}
const stopFnList: Array<() => void> = []
function stop() {
stopFnList.forEach(fn => fn())
stopFnList.length = 0
}
tryOnMounted(() => {
update()
})
if (window) {
const {
stop: stopResizeObserver,
} = useResizeObserver(targetRef, update)
const {
stop: stopMutationObserver,
} = useMutationObserver(targetRef, update, {
attributeFilter: ['style', 'class'],
})
const stopWatch = watch(
[targetRef, x, y],
update,
)
stopFnList.push(
stopResizeObserver,
stopMutationObserver,
stopWatch,
)
useEventListener(
document,
'mouseleave',
() => isOutside.value = true,
{ passive: true },
)
if (windowScroll) {
stopFnList.push(
useEventListener('scroll', update, { capture: true, passive: true }),
)
}
if (windowResize) {
stopFnList.push(
useEventListener('resize', update, { passive: true }),
)
}
}
return {
x,
y,
sourceType,
elementX,
elementY,
elementPositionX,
elementPositionY,
elementHeight,
elementWidth,
isOutside,
stop,
}
}
export type UseMouseInElementReturn = ReturnType<typeof useElectronMouseInElement>
@@ -0,0 +1,26 @@
import type { UseMouseOptions } from '@vueuse/core'
import { defineInvoke } from '@unbird/eventa'
import { useMouse } from '@vueuse/core'
import { ref } from 'vue'
import { cursorScreenPoint, startLoopGetCursorScreenPoint } from '../../../../shared/electron/screen'
import { useElectronEventaContext } from '../use-electron-eventa-context'
export function useElectronMouseEventTarget() {
const context = useElectronEventaContext()
const eventTarget = ref(new EventTarget())
context.value.on(cursorScreenPoint, (event) => {
const e = new MouseEvent('mousemove', { screenX: event.body?.x, screenY: event.body?.y })
eventTarget.value.dispatchEvent(e)
})
defineInvoke(context.value!, startLoopGetCursorScreenPoint)()
return eventTarget
}
export function useElectronMouse(options?: UseMouseOptions) {
const eventTarget = useElectronMouseEventTarget()
return useMouse({ ...options, target: eventTarget, type: 'screen' })
}
@@ -0,0 +1,21 @@
import type { UseMouseOptions } from '@vueuse/core'
import { computed } from 'vue'
import { useElectronMouse } from '../use-electron-mouse'
import { useElectronWindowBounds } from '../use-electron-window-bounds'
export function useElectronRelativeMouse(options?: UseMouseOptions) {
const mouse = useElectronMouse(options)
const { x: windowX, y: windowY } = useElectronWindowBounds()
// Transform screen coordinates to window-relative coordinates
const x = computed(() => mouse.x.value - windowX.value)
const y = computed(() => mouse.y.value - windowY.value)
return {
...mouse,
x,
y,
}
}
@@ -0,0 +1,31 @@
import { defineInvoke } from '@unbird/eventa'
import { ref } from 'vue'
import { bounds, startLoopGetBounds } from '../../../../shared/electron/window'
import { useElectronEventaContext } from '../use-electron-eventa-context'
export function useElectronWindowBounds() {
const context = useElectronEventaContext()
const windowBoundsX = ref(0)
const windowBoundsY = ref(0)
const windowBoundsWidth = ref(0)
const windowBoundsHeight = ref(0)
context.value.on(bounds, (event) => {
if (!event || !event.body)
return
windowBoundsX.value = event.body.x
windowBoundsY.value = event.body.y
windowBoundsWidth.value = event.body.width
windowBoundsHeight.value = event.body.height
})
defineInvoke(context.value!, startLoopGetBounds)()
return {
x: windowBoundsX,
y: windowBoundsY,
width: windowBoundsWidth,
height: windowBoundsHeight,
}
}
@@ -2,17 +2,21 @@
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useCanvasPixelIsTransparentAtPoint } from '@proj-airi/stage-ui/composables/canvas-alpha'
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
import { debouncedRef, watchPausable } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, toRef, watch } from 'vue'
import ControlsIsland from '../components/Widgets/ControlsIsland/index.vue'
import ResourceStatusIsland from '../components/Widgets/ResourceStatusIsland/index.vue'
import { useElectronRelativeMouse, useWindowStore } from '../stores/window'
import { electron } from '../../shared/electron'
import { useElectronEventaInvoke, useElectronMouseInElement, useElectronRelativeMouse } from '../composables/electron-vueuse'
import { useWindowStore } from '../stores/window'
import { useWindowControlStore } from '../stores/window-controls'
import { WindowControlMode } from '../types/window-controls'
const resourceStatusIslandRef = ref<InstanceType<typeof ResourceStatusIsland>>()
const controlsIslandRef = ref<InstanceType<typeof ControlsIsland>>()
const widgetStageRef = ref<{ canvasElement: () => HTMLCanvasElement }>()
const stageCanvas = toRef(() => widgetStageRef.value?.canvasElement())
const isClickThrough = ref(false)
@@ -23,6 +27,9 @@ const componentStateStage = ref<'pending' | 'loading' | 'mounted'>('pending')
const windowControlStore = useWindowControlStore()
const { x: relativeMouseX, y: relativeMouseY } = useElectronRelativeMouse()
const isTransparent = useCanvasPixelIsTransparentAtPoint(stageCanvas, relativeMouseX, relativeMouseY)
const setIgnoreMouseEvents = useElectronEventaInvoke(electron.window.setIgnoreMouseEvents)
const { isOutside } = useElectronMouseInElement(controlsIslandRef)
const isOutsideFor250Ms = debouncedRef(isOutside, 250)
const { scale, positionInPercentageString } = storeToRefs(useLive2d())
const { live2dLookAtX, live2dLookAtY } = storeToRefs(useWindowStore())
@@ -41,10 +48,34 @@ const modeIndicatorClass = computed(() => {
})
watch(componentStateStage, () => isLoading.value = componentStateStage.value !== 'mounted', { immediate: true })
watch(isTransparent, (transparent) => {
const { pause, resume } = watchPausable(isTransparent, (transparent) => {
isClickThrough.value = transparent
isPassingThrough.value = transparent
isPassingThrough.value = !transparent
windowControlStore.isIgnoringMouseEvent = !transparent
if (windowControlStore.isIgnoringMouseEvent) {
setIgnoreMouseEvents([true, { forward: true }])
}
else {
setIgnoreMouseEvents([false, { forward: true }])
}
})
watch(isOutsideFor250Ms, () => {
if (!isOutsideFor250Ms.value) {
isClickThrough.value = false
isPassingThrough.value = false
windowControlStore.isIgnoringMouseEvent = false
setIgnoreMouseEvents([false, { forward: true }])
pause()
}
else {
isClickThrough.value = true
isPassingThrough.value = true
windowControlStore.isIgnoringMouseEvent = true
setIgnoreMouseEvents([true, { forward: true }])
resume()
}
})
</script>
@@ -84,7 +115,7 @@ watch(isTransparent, (transparent) => {
:y-offset="positionInPercentageString.y"
mb="<md:18"
/>
<ControlsIsland />
<ControlsIsland ref="controlsIslandRef" />
</div>
</div>
<div v-show="isLoading" h-full w-full>
@@ -1,34 +1,15 @@
import { defineInvoke } from '@unbird/eventa'
import { createContext } from '@unbird/eventa/adapters/electron/renderer'
import { useAsyncState, useIntervalFn, useMouse, useWindowSize } from '@vueuse/core'
import { useAsyncState, useIntervalFn, useWindowSize } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { cursorScreenPoint, startLoopGetCursorScreenPoint } from '../../shared/electron/screen'
import { bounds, startLoopGetBounds } from '../../shared/electron/window'
import { electron } from '../../shared/eventa'
import { useElectronRelativeMouse } from '../composables/electron-vueuse'
import { useElectronEventaContext } from '../composables/electron-vueuse/use-electron-eventa-context'
import { useWindowControlStore } from './window-controls'
export function useElectronMouseEventTarget() {
const context = ref(createContext(window.electron.ipcRenderer).context)
const eventTarget = ref(new EventTarget())
context.value.on(cursorScreenPoint, (event) => {
const e = new MouseEvent('mousemove', { screenX: event.body?.x, screenY: event.body?.y })
eventTarget.value.dispatchEvent(e)
})
defineInvoke(context.value!, startLoopGetCursorScreenPoint)()
return eventTarget
}
export function useElectronMouse(options?: { x: number, y: number }) {
const eventTarget = useElectronMouseEventTarget()
return useMouse({ target: eventTarget, type: 'screen', initialValue: options })
}
export function useElectronAllDisplays() {
const context = ref(createContext(window.electron.ipcRenderer).context)
const context = useElectronEventaContext()
const getAllDisplays = defineInvoke(context.value, electron.screen.getAllDisplays)
const { state: allDisplays, execute } = useAsyncState(() => getAllDisplays(), [])
@@ -39,43 +20,6 @@ export function useElectronAllDisplays() {
return allDisplays
}
export function useElectronWindowBounds() {
const context = ref(createContext(window.electron.ipcRenderer).context)
const windowBoundsX = ref(0)
const windowBoundsY = ref(0)
const windowBoundsWidth = ref(0)
const windowBoundsHeight = ref(0)
context.value.on(bounds, (event) => {
if (!event || !event.body)
return
windowBoundsX.value = event.body.x
windowBoundsY.value = event.body.y
windowBoundsWidth.value = event.body.width
windowBoundsHeight.value = event.body.height
})
defineInvoke(context.value!, startLoopGetBounds)()
return {
x: windowBoundsX,
y: windowBoundsY,
width: windowBoundsWidth,
height: windowBoundsHeight,
}
}
export function useElectronRelativeMouse(initialValue?: { x: number, y: number }) {
const { x: mouseX, y: mouseY } = useElectronMouse(initialValue)
const { x: windowX, y: windowY } = useElectronWindowBounds()
// Transform screen coordinates to window-relative coordinates
const x = computed(() => mouseX.value - windowX.value)
const y = computed(() => mouseY.value - windowY.value)
return { x, y }
}
export const useWindowStore = defineStore('tamagotchi-window', () => {
const windowControlStore = useWindowControlStore()
@@ -84,7 +28,7 @@ export const useWindowStore = defineStore('tamagotchi-window', () => {
// Use window-relative mouse coordinates for Live2D focus
// Transforms screen coordinates to window-relative coordinates
const { x: live2dLookAtX, y: live2dLookAtY } = useElectronRelativeMouse(centerPos.value)
const { x: live2dLookAtX, y: live2dLookAtY } = useElectronRelativeMouse({ initialValue: centerPos.value })
const isCursorInside = ref(false)
const shouldHideView = computed(() => isCursorInside.value && !windowControlStore.isControlActive && windowControlStore.isIgnoringMouseEvent)
@@ -6,7 +6,9 @@ export const bounds = defineEventa<Rectangle>('eventa:event:electron:window:boun
export const startLoopGetBounds = defineInvokeEventa('eventa:event:electron:window:start-loop-get-bounds')
const getBounds = defineInvokeEventa<ReturnType<BrowserWindow['getBounds']>>('eventa:invoke:electron:window:get-bounds')
const setIgnoreMouseEvents = defineInvokeEventa<void, [boolean, { forward: boolean }]>('eventa:invoke:electron:window:set-ignore-mouse-events')
export const window = {
getBounds,
setIgnoreMouseEvents,
}