From 69bf97b3ccf0bc22c8ee3d0e53012b06833d4bde Mon Sep 17 00:00:00 2001 From: Isaac Priestley Date: Sat, 7 Feb 2026 10:20:45 -0500 Subject: [PATCH 1/3] feat: add pop-out window for LocationsPanel (#441) Allow GM to pop out the locations panel into a separate browser window for screen-sharing while keeping the main encounter view for combat. Uses window.open() + createPortal() to render in a new window while sharing all React context (EncounterContext, WebSocket, etc). --- src/components/encounters/MenuBar.tsx | 22 +++-- src/components/encounters/ShotCounter.tsx | 44 ++++++++- .../encounters/locations/LocationsPanel.tsx | 37 ++++++- src/components/ui/PopOutWindow.tsx | 41 ++++++++ src/hooks/index.ts | 1 + src/hooks/usePopOutWindow.ts | 96 +++++++++++++++++++ 6 files changed, 226 insertions(+), 15 deletions(-) create mode 100644 src/components/ui/PopOutWindow.tsx create mode 100644 src/hooks/usePopOutWindow.ts diff --git a/src/components/encounters/MenuBar.tsx b/src/components/encounters/MenuBar.tsx index 70b2688a..143ec1e1 100644 --- a/src/components/encounters/MenuBar.tsx +++ b/src/components/encounters/MenuBar.tsx @@ -51,6 +51,7 @@ interface MenuBarProps { onShowHiddenChange: (show: boolean) => void onViewLocations?: () => void locationsViewActive?: boolean + locationsPoppedOut?: boolean } export default function MenuBar({ @@ -58,6 +59,7 @@ export default function MenuBar({ onShowHiddenChange, onViewLocations, locationsViewActive, + locationsPoppedOut, }: MenuBarProps) { const theme = useTheme() const { encounter, updateEncounter } = useEncounter() @@ -288,19 +290,23 @@ export default function MenuBar({ onViewLocations?.()} sx={{ - color: "white", + color: locationsPoppedOut ? "warning.main" : "white", px: { xs: 0.5, sm: 1 }, - backgroundColor: locationsViewActive - ? "rgba(255, 255, 255, 0.2)" - : "transparent", + backgroundColor: + locationsViewActive || locationsPoppedOut + ? "rgba(255, 255, 255, 0.2)" + : "transparent", borderRadius: 1, "&:hover": { - backgroundColor: locationsViewActive - ? "rgba(255, 255, 255, 0.3)" - : "rgba(255, 255, 255, 0.1)", + backgroundColor: + locationsViewActive || locationsPoppedOut + ? "rgba(255, 255, 255, 0.3)" + : "rgba(255, 255, 255, 0.1)", }, }} - title="View Locations" + title={ + locationsPoppedOut ? "Locations (popped out)" : "View Locations" + } > diff --git a/src/components/encounters/ShotCounter.tsx b/src/components/encounters/ShotCounter.tsx index 9aeba411..f7e817c6 100644 --- a/src/components/encounters/ShotCounter.tsx +++ b/src/components/encounters/ShotCounter.tsx @@ -17,16 +17,25 @@ import { SpeedCheckPanel, } from "@/components/encounters" import { LocationsPanel } from "@/components/encounters/locations" +import PopOutWindow from "@/components/ui/PopOutWindow" import { useEncounter } from "@/contexts" +import { useToast } from "@/contexts" import { useLocalStorage } from "@/contexts/LocalStorageContext" +import { usePopOutWindow } from "@/hooks" import { getAllVisibleShots } from "@/components/encounters/attacks/shotSorting" export default function ShotCounter() { const { encounter, selectedActorId, setSelectedActor } = useEncounter() const { getLocally, saveLocally } = useLocalStorage() + const { toastError } = useToast() const [showHidden, setShowHidden] = useState(true) const [activePanel, setActivePanel] = useState(null) + const fightName = encounter?.name || "Fight" + const { popOut, popIn, isPoppedOut, containerEl } = usePopOutWindow( + `Locations - ${fightName}` + ) + // Refs for each panel const attackPanelRef = useRef(null) const healPanelRef = useRef(null) @@ -109,8 +118,30 @@ export default function ShotCounter() { return shot?.character || null }, [selectedActorId, allVisibleShots]) + const handlePopOutLocations = () => { + if (isPoppedOut) { + popIn() + } else { + const result = popOut() + if (result === null) { + toastError("Pop-up blocked. Please allow pop-ups for this site.") + return + } + // Close the inline panel if it's open + if (activePanel === "locations") { + setActivePanel(null) + } + } + } + // Handle action from EncounterActionBar const handleAction = (action: string) => { + // If locations are popped out and user clicks locations, focus the window + if (action === "locations" && isPoppedOut) { + popOut() // This will focus the existing window + return + } + // Toggle panel - if clicking same action, close it if (activePanel === action) { setActivePanel(null) @@ -156,6 +187,7 @@ export default function ShotCounter() { onShowHiddenChange={handleShowHiddenChange} onViewLocations={() => handleAction("locations")} locationsViewActive={activePanel === "locations"} + locationsPoppedOut={isPoppedOut} /> {/* Character selector below MenuBar */} @@ -253,12 +285,20 @@ export default function ShotCounter() { )} - {activePanel === "locations" && ( + {activePanel === "locations" && !isPoppedOut && ( - + )} + {/* Pop-out window for locations */} + + + + {/* Shot List */} {visibleShots.map((shot, index) => ( diff --git a/src/components/encounters/locations/LocationsPanel.tsx b/src/components/encounters/locations/LocationsPanel.tsx index 7098788c..9acd1bfa 100644 --- a/src/components/encounters/locations/LocationsPanel.tsx +++ b/src/components/encounters/locations/LocationsPanel.tsx @@ -19,6 +19,7 @@ import { Map as CanvasIcon, Add as AddIcon, Timeline as ConnectIcon, + OpenInNew as OpenInNewIcon, } from "@mui/icons-material" import { FaMapLocationDot } from "react-icons/fa6" import { @@ -70,6 +71,8 @@ const ZOOM_STEP = 0.1 interface LocationsPanelProps { onClose: () => void + poppedOut?: boolean + onPopOut?: () => void } type ViewMode = "grid" | "canvas" @@ -100,7 +103,11 @@ function rectsOverlap(a: Rect, b: Rect, padding: number = 0): boolean { * Phase 2: Drag-and-drop support for moving characters between zones. * Phase 3: Canvas mode with draggable/resizable zones. */ -export default function LocationsPanel({ onClose }: LocationsPanelProps) { +export default function LocationsPanel({ + onClose, + poppedOut, + onPopOut, +}: LocationsPanelProps) { const { encounter, dispatchEncounter } = useEncounter() const { client } = useClient() const { toastSuccess, toastError } = useToast() @@ -1255,12 +1262,22 @@ export default function LocationsPanel({ onClose }: LocationsPanelProps) { return { x: padding, y: maxY + padding } } + const panelSx = poppedOut + ? { + position: "relative" as const, + height: "100vh", + overflow: "auto", + borderRadius: 0, + } + : { position: "relative" as const } + if (loading) { return ( } borderColor="info.main" + sx={panelSx} > } borderColor="error.main" + sx={panelSx} > {error} @@ -1293,7 +1311,7 @@ export default function LocationsPanel({ onClose }: LocationsPanelProps) { title="Locations" icon={} borderColor="info.main" - sx={{ position: "relative" }} + sx={panelSx} > {/* Header controls */} - - - + {!poppedOut && onPopOut && ( + + + + + + )} + {!poppedOut && ( + + + + )} {displayData.locations.length === 0 ? ( diff --git a/src/components/ui/PopOutWindow.tsx b/src/components/ui/PopOutWindow.tsx new file mode 100644 index 00000000..70d8aa81 --- /dev/null +++ b/src/components/ui/PopOutWindow.tsx @@ -0,0 +1,41 @@ +"use client" + +import { useMemo } from "react" +import { createPortal } from "react-dom" +import createCache from "@emotion/cache" +import { CacheProvider } from "@emotion/react" +import { ThemeProvider } from "@mui/material/styles" +import CssBaseline from "@mui/material/CssBaseline" +import theme from "@/theme" + +interface PopOutWindowProps { + isPoppedOut: boolean + containerEl: HTMLElement | null + children: React.ReactNode +} + +export default function PopOutWindow({ + isPoppedOut, + containerEl, + children, +}: PopOutWindowProps) { + const emotionCache = useMemo(() => { + if (!containerEl) return null + return createCache({ + key: "popout", + container: containerEl.ownerDocument.head, + }) + }, [containerEl]) + + if (!isPoppedOut || !containerEl || !emotionCache) return null + + return createPortal( + + + + {children} + + , + containerEl + ) +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 88a9bfa5..2f377ae7 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -27,3 +27,4 @@ export * from "@/hooks/useLocations" export * from "@/hooks/useLocationConnections" export * from "@/hooks/usePasswordValidation" export * from "@/hooks/useDebouncedLocationUpdate" +export * from "@/hooks/usePopOutWindow" diff --git a/src/hooks/usePopOutWindow.ts b/src/hooks/usePopOutWindow.ts new file mode 100644 index 00000000..28de99bf --- /dev/null +++ b/src/hooks/usePopOutWindow.ts @@ -0,0 +1,96 @@ +"use client" + +import { useState, useRef, useEffect, useCallback } from "react" + +interface UsePopOutWindowReturn { + popOut: () => Window | null | undefined + popIn: () => void + isPoppedOut: boolean + containerEl: HTMLElement | null +} + +export function usePopOutWindow(title: string): UsePopOutWindowReturn { + const [isPoppedOut, setIsPoppedOut] = useState(false) + const [containerEl, setContainerEl] = useState(null) + const windowRef = useRef(null) + const pollRef = useRef | null>(null) + + const cleanup = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + if (windowRef.current && !windowRef.current.closed) { + windowRef.current.close() + } + windowRef.current = null + setContainerEl(null) + setIsPoppedOut(false) + }, []) + + const popOut = useCallback(() => { + // If already open, just focus + if (windowRef.current && !windowRef.current.closed) { + windowRef.current.focus() + return + } + + const newWindow = window.open( + "", + title, + "width=900,height=700,toolbar=no,menubar=no,resizable=yes,scrollbars=yes" + ) + + if (!newWindow) { + return null + } + + newWindow.document.title = title + + // Set up basic styling on the new window body + const body = newWindow.document.body + body.style.margin = "0" + body.style.padding = "0" + body.style.backgroundColor = "#0a0a0a" + body.style.color = "#fafafa" + body.style.fontFamily = "Arial, Helvetica, sans-serif" + + // Create container div for the portal + const container = newWindow.document.createElement("div") + container.id = "pop-out-root" + body.appendChild(container) + + windowRef.current = newWindow + setContainerEl(container) + setIsPoppedOut(true) + + // Poll for window close + pollRef.current = setInterval(() => { + if (newWindow.closed) { + cleanup() + } + }, 500) + + return newWindow + }, [title, cleanup]) + + const popIn = useCallback(() => { + cleanup() + }, [cleanup]) + + // Close child window on parent beforeunload + useEffect(() => { + const handleBeforeUnload = () => { + if (windowRef.current && !windowRef.current.closed) { + windowRef.current.close() + } + } + window.addEventListener("beforeunload", handleBeforeUnload) + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload) + cleanup() + } + }, [cleanup]) + + return { popOut, popIn, isPoppedOut, containerEl } +} From f9f588e8839c78a83f27923039eb3887cff35e28 Mon Sep 17 00:00:00 2001 From: Isaac Priestley Date: Sat, 7 Feb 2026 10:27:22 -0500 Subject: [PATCH 2/3] fix: address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use beforeunload/unload listeners on child window for faster close detection, with slower 2s fallback poll instead of 500ms polling-only - Return existing window ref when focusing (not undefined), clean up return type to Window | null - Handle blocked popup in isPoppedOut path — fall back to inline panel --- src/components/encounters/ShotCounter.tsx | 6 +++++- src/hooks/usePopOutWindow.ts | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/components/encounters/ShotCounter.tsx b/src/components/encounters/ShotCounter.tsx index f7e817c6..8f817fb3 100644 --- a/src/components/encounters/ShotCounter.tsx +++ b/src/components/encounters/ShotCounter.tsx @@ -138,7 +138,11 @@ export default function ShotCounter() { const handleAction = (action: string) => { // If locations are popped out and user clicks locations, focus the window if (action === "locations" && isPoppedOut) { - popOut() // This will focus the existing window + const result = popOut() + if (result === null) { + toastError("Pop-up blocked. Please allow pop-ups for this site.") + setActivePanel("locations") + } return } diff --git a/src/hooks/usePopOutWindow.ts b/src/hooks/usePopOutWindow.ts index 28de99bf..ab1cbe9d 100644 --- a/src/hooks/usePopOutWindow.ts +++ b/src/hooks/usePopOutWindow.ts @@ -3,7 +3,7 @@ import { useState, useRef, useEffect, useCallback } from "react" interface UsePopOutWindowReturn { - popOut: () => Window | null | undefined + popOut: () => Window | null popIn: () => void isPoppedOut: boolean containerEl: HTMLElement | null @@ -29,10 +29,10 @@ export function usePopOutWindow(title: string): UsePopOutWindowReturn { }, []) const popOut = useCallback(() => { - // If already open, just focus + // If already open, just focus and return the existing window if (windowRef.current && !windowRef.current.closed) { windowRef.current.focus() - return + return windowRef.current } const newWindow = window.open( @@ -64,12 +64,19 @@ export function usePopOutWindow(title: string): UsePopOutWindowReturn { setContainerEl(container) setIsPoppedOut(true) - // Poll for window close + // Detect window close via lifecycle events + const handleUnload = () => { + cleanup() + } + newWindow.addEventListener("beforeunload", handleUnload) + newWindow.addEventListener("unload", handleUnload) + + // Fallback poll in case events are missed pollRef.current = setInterval(() => { if (newWindow.closed) { cleanup() } - }, 500) + }, 2000) return newWindow }, [title, cleanup]) From 0ed96cc06a81ddb2fc8f020c0b67a86ca5c168c3 Mon Sep 17 00:00:00 2001 From: Isaac Priestley Date: Sat, 7 Feb 2026 10:29:06 -0500 Subject: [PATCH 3/3] test: add unit tests for usePopOutWindow hook Covers popup blocked path, cleanup on close, unmount cleanup, focus existing window, polling fallback, and lifecycle event listeners. --- src/hooks/__tests__/usePopOutWindow.test.ts | 199 ++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 src/hooks/__tests__/usePopOutWindow.test.ts diff --git a/src/hooks/__tests__/usePopOutWindow.test.ts b/src/hooks/__tests__/usePopOutWindow.test.ts new file mode 100644 index 00000000..b14f19e7 --- /dev/null +++ b/src/hooks/__tests__/usePopOutWindow.test.ts @@ -0,0 +1,199 @@ +import { renderHook, act } from "@testing-library/react" +import { usePopOutWindow } from "../usePopOutWindow" + +// Mock window for the pop-out +function createMockWindow() { + const listeners: Record void>> = {} + return { + closed: false, + document: { + title: "", + body: { + style: {} as CSSStyleDeclaration, + appendChild: jest.fn(), + }, + createElement: jest.fn(() => ({ + id: "", + })), + }, + close: jest.fn(), + focus: jest.fn(), + addEventListener: jest.fn((event: string, handler: () => void) => { + if (!listeners[event]) listeners[event] = [] + listeners[event].push(handler) + }), + _listeners: listeners, + } as unknown as Window +} + +describe("usePopOutWindow", () => { + let originalWindowOpen: typeof window.open + + beforeEach(() => { + originalWindowOpen = window.open + jest.useFakeTimers() + }) + + afterEach(() => { + window.open = originalWindowOpen + jest.useRealTimers() + }) + + it("starts with isPoppedOut false and containerEl null", () => { + const { result } = renderHook(() => usePopOutWindow("Test Window")) + + expect(result.current.isPoppedOut).toBe(false) + expect(result.current.containerEl).toBeNull() + }) + + it("opens a new window on popOut and sets isPoppedOut to true", () => { + const mockWin = createMockWindow() + window.open = jest.fn(() => mockWin) + + const { result } = renderHook(() => usePopOutWindow("Test Window")) + + act(() => { + const win = result.current.popOut() + expect(win).toBe(mockWin) + }) + + expect(window.open).toHaveBeenCalledWith( + "", + "Test Window", + "width=900,height=700,toolbar=no,menubar=no,resizable=yes,scrollbars=yes" + ) + expect(result.current.isPoppedOut).toBe(true) + expect(result.current.containerEl).not.toBeNull() + }) + + it("returns null when popup is blocked", () => { + window.open = jest.fn(() => null) + + const { result } = renderHook(() => usePopOutWindow("Test Window")) + + let win: Window | null = null + act(() => { + win = result.current.popOut() + }) + + expect(win).toBeNull() + expect(result.current.isPoppedOut).toBe(false) + expect(result.current.containerEl).toBeNull() + }) + + it("focuses existing window instead of opening a new one", () => { + const mockWin = createMockWindow() + window.open = jest.fn(() => mockWin) + + const { result } = renderHook(() => usePopOutWindow("Test Window")) + + act(() => { + result.current.popOut() + }) + + // Call popOut again — should focus, not open a new window + act(() => { + const win = result.current.popOut() + expect(win).toBe(mockWin) + }) + + expect(window.open).toHaveBeenCalledTimes(1) + expect(mockWin.focus).toHaveBeenCalled() + }) + + it("closes window and resets state on popIn", () => { + const mockWin = createMockWindow() + window.open = jest.fn(() => mockWin) + + const { result } = renderHook(() => usePopOutWindow("Test Window")) + + act(() => { + result.current.popOut() + }) + + expect(result.current.isPoppedOut).toBe(true) + + act(() => { + result.current.popIn() + }) + + expect(mockWin.close).toHaveBeenCalled() + expect(result.current.isPoppedOut).toBe(false) + expect(result.current.containerEl).toBeNull() + }) + + it("detects child window close via fallback polling", () => { + const mockWin = createMockWindow() + window.open = jest.fn(() => mockWin) + + const { result } = renderHook(() => usePopOutWindow("Test Window")) + + act(() => { + result.current.popOut() + }) + + expect(result.current.isPoppedOut).toBe(true) + + // Simulate the window being closed + ;(mockWin as unknown as { closed: boolean }).closed = true + + act(() => { + jest.advanceTimersByTime(2000) + }) + + expect(result.current.isPoppedOut).toBe(false) + expect(result.current.containerEl).toBeNull() + }) + + it("cleans up on unmount", () => { + const mockWin = createMockWindow() + window.open = jest.fn(() => mockWin) + + const { result, unmount } = renderHook(() => usePopOutWindow("Test Window")) + + act(() => { + result.current.popOut() + }) + + expect(result.current.isPoppedOut).toBe(true) + + unmount() + + expect(mockWin.close).toHaveBeenCalled() + }) + + it("sets document title and body styles on the new window", () => { + const mockWin = createMockWindow() + window.open = jest.fn(() => mockWin) + + const { result } = renderHook(() => usePopOutWindow("Locations - Fight")) + + act(() => { + result.current.popOut() + }) + + expect(mockWin.document.title).toBe("Locations - Fight") + expect(mockWin.document.body.style.backgroundColor).toBe("#0a0a0a") + expect(mockWin.document.body.style.color).toBe("#fafafa") + }) + + it("registers beforeunload and unload listeners on child window", () => { + const mockWin = createMockWindow() + window.open = jest.fn(() => mockWin) + + const { result } = renderHook(() => usePopOutWindow("Test Window")) + + act(() => { + result.current.popOut() + }) + + expect(mockWin.addEventListener).toHaveBeenCalledWith( + "beforeunload", + expect.any(Function) + ) + expect(mockWin.addEventListener).toHaveBeenCalledWith( + "unload", + expect.any(Function) + ) + }) +})