From 35d15ae0730be79e7c1fb0c3fca987684724b81f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 05:10:39 -0700 Subject: [PATCH] Clamp pet overlay position during render on size changes --- .../src/components/pet/PetOverlay.tsx | 42 ++++++++++++++----- .../pet/pet-overlay-position.test.ts | 22 ++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/pet/pet-overlay-position.test.ts diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx index 56358171fb6..38aac10a844 100644 --- a/src/renderer/src/components/pet/PetOverlay.tsx +++ b/src/renderer/src/components/pet/PetOverlay.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useRef, useState } from 'react' import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' import { usePetUrl } from './usePetUrl' import type { DetectedSpriteCacheEntry } from './pet-blob-cache' @@ -261,25 +261,47 @@ export function PetOverlay(): React.JSX.Element { const { url, sprite, detected } = usePetUrl() const size = useAppStore((s) => s.petSize) - const [position, setPosition] = useState(() => { + const [positionState, setPositionState] = useState<{ + size: number + position: Position + }>(() => { // Why: read the persisted size eagerly via getState so the initial clamp // uses the user's last pet size — useState's lazy initializer runs // before the `size` prop binding settles, and `loadStoredPosition` would // otherwise default to SIZE and clip a previously-saved position. const currentSize = useAppStore.getState().petSize ?? SIZE - return loadStoredPosition(currentSize) ?? defaultPosition(currentSize) + return { + size: currentSize, + position: loadStoredPosition(currentSize) ?? defaultPosition(currentSize) + } }) + let position = positionState.position + if (positionState.size !== size) { + position = clampToViewport(positionState.position, size) + setPositionState({ size, position }) + } + const setPosition = useCallback( + (nextPosition: Position | ((current: Position) => Position)): void => { + setPositionState((current) => { + const currentPosition = + current.size === size ? current.position : clampToViewport(current.position, size) + return { + size, + position: + typeof nextPosition === 'function' ? nextPosition(currentPosition) : nextPosition + } + }) + }, + [size] + ) const [dragging, setDragging] = useState(false) const dragOffsetRef = useRef({ x: 0, y: 0 }) useEffect(() => { - const clampPosition = (): void => setPosition((prev) => clampToViewport(prev, size)) - // Why: resizing the pet or the window uses the same viewport boundary; keep - // that clamp in one Effect so size changes do not need a second render pass. - clampPosition() - window.addEventListener('resize', clampPosition) - return () => window.removeEventListener('resize', clampPosition) - }, [size]) + const onResize = (): void => setPosition((prev) => clampToViewport(prev, size)) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, [setPosition, size]) useEffect(() => { if (dragging) { diff --git a/src/renderer/src/components/pet/pet-overlay-position.test.ts b/src/renderer/src/components/pet/pet-overlay-position.test.ts new file mode 100644 index 00000000000..a85c73335b6 --- /dev/null +++ b/src/renderer/src/components/pet/pet-overlay-position.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { clampPositionToViewport } from './PetOverlay' + +describe('clampPositionToViewport', () => { + it('keeps the overlay inside the viewport bounds', () => { + expect( + clampPositionToViewport({ x: 900, y: -20 }, 180, { + width: 800, + height: 600 + }) + ).toEqual({ x: 620, y: 0 }) + }) + + it('pins to the origin when the overlay is larger than the viewport', () => { + expect( + clampPositionToViewport({ x: 80, y: 40 }, 500, { + width: 320, + height: 240 + }) + ).toEqual({ x: 0, y: 0 }) + }) +})