Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 50 additions & 26 deletions apps/web/src/browser/BrowserSurfaceSlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,41 +27,45 @@ export function BrowserSurfaceSlot(props: {
// The lease lives as long as this element for one tab, so the ref callback owns it.
const attachSurface = (element: HTMLDivElement) => {
let lease = acquireBrowserSurface(tabId, fitSourceContent);
// An inline panel animates its clip box while the slot keeps its final size and position.
const panel = element.closest<HTMLElement>("[data-preview-panel-clip]");
let wasVisible = presentationRef.current.visible;
let closing = false;
Comment thread
Byntham marked this conversation as resolved.
Resolved
const update = () => {
const rect = element.getBoundingClientRect();
const bounds = element.getBoundingClientRect();
const presentation = presentationRef.current;
const presented = lease.present(
{
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.max(1, Math.round(rect.width)),
height: Math.max(1, Math.round(rect.height)),
},
presentation.visible && rect.width > 0 && rect.height > 0,
presentation.cornerRadius,
presentation.zIndex,
);
if (presentation.visible && !presented) {
if (presentation.visible !== wasVisible) {
wasVisible = presentation.visible;
// A close hides the slot in the same commit that marks the panel closed; stay on
// screen while it slides away, like the DOM surfaces beside it.
closing = !presentation.visible && panel?.dataset.previewPanelClip === "closed";
}
const rect = toSurfaceRect(bounds);
const panelBounds = panel?.getBoundingClientRect();
const visibleBounds = panelBounds ? intersectBounds(bounds, panelBounds) : bounds;
// Unrounded, so the clip edge tracks the panel's fractional edge exactly.
const clip = rectEquals(visibleBounds, bounds)
? null
: {
x: visibleBounds.x,
y: visibleBounds.y,
width: visibleBounds.width,
height: visibleBounds.height,
};
const shown =
(presentation.visible || closing) && visibleBounds.width > 0 && visibleBounds.height > 0;
if (!shown) closing = false;
const present = () =>
lease.present(rect, shown, presentation.cornerRadius, presentation.zIndex, clip);
if (!present() && presentation.visible) {
lease.release();
lease = acquireBrowserSurface(tabId, fitSourceContent);
lease.present(
{
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.max(1, Math.round(rect.width)),
height: Math.max(1, Math.round(rect.height)),
},
rect.width > 0 && rect.height > 0,
presentation.cornerRadius,
presentation.zIndex,
);
present();
}
};
updateRef.current = update;
update();
const observed: Element[] = [element];
// Inline shells animate without resizing the slot, so observe the shell too.
const panel = element.closest('[data-preview-panel-mode="inline"]');
if (panel) observed.push(panel);
// Sidebar width changes move the slot without changing its size or column offset.
observed.push(...document.querySelectorAll('[data-slot="sidebar-gap"]'));
Expand Down Expand Up @@ -91,3 +95,23 @@ export function BrowserSurfaceSlot(props: {

return <div ref={attachSurface} className={className} data-browser-surface-slot={tabId} />;
}

function toSurfaceRect(bounds: DOMRectReadOnly) {
return {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(1, Math.round(bounds.width)),
height: Math.max(1, Math.round(bounds.height)),
};
}

function intersectBounds(a: DOMRectReadOnly, b: DOMRectReadOnly): DOMRectReadOnly {
const left = Math.max(a.left, b.left);
const top = Math.max(a.top, b.top);
const right = Math.min(a.right, b.right);
const bottom = Math.min(a.bottom, b.bottom);
return new DOMRectReadOnly(left, top, Math.max(0, right - left), Math.max(0, bottom - top));
}

const rectEquals = (a: DOMRectReadOnly, b: DOMRectReadOnly) =>
a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
2 changes: 2 additions & 0 deletions apps/web/src/browser/HostedBrowserWebview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function HostedBrowserWebview(props: {
useShallow((state) => {
const current = state.byTabId[runtimeTabId];
return {
clip: current?.clip ?? null,
content: current?.content ?? null,
cornerRadius: current?.cornerRadius ?? 0,
fitSourceContent: current?.fitSourceContent ?? false,
Expand Down Expand Up @@ -300,6 +301,7 @@ export function HostedBrowserWebview(props: {
cornerRadius: presentation.cornerRadius,
zIndex: presentation.zIndex,
rect: lastRect,
clip: presentation.clip,
hiddenSize: { width: hiddenWidth, height: hiddenHeight },
});

Expand Down
42 changes: 31 additions & 11 deletions apps/web/src/browser/browserSurfaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface BrowserSurfaceRect {

export interface BrowserSurfacePresentation {
readonly rect: BrowserSurfaceRect | null;
/** The part of `rect` left uncovered by an animating panel; null shows all of it. */
readonly clip: BrowserSurfaceRect | null;
readonly visible: boolean;
readonly zIndex: number;
readonly content: BrowserSurfaceContentPresentation | null;
Expand Down Expand Up @@ -41,6 +43,7 @@ interface BrowserSurfaceStoreState {
visible: boolean,
cornerRadius: number,
zIndex: number,
clip: BrowserSurfaceRect | null,
) => void;
readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void;
readonly release: (tabId: string, owner: symbol) => void;
Expand All @@ -52,6 +55,7 @@ export interface BrowserSurfaceLease {
visible: boolean,
cornerRadius?: number,
zIndex?: number,
clip?: BrowserSurfaceRect | null,
) => boolean;
readonly release: () => void;
}
Expand All @@ -64,12 +68,14 @@ export function resolveBrowserSurfacePanelRect(
return current?.rect ?? null;
}

const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean =>
left !== null &&
left.x === right.x &&
left.y === right.y &&
left.width === right.width &&
left.height === right.height;
const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect | null): boolean =>
left === right ||
(left !== null &&
right !== null &&
left.x === right.x &&
left.y === right.y &&
left.width === right.width &&
left.height === right.height);

export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({
activityByTabId: {},
Expand Down Expand Up @@ -103,6 +109,7 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
...state.byTabId,
[tabId]: {
rect: current?.rect ?? null,
clip: current?.clip ?? null,
visible: false,
zIndex: current?.zIndex ?? 30,
content: current?.content ?? null,
Expand All @@ -115,7 +122,7 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
},
};
}),
present: (tabId, owner, rect, visible, cornerRadius, zIndex) =>
present: (tabId, owner, rect, visible, cornerRadius, zIndex, clip) =>
set((state) => {
const current = state.byTabId[tabId];
if (current?.owner !== owner) return state;
Expand All @@ -124,14 +131,24 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
current.visible === visible &&
current.cornerRadius === cornerRadius &&
current.zIndex === zIndex &&
rectEquals(current.rect, rect)
rectEquals(current.rect, rect) &&
rectEquals(current.clip, clip)
) {
return state;
}
return {
byTabId: {
...state.byTabId,
[tabId]: { ...current, rect, visible, cornerRadius, zIndex, updatedAt: Date.now() },
[tabId]: {
...current,
// A clip-only change keeps the rect identity so rect subscribers skip the frame.
rect: rectEquals(current.rect, rect) ? current.rect : rect,
clip,
visible,
cornerRadius,
zIndex,
updatedAt: Date.now(),
},
},
};
}),
Expand All @@ -144,6 +161,7 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
...state.byTabId,
[tabId]: {
rect: null,
clip: null,
visible: false,
zIndex: 30,
content,
Expand Down Expand Up @@ -216,10 +234,12 @@ export function acquireBrowserSurface(
useBrowserSurfaceStore.getState().claim(tabId, owner, fitSourceContent);

return {
present: (rect, visible, cornerRadius = 0, zIndex = 30) => {
present: (rect, visible, cornerRadius = 0, zIndex = 30, clip = null) => {
if (released) return false;
if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false;
useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius, zIndex);
useBrowserSurfaceStore
.getState()
.present(tabId, owner, rect, visible, cornerRadius, zIndex, clip);
return true;
},
release: () => {
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/browser/hostedBrowserWebviewStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface HostedBrowserWebviewWrapperStyle {
readonly zIndex: number;
readonly pointerEvents: "auto" | "none";
readonly borderRadius?: number;
readonly clipPath?: string;
readonly visibility?: "hidden" | "visible";
}

Expand All @@ -25,10 +26,12 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: {
readonly cornerRadius?: number;
readonly zIndex?: number;
readonly rect: BrowserSurfaceRect | null;
readonly clip?: BrowserSurfaceRect | null;
readonly hiddenSize: HostedBrowserWebviewSize;
}): HostedBrowserWebviewWrapperStyle {
const {
active,
clip = null,
cornerRadius = 0,
hiddenSize,
keepPaintableWhenInactive = false,
Expand All @@ -45,6 +48,12 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: {
zIndex,
pointerEvents: "auto",
...(cornerRadius > 0 ? { borderRadius: cornerRadius } : {}),
// Clips instead of resizing so the page keeps its layout while a panel slides over it.
...(clip
? {
clipPath: `inset(${clip.y - rect.y}px ${rect.x + rect.width - clip.x - clip.width}px ${rect.y + rect.height - clip.y - clip.height}px ${clip.x - rect.x}px)`,
}
: {}),
};
}

Comment thread
Byntham marked this conversation as resolved.
Resolved
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/preview/PreviewPanelShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ function PreviewPanelShellFrame(
{/* Content stays pinned to the right edge, so closing uncovers the chat
instead of sliding the tab bar under the title bar controls. */}
<div
data-preview-panel-clip={collapsible ? (open ? "open" : "closed") : undefined}
className={cn(
"h-full min-h-0 w-full",
collapsible && "overflow-clip",
Expand Down
Loading