diff --git a/apps/stage-tamagotchi/src/main/services/electron/window.ts b/apps/stage-tamagotchi/src/main/services/electron/window.ts index 84dcb4f41..4ba6c6302 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/window.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/window.ts @@ -24,4 +24,5 @@ export function createWindowService(params: { context: ReturnType start()) defineInvokeHandler(params.context, electron.window.getBounds, () => params.window.getBounds()) + defineInvokeHandler(params.context, electron.window.setIgnoreMouseEvents, invokeOpts => params.window.setIgnoreMouseEvents(invokeOpts[0], invokeOpts[1])) } diff --git a/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/index.ts b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/index.ts new file mode 100644 index 000000000..b171b114d --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/index.ts @@ -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' diff --git a/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-eventa-context/index.ts b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-eventa-context/index.ts new file mode 100644 index 000000000..16f4072eb --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-eventa-context/index.ts @@ -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(invoke: InvokeEventa, context?: ReturnType['context']) { + return defineInvoke(context ?? useElectronEventaContext().value, invoke) +} diff --git a/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-mouse-in-element/index.ts b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-mouse-in-element/index.ts new file mode 100644 index 000000000..e9507a9b6 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-mouse-in-element/index.ts @@ -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 diff --git a/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-mouse/index.ts b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-mouse/index.ts new file mode 100644 index 000000000..11a3f8861 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-mouse/index.ts @@ -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' }) +} diff --git a/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-relative-mouse/index.ts b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-relative-mouse/index.ts new file mode 100644 index 000000000..ecd781ee0 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-relative-mouse/index.ts @@ -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, + } +} diff --git a/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-window-bounds/index.ts b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-window-bounds/index.ts new file mode 100644 index 000000000..80968c891 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-window-bounds/index.ts @@ -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, + } +} diff --git a/apps/stage-tamagotchi/src/renderer/pages/index.vue b/apps/stage-tamagotchi/src/renderer/pages/index.vue index 632c8ce26..93ca574f0 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/index.vue @@ -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>() +const controlsIslandRef = ref>() 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() + } }) @@ -84,7 +115,7 @@ watch(isTransparent, (transparent) => { :y-offset="positionInPercentageString.y" mb=" - +
diff --git a/apps/stage-tamagotchi/src/renderer/stores/window.ts b/apps/stage-tamagotchi/src/renderer/stores/window.ts index a175387f2..1d50435c3 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/window.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/window.ts @@ -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) diff --git a/apps/stage-tamagotchi/src/shared/electron/window.ts b/apps/stage-tamagotchi/src/shared/electron/window.ts index 038adc990..1637057e9 100644 --- a/apps/stage-tamagotchi/src/shared/electron/window.ts +++ b/apps/stage-tamagotchi/src/shared/electron/window.ts @@ -6,7 +6,9 @@ export const bounds = defineEventa('eventa:event:electron:window:boun export const startLoopGetBounds = defineInvokeEventa('eventa:event:electron:window:start-loop-get-bounds') const getBounds = defineInvokeEventa>('eventa:invoke:electron:window:get-bounds') +const setIgnoreMouseEvents = defineInvokeEventa('eventa:invoke:electron:window:set-ignore-mouse-events') export const window = { getBounds, + setIgnoreMouseEvents, }