diff --git a/gbajs3/src/components/controls/control-panel.spec.tsx b/gbajs3/src/components/controls/control-panel.spec.tsx index f151a763..6cb56550 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(), - initialBounds: { - ...original().initialBounds, - screen: { left: 0, bottom: 0 } as DOMRect + layouts: { + ...original().layouts, + screen: { initialBounds: { left: 0, bottom: 0 } as DOMRect } } })); }); @@ -95,7 +95,7 @@ describe('', () => { }); it('sets initial bounds when rendered', async () => { - const setInitialBoundSpy = vi.fn(); + const setLayoutSpy = vi.fn(); const { useLayoutContext: originalLayout } = await vi.importActual< typeof contextHooks @@ -103,20 +103,13 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...originalLayout(), - setInitialBound: setInitialBoundSpy + setLayout: setLayoutSpy })); renderWithContext(); - expect(setInitialBoundSpy).toHaveBeenCalledWith('controlPanel', { - bottom: 0, - height: 0, - left: 0, - right: 0, - top: 0, - width: 0, - x: 0, - y: 0 + expect(setLayoutSpy).toHaveBeenCalledWith('controlPanel', { + initialBounds: expect.anything() }); }); @@ -133,11 +126,14 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...originalLayout(), setLayout: setLayoutSpy, - initialBounds: { screen: new DOMRect() } + hasSetLayout: true, + layouts: { screen: { initialBounds: new DOMRect() } } })); renderWithContext(); + setLayoutSpy.mockClear(); // clear calls from initial render + // simulate mouse events on wrapper fireEvent.mouseDown( screen.getByTestId('control-panel-wrapper'), @@ -168,12 +164,11 @@ describe('', () => { // needs to be a consistent object const testLayout = { - clearLayoutsAndBounds: vi.fn(), + clearLayouts: vi.fn(), setLayout: setLayoutSpy, setLayouts: vi.fn(), - setInitialBound: vi.fn(), - layouts: {}, - initialBounds: { screen: new DOMRect() } + hasSetLayout: true, + layouts: { screen: { initialBounds: new DOMRect() } } }; vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation( @@ -182,6 +177,8 @@ 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 28f3aecb..d4bc1e7c 100644 --- a/gbajs3/src/components/controls/control-panel.tsx +++ b/gbajs3/src/components/controls/control-panel.tsx @@ -81,8 +81,7 @@ export const ControlPanel = () => { const { isRunning } = useRunningContext(); const { areItemsDraggable, setAreItemsDraggable } = useDragContext(); const { areItemsResizable, setAreItemsResizable } = useResizeContext(); - const { layouts, setLayout, initialBounds, setInitialBound } = - useLayoutContext(); + const { layouts, setLayout, hasSetLayout } = useLayoutContext(); const theme = useTheme(); const isLargerThanPhone = useMediaQuery(theme.isLargerThanPhone); const isMobileLandscape = useMediaQuery(theme.isMobileLandscape); @@ -105,16 +104,15 @@ export const ControlPanel = () => { const refSetLayout = useCallback( (node: Rnd | null) => { - if (!initialBounds?.controlPanel && node) - setInitialBound( - 'controlPanel', - node?.resizableElement.current?.getBoundingClientRect() - ); + if (!hasSetLayout && node) + setLayout('controlPanel', { + initialBounds: node.resizableElement.current?.getBoundingClientRect() + }); }, - [initialBounds?.controlPanel, setInitialBound] + [setLayout, hasSetLayout] ); - const canvasBounds = initialBounds?.screen; + const canvasBounds = layouts?.screen?.initialBounds; 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 274b87b0..f415950c 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(), - initialBounds: { - ...original().initialBounds, - controlPanel: { left: 0, bottom: 0 } as DOMRect + layouts: { + ...original().layouts, + controlPanel: { initialBounds: { 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 8b9f94ce..6f30fb97 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 { initialBounds } = useLayoutContext(); + const { layouts } = useLayoutContext(); const virtualControlToastId = useId(); const quickReload = useQuickReload(); const { syncActionIfEnabled } = useAddCallbacks(); @@ -73,8 +73,8 @@ export const VirtualControls = () => { AreVirtualControlsEnabledProps | undefined >(virtualControlsLocalStorageKey); - const controlPanelBounds = initialBounds?.controlPanel; - const canvasBounds = initialBounds?.screen; + const controlPanelBounds = layouts?.controlPanel?.initialBounds; + const canvasBounds = layouts?.screen?.initialBounds; if (!controlPanelBounds) return null; diff --git a/gbajs3/src/components/modals/controls.spec.tsx b/gbajs3/src/components/modals/controls.spec.tsx index 63ae7519..c2792805 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 clearLayoutsAndBoundsSpy = vi.fn(); + const clearLayoutsSpy = vi.fn(); const { useLayoutContext: original } = await vi.importActual< typeof contextHooks >('../../hooks/context.tsx'); vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...original(), - clearLayoutsAndBounds: clearLayoutsAndBoundsSpy + clearLayouts: clearLayoutsSpy })); renderWithContext(); @@ -115,7 +115,7 @@ describe('', () => { await userEvent.click(resetPositionsButton); - expect(clearLayoutsAndBoundsSpy).toHaveBeenCalledOnce(); + expect(clearLayoutsSpy).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 f54371e6..6b405a8f 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 { clearLayoutsAndBounds } = useLayoutContext(); + const { clearLayouts } = 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 0923bffe..256f0908 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 { fireEvent, screen } from '@testing-library/react'; +import { 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,66 +433,4 @@ 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 54182cb7..9de98cd7 100644 --- a/gbajs3/src/components/navigation-menu/navigation-menu.tsx +++ b/gbajs3/src/components/navigation-menu/navigation-menu.tsx @@ -1,6 +1,5 @@ import { useMediaQuery } from '@mui/material'; -import { useId, useRef, useState } from 'react'; -import Draggable from 'react-draggable'; +import { useId, useState } from 'react'; import toast from 'react-hot-toast'; import { BiInfoCircle, @@ -32,9 +31,7 @@ import { useEmulatorContext, useAuthContext, useModalContext, - useRunningContext, - useDragContext, - useLayoutContext + useRunningContext } from '../../hooks/context.tsx'; import { useQuickReload } from '../../hooks/emulator/use-quick-reload.tsx'; import { useLogout } from '../../hooks/use-logout.tsx'; @@ -111,9 +108,7 @@ const MenuItemWrapper = styled.ul` } `; -const HamburgerButton = styled(ButtonBase)< - ExpandableComponentProps & { $areItemsDraggable: boolean } ->` +const HamburgerButton = styled(ButtonBase)` background-color: ${({ theme }) => theme.mediumBlack}; color: ${({ theme }) => theme.pureWhite}; z-index: 200; @@ -122,7 +117,6 @@ 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; @@ -143,14 +137,6 @@ 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` @@ -165,14 +151,11 @@ 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); @@ -186,29 +169,16 @@ export const NavigationMenu = () => { return ( <> - - setLayout('menuButton', { position: { x: 0, y: data.y } }) - } + setIsExpanded((prevState) => !prevState)} + aria-label="Menu Toggle" > - setIsExpanded((prevState) => !prevState)} - aria-label="Menu Toggle" - $areItemsDraggable={areItemsDraggable} - > - - - + + ', () => { }); it('sets initial bounds when rendered', async () => { - const setInitialBoundSpy = vi.fn(); + const setLayoutSpy = vi.fn(); const { useLayoutContext: originalLayout } = await vi.importActual< typeof contextHooks @@ -49,20 +49,13 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...originalLayout(), - setInitialBound: setInitialBoundSpy + setLayout: setLayoutSpy })); renderWithContext(); - expect(setInitialBoundSpy).toHaveBeenCalledWith('screen', { - bottom: 0, - height: 0, - left: 0, - right: 0, - top: 0, - width: 0, - x: 0, - y: 0 + expect(setLayoutSpy).toHaveBeenCalledWith('screen', { + initialBounds: expect.anything() }); }); @@ -129,7 +122,9 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...originalLayout(), - setLayout: setLayoutSpy + setLayout: setLayoutSpy, + hasSetLayout: true, + layouts: { screen: { initialBounds: new DOMRect() } } })); renderWithContext(); @@ -163,7 +158,8 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...originalLayout(), setLayout: setLayoutSpy, - initialBounds: { screen: new DOMRect() } + hasSetLayout: true, + layouts: { screen: { initialBounds: new DOMRect() } } })); renderWithContext(); diff --git a/gbajs3/src/components/screen/screen.tsx b/gbajs3/src/components/screen/screen.tsx index 4a4be74b..df85d514 100644 --- a/gbajs3/src/components/screen/screen.tsx +++ b/gbajs3/src/components/screen/screen.tsx @@ -1,5 +1,6 @@ import { useMediaQuery } from '@mui/material'; -import { useCallback, useRef } from 'react'; +import { useOrientation } from '@uidotdev/usehooks'; +import { useCallback, useLayoutEffect, useRef } from 'react'; import { Rnd, type Props as RndProps } from 'react-rnd'; import { styled, useTheme } from 'styled-components'; @@ -83,31 +84,37 @@ export const Screen = () => { const { setCanvas } = useEmulatorContext(); const { areItemsDraggable } = useDragContext(); const { areItemsResizable } = useResizeContext(); - const { layouts, setLayout, initialBounds, setInitialBound } = - useLayoutContext(); + const { layouts, setLayout, hasSetLayout } = 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 (!layouts?.screen) { + if (!hasSetLayout) { node?.resizableElement?.current?.style?.removeProperty('width'); node?.resizableElement?.current?.style?.removeProperty('height'); } - if (!initialBounds?.screen && node) { - setInitialBound( - 'screen', - node.resizableElement.current?.getBoundingClientRect() - ); - } + if (!hasSetLayout && node) + setLayout('screen', { + initialBounds: node.resizableElement.current?.getBoundingClientRect() + }); if (!rndRef.current) rndRef.current = node; }, - [initialBounds?.screen, layouts?.screen, setInitialBound] + [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] diff --git a/gbajs3/src/context/layout/layout-context.tsx b/gbajs3/src/context/layout/layout-context.tsx index 2ed27ccc..5604bd93 100644 --- a/gbajs3/src/context/layout/layout-context.tsx +++ b/gbajs3/src/context/layout/layout-context.tsx @@ -3,21 +3,17 @@ 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; - clearLayoutsAndBounds: () => void; - initialBounds?: InitialBounds; - setInitialBound: (key: string, bounds?: DOMRect) => void; + hasSetLayout: boolean; + clearLayouts: () => 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 bc78fa1a..031a8b9d 100644 --- a/gbajs3/src/context/layout/layout-provider.tsx +++ b/gbajs3/src/context/layout/layout-provider.tsx @@ -1,50 +1,40 @@ -import { useOrientation } from '@uidotdev/usehooks'; -import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { useCallback, useEffect, type ReactNode } from 'react'; import { LayoutContext } from './layout-context.tsx'; import { useLayouts } from '../../hooks/use-layouts.tsx'; -import type { InitialBounds, Layout } from './layout-context.tsx'; +import type { Layout } from './layout-context.tsx'; type LayoutProviderProps = { children: ReactNode }; export const LayoutProvider = ({ children }: LayoutProviderProps) => { - const { layouts, setLayouts, clearLayouts } = useLayouts(); - const [initialBounds, setInitialBounds] = useState(); - const orientation = useOrientation(); + const { layouts, setLayouts, hasSetLayout, clearLayouts } = useLayouts(); const setLayout = useCallback( (layoutKey: string, layout: Layout) => - setLayouts((prevState) => ({ - ...prevState, - [layoutKey]: { ...prevState?.[layoutKey], ...layout } - })), + setLayouts((prevState) => { + return { + ...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 (orientation.angle !== null && [0, 90, 270].includes(orientation.angle)) - setInitialBounds({}); - }, [setInitialBounds, orientation.angle]); + 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 + }, []); return ( { const clearLayouts = useCallback(() => setLayouts({}), [setLayouts]); - return { layouts, setLayouts, clearLayouts }; + const hasSetLayout = useMemo( + () => + !!Object.values(layouts).some( + (layout) => !!layout?.position || !!layout?.size + ), + [layouts] + ); + + return { layouts, setLayouts, hasSetLayout, clearLayouts }; };