Skip to content

Commit 84af694

Browse files
authored
Merge pull request #4259 from kahirokunn/add-positions
frontend: Add vertical snap positions for activities
2 parents b228929 + f1b7a13 commit 84af694

File tree

15 files changed

+324
-103
lines changed

15 files changed

+324
-103
lines changed

frontend/src/components/App/icons.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,12 @@ const mdiIcons = {
351351
'dock-right': {
352352
body: '\u003Cpath fill="currentColor" d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2m-5 14H4V6h11Z"/\u003E',
353353
},
354+
'dock-top': {
355+
body: '\u003Cpath fill="currentColor" d="M4 20h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2m0-9h16v7H4z"/\u003E',
356+
},
357+
'dock-bottom': {
358+
body: '\u003Cpath fill="currentColor" d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2m0 9H4V6h16Z"/\u003E',
359+
},
354360
'dock-window': {
355361
body: '\u003Cpath fill="currentColor" d="M18 18v2H4a2 2 0 0 1-2-2V8h2v10M22 6v8a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2m-2 0H8v8h12Z"/\u003E',
356362
},

frontend/src/components/activity/Activity.tsx

Lines changed: 264 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
4754
const 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 */
5366
export 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

Comments
 (0)