diff --git a/packages/app/src/utils/desktop-window-drag-coordinates.test.ts b/packages/app/src/utils/desktop-window-drag-coordinates.test.ts new file mode 100644 index 000000000..3f21e147c --- /dev/null +++ b/packages/app/src/utils/desktop-window-drag-coordinates.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { readFiniteScreenPoint } from './desktop-window-drag-coordinates' + +describe('readFiniteScreenPoint', () => { + it('returns finite screen coordinates', () => { + expect(readFiniteScreenPoint({ screenX: 1280, screenY: 720 })).toEqual({ + screenX: 1280, + screenY: 720, + }) + }) + + it('rejects NaN screen coordinates', () => { + expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull() + expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull() + }) + + it('rejects infinite screen coordinates', () => { + expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull() + expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull() + }) + + it('rejects missing screen coordinates', () => { + expect(readFiniteScreenPoint(undefined)).toBeNull() + expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull() + expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull() + }) +}) diff --git a/packages/app/src/utils/desktop-window-drag-coordinates.ts b/packages/app/src/utils/desktop-window-drag-coordinates.ts new file mode 100644 index 000000000..f5db90caf --- /dev/null +++ b/packages/app/src/utils/desktop-window-drag-coordinates.ts @@ -0,0 +1,27 @@ +export type DesktopWindowScreenPoint = { + screenX: number + screenY: number +} + +type ScreenPointInput = + | { + screenX?: unknown + screenY?: unknown + } + | null + | undefined + +function isFiniteCoordinate(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +export function readFiniteScreenPoint(input: ScreenPointInput): DesktopWindowScreenPoint | null { + if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) { + return null + } + + return { + screenX: input.screenX, + screenY: input.screenY, + } +} diff --git a/packages/app/src/utils/desktop-window.ts b/packages/app/src/utils/desktop-window.ts index e27898b0f..468d5e5b7 100644 --- a/packages/app/src/utils/desktop-window.ts +++ b/packages/app/src/utils/desktop-window.ts @@ -1,20 +1,21 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native"; +import { useEffect, useMemo, useRef, useState } from 'react' +import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from 'react-native' import { getIsDesktopMac, DESKTOP_TRAFFIC_LIGHT_WIDTH, DESKTOP_TRAFFIC_LIGHT_HEIGHT, -} from "@/constants/layout"; -import { getDesktopWindow } from "@/desktop/electron/window"; -import { isDesktop } from "@/desktop/host"; +} from '@/constants/layout' +import { getDesktopWindow } from '@/desktop/electron/window' +import { isDesktop } from '@/desktop/host' +import { readFiniteScreenPoint } from './desktop-window-drag-coordinates' export async function toggleMaximize() { - const win = getDesktopWindow(); - if (win && typeof win.toggleMaximize === "function") { + const win = getDesktopWindow() + if (win && typeof win.toggleMaximize === 'function') { try { - await win.toggleMaximize(); + await win.toggleMaximize() } catch (error) { - console.warn("[DesktopWindow] toggleMaximize failed", error); + console.warn('[DesktopWindow] toggleMaximize failed', error) } } } @@ -27,147 +28,152 @@ export async function toggleMaximize() { // --------------------------------------------------------------------------- const INTERACTIVE_SELECTOR = - "button, a, input, textarea, select, " + + 'button, a, input, textarea, select, ' + "[role='button'], [role='link'], [role='textbox'], [role='combobox'], " + "[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " + - "[role='menuitem'], [contenteditable='true']"; + "[role='menuitem'], [contenteditable='true']" -const DOUBLE_CLICK_MS = 300; +const DOUBLE_CLICK_MS = 300 type DesktopDragViewProps = Pick< ViewProps, - "onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel" ->; + 'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel' +> export function useDesktopDragHandlers(): DesktopDragViewProps { - const isDragging = useRef(false); - const lastPointerDownAt = useRef(0); - const isActive = Platform.OS === "web" && isDesktop(); + const isDragging = useRef(false) + const lastPointerDownAt = useRef(0) + const isActive = Platform.OS === 'web' && isDesktop() useEffect(() => { - if (!isActive) return; + if (!isActive) return function handleBlur() { - if (!isDragging.current) return; - isDragging.current = false; - getDesktopWindow()?.endMove?.(); + if (!isDragging.current) return + isDragging.current = false + getDesktopWindow()?.endMove?.() } - window.addEventListener("blur", handleBlur); - return () => window.removeEventListener("blur", handleBlur); - }, [isActive]); + window.addEventListener('blur', handleBlur) + return () => window.removeEventListener('blur', handleBlur) + }, [isActive]) return useMemo((): DesktopDragViewProps => { - if (!isActive) return {}; + if (!isActive) return {} function stopDrag(e: RNPointerEvent) { - if (!isDragging.current) return; - isDragging.current = false; + if (!isDragging.current) return + isDragging.current = false // On web, currentTarget is a DOM Element (typed as HostInstance in RN) - const el = e.currentTarget as unknown as Element | null; - if (el && "releasePointerCapture" in el) { - el.releasePointerCapture(e.nativeEvent.pointerId); + const el = e.currentTarget as unknown as Element | null + if (el && 'releasePointerCapture' in el) { + el.releasePointerCapture(e.nativeEvent.pointerId) } - getDesktopWindow()?.endMove?.(); + getDesktopWindow()?.endMove?.() } return { onPointerDown: (e: RNPointerEvent) => { - if (e.nativeEvent.button !== 0) return; + if (e.nativeEvent.button !== 0) return // On web, e.target is a DOM Element (typed as HostInstance in RN) - const target = e.target as unknown as Element; - if (target.closest?.(INTERACTIVE_SELECTOR)) return; + const target = e.target as unknown as Element + if (target.closest?.(INTERACTIVE_SELECTOR)) return - e.preventDefault(); + e.preventDefault() - const now = Date.now(); + const now = Date.now() if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) { - lastPointerDownAt.current = 0; - void toggleMaximize(); - return; + lastPointerDownAt.current = 0 + void toggleMaximize() + return } - lastPointerDownAt.current = now; + lastPointerDownAt.current = now - const win = getDesktopWindow(); - if (!win?.startMove) return; + const win = getDesktopWindow() + if (!win?.startMove) return + const screenPoint = readFiniteScreenPoint(e.nativeEvent) + if (!screenPoint) return - isDragging.current = true; - const el = e.currentTarget as unknown as Element; - el.setPointerCapture(e.nativeEvent.pointerId); - win.startMove(e.nativeEvent.screenX, e.nativeEvent.screenY); + isDragging.current = true + const el = e.currentTarget as unknown as Element + el.setPointerCapture(e.nativeEvent.pointerId) + win.startMove(screenPoint.screenX, screenPoint.screenY) }, onPointerMove: (e: RNPointerEvent) => { - if (!isDragging.current) return; - getDesktopWindow()?.moving?.(e.nativeEvent.screenX, e.nativeEvent.screenY); + if (!isDragging.current) return + const screenPoint = readFiniteScreenPoint(e.nativeEvent) + if (!screenPoint) { + stopDrag(e) + return + } + getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY) }, onPointerUp: stopDrag, onPointerCancel: stopDrag, - }; - }, [isActive]); + } + }, [isActive]) } export function useTrafficLightPadding(): { left: number; top: number } { - const [isFullscreen, setIsFullscreen] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false) useEffect(() => { - if (Platform.OS !== "web" || !getIsDesktopMac()) return; + if (Platform.OS !== 'web' || !getIsDesktopMac()) return - let disposed = false; - let cleanup: (() => void) | undefined; - let didCleanup = false; + let disposed = false + let cleanup: (() => void) | undefined + let didCleanup = false function runCleanup() { - if (!cleanup || didCleanup) return; - didCleanup = true; + if (!cleanup || didCleanup) return + didCleanup = true try { void Promise.resolve(cleanup()).catch((error) => { - console.warn("[DesktopWindow] Failed to remove resize listener", error); - }); + console.warn('[DesktopWindow] Failed to remove resize listener', error) + }) } catch (error) { - console.warn("[DesktopWindow] Failed to remove resize listener", error); + console.warn('[DesktopWindow] Failed to remove resize listener', error) } } async function setup() { - const win = getDesktopWindow(); - if (!win) return; + const win = getDesktopWindow() + if (!win) return - const fullscreen = - typeof win.isFullscreen === "function" ? await win.isFullscreen() : false; - if (disposed) return; - setIsFullscreen(fullscreen); + const fullscreen = typeof win.isFullscreen === 'function' ? await win.isFullscreen() : false + if (disposed) return + setIsFullscreen(fullscreen) - if (typeof win.onResized !== "function") { - return; + if (typeof win.onResized !== 'function') { + return } const unlisten = await win.onResized(async () => { - if (disposed) return; - const fs = - typeof win.isFullscreen === "function" ? await win.isFullscreen() : false; - if (disposed) return; - setIsFullscreen(fs); - }); + if (disposed) return + const fs = typeof win.isFullscreen === 'function' ? await win.isFullscreen() : false + if (disposed) return + setIsFullscreen(fs) + }) - cleanup = unlisten; + cleanup = unlisten if (disposed) { - runCleanup(); + runCleanup() } } - void setup(); + void setup() return () => { - disposed = true; - runCleanup(); - }; - }, []); + disposed = true + runCleanup() + } + }, []) if (!getIsDesktopMac() || isFullscreen) { - return { left: 0, top: 0 }; + return { left: 0, top: 0 } } return { left: DESKTOP_TRAFFIC_LIGHT_WIDTH, top: DESKTOP_TRAFFIC_LIGHT_HEIGHT, - }; + } } diff --git a/packages/desktop/src/window/window-drag-coordinates.test.ts b/packages/desktop/src/window/window-drag-coordinates.test.ts new file mode 100644 index 000000000..78e3c53ab --- /dev/null +++ b/packages/desktop/src/window/window-drag-coordinates.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +import { + createWindowMoveState, + readWindowMovePayload, + resolveWindowMovePosition, +} from './window-drag-coordinates' + +describe('window-drag-coordinates', () => { + describe('readWindowMovePayload', () => { + it('returns finite screen coordinates', () => { + expect(readWindowMovePayload({ screenX: 1500, screenY: 900 })).toEqual({ + screenX: 1500, + screenY: 900, + }) + }) + + it('rejects non-finite screen coordinates', () => { + expect(readWindowMovePayload({ screenX: Number.NaN, screenY: 900 })).toBeNull() + expect(readWindowMovePayload({ screenX: 1500, screenY: Number.POSITIVE_INFINITY })).toBeNull() + }) + }) + + describe('createWindowMoveState', () => { + it('derives finite offsets from the window position', () => { + expect( + createWindowMoveState({ + payload: { screenX: 1500, screenY: 900 }, + windowX: 1200, + windowY: 700, + }) + ).toEqual({ + offsetX: 300, + offsetY: 200, + }) + }) + + it('rejects non-finite offsets', () => { + expect( + createWindowMoveState({ + payload: { screenX: 1500, screenY: 900 }, + windowX: Number.NaN, + windowY: 700, + }) + ).toBeNull() + }) + }) + + describe('resolveWindowMovePosition', () => { + it('rounds the next window position', () => { + expect( + resolveWindowMovePosition({ + payload: { screenX: 1501.4, screenY: 902.6 }, + state: { offsetX: 300, offsetY: 200 }, + }) + ).toEqual({ + x: 1201, + y: 703, + }) + }) + + it('rejects non-finite next positions', () => { + expect( + resolveWindowMovePosition({ + payload: { screenX: 1500, screenY: 900 }, + state: { offsetX: Number.NaN, offsetY: 200 }, + }) + ).toBeNull() + }) + }) +}) diff --git a/packages/desktop/src/window/window-drag-coordinates.ts b/packages/desktop/src/window/window-drag-coordinates.ts new file mode 100644 index 000000000..a2e4a612a --- /dev/null +++ b/packages/desktop/src/window/window-drag-coordinates.ts @@ -0,0 +1,64 @@ +export type WindowMovePayload = { + screenX: number + screenY: number +} + +export type WindowMoveState = { + offsetX: number + offsetY: number +} + +export type WindowPosition = { + x: number + y: number +} + +type WindowMovePayloadInput = + | { + screenX?: unknown + screenY?: unknown + } + | null + | undefined + +function isFiniteCoordinate(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +export function readWindowMovePayload(input: WindowMovePayloadInput): WindowMovePayload | null { + if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) { + return null + } + + return { + screenX: input.screenX, + screenY: input.screenY, + } +} + +export function createWindowMoveState(input: { + payload: WindowMovePayload + windowX: number + windowY: number +}): WindowMoveState | null { + const offsetX = input.payload.screenX - input.windowX + const offsetY = input.payload.screenY - input.windowY + if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) { + return null + } + + return { offsetX, offsetY } +} + +export function resolveWindowMovePosition(input: { + payload: WindowMovePayload + state: WindowMoveState +}): WindowPosition | null { + const x = Math.round(input.payload.screenX - input.state.offsetX) + const y = Math.round(input.payload.screenY - input.state.offsetY) + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return null + } + + return { x, y } +} diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts index 511ac0f3b..747366352 100644 --- a/packages/desktop/src/window/window-manager.ts +++ b/packages/desktop/src/window/window-manager.ts @@ -1,81 +1,103 @@ -import { app, BrowserWindow, ipcMain } from "electron"; +import { app, BrowserWindow, ipcMain } from 'electron' +import { + createWindowMoveState, + readWindowMovePayload, + resolveWindowMovePosition, + type WindowMoveState, +} from './window-drag-coordinates.js' export function registerWindowManager(): void { // --------------------------------------------------------------------------- // Manual window dragging (replaces flaky CSS -webkit-app-region: drag). // State is keyed per window id to support multiple windows. // --------------------------------------------------------------------------- - const moveStates = new Map(); + const moveStates = new Map() - ipcMain.on( - "paseo:window:startMove", - (event, payload: { screenX: number; screenY: number }) => { - if (typeof payload?.screenX !== "number" || typeof payload?.screenY !== "number") return; - const win = BrowserWindow.fromWebContents(event.sender); - if (!win) return; - const [winX, winY] = win.getPosition(); - moveStates.set(win.id, { offsetX: payload.screenX - winX, offsetY: payload.screenY - winY }); + ipcMain.on('paseo:window:startMove', (event, payload: { screenX: number; screenY: number }) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return + const movePayload = readWindowMovePayload(payload) + if (!movePayload) { + moveStates.delete(win.id) + return } - ); - - ipcMain.on( - "paseo:window:moving", - (event, payload: { screenX: number; screenY: number }) => { - if (typeof payload?.screenX !== "number" || typeof payload?.screenY !== "number") return; - const win = BrowserWindow.fromWebContents(event.sender); - if (!win) return; - const state = moveStates.get(win.id); - if (!state) return; - win.setPosition( - Math.round(payload.screenX - state.offsetX), - Math.round(payload.screenY - state.offsetY) - ); + const [winX, winY] = win.getPosition() + const moveState = createWindowMoveState({ + payload: movePayload, + windowX: winX, + windowY: winY, + }) + if (!moveState) { + moveStates.delete(win.id) + return } - ); + moveStates.set(win.id, moveState) + }) - ipcMain.on("paseo:window:endMove", (event) => { - const win = BrowserWindow.fromWebContents(event.sender); - if (win) moveStates.delete(win.id); - }); + ipcMain.on('paseo:window:moving', (event, payload: { screenX: number; screenY: number }) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return + const state = moveStates.get(win.id) + if (!state) return + const movePayload = readWindowMovePayload(payload) + if (!movePayload) { + moveStates.delete(win.id) + return + } + const nextPosition = resolveWindowMovePosition({ + payload: movePayload, + state, + }) + if (!nextPosition) { + moveStates.delete(win.id) + return + } + win.setPosition(nextPosition.x, nextPosition.y) + }) - app.on("browser-window-created", (_event, win) => { - win.on("closed", () => moveStates.delete(win.id)); - }); + ipcMain.on('paseo:window:endMove', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (win) moveStates.delete(win.id) + }) - ipcMain.handle("paseo:window:toggleMaximize", (event) => { - const win = BrowserWindow.fromWebContents(event.sender); - if (!win) return; + app.on('browser-window-created', (_event, win) => { + win.on('closed', () => moveStates.delete(win.id)) + }) + + ipcMain.handle('paseo:window:toggleMaximize', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return if (win.isMaximized()) { - win.unmaximize(); + win.unmaximize() } else { - win.maximize(); + win.maximize() } - }); + }) - ipcMain.handle("paseo:window:isFullscreen", (event) => { - const win = BrowserWindow.fromWebContents(event.sender); - return win?.isFullScreen() ?? false; - }); + ipcMain.handle('paseo:window:isFullscreen', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + return win?.isFullScreen() ?? false + }) - ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: number) => { - if (process.platform === "darwin" || process.platform === "linux") { - app.setBadgeCount(count ?? 0); + ipcMain.handle('paseo:window:setBadgeCount', (_event, count?: number) => { + if (process.platform === 'darwin' || process.platform === 'linux') { + app.setBadgeCount(count ?? 0) } - }); + }) } export function setupWindowResizeEvents(win: BrowserWindow): void { - win.on("resize", () => { - win.webContents.send("paseo:window:resized", {}); - }); + win.on('resize', () => { + win.webContents.send('paseo:window:resized', {}) + }) - win.on("enter-full-screen", () => { - win.webContents.send("paseo:window:resized", {}); - }); + win.on('enter-full-screen', () => { + win.webContents.send('paseo:window:resized', {}) + }) - win.on("leave-full-screen", () => { - win.webContents.send("paseo:window:resized", {}); - }); + win.on('leave-full-screen', () => { + win.webContents.send('paseo:window:resized', {}) + }) } /** @@ -83,11 +105,11 @@ export function setupWindowResizeEvents(win: BrowserWindow): void { * The renderer handles drag-drop via standard HTML5 APIs instead. */ export function setupDragDropPrevention(win: BrowserWindow): void { - win.webContents.on("will-navigate", (event, url) => { + win.webContents.on('will-navigate', (event, url) => { // Allow normal navigation (e.g. dev server hot-reload) but block file:// URLs // that result from dropping files onto the window. - if (url.startsWith("file://")) { - event.preventDefault(); + if (url.startsWith('file://')) { + event.preventDefault() } - }); + }) }