diff --git a/gbajs3/src/components/controls/control-panel.spec.tsx b/gbajs3/src/components/controls/control-panel.spec.tsx index f151a763..380d92f6 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,22 @@ 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: { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0 + } }); }); @@ -133,11 +135,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 +173,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 +186,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..ecf42f9e 100644 --- a/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx +++ b/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx @@ -470,7 +470,8 @@ describe('', () => { position: { x: movements[1].clientX, y: movements[1].clientY - } + }, + standalone: true }); }); diff --git a/gbajs3/src/components/navigation-menu/navigation-menu.tsx b/gbajs3/src/components/navigation-menu/navigation-menu.tsx index 54182cb7..dd0257bd 100644 --- a/gbajs3/src/components/navigation-menu/navigation-menu.tsx +++ b/gbajs3/src/components/navigation-menu/navigation-menu.tsx @@ -193,7 +193,10 @@ export const NavigationMenu = () => { position={layouts?.menuButton?.position ?? { x: 0, y: 0 }} disabled={!areItemsDraggable} onStop={(_, data) => - setLayout('menuButton', { position: { x: 0, y: data.y } }) + setLayout('menuButton', { + position: { x: 0, y: data.y }, + standalone: true + }) } > ', () => { }); 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,22 @@ 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: { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0 + } }); }); @@ -129,7 +131,9 @@ describe('', () => { vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ ...originalLayout(), - setLayout: setLayoutSpy + setLayout: setLayoutSpy, + hasSetLayout: true, + layouts: { screen: { initialBounds: new DOMRect() } } })); renderWithContext(); @@ -163,7 +167,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..9cca8b6c 100644 --- a/gbajs3/src/context/layout/layout-context.tsx +++ b/gbajs3/src/context/layout/layout-context.tsx @@ -3,21 +3,19 @@ import { createContext } from 'react'; export type Layout = { position?: { x: number; y: number }; size?: { width: string | number; height: string | number }; + initialBounds?: DOMRect; + /** indicates whether or not this layout is standalone, and considered in any relative calculations or counts */ + standalone?: boolean; }; 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..4a16f53a 100644 --- a/gbajs3/src/context/layout/layout-provider.tsx +++ b/gbajs3/src/context/layout/layout-provider.tsx @@ -1,50 +1,39 @@ -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) && !layout.standalone + ), + [layouts] + ); + + return { layouts, setLayouts, hasSetLayout, clearLayouts }; };