Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 176 additions & 59 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4179,67 +4179,184 @@ describe("PreviewManager", () => {
),
);

effectIt.effect("crops the picked area out of a full-page capture at device scale", () =>
withManager((manager) =>
Effect.gen(function* () {
let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined;
const crop = vi.fn((rect: Electron.Rectangle) => ({
getSize: () => ({ width: rect.width, height: rect.height }),
toDataURL: () => "data:image/png;base64,cropped",
}));
const capturePage = vi.fn(async () => ({
getSize: () => ({ width: 2000, height: 1200 }),
crop,
}));
fromId.mockReturnValue(
Object.assign(makeTestPreviewWebContents(capturePage as never), {
isFocused: () => true,
once: vi.fn(),
ipc: {
on: vi.fn((channel: string, listener: typeof onPicked) => {
if (channel === "preview:element-picked") onPicked = listener;
}),
off: vi.fn(),
removeListener: vi.fn(),
},
}),
);
effectIt.effect.each([
["unscaled", { x: 5, y: 6, width: 20, height: 30 }, 1, { x: 5, y: 6, width: 20, height: 30 }],
["retina", { x: 5, y: 6, width: 20, height: 30 }, 2, { x: 10, y: 12, width: 40, height: 60 }],
[
"zoomed retina",
{ x: 5, y: 6, width: 20, height: 30 },
2.5,
{ x: 12, y: 15, width: 51, height: 75 },
],
[
"double zoom retina",
{ x: 5, y: 6, width: 20, height: 30 },
4,
{ x: 20, y: 24, width: 80, height: 120 },
],
[
"clipped top left",
{ x: -5, y: -6, width: 20, height: 30 },
2,
{ x: 0, y: 0, width: 30, height: 48 },
],
[
"clipped bottom right",
{ x: 995, y: 595, width: 20, height: 30 },
2,
{ x: 1990, y: 1190, width: 10, height: 10 },
],
["above viewport", { x: 5, y: -100, width: 20, height: 30 }, 2, null],
["below viewport", { x: 5, y: 601, width: 20, height: 30 }, 2, null],
["left of viewport", { x: -100, y: 6, width: 20, height: 30 }, 2, null],
["right of viewport", { x: 1001, y: 6, width: 20, height: 30 }, 2, null],
[
"padding above viewport",
{ x: 40, y: -100, width: 240, height: 110 },
2,
null,
[{ x: 60, y: -80, width: 200, height: 70 }],
],
[
"padding below viewport",
{ x: 40, y: 590, width: 240, height: 110 },
2,
null,
[{ x: 60, y: 610, width: 200, height: 70 }],
],
[
"padding left of viewport",
{ x: -230, y: 40, width: 240, height: 110 },
2,
null,
[{ x: -210, y: 60, width: 200, height: 70 }],
],
[
"padding right of viewport",
{ x: 990, y: 40, width: 240, height: 110 },
2,
null,
[{ x: 1010, y: 60, width: 200, height: 70 }],
],
[
"opposite offscreen targets",
{ x: -230, y: 40, width: 1460, height: 110 },
2,
null,
[
{ x: -210, y: 60, width: 200, height: 70 },
{ x: 1010, y: 60, width: 200, height: 70 },
],
],
[
"partial target with padding",
{ x: 40, y: -60, width: 240, height: 110 },
2,
{ x: 80, y: 0, width: 480, height: 100 },
[{ x: 60, y: -40, width: 200, height: 70 }],
],
[
"visible and offscreen targets",
{ x: -230, y: 40, width: 510, height: 110 },
2,
{ x: 0, y: 80, width: 560, height: 220 },
[
{ x: -210, y: 60, width: 200, height: 70 },
{ x: 60, y: 60, width: 200, height: 70 },
],
],
[
"offscreen target without crop bounds",
null,
2,
null,
[{ x: 60, y: -80, width: 200, height: 70 }],
],
[
"visible target without crop bounds",
null,
2,
{ x: 0, y: 0, width: 2000, height: 1200 },
[{ x: 60, y: 80, width: 200, height: 70 }],
],
["no targets or crop bounds", null, 2, { x: 0, y: 0, width: 2000, height: 1200 }, []],
] as const)(
"crops the picked viewport area: %s",
([, cropRect, devicePixelRatio, expectedCrop, targetRects]) =>
withManager((manager) =>
Effect.gen(function* () {
let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined;
const crop = vi.fn((rect: Electron.Rectangle) => ({
getSize: () => ({ width: rect.width, height: rect.height }),
toDataURL: () => "data:image/png;base64,cropped",
}));
const capturePage = vi.fn(async () => ({
getSize: () => ({ width: 2000, height: 1200 }),
toDataURL: () => "data:image/png;base64,cropped",
crop,
}));
fromId.mockReturnValue(
Object.assign(makeTestPreviewWebContents(capturePage as never), {
isFocused: () => true,
once: vi.fn(),
ipc: {
on: vi.fn((channel: string, listener: typeof onPicked) => {
if (channel === "preview:element-picked") onPicked = listener;
}),
off: vi.fn(),
removeListener: vi.fn(),
},
}),
);

yield* manager.createTab("tab_1");
yield* manager.registerWebview("tab_1", 42);
const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
yield* Effect.yieldNow;
onPicked?.(
{},
{
id: "annotation_1",
pageUrl: "https://example.com",
pageTitle: "Example",
comment: "Tighten this spacing",
elements: [],
regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }],
strokes: [],
styleChanges: [],
screenshot: null,
createdAt: "2026-06-11T00:00:00.000Z",
},
{ x: 5, y: 6, width: 20, height: 30 },
"attach",
2,
);
yield* manager.createTab("tab_1");
yield* manager.registerWebview("tab_1", 42);
const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
yield* Effect.yieldNow;
onPicked?.(
{},
{
id: "annotation_1",
pageUrl: "https://example.com",
pageTitle: "Example",
comment: "Tighten this spacing",
elements: [],
regions: (targetRects ?? (cropRect ? [cropRect] : [])).map((rect, index) => ({
id: `region_${index}`,
rect,
})),
strokes: [],
styleChanges: [],
screenshot: null,
createdAt: "2026-06-11T00:00:00.000Z",
},
cropRect,
"attach",
devicePixelRatio,
);

const result = yield* Fiber.join(pick);
expect(capturePage).toHaveBeenCalledWith();
expect(crop).toHaveBeenCalledWith({ x: 10, y: 12, width: 40, height: 60 });
expect(result?.screenshotFailed).toBeUndefined();
expect(result?.annotation.screenshot).toEqual({
dataUrl: "data:image/png;base64,cropped",
width: 40,
height: 60,
cropRect: { x: 5, y: 6, width: 20, height: 30 },
});
}),
),
const result = yield* Fiber.join(pick);
expect(capturePage).toHaveBeenCalledWith();
expect(result?.annotation.comment).toBe("Tighten this spacing");
expect(result?.submission).toBe("attach");
expect(webviewSend).toHaveBeenCalledWith("preview:annotation-captured");
if (!expectedCrop) {
expect(crop).not.toHaveBeenCalled();
expect(result?.annotation.screenshot).toBeNull();
expect(result?.screenshotFailed).toBe(true);
return;
}
if (cropRect) expect(crop).toHaveBeenCalledWith(expectedCrop);
else expect(crop).not.toHaveBeenCalled();
expect(result?.screenshotFailed).toBeUndefined();
expect(result?.annotation.screenshot).toEqual({
dataUrl: "data:image/png;base64,cropped",
width: expectedCrop.width,
height: expectedCrop.height,
cropRect: cropRect ?? expectedCrop,
});
}),
),
);

effectIt.effect("a stale capture from a replaced pick never touches the next pick", () =>
Expand Down
48 changes: 39 additions & 9 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,23 +280,42 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
return null;
}
return {
x: Math.max(0, Math.floor(x)),
y: Math.max(0, Math.floor(y)),
x: Math.floor(x),
y: Math.floor(y),
width: Math.max(1, Math.ceil(width)),
height: Math.max(1, Math.ceil(height)),
};
};

/**
* Crops a full-page capture to a picked annotation. `cropRect` is in CSS px;
* Crops a viewport capture to a picked annotation. `cropRect` is in CSS px;
* the capture's pixels are CSS px × the guest's `devicePixelRatio`.
*/
const cropAnnotationScreenshot = (
image: Electron.NativeImage,
cropRect: PreviewAnnotationRect | null,
devicePixelRatio: number,
targetRects: ReadonlyArray<PreviewAnnotationRect>,
): PreviewAnnotationPayload["screenshot"] => {
const full = image.getSize();
if (full.width <= 0 || full.height <= 0) return null;
const scale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
// Padding and unions can overlap the viewport without any selected target.
// Check the individual unpadded bounds before accepting even a full capture.
if (
targetRects.length > 0 &&
!targetRects.some(
(rect) =>
rect.width > 0 &&
rect.height > 0 &&
rect.x * scale < full.width &&
rect.y * scale < full.height &&
(rect.x + rect.width) * scale > 0 &&
(rect.y + rect.height) * scale > 0,
)
) {
return null;
}
if (!cropRect) {
return {
dataUrl: image.toDataURL(),
Expand All @@ -305,14 +324,18 @@ const cropAnnotationScreenshot = (
cropRect: { x: 0, y: 0, width: full.width, height: full.height },
};
}
const scale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
const x = Math.min(full.width - 1, Math.max(0, Math.floor(cropRect.x * scale)));
const y = Math.min(full.height - 1, Math.max(0, Math.floor(cropRect.y * scale)));
const x = Math.max(0, Math.floor(cropRect.x * scale));
const y = Math.max(0, Math.floor(cropRect.y * scale));
const right = Math.min(full.width, Math.ceil((cropRect.x + cropRect.width) * scale));
const bottom = Math.min(full.height, Math.ceil((cropRect.y + cropRect.height) * scale));
// A target scrolled out of view has no pixels in this capture. Clamping its
// origin onto the edge would attach an unrelated strip as a successful crop.
if (right <= x || bottom <= y) return null;
const cropped = image.crop({
x,
y,
width: Math.max(1, Math.min(full.width - x, Math.ceil(cropRect.width * scale))),
height: Math.max(1, Math.min(full.height - y, Math.ceil(cropRect.height * scale))),
width: right - x,
height: bottom - y,
});
const size = cropped.getSize();
return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect };
Expand Down Expand Up @@ -2255,6 +2278,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const submission =
args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach";
const devicePixelRatio = typeof args[3] === "number" ? args[3] : 1;
const targetRects = [
...payload.elements.map((target) => target.rect),
...payload.regions.map((target) => target.rect),
...payload.strokes.map((target) => target.bounds),
];
runFork(
// The full-page capture with retries is what the screenshot button
// uses. A single cropped `capturePage(rect)` with a fixed timeout
Expand All @@ -2264,7 +2292,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
tabId,
wc,
).pipe(
Effect.map((image) => cropAnnotationScreenshot(image, cropRect, devicePixelRatio)),
Effect.map((image) =>
cropAnnotationScreenshot(image, cropRect, devicePixelRatio, targetRects),
),
Effect.tapError((error) =>
Effect.logWarning("preview annotation screenshot failed").pipe(
Effect.annotateLogs({ tabId, webContentsId: wc.id, error: error.message }),
Expand Down
16 changes: 7 additions & 9 deletions apps/desktop/src/preview/PickPreload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,15 +257,13 @@ function unionRects(
const top = Math.min(...rects.map((rect) => rect.y));
const right = Math.max(...rects.map((rect) => rect.x + rect.width));
const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
const x = Math.max(0, left - padding);
const y = Math.max(0, top - padding);
const maxWidth = Math.max(1, window.innerWidth - x);
const maxHeight = Math.max(1, window.innerHeight - y);
// Keep offscreen bounds intact; main intersects them with the captured
// viewport instead of moving an invisible selection onto unrelated pixels.
return {
x,
y,
width: Math.min(maxWidth, right - left + padding * 2),
height: Math.min(maxHeight, bottom - top + padding * 2),
x: left - padding,
y: top - padding,
width: right - left + padding * 2,
height: bottom - top + padding * 2,
};
}

Expand Down Expand Up @@ -1452,7 +1450,7 @@ function startAnnotation(sendEnabled: boolean): void {
annotation,
screenshotRect,
submission === "send" && !sendEnabled ? "attach" : submission,
// Main crops a full-page capture, whose pixels are CSS px × this.
// Main crops a viewport capture, whose pixels are CSS px × this.
window.devicePixelRatio,
);
})
Expand Down
Loading