diff --git a/packages/app/src/components/ui/floating-panel-hosts.test.ts b/packages/app/src/components/ui/floating-panel-hosts.test.ts new file mode 100644 index 0000000000..a4b52d3a2c --- /dev/null +++ b/packages/app/src/components/ui/floating-panel-hosts.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + createFloatingPanelHosts, + type FloatingPanelHostRect, + type MeasurableFloatingPanelHost, +} from "./floating-panel-hosts"; + +function createHost(rect: FloatingPanelHostRect): MeasurableFloatingPanelHost { + return { + measureInWindow: (callback) => callback(rect.x, rect.y, rect.width, rect.height), + }; +} + +const WORKSPACE_RECT: FloatingPanelHostRect = { x: 10, y: 20, width: 800, height: 600 }; +const RETAINED_VIEWPORT: FloatingPanelHostRect = { x: 0, y: 0, width: 1200, height: 900 }; + +describe("floating panel hosts", () => { + it("measures the newest host registered under the name", async () => { + const hosts = createFloatingPanelHosts(); + hosts.register("workspace", createHost(WORKSPACE_RECT)); + hosts.register("workspace", createHost(RETAINED_VIEWPORT)); + + expect(await hosts.measure("workspace")).toEqual(RETAINED_VIEWPORT); + }); + + it("keeps measuring a survivor after the newest host unregisters", async () => { + const hosts = createFloatingPanelHosts(); + const unregisterSurvivor = hosts.register("workspace", createHost(WORKSPACE_RECT)); + const unregisterNewest = hosts.register("workspace", createHost(RETAINED_VIEWPORT)); + + unregisterNewest(); + + expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT); + unregisterSurvivor(); + }); + + it("returns null once every host under the name is gone", async () => { + const hosts = createFloatingPanelHosts(); + const unregisterFirst = hosts.register("workspace", createHost(WORKSPACE_RECT)); + const unregisterSecond = hosts.register("workspace", createHost(RETAINED_VIEWPORT)); + + unregisterFirst(); + unregisterSecond(); + + expect(await hosts.measure("workspace")).toBeNull(); + }); + + it("skips hosts that are not laid out", async () => { + const hosts = createFloatingPanelHosts(); + hosts.register("workspace", createHost(WORKSPACE_RECT)); + hosts.register("workspace", createHost({ x: 0, y: 0, width: 0, height: 0 })); + + expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT); + }); + + it("ignores a repeated unregister of the same host", async () => { + const hosts = createFloatingPanelHosts(); + const unregister = hosts.register("workspace", createHost(WORKSPACE_RECT)); + + unregister(); + unregister(); + + expect(await hosts.measure("workspace")).toBeNull(); + }); +}); diff --git a/packages/app/src/components/ui/floating-panel-hosts.ts b/packages/app/src/components/ui/floating-panel-hosts.ts new file mode 100644 index 0000000000..88451b1ebc --- /dev/null +++ b/packages/app/src/components/ui/floating-panel-hosts.ts @@ -0,0 +1,57 @@ +export interface FloatingPanelHostRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface MeasurableFloatingPanelHost { + measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void; +} + +export interface FloatingPanelHosts { + register(name: string, host: T): () => void; + measure(name: string): Promise; +} + +function measureHost(host: MeasurableFloatingPanelHost): Promise { + return new Promise((resolve) => { + host.measureInWindow((x, y, width, height) => resolve({ x, y, width, height })); + }); +} + +/** + * Hosts are addressed by name, and one name can have several live hosts: a workspace + * screen mounts its own host while retained copies of that screen stay mounted. Track + * every host so unmounting one leaves the others measurable. + */ +export function createFloatingPanelHosts< + T extends MeasurableFloatingPanelHost, +>(): FloatingPanelHosts { + const hostsByName = new Map(); + + function register(name: string, host: T): () => void { + const hosts = hostsByName.get(name) ?? []; + hostsByName.set(name, hosts); + hosts.push(host); + + return () => { + const index = hosts.indexOf(host); + if (index === -1) return; + hosts.splice(index, 1); + if (hosts.length === 0 && hostsByName.get(name) === hosts) { + hostsByName.delete(name); + } + }; + } + + async function measure(name: string): Promise { + const hosts = hostsByName.get(name); + if (!hosts || hosts.length === 0) return null; + + const rects = await Promise.all(hosts.toReversed().map(measureHost)); + return rects.find((rect) => rect.width > 0 && rect.height > 0) ?? rects[0]; + } + + return { register, measure }; +} diff --git a/packages/app/src/components/ui/floating-panel-portal.tsx b/packages/app/src/components/ui/floating-panel-portal.tsx index 21ea94a03f..e19e1a5575 100644 --- a/packages/app/src/components/ui/floating-panel-portal.tsx +++ b/packages/app/src/components/ui/floating-panel-portal.tsx @@ -8,32 +8,21 @@ import { } from "react"; import { StyleSheet, View } from "react-native"; import { PortalHost } from "@gorhom/portal"; +import { + createFloatingPanelHosts, + type FloatingPanelHostRect, +} from "@/components/ui/floating-panel-hosts"; export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels"; const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST); -interface Rect { - x: number; - y: number; - width: number; - height: number; -} +const portalHosts = createFloatingPanelHosts(); -const portalHostElements = new Map(); - -function measureElement(element: View): Promise { - return new Promise((resolve) => { - element.measureInWindow((x, y, width, height) => { - resolve({ x, y, width, height }); - }); - }); -} - -export function measureFloatingPanelPortalHost(name: string): Promise { - const element = portalHostElements.get(name); - if (!element) return Promise.resolve(null); - return measureElement(element); +export function measureFloatingPanelPortalHost( + name: string, +): Promise { + return portalHosts.measure(name); } export function FloatingPanelPortalHostNameProvider({ @@ -64,12 +53,7 @@ export function FloatingPanelPortalHost({ useEffect(() => { const host = hostRef.current; if (!host) return undefined; - portalHostElements.set(name, host); - return () => { - if (portalHostElements.get(name) === host) { - portalHostElements.delete(name); - } - }; + return portalHosts.register(name, host); }, [name]); return (