Skip to content

Commit cec2205

Browse files
authored
Ensure overflow is captured in screenshots, and ensure smooth capture (#8046)
1 parent e662a0f commit cec2205

File tree

2 files changed

+90
-80
lines changed

2 files changed

+90
-80
lines changed

‎frontend/src/utils/__tests__/download.test.tsx‎

Lines changed: 71 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,63 @@ describe("getImageDataUrlForCell", () => {
166166
);
167167
});
168168

169-
it("should restore original overflow style after capture", async () => {
169+
it("should pass style options to prevent clipping", async () => {
170+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
171+
172+
await getImageDataUrlForCell("cell-1" as CellId);
173+
174+
expect(toPng).toHaveBeenCalledWith(
175+
mockElement,
176+
expect.objectContaining({
177+
style: {
178+
maxHeight: "none",
179+
overflow: "visible",
180+
},
181+
}),
182+
);
183+
});
184+
185+
it("should pass scrollHeight as height option", async () => {
186+
// Set up element with scrollHeight
187+
Object.defineProperty(mockElement, "scrollHeight", {
188+
value: 500,
189+
configurable: true,
190+
});
191+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
192+
193+
await getImageDataUrlForCell("cell-1" as CellId);
194+
195+
expect(toPng).toHaveBeenCalledWith(
196+
mockElement,
197+
expect.objectContaining({
198+
height: 500,
199+
}),
200+
);
201+
});
202+
203+
it("should pass scrollbar hiding styles via extraStyleContent", async () => {
204+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
205+
206+
await getImageDataUrlForCell("cell-1" as CellId);
207+
208+
expect(toPng).toHaveBeenCalledWith(
209+
mockElement,
210+
expect.objectContaining({
211+
extraStyleContent: expect.stringContaining("scrollbar-width: none"),
212+
}),
213+
);
214+
});
215+
216+
it("should not modify the live DOM element", async () => {
170217
mockElement.style.overflow = "hidden";
218+
mockElement.style.maxHeight = "100px";
171219
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
172220

173221
await getImageDataUrlForCell("cell-1" as CellId);
174222

223+
// DOM should remain unchanged
175224
expect(mockElement.style.overflow).toBe("hidden");
225+
expect(mockElement.style.maxHeight).toBe("100px");
176226
});
177227

178228
it("should throw error on failure", async () => {
@@ -183,34 +233,20 @@ describe("getImageDataUrlForCell", () => {
183233
);
184234
});
185235

186-
it("should cleanup even on failure", async () => {
187-
mockElement.style.overflow = "scroll";
188-
vi.mocked(toPng).mockRejectedValue(new Error("Capture failed"));
189-
190-
await expect(getImageDataUrlForCell("cell-1" as CellId)).rejects.toThrow();
191-
192-
expect(document.body.classList.contains("printing")).toBe(false);
193-
expect(mockElement.style.overflow).toBe("scroll");
194-
});
195-
196236
it("should handle concurrent captures correctly", async () => {
197237
// Create a second element
198238
const mockElement2 = document.createElement("div");
199239
mockElement2.id = CellOutputId.create("cell-2" as CellId);
200240
document.body.append(mockElement2);
201241

202-
vi.mocked(toPng).mockImplementation(async () => {
203-
// body.printing should not be added during cell captures
204-
expect(document.body.classList.contains("printing")).toBe(false);
205-
return mockDataUrl;
206-
});
242+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
207243

208244
const capture1 = getImageDataUrlForCell("cell-1" as CellId);
209245
const capture2 = getImageDataUrlForCell("cell-2" as CellId);
210246

211247
await Promise.all([capture1, capture2]);
212248

213-
expect(document.body.classList.contains("printing")).toBe(false);
249+
expect(toPng).toHaveBeenCalledTimes(2);
214250

215251
mockElement2.remove();
216252
});
@@ -266,23 +302,6 @@ describe("downloadHTMLAsImage", () => {
266302
expect(mockAnchor.click).toHaveBeenCalled();
267303
});
268304

269-
it("should add body.printing class without prepare function", async () => {
270-
vi.mocked(toPng).mockImplementation(async () => {
271-
expect(document.body.classList.contains("printing")).toBe(true);
272-
return mockDataUrl;
273-
});
274-
275-
await downloadHTMLAsImage({ element: mockElement, filename: "test" });
276-
});
277-
278-
it("should remove body.printing class after download without prepare", async () => {
279-
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
280-
281-
await downloadHTMLAsImage({ element: mockElement, filename: "test" });
282-
283-
expect(document.body.classList.contains("printing")).toBe(false);
284-
});
285-
286305
it("should use prepare function when provided", async () => {
287306
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
288307
const cleanup = vi.fn();
@@ -406,24 +425,32 @@ describe("downloadCellOutputAsImage", () => {
406425
expect(mockAnchor.download).toBe("result.png");
407426
});
408427

409-
it("should apply cell-specific preparation", async () => {
410-
vi.mocked(toPng).mockImplementation(async () => {
411-
// Check that cell-specific classes are applied
412-
expect(mockElement.style.overflow).toBe("visible");
413-
return mockDataUrl;
414-
});
428+
it("should pass style options to toPng for full content capture", async () => {
429+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
415430

416431
await downloadCellOutputAsImage("cell-1" as CellId, "result");
432+
433+
expect(toPng).toHaveBeenCalledWith(
434+
mockElement,
435+
expect.objectContaining({
436+
style: {
437+
maxHeight: "none",
438+
overflow: "visible",
439+
},
440+
}),
441+
);
417442
});
418443

419-
it("should cleanup after download", async () => {
420-
mockElement.style.overflow = "visible";
444+
it("should not modify the live DOM element", async () => {
445+
mockElement.style.overflow = "hidden";
446+
mockElement.style.maxHeight = "100px";
421447
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
422448

423449
await downloadCellOutputAsImage("cell-1" as CellId, "result");
424450

425-
expect(document.body.classList.contains("printing")).toBe(false);
426-
expect(mockElement.style.overflow).toBe("visible");
451+
// DOM should remain unchanged
452+
expect(mockElement.style.overflow).toBe("hidden");
453+
expect(mockElement.style.maxHeight).toBe("100px");
427454
});
428455
});
429456

‎frontend/src/utils/download.ts‎

Lines changed: 19 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -46,24 +46,6 @@ function findElementForCell(cellId: CellId): HTMLElement | undefined {
4646
return element;
4747
}
4848

49-
/**
50-
* Prepare a cell element for screenshot capture.
51-
*
52-
* @param element - The cell output element to prepare
53-
* @returns A cleanup function to restore the element's original state
54-
*/
55-
function prepareCellElementForScreenshot(element: HTMLElement) {
56-
const originalOverflow = element.style.overflow;
57-
const maxHeight = element.style.maxHeight;
58-
element.style.overflow = "visible";
59-
element.style.maxHeight = "none";
60-
61-
return () => {
62-
element.style.overflow = originalOverflow;
63-
element.style.maxHeight = maxHeight;
64-
};
65-
}
66-
6749
const THRESHOLD_TIME_MS = 500;
6850
const HIDE_SCROLLBAR_STYLES = `
6951
* { scrollbar-width: none; -ms-overflow-style: none; }
@@ -89,25 +71,26 @@ export async function getImageDataUrlForCell(
8971
return iframeDataUrl;
9072
}
9173

92-
const cleanup = prepareCellElementForScreenshot(element);
93-
94-
try {
95-
const startTime = Date.now();
96-
const dataUrl = await toPng(element, {
97-
extraStyleContent: HIDE_SCROLLBAR_STYLES,
98-
});
99-
const timeTaken = Date.now() - startTime;
100-
if (timeTaken > THRESHOLD_TIME_MS) {
101-
Logger.debug(
102-
"toPng operation for element",
103-
element,
104-
`took ${timeTaken} ms (exceeds threshold)`,
105-
);
106-
}
107-
return dataUrl;
108-
} finally {
109-
cleanup();
74+
const startTime = Date.now();
75+
const dataUrl = await toPng(element, {
76+
extraStyleContent: HIDE_SCROLLBAR_STYLES,
77+
// Add these styles so the element output is not clipped
78+
// Width can be clipped since pdf has limited width
79+
style: {
80+
maxHeight: "none",
81+
overflow: "visible",
82+
},
83+
height: element.scrollHeight,
84+
});
85+
const timeTaken = Date.now() - startTime;
86+
if (timeTaken > THRESHOLD_TIME_MS) {
87+
Logger.debug(
88+
"toPng operation for element",
89+
element,
90+
`took ${timeTaken} ms (exceeds threshold)`,
91+
);
11092
}
93+
return dataUrl;
11194
}
11295

11396
/**

0 commit comments

Comments
 (0)