@@ -19,7 +19,14 @@ import {
1919 alpha ,
2020 Box ,
2121 Button ,
22+ ClickAwayListener ,
2223 IconButton ,
24+ ListItemIcon ,
25+ ListItemText ,
26+ MenuItem ,
27+ MenuList ,
28+ Paper ,
29+ Popper ,
2330 Tooltip ,
2431 Typography ,
2532 useMediaQuery ,
@@ -47,7 +54,13 @@ import { activitySlice } from './activitySlice';
4754const areWindowsEnabled = false ;
4855
4956/** Activity position relative to the main container */
50- type ActivityLocation = 'full' | 'split-left' | 'split-right' | 'window' ;
57+ type ActivityLocation =
58+ | 'full'
59+ | 'split-left'
60+ | 'split-right'
61+ | 'split-top'
62+ | 'split-bottom'
63+ | 'window' ;
5164
5265/** Independent screen or a page rendered on top of the app */
5366export interface Activity {
@@ -129,11 +142,29 @@ export function SingleActivityRenderer({
129142 const containerElementRef = useRef ( document . getElementById ( 'main' ) ) ;
130143 const theme = useTheme ( ) ;
131144 const isSmallScreen = useMediaQuery ( theme . breakpoints . down ( 'lg' ) ) ;
145+ const [ snapMenuAnchor , setSnapMenuAnchor ] = useState < HTMLElement | null > ( null ) ;
146+ const [ snapMenuOpenReason , setSnapMenuOpenReason ] = useState < 'hover' | 'click' | null > ( null ) ;
147+ const openTimerRef = useRef < NodeJS . Timeout | null > ( null ) ;
148+ const closeTimerRef = useRef < NodeJS . Timeout | null > ( null ) ;
149+ const menuListRef = useRef < HTMLUListElement > ( null ) ;
150+ const isSnapMenuOpen = Boolean ( snapMenuAnchor ) ;
132151
133152 useEffect ( ( ) => {
134153 containerElementRef . current = document . getElementById ( 'main' ) ;
135154 } , [ ] ) ;
136155
156+ // Cleanup timers on unmount
157+ useEffect ( ( ) => {
158+ return ( ) => {
159+ if ( openTimerRef . current ) {
160+ clearTimeout ( openTimerRef . current ) ;
161+ }
162+ if ( closeTimerRef . current ) {
163+ clearTimeout ( closeTimerRef . current ) ;
164+ }
165+ } ;
166+ } , [ ] ) ;
167+
137168 // Styles of different activity locations
138169 const locationStyles = {
139170 full : {
@@ -158,6 +189,21 @@ export function SingleActivityRenderer({
158189 height : '100%' ,
159190 gridColumn : '2 / 4' ,
160191 } ,
192+ 'split-top' : {
193+ position : 'absolute' ,
194+ top : 0 ,
195+ left : 0 ,
196+ width : '100%' ,
197+ height : '50%' ,
198+ borderBottom : '1px solid' ,
199+ } ,
200+ 'split-bottom' : {
201+ position : 'absolute' ,
202+ bottom : 0 ,
203+ left : 0 ,
204+ width : '100%' ,
205+ height : '50%' ,
206+ } ,
161207 window : {
162208 position : 'absolute' ,
163209 width : '50%' ,
@@ -218,7 +264,7 @@ export function SingleActivityRenderer({
218264 activity . style . height = oldHeight ;
219265 }
220266 } ;
221- } , [ isOverview ] ) ;
267+ } , [ isOverview , index ] ) ;
222268
223269 // Move focus inside the Activity
224270 useEffect ( ( ) => {
@@ -288,6 +334,10 @@ export function SingleActivityRenderer({
288334 ? {
289335 borderRadius : '20px' ,
290336 cursor : 'pointer' ,
337+ top : 0 ,
338+ left : 0 ,
339+ right : 'auto' ,
340+ bottom : 'auto' ,
291341 ':hover' : {
292342 boxShadow :
293343 theme . palette . mode === 'light'
@@ -377,20 +427,209 @@ export function SingleActivityRenderer({
377427 < >
378428 < IconButton
379429 size = "small"
380- title = { t ( 'Snap Left' ) }
381- onClick = { ( ) => Activity . update ( id , { location : 'split-left' } ) }
382- disabled = { location === 'split-left' }
430+ title = { t ( 'Window' ) }
431+ aria-haspopup = "menu"
432+ aria-expanded = { isSnapMenuOpen }
433+ aria-controls = { isSnapMenuOpen ? 'snap-menu' : undefined }
434+ onMouseEnter = { event => {
435+ // Clear any existing close timer
436+ if ( closeTimerRef . current ) {
437+ clearTimeout ( closeTimerRef . current ) ;
438+ closeTimerRef . current = null ;
439+ }
440+ // Set 350ms timer to open on hover
441+ if ( openTimerRef . current ) {
442+ clearTimeout ( openTimerRef . current ) ;
443+ }
444+ const target = event . currentTarget ;
445+ openTimerRef . current = setTimeout ( ( ) => {
446+ setSnapMenuAnchor ( target ) ;
447+ setSnapMenuOpenReason ( 'hover' ) ;
448+ openTimerRef . current = null ;
449+ } , 350 ) ;
450+ } }
451+ onMouseLeave = { ( ) => {
452+ // Cancel open timer if pointer leaves before 350ms
453+ if ( openTimerRef . current ) {
454+ clearTimeout ( openTimerRef . current ) ;
455+ openTimerRef . current = null ;
456+ }
457+ // If menu is open due to hover, start close timer
458+ if ( snapMenuOpenReason === 'hover' && isSnapMenuOpen ) {
459+ if ( closeTimerRef . current ) {
460+ clearTimeout ( closeTimerRef . current ) ;
461+ }
462+ closeTimerRef . current = setTimeout ( ( ) => {
463+ setSnapMenuAnchor ( null ) ;
464+ setSnapMenuOpenReason ( null ) ;
465+ closeTimerRef . current = null ;
466+ } , 150 ) ;
467+ }
468+ } }
469+ onClick = { event => {
470+ // Clear any timers
471+ if ( openTimerRef . current ) {
472+ clearTimeout ( openTimerRef . current ) ;
473+ openTimerRef . current = null ;
474+ }
475+ if ( closeTimerRef . current ) {
476+ clearTimeout ( closeTimerRef . current ) ;
477+ closeTimerRef . current = null ;
478+ }
479+ // Toggle menu on click
480+ if ( isSnapMenuOpen && snapMenuOpenReason === 'click' ) {
481+ setSnapMenuAnchor ( null ) ;
482+ setSnapMenuOpenReason ( null ) ;
483+ } else {
484+ setSnapMenuAnchor ( event . currentTarget ) ;
485+ setSnapMenuOpenReason ( 'click' ) ;
486+ }
487+ } }
488+ onKeyDown = { event => {
489+ if ( event . key === 'Enter' || event . key === 'ArrowDown' ) {
490+ event . preventDefault ( ) ;
491+ if ( openTimerRef . current ) {
492+ clearTimeout ( openTimerRef . current ) ;
493+ openTimerRef . current = null ;
494+ }
495+ if ( ! isSnapMenuOpen ) {
496+ setSnapMenuAnchor ( event . currentTarget as HTMLElement ) ;
497+ setSnapMenuOpenReason ( 'click' ) ;
498+ }
499+ }
500+ } }
383501 >
384- < Icon icon = "mdi:dock-left " />
502+ < Icon icon = "mdi:dock-window " />
385503 </ IconButton >
386- < IconButton
387- size = "small"
388- title = { t ( 'Snap Right' ) }
389- onClick = { ( ) => Activity . update ( id , { location : 'split-right' } ) }
390- disabled = { location === 'split-right' }
504+ < Popper
505+ id = "snap-menu"
506+ open = { isSnapMenuOpen }
507+ anchorEl = { snapMenuAnchor }
508+ placement = "bottom-end"
509+ sx = { { zIndex : theme . zIndex . modal } }
391510 >
392- < Icon icon = "mdi:dock-right" />
393- </ IconButton >
511+ < ClickAwayListener
512+ onClickAway = { ( ) => {
513+ if ( snapMenuOpenReason === 'click' ) {
514+ setSnapMenuAnchor ( null ) ;
515+ setSnapMenuOpenReason ( null ) ;
516+ }
517+ } }
518+ >
519+ < Paper
520+ elevation = { 8 }
521+ onMouseEnter = { ( ) => {
522+ // Cancel close timer when entering menu (for hover-open case)
523+ if ( closeTimerRef . current && snapMenuOpenReason === 'hover' ) {
524+ clearTimeout ( closeTimerRef . current ) ;
525+ closeTimerRef . current = null ;
526+ }
527+ } }
528+ onMouseLeave = { ( ) => {
529+ // Start close timer when leaving menu (for hover-open case)
530+ if ( snapMenuOpenReason === 'hover' ) {
531+ if ( closeTimerRef . current ) {
532+ clearTimeout ( closeTimerRef . current ) ;
533+ }
534+ closeTimerRef . current = setTimeout ( ( ) => {
535+ setSnapMenuAnchor ( null ) ;
536+ setSnapMenuOpenReason ( null ) ;
537+ closeTimerRef . current = null ;
538+ } , 150 ) ;
539+ }
540+ } }
541+ onKeyDown = { event => {
542+ if ( event . key === 'Escape' ) {
543+ setSnapMenuAnchor ( null ) ;
544+ setSnapMenuOpenReason ( null ) ;
545+ snapMenuAnchor ?. focus ( ) ;
546+ }
547+ } }
548+ >
549+ < MenuList
550+ ref = { menuListRef }
551+ aria-label = { t ( 'Window' ) }
552+ autoFocusItem = { isSnapMenuOpen }
553+ >
554+ < MenuItem
555+ selected = { location === 'full' }
556+ aria-label = { t ( 'Fullscreen' ) }
557+ title = { t ( 'Fullscreen' ) }
558+ onClick = { ( ) => {
559+ Activity . update ( id , { location : 'full' } ) ;
560+ setSnapMenuAnchor ( null ) ;
561+ setSnapMenuOpenReason ( null ) ;
562+ } }
563+ >
564+ < ListItemIcon >
565+ < Icon icon = "mdi:fullscreen" />
566+ </ ListItemIcon >
567+ < ListItemText > { t ( 'Fullscreen' ) } </ ListItemText >
568+ </ MenuItem >
569+ < MenuItem
570+ selected = { location === 'split-left' }
571+ aria-label = { t ( 'Snap Left' ) }
572+ title = { t ( 'Snap Left' ) }
573+ onClick = { ( ) => {
574+ Activity . update ( id , { location : 'split-left' } ) ;
575+ setSnapMenuAnchor ( null ) ;
576+ setSnapMenuOpenReason ( null ) ;
577+ } }
578+ >
579+ < ListItemIcon >
580+ < Icon icon = "mdi:dock-left" />
581+ </ ListItemIcon >
582+ < ListItemText > { t ( 'Snap Left' ) } </ ListItemText >
583+ </ MenuItem >
584+ < MenuItem
585+ selected = { location === 'split-right' }
586+ aria-label = { t ( 'Snap Right' ) }
587+ title = { t ( 'Snap Right' ) }
588+ onClick = { ( ) => {
589+ Activity . update ( id , { location : 'split-right' } ) ;
590+ setSnapMenuAnchor ( null ) ;
591+ setSnapMenuOpenReason ( null ) ;
592+ } }
593+ >
594+ < ListItemIcon >
595+ < Icon icon = "mdi:dock-right" />
596+ </ ListItemIcon >
597+ < ListItemText > { t ( 'Snap Right' ) } </ ListItemText >
598+ </ MenuItem >
599+ < MenuItem
600+ selected = { location === 'split-top' }
601+ aria-label = { t ( 'Snap Top' ) }
602+ title = { t ( 'Snap Top' ) }
603+ onClick = { ( ) => {
604+ Activity . update ( id , { location : 'split-top' } ) ;
605+ setSnapMenuAnchor ( null ) ;
606+ setSnapMenuOpenReason ( null ) ;
607+ } }
608+ >
609+ < ListItemIcon >
610+ < Icon icon = "mdi:dock-top" />
611+ </ ListItemIcon >
612+ < ListItemText > { t ( 'Snap Top' ) } </ ListItemText >
613+ </ MenuItem >
614+ < MenuItem
615+ selected = { location === 'split-bottom' }
616+ aria-label = { t ( 'Snap Bottom' ) }
617+ title = { t ( 'Snap Bottom' ) }
618+ onClick = { ( ) => {
619+ Activity . update ( id , { location : 'split-bottom' } ) ;
620+ setSnapMenuAnchor ( null ) ;
621+ setSnapMenuOpenReason ( null ) ;
622+ } }
623+ >
624+ < ListItemIcon >
625+ < Icon icon = "mdi:dock-bottom" />
626+ </ ListItemIcon >
627+ < ListItemText > { t ( 'Snap Bottom' ) } </ ListItemText >
628+ </ MenuItem >
629+ </ MenuList >
630+ </ Paper >
631+ </ ClickAwayListener >
632+ </ Popper >
394633 < IconButton
395634 onClick = { ( ) => {
396635 Activity . update ( id , { minimized : true } ) ;
@@ -400,30 +639,6 @@ export function SingleActivityRenderer({
400639 >
401640 < Icon icon = "mdi:minimize" />
402641 </ IconButton >
403-
404- < >
405- { location === 'full' ? (
406- < IconButton
407- size = "small"
408- onClick = { ( ) => {
409- Activity . update ( id , {
410- location : lastNonFullscreenLocation . current ?? 'split-right' ,
411- } ) ;
412- } }
413- title = { t ( 'Window' ) }
414- >
415- < Icon icon = "mdi:dock-window" />
416- </ IconButton >
417- ) : (
418- < IconButton
419- size = "small"
420- onClick = { ( ) => Activity . update ( id , { location : 'full' } ) }
421- title = { t ( 'Fullscreen' ) }
422- >
423- < Icon icon = "mdi:fullscreen" />
424- </ IconButton >
425- ) }
426- </ >
427642 < IconButton onClick = { ( ) => Activity . close ( id ) } size = "small" title = { t ( 'Close' ) } >
428643 < Icon icon = "mdi:close" />
429644 </ IconButton >
@@ -753,6 +968,18 @@ export const ActivitiesRenderer = React.memo(function ActivitiesRenderer() {
753968 }
754969 } ) ;
755970
971+ useHotkeys ( 'Ctrl+Shift+ArrowUp' , ( ) => {
972+ if ( lastElement ) {
973+ Activity . update ( lastElement , { location : 'split-top' } ) ;
974+ }
975+ } ) ;
976+
977+ useHotkeys ( 'Ctrl+Shift+ArrowDown' , ( ) => {
978+ if ( lastElement ) {
979+ Activity . update ( lastElement , { location : 'split-bottom' } ) ;
980+ }
981+ } ) ;
982+
756983 useHotkeys ( 'Ctrl+ArrowUp' , ( ) => {
757984 if ( lastElement ) {
758985 Activity . update ( lastElement , { location : 'full' } ) ;
0 commit comments