245 lines
7.7 KiB
TypeScript
245 lines
7.7 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { BrowserNavState, BrowserRect } from "../../main/browser-view-host";
|
|
|
|
export type { BrowserNavState };
|
|
|
|
type UseBrowserViewOptions = {
|
|
sessionId: string;
|
|
active: boolean;
|
|
poppedOut: boolean;
|
|
/**
|
|
* Preview target driven by the daemon (via `ao preview`, streamed over CDC).
|
|
* When set, the view navigates here automatically; an empty value clears it.
|
|
*/
|
|
previewUrl?: string;
|
|
/**
|
|
* Monotonic counter the daemon bumps on every `ao preview` call, even when
|
|
* previewUrl is unchanged. The view re-navigates whenever it advances, so a
|
|
* repeated `ao preview <same-url>` still refreshes (and CDC replays of an
|
|
* unrelated session update, which leave it unchanged, are ignored).
|
|
*/
|
|
previewRevision?: number;
|
|
};
|
|
|
|
export type BrowserViewModel = {
|
|
viewId: string;
|
|
navState: BrowserNavState;
|
|
slotRef: (node: HTMLDivElement | null) => void;
|
|
navigate: (url: string) => Promise<void>;
|
|
goBack: () => Promise<void>;
|
|
goForward: () => Promise<void>;
|
|
reload: () => Promise<void>;
|
|
stop: () => Promise<void>;
|
|
destroy: () => void;
|
|
};
|
|
|
|
const EMPTY_NAV_STATE: BrowserNavState = {
|
|
viewId: "",
|
|
url: "",
|
|
title: "",
|
|
canGoBack: false,
|
|
canGoForward: false,
|
|
isLoading: false,
|
|
};
|
|
|
|
const HIDDEN_RECT: BrowserRect = { x: 0, y: 0, width: 0, height: 0 };
|
|
|
|
// The native WebContentsView is a window-level overlay, so DOM `overflow:
|
|
// hidden` never clips it — it paints wherever the slot's bounding box lands.
|
|
// Inside the collapsible inspector the slot sits in a `min-w-[280px]` wrapper,
|
|
// so on a narrow panel (small window, or mid-collapse) the slot's box spills
|
|
// past its resizable-panel column. Intersect the slot box with that column so
|
|
// the view can only ever paint inside it, never over the terminal/sidebar.
|
|
function visibleSlotRect(node: HTMLElement): BrowserRect {
|
|
const rect = node.getBoundingClientRect();
|
|
let { left, top, right, bottom } = rect;
|
|
const column = node.closest<HTMLElement>("[data-panel]");
|
|
if (column) {
|
|
const bounds = column.getBoundingClientRect();
|
|
left = Math.max(left, bounds.left);
|
|
top = Math.max(top, bounds.top);
|
|
right = Math.min(right, bounds.right);
|
|
bottom = Math.min(bottom, bounds.bottom);
|
|
}
|
|
return { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) };
|
|
}
|
|
|
|
export function useBrowserView({
|
|
sessionId,
|
|
active,
|
|
poppedOut,
|
|
previewUrl,
|
|
previewRevision,
|
|
}: UseBrowserViewOptions): BrowserViewModel {
|
|
const [viewId, setViewId] = useState("");
|
|
const [navState, setNavState] = useState<BrowserNavState>(EMPTY_NAV_STATE);
|
|
const slotNodeRef = useRef<HTMLDivElement | null>(null);
|
|
const viewIdRef = useRef("");
|
|
const activeRef = useRef(active);
|
|
const frameRef = useRef<number | null>(null);
|
|
const observerRef = useRef<ResizeObserver | null>(null);
|
|
const previewTriggerRef = useRef<{ revision: number | null; target: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
activeRef.current = active;
|
|
}, [active]);
|
|
|
|
const sendHiddenBounds = useCallback((id = viewIdRef.current) => {
|
|
if (!id) return;
|
|
window.ao?.browser.setBounds({ viewId: id, rect: HIDDEN_RECT, visible: false });
|
|
}, []);
|
|
|
|
const measureAndSend = useCallback(() => {
|
|
frameRef.current = null;
|
|
const id = viewIdRef.current;
|
|
const node = slotNodeRef.current;
|
|
if (!id) return;
|
|
if (!activeRef.current || !node || !node.isConnected) {
|
|
sendHiddenBounds(id);
|
|
return;
|
|
}
|
|
const rect = visibleSlotRect(node);
|
|
const payload = {
|
|
viewId: id,
|
|
rect,
|
|
visible: rect.width > 0 && rect.height > 0,
|
|
};
|
|
window.ao?.browser.setBounds(payload);
|
|
}, [sendHiddenBounds]);
|
|
|
|
const cancelScheduledMeasure = useCallback(() => {
|
|
if (frameRef.current === null) return;
|
|
if (window.cancelAnimationFrame) {
|
|
window.cancelAnimationFrame(frameRef.current);
|
|
}
|
|
window.clearTimeout(frameRef.current);
|
|
frameRef.current = null;
|
|
}, []);
|
|
|
|
const scheduleMeasure = useCallback(() => {
|
|
if (frameRef.current !== null) return;
|
|
frameRef.current = window.requestAnimationFrame
|
|
? window.requestAnimationFrame(() => measureAndSend())
|
|
: window.setTimeout(() => measureAndSend(), 16);
|
|
}, [measureAndSend]);
|
|
|
|
const slotRef = useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
observerRef.current?.disconnect();
|
|
slotNodeRef.current = node;
|
|
if (node) {
|
|
const observer = new ResizeObserver(scheduleMeasure);
|
|
observer.observe(node);
|
|
// Also track the resizable-panel column: while the inspector
|
|
// collapse/expand animates, the slot's own width stays pinned by
|
|
// `min-w-[280px]` (so a slot-only observer never fires), but the
|
|
// column's width changes every frame. Observing it re-measures
|
|
// through the whole animation so the view never lags behind.
|
|
const column = node.closest("[data-panel]");
|
|
if (column) observer.observe(column);
|
|
observerRef.current = observer;
|
|
}
|
|
scheduleMeasure();
|
|
},
|
|
[scheduleMeasure],
|
|
);
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
window.ao?.browser.ensure(sessionId).then((state) => {
|
|
if (disposed) return;
|
|
viewIdRef.current = state.viewId;
|
|
setViewId(state.viewId);
|
|
setNavState(state);
|
|
scheduleMeasure();
|
|
});
|
|
return () => {
|
|
disposed = true;
|
|
const id = viewIdRef.current;
|
|
if (id) {
|
|
sendHiddenBounds(id);
|
|
}
|
|
viewIdRef.current = "";
|
|
};
|
|
}, [scheduleMeasure, sendHiddenBounds, sessionId]);
|
|
|
|
useEffect(() => {
|
|
return window.ao?.browser.onNavState((state) => {
|
|
if (state.viewId !== viewIdRef.current) return;
|
|
setNavState(state);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (active) {
|
|
scheduleMeasure();
|
|
} else {
|
|
sendHiddenBounds();
|
|
}
|
|
}, [active, poppedOut, scheduleMeasure, sendHiddenBounds]);
|
|
|
|
useEffect(() => {
|
|
const handle = () => scheduleMeasure();
|
|
window.addEventListener("resize", handle);
|
|
window.addEventListener("scroll", handle, true);
|
|
return () => {
|
|
window.removeEventListener("resize", handle);
|
|
window.removeEventListener("scroll", handle, true);
|
|
observerRef.current?.disconnect();
|
|
cancelScheduledMeasure();
|
|
};
|
|
}, [cancelScheduledMeasure, scheduleMeasure]);
|
|
|
|
const withView = useCallback(async (fn: (id: string) => Promise<BrowserNavState | void>) => {
|
|
const id = viewIdRef.current;
|
|
if (!id) return;
|
|
const next = await fn(id);
|
|
if (next) setNavState(next);
|
|
}, []);
|
|
|
|
const navigate = useCallback(
|
|
(url: string) => withView((id) => window.ao!.browser.navigate({ viewId: id, url })),
|
|
[withView],
|
|
);
|
|
|
|
const clear = useCallback(() => withView((id) => window.ao!.browser.clear(id)), [withView]);
|
|
|
|
// Drive the view from the daemon-set preview target. Current daemons key
|
|
// this on previewRevision (bumped on every `ao preview` call); older daemons
|
|
// did not send it, so fall back to URL changes for compatibility.
|
|
useEffect(() => {
|
|
if (!viewId) return;
|
|
const target = previewUrl?.trim() ?? "";
|
|
const revision = typeof previewRevision === "number" ? previewRevision : null;
|
|
const previous = previewTriggerRef.current;
|
|
if (previous?.revision === revision && previous.target === target) return;
|
|
if (revision !== null && previous?.revision === revision) return;
|
|
previewTriggerRef.current = { revision, target };
|
|
if (target) {
|
|
void navigate(target);
|
|
} else if ((revision !== null && revision > 0) || previous?.target) {
|
|
void clear();
|
|
}
|
|
}, [clear, navigate, previewRevision, previewUrl, viewId]);
|
|
|
|
const destroy = useCallback(() => {
|
|
const id = viewIdRef.current;
|
|
if (!id) return;
|
|
sendHiddenBounds(id);
|
|
window.ao?.browser.destroy(id);
|
|
viewIdRef.current = "";
|
|
}, [sendHiddenBounds]);
|
|
|
|
return {
|
|
viewId,
|
|
navState,
|
|
slotRef,
|
|
navigate,
|
|
goBack: () => withView((id) => window.ao!.browser.goBack(id)),
|
|
goForward: () => withView((id) => window.ao!.browser.goForward(id)),
|
|
reload: () => withView((id) => window.ao!.browser.reload(id)),
|
|
stop: () => withView((id) => window.ao!.browser.stop(id)),
|
|
destroy,
|
|
};
|
|
}
|