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 (
<>
-
+
+
', () => {
});
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 };
};