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..8f817fb3 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,34 @@ 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) {
+ const result = popOut()
+ if (result === null) {
+ toastError("Pop-up blocked. Please allow pop-ups for this site.")
+ setActivePanel("locations")
+ }
+ return
+ }
+
// Toggle panel - if clicking same action, close it
if (activePanel === action) {
setActivePanel(null)
@@ -156,6 +191,7 @@ export default function ShotCounter() {
onShowHiddenChange={handleShowHiddenChange}
onViewLocations={() => handleAction("locations")}
locationsViewActive={activePanel === "locations"}
+ locationsPoppedOut={isPoppedOut}
/>
{/* Character selector below MenuBar */}
@@ -253,12 +289,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/__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)
+ )
+ })
+})
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..ab1cbe9d
--- /dev/null
+++ b/src/hooks/usePopOutWindow.ts
@@ -0,0 +1,103 @@
+"use client"
+
+import { useState, useRef, useEffect, useCallback } from "react"
+
+interface UsePopOutWindowReturn {
+ popOut: () => Window | null
+ 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 and return the existing window
+ if (windowRef.current && !windowRef.current.closed) {
+ windowRef.current.focus()
+ return windowRef.current
+ }
+
+ 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)
+
+ // 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()
+ }
+ }, 2000)
+
+ 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 }
+}