diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 3a823cb952..9675e33033 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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(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() { diff --git a/apps/desktop/src/components/grid/__tests__/DataGridCanvasZoom.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridCanvasZoom.spec.ts new file mode 100644 index 0000000000..855d41f285 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridCanvasZoom.spec.ts @@ -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(); + 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(); + 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, + 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(); + 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({ + 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); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts index fd607273e8..656750332c 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts @@ -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 });