mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(grid): prevent blurry canvas rendering under ui scale zoom
This commit is contained in:
@@ -7904,7 +7904,11 @@ const canvasScrollTop = ref(0);
|
||||
const canvasHoverCell = ref<{ rowIndex: number; visibleColIdx: number } | null>(null);
|
||||
const canvasDevicePixelRatio = ref(typeof window === "undefined" ? 1 : window.devicePixelRatio || 1);
|
||||
const canvasMeasuredDevicePixelSize = ref<CanvasDevicePixelSize | null>(null);
|
||||
const canvasBackingPixelRatio = computed(() => Math.min(MAX_CANVAS_DATA_GRID_PIXEL_RATIO, Math.max(1, canvasDevicePixelRatio.value * settingsStore.editorSettings.uiScale)));
|
||||
// window.devicePixelRatio 已包含系统 DPI 与 Webview 缩放(uiScale / setZoom)。
|
||||
// 若在此处再次乘以 uiScale,会导致非 Chromium 环境(如 WebKitGTK / Safari)在未支持
|
||||
// devicePixelContentBoxSize 走 fallback 时,画布尺寸被二次放大(如 1.25^2=1.5625),
|
||||
// 与实际屏幕视口像素尺寸不符而触发合成器重采样,导致表格数据模糊(issue #12475)。
|
||||
const canvasBackingPixelRatio = computed(() => Math.min(MAX_CANVAS_DATA_GRID_PIXEL_RATIO, Math.max(1, canvasDevicePixelRatio.value)));
|
||||
const useCanvasGridRows = computed(() => dataGridRenderMode.value === "canvas");
|
||||
const canvasContentHeight = computed(() => Math.max(1, displayRowCount.value * CANVAS_DATA_GRID_ROW_HEIGHT));
|
||||
// Clamp the sticky canvas/overlay to the content width. A viewport-wide sticky surface inflates the
|
||||
@@ -8067,7 +8071,9 @@ watch(isPanelResizing, (resizing) => {
|
||||
});
|
||||
|
||||
function currentCanvasDevicePixelRatio(): number {
|
||||
return typeof window === "undefined" ? 1 : Math.max(1, window.devicePixelRatio || 1);
|
||||
if (typeof window === "undefined") return 1;
|
||||
const dpr = window.devicePixelRatio;
|
||||
return typeof dpr === "number" && Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
|
||||
}
|
||||
|
||||
function scheduleCanvasPixelRatioRefresh() {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, markRaw, nextTick, shallowRef, type App, type PropType } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
interface CanvasDrawCall {
|
||||
pixelRatio?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const drawCalls: CanvasDrawCall[] = [];
|
||||
|
||||
vi.mock("@/lib/dataGrid/canvasDataGridRenderer", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/dataGrid/canvasDataGridRenderer")>();
|
||||
return {
|
||||
...actual,
|
||||
drawCanvasDataGrid: vi.fn((options: any) => {
|
||||
drawCalls.push({
|
||||
pixelRatio: options.pixelRatio,
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
});
|
||||
return actual.drawCanvasDataGrid(options);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/backend/api")>();
|
||||
return {
|
||||
...actual,
|
||||
buildTableSelectSql: vi.fn(async ({ tableName }: { tableName: string }) => `SELECT * FROM ${tableName}`),
|
||||
loadEditorSettings: vi.fn().mockResolvedValue({}),
|
||||
saveEditorSettings: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("vue-virtual-scroller", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
RecycleScroller: defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<unknown[]>,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
setup(props, { attrs, slots }) {
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
attrs,
|
||||
props.items.map((item) => slots.default?.({ item })),
|
||||
);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/composables/useDataGridColumnResize")>();
|
||||
const { ref } = await import("vue");
|
||||
return {
|
||||
...actual,
|
||||
useDataGridColumnResize: () => ({
|
||||
initColumnWidths: vi.fn(),
|
||||
onResizeStart: vi.fn(),
|
||||
autoFitColumn: vi.fn(),
|
||||
renderedColumnWidths: ref([120]),
|
||||
totalWidth: ref(120),
|
||||
columnVars: ref({ "--total-w": "120px" }),
|
||||
getIsResizing: () => false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import DataGrid from "../DataGrid.vue";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
function mountGrid() {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const settingsStore = useSettingsStore();
|
||||
settingsStore.updateEditorSettings({ dataGridRenderMode: "canvas" });
|
||||
|
||||
const resultRef = shallowRef(
|
||||
markRaw<QueryResult>({
|
||||
columns: ["id", "val"],
|
||||
rows: [[1, "test"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const Root = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
TooltipProvider,
|
||||
{ delayDuration: 0 },
|
||||
{
|
||||
default: () =>
|
||||
h(DataGrid, {
|
||||
result: resultRef.value,
|
||||
databaseType: "postgres",
|
||||
context: "table-data",
|
||||
connectionId: "conn-1",
|
||||
database: "db1",
|
||||
tableMeta: {
|
||||
tableName: "items",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int" },
|
||||
{ name: "val", data_type: "text" },
|
||||
],
|
||||
primaryKeys: ["id"],
|
||||
},
|
||||
onExecuteSql: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
const app = createApp(Root);
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
mountedApps.push({ app, host });
|
||||
return { host, settingsStore };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mountedApps.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
drawCalls.length = 0;
|
||||
});
|
||||
|
||||
describe("DataGrid canvas backing ratio under UI zoom", () => {
|
||||
it("does not multiply devicePixelRatio by uiScale again at 1.25x (prevents blur)", async () => {
|
||||
// Under Webview zoom factor 1.25 on 1x display, window.devicePixelRatio is 1.25
|
||||
Object.defineProperty(window, "devicePixelRatio", { value: 1.25, writable: true, configurable: true });
|
||||
|
||||
const { settingsStore } = mountGrid();
|
||||
settingsStore.updateEditorSettings({ uiScale: 1.25 });
|
||||
window.dispatchEvent(new CustomEvent("dbx:ui-scale-applied", { detail: { scale: 1.25 } }));
|
||||
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(drawCalls.length).toBeGreaterThan(0);
|
||||
const lastDraw = drawCalls[drawCalls.length - 1];
|
||||
// It should be 1.25, NOT 1.25 * 1.25 = 1.5625
|
||||
expect(lastDraw?.pixelRatio).toBe(1.25);
|
||||
});
|
||||
|
||||
it("does not multiply devicePixelRatio by uiScale again at 1.5x zoom (issue #12475)", async () => {
|
||||
// Under Webview zoom factor 1.5, window.devicePixelRatio is 1.5
|
||||
Object.defineProperty(window, "devicePixelRatio", { value: 1.5, writable: true, configurable: true });
|
||||
|
||||
const { settingsStore } = mountGrid();
|
||||
settingsStore.updateEditorSettings({ uiScale: 1.5 });
|
||||
window.dispatchEvent(new CustomEvent("dbx:ui-scale-applied", { detail: { scale: 1.5 } }));
|
||||
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(drawCalls.length).toBeGreaterThan(0);
|
||||
const lastDraw = drawCalls[drawCalls.length - 1];
|
||||
// It should be 1.5, NOT 1.5 * 1.5 = 2.25
|
||||
expect(lastDraw?.pixelRatio).toBe(1.5);
|
||||
});
|
||||
|
||||
it("keeps exact device pixel ratio on high-DPI display with zoom", async () => {
|
||||
// 2x Retina display with 1.25x zoom: window.devicePixelRatio is 2.5
|
||||
Object.defineProperty(window, "devicePixelRatio", { value: 2.5, writable: true, configurable: true });
|
||||
|
||||
const { settingsStore } = mountGrid();
|
||||
settingsStore.updateEditorSettings({ uiScale: 1.25 });
|
||||
window.dispatchEvent(new CustomEvent("dbx:ui-scale-applied", { detail: { scale: 1.25 } }));
|
||||
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(drawCalls.length).toBeGreaterThan(0);
|
||||
const lastDraw = drawCalls[drawCalls.length - 1];
|
||||
// It should be 2.5, NOT 2.5 * 1.25 = 3.125
|
||||
expect(lastDraw?.pixelRatio).toBe(2.5);
|
||||
});
|
||||
});
|
||||
@@ -146,6 +146,39 @@ describe("drawCanvasDataGrid with frozen columns", () => {
|
||||
expect(resolveCanvasBackingStoreMetrics({ width: 801, height: 399, pixelRatio: 5 })).toEqual({ pixelWidth: 3204, pixelHeight: 1596, scaleX: 4, scaleY: 4, measured: false });
|
||||
});
|
||||
|
||||
it("resolves crisp 1:1 backing store metrics under fractional webview zoom when devicePixelSize is not supported", () => {
|
||||
// WebKitGTK on Linux and WKWebView on macOS do not support devicePixelContentBoxSize,
|
||||
// so devicePixelSize is null/undefined. The backing store must match the webview's
|
||||
// devicePixelRatio (which already incorporates uiScale / zoom level) 1:1.
|
||||
const metrics125 = resolveCanvasBackingStoreMetrics({
|
||||
width: 1000,
|
||||
height: 600,
|
||||
pixelRatio: 1.25,
|
||||
devicePixelSize: null,
|
||||
});
|
||||
expect(metrics125).toEqual({
|
||||
pixelWidth: 1250,
|
||||
pixelHeight: 750,
|
||||
scaleX: 1.25,
|
||||
scaleY: 1.25,
|
||||
measured: false,
|
||||
});
|
||||
|
||||
const metrics150 = resolveCanvasBackingStoreMetrics({
|
||||
width: 1000,
|
||||
height: 600,
|
||||
pixelRatio: 1.5,
|
||||
devicePixelSize: null,
|
||||
});
|
||||
expect(metrics150).toEqual({
|
||||
pixelWidth: 1500,
|
||||
pixelHeight: 900,
|
||||
scaleX: 1.5,
|
||||
scaleY: 1.5,
|
||||
measured: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a completed draw only after obtaining a Canvas context", () => {
|
||||
const canvas = createMockCanvas();
|
||||
const options = createBaseOptions({ canvas, rowCount: 0, rowAt: () => undefined });
|
||||
|
||||
Reference in New Issue
Block a user