@@ -3,19 +3,29 @@ import {
33 useEffect ,
44 useRef ,
55 useState ,
6+ type MouseEvent as ReactMouseEvent ,
67 type PointerEvent as ReactPointerEvent ,
78} from 'react' ;
8- import { ChevronDown , ChevronUp } from 'lucide-react' ;
9+ import { useTranslation } from 'react-i18next' ;
10+ import { ChevronDown , ChevronUp , Maximize2 } from 'lucide-react' ;
911import { usePetCompanion } from '@/pets/usePetCompanion' ;
1012import { usePetActivityState } from '@/pets/usePetActivityState' ;
1113import { usePetNotifications } from '@/pets/usePetNotifications' ;
14+ import { normalizePetSize } from '@/pets/pet-size' ;
1215import { PetNotifications } from './PetNotifications' ;
1316import { QwenPet } from './QwenPet' ;
1417
1518function ignoreDragError ( promise : Promise < void > | undefined ) : void {
1619 void promise ?. catch ( ( ) => { } ) ;
1720}
1821
22+ type ResizeState = {
23+ pointerId : number ;
24+ startScreenX : number ;
25+ startScreenY : number ;
26+ startSize : number ;
27+ } ;
28+
1929/**
2030 * Fills the transparent, always-on-top pet window. Everything is clustered at
2131 * the bottom-right: notification cards stack just above a small toggle, which
@@ -27,13 +37,22 @@ function ignoreDragError(promise: Promise<void> | undefined): void {
2737 * and the toggle are interactive; everything else passes through to the desktop.
2838 */
2939export function DesktopPet ( ) {
30- const { selectedPet, petEnabled } = usePetCompanion ( ) ;
40+ const { t } = useTranslation ( ) ;
41+ const { selectedPet, petEnabled, petSize, setPetEnabled, setPetSize } =
42+ usePetCompanion ( ) ;
3143 const state = usePetActivityState ( ) ;
3244 const { items, dismiss } = usePetNotifications ( ) ;
3345 const [ collapsed , setCollapsed ] = useState ( false ) ;
46+ const [ resizePreview , setResizePreview ] = useState < number | null > ( null ) ;
47+ const [ contextMenu , setContextMenu ] = useState < {
48+ x : number ;
49+ y : number ;
50+ } | null > ( null ) ;
3451
3552 const ignoringRef = useRef ( true ) ;
3653 const draggingRef = useRef ( false ) ;
54+ const resizingRef = useRef < ResizeState | null > ( null ) ;
55+ const resizePreviewRef = useRef < number | null > ( null ) ;
3756
3857 const setIgnore = useCallback ( ( ignore : boolean ) => {
3958 if ( ignore === ignoringRef . current ) return ;
@@ -44,21 +63,23 @@ export function DesktopPet() {
4463 useEffect ( ( ) => {
4564 setIgnore ( true ) ;
4665 const onMove = ( event : MouseEvent ) => {
47- if ( draggingRef . current ) return ;
66+ if ( draggingRef . current || resizingRef . current ) return ;
4867 const el = document . elementFromPoint ( event . clientX , event . clientY ) ;
4968 const interactive = ! ! el ?. closest ?.( '[data-pet-interactive]' ) ;
69+ if ( contextMenu && ! interactive ) setContextMenu ( null ) ;
5070 setIgnore ( ! interactive ) ;
5171 } ;
5272 window . addEventListener ( 'mousemove' , onMove ) ;
5373 return ( ) => {
5474 window . removeEventListener ( 'mousemove' , onMove ) ;
5575 ignoreDragError ( window . electronAPI ?. petWindowSetIgnoreMouse ?.( false ) ) ;
5676 } ;
57- } , [ setIgnore ] ) ;
77+ } , [ contextMenu , setIgnore ] ) ;
5878
5979 const onPointerDown = useCallback (
6080 ( event : ReactPointerEvent < HTMLDivElement > ) => {
6181 if ( event . button !== 0 ) return ;
82+ setContextMenu ( null ) ;
6283 draggingRef . current = true ;
6384 event . currentTarget . setPointerCapture ( event . pointerId ) ;
6485 ignoreDragError (
@@ -89,8 +110,84 @@ export function DesktopPet() {
89110 [ ] ,
90111 ) ;
91112
113+ const updateResizePreview = useCallback ( ( size : number ) => {
114+ const normalized = normalizePetSize ( size ) ;
115+ resizePreviewRef . current = normalized ;
116+ setResizePreview ( normalized ) ;
117+ } , [ ] ) ;
118+
119+ const onResizePointerDown = useCallback (
120+ ( event : ReactPointerEvent < HTMLButtonElement > ) => {
121+ if ( event . button !== 0 ) return ;
122+ event . preventDefault ( ) ;
123+ event . stopPropagation ( ) ;
124+ setContextMenu ( null ) ;
125+ resizingRef . current = {
126+ pointerId : event . pointerId ,
127+ startScreenX : event . screenX ,
128+ startScreenY : event . screenY ,
129+ startSize : resizePreviewRef . current ?? petSize ,
130+ } ;
131+ event . currentTarget . setPointerCapture ( event . pointerId ) ;
132+ updateResizePreview ( resizePreviewRef . current ?? petSize ) ;
133+ } ,
134+ [ petSize , updateResizePreview ] ,
135+ ) ;
136+
137+ const onPetContextMenu = useCallback (
138+ ( event : ReactMouseEvent < HTMLDivElement > ) => {
139+ event . preventDefault ( ) ;
140+ event . stopPropagation ( ) ;
141+ setContextMenu ( {
142+ x : Math . min ( Math . max ( 8 , event . clientX ) , window . innerWidth - 100 ) ,
143+ y : Math . min ( Math . max ( 8 , event . clientY ) , window . innerHeight - 44 ) ,
144+ } ) ;
145+ } ,
146+ [ ] ,
147+ ) ;
148+
149+ const onClosePet = useCallback ( ( ) => {
150+ setContextMenu ( null ) ;
151+ setPetEnabled ( false ) ;
152+ ignoreDragError ( window . electronAPI ?. setPetWindowEnabled ?.( false ) ) ;
153+ } , [ setPetEnabled ] ) ;
154+
155+ const onResizePointerMove = useCallback (
156+ ( event : ReactPointerEvent < HTMLButtonElement > ) => {
157+ const resize = resizingRef . current ;
158+ if ( ! resize || resize . pointerId !== event . pointerId ) return ;
159+ event . preventDefault ( ) ;
160+ event . stopPropagation ( ) ;
161+ const deltaX = event . screenX - resize . startScreenX ;
162+ const deltaY = event . screenY - resize . startScreenY ;
163+ const delta = Math . abs ( deltaX ) > Math . abs ( deltaY ) ? deltaX : deltaY ;
164+ updateResizePreview ( resize . startSize + delta ) ;
165+ } ,
166+ [ updateResizePreview ] ,
167+ ) ;
168+
169+ const onResizePointerEnd = useCallback (
170+ ( event : ReactPointerEvent < HTMLButtonElement > ) => {
171+ const resize = resizingRef . current ;
172+ if ( ! resize || resize . pointerId !== event . pointerId ) return ;
173+ event . preventDefault ( ) ;
174+ event . stopPropagation ( ) ;
175+ if ( event . currentTarget . hasPointerCapture ( event . pointerId ) ) {
176+ event . currentTarget . releasePointerCapture ( event . pointerId ) ;
177+ }
178+ const nextSize = resizePreviewRef . current ?? resize . startSize ;
179+ resizingRef . current = null ;
180+ resizePreviewRef . current = null ;
181+ setResizePreview ( null ) ;
182+ setPetSize ( nextSize ) ;
183+ } ,
184+ [ setPetSize ] ,
185+ ) ;
186+
92187 if ( ! petEnabled ) return null ;
93188
189+ const displayPetSize = resizePreview ?? petSize ;
190+
94191 return (
95192 < div className = "pointer-events-none fixed inset-0 flex flex-col items-end justify-end gap-1.5 p-2.5" >
96193 { items . length > 0 && ! collapsed && (
@@ -120,20 +217,55 @@ export function DesktopPet() {
120217
121218 < div
122219 data-pet-interactive
123- className = "pointer-events-auto cursor-grab active:cursor-grabbing"
220+ className = "group relative pointer-events-auto cursor-grab active:cursor-grabbing"
124221 title = { selectedPet . displayName }
125222 onPointerDown = { onPointerDown }
126223 onPointerMove = { onPointerMove }
127224 onPointerUp = { onPointerEnd }
128225 onPointerCancel = { onPointerEnd }
226+ onContextMenu = { onPetContextMenu }
129227 >
130228 < QwenPet
131229 spritesheetUrl = { selectedPet . spritesheetUrl }
132230 state = { state }
133- size = { 96 }
231+ size = { displayPetSize }
134232 className = "drop-shadow-[0_3px_6px_rgba(0,0,0,0.35)]"
135233 />
234+ < button
235+ type = "button"
236+ data-pet-interactive
237+ aria-label = "resize pet"
238+ title = "Resize pet"
239+ onPointerDown = { onResizePointerDown }
240+ onPointerMove = { onResizePointerMove }
241+ onPointerUp = { onResizePointerEnd }
242+ onPointerCancel = { onResizePointerEnd }
243+ className = { `absolute -bottom-0.5 -right-0.5 flex h-6 w-6 cursor-nwse-resize items-center justify-center rounded-lg border border-white/15 bg-neutral-900/55 text-white shadow-[0_3px_10px_rgba(0,0,0,0.28)] backdrop-blur transition-opacity hover:bg-neutral-900/70 focus-visible:opacity-100 ${
244+ resizePreview == null
245+ ? 'opacity-0 group-hover:opacity-100'
246+ : 'opacity-100'
247+ } `}
248+ >
249+ < Maximize2 className = "h-3 w-3" />
250+ </ button >
136251 </ div >
252+
253+ { contextMenu && (
254+ < div
255+ data-pet-interactive
256+ className = "pointer-events-auto absolute z-50 rounded-md border border-neutral-200 bg-white p-0.5 text-neutral-800 shadow-[0_5px_16px_rgba(0,0,0,0.16)]"
257+ style = { { left : contextMenu . x , top : contextMenu . y } }
258+ onContextMenu = { ( event ) => event . preventDefault ( ) }
259+ >
260+ < button
261+ type = "button"
262+ onClick = { onClosePet }
263+ className = "flex h-6 min-w-20 items-center rounded px-2 text-xs hover:bg-neutral-100"
264+ >
265+ { t ( 'pet.menu.close' ) }
266+ </ button >
267+ </ div >
268+ ) }
137269 </ div >
138270 ) ;
139271}
0 commit comments