diff --git a/app/components/building/information.tsx b/app/components/building/information.tsx index e8d26e22..1b818329 100644 --- a/app/components/building/information.tsx +++ b/app/components/building/information.tsx @@ -4,71 +4,65 @@ import BodyRPCStore from 'app/stores/rpc/body'; import environment from 'app/environment'; import { BuildingWindowOptions } from 'app/interfaces'; import MenuStore from 'app/stores/menu'; +import { useBuildingViewQuery } from 'app/queries/building'; type Props = { options: BuildingWindowOptions; }; -class BuildingInformation extends React.Component { - render() { - return ( -
-
-
+const BuildingInformation: React.FunctionComponent = ({ options }) => { + const { data } = useBuildingViewQuery(options.url, options.id); + return ( +
+
+
+
-
-
+ />
+
-
-

- {this.props.options.name} {this.props.options.level}{' '} - {MenuStore.shouldShowIds ? <>(ID: {this.props.options.id}) : ''} -

+
+

+ {options.name} {data?.building.level ?? options.level}{' '} + {MenuStore.shouldShowIds ? <>(ID: {options.id}) : ''} +

-
- { - resources.buildings[this.props.options.url as keyof typeof resources.buildings] - .description - } -
+
+ {resources.buildings[options.url as keyof typeof resources.buildings].description} +
- +
- ); - } -} +
+ ); +}; export default BuildingInformation; diff --git a/app/components/menu/panel.tsx b/app/components/menu/panel.tsx index 56ad2f33..e0801e7b 100644 --- a/app/components/menu/panel.tsx +++ b/app/components/menu/panel.tsx @@ -1,7 +1,6 @@ import WindowsStore from 'app/stores/windows'; -import React from 'react'; -import $ from 'app/shims/jquery'; +import React, { useLayoutEffect, useRef, useState } from 'react'; import Draggable from 'react-draggable'; import PanelHeader from 'app/components/menu/panel/panelHeader'; @@ -15,58 +14,118 @@ type Props = { type: WindowType; options: WindowOptions; window: WindowDefinition; + // See Window.cascadeIndex: 0 opens dead centre, 1+ fans out horizontally. + cascadeIndex: number; closing?: boolean; }; -class Panel extends React.Component { - handleCentering() { - return ($(window.document).width() - this.props.window.config.width) / 2; - } +// Horizontal gap between the top-left corners of windows opened one after another. +const CASCADE_STEP = 48; +// Smallest gap kept between a window and the edge of the viewport. +const EDGE_MARGIN = 12; - onBringToTop() { - WindowsStore.bringToTop(this.props.type); - } +type Position = { left: number; top: number }; + +const computePosition = (node: HTMLElement, width: number, cascadeIndex: number): Position => { + const viewportW = window.innerWidth; + const viewportH = window.innerHeight; + + // Vertical centring is measured, not derived from config, so windows with `height: 'auto'` + // (login, register, most building windows) end up centred once their real height is known. + const centreLeft = Math.max(EDGE_MARGIN, (viewportW - width) / 2); + const top = Math.max(EDGE_MARGIN, (viewportH - node.offsetHeight) / 2); - closeWindow() { - WindowsStore.close(this.props.type); + if (cascadeIndex <= 0) { + return { left: centreLeft, top }; } - render() { - return ( - -
this.onBringToTop()} - > - - this.closeWindow()} - /> - - -
- -
-
-
-
-
- ); + // Later windows share that vertical centre but step to the right so the windows already on + // screen stay visible and grabbable. Running off the right edge wraps back towards the left and + // keeps marching across, so a long session never strands a window out of reach. + const maxLeft = Math.max(EDGE_MARGIN, viewportW - width - EDGE_MARGIN); + let left = centreLeft + cascadeIndex * CASCADE_STEP; + if (left > maxLeft) { + const span = maxLeft - EDGE_MARGIN; + left = span > 0 ? EDGE_MARGIN + ((left - EDGE_MARGIN) % span) : EDGE_MARGIN; } -} + + return { left, top }; +}; + +const Panel: React.FunctionComponent = ({ + zIndex, + type, + options, + window: windowDef, + cascadeIndex, + closing, +}) => { + const { config } = windowDef; + const nodeRef = useRef(null); + const draggedRef = useRef(false); + const [position, setPosition] = useState(null); + + // Place the window on mount, then keep it centred as its height settles (an `auto` window only + // reaches its real height once its data has loaded) and as the viewport resizes. Once the user + // has dragged it we leave it wherever they put it. + useLayoutEffect(() => { + const node = nodeRef.current; + if (!node) return; + + const reposition = () => { + if (draggedRef.current) return; + setPosition(computePosition(node, config.width, cascadeIndex)); + }; + + reposition(); + + const observer = new ResizeObserver(reposition); + observer.observe(node); + window.addEventListener('resize', reposition); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', reposition); + }; + }, [config.width, cascadeIndex]); + + return ( + { + draggedRef.current = true; + }} + > +
WindowsStore.bringToTop(type)} + > + + WindowsStore.close(type)} + /> + + +
+ +
+
+
+
+
+ ); +}; export default Panel; diff --git a/app/components/menu/windowManager.tsx b/app/components/menu/windowManager.tsx index eee39f80..41a8147a 100644 --- a/app/components/menu/windowManager.tsx +++ b/app/components/menu/windowManager.tsx @@ -682,6 +682,7 @@ const WindowManager: React.FunctionComponent = () => ( type={row.type} options={row.options} zIndex={row.zIndex} + cascadeIndex={row.cascadeIndex} closing={row.closing} // Keyed by type, which WindowsStore guarantees is unique. Keying by array index meant // closing one window re-keyed every window above it in the stack, remounting them and diff --git a/app/interfaces/window.ts b/app/interfaces/window.ts index 590d265b..dadc8158 100644 --- a/app/interfaces/window.ts +++ b/app/interfaces/window.ts @@ -82,6 +82,10 @@ export interface Window { type: WindowType; zIndex: number; options: WindowOptions; + // How many windows were already open when this one was added: 0 for the first (which opens dead + // centre), 1 for the next, and so on. Panel uses it to fan later windows out horizontally so + // they do not land exactly on top of the ones already on screen. + cascadeIndex: number; // Set by WindowsStore.close() while the panel fades out, just before the window is removed from // the stack. A re-open (WindowsStore.add) before removal clears it again. closing?: boolean; diff --git a/app/stores/windows.test.ts b/app/stores/windows.test.ts index 50f19574..96598c3b 100644 --- a/app/stores/windows.test.ts +++ b/app/stores/windows.test.ts @@ -87,6 +87,31 @@ test('onEscKey() skips a window that is already fading out', () => { expect(about?.closing).toBe(true); }); +test('add() records how many windows were already open, for the horizontal cascade', () => { + WindowsStore.add('about'); + jest.advanceTimersByTime(ADD_DELAY_MS); + WindowsStore.add('changes'); + jest.advanceTimersByTime(ADD_DELAY_MS); + WindowsStore.add('stats'); + jest.advanceTimersByTime(ADD_DELAY_MS); + + const byType = (t: string) => WindowsStore.windows.find((w) => w.type === t); + expect(byType('about')?.cascadeIndex).toBe(0); + expect(byType('changes')?.cascadeIndex).toBe(1); + expect(byType('stats')?.cascadeIndex).toBe(2); +}); + +test('add() ignores windows that are fading out when numbering the cascade', () => { + WindowsStore.add('about'); + jest.advanceTimersByTime(ADD_DELAY_MS); + WindowsStore.close('about'); + + WindowsStore.add('changes'); + jest.advanceTimersByTime(ADD_DELAY_MS); + + expect(WindowsStore.windows.find((w) => w.type === 'changes')?.cascadeIndex).toBe(0); +}); + test('close() removes synchronously when the user prefers reduced motion', () => { const original = window.matchMedia; window.matchMedia = ((query: string) => ({ diff --git a/app/stores/windows.ts b/app/stores/windows.ts index f5dd4d17..0f6b0c75 100644 --- a/app/stores/windows.ts +++ b/app/stores/windows.ts @@ -104,6 +104,10 @@ class WindowsStore { type, zIndex, options, + // Count of the windows already on screen (holes and windows mid-close do not count), + // resolved now rather than at call time so it reflects anything that opened/closed + // during the 10ms defer. + cascadeIndex: this.windows.filter((w) => w && !w.closing).length, closing: false, }; }),