Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -411,6 +411,11 @@ describe("mountBrowserAutomationHandler", () => {
|
||||
height: 768,
|
||||
},
|
||||
});
|
||||
expect(useBrowserStore.getState().browsersById[result.browserId]?.viewport).toEqual({
|
||||
mode: "fixed",
|
||||
width: 1024,
|
||||
height: 768,
|
||||
});
|
||||
expect(browser.browser.executedRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
removeResidentBrowserWebview,
|
||||
resizeResidentBrowserWebview,
|
||||
} from "@/desktop/browser/resident-webviews";
|
||||
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/desktop/browser/store";
|
||||
import {
|
||||
createFixedBrowserViewport,
|
||||
createWorkspaceBrowser,
|
||||
getBrowserRecord,
|
||||
useBrowserStore,
|
||||
} from "@/desktop/browser/store";
|
||||
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
@@ -208,6 +213,9 @@ function resizeBrowserTabForRequest(params: {
|
||||
message: `No browser tab found for ID: ${browserId}`,
|
||||
});
|
||||
}
|
||||
useBrowserStore
|
||||
.getState()
|
||||
.setBrowserViewport(browserId, createFixedBrowserViewport(dimensions.width, dimensions.height));
|
||||
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import {
|
||||
@@ -49,10 +50,22 @@ import {
|
||||
isElectronRuntime,
|
||||
type DesktopBrowserShortcutEvent,
|
||||
} from "@/desktop/host";
|
||||
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/desktop/browser/store";
|
||||
import {
|
||||
type BrowserViewport,
|
||||
createFixedBrowserViewport,
|
||||
normalizeWorkspaceBrowserUrl,
|
||||
RESPONSIVE_BROWSER_VIEWPORT,
|
||||
useBrowserStore,
|
||||
} from "@/desktop/browser/store";
|
||||
import {
|
||||
applyBrowserWebviewViewport,
|
||||
applyInactiveBrowserWebviewViewport,
|
||||
isResidentBrowserWebviewReady,
|
||||
prepareBrowserWebview,
|
||||
presentBrowserWebview,
|
||||
rememberBrowserWebviewSize,
|
||||
releaseResidentBrowserWebview,
|
||||
removeResidentBrowserWebview,
|
||||
takeResidentBrowserWebview,
|
||||
} from "../resident-webviews";
|
||||
|
||||
@@ -551,7 +564,7 @@ function DeviceSizeMenu({
|
||||
onSelect,
|
||||
triggerStyle,
|
||||
}: {
|
||||
selectedId: DeviceSizeId;
|
||||
selectedId: DeviceSizeId | null;
|
||||
onSelect: (id: DeviceSizeId) => void;
|
||||
triggerStyle: (state: { hovered?: boolean; pressed?: boolean }) => StyleProp<ViewStyle>;
|
||||
}) {
|
||||
@@ -590,6 +603,22 @@ function DeviceSizeMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function deviceSizeIdForViewport(viewport: BrowserViewport): DeviceSizeId | null {
|
||||
if (viewport.mode === "responsive") {
|
||||
return "responsive";
|
||||
}
|
||||
return (
|
||||
DEVICE_SIZE_PRESETS.find(
|
||||
(preset) => preset.width === viewport.width && preset.height === viewport.height,
|
||||
)?.id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function rememberResolvedBrowserWebviewSize(browserId: string, webview: HTMLElement): void {
|
||||
const bounds = webview.getBoundingClientRect();
|
||||
rememberBrowserWebviewSize({ browserId, width: bounds.width, height: bounds.height });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
export function BrowserPane({
|
||||
browserId,
|
||||
@@ -610,8 +639,16 @@ export function BrowserPane({
|
||||
const { t } = useTranslation();
|
||||
const browser = useBrowserStore((state) => state.browsersById[browserId] ?? null);
|
||||
const updateBrowser = useBrowserStore((state) => state.updateBrowser);
|
||||
const setBrowserViewport = useBrowserStore((state) => state.setBrowserViewport);
|
||||
const browserViewport = browser?.viewport ?? RESPONSIVE_BROWSER_VIEWPORT;
|
||||
const browserViewportRef = useRef(browserViewport);
|
||||
browserViewportRef.current = browserViewport;
|
||||
const isPresented = useRetainedPanelActive();
|
||||
const isPresentedRef = useRef(isPresented);
|
||||
isPresentedRef.current = isPresented;
|
||||
const webviewRef = useRef<ElectronWebview | null>(null);
|
||||
const webviewHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const webviewClipRef = useRef<HTMLElement | null>(null);
|
||||
const urlInputRef = useRef<WebTextInput | null>(null);
|
||||
const initialUrlRef = useRef(browser?.url ?? "https://example.com");
|
||||
const browserIdRef = useRef(browserId);
|
||||
@@ -629,7 +666,6 @@ export function BrowserPane({
|
||||
const toast = useToast();
|
||||
const toastRef = useRef(toast);
|
||||
toastRef.current = toast;
|
||||
const [deviceSizeId, setDeviceSizeId] = useState<DeviceSizeId>("responsive");
|
||||
const [pendingSelection, setPendingSelection] = useState<BrowserElementSelection | null>(null);
|
||||
// Screenshot is captured at selection time (overlay already torn down, no
|
||||
// scroll drift) and reused when the annotation card is submitted.
|
||||
@@ -727,7 +763,8 @@ export function BrowserPane({
|
||||
}
|
||||
|
||||
const host = webviewHostRef.current;
|
||||
if (!host) {
|
||||
const clip = webviewClipRef.current;
|
||||
if (!host || !clip) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -740,6 +777,7 @@ export function BrowserPane({
|
||||
const residentWebview = takeResidentBrowserWebview(browserId) as ElectronWebview | null;
|
||||
const webview = residentWebview ?? (document.createElement("webview") as ElectronWebview);
|
||||
webviewRef.current = webview;
|
||||
domReadyRef.current = isResidentBrowserWebviewReady(webview);
|
||||
if (!residentWebview) {
|
||||
prepareBrowserWebview(webview, {
|
||||
browserId,
|
||||
@@ -747,14 +785,26 @@ export function BrowserPane({
|
||||
initialUrl: initialUnsafeNavigationMessage ? "about:blank" : initialUrlRef.current,
|
||||
});
|
||||
}
|
||||
webview.style.display = "flex";
|
||||
webview.style.flex = "1";
|
||||
webview.style.width = "100%";
|
||||
webview.style.height = "100%";
|
||||
webview.style.border = "0";
|
||||
webview.style.background = "transparent";
|
||||
releaseResidentBrowserWebview(browserId, webview);
|
||||
if (isPresentedRef.current) {
|
||||
applyBrowserWebviewViewport(webview, browserViewportRef.current);
|
||||
presentBrowserWebview(browserId, webview, host, clip);
|
||||
} else {
|
||||
applyInactiveBrowserWebviewViewport(browserId, webview, browserViewportRef.current);
|
||||
}
|
||||
const sizeObserver =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(() => {
|
||||
if (!isPresentedRef.current) {
|
||||
return;
|
||||
}
|
||||
presentBrowserWebview(browserIdRef.current, webview, host, clip);
|
||||
rememberResolvedBrowserWebviewSize(browserIdRef.current, webview);
|
||||
});
|
||||
|
||||
const handleStartLoading = () => {
|
||||
domReadyRef.current = false;
|
||||
updateBrowser(browserId, { isLoading: true, lastError: null });
|
||||
syncNavigationState({ syncUrl: false });
|
||||
};
|
||||
@@ -846,7 +896,11 @@ export function BrowserPane({
|
||||
webview.addEventListener("focus", handleWebviewFocus);
|
||||
webview.addEventListener("mousedown", handleWebviewFocus);
|
||||
|
||||
host.appendChild(webview);
|
||||
if (isPresentedRef.current) {
|
||||
rememberResolvedBrowserWebviewSize(browserId, webview);
|
||||
}
|
||||
sizeObserver?.observe(host);
|
||||
sizeObserver?.observe(clip);
|
||||
if (initialUnsafeNavigationMessage) {
|
||||
updateBrowserRef.current(browserIdRef.current, {
|
||||
isLoading: false,
|
||||
@@ -855,6 +909,7 @@ export function BrowserPane({
|
||||
}
|
||||
|
||||
return () => {
|
||||
sizeObserver?.disconnect();
|
||||
webview.removeEventListener("did-start-loading", handleStartLoading);
|
||||
webview.removeEventListener("did-stop-loading", handleStopLoading);
|
||||
webview.removeEventListener("will-navigate", handleWillNavigate);
|
||||
@@ -866,15 +921,13 @@ export function BrowserPane({
|
||||
webview.removeEventListener("dom-ready", handleDomReady);
|
||||
webview.removeEventListener("focus", handleWebviewFocus);
|
||||
webview.removeEventListener("mousedown", handleWebviewFocus);
|
||||
if (host.contains(webview)) {
|
||||
const browserStillExists = Boolean(
|
||||
useBrowserStore.getState().browsersById[browserIdRef.current],
|
||||
);
|
||||
if (browserStillExists) {
|
||||
releaseResidentBrowserWebview(browserIdRef.current, webview);
|
||||
} else {
|
||||
host.removeChild(webview);
|
||||
}
|
||||
const browserStillExists = Boolean(
|
||||
useBrowserStore.getState().browsersById[browserIdRef.current],
|
||||
);
|
||||
if (browserStillExists) {
|
||||
releaseResidentBrowserWebview(browserIdRef.current, webview);
|
||||
} else {
|
||||
removeResidentBrowserWebview(browserIdRef.current);
|
||||
}
|
||||
if (webviewRef.current === webview) {
|
||||
webviewRef.current = null;
|
||||
@@ -884,6 +937,32 @@ export function BrowserPane({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [browserId, onFocusPane]);
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current;
|
||||
if (!webview) {
|
||||
return;
|
||||
}
|
||||
if (!isPresented) {
|
||||
releaseResidentBrowserWebview(browserId, webview);
|
||||
return;
|
||||
}
|
||||
applyBrowserWebviewViewport(webview, browserViewport);
|
||||
const host = webviewHostRef.current;
|
||||
const clip = webviewClipRef.current;
|
||||
if (host && clip) {
|
||||
presentBrowserWebview(browserId, webview, host, clip);
|
||||
}
|
||||
if (browserViewport.mode === "fixed") {
|
||||
rememberBrowserWebviewSize({
|
||||
browserId,
|
||||
width: browserViewport.width,
|
||||
height: browserViewport.height,
|
||||
});
|
||||
} else {
|
||||
rememberResolvedBrowserWebviewSize(browserId, webview);
|
||||
}
|
||||
}, [browserId, browserViewport, isPresented]);
|
||||
|
||||
const navigate = useCallback(
|
||||
(nextUrl: string) => {
|
||||
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
|
||||
@@ -1525,12 +1604,26 @@ export function BrowserPane({
|
||||
[selectorMode],
|
||||
);
|
||||
|
||||
const devicePreset = useMemo(
|
||||
() =>
|
||||
DEVICE_SIZE_PRESETS.find((preset) => preset.id === deviceSizeId) ?? DEVICE_SIZE_PRESETS[0],
|
||||
[deviceSizeId],
|
||||
const selectedDeviceSizeId = useMemo(
|
||||
() => deviceSizeIdForViewport(browserViewport),
|
||||
[browserViewport],
|
||||
);
|
||||
const isResponsiveDevice = browserViewport.mode === "responsive";
|
||||
|
||||
const handleSelectDeviceSize = useCallback(
|
||||
(deviceSizeId: DeviceSizeId) => {
|
||||
const preset =
|
||||
DEVICE_SIZE_PRESETS.find((candidate) => candidate.id === deviceSizeId) ??
|
||||
DEVICE_SIZE_PRESETS[0];
|
||||
setBrowserViewport(
|
||||
browserId,
|
||||
preset.width === null || preset.height === null
|
||||
? RESPONSIVE_BROWSER_VIEWPORT
|
||||
: createFixedBrowserViewport(preset.width, preset.height),
|
||||
);
|
||||
},
|
||||
[browserId, setBrowserViewport],
|
||||
);
|
||||
const isResponsiveDevice = devicePreset.width === null;
|
||||
|
||||
const webviewHostStyle = useMemo<CSSProperties>(
|
||||
() =>
|
||||
@@ -1546,15 +1639,13 @@ export function BrowserPane({
|
||||
: {
|
||||
// Fixed-size device frame, centered within webviewWrap (see styles).
|
||||
display: "flex",
|
||||
width: devicePreset.width ?? undefined,
|
||||
maxWidth: "100%",
|
||||
height: devicePreset.height ?? undefined,
|
||||
maxHeight: "100%",
|
||||
width: browserViewport.width,
|
||||
height: browserViewport.height,
|
||||
minHeight: 0,
|
||||
background: theme.colors.surface0,
|
||||
boxShadow: "0 2px 16px rgba(0,0,0,0.25)",
|
||||
},
|
||||
[devicePreset.height, devicePreset.width, isResponsiveDevice, theme.colors.surface0],
|
||||
[browserViewport, isResponsiveDevice, theme.colors.surface0],
|
||||
);
|
||||
|
||||
const webviewWrapStyle = useMemo(
|
||||
@@ -1566,6 +1657,10 @@ export function BrowserPane({
|
||||
webviewHostRef.current = node;
|
||||
}, []);
|
||||
|
||||
const setWebviewClipNode = useCallback((node: unknown) => {
|
||||
webviewClipRef.current = node instanceof HTMLElement ? node : null;
|
||||
}, []);
|
||||
|
||||
if (!isElectronRuntime()) {
|
||||
return (
|
||||
<View style={styles.unavailableState}>
|
||||
@@ -1624,8 +1719,8 @@ export function BrowserPane({
|
||||
</View>
|
||||
<View style={styles.chromeRight}>
|
||||
<DeviceSizeMenu
|
||||
selectedId={deviceSizeId}
|
||||
onSelect={setDeviceSizeId}
|
||||
selectedId={selectedDeviceSizeId}
|
||||
onSelect={handleSelectDeviceSize}
|
||||
triggerStyle={baseIconButtonStyle}
|
||||
/>
|
||||
<ToolbarButton
|
||||
@@ -1678,7 +1773,11 @@ export function BrowserPane({
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={webviewWrapStyle}>
|
||||
<View
|
||||
ref={setWebviewClipNode}
|
||||
style={webviewWrapStyle}
|
||||
testID={`browser-webview-clip-${browserId}`}
|
||||
>
|
||||
{createElement("div", {
|
||||
ref: setWebviewHostNode,
|
||||
style: webviewHostStyle,
|
||||
@@ -1884,6 +1983,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
annotationOverlay: {
|
||||
position: "absolute",
|
||||
zIndex: 1,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyBrowserWebviewViewport,
|
||||
applyInactiveBrowserWebviewViewport,
|
||||
type BrowserWebviewProfileHost,
|
||||
clearResidentBrowserWebviewsForTests,
|
||||
ensureResidentBrowserWebview,
|
||||
getResidentBrowserWebview,
|
||||
isResidentBrowserWebviewReady,
|
||||
prepareBrowserWebview,
|
||||
presentBrowserWebview,
|
||||
rememberBrowserWebviewSize,
|
||||
releaseResidentBrowserWebview,
|
||||
removeResidentBrowserWebview,
|
||||
resizeResidentBrowserWebview,
|
||||
takeResidentBrowserWebview,
|
||||
} from "./resident-webviews";
|
||||
import {
|
||||
@@ -54,14 +60,28 @@ function expectPermanentHostParking(host: HTMLElement): void {
|
||||
expect(host.style.position).toBe("fixed");
|
||||
expect(host.style.left).toBe("0px");
|
||||
expect(host.style.top).toBe("0px");
|
||||
expect(host.style.width).toBe("1px");
|
||||
expect(host.style.height).toBe("1px");
|
||||
expect(host.style.overflow).toBe("hidden");
|
||||
expect(host.style.width).toBe("100vw");
|
||||
expect(host.style.height).toBe("100vh");
|
||||
expect(host.style.overflow).toBe("visible");
|
||||
expect(host.style.opacity).toBe("1");
|
||||
expect(host.style.pointerEvents).toBe("none");
|
||||
expect(host.style.display).toBe("block");
|
||||
expect(host.style.visibility).toBe("visible");
|
||||
expect(host.style.transform).toBe("");
|
||||
expect(host.style.zIndex).toBe("0");
|
||||
}
|
||||
|
||||
function expectParkedSurface(surface: HTMLElement): void {
|
||||
expect(surface.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(surface.style.position).toBe("fixed");
|
||||
expect(surface.style.left).toBe("0px");
|
||||
expect(surface.style.top).toBe("0px");
|
||||
expect(surface.style.width).toBe("1px");
|
||||
expect(surface.style.height).toBe("1px");
|
||||
expect(surface.style.overflow).toBe("hidden");
|
||||
expect(surface.style.opacity).toBe("1");
|
||||
expect(surface.style.pointerEvents).toBe("none");
|
||||
expect(surface.style.display).toBe("block");
|
||||
expect(surface.style.visibility).toBe("visible");
|
||||
expect(surface.style.transform).toBe("");
|
||||
}
|
||||
|
||||
function expectResidentWebviewParking(webview: HTMLElement): void {
|
||||
@@ -94,12 +114,110 @@ describe("resident browser webviews", () => {
|
||||
|
||||
const host = residentHost();
|
||||
expect(visibleHost.children).toHaveLength(0);
|
||||
expect(Array.from(host.children)).toEqual([webview]);
|
||||
const surface = host.firstElementChild as HTMLElement;
|
||||
expect(Array.from(surface.children)).toEqual([webview]);
|
||||
expect(webview.isConnected).toBe(true);
|
||||
expectPermanentHostParking(host);
|
||||
expectParkedSurface(surface);
|
||||
expectResidentWebviewParking(webview);
|
||||
});
|
||||
|
||||
it("presents and parks a browser without changing its webview parent", () => {
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-stable-parent",
|
||||
workspaceId: "workspace-stable-parent",
|
||||
url: "https://example.com",
|
||||
});
|
||||
const anchor = document.createElement("div");
|
||||
const clip = document.createElement("div");
|
||||
Object.defineProperty(anchor, "getBoundingClientRect", {
|
||||
value: () => ({ left: 40, top: 60, width: 640, height: 480 }),
|
||||
});
|
||||
Object.defineProperty(clip, "getBoundingClientRect", {
|
||||
value: () => ({ left: 40, top: 60, width: 640, height: 480 }),
|
||||
});
|
||||
if (!webview?.parentElement) {
|
||||
throw new Error("Expected resident browser surface");
|
||||
}
|
||||
const permanentParent = webview.parentElement;
|
||||
|
||||
presentBrowserWebview("browser-stable-parent", webview, anchor, clip);
|
||||
expect(webview.parentElement).toBe(permanentParent);
|
||||
expect(permanentParent.style.left).toBe("40px");
|
||||
expect(permanentParent.style.top).toBe("60px");
|
||||
expect(permanentParent.style.width).toBe("640px");
|
||||
expect(permanentParent.style.height).toBe("480px");
|
||||
expect(permanentParent.style.pointerEvents).toBe("auto");
|
||||
expect(permanentParent.getAttribute("aria-hidden")).toBe("false");
|
||||
|
||||
releaseResidentBrowserWebview("browser-stable-parent", webview);
|
||||
expect(webview.parentElement).toBe(permanentParent);
|
||||
expectParkedSurface(permanentParent);
|
||||
|
||||
presentBrowserWebview("browser-stable-parent", webview, anchor, clip);
|
||||
expect(webview.parentElement).toBe(permanentParent);
|
||||
});
|
||||
|
||||
it("clips an oversized fixed viewport to its pane without resizing the webview", () => {
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-oversized",
|
||||
workspaceId: "workspace-oversized",
|
||||
url: "https://example.com",
|
||||
});
|
||||
if (!webview?.parentElement) {
|
||||
throw new Error("Expected resident browser surface");
|
||||
}
|
||||
applyBrowserWebviewViewport(webview, { mode: "fixed", width: 2560, height: 1440 });
|
||||
const anchor = document.createElement("div");
|
||||
const clip = document.createElement("div");
|
||||
Object.defineProperty(anchor, "getBoundingClientRect", {
|
||||
value: () => ({ left: -800, top: -300, width: 2560, height: 1440 }),
|
||||
});
|
||||
Object.defineProperty(clip, "getBoundingClientRect", {
|
||||
value: () => ({ left: 100, top: 150, width: 800, height: 600 }),
|
||||
});
|
||||
|
||||
presentBrowserWebview("browser-oversized", webview, anchor, clip);
|
||||
|
||||
expect(webview.parentElement.style.left).toBe("100px");
|
||||
expect(webview.parentElement.style.top).toBe("150px");
|
||||
expect(webview.parentElement.style.width).toBe("800px");
|
||||
expect(webview.parentElement.style.height).toBe("600px");
|
||||
expect(webview.style.left).toBe("-900px");
|
||||
expect(webview.style.top).toBe("-450px");
|
||||
expect(webview.style.width).toBe("2560px");
|
||||
expect(webview.style.height).toBe("1440px");
|
||||
|
||||
resizeResidentBrowserWebview({ browserId: "browser-oversized", width: 2560, height: 1440 });
|
||||
|
||||
expect(webview.style.left).toBe("-900px");
|
||||
expect(webview.style.top).toBe("-450px");
|
||||
expect(webview.parentElement.style.width).toBe("800px");
|
||||
expect(webview.parentElement.style.height).toBe("600px");
|
||||
});
|
||||
|
||||
it("retains document readiness with the resident webview", () => {
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-ready",
|
||||
workspaceId: "workspace-ready",
|
||||
url: "https://example.com",
|
||||
});
|
||||
if (!webview) {
|
||||
throw new Error("Expected resident browser webview");
|
||||
}
|
||||
|
||||
expect(isResidentBrowserWebviewReady(webview)).toBe(false);
|
||||
webview.dispatchEvent(new Event("dom-ready"));
|
||||
releaseResidentBrowserWebview("browser-ready", webview);
|
||||
|
||||
const reusedWebview = takeResidentBrowserWebview("browser-ready");
|
||||
expect(reusedWebview).toBe(webview);
|
||||
expect(isResidentBrowserWebviewReady(reusedWebview as HTMLElement)).toBe(true);
|
||||
|
||||
webview.dispatchEvent(new Event("did-start-loading"));
|
||||
expect(isResidentBrowserWebviewReady(webview)).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a resident webview for an agent-created unfocused tab", () => {
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-agent",
|
||||
@@ -197,8 +315,10 @@ describe("resident browser webviews", () => {
|
||||
});
|
||||
|
||||
expect(webview).toBe(staleWebview);
|
||||
expect(Array.from(staleHost.children)).toEqual([staleWebview]);
|
||||
const surface = staleHost.firstElementChild as HTMLElement;
|
||||
expect(Array.from(surface.children)).toEqual([staleWebview]);
|
||||
expectPermanentHostParking(staleHost);
|
||||
expectParkedSurface(surface);
|
||||
expectResidentWebviewParking(staleWebview);
|
||||
});
|
||||
|
||||
@@ -215,27 +335,73 @@ describe("resident browser webviews", () => {
|
||||
});
|
||||
|
||||
const host = residentHost();
|
||||
expect(firstWebview?.parentElement).toBe(host);
|
||||
expect(secondWebview?.parentElement).toBe(host);
|
||||
expect(firstWebview?.parentElement?.parentElement).toBe(host);
|
||||
expect(secondWebview?.parentElement?.parentElement).toBe(host);
|
||||
expect(firstWebview?.parentElement).not.toBe(secondWebview?.parentElement);
|
||||
expectResidentWebviewParking(firstWebview as HTMLElement);
|
||||
expectResidentWebviewParking(secondWebview as HTMLElement);
|
||||
});
|
||||
|
||||
it("moves a resident webview into a visible pane without recreating the node", () => {
|
||||
it("returns a resident webview without detaching it from its permanent surface", () => {
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-visible",
|
||||
workspaceId: "workspace-visible",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
const permanentParent = webview?.parentElement;
|
||||
const visibleWebview = takeResidentBrowserWebview("browser-visible");
|
||||
|
||||
expect(visibleWebview).toBe(webview);
|
||||
expect(webview?.style.position).toBe("");
|
||||
expect(webview?.style.left).toBe("");
|
||||
expect(webview?.style.top).toBe("");
|
||||
expect(webview?.style.zIndex).toBe("");
|
||||
expect(takeResidentBrowserWebview("browser-visible")).toBeNull();
|
||||
expect(webview?.parentElement).toBe(permanentParent);
|
||||
expect(takeResidentBrowserWebview("browser-visible")).toBe(webview);
|
||||
});
|
||||
|
||||
it("applies an exact fixed viewport without a flex width override", () => {
|
||||
const webview = document.createElement("webview");
|
||||
|
||||
applyBrowserWebviewViewport(webview, { mode: "fixed", width: 640, height: 480 });
|
||||
|
||||
expect(webview.style.flex).toBe("0 0 auto");
|
||||
expect(webview.style.width).toBe("640px");
|
||||
expect(webview.style.height).toBe("480px");
|
||||
});
|
||||
|
||||
it("parks a browser at its last resolved viewport dimensions", () => {
|
||||
const visibleHost = document.createElement("div");
|
||||
const webview = document.createElement("webview");
|
||||
visibleHost.appendChild(webview);
|
||||
document.body.appendChild(visibleHost);
|
||||
rememberBrowserWebviewSize({ browserId: "browser-sized", width: 640, height: 480 });
|
||||
|
||||
releaseResidentBrowserWebview("browser-sized", webview);
|
||||
|
||||
expect(webview.style.width).toBe("640px");
|
||||
expect(webview.style.height).toBe("480px");
|
||||
});
|
||||
|
||||
it("keeps an inactive in-place browser at its last resolved viewport dimensions", () => {
|
||||
const webview = document.createElement("webview");
|
||||
rememberBrowserWebviewSize({ browserId: "browser-inactive", width: 640, height: 480 });
|
||||
|
||||
applyInactiveBrowserWebviewViewport("browser-inactive", webview, { mode: "responsive" });
|
||||
|
||||
expect(webview.style.flex).toBe("0 0 auto");
|
||||
expect(webview.style.width).toBe("640px");
|
||||
expect(webview.style.height).toBe("480px");
|
||||
});
|
||||
|
||||
it("parks a cold inactive browser at its canonical fixed viewport", () => {
|
||||
const webview = document.createElement("webview");
|
||||
|
||||
applyInactiveBrowserWebviewViewport("browser-restored-fixed", webview, {
|
||||
mode: "fixed",
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
expect(webview.style.width).toBe("800px");
|
||||
expect(webview.style.height).toBe("600px");
|
||||
});
|
||||
|
||||
it("returns an existing visible pane webview instead of creating a resident duplicate", () => {
|
||||
|
||||
@@ -3,14 +3,18 @@ import {
|
||||
type DesktopAttachedBrowserRegistration,
|
||||
type DesktopBrowserBridge,
|
||||
} from "@/desktop/host";
|
||||
import type { BrowserViewport } from "@/desktop/browser/store";
|
||||
|
||||
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
|
||||
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
|
||||
const BROWSER_SURFACE_ATTRIBUTE = "data-paseo-browser-surface";
|
||||
const RESIDENT_VIEWPORT_WIDTH = 1280;
|
||||
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
||||
|
||||
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
|
||||
const residentSurfacesByBrowserId = new Map<string, HTMLElement>();
|
||||
const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>();
|
||||
const readyResidentWebviews = new WeakSet<HTMLElement>();
|
||||
|
||||
interface BrowserWebviewElement extends HTMLElement {
|
||||
src: string;
|
||||
@@ -70,6 +74,15 @@ function registerBrowserWhenAttached(
|
||||
});
|
||||
}
|
||||
|
||||
function registerBrowserReadiness(webview: HTMLElement): void {
|
||||
webview.addEventListener("did-start-loading", () => {
|
||||
readyResidentWebviews.delete(webview);
|
||||
});
|
||||
webview.addEventListener("dom-ready", () => {
|
||||
readyResidentWebviews.add(webview);
|
||||
});
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -83,24 +96,52 @@ function readDocument(): Document | null {
|
||||
}
|
||||
|
||||
function applyResidentHostParkingStyle(host: HTMLElement): void {
|
||||
// Parked browser webviews must remain paintable at all times; screenshot
|
||||
// correctness depends on the proven states in docs/browser-capture-harness.md.
|
||||
host.setAttribute("aria-hidden", "true");
|
||||
// The host is permanent. Individual browser surfaces switch between their
|
||||
// pane bounds and the proven paintable 1x1 parking geometry.
|
||||
host.removeAttribute("aria-hidden");
|
||||
host.style.position = "fixed";
|
||||
host.style.left = "0";
|
||||
host.style.top = "0";
|
||||
host.style.width = "1px";
|
||||
host.style.height = "1px";
|
||||
host.style.overflow = "hidden";
|
||||
host.style.width = "100vw";
|
||||
host.style.height = "100vh";
|
||||
host.style.overflow = "visible";
|
||||
host.style.opacity = "1";
|
||||
host.style.pointerEvents = "none";
|
||||
host.style.display = "block";
|
||||
host.style.zIndex = "";
|
||||
host.style.zIndex = "0";
|
||||
host.style.clipPath = "";
|
||||
host.style.visibility = "visible";
|
||||
host.style.transform = "";
|
||||
}
|
||||
|
||||
function applyParkedBrowserSurfaceStyle(surface: HTMLElement): void {
|
||||
surface.setAttribute("aria-hidden", "true");
|
||||
surface.style.position = "fixed";
|
||||
surface.style.left = "0";
|
||||
surface.style.top = "0";
|
||||
surface.style.width = "1px";
|
||||
surface.style.height = "1px";
|
||||
surface.style.overflow = "hidden";
|
||||
surface.style.opacity = "1";
|
||||
surface.style.pointerEvents = "none";
|
||||
surface.style.display = "block";
|
||||
surface.style.visibility = "visible";
|
||||
surface.style.transform = "";
|
||||
}
|
||||
|
||||
function getBrowserSurface(browserId: string, ownerDocument: Document): HTMLElement {
|
||||
const existing = residentSurfacesByBrowserId.get(browserId);
|
||||
if (existing?.isConnected) {
|
||||
return existing;
|
||||
}
|
||||
const surface = ownerDocument.createElement("div");
|
||||
surface.setAttribute(BROWSER_SURFACE_ATTRIBUTE, browserId);
|
||||
applyParkedBrowserSurfaceStyle(surface);
|
||||
getResidentBrowserHost(ownerDocument).appendChild(surface);
|
||||
residentSurfacesByBrowserId.set(browserId, surface);
|
||||
return surface;
|
||||
}
|
||||
|
||||
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
|
||||
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
|
||||
if (existing) {
|
||||
@@ -162,6 +203,105 @@ function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
|
||||
webview.style.zIndex = "";
|
||||
}
|
||||
|
||||
export function rememberBrowserWebviewSize(input: {
|
||||
browserId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}): { width: number; height: number } | null {
|
||||
const browserId = trimNonEmpty(input.browserId);
|
||||
if (!browserId || input.width <= 0 || input.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
const dimensions = {
|
||||
width: Math.max(1, Math.round(input.width)),
|
||||
height: Math.max(1, Math.round(input.height)),
|
||||
};
|
||||
residentWebviewSizesByBrowserId.set(browserId, dimensions);
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
function applyBrowserWebviewDimensions(webview: HTMLElement, viewport: BrowserViewport): void {
|
||||
webview.style.display = "flex";
|
||||
webview.style.border = "0";
|
||||
webview.style.background = "transparent";
|
||||
if (viewport.mode === "responsive") {
|
||||
webview.style.flex = "1";
|
||||
webview.style.width = "100%";
|
||||
webview.style.height = "100%";
|
||||
return;
|
||||
}
|
||||
webview.style.flex = "0 0 auto";
|
||||
webview.style.width = `${viewport.width}px`;
|
||||
webview.style.height = `${viewport.height}px`;
|
||||
}
|
||||
|
||||
export function applyBrowserWebviewViewport(webview: HTMLElement, viewport: BrowserViewport): void {
|
||||
clearResidentWebviewParkingStyle(webview);
|
||||
applyBrowserWebviewDimensions(webview, viewport);
|
||||
}
|
||||
|
||||
export function applyInactiveBrowserWebviewViewport(
|
||||
browserId: string,
|
||||
webview: HTMLElement,
|
||||
viewport: BrowserViewport,
|
||||
): void {
|
||||
if (viewport.mode === "fixed") {
|
||||
rememberBrowserWebviewSize({ browserId, width: viewport.width, height: viewport.height });
|
||||
}
|
||||
applyResidentWebviewStyle(webview, trimNonEmpty(browserId));
|
||||
}
|
||||
|
||||
export function presentBrowserWebview(
|
||||
browserId: string,
|
||||
webview: HTMLElement,
|
||||
anchor: HTMLElement,
|
||||
clip: HTMLElement,
|
||||
): void {
|
||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||
if (!normalizedBrowserId) {
|
||||
return;
|
||||
}
|
||||
const ownerDocument = readDocument();
|
||||
if (!ownerDocument) {
|
||||
return;
|
||||
}
|
||||
const surface = getBrowserSurface(normalizedBrowserId, ownerDocument);
|
||||
if (webview.parentElement !== surface) {
|
||||
surface.appendChild(webview);
|
||||
}
|
||||
const anchorBounds = anchor.getBoundingClientRect();
|
||||
const clipBounds = clip.getBoundingClientRect();
|
||||
const left = Math.max(anchorBounds.left, clipBounds.left);
|
||||
const top = Math.max(anchorBounds.top, clipBounds.top);
|
||||
const right = Math.min(
|
||||
anchorBounds.left + anchorBounds.width,
|
||||
clipBounds.left + clipBounds.width,
|
||||
);
|
||||
const bottom = Math.min(
|
||||
anchorBounds.top + anchorBounds.height,
|
||||
clipBounds.top + clipBounds.height,
|
||||
);
|
||||
const surfaceLeft = Math.ceil(left);
|
||||
const surfaceTop = Math.ceil(top);
|
||||
const surfaceRight = Math.floor(right);
|
||||
const surfaceBottom = Math.floor(bottom);
|
||||
const hasVisibleArea = surfaceRight > surfaceLeft && surfaceBottom > surfaceTop;
|
||||
surface.setAttribute("aria-hidden", "false");
|
||||
surface.style.position = "fixed";
|
||||
surface.style.left = `${surfaceLeft}px`;
|
||||
surface.style.top = `${surfaceTop}px`;
|
||||
surface.style.width = `${Math.max(0, surfaceRight - surfaceLeft)}px`;
|
||||
surface.style.height = `${Math.max(0, surfaceBottom - surfaceTop)}px`;
|
||||
surface.style.overflow = "hidden";
|
||||
surface.style.opacity = "1";
|
||||
surface.style.pointerEvents = hasVisibleArea ? "auto" : "none";
|
||||
surface.style.display = "flex";
|
||||
surface.style.visibility = "visible";
|
||||
webview.style.position = "absolute";
|
||||
webview.style.left = `${Math.round(anchorBounds.left - surfaceLeft)}px`;
|
||||
webview.style.top = `${Math.round(anchorBounds.top - surfaceTop)}px`;
|
||||
}
|
||||
|
||||
export function prepareBrowserWebview(
|
||||
webview: HTMLElement,
|
||||
input: {
|
||||
@@ -181,6 +321,11 @@ export function prepareBrowserWebview(
|
||||
(webview as BrowserWebviewElement).src = input.initialUrl;
|
||||
}
|
||||
registerBrowserWhenAttached(webview as BrowserWebviewElement, input, browser);
|
||||
registerBrowserReadiness(webview);
|
||||
}
|
||||
|
||||
export function isResidentBrowserWebviewReady(webview: HTMLElement): boolean {
|
||||
return readyResidentWebviews.has(webview);
|
||||
}
|
||||
|
||||
export function ensureResidentBrowserWebview(input: {
|
||||
@@ -247,8 +392,6 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||
clearResidentWebviewParkingStyle(webview);
|
||||
return webview;
|
||||
}
|
||||
|
||||
@@ -265,7 +408,11 @@ export function releaseResidentBrowserWebview(browserId: string, webview: HTMLEl
|
||||
|
||||
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
||||
applyResidentWebviewStyle(webview, normalizedBrowserId);
|
||||
getResidentBrowserHost(ownerDocument).appendChild(webview);
|
||||
const surface = getBrowserSurface(normalizedBrowserId, ownerDocument);
|
||||
applyParkedBrowserSurfaceStyle(surface);
|
||||
if (webview.parentElement !== surface) {
|
||||
surface.appendChild(webview);
|
||||
}
|
||||
}
|
||||
|
||||
export function resizeResidentBrowserWebview(input: {
|
||||
@@ -277,18 +424,18 @@ export function resizeResidentBrowserWebview(input: {
|
||||
if (!normalizedBrowserId) {
|
||||
return null;
|
||||
}
|
||||
const width = Math.max(1, Math.round(input.width));
|
||||
const height = Math.max(1, Math.round(input.height));
|
||||
residentWebviewSizesByBrowserId.set(normalizedBrowserId, { width, height });
|
||||
const dimensions = rememberBrowserWebviewSize(input);
|
||||
if (!dimensions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ownerDocument = readDocument();
|
||||
const webview = ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
|
||||
if (webview) {
|
||||
webview.style.width = `${width}px`;
|
||||
webview.style.height = `${height}px`;
|
||||
applyBrowserWebviewDimensions(webview, { mode: "fixed", ...dimensions });
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
export function removeResidentBrowserWebview(browserId: string): void {
|
||||
@@ -298,9 +445,12 @@ export function removeResidentBrowserWebview(browserId: string): void {
|
||||
}
|
||||
|
||||
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||
const surface = residentSurfacesByBrowserId.get(normalizedBrowserId) ?? null;
|
||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||
residentSurfacesByBrowserId.delete(normalizedBrowserId);
|
||||
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
|
||||
resident?.remove();
|
||||
surface?.remove();
|
||||
}
|
||||
|
||||
export function clearResidentBrowserWebviewsForTests(): void {
|
||||
@@ -308,6 +458,7 @@ export function clearResidentBrowserWebviewsForTests(): void {
|
||||
webview.remove();
|
||||
}
|
||||
residentWebviewsByBrowserId.clear();
|
||||
residentSurfacesByBrowserId.clear();
|
||||
residentWebviewSizesByBrowserId.clear();
|
||||
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
||||
}
|
||||
|
||||
@@ -7,18 +7,26 @@ import {
|
||||
type BrowserIndexState,
|
||||
type BrowserRecord,
|
||||
type BrowserRecordPatch,
|
||||
type BrowserViewport,
|
||||
createBrowserRecord,
|
||||
normalizeBrowserIndexState,
|
||||
normalizeBrowserUrl,
|
||||
removeBrowserFromIndex,
|
||||
sanitizeBrowsersForPersist,
|
||||
trimNonEmpty,
|
||||
} from "./state";
|
||||
|
||||
export type { BrowserRecord } from "./state";
|
||||
export {
|
||||
createFixedBrowserViewport,
|
||||
RESPONSIVE_BROWSER_VIEWPORT,
|
||||
type BrowserRecord,
|
||||
type BrowserViewport,
|
||||
} from "./state";
|
||||
|
||||
interface BrowserStoreState extends BrowserIndexState {
|
||||
createBrowser: (input?: { initialUrl?: string }) => string;
|
||||
updateBrowser: (browserId: string, patch: BrowserRecordPatch) => void;
|
||||
setBrowserViewport: (browserId: string, viewport: BrowserViewport) => void;
|
||||
removeBrowser: (browserId: string) => void;
|
||||
}
|
||||
|
||||
@@ -57,6 +65,9 @@ export const useBrowserStore = create<BrowserStoreState>()(
|
||||
updateBrowser: (browserId, patch) => {
|
||||
set((state) => applyBrowserPatch(state, browserId, patch));
|
||||
},
|
||||
setBrowserViewport: (browserId, viewport) => {
|
||||
set((state) => applyBrowserPatch(state, browserId, { viewport }));
|
||||
},
|
||||
removeBrowser: (browserId) => {
|
||||
set((state) => removeBrowserFromIndex(state, browserId));
|
||||
},
|
||||
@@ -65,6 +76,10 @@ export const useBrowserStore = create<BrowserStoreState>()(
|
||||
name: "workspace-browser-store",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => sanitizeBrowsersForPersist(state),
|
||||
merge: (persistedState, currentState) => ({
|
||||
...currentState,
|
||||
...normalizeBrowserIndexState(persistedState),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyBrowserPatch,
|
||||
type BrowserIndexState,
|
||||
createFixedBrowserViewport,
|
||||
createBrowserRecord,
|
||||
normalizeBrowserIndexState,
|
||||
normalizeBrowserUrl,
|
||||
removeBrowserFromIndex,
|
||||
sanitizeBrowsersForPersist,
|
||||
@@ -57,12 +59,25 @@ describe("createBrowserRecord", () => {
|
||||
canGoForward: false,
|
||||
faviconUrl: null,
|
||||
lastError: null,
|
||||
viewport: { mode: "responsive" },
|
||||
createdAt: 1000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyBrowserPatch", () => {
|
||||
it("updates the browser's canonical viewport", () => {
|
||||
const initial = withRecords([
|
||||
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
|
||||
]);
|
||||
|
||||
const next = applyBrowserPatch(initial, "b1", {
|
||||
viewport: createFixedBrowserViewport(639.6, 479.6),
|
||||
});
|
||||
|
||||
expect(next.browsersById.b1?.viewport).toEqual({ mode: "fixed", width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it("normalizes URL updates", () => {
|
||||
const initial = withRecords([
|
||||
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
|
||||
@@ -145,4 +160,32 @@ describe("sanitizeBrowsersForPersist", () => {
|
||||
expect(persisted.browsersById.b1?.isLoading).toBe(false);
|
||||
expect(persisted.browsersById.b1?.lastError).toBe(null);
|
||||
});
|
||||
|
||||
it("preserves viewport state", () => {
|
||||
const base = createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 });
|
||||
const state = applyBrowserPatch(withRecords([base]), "b1", {
|
||||
viewport: createFixedBrowserViewport(640, 480),
|
||||
});
|
||||
|
||||
expect(sanitizeBrowsersForPersist(state).browsersById.b1?.viewport).toEqual({
|
||||
mode: "fixed",
|
||||
width: 640,
|
||||
height: 480,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeBrowserIndexState", () => {
|
||||
it("defaults legacy persisted records to Responsive", () => {
|
||||
const legacy = createBrowserRecord({
|
||||
browserId: "b1",
|
||||
initialUrl: "https://a.test",
|
||||
now: 0,
|
||||
}) as Partial<ReturnType<typeof createBrowserRecord>>;
|
||||
delete legacy.viewport;
|
||||
|
||||
expect(
|
||||
normalizeBrowserIndexState({ browsersById: { b1: legacy } }).browsersById.b1?.viewport,
|
||||
).toEqual({ mode: "responsive" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export type BrowserViewport =
|
||||
| { mode: "responsive" }
|
||||
| { mode: "fixed"; width: number; height: number };
|
||||
|
||||
export const RESPONSIVE_BROWSER_VIEWPORT: BrowserViewport = { mode: "responsive" };
|
||||
|
||||
export interface BrowserRecord {
|
||||
browserId: string;
|
||||
url: string;
|
||||
@@ -7,6 +13,7 @@ export interface BrowserRecord {
|
||||
canGoForward: boolean;
|
||||
faviconUrl: string | null;
|
||||
lastError: string | null;
|
||||
viewport: BrowserViewport;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -16,6 +23,62 @@ export interface BrowserIndexState {
|
||||
browsersById: Record<string, BrowserRecord>;
|
||||
}
|
||||
|
||||
export function createFixedBrowserViewport(width: number, height: number): BrowserViewport {
|
||||
return {
|
||||
mode: "fixed",
|
||||
width: Math.max(1, Math.round(width)),
|
||||
height: Math.max(1, Math.round(height)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBrowserViewport(value: unknown): BrowserViewport {
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
(value as { mode?: unknown }).mode === "fixed" &&
|
||||
typeof (value as { width?: unknown }).width === "number" &&
|
||||
typeof (value as { height?: unknown }).height === "number"
|
||||
) {
|
||||
return createFixedBrowserViewport(
|
||||
(value as { width: number }).width,
|
||||
(value as { height: number }).height,
|
||||
);
|
||||
}
|
||||
return RESPONSIVE_BROWSER_VIEWPORT;
|
||||
}
|
||||
|
||||
function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): boolean {
|
||||
return (
|
||||
left.mode === right.mode &&
|
||||
(left.mode === "responsive" ||
|
||||
(right.mode === "fixed" && left.width === right.width && left.height === right.height))
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeBrowserIndexState(value: unknown): BrowserIndexState {
|
||||
if (!value || typeof value !== "object") {
|
||||
return { browsersById: {} };
|
||||
}
|
||||
const browsersById = (value as { browsersById?: unknown }).browsersById;
|
||||
if (!browsersById || typeof browsersById !== "object") {
|
||||
return { browsersById: {} };
|
||||
}
|
||||
return {
|
||||
browsersById: Object.fromEntries(
|
||||
Object.entries(browsersById).map(([browserId, record]) => {
|
||||
const browser = record as BrowserRecord & { viewport?: unknown };
|
||||
return [
|
||||
browserId,
|
||||
{
|
||||
...browser,
|
||||
viewport: normalizeBrowserViewport(browser.viewport),
|
||||
} satisfies BrowserRecord,
|
||||
];
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -55,6 +118,7 @@ export function createBrowserRecord(input: {
|
||||
canGoForward: false,
|
||||
faviconUrl: null,
|
||||
lastError: null,
|
||||
viewport: RESPONSIVE_BROWSER_VIEWPORT,
|
||||
createdAt: input.now,
|
||||
};
|
||||
}
|
||||
@@ -73,9 +137,13 @@ export function applyBrowserPatch<S extends BrowserIndexState>(
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextViewport = patch.viewport
|
||||
? normalizeBrowserViewport(patch.viewport)
|
||||
: existing.viewport;
|
||||
const nextRecord: BrowserRecord = {
|
||||
...existing,
|
||||
...patch,
|
||||
viewport: nextViewport,
|
||||
url: normalizeBrowserUrl(patch.url ?? existing.url),
|
||||
};
|
||||
|
||||
@@ -86,7 +154,8 @@ export function applyBrowserPatch<S extends BrowserIndexState>(
|
||||
nextRecord.canGoBack === existing.canGoBack &&
|
||||
nextRecord.canGoForward === existing.canGoForward &&
|
||||
nextRecord.faviconUrl === existing.faviconUrl &&
|
||||
nextRecord.lastError === existing.lastError
|
||||
nextRecord.lastError === existing.lastError &&
|
||||
browserViewportsEqual(nextRecord.viewport, existing.viewport)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user