mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
fix spillage
This commit is contained in:
parent
b0f5ed85c6
commit
8991c562d0
5 changed files with 173 additions and 60 deletions
|
|
@ -97,12 +97,25 @@ export class BrowserViewManager extends EventEmitter {
|
||||||
|
|
||||||
const emit = () => this.emit('state-updated', this.snapshotState());
|
const emit = () => this.emit('state-updated', this.snapshotState());
|
||||||
|
|
||||||
wc.on('did-navigate', emit);
|
// Defensively re-apply bounds on navigation events. Electron's
|
||||||
wc.on('did-navigate-in-page', emit);
|
// WebContentsView is known to occasionally reset its laid-out bounds on
|
||||||
wc.on('did-start-loading', emit);
|
// navigation (a behavior carried over from the deprecated BrowserView),
|
||||||
wc.on('did-stop-loading', emit);
|
// which manifests as the view "spilling" outside its intended pane.
|
||||||
wc.on('did-finish-load', emit);
|
// Re-applying after every navigation/load event is cheap and idempotent.
|
||||||
wc.on('did-fail-load', emit);
|
const reapplyBounds = () => {
|
||||||
|
if (this.attached && this.bounds.width > 0 && this.bounds.height > 0) {
|
||||||
|
view.setBounds(this.bounds);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wc.on('did-start-navigation', reapplyBounds);
|
||||||
|
wc.on('did-navigate', () => { reapplyBounds(); emit(); });
|
||||||
|
wc.on('did-navigate-in-page', () => { reapplyBounds(); emit(); });
|
||||||
|
wc.on('did-start-loading', () => { reapplyBounds(); emit(); });
|
||||||
|
wc.on('did-stop-loading', () => { reapplyBounds(); emit(); });
|
||||||
|
wc.on('did-finish-load', () => { reapplyBounds(); emit(); });
|
||||||
|
wc.on('did-frame-finish-load', reapplyBounds);
|
||||||
|
wc.on('did-fail-load', () => { reapplyBounds(); emit(); });
|
||||||
wc.on('page-title-updated', emit);
|
wc.on('page-title-updated', emit);
|
||||||
|
|
||||||
// Pop-ups / target="_blank" — hand off to the OS browser for now.
|
// Pop-ups / target="_blank" — hand off to the OS browser for now.
|
||||||
|
|
@ -121,10 +134,16 @@ export class BrowserViewManager extends EventEmitter {
|
||||||
const contentView = this.window.contentView;
|
const contentView = this.window.contentView;
|
||||||
|
|
||||||
if (visible) {
|
if (visible) {
|
||||||
|
// Order: attach FIRST, then setBounds. Calling setBounds on an
|
||||||
|
// unattached WebContentsView can leave it in a state where the next
|
||||||
|
// attach uses default bounds, blanking the renderer area.
|
||||||
if (!this.attached) {
|
if (!this.attached) {
|
||||||
contentView.addChildView(view);
|
contentView.addChildView(view);
|
||||||
this.attached = true;
|
this.attached = true;
|
||||||
}
|
}
|
||||||
|
// The renderer only asks us to show the view after it has pushed a
|
||||||
|
// fresh non-zero rect, so applying the cached bounds here should land
|
||||||
|
// the surface in the correct pane immediately on attach.
|
||||||
view.setBounds(this.bounds);
|
view.setBounds(this.bounds);
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
|
|
||||||
|
|
@ -144,7 +163,10 @@ export class BrowserViewManager extends EventEmitter {
|
||||||
|
|
||||||
setBounds(bounds: BrowserBounds): void {
|
setBounds(bounds: BrowserBounds): void {
|
||||||
this.bounds = bounds;
|
this.bounds = bounds;
|
||||||
if (this.view && this.visible) {
|
// Only apply to the view if it's currently attached and visible.
|
||||||
|
// Applying to a detached view appears to put Electron's WebContentsView
|
||||||
|
// into a bad state on subsequent attach (renderer area blanks out).
|
||||||
|
if (this.view && this.attached && this.visible) {
|
||||||
this.view.setBounds(bounds);
|
this.view.setBounds(bounds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron';
|
||||||
import { ipc as ipcShared } from '@x/shared';
|
import { ipc as ipcShared } from '@x/shared';
|
||||||
|
|
||||||
type InvokeChannels = ipcShared.InvokeChannels;
|
type InvokeChannels = ipcShared.InvokeChannels;
|
||||||
|
|
@ -55,4 +55,5 @@ contextBridge.exposeInMainWorld('ipc', ipc);
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronUtils', {
|
contextBridge.exposeInMainWorld('electronUtils', {
|
||||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||||
});
|
getZoomFactor: () => webFrame.getZoomFactor(),
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -39,58 +39,78 @@ interface BrowserPaneProps {
|
||||||
export function BrowserPane({ onClose }: BrowserPaneProps) {
|
export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
|
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
|
||||||
const [addressValue, setAddressValue] = useState('')
|
const [addressValue, setAddressValue] = useState('')
|
||||||
const [addressFocused, setAddressFocused] = useState(false)
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const addressFocusedRef = useRef(false)
|
||||||
const viewportRef = useRef<HTMLDivElement>(null)
|
const viewportRef = useRef<HTMLDivElement>(null)
|
||||||
const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
|
const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
|
||||||
|
const viewVisibleRef = useRef(false)
|
||||||
|
|
||||||
// ── Subscribe to state updates from main ──────────────────────────────────
|
// ── Subscribe to state updates from main ──────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => {
|
const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => {
|
||||||
const next = incoming as BrowserState
|
const next = incoming as BrowserState
|
||||||
setState(next)
|
setState(next)
|
||||||
|
if (!addressFocusedRef.current) {
|
||||||
|
setAddressValue(next.url)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
// Kick an initial state fetch so the chrome reflects wherever the view
|
// Kick an initial state fetch so the chrome reflects wherever the view
|
||||||
// was left from a previous session (or empty, if never loaded).
|
// was left from a previous session (or empty, if never loaded).
|
||||||
void window.ipc.invoke('browser:getState', null).then((initial) => {
|
void window.ipc.invoke('browser:getState', null).then((initial) => {
|
||||||
setState(initial as BrowserState)
|
const next = initial as BrowserState
|
||||||
|
setState(next)
|
||||||
|
if (!addressFocusedRef.current) {
|
||||||
|
setAddressValue(next.url)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Keep the address bar in sync with the active page URL, but only when the
|
|
||||||
// input isn't focused — don't clobber whatever the user is typing.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!addressFocused) {
|
|
||||||
setAddressValue(state.url)
|
|
||||||
}
|
|
||||||
}, [state.url, addressFocused])
|
|
||||||
|
|
||||||
// ── Visibility lifecycle ──────────────────────────────────────────────────
|
|
||||||
// Show on mount, hide on unmount. The WebContentsView is expensive to
|
|
||||||
// create but cheap to show/hide.
|
|
||||||
useEffect(() => {
|
|
||||||
void window.ipc.invoke('browser:setVisible', { visible: true })
|
|
||||||
return () => {
|
|
||||||
void window.ipc.invoke('browser:setVisible', { visible: false })
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// ── Bounds tracking ───────────────────────────────────────────────────────
|
// ── Bounds tracking ───────────────────────────────────────────────────────
|
||||||
// The main process needs pixel-accurate bounds *relative to the window
|
// The main process needs pixel-accurate bounds *relative to the window
|
||||||
// content area*. getBoundingClientRect() returns viewport-relative coords,
|
// content area*. getBoundingClientRect() returns viewport-relative coords,
|
||||||
// which in Electron with no custom chrome equal content-area coords.
|
// which in Electron with hiddenInset titleBar equal content-area coords.
|
||||||
const pushBounds = useCallback(() => {
|
//
|
||||||
|
// Reads layout synchronously and posts an IPC update only when the rect
|
||||||
|
// actually changed. Cheap enough to call from a RAF loop or observer.
|
||||||
|
const setViewVisible = useCallback((visible: boolean) => {
|
||||||
|
if (viewVisibleRef.current === visible) return
|
||||||
|
viewVisibleRef.current = visible
|
||||||
|
void window.ipc.invoke('browser:setVisible', { visible })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const measureBounds = useCallback(() => {
|
||||||
const el = viewportRef.current
|
const el = viewportRef.current
|
||||||
if (!el) return
|
if (!el) return null
|
||||||
|
const zoomFactor = Math.max(window.electronUtils.getZoomFactor(), 0.01)
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
|
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[data-chat-sidebar-root]')
|
||||||
|
const chatSidebarRect = chatSidebar?.getBoundingClientRect()
|
||||||
|
const clampedRightCss = chatSidebarRect && chatSidebarRect.width > 0
|
||||||
|
? Math.min(rect.right, chatSidebarRect.left)
|
||||||
|
: rect.right
|
||||||
|
// `getBoundingClientRect()` is reported in zoomed CSS pixels. Electron's
|
||||||
|
// native view bounds are in unzoomed window coordinates, so we have to
|
||||||
|
// convert back using the renderer zoom factor.
|
||||||
|
const left = Math.ceil(rect.left * zoomFactor)
|
||||||
|
const top = Math.ceil(rect.top * zoomFactor)
|
||||||
|
const right = Math.floor(clampedRightCss * zoomFactor)
|
||||||
|
const bottom = Math.floor(rect.bottom * zoomFactor)
|
||||||
|
const width = right - left
|
||||||
|
const height = bottom - top
|
||||||
|
// A zero-sized rect means the element isn't laid out yet or has been
|
||||||
|
// collapsed behind the chat pane — hide the native view in that case.
|
||||||
|
if (width <= 0 || height <= 0) return null
|
||||||
const bounds = {
|
const bounds = {
|
||||||
x: Math.round(rect.left),
|
x: left,
|
||||||
y: Math.round(rect.top),
|
y: top,
|
||||||
width: Math.round(rect.width),
|
width,
|
||||||
height: Math.round(rect.height),
|
height,
|
||||||
}
|
}
|
||||||
|
return bounds
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const pushBounds = useCallback((bounds: { x: number; y: number; width: number; height: number }) => {
|
||||||
const last = lastBoundsRef.current
|
const last = lastBoundsRef.current
|
||||||
if (
|
if (
|
||||||
last &&
|
last &&
|
||||||
|
|
@ -99,36 +119,96 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
last.width === bounds.width &&
|
last.width === bounds.width &&
|
||||||
last.height === bounds.height
|
last.height === bounds.height
|
||||||
) {
|
) {
|
||||||
return
|
return bounds
|
||||||
}
|
}
|
||||||
lastBoundsRef.current = bounds
|
lastBoundsRef.current = bounds
|
||||||
void window.ipc.invoke('browser:setBounds', bounds)
|
void window.ipc.invoke('browser:setBounds', bounds)
|
||||||
|
return bounds
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const syncView = useCallback(() => {
|
||||||
|
const bounds = measureBounds()
|
||||||
|
if (!bounds) {
|
||||||
|
lastBoundsRef.current = null
|
||||||
|
setViewVisible(false)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
pushBounds(bounds)
|
||||||
|
setViewVisible(true)
|
||||||
|
return bounds
|
||||||
|
}, [measureBounds, pushBounds, setViewVisible])
|
||||||
|
|
||||||
|
// Defensively re-push bounds whenever the underlying page state changes.
|
||||||
|
// Electron's WebContentsView can drop its laid-out rect on navigation,
|
||||||
|
// and even though main re-applies on the same events, pushing from the
|
||||||
|
// renderer ensures we use the *current* layout (in case the chat sidebar
|
||||||
|
// or window resized while the page was loading).
|
||||||
|
useEffect(() => {
|
||||||
|
syncView()
|
||||||
|
}, [state.url, state.loading, syncView])
|
||||||
|
|
||||||
|
// ── Visibility lifecycle ──────────────────────────────────────────────────
|
||||||
|
// Order matters: push bounds FIRST so the WCV is created at the right
|
||||||
|
// position, THEN setVisible. Otherwise the view gets attached at stale
|
||||||
|
// (or zero) cached bounds and visually spills until the next bounds push.
|
||||||
|
//
|
||||||
|
// We wait one animation frame after mount so React's commit + the browser's
|
||||||
|
// layout pass for any sibling that just mounted (e.g. the chat sidebar) are
|
||||||
|
// both reflected in getBoundingClientRect. A single RAF is enough — by then
|
||||||
|
// the renderer has painted the post-commit DOM at least once.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
syncView()
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
lastBoundsRef.current = null
|
||||||
|
setViewVisible(false)
|
||||||
|
}
|
||||||
|
}, [setViewVisible, syncView])
|
||||||
|
|
||||||
|
// Ongoing bounds tracking. Observes everything that can move our viewport:
|
||||||
|
// - the viewport div itself (local size changes)
|
||||||
|
// - the SidebarInset ancestor (changes when chat sidebar mounts/resizes)
|
||||||
|
// - the document root (window resize, devicePixelRatio changes)
|
||||||
|
//
|
||||||
|
// ResizeObserver fires after layout but before paint, so by the time we
|
||||||
|
// call pushBounds the rect is current. Multiple observers may fire in the
|
||||||
|
// same frame — RAF-coalesce them so we only post one IPC per frame.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = viewportRef.current
|
const el = viewportRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
// Initial push + ResizeObserver for size changes.
|
// The sidebar-inset main element is the immediate flex parent whose
|
||||||
pushBounds()
|
// width shrinks when a sibling appears. Walking up the tree is more
|
||||||
const ro = new ResizeObserver(() => pushBounds())
|
// robust than passing a ref through props.
|
||||||
|
const sidebarInset = el.closest<HTMLElement>('[data-slot="sidebar-inset"]')
|
||||||
|
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[data-chat-sidebar-root]')
|
||||||
|
const documentElement = el.ownerDocument.documentElement
|
||||||
|
|
||||||
|
let pendingRaf: number | null = null
|
||||||
|
const schedule = () => {
|
||||||
|
if (pendingRaf !== null) return
|
||||||
|
pendingRaf = requestAnimationFrame(() => {
|
||||||
|
pendingRaf = null
|
||||||
|
syncView()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(schedule)
|
||||||
ro.observe(el)
|
ro.observe(el)
|
||||||
|
if (sidebarInset) ro.observe(sidebarInset)
|
||||||
// The container may move without resizing (sidebar collapse, window
|
if (chatSidebar) ro.observe(chatSidebar)
|
||||||
// resize). Listen on window resize for that case.
|
ro.observe(documentElement)
|
||||||
const onWindowResize = () => pushBounds()
|
|
||||||
window.addEventListener('resize', onWindowResize)
|
|
||||||
|
|
||||||
// Also poll briefly during layout transitions (the sidebar uses CSS
|
|
||||||
// transitions that don't fire ResizeObserver every frame).
|
|
||||||
const interval = window.setInterval(pushBounds, 100)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (pendingRaf !== null) cancelAnimationFrame(pendingRaf)
|
||||||
ro.disconnect()
|
ro.disconnect()
|
||||||
window.removeEventListener('resize', onWindowResize)
|
|
||||||
window.clearInterval(interval)
|
|
||||||
}
|
}
|
||||||
}, [pushBounds])
|
}, [syncView])
|
||||||
|
|
||||||
// ── Actions ───────────────────────────────────────────────────────────────
|
// ── Actions ───────────────────────────────────────────────────────────────
|
||||||
const handleSubmitAddress = useCallback((e: React.FormEvent) => {
|
const handleSubmitAddress = useCallback((e: React.FormEvent) => {
|
||||||
|
|
@ -157,8 +237,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"
|
||||||
className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"
|
|
||||||
>
|
>
|
||||||
{/* Chrome row: back / forward / reload / address bar */}
|
{/* Chrome row: back / forward / reload / address bar */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -207,10 +286,13 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
value={addressValue}
|
value={addressValue}
|
||||||
onChange={(e) => setAddressValue(e.target.value)}
|
onChange={(e) => setAddressValue(e.target.value)}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
setAddressFocused(true)
|
addressFocusedRef.current = true
|
||||||
e.currentTarget.select()
|
e.currentTarget.select()
|
||||||
}}
|
}}
|
||||||
onBlur={() => setAddressFocused(false)}
|
onBlur={() => {
|
||||||
|
addressFocusedRef.current = false
|
||||||
|
setAddressValue(state.url)
|
||||||
|
}}
|
||||||
placeholder="Enter URL or search..."
|
placeholder="Enter URL or search..."
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-7 w-full rounded-md border border-transparent bg-background px-3 text-sm text-foreground',
|
'h-7 w-full rounded-md border border-transparent bg-background px-3 text-sm text-foreground',
|
||||||
|
|
@ -242,7 +324,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
user sees the web page, not a React element. */}
|
user sees the web page, not a React element. */}
|
||||||
<div
|
<div
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
className="relative min-h-0 flex-1"
|
className="relative min-h-0 min-w-0 flex-1"
|
||||||
data-browser-viewport
|
data-browser-viewport
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,18 @@ function matchBillingError(message: string) {
|
||||||
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
|
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BillingRowboatAccount {
|
||||||
|
config?: {
|
||||||
|
appUrl?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
function BillingErrorCTA({ label }: { label: string }) {
|
function BillingErrorCTA({ label }: { label: string }) {
|
||||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.ipc.invoke('account:getRowboat', null)
|
window.ipc.invoke('account:getRowboat', null)
|
||||||
.then((account: any) => setAppUrl(account.config?.appUrl ?? null))
|
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -467,6 +473,7 @@ export function ChatSidebar({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={paneRef}
|
ref={paneRef}
|
||||||
|
data-chat-sidebar-root
|
||||||
onMouseDownCapture={onActivate}
|
onMouseDownCapture={onActivate}
|
||||||
onFocusCapture={onActivate}
|
onFocusCapture={onActivate}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
3
apps/x/apps/renderer/src/global.d.ts
vendored
3
apps/x/apps/renderer/src/global.d.ts
vendored
|
|
@ -35,8 +35,9 @@ declare global {
|
||||||
};
|
};
|
||||||
electronUtils: {
|
electronUtils: {
|
||||||
getPathForFile: (file: File) => string;
|
getPathForFile: (file: File) => string;
|
||||||
|
getZoomFactor: () => number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { };
|
export { };
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue