fix(desktop): capture the annotated element when the app is zoomed (#5703)

The browser pane measures the selected element in page CSS pixels, but
capturePage takes device-independent pixels. Paseo's zoom carries into the
in-app browser, so at any zoom other than 100% the screenshot attached to
an annotation was offset and undersized. Scale the region by the guest's
zoom factor before capturing.

Closes #5690

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-29 20:08:05 +02:00
committed by GitHub
parent 5764863f63
commit 5de45e2086
2 changed files with 28 additions and 6 deletions
@@ -29,7 +29,7 @@ function harness(guest: BrowserCaptureGuest | null = null) {
describe("browser capture service", () => {
it("validates and rounds guest-relative bounds before capture", async () => {
const capturePage = vi.fn(async () => image());
const { service } = harness({ isDestroyed: () => false, capturePage });
const { service } = harness({ isDestroyed: () => false, getZoomFactor: () => 1, capturePage });
await expect(
service.capture({
@@ -41,9 +41,25 @@ describe("browser capture service", () => {
expect(capturePage).toHaveBeenCalledWith({ x: 0, y: 9, width: 20, height: 11 });
});
it("captures the selected element when the page is zoomed", async () => {
const capturePage = vi.fn(async () => image());
const { service } = harness({
isDestroyed: () => false,
getZoomFactor: () => 1.2,
capturePage,
});
await service.capture({
browserId: "browser-1",
hostWebContentsId: 42,
rect: { x: 300, y: 200, width: 120, height: 60 },
});
expect(capturePage).toHaveBeenCalledWith({ x: 360, y: 240, width: 144, height: 72 });
});
it("rejects invalid or unavailable captures without touching the guest", async () => {
const capturePage = vi.fn(async () => image());
const { service } = harness({ isDestroyed: () => false, capturePage });
const { service } = harness({ isDestroyed: () => false, getZoomFactor: () => 1, capturePage });
await expect(
service.capture({ browserId: "browser-1", hostWebContentsId: 42, rect: { width: 0 } }),
@@ -5,6 +5,7 @@ export interface BrowserCaptureImage {
export interface BrowserCaptureGuest<TImage extends BrowserCaptureImage = BrowserCaptureImage> {
isDestroyed(): boolean;
getZoomFactor(): number;
capturePage(rect: BrowserCaptureRect): Promise<TImage>;
}
@@ -40,7 +41,9 @@ export interface BrowserCaptureService {
copy(payload: unknown): Promise<boolean>;
}
function captureRect(value: unknown): BrowserCaptureRect | null {
// The renderer measures the element in page CSS pixels, while capturePage
// takes device-independent pixels, so the page zoom scales the region.
function captureRect(value: unknown, zoomFactor: number): BrowserCaptureRect | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const coordinates = [record.x, record.y, record.width, record.height];
@@ -51,7 +54,9 @@ function captureRect(value: unknown): BrowserCaptureRect | null {
) {
return null;
}
const [x, y, width, height] = coordinates as number[];
const [x, y, width, height] = (coordinates as number[]).map(
(coordinate) => coordinate * zoomFactor,
);
if (width <= 0 || height <= 0) return null;
return {
x: Math.max(0, Math.round(x)),
@@ -80,8 +85,9 @@ export function createBrowserCaptureService<TImage extends BrowserCaptureImage>(
async capture({ browserId, hostWebContentsId, rect }) {
if (typeof browserId !== "string" || browserId.trim().length === 0) return null;
const guest = dependencies.findGuest(browserId, hostWebContentsId);
const bounds = captureRect(rect);
if (!guest || guest.isDestroyed() || !bounds) return null;
if (!guest || guest.isDestroyed()) return null;
const bounds = captureRect(rect, guest.getZoomFactor());
if (!bounds) return null;
try {
const image = await guest.capturePage(bounds);
return image.isEmpty() ? null : image.toDataURL();