fix(grid): sharpen canvas text at fractional scaling

This commit is contained in:
TangT 2026-08-05 02:59:34 +08:00 committed by GitHub
parent eed0a26b6f
commit 16369acbda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 163 additions and 29 deletions

View File

@ -142,7 +142,7 @@ import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
import { CANVAS_DATA_GRID_ROW_HEIGHT, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer";
import { CANVAS_DATA_GRID_ROW_HEIGHT, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid, type CanvasDevicePixelSize } from "@/lib/dataGrid/canvasDataGridRenderer";
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme";
import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText";
import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi";
@ -194,7 +194,7 @@ import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { DATA_GRID_ROW_NUM_WIDTH, dataGridRowNumberColumnWidth, resolveDataGridMaxRowNumber, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
import { useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime";
import { dataGridCanvasDevicePixelSize, useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime";
import { useDataGridScrollbars, type DataGridScrollbarsRuntime } from "@/composables/useDataGridScrollbars";
import { useDataGridSelection } from "@/composables/useDataGridSelection";
import { moveDataGridCell } from "@/lib/dataGrid/dataGridNavigation";
@ -4761,6 +4761,7 @@ const canvasViewportHeight = ref(0);
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(4, Math.max(1, canvasDevicePixelRatio.value * settingsStore.editorSettings.uiScale)));
const useCanvasGridRows = computed(() => dataGridRenderMode.value === "canvas");
const canvasContentHeight = computed(() => Math.max(1, displayRowCount.value * CANVAS_DATA_GRID_ROW_HEIGHT));
@ -4850,10 +4851,13 @@ function dataGridRowFromClientPoint(_clientX: number, clientY: number): number |
return Number.isInteger(rowIndex) ? rowIndex : null;
}
function syncCanvasViewport() {
function syncCanvasViewport(entries?: readonly ResizeObserverEntry[]) {
if (!dataGridIsActive) return;
const scroller = canvasScrollerElement();
if (!scroller) return;
const canvas = canvasRef.value;
const canvasEntry = canvas ? entries?.find((entry) => entry.target === canvas) : undefined;
if (canvasEntry) canvasMeasuredDevicePixelSize.value = dataGridCanvasDevicePixelSize(canvasEntry);
canvasViewportWidth.value = scroller.clientWidth;
canvasViewportHeight.value = scroller.clientHeight;
canvasScrollTop.value = scroller.scrollTop;
@ -4914,6 +4918,7 @@ canvasRuntime = useDataGridCanvasRuntime({
draw: drawCanvasGrid,
syncViewport: syncCanvasViewport,
getViewport: canvasScrollerElement,
getSurface: () => canvasRef.value ?? null,
refreshPixelRatio: () => {
const next = currentCanvasDevicePixelRatio();
if (Math.abs(next - canvasDevicePixelRatio.value) > 0.001) {
@ -5343,6 +5348,7 @@ function drawCanvasGrid() {
width: Math.max(1, canvasSurfaceWidth.value || scroller.clientWidth),
height: Math.max(1, canvasViewportHeight.value || scroller.clientHeight),
pixelRatio: canvasBackingPixelRatio.value,
devicePixelSize: canvasMeasuredDevicePixelSize.value,
isDark: isDark.value,
styleKey: canvasRenderStyleKey.value,
rowCount: displayRowCount.value,

View File

@ -1,6 +1,6 @@
import { effectScope } from "vue";
import { describe, expect, it, vi } from "vitest";
import { useDataGridCanvasRuntime, type DataGridAnimationFrameDriver } from "@/composables/useDataGridCanvasRuntime";
import { dataGridCanvasDevicePixelSize, useDataGridCanvasRuntime, type DataGridAnimationFrameDriver } from "@/composables/useDataGridCanvasRuntime";
import { useDataGridScrollbars } from "@/composables/useDataGridScrollbars";
function createFrameDriver() {
@ -26,6 +26,34 @@ function createObserver() {
}
describe("data grid visual runtimes", () => {
it("reads the exact device-pixel content box reported for a canvas", () => {
const size = dataGridCanvasDevicePixelSize({
contentRect: { width: 801, height: 399 },
devicePixelContentBoxSize: [{ inlineSize: 1001, blockSize: 499 }],
} as unknown as ResizeObserverEntry);
expect(size).toEqual({ cssWidth: 801, cssHeight: 399, pixelWidth: 1001, pixelHeight: 499 });
});
it("observes the canvas device-pixel box together with its viewport", () => {
const resize = createObserver();
const viewport = { children: [] } as unknown as Element;
const surface = { children: [] } as unknown as Element;
const runtime = useDataGridCanvasRuntime({
draw: vi.fn(),
syncViewport: vi.fn(),
getViewport: () => viewport,
getSurface: () => surface,
createResizeObserver: () => resize.observer,
});
runtime.observeViewport();
expect(resize.observe).toHaveBeenNthCalledWith(1, viewport);
expect(resize.observe).toHaveBeenNthCalledWith(2, surface, { box: "device-pixel-content-box" });
runtime.dispose();
});
it("coalesces canvas frames and cancels all resources on dispose", () => {
const frames = createFrameDriver();
const resize = createObserver();

View File

@ -1,4 +1,5 @@
import { getCurrentScope, onScopeDispose } from "vue";
import type { CanvasDevicePixelSize } from "@/lib/dataGrid/canvasDataGridRenderer";
export interface DataGridAnimationFrameDriver {
request(callback: FrameRequestCallback): number;
@ -7,8 +8,9 @@ export interface DataGridAnimationFrameDriver {
export interface DataGridCanvasRuntimeOptions {
draw(): void;
syncViewport(): void;
syncViewport(entries?: readonly ResizeObserverEntry[]): void;
getViewport(): Element | null;
getSurface?: () => Element | null;
refreshPixelRatio?: () => void;
frameDriver?: DataGridAnimationFrameDriver;
createResizeObserver?: (callback: ResizeObserverCallback) => ResizeObserver | null;
@ -27,6 +29,18 @@ export interface DataGridCanvasRuntime {
dispose(): void;
}
export function dataGridCanvasDevicePixelSize(entry: ResizeObserverEntry): CanvasDevicePixelSize | null {
const boxes = entry.devicePixelContentBoxSize;
const box = Array.isArray(boxes) ? boxes[0] : (boxes as unknown as ResizeObserverSize | undefined);
if (!box || !Number.isFinite(box.inlineSize) || !Number.isFinite(box.blockSize) || box.inlineSize <= 0 || box.blockSize <= 0) return null;
return {
cssWidth: entry.contentRect.width,
cssHeight: entry.contentRect.height,
pixelWidth: Math.round(box.inlineSize),
pixelHeight: Math.round(box.blockSize),
};
}
function defaultFrameDriver(): DataGridAnimationFrameDriver {
return {
request: (callback) => requestAnimationFrame(callback),
@ -88,10 +102,18 @@ export function useDataGridCanvasRuntime(options: DataGridCanvasRuntimeOptions):
options.syncViewport();
const viewport = options.getViewport();
if (!viewport) return;
resizeObserver = createResizeObserver(() => {
if (active && !disposed) options.syncViewport();
resizeObserver = createResizeObserver((entries) => {
if (active && !disposed) options.syncViewport(entries);
});
resizeObserver?.observe(viewport);
const surface = options.getSurface?.();
if (resizeObserver && surface && surface !== viewport) {
try {
resizeObserver.observe(surface, { box: "device-pixel-content-box" });
} catch {
resizeObserver.observe(surface);
}
}
},
pause() {
active = false;

View File

@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CANVAS_DATA_GRID_ROW_HEIGHT, drawCanvasDataGrid, type DrawCanvasDataGridOptions } from "@/lib/dataGrid/canvasDataGridRenderer";
import { drawCanvasDataGrid, resolveCanvasBackingStoreMetrics, type DrawCanvasDataGridOptions } from "@/lib/dataGrid/canvasDataGridRenderer";
function createMockCanvas(width = 800, height = 400) {
const canvas = document.createElement("canvas");
@ -80,6 +80,45 @@ describe("drawCanvasDataGrid with frozen columns", () => {
} as CSSStyleDeclaration);
});
it("uses the exact observed device-pixel size instead of a nominal DPR", () => {
expect(
resolveCanvasBackingStoreMetrics({
width: 801,
height: 399,
pixelRatio: 1.25,
devicePixelSize: { cssWidth: 801, cssHeight: 399, pixelWidth: 1001, pixelHeight: 499 },
}),
).toEqual({
pixelWidth: 1001,
pixelHeight: 499,
scaleX: 1001 / 801,
scaleY: 499 / 399,
measured: true,
});
});
it("ignores a stale observed size after the CSS viewport changes", () => {
expect(
resolveCanvasBackingStoreMetrics({
width: 900,
height: 400,
pixelRatio: 1.25,
devicePixelSize: { cssWidth: 801, cssHeight: 399, pixelWidth: 1001, pixelHeight: 499 },
}),
).toEqual({ pixelWidth: 1125, pixelHeight: 500, scaleX: 1.25, scaleY: 1.25, measured: false });
});
it("caps the observed backing store at the configured pixel ratio", () => {
expect(
resolveCanvasBackingStoreMetrics({
width: 801,
height: 399,
pixelRatio: 4,
devicePixelSize: { cssWidth: 801, cssHeight: 399, pixelWidth: 4005, pixelHeight: 1995 },
}),
).toEqual({ pixelWidth: 3204, pixelHeight: 1596, scaleX: 4, scaleY: 4, measured: true });
});
it("draws without errors when frozenColumnCount is 0", () => {
const canvas = createMockCanvas();
const options = createBaseOptions({ canvas, frozenColumnCount: 0 });

View File

@ -5,6 +5,13 @@ import { BOOLEAN_CHECKBOX_SIZE, isBooleanCheckboxValue, normalizeBooleanCellValu
export const CANVAS_DATA_GRID_ROW_HEIGHT = 26;
export interface CanvasDevicePixelSize {
cssWidth: number;
cssHeight: number;
pixelWidth: number;
pixelHeight: number;
}
export interface CanvasDataGridRow {
id: number;
displayIndex: number;
@ -49,6 +56,7 @@ export interface DrawCanvasDataGridOptions {
width: number;
height: number;
pixelRatio?: number;
devicePixelSize?: CanvasDevicePixelSize | null;
isDark: boolean;
styleKey?: string;
rowCount: number;
@ -94,6 +102,33 @@ interface CanvasRenderState {
currentSearchBorder: string;
}
export interface CanvasBackingStoreMetrics {
pixelWidth: number;
pixelHeight: number;
scaleX: number;
scaleY: number;
measured: boolean;
}
export function resolveCanvasBackingStoreMetrics(options: { width: number; height: number; pixelRatio: number; devicePixelSize?: CanvasDevicePixelSize | null }): CanvasBackingStoreMetrics {
const width = Math.max(1, options.width);
const height = Math.max(1, options.height);
const fallbackRatio = Math.max(1, options.pixelRatio);
const measured = options.devicePixelSize;
const measurementMatches = !!measured && Math.abs(measured.cssWidth - width) <= 0.5 && Math.abs(measured.cssHeight - height) <= 0.5 && measured.pixelWidth > 0 && measured.pixelHeight > 0;
const fallbackPixelWidth = Math.max(1, Math.ceil(width * fallbackRatio));
const fallbackPixelHeight = Math.max(1, Math.ceil(height * fallbackRatio));
const pixelWidth = measurementMatches ? Math.min(measured.pixelWidth, fallbackPixelWidth) : fallbackPixelWidth;
const pixelHeight = measurementMatches ? Math.min(measured.pixelHeight, fallbackPixelHeight) : fallbackPixelHeight;
return {
pixelWidth,
pixelHeight,
scaleX: pixelWidth / width,
scaleY: pixelHeight / height,
measured: measurementMatches,
};
}
export function resolveCanvasDataGridRowFill(theme: Pick<DataGridPaintTheme, "cellActive" | "cellSelected">, rowBase: string, options: { isActive: boolean; isDeleted: boolean; isSelected: boolean }): string {
if (options.isSelected) return theme.cellSelected;
if (options.isActive && !options.isDeleted) return theme.cellActive;
@ -193,11 +228,11 @@ function alignCanvasPixel(value: number, dpr: number): number {
return Math.round(value * dpr) / dpr;
}
function drawBooleanCheckbox(ctx: CanvasRenderingContext2D, options: { drawX: number; y: number; colWidth: number; dpr: number; theme: DataGridPaintTheme; checked: boolean }): void {
const { drawX, y, colWidth, dpr, theme, checked } = options;
function drawBooleanCheckbox(ctx: CanvasRenderingContext2D, options: { drawX: number; y: number; colWidth: number; scaleX: number; scaleY: number; theme: DataGridPaintTheme; checked: boolean }): void {
const { drawX, y, colWidth, scaleX, scaleY, theme, checked } = options;
const size = BOOLEAN_CHECKBOX_SIZE;
const boxX = alignCanvasPixel(drawX + (colWidth - size) / 2, dpr);
const boxY = alignCanvasPixel(y + (CANVAS_DATA_GRID_ROW_HEIGHT - size) / 2, dpr);
const boxX = alignCanvasPixel(drawX + (colWidth - size) / 2, scaleX);
const boxY = alignCanvasPixel(y + (CANVAS_DATA_GRID_ROW_HEIGHT - size) / 2, scaleY);
ctx.lineWidth = 1;
if (checked) {
ctx.fillStyle = theme.primary;
@ -302,9 +337,13 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
rightAlignedActionCell,
columnIsBoolean,
} = options;
const dpr = Math.max(1, options.pixelRatio ?? window.devicePixelRatio ?? 1);
const pixelWidth = Math.max(1, Math.ceil(width * dpr));
const pixelHeight = Math.max(1, Math.ceil(height * dpr));
const fallbackRatio = Math.max(1, options.pixelRatio ?? window.devicePixelRatio ?? 1);
const { pixelWidth, pixelHeight, scaleX, scaleY } = resolveCanvasBackingStoreMetrics({
width,
height,
pixelRatio: fallbackRatio,
devicePixelSize: options.devicePixelSize,
});
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
@ -316,7 +355,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, width, height);
@ -343,9 +382,9 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
const firstCol = firstVisibleColumn(offsets, Math.max(0, contentStart - maxPreviewRightShift));
const columnOffset = offsets[firstCol] ?? 0;
const paintSearchMatches = !isScrolling && searchMatchKeys.size > 0;
const rowNumberBorderX = crispCanvasLine(rowNumberWidth - 1, dpr);
const rowNumberTextX = alignCanvasPixel(Math.max(0, rowNumberWidth - 1) / 2, dpr);
const rowTextOffsetY = alignCanvasPixel(CANVAS_DATA_GRID_ROW_HEIGHT / 2, dpr);
const rowNumberBorderX = crispCanvasLine(rowNumberWidth - 1, scaleX);
const rowNumberTextX = alignCanvasPixel(Math.max(0, rowNumberWidth - 1) / 2, scaleX);
const rowTextOffsetY = alignCanvasPixel(CANVAS_DATA_GRID_ROW_HEIGHT / 2, scaleY);
for (let rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) {
const item = rowAt(rowIndex);
@ -360,7 +399,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
isDeleted: item.isDeleted,
isSelected: rowSelectionVisual,
});
const rowBorderY = crispCanvasLine(y + CANVAS_DATA_GRID_ROW_HEIGHT - 1, dpr);
const rowBorderY = crispCanvasLine(y + CANVAS_DATA_GRID_ROW_HEIGHT - 1, scaleY);
ctx.globalAlpha = item.isDeleted ? 0.7 : 1;
ctx.fillStyle = rowFill;
ctx.fillRect(0, y, width, CANVAS_DATA_GRID_ROW_HEIGHT);
@ -390,7 +429,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
ctx.fillStyle = rowNumberText;
ctx.font = item.status === "new" || item.status === "edited" || item.status === "draft" ? semiboldFont : normalFont;
ctx.textAlign = "center";
const textY = alignCanvasPixel(y + rowTextOffsetY, dpr);
const textY = alignCanvasPixel(y + rowTextOffsetY, scaleY);
ctx.save();
ctx.beginPath();
ctx.rect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
@ -472,24 +511,24 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
ctx.font = value === null ? italicFont : tabularFont;
setCanvasNumericVariant(ctx, value === null ? "normal" : "tabular-nums");
const reservedWidth = rightAlignedActionCell?.rowIndex === item.displayIndex && rightAlignedActionCell.visibleColIdx === visibleColIdx ? rightAlignedActionCell.reservedWidth : 0;
const { textAnchorX, maxWidth: cellMaxWidth } = resolveCanvasCellTextLayout({ drawX, colWidth, dpr, isRightAlign, reservedWidth });
const { textAnchorX, maxWidth: cellMaxWidth } = resolveCanvasCellTextLayout({ drawX, colWidth, dpr: scaleX, isRightAlign, reservedWidth });
if (isBooleanCell && value !== null && !isEditingThisCell) {
drawBooleanCheckbox(ctx, { drawX, y, colWidth, dpr, theme, checked: normalizeBooleanCellValue(value) === true });
drawBooleanCheckbox(ctx, { drawX, y, colWidth, scaleX, scaleY, theme, checked: normalizeBooleanCellValue(value) === true });
if (item.isDeleted) {
const boxX = alignCanvasPixel(drawX + (colWidth - BOOLEAN_CHECKBOX_SIZE) / 2, dpr);
const strikeY = alignCanvasPixel(y + CANVAS_DATA_GRID_ROW_HEIGHT / 2, dpr);
const boxX = alignCanvasPixel(drawX + (colWidth - BOOLEAN_CHECKBOX_SIZE) / 2, scaleX);
const strikeY = alignCanvasPixel(y + CANVAS_DATA_GRID_ROW_HEIGHT / 2, scaleY);
ctx.strokeStyle = theme.foreground;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(boxX - 1, strikeY);
ctx.lineTo(alignCanvasPixel(boxX + BOOLEAN_CHECKBOX_SIZE + 1, dpr), strikeY);
ctx.lineTo(alignCanvasPixel(boxX + BOOLEAN_CHECKBOX_SIZE + 1, scaleX), strikeY);
ctx.stroke();
}
} else {
const rawDisplayText = item.isDraft && value === null ? (draftCellPlaceholder ?? "") : formatCell(value, actualColIdx);
const displayText = isEditingThisCell ? "" : firstLineCellDisplayValue(rawDisplayText);
const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, cellMaxWidth, isBooleanNullCell ? "left" : isRightAlign ? "right" : "left");
const anchorX = isBooleanNullCell ? alignCanvasPixel(drawX + colWidth / 2, dpr) : textAnchorX;
const anchorX = isBooleanNullCell ? alignCanvasPixel(drawX + colWidth / 2, scaleX) : textAnchorX;
ctx.fillText(text, anchorX, textY);
if (item.isDeleted && text) {
const textWidth = ctx.measureText(text).width;
@ -497,7 +536,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
ctx.strokeStyle = theme.foreground;
ctx.beginPath();
ctx.moveTo(lineStartX, textY);
ctx.lineTo(alignCanvasPixel(lineStartX + textWidth, dpr), textY);
ctx.lineTo(alignCanvasPixel(lineStartX + textWidth, scaleX), textY);
ctx.stroke();
}
}
@ -507,7 +546,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
ctx.strokeStyle = theme.border;
ctx.beginPath();
const columnBorderX = crispCanvasLine(drawX + colWidth - 1, dpr);
const columnBorderX = crispCanvasLine(drawX + colWidth - 1, scaleX);
ctx.moveTo(columnBorderX, y);
ctx.lineTo(columnBorderX, y + CANVAS_DATA_GRID_ROW_HEIGHT);
ctx.stroke();