diff --git a/gbajs3/src/components/controls/__snapshots__/control-panel.spec.tsx.snap b/gbajs3/src/components/controls/__snapshots__/control-panel.spec.tsx.snap index 91c39d18..ac92be94 100644 --- a/gbajs3/src/components/controls/__snapshots__/control-panel.spec.tsx.snap +++ b/gbajs3/src/components/controls/__snapshots__/control-panel.spec.tsx.snap @@ -57,7 +57,7 @@ exports[` > renders with default desktop position and size 1`] = color: #0d6efd; } -.c4 { +.c5 { cursor: pointer; background-color: #a9a9a9; border-radius: 0.25rem; @@ -74,7 +74,7 @@ exports[` > renders with default desktop position and size 1`] = max-height: 40px; } -.c6 { +.c7 { cursor: pointer; background-color: #a9a9a9; border-radius: 0.25rem; @@ -91,29 +91,40 @@ exports[` > renders with default desktop position and size 1`] = max-height: 40px; } -.c5 { +.c6 { flex-grow: 1; } -.c5 >.MuiSlider-markActive { +.c6 >.MuiSlider-markActive { opacity: 1; background-color: currentColor; } +.c4 { + display: contents; +} + @media only screen and (min-width: 600px) { .c3 { width: auto; } } +@media only screen and (max-height: 450px) and (max-width: 1000px) and (orientation: landscape) { + .c3 { + flex-shrink: 1; + min-width: unset; + } +} + @media only screen and (min-width: 600px) { - .c4 { + .c5 { width: auto; } } @media only screen and (min-width: 600px) { - .c6 { + .c7 { width: auto; } } @@ -225,273 +236,281 @@ exports[` > renders with default desktop position and size 1`] = -
  • - - - - - - - - - - - - - - - + + + + - + + + + + + + + +
  • -
  • + + + + +
  • + + - - - - - - - - - + + + + - + + + + + + - - - + + + + + +
    @@ -549,7 +568,7 @@ exports[` > renders with default mobile position and size 1`] = color: #0d6efd; } -.c4 { +.c5 { cursor: pointer; background-color: #a9a9a9; border-radius: 0.25rem; @@ -566,7 +585,7 @@ exports[` > renders with default mobile position and size 1`] = max-height: 40px; } -.c6 { +.c7 { cursor: pointer; background-color: #a9a9a9; border-radius: 0.25rem; @@ -583,29 +602,40 @@ exports[` > renders with default mobile position and size 1`] = max-height: 40px; } -.c5 { +.c6 { flex-grow: 1; } -.c5 >.MuiSlider-markActive { +.c6 >.MuiSlider-markActive { opacity: 1; background-color: currentColor; } +.c4 { + display: contents; +} + @media only screen and (min-width: 600px) { .c3 { width: auto; } } +@media only screen and (max-height: 450px) and (max-width: 1000px) and (orientation: landscape) { + .c3 { + flex-shrink: 1; + min-width: unset; + } +} + @media only screen and (min-width: 600px) { - .c4 { + .c5 { width: auto; } } @media only screen and (min-width: 600px) { - .c6 { + .c7 { width: auto; } } @@ -717,273 +747,281 @@ exports[` > renders with default mobile position and size 1`] = -
  • - - - - - - - - - - - - - - - + + + + - + + + + + + + + +
  • -
  • + + + + +
  • +
    + - - - - - - - - - + + + + - + + + + + + - - - + + + + + +
    diff --git a/gbajs3/src/components/controls/control-panel.tsx b/gbajs3/src/components/controls/control-panel.tsx index 938a0252..984344fd 100644 --- a/gbajs3/src/components/controls/control-panel.tsx +++ b/gbajs3/src/components/controls/control-panel.tsx @@ -1,6 +1,19 @@ -import { IconButton, Slider, useMediaQuery } from '@mui/material'; +import { + ClickAwayListener, + IconButton, + Slider, + Tooltip, + tooltipClasses, + useMediaQuery +} from '@mui/material'; import { useLocalStorage } from '@uidotdev/usehooks'; -import { useCallback, useId, useState, type ReactNode } from 'react'; +import { + useCallback, + useId, + useState, + forwardRef, + type ReactNode +} from 'react'; import { IconContext } from 'react-icons'; import { AiOutlineFastForward, AiOutlineForward } from 'react-icons/ai'; import { @@ -36,6 +49,7 @@ import { ButtonBase } from '../shared/custom-button-base.tsx'; import { GripperHandle } from '../shared/gripper-handle.tsx'; import type { IconButtonProps, SliderProps } from '@mui/material'; +import type { IconType } from 'react-icons'; type PanelProps = { $controlled: boolean; @@ -61,6 +75,10 @@ type PanelSliderProps = { minIcon: ReactNode; } & SliderProps; +type TooltipSliderProps = PanelSliderProps & { + ButtonIcon: IconType; +}; + type ControlledProps = { $controlled: boolean; }; @@ -138,6 +156,11 @@ const PanelControlButton = styled(ButtonBase).attrs({ &:active { color: ${({ theme }) => theme.gbaThemeBlue}; } + + @media ${({ theme }) => theme.isMobileLandscape} { + flex-shrink: 1; + min-width: unset; + } `; const PanelControlSlider = styled.li` @@ -155,6 +178,10 @@ const MutedMarkSlider = styled(Slider)` } `; +const ContentSpan = styled.span` + display: contents; +`; + const PanelButton = ({ ariaLabel, children, @@ -194,29 +221,83 @@ const SliderIconButton = ({ icon, ...rest }: SliderIconButtonProps) => { ); }; -const PanelSlider = ({ - controlled, - gridArea, - id, - maxIcon, - minIcon, - ...rest -}: PanelSliderProps) => { - return ( - - {minIcon} - - {maxIcon} - +const PanelSlider = forwardRef( + ({ controlled, gridArea, id, maxIcon, minIcon, ...rest }, ref) => { + return ( + + + {minIcon} + + {maxIcon} + + + ); + } +); + +const popperStyles = { + [`&.${tooltipClasses.popper}[data-popper-placement*="bottom"] .${tooltipClasses.tooltip}`]: + { + marginTop: '16px' + }, + [`&.${tooltipClasses.popper}[data-popper-placement*="top"] .${tooltipClasses.tooltip}`]: + { + marginBottom: '16px' + }, + [`&.${tooltipClasses.popper}[data-popper-placement*="right"] .${tooltipClasses.tooltip}`]: + { + marginLeft: '16px' + }, + [`&.${tooltipClasses.popper}[data-popper-placement*="left"] .${tooltipClasses.tooltip}`]: + { + marginRight: '16px' + } +}; + +const TooltipPanelSlider = ({ ButtonIcon, ...rest }: TooltipSliderProps) => { + const theme = useTheme(); + const isMobileLandscape = useMediaQuery(theme.isMobileLandscape); + const [isTooltipOpen, setIsTooltipOpen] = useState(false); + + return isMobileLandscape ? ( + setIsTooltipOpen(false)}> + + + } + arrow + slotProps={{ + popper: { + sx: popperStyles + }, + tooltip: { sx: { padding: '8px 16px' } } + }} + placement="bottom-end" + > + setIsTooltipOpen((prevState) => !prevState)} + $controlled={rest.controlled} + > + + + + ) : ( + ); }; @@ -225,9 +306,10 @@ export const ControlPanel = () => { const { isRunning } = useRunningContext(); const { areItemsDraggable, setAreItemsDraggable } = useDragContext(); const { areItemsResizable, setAreItemsResizable } = useResizeContext(); - const { layouts, setLayout } = useLayoutContext(); + const { layouts, setLayout, hasSetLayout } = useLayoutContext(); const theme = useTheme(); const isLargerThanPhone = useMediaQuery(theme.isLargerThanPhone); + const isMobileLandscape = useMediaQuery(theme.isMobileLandscape); const [isPaused, setIsPaused] = useState(false); const [isResizing, setIsResizing] = useState(false); const controlPanelId = useId(); @@ -247,12 +329,12 @@ export const ControlPanel = () => { const refSetLayout = useCallback( (node: Rnd | null) => { - if (!layouts?.controlPanel?.initialBounds && node) + if (!hasSetLayout && node) setLayout('controlPanel', { initialBounds: node.resizableElement.current?.getBoundingClientRect() }); }, - [setLayout, layouts] + [setLayout, hasSetLayout] ); const canvasBounds = layouts?.screen?.initialBounds; @@ -362,14 +444,21 @@ export const ControlPanel = () => { } ]; - const defaultPosition = { - x: Math.floor(canvasBounds.left), - y: Math.floor(canvasBounds.bottom + dragWrapperPadding) - }; - const defaultSize = { - width: isLargerThanPhone ? 'auto' : '100dvw', - height: 'auto' - }; + const defaultPosition = isMobileLandscape + ? { x: Math.floor(canvasBounds.left + canvasBounds.width), y: 0 } + : { + x: Math.floor(canvasBounds.left), + y: Math.floor(canvasBounds.bottom + dragWrapperPadding) + }; + const defaultSize = isMobileLandscape + ? { + width: Math.min(80, canvasBounds.left), + height: 'auto' + } + : { + width: isLargerThanPhone ? 'auto' : '100dvw', + height: 'auto' + }; const position = layouts?.controlPanel?.position ?? defaultPosition; const size = layouts?.controlPanel?.size ?? defaultSize; @@ -471,7 +560,7 @@ export const ControlPanel = () => { )} - { } valueLabelFormat={`${currentEmulatorVolume * 100}`} onChange={setVolumeFromEvent} + ButtonIcon={BiVolumeFull} {...defaultSliderEvents} /> - { } valueLabelFormat={`x${fastForwardMultiplier}`} onChange={setFastForwardFromEvent} + ButtonIcon={AiOutlineFastForward} {...defaultSliderEvents} /> diff --git a/gbajs3/src/components/controls/o-pad.tsx b/gbajs3/src/components/controls/o-pad.tsx index fdf5c8cc..8465cffd 100644 --- a/gbajs3/src/components/controls/o-pad.tsx +++ b/gbajs3/src/components/controls/o-pad.tsx @@ -65,6 +65,10 @@ const BackgroundContainer = styled.section` top: ${$initialPosition.top}; left: ${$initialPosition.left}; `}; + + @media ${({ theme }) => theme.isMobileLandscape} { + background-color: transparent; + } `; const CenterKnob = styled.div` @@ -87,6 +91,10 @@ const CenterKnob = styled.div` border: 0.8rem solid ${({ theme }) => theme.gbaThemeBlue}50; border-radius: 50%; } + + @media ${({ theme }) => theme.isMobileLandscape} { + background-color: transparent; + } `; const DirectionArrow = styled.div` diff --git a/gbajs3/src/components/controls/virtual-button.tsx b/gbajs3/src/components/controls/virtual-button.tsx index 2201ff56..3e864035 100644 --- a/gbajs3/src/components/controls/virtual-button.tsx +++ b/gbajs3/src/components/controls/virtual-button.tsx @@ -50,6 +50,10 @@ const VirtualButtonBase = styled(ButtonBase)` cursor: pointer; box-sizing: content-box; border-width: 2px; + + @media ${({ theme }) => theme.isMobileLandscape} { + background-color: transparent; + } `; const CircularButton = styled(VirtualButtonBase)` diff --git a/gbajs3/src/components/controls/virtual-controls.tsx b/gbajs3/src/components/controls/virtual-controls.tsx index 79860bd4..498a2cdf 100644 --- a/gbajs3/src/components/controls/virtual-controls.tsx +++ b/gbajs3/src/components/controls/virtual-controls.tsx @@ -56,6 +56,7 @@ export const VirtualControls = () => { const theme = useTheme(); const isLargerThanPhone = useMediaQuery(theme.isLargerThanPhone); const isMobileWithUrlBar = useMediaQuery(theme.isMobileWithUrlBar); + const isMobileLandscape = useMediaQuery(theme.isMobileLandscape); const { emulator } = useEmulatorContext(); const { isRunning } = useRunningContext(); const { isAuthenticated } = useAuthContext(); @@ -73,12 +74,14 @@ export const VirtualControls = () => { >(virtualControlsLocalStorageKey); const controlPanelBounds = layouts?.controlPanel?.initialBounds; + const canvasBounds = layouts?.screen?.initialBounds; if (!controlPanelBounds) return null; const shouldShowVirtualControl = (virtualControlEnabled?: boolean) => { return ( - (virtualControlEnabled === undefined && !isLargerThanPhone) || + (virtualControlEnabled === undefined && + (!isLargerThanPhone || isMobileLandscape)) || !!virtualControlEnabled ); }; @@ -99,6 +102,7 @@ export const VirtualControls = () => { mobileWithUrlBar?: { top?: string; left?: string }; largerThanPhone?: { top?: string; left?: string }; defaultMobile: { top: string; left: string }; + mobileLandscape?: { top: string; left: string }; }; } = { 'a-button': { @@ -112,6 +116,10 @@ export const VirtualControls = () => { largerThanPhone: { top: `calc(${verticalStartPos}px + 35px - 3%)`, left: `calc(${horizontalStartPos}px + 450px)` + }, + mobileLandscape: { + top: '235px', + left: `calc(${horizontalStartPos}px - 10px)` } }, 'b-button': { @@ -125,6 +133,10 @@ export const VirtualControls = () => { largerThanPhone: { top: `calc(${verticalStartPos}px + 35px)`, left: `calc(${horizontalStartPos}px + 375px)` + }, + mobileLandscape: { + top: 'calc(235px + 3%)', + left: `calc(${horizontalStartPos}px - 85px)` } }, 'start-button': { @@ -139,6 +151,10 @@ export const VirtualControls = () => { largerThanPhone: { top: `calc(${verticalStartPos}px + 60px)`, left: `${horizontalStartPos}px` + }, + mobileLandscape: { + top: 'calc(100dvh - 60px)', + left: '220px' } }, 'select-button': { @@ -153,6 +169,10 @@ export const VirtualControls = () => { largerThanPhone: { top: `calc(${verticalStartPos}px + 60px)`, left: `calc(${horizontalStartPos}px + 103px)` + }, + mobileLandscape: { + top: 'calc(100dvh - 60px)', + left: 'calc(100dvw - 255px)' } }, 'l-button': { @@ -163,6 +183,10 @@ export const VirtualControls = () => { largerThanPhone: { top: `calc(${verticalStartPos}px + 15px)`, left: `${horizontalStartPos}px` + }, + mobileLandscape: { + top: 'calc(100dvh - 105px)', + left: '230px' } }, 'r-button': { @@ -173,6 +197,10 @@ export const VirtualControls = () => { largerThanPhone: { top: `calc(${verticalStartPos}px + 15px)`, left: `calc(${horizontalStartPos}px + 190px)` + }, + mobileLandscape: { + top: 'calc(100dvh - 60px)', + left: 'calc(100dvw - 65px)' } }, 'quickreload-button': { @@ -183,6 +211,10 @@ export const VirtualControls = () => { largerThanPhone: { top: `calc(${verticalStartPos}px + 10px)`, left: `calc(${horizontalStartPos}px + 205px)` + }, + mobileLandscape: { + top: '5px', + left: `calc(${canvasBounds?.left}px - 50px)` } }, 'uploadsave-button': { @@ -193,6 +225,10 @@ export const VirtualControls = () => { largerThanPhone: { top: `calc(${verticalStartPos}px + 10px)`, left: `calc(${horizontalStartPos}px + 300px)` + }, + mobileLandscape: { + top: '55px', + left: `calc(${canvasBounds?.left}px - 5px)` } }, 'loadstate-button': { @@ -206,6 +242,10 @@ export const VirtualControls = () => { largerThanPhone: { top: `calc(${verticalStartPos}px + 60px)`, left: `calc(${horizontalStartPos}px + 248px)` + }, + mobileLandscape: { + top: '105px', + left: `calc(${canvasBounds?.left}px - 5px)` } }, 'savestate-button': { @@ -219,6 +259,10 @@ export const VirtualControls = () => { largerThanPhone: { top: `calc(${verticalStartPos}px + 60px)`, left: `calc(${horizontalStartPos}px + 300px)` + }, + mobileLandscape: { + top: '155px', + left: `calc(${canvasBounds?.left}px - 5px)` } }, 'o-pad': { @@ -229,6 +273,10 @@ export const VirtualControls = () => { largerThanPhone: { top: `calc(${verticalStartPos}px + 10px)`, left: `calc(${horizontalStartPos}px + 450px)` + }, + mobileLandscape: { + top: 'calc(100dvh - 205px)', + left: '25px' } } }; @@ -237,6 +285,8 @@ export const VirtualControls = () => { let variation = undefined; if (isMobileWithUrlBar && positionVariations[key]?.mobileWithUrlBar) { variation = positionVariations[key]?.mobileWithUrlBar; + } else if (isMobileLandscape && positionVariations[key]?.mobileLandscape) { + variation = positionVariations[key]?.mobileLandscape; } else if (isLargerThanPhone && positionVariations[key]?.largerThanPhone) { variation = positionVariations[key]?.largerThanPhone; } diff --git a/gbajs3/src/components/modals/controls/virtual-controls-form.tsx b/gbajs3/src/components/modals/controls/virtual-controls-form.tsx index b07cdec7..45d49035 100644 --- a/gbajs3/src/components/modals/controls/virtual-controls-form.tsx +++ b/gbajs3/src/components/modals/controls/virtual-controls-form.tsx @@ -38,10 +38,12 @@ export const VirtualControlsForm = ({ ); const theme = useTheme(); const isLargerThanPhone = useMediaQuery(theme.isLargerThanPhone); + const isMobileLandscape = useMediaQuery(theme.isMobileLandscape); const shouldShowVirtualControl = (virtualControlEnabled?: boolean) => { return ( - (virtualControlEnabled === undefined && !isLargerThanPhone) || + (virtualControlEnabled === undefined && + (!isLargerThanPhone || isMobileLandscape)) || !!virtualControlEnabled ); }; diff --git a/gbajs3/src/components/modals/modal-container.tsx b/gbajs3/src/components/modals/modal-container.tsx index d4653163..361a91ba 100644 --- a/gbajs3/src/components/modals/modal-container.tsx +++ b/gbajs3/src/components/modals/modal-container.tsx @@ -1,4 +1,6 @@ +import { useMediaQuery } from '@mui/material'; import Modal from 'react-modal'; +import { useTheme } from 'styled-components'; import { useEmulatorContext, useModalContext } from '../../hooks/context.tsx'; @@ -24,16 +26,26 @@ const modalStyles = { } }; +const landscapeModalStyles = { + ...modalStyles, + content: { + ...modalStyles.content, + margin: '5px auto auto auto' + } +}; + export const ModalContainer = () => { const { modalContent, isModalOpen, setIsModalOpen } = useModalContext(); const { emulator } = useEmulatorContext(); + const theme = useTheme(); + const isMobileLandscape = useMediaQuery(theme.isMobileLandscape); return ( setIsModalOpen(false)} onAfterOpen={emulator?.disableKeyboardInput} onAfterClose={emulator?.enableKeyboardInput} diff --git a/gbajs3/src/components/navigation-menu/navigation-menu.tsx b/gbajs3/src/components/navigation-menu/navigation-menu.tsx index 4021b0f8..5bc9727f 100644 --- a/gbajs3/src/components/navigation-menu/navigation-menu.tsx +++ b/gbajs3/src/components/navigation-menu/navigation-menu.tsx @@ -123,6 +123,11 @@ const HamburgerButton = styled(ButtonBase)` min-height: 36px; min-width: 40px; + @media ${({ theme }) => theme.isMobileLandscape} { + bottom: 15px; + top: unset; + } + ${({ $isExpanded = false }) => !$isExpanded && `left: -8px; @@ -153,6 +158,7 @@ export const NavigationMenu = () => { const { execute: executeLogout } = useLogout(); const theme = useTheme(); const isLargerThanPhone = useMediaQuery(theme.isLargerThanPhone); + const isMobileLandscape = useMediaQuery(theme.isMobileLandscape); const menuHeaderId = useId(); const quickReload = useQuickReload(); @@ -388,7 +394,7 @@ export const NavigationMenu = () => { /> - {isExpanded && !isLargerThanPhone && ( + {isExpanded && (!isLargerThanPhone || isMobileLandscape) && ( { diff --git a/gbajs3/src/components/screen/__snapshots__/screen.spec.tsx.snap b/gbajs3/src/components/screen/__snapshots__/screen.spec.tsx.snap index 2f66dee4..ac18b335 100644 --- a/gbajs3/src/components/screen/__snapshots__/screen.spec.tsx.snap +++ b/gbajs3/src/components/screen/__snapshots__/screen.spec.tsx.snap @@ -31,6 +31,13 @@ exports[` > renders with default desktop position and size 1`] = ` } } +@media only screen and (max-height: 450px) and (max-width: 1000px) and (orientation: landscape) { + .c0 { + width: calc(100dvh * (3 / 2)); + height: 100dvh; + } +} +
    > renders with default mobile position and size 1`] = ` } } +@media only screen and (max-height: 450px) and (max-width: 1000px) and (orientation: landscape) { + .c0 { + width: calc(100dvh * (3 / 2)); + height: 100dvh; + } +} +
    ` ); height: 85dvh; } + + @media ${({ theme }) => theme.isMobileLandscape} { + width: calc(100dvh * (3 / 2)); + height: 100dvh; + } `; // overrides rnd styles to fallback to css @@ -54,13 +60,20 @@ const defaultSize = { export const Screen = () => { const theme = useTheme(); - const isLargerThanPhone = useMediaQuery(theme.isLargerThanPhone); + const isLargerThanPhone = useMediaQuery(theme.isLargerThanPhone, { + noSsr: true + }); + const isMobileLandscape = useMediaQuery(theme.isMobileLandscape, { + noSsr: true + }); const { setCanvas } = useEmulatorContext(); const { areItemsDraggable } = useDragContext(); const { areItemsResizable } = useResizeContext(); const { layouts, setLayout, hasSetLayout } = useLayoutContext(); const screenWrapperXStart = isLargerThanPhone ? NavigationMenuWidth + 10 : 0; - const screenWrapperYStart = isLargerThanPhone ? 15 : 0; + const screenWrapperYStart = isLargerThanPhone && !isMobileLandscape ? 15 : 0; + const rndRef = useRef(); + const orientation = useOrientation(); const refUpdateDefaultPosition = useCallback( (node: Rnd | null) => { @@ -69,23 +82,44 @@ export const Screen = () => { node?.resizableElement?.current?.style?.removeProperty('height'); } - if (!layouts?.screen?.initialBounds && node) + if (!hasSetLayout && node) setLayout('screen', { initialBounds: node.resizableElement.current?.getBoundingClientRect() }); + + if (!rndRef.current) rndRef.current = node; }, - [hasSetLayout, layouts, setLayout] + [hasSetLayout, setLayout] ); + useLayoutEffect(() => { + if (!hasSetLayout && [0, 90, 270].includes(orientation.angle)) + setLayout('screen', { + initialBounds: + rndRef.current?.resizableElement?.current?.getBoundingClientRect() + }); + }, [hasSetLayout, isMobileLandscape, setLayout, orientation.angle]); + const refSetCanvas = useCallback( (node: HTMLCanvasElement | null) => setCanvas(node), [setCanvas] ); - const position = layouts?.screen?.position ?? { - x: screenWrapperXStart, - y: screenWrapperYStart - }; + const currentDimensions = + rndRef?.current?.resizableElement?.current?.getBoundingClientRect(); + const width = currentDimensions?.width ?? 0; + const height = currentDimensions?.height ?? 0; + const position = + layouts?.screen?.position ?? + (isMobileLandscape + ? { + x: Math.floor(document.documentElement.clientWidth / 2 - width / 2), + y: Math.floor(document.documentElement.clientHeight / 2 - height / 2) + } + : { + x: screenWrapperXStart, + y: screenWrapperYStart + }); const size = layouts?.screen?.size ?? defaultSize; return ( diff --git a/gbajs3/src/context/theme/theme.tsx b/gbajs3/src/context/theme/theme.tsx index d57ebb38..11a1616d 100644 --- a/gbajs3/src/context/theme/theme.tsx +++ b/gbajs3/src/context/theme/theme.tsx @@ -4,7 +4,7 @@ export const GbaDarkTheme: DefaultTheme = { // media queries isLargerThanPhone: 'only screen and (min-width: 600px)', isMobileLandscape: - 'only screen and (max-height: 1000px) and (max-width: 1000px) and (orientation: landscape)', + 'only screen and (max-height: 450px) and (max-width: 1000px) and (orientation: landscape)', isMobilePortrait: 'only screen and (max-width: 1000px) and (orientation: portrait)', isMobileWithUrlBar: