refactor(decnet_web/MazeNET): extract useFullscreenMode
Lift the four fullscreen-related side-effects off the page shell.
The hook owns:
1. body class toggle so page CSS can hide its chrome
2. browser fullscreen API request/exit (failures ignored)
3. fullscreenchange listener so F11/Esc from outside our button
keeps internal state in sync
4. Esc keystroke handler
Returns { fullscreen, setFullscreen, toggle }.
- New MazeNET/useFullscreenMode.ts
- useFullscreenMode.test.ts (jsdom) covers initial toggle, body
class lifecycle, Esc-to-exit, and unmount cleanup.
- MazeNET.tsx loses ~30 LOC of inline state + effects.
This commit is contained in:
@@ -22,6 +22,7 @@ import { useTopologyEditor } from './useTopologyEditor';
|
|||||||
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
|
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
|
||||||
import { useLayoutPersistor } from './useMazeLayoutStore';
|
import { useLayoutPersistor } from './useMazeLayoutStore';
|
||||||
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
|
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
|
||||||
|
import { useFullscreenMode } from './useFullscreenMode';
|
||||||
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
|
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
|
||||||
import { useToast } from '../Toasts/useToast';
|
import { useToast } from '../Toasts/useToast';
|
||||||
import { useServiceRegistry } from '../../hooks/useServiceRegistry';
|
import { useServiceRegistry } from '../../hooks/useServiceRegistry';
|
||||||
@@ -187,43 +188,7 @@ const MazeNET: React.FC = () => {
|
|||||||
const [selection, setSelection] = useState<Selection>(null);
|
const [selection, setSelection] = useState<Selection>(null);
|
||||||
const [inspectorOpen, setInspectorOpen] = useState(true);
|
const [inspectorOpen, setInspectorOpen] = useState(true);
|
||||||
const [paletteOpen, setPaletteOpen] = useState(true);
|
const [paletteOpen, setPaletteOpen] = useState(true);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const { fullscreen, toggle: toggleFullscreen } = useFullscreenMode();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cls = 'maze-fullscreen';
|
|
||||||
if (fullscreen) document.body.classList.add(cls);
|
|
||||||
else document.body.classList.remove(cls);
|
|
||||||
return () => document.body.classList.remove(cls);
|
|
||||||
}, [fullscreen]);
|
|
||||||
|
|
||||||
// Request/exit browser fullscreen alongside the in-app chrome hide.
|
|
||||||
// Ignore failures (fullscreen requires a user gesture; the chrome-only
|
|
||||||
// mode still works if the API rejects).
|
|
||||||
useEffect(() => {
|
|
||||||
if (fullscreen && !document.fullscreenElement) {
|
|
||||||
document.documentElement.requestFullscreen?.().catch(() => {});
|
|
||||||
} else if (!fullscreen && document.fullscreenElement) {
|
|
||||||
document.exitFullscreen?.().catch(() => {});
|
|
||||||
}
|
|
||||||
}, [fullscreen]);
|
|
||||||
|
|
||||||
// Sync state if the user presses F11/Esc to leave fullscreen from
|
|
||||||
// outside our button.
|
|
||||||
useEffect(() => {
|
|
||||||
const onFsChange = () => {
|
|
||||||
if (!document.fullscreenElement) setFullscreen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
|
||||||
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape' && fullscreen) setFullscreen(false);
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', onKey);
|
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
|
||||||
}, [fullscreen]);
|
|
||||||
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
|
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
|
||||||
const [archetypes, setArchetypes] = useState<Archetype[]>(DEFAULT_ARCHETYPES);
|
const [archetypes, setArchetypes] = useState<Archetype[]>(DEFAULT_ARCHETYPES);
|
||||||
|
|
||||||
@@ -839,7 +804,7 @@ const MazeNET: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="maze-btn ghost"
|
className="maze-btn ghost"
|
||||||
onClick={() => setFullscreen((f) => !f)}
|
onClick={toggleFullscreen}
|
||||||
title={fullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen canvas'}
|
title={fullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen canvas'}
|
||||||
>
|
>
|
||||||
{fullscreen ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
|
{fullscreen ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
|
||||||
|
|||||||
48
decnet_web/src/components/MazeNET/useFullscreenMode.test.ts
Normal file
48
decnet_web/src/components/MazeNET/useFullscreenMode.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { useFullscreenMode } from './useFullscreenMode';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.classList.remove('maze-fullscreen');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useFullscreenMode', () => {
|
||||||
|
it('starts off and toggles cleanly', () => {
|
||||||
|
const { result } = renderHook(() => useFullscreenMode());
|
||||||
|
expect(result.current.fullscreen).toBe(false);
|
||||||
|
act(() => result.current.toggle());
|
||||||
|
expect(result.current.fullscreen).toBe(true);
|
||||||
|
act(() => result.current.toggle());
|
||||||
|
expect(result.current.fullscreen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the maze-fullscreen body class only while active', () => {
|
||||||
|
const { result } = renderHook(() => useFullscreenMode());
|
||||||
|
expect(document.body.classList.contains('maze-fullscreen')).toBe(false);
|
||||||
|
act(() => result.current.setFullscreen(true));
|
||||||
|
expect(document.body.classList.contains('maze-fullscreen')).toBe(true);
|
||||||
|
act(() => result.current.setFullscreen(false));
|
||||||
|
expect(document.body.classList.contains('maze-fullscreen')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Esc keystroke flips fullscreen back off', () => {
|
||||||
|
const { result } = renderHook(() => useFullscreenMode());
|
||||||
|
act(() => result.current.setFullscreen(true));
|
||||||
|
expect(result.current.fullscreen).toBe(true);
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
});
|
||||||
|
expect(result.current.fullscreen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the body class on unmount', () => {
|
||||||
|
const { result, unmount } = renderHook(() => useFullscreenMode());
|
||||||
|
act(() => result.current.setFullscreen(true));
|
||||||
|
expect(document.body.classList.contains('maze-fullscreen')).toBe(true);
|
||||||
|
unmount();
|
||||||
|
expect(document.body.classList.contains('maze-fullscreen')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
decnet_web/src/components/MazeNET/useFullscreenMode.ts
Normal file
62
decnet_web/src/components/MazeNET/useFullscreenMode.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const BODY_CLASS = 'maze-fullscreen';
|
||||||
|
|
||||||
|
export interface UseFullscreenModeResult {
|
||||||
|
fullscreen: boolean;
|
||||||
|
setFullscreen: (next: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fullscreen-mode state for the MazeNET canvas. Owns four
|
||||||
|
* side-effects:
|
||||||
|
* 1. Toggle a body class so the page CSS can hide its chrome.
|
||||||
|
* 2. Request/exit the browser-level fullscreen API (failures
|
||||||
|
* are ignored; chrome-only mode still works without it).
|
||||||
|
* 3. Listen for fullscreenchange so F11/Esc from outside our
|
||||||
|
* button keeps internal state in sync.
|
||||||
|
* 4. Esc shortcut to leave fullscreen via keyboard. */
|
||||||
|
export function useFullscreenMode(): UseFullscreenModeResult {
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fullscreen) document.body.classList.add(BODY_CLASS);
|
||||||
|
else document.body.classList.remove(BODY_CLASS);
|
||||||
|
return () => document.body.classList.remove(BODY_CLASS);
|
||||||
|
}, [fullscreen]);
|
||||||
|
|
||||||
|
// Request/exit browser fullscreen alongside the in-app chrome hide.
|
||||||
|
// Ignore failures (fullscreen requires a user gesture; the chrome-only
|
||||||
|
// mode still works if the API rejects).
|
||||||
|
useEffect(() => {
|
||||||
|
if (fullscreen && !document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||||
|
} else if (!fullscreen && document.fullscreenElement) {
|
||||||
|
document.exitFullscreen?.().catch(() => {});
|
||||||
|
}
|
||||||
|
}, [fullscreen]);
|
||||||
|
|
||||||
|
// Sync state if the user presses F11/Esc to leave fullscreen from
|
||||||
|
// outside our button.
|
||||||
|
useEffect(() => {
|
||||||
|
const onFsChange = () => {
|
||||||
|
if (!document.fullscreenElement) setFullscreen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && fullscreen) setFullscreen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [fullscreen]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullscreen,
|
||||||
|
setFullscreen,
|
||||||
|
toggle: () => setFullscreen((f) => !f),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user