diff --git a/gbajs3/src/components/controls/control-panel.spec.tsx b/gbajs3/src/components/controls/control-panel.spec.tsx index 6cb56550..f151a763 100644 --- a/gbajs3/src/components/controls/control-panel.spec.tsx +++ b/gbajs3/src/components/controls/control-panel.spec.tsx @@ -32,9 +32,9 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...original(), - layouts: { - ...original().layouts, - screen: { initialBounds: { left: 0, bottom: 0 } as DOMRect } + initialBounds: { + ...original().initialBounds, + screen: { left: 0, bottom: 0 } as DOMRect } })); }); @@ -95,7 +95,7 @@ describe('', () => { }); it('sets initial bounds when rendered', async () => { - const setLayoutSpy = vi.fn(); + const setInitialBoundSpy = vi.fn(); const { useLayoutContext: originalLayout } = await vi.importActual< typeof contextHooks @@ -103,13 +103,20 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...originalLayout(), - setLayout: setLayoutSpy + setInitialBound: setInitialBoundSpy })); renderWithContext(); - expect(setLayoutSpy).toHaveBeenCalledWith('controlPanel', { - initialBounds: expect.anything() + expect(setInitialBoundSpy).toHaveBeenCalledWith('controlPanel', { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0 }); }); @@ -126,14 +133,11 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...originalLayout(), setLayout: setLayoutSpy, - hasSetLayout: true, - layouts: { screen: { initialBounds: new DOMRect() } } + initialBounds: { screen: new DOMRect() } })); renderWithContext(); - setLayoutSpy.mockClear(); // clear calls from initial render - // simulate mouse events on wrapper fireEvent.mouseDown( screen.getByTestId('control-panel-wrapper'), @@ -164,11 +168,12 @@ describe('', () => { // needs to be a consistent object const testLayout = { - clearLayouts: vi.fn(), + clearLayoutsAndBounds: vi.fn(), setLayout: setLayoutSpy, setLayouts: vi.fn(), - hasSetLayout: true, - layouts: { screen: { initialBounds: new DOMRect() } } + setInitialBound: vi.fn(), + layouts: {}, + initialBounds: { screen: new DOMRect() } }; vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation( @@ -177,8 +182,6 @@ describe('', () => { renderWithContext(); - setLayoutSpy.mockClear(); // clear calls from initial render - fireEvent.resize(screen.getByTestId('control-panel-wrapper')); // simulate mouse events on a resize handle diff --git a/gbajs3/src/components/controls/control-panel.tsx b/gbajs3/src/components/controls/control-panel.tsx index d4bc1e7c..28f3aecb 100644 --- a/gbajs3/src/components/controls/control-panel.tsx +++ b/gbajs3/src/components/controls/control-panel.tsx @@ -81,7 +81,8 @@ export const ControlPanel = () => { const { isRunning } = useRunningContext(); const { areItemsDraggable, setAreItemsDraggable } = useDragContext(); const { areItemsResizable, setAreItemsResizable } = useResizeContext(); - const { layouts, setLayout, hasSetLayout } = useLayoutContext(); + const { layouts, setLayout, initialBounds, setInitialBound } = + useLayoutContext(); const theme = useTheme(); const isLargerThanPhone = useMediaQuery(theme.isLargerThanPhone); const isMobileLandscape = useMediaQuery(theme.isMobileLandscape); @@ -104,15 +105,16 @@ export const ControlPanel = () => { const refSetLayout = useCallback( (node: Rnd | null) => { - if (!hasSetLayout && node) - setLayout('controlPanel', { - initialBounds: node.resizableElement.current?.getBoundingClientRect() - }); + if (!initialBounds?.controlPanel && node) + setInitialBound( + 'controlPanel', + node?.resizableElement.current?.getBoundingClientRect() + ); }, - [setLayout, hasSetLayout] + [initialBounds?.controlPanel, setInitialBound] ); - const canvasBounds = layouts?.screen?.initialBounds; + const canvasBounds = initialBounds?.screen; if (!canvasBounds) return null; diff --git a/gbajs3/src/components/controls/virtual-controls.spec.tsx b/gbajs3/src/components/controls/virtual-controls.spec.tsx index f415950c..274b87b0 100644 --- a/gbajs3/src/components/controls/virtual-controls.spec.tsx +++ b/gbajs3/src/components/controls/virtual-controls.spec.tsx @@ -22,9 +22,9 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...original(), - layouts: { - ...original().layouts, - controlPanel: { initialBounds: { left: 0, bottom: 0 } as DOMRect } + initialBounds: { + ...original().initialBounds, + controlPanel: { left: 0, bottom: 0 } as DOMRect } })); }); diff --git a/gbajs3/src/components/controls/virtual-controls.tsx b/gbajs3/src/components/controls/virtual-controls.tsx index 6f30fb97..8b9f94ce 100644 --- a/gbajs3/src/components/controls/virtual-controls.tsx +++ b/gbajs3/src/components/controls/virtual-controls.tsx @@ -61,7 +61,7 @@ export const VirtualControls = () => { const { isRunning } = useRunningContext(); const { isAuthenticated } = useAuthContext(); const { setModalContent, setIsModalOpen } = useModalContext(); - const { layouts } = useLayoutContext(); + const { initialBounds } = useLayoutContext(); const virtualControlToastId = useId(); const quickReload = useQuickReload(); const { syncActionIfEnabled } = useAddCallbacks(); @@ -73,8 +73,8 @@ export const VirtualControls = () => { AreVirtualControlsEnabledProps | undefined >(virtualControlsLocalStorageKey); - const controlPanelBounds = layouts?.controlPanel?.initialBounds; - const canvasBounds = layouts?.screen?.initialBounds; + const controlPanelBounds = initialBounds?.controlPanel; + const canvasBounds = initialBounds?.screen; if (!controlPanelBounds) return null; diff --git a/gbajs3/src/components/modals/controls.spec.tsx b/gbajs3/src/components/modals/controls.spec.tsx index c2792805..63ae7519 100644 --- a/gbajs3/src/components/modals/controls.spec.tsx +++ b/gbajs3/src/components/modals/controls.spec.tsx @@ -95,14 +95,14 @@ describe('', () => { }); it('resets movable control layouts', async () => { - const clearLayoutsSpy = vi.fn(); + const clearLayoutsAndBoundsSpy = vi.fn(); const { useLayoutContext: original } = await vi.importActual< typeof contextHooks >('../../hooks/context.tsx'); vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...original(), - clearLayouts: clearLayoutsSpy + clearLayoutsAndBounds: clearLayoutsAndBoundsSpy })); renderWithContext(); @@ -115,7 +115,7 @@ describe('', () => { await userEvent.click(resetPositionsButton); - expect(clearLayoutsSpy).toHaveBeenCalledOnce(); + expect(clearLayoutsAndBoundsSpy).toHaveBeenCalledOnce(); }); it('closes modal using the close button', async () => { diff --git a/gbajs3/src/components/modals/controls.tsx b/gbajs3/src/components/modals/controls.tsx index 6b405a8f..f54371e6 100644 --- a/gbajs3/src/components/modals/controls.tsx +++ b/gbajs3/src/components/modals/controls.tsx @@ -71,7 +71,7 @@ const ControlTabs = ({ resetPositionsButtonId, setIsSuccessfulSubmit }: ControlTabsProps) => { - const { clearLayouts } = useLayoutContext(); + const { clearLayoutsAndBounds } = useLayoutContext(); const [value, setValue] = useState(0); const tabIndexToFormId = (tabIndex: number) => { @@ -116,7 +116,7 @@ const ControlTabs = ({ diff --git a/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx b/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx index 256f0908..0923bffe 100644 --- a/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx +++ b/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import * as toast from 'react-hot-toast'; import { describe, expect, it, vi } from 'vitest'; @@ -433,4 +433,66 @@ describe('', () => { } ); }); + + describe('menu button', () => { + const initialPos = { + clientX: 0, + clientY: 0 + }; + const movements = [ + { clientX: 0, clientY: 220 }, + { clientX: 0, clientY: 120 } + ]; + + it('sets layout on drag', async () => { + const setLayoutSpy = vi.fn(); + const { useLayoutContext: originalLayout, useDragContext: originalDrag } = + await vi.importActual('../../hooks/context.tsx'); + + vi.spyOn(contextHooks, 'useDragContext').mockImplementation(() => ({ + ...originalDrag(), + areItemsDraggable: true + })); + + vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ + ...originalLayout(), + setLayout: setLayoutSpy + })); + + renderWithContext(); + + fireEvent.mouseDown(screen.getByLabelText('Menu Toggle'), initialPos); + fireEvent.mouseMove(document, movements[0]); + fireEvent.mouseUp(document, movements[1]); + + expect(setLayoutSpy).toHaveBeenCalledOnce(); + expect(setLayoutSpy).toHaveBeenCalledWith('menuButton', { + position: { + x: movements[1].clientX, + y: movements[1].clientY + } + }); + }); + + it('renders with existing layout', async () => { + const { useLayoutContext: originalLayout, useDragContext: originalDrag } = + await vi.importActual('../../hooks/context.tsx'); + + vi.spyOn(contextHooks, 'useDragContext').mockImplementation(() => ({ + ...originalDrag(), + areItemsDraggable: true + })); + + vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ + ...originalLayout(), + layouts: { menuButton: { position: { x: 0, y: 200 } } } + })); + + renderWithContext(); + + expect(screen.getByLabelText('Menu Toggle')).toHaveStyle({ + transform: 'translate(0px,200px)' + }); + }); + }); }); diff --git a/gbajs3/src/components/navigation-menu/navigation-menu.tsx b/gbajs3/src/components/navigation-menu/navigation-menu.tsx index 9de98cd7..54182cb7 100644 --- a/gbajs3/src/components/navigation-menu/navigation-menu.tsx +++ b/gbajs3/src/components/navigation-menu/navigation-menu.tsx @@ -1,5 +1,6 @@ import { useMediaQuery } from '@mui/material'; -import { useId, useState } from 'react'; +import { useId, useRef, useState } from 'react'; +import Draggable from 'react-draggable'; import toast from 'react-hot-toast'; import { BiInfoCircle, @@ -31,7 +32,9 @@ import { useEmulatorContext, useAuthContext, useModalContext, - useRunningContext + useRunningContext, + useDragContext, + useLayoutContext } from '../../hooks/context.tsx'; import { useQuickReload } from '../../hooks/emulator/use-quick-reload.tsx'; import { useLogout } from '../../hooks/use-logout.tsx'; @@ -108,7 +111,9 @@ const MenuItemWrapper = styled.ul` } `; -const HamburgerButton = styled(ButtonBase)` +const HamburgerButton = styled(ButtonBase)< + ExpandableComponentProps & { $areItemsDraggable: boolean } +>` background-color: ${({ theme }) => theme.mediumBlack}; color: ${({ theme }) => theme.pureWhite}; z-index: 200; @@ -117,6 +122,7 @@ const HamburgerButton = styled(ButtonBase)` top: 12px; transition: 0.4s ease-in-out; -webkit-transition: 0.4s ease-in-out; + transition-property: left; cursor: pointer; border-radius: 0.25rem; border: none; @@ -137,6 +143,14 @@ const HamburgerButton = styled(ButtonBase)` outline: 0; box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); } + + ${({ $areItemsDraggable = false, theme }) => + $areItemsDraggable && + ` + outline-color: ${theme.gbaThemeBlue}; + outline-style: dashed; + outline-width: 2px; + `} `; const NavigationMenuClearDismiss = styled.button` @@ -151,11 +165,14 @@ const NavigationMenuClearDismiss = styled.button` export const NavigationMenu = () => { const [isExpanded, setIsExpanded] = useState(true); + const menuButtonRef = useRef(null); const { setModalContent, setIsModalOpen } = useModalContext(); const { isAuthenticated } = useAuthContext(); const { canvas, emulator } = useEmulatorContext(); const { isRunning } = useRunningContext(); const { execute: executeLogout } = useLogout(); + const { areItemsDraggable } = useDragContext(); + const { layouts, setLayout } = useLayoutContext(); const theme = useTheme(); const isLargerThanPhone = useMediaQuery(theme.isLargerThanPhone); const isMobileLandscape = useMediaQuery(theme.isMobileLandscape); @@ -169,16 +186,29 @@ export const NavigationMenu = () => { return ( <> - setIsExpanded((prevState) => !prevState)} - aria-label="Menu Toggle" + + setLayout('menuButton', { position: { x: 0, y: data.y } }) + } > - - + setIsExpanded((prevState) => !prevState)} + aria-label="Menu Toggle" + $areItemsDraggable={areItemsDraggable} + > + + + ', () => { }); it('sets initial bounds when rendered', async () => { - const setLayoutSpy = vi.fn(); + const setInitialBoundSpy = vi.fn(); const { useLayoutContext: originalLayout } = await vi.importActual< typeof contextHooks @@ -49,13 +49,20 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...originalLayout(), - setLayout: setLayoutSpy + setInitialBound: setInitialBoundSpy })); renderWithContext(); - expect(setLayoutSpy).toHaveBeenCalledWith('screen', { - initialBounds: expect.anything() + expect(setInitialBoundSpy).toHaveBeenCalledWith('screen', { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0 }); }); @@ -122,9 +129,7 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...originalLayout(), - setLayout: setLayoutSpy, - hasSetLayout: true, - layouts: { screen: { initialBounds: new DOMRect() } } + setLayout: setLayoutSpy })); renderWithContext(); @@ -158,8 +163,7 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...originalLayout(), setLayout: setLayoutSpy, - hasSetLayout: true, - layouts: { screen: { initialBounds: new DOMRect() } } + initialBounds: { screen: new DOMRect() } })); renderWithContext(); diff --git a/gbajs3/src/components/screen/screen.tsx b/gbajs3/src/components/screen/screen.tsx index df85d514..4a4be74b 100644 --- a/gbajs3/src/components/screen/screen.tsx +++ b/gbajs3/src/components/screen/screen.tsx @@ -1,6 +1,5 @@ import { useMediaQuery } from '@mui/material'; -import { useOrientation } from '@uidotdev/usehooks'; -import { useCallback, useLayoutEffect, useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { Rnd, type Props as RndProps } from 'react-rnd'; import { styled, useTheme } from 'styled-components'; @@ -84,37 +83,31 @@ export const Screen = () => { const { setCanvas } = useEmulatorContext(); const { areItemsDraggable } = useDragContext(); const { areItemsResizable } = useResizeContext(); - const { layouts, setLayout, hasSetLayout } = useLayoutContext(); + const { layouts, setLayout, initialBounds, setInitialBound } = + useLayoutContext(); const screenWrapperXStart = isLargerThanPhone ? NavigationMenuWidth + 10 : 0; const screenWrapperYStart = isLargerThanPhone && !isMobileLandscape ? 15 : 0; const rndRef = useRef(); - const orientation = useOrientation(); const refUpdateDefaultPosition = useCallback( (node: Rnd | null) => { - if (!hasSetLayout) { + if (!layouts?.screen) { node?.resizableElement?.current?.style?.removeProperty('width'); node?.resizableElement?.current?.style?.removeProperty('height'); } - if (!hasSetLayout && node) - setLayout('screen', { - initialBounds: node.resizableElement.current?.getBoundingClientRect() - }); + if (!initialBounds?.screen && node) { + setInitialBound( + 'screen', + node.resizableElement.current?.getBoundingClientRect() + ); + } if (!rndRef.current) rndRef.current = node; }, - [hasSetLayout, setLayout] + [initialBounds?.screen, layouts?.screen, setInitialBound] ); - 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] diff --git a/gbajs3/src/context/layout/layout-context.tsx b/gbajs3/src/context/layout/layout-context.tsx index 5604bd93..2ed27ccc 100644 --- a/gbajs3/src/context/layout/layout-context.tsx +++ b/gbajs3/src/context/layout/layout-context.tsx @@ -3,17 +3,21 @@ import { createContext } from 'react'; export type Layout = { position?: { x: number; y: number }; size?: { width: string | number; height: string | number }; - initialBounds?: DOMRect; }; export type Layouts = { [key: string]: Layout; }; +export type InitialBounds = { + [key: string]: DOMRect | undefined; +}; + export type LayoutContextProps = { layouts: Layouts; - hasSetLayout: boolean; - clearLayouts: () => void; + clearLayoutsAndBounds: () => void; + initialBounds?: InitialBounds; + setInitialBound: (key: string, bounds?: DOMRect) => void; setLayout: (layoutKey: string, layout: Layout) => void; setLayouts: (layouts: Layouts) => void; }; diff --git a/gbajs3/src/context/layout/layout-provider.tsx b/gbajs3/src/context/layout/layout-provider.tsx index 031a8b9d..bc78fa1a 100644 --- a/gbajs3/src/context/layout/layout-provider.tsx +++ b/gbajs3/src/context/layout/layout-provider.tsx @@ -1,40 +1,50 @@ -import { useCallback, useEffect, type ReactNode } from 'react'; +import { useOrientation } from '@uidotdev/usehooks'; +import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { LayoutContext } from './layout-context.tsx'; import { useLayouts } from '../../hooks/use-layouts.tsx'; -import type { Layout } from './layout-context.tsx'; +import type { InitialBounds, Layout } from './layout-context.tsx'; type LayoutProviderProps = { children: ReactNode }; export const LayoutProvider = ({ children }: LayoutProviderProps) => { - const { layouts, setLayouts, hasSetLayout, clearLayouts } = useLayouts(); + const { layouts, setLayouts, clearLayouts } = useLayouts(); + const [initialBounds, setInitialBounds] = useState(); + const orientation = useOrientation(); const setLayout = useCallback( (layoutKey: string, layout: Layout) => - setLayouts((prevState) => { - return { - ...prevState, - [layoutKey]: { ...prevState?.[layoutKey], ...layout } - }; - }), + setLayouts((prevState) => ({ + ...prevState, + [layoutKey]: { ...prevState?.[layoutKey], ...layout } + })), [setLayouts] ); + const setInitialBound = useCallback( + (key: string, bounds?: DOMRect) => + setInitialBounds((prevState) => ({ ...prevState, [key]: bounds })), + [] + ); + + const clearLayoutsAndBounds = useCallback(() => { + clearLayouts(); + setInitialBounds({}); + }, [clearLayouts]); + useEffect(() => { - if (!hasSetLayout) { - clearLayouts(); - } - // clears the initial bounds if no actual layouts are set on initial render only - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + if (orientation.angle !== null && [0, 90, 270].includes(orientation.angle)) + setInitialBounds({}); + }, [setInitialBounds, orientation.angle]); return ( { const clearLayouts = useCallback(() => setLayouts({}), [setLayouts]); - const hasSetLayout = useMemo( - () => - !!Object.values(layouts).some( - (layout) => !!layout?.position || !!layout?.size - ), - [layouts] - ); - - return { layouts, setLayouts, hasSetLayout, clearLayouts }; + return { layouts, setLayouts, clearLayouts }; };