From 64ce119aa4a08d997ce6d3d75aebc9236c93e1af Mon Sep 17 00:00:00 2001
From: lxk955 <164191365+lxk955@users.noreply.github.com>
Date: Sun, 18 Oct 2026 00:44:56 +0800
Subject: [PATCH] fix(grid): prevent clipping last row in grid snapshot export
---
.../__tests__/codeSnapshot.spec.ts | 42 +++++++++++++++++++
.../src/lib/codeSnapshot/codeSnapshot.ts | 41 ++++++++++++++++--
.../__tests__/gridSnapshot.spec.ts | 16 +++++++
.../src/lib/gridSnapshot/gridSnapshot.ts | 25 ++++++-----
4 files changed, 109 insertions(+), 15 deletions(-)
diff --git a/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts b/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts
index 2c581fc58d..f6ec8c10a0 100644
--- a/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts
+++ b/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts
@@ -329,4 +329,46 @@ describe("renderCodeSnapshotHtml", () => {
await expect(savePngDataUrlToFile("data:image/png;base64,AA==", "snapshot.png")).rejects.toThrow("disk full");
expect(writeFile).toHaveBeenCalledOnce();
});
+
+ it("encompasses the last row bottom edge and relaxes clone height for grid snapshots", async () => {
+ toPng.mockResolvedValue("data:image/png;base64,test");
+ const container = document.createElement("div");
+ container.className = "dbx-grid-snapshot";
+ container.innerHTML = "
";
+ document.body.appendChild(container);
+
+ // Mock layout measurements
+ Object.defineProperty(container, "offsetWidth", { value: 400 });
+ Object.defineProperty(container, "offsetHeight", { value: 300 });
+ Object.defineProperty(container, "scrollWidth", { value: 400 });
+ Object.defineProperty(container, "scrollHeight", { value: 298 });
+ container.getBoundingClientRect = () => ({ top: 10, bottom: 310, left: 0, right: 400, width: 400, height: 300 }) as DOMRect;
+
+ const lastRow = container.querySelector("tr:last-child") as HTMLTableRowElement;
+ lastRow.getBoundingClientRect = () => ({ top: 280, bottom: 318, left: 0, right: 400, width: 400, height: 38 }) as DOMRect;
+
+ try {
+ await snapshotElementToPng(container);
+
+ // Height should encompass lastRow.bottom (318) - container.top (10) = 308
+ expect(toPng).toHaveBeenLastCalledWith(
+ container,
+ expect.objectContaining({
+ width: 400,
+ height: 308,
+ }),
+ );
+
+ const options = toPng.mock.calls.at(-1)?.[1];
+ expect(typeof options?.onclone).toBe("function");
+
+ const clone = container.cloneNode(true) as HTMLElement;
+ clone.style.height = "308px";
+ options.onclone(clone);
+ expect(clone.style.height).toBe("auto");
+ expect(clone.style.minHeight).toBe("308px");
+ } finally {
+ container.remove();
+ }
+ });
});
diff --git a/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts b/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts
index d9a0b64475..23034decb3 100644
--- a/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts
+++ b/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts
@@ -343,8 +343,34 @@ export async function renderCodeSnapshotHtml(source: CodeSnapshotSource, options
export async function snapshotElementToPng(element: HTMLElement): Promise {
const domtoimage = (await import("dom-to-image-more")).default;
// 预览区允许滚动,因此导出尺寸必须覆盖完整内容,而不是只取当前可见宽度。
- const width = Math.max(element.offsetWidth, element.scrollWidth);
- const height = Math.max(element.offsetHeight, element.scrollHeight);
+ const rect = typeof element.getBoundingClientRect === "function" ? element.getBoundingClientRect() : null;
+ let borderTop = 0;
+ let borderBottom = 0;
+ if (typeof window !== "undefined" && typeof window.getComputedStyle === "function" && element instanceof (window.Element || Object)) {
+ try {
+ const computedStyle = window.getComputedStyle(element);
+ borderTop = parseFloat(computedStyle.borderTopWidth) || 0;
+ borderBottom = parseFloat(computedStyle.borderBottomWidth) || 0;
+ } catch {
+ // Ignored for non-DOM elements in test runners
+ }
+ }
+
+ let width = Math.max(element.offsetWidth || 0, element.scrollWidth || 0, rect ? Math.ceil(rect.width) : 0);
+ let height = Math.max(element.offsetHeight || 0, element.scrollHeight || 0, (element.scrollHeight || 0) + Math.ceil(borderTop + borderBottom), rect ? Math.ceil(rect.height) : 0);
+
+ if (rect && typeof element.querySelector === "function") {
+ // If element contains table rows, ensure height fully encloses the bottom edge of the last row
+ const lastRow = element.querySelector("tr:last-child");
+ if (lastRow && typeof lastRow.getBoundingClientRect === "function") {
+ const rowRect = lastRow.getBoundingClientRect();
+ const requiredHeight = Math.ceil(rowRect.bottom - rect.top) + Math.ceil(borderBottom);
+ if (requiredHeight > height) {
+ height = requiredHeight;
+ }
+ }
+ }
+
const basePixels = width * height;
const targetScale = Math.min(MAX_EXPORT_PIXEL_RATIO, Math.max(1, typeof window === "undefined" ? 1 : window.devicePixelRatio || 1));
// Keep the output canvas within browser limits without rejecting a larger
@@ -352,6 +378,8 @@ export async function snapshotElementToPng(element: HTMLElement): Promise {
- if (typeof (clone as ParentNode).querySelectorAll === "function") {
+ if (useLegacyLineNumberFallback && typeof (clone as ParentNode).querySelectorAll === "function") {
materializeSnapshotCloneLineNumbers(clone as ParentNode);
}
+ if (isGridSnapshot && clone && (clone as HTMLElement).style) {
+ // Prevent rigid fixed height from clipping table rows inside overflow:hidden
+ (clone as HTMLElement).style.height = "auto";
+ (clone as HTMLElement).style.minHeight = `${height}px`;
+ }
}
: undefined,
style: {
diff --git a/apps/desktop/src/lib/gridSnapshot/__tests__/gridSnapshot.spec.ts b/apps/desktop/src/lib/gridSnapshot/__tests__/gridSnapshot.spec.ts
index f5ddda1919..476c139c2c 100644
--- a/apps/desktop/src/lib/gridSnapshot/__tests__/gridSnapshot.spec.ts
+++ b/apps/desktop/src/lib/gridSnapshot/__tests__/gridSnapshot.spec.ts
@@ -126,4 +126,20 @@ describe("grid snapshot rendering", () => {
const unstripedHtml = renderGridSnapshotHtml(source, { appearance: "light", stripedRows: false });
expect(unstripedHtml).not.toContain("background:rgb(240, 240, 240)");
});
+
+ it("defines explicit line-height and safe bottom padding on the last row to prevent clipping", () => {
+ const source = {
+ columns: ["id", "val"],
+ rows: [
+ [1, "first"],
+ [2, "second"],
+ ],
+ };
+ const standardHtml = renderGridSnapshotHtml(source, { appearance: "light" });
+ expect(standardHtml).toContain("line-height: 1.4");
+ expect(standardHtml).toContain("padding:4px 12px 6px");
+
+ const compactHtml = renderGridSnapshotHtml(source, { appearance: "light", compact: true });
+ expect(compactHtml).toContain("padding:3px 8px 5px");
+ });
});
diff --git a/apps/desktop/src/lib/gridSnapshot/gridSnapshot.ts b/apps/desktop/src/lib/gridSnapshot/gridSnapshot.ts
index 8ad1cb4801..83863ee737 100644
--- a/apps/desktop/src/lib/gridSnapshot/gridSnapshot.ts
+++ b/apps/desktop/src/lib/gridSnapshot/gridSnapshot.ts
@@ -90,14 +90,15 @@ export const GRID_SNAPSHOT_CSS = `
border-radius: 8px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.4;
text-align: left;
box-shadow: 0 8px 24px rgba(0, 0, 0, .35);
}
.dbx-grid-snapshot__bar { display: flex; width: 100%; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--dbx-grid-snapshot-border); }
.dbx-grid-snapshot__dot { width: 12px; height: 12px; border-radius: 50%; flex: none; }
.dbx-grid-snapshot__title { margin-left: 4px; font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.dbx-grid-snapshot__table { border-collapse: separate; border-spacing: 0; table-layout: auto; width: 100%; }
-.dbx-grid-snapshot__cell { border-right: 1px solid var(--dbx-grid-snapshot-border); border-bottom: 1px solid var(--dbx-grid-snapshot-border); padding: 7px 10px; white-space: nowrap; vertical-align: top; }
+.dbx-grid-snapshot__table { border-collapse: separate; border-spacing: 0; table-layout: auto; width: 100%; line-height: 1.4; }
+.dbx-grid-snapshot__cell { border-right: 1px solid var(--dbx-grid-snapshot-border); border-bottom: 1px solid var(--dbx-grid-snapshot-border); padding: 7px 10px; white-space: nowrap; vertical-align: top; line-height: 1.4; }
.dbx-grid-snapshot__cell--wrapped { max-width: 420px; white-space: pre-wrap; overflow-wrap: anywhere; }
.dbx-grid-snapshot__cell:last-child { border-right: 0; }
.dbx-grid-snapshot__table tbody tr:last-child .dbx-grid-snapshot__cell { border-bottom: 0; }
@@ -136,6 +137,7 @@ export function renderGridSnapshotHtml(source: GridSnapshotSource, options: Grid
const wrapCells = options.wrapCells === true;
const compact = options.compact === true;
const cellPadding = compact ? "3px 8px" : "4px 12px";
+ const lastRowCellPadding = compact ? "3px 8px 5px" : "4px 12px 6px";
const headerPadding = compact ? "4px 8px" : "6px 8px";
const fontSize = compact ? "12px" : "13px";
const rowNumberHeader = showRowNumbers ? `` : "";
@@ -151,15 +153,16 @@ export function renderGridSnapshotHtml(source: GridSnapshotSource, options: Grid
: "";
const displayRows: GridSnapshotCellValue[][] = transpose ? source.columns.map((_, columnIndex) => source.rows.map((row) => row[columnIndex] ?? null)) : source.rows;
const body = displayRows
- .map(
- (row, rowIndex) =>
- `${showRowNumbers ? `| ${rowIndex + 1} | ` : ""}${transpose && showFieldNames ? renderFieldCell(source.columns[rowIndex] ?? "", rowIndex, "td") : ""}${row
- .map((value) => {
- const nullClass = value === null ? " dbx-grid-snapshot__cell--null" : "";
- return `${escapeHtml(formatGridSnapshotCell(value))} | `;
- })
- .join("")}
`,
- )
+ .map((row, rowIndex) => {
+ const isLastRow = rowIndex === displayRows.length - 1;
+ const rowPadding = isLastRow ? lastRowCellPadding : cellPadding;
+ return `${showRowNumbers ? `| ${rowIndex + 1} | ` : ""}${transpose && showFieldNames ? renderFieldCell(source.columns[rowIndex] ?? "", rowIndex, "td") : ""}${row
+ .map((value) => {
+ const nullClass = value === null ? " dbx-grid-snapshot__cell--null" : "";
+ return `${escapeHtml(formatGridSnapshotCell(value))} | `;
+ })
+ .join("")}
`;
+ })
.join("");
const bar = options.showTrafficLights !== false || source.title ? renderSnapshotBar(source.title, appearance, options.showTrafficLights !== false) : "";
return ``;