fix(grid): prevent clipping last row in grid snapshot export

This commit is contained in:
lxk955
2026-10-18 00:44:56 +08:00
committed by GitHub
parent 387d215c81
commit 64ce119aa4
4 changed files with 109 additions and 15 deletions
@@ -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 = "<table><tbody><tr><td>row1</td></tr><tr><td>row2</td></tr></tbody></table>";
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();
}
});
});
@@ -343,8 +343,34 @@ export async function renderCodeSnapshotHtml(source: CodeSnapshotSource, options
export async function snapshotElementToPng(element: HTMLElement): Promise<string> {
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<string
// full DOM at a reduced scale.
const scale = Math.min(targetScale, MAX_EXPORT_CANVAS_EDGE / width, MAX_EXPORT_CANVAS_EDGE / height, Math.sqrt(MAX_EXPORT_CANVAS_PIXELS / basePixels));
const useLegacyLineNumberFallback = legacyWebViewExportFallbackEnabled();
const isGridSnapshot = typeof element.classList?.contains === "function" && element.classList.contains("dbx-grid-snapshot");
const hasOnclone = useLegacyLineNumberFallback || isGridSnapshot;
return domtoimage.toPng(element, {
quality: 1,
width,
@@ -366,11 +394,16 @@ export async function snapshotElementToPng(element: HTMLElement): Promise<string
return undefined;
}
: undefined,
onclone: useLegacyLineNumberFallback
onclone: hasOnclone
? (clone: Node) => {
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: {
@@ -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");
});
});
@@ -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 ? `<th class="dbx-grid-snapshot__cell dbx-grid-snapshot__cell--header dbx-grid-snapshot__cell--row-number" style="background:${GRID_SNAPSHOT_HEADER[appearance]};color:${GRID_SNAPSHOT_MUTED[appearance]};padding:${headerPadding}">#</th>` : "";
@@ -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) =>
`<tr style="background:${options.stripedRows !== false && rowIndex % 2 === 1 ? GRID_SNAPSHOT_STRIPED_ROW[appearance] : GRID_SNAPSHOT_BACKGROUND[appearance]}">${showRowNumbers ? `<td class="dbx-grid-snapshot__cell dbx-grid-snapshot__cell--row-number" style="background:${GRID_SNAPSHOT_ROW_NUMBER[appearance]};color:${GRID_SNAPSHOT_MUTED[appearance]};padding:${cellPadding}">${rowIndex + 1}</td>` : ""}${transpose && showFieldNames ? renderFieldCell(source.columns[rowIndex] ?? "", rowIndex, "td") : ""}${row
.map((value) => {
const nullClass = value === null ? " dbx-grid-snapshot__cell--null" : "";
return `<td class="dbx-grid-snapshot__cell${wrapCells ? " dbx-grid-snapshot__cell--wrapped" : ""}${nullClass}" style="color:${value === null ? GRID_SNAPSHOT_MUTED[appearance] : GRID_SNAPSHOT_TEXT[appearance]};padding:${cellPadding}">${escapeHtml(formatGridSnapshotCell(value))}</td>`;
})
.join("")}</tr>`,
)
.map((row, rowIndex) => {
const isLastRow = rowIndex === displayRows.length - 1;
const rowPadding = isLastRow ? lastRowCellPadding : cellPadding;
return `<tr style="background:${options.stripedRows !== false && rowIndex % 2 === 1 ? GRID_SNAPSHOT_STRIPED_ROW[appearance] : GRID_SNAPSHOT_BACKGROUND[appearance]}">${showRowNumbers ? `<td class="dbx-grid-snapshot__cell dbx-grid-snapshot__cell--row-number" style="background:${GRID_SNAPSHOT_ROW_NUMBER[appearance]};color:${GRID_SNAPSHOT_MUTED[appearance]};padding:${rowPadding}">${rowIndex + 1}</td>` : ""}${transpose && showFieldNames ? renderFieldCell(source.columns[rowIndex] ?? "", rowIndex, "td") : ""}${row
.map((value) => {
const nullClass = value === null ? " dbx-grid-snapshot__cell--null" : "";
return `<td class="dbx-grid-snapshot__cell${wrapCells ? " dbx-grid-snapshot__cell--wrapped" : ""}${nullClass}" style="color:${value === null ? GRID_SNAPSHOT_MUTED[appearance] : GRID_SNAPSHOT_TEXT[appearance]};padding:${rowPadding}">${escapeHtml(formatGridSnapshotCell(value))}</td>`;
})
.join("")}</tr>`;
})
.join("");
const bar = options.showTrafficLights !== false || source.title ? renderSnapshotBar(source.title, appearance, options.showTrafficLights !== false) : "";
return `<style>${GRID_SNAPSHOT_CSS}</style><div class="dbx-grid-snapshot" data-snapshot-appearance="${appearance}" style="background:${GRID_SNAPSHOT_BACKGROUND[appearance]};font-size:${fontSize};color:${GRID_SNAPSHOT_TEXT[appearance]};--dbx-grid-snapshot-border:${GRID_SNAPSHOT_BORDER[appearance]}">${bar}<table class="dbx-grid-snapshot__table">${header}<tbody>${body}</tbody></table></div>`;