From cb581baf20d14c4446c0cf83e7d9ed2f8a6a90ae Mon Sep 17 00:00:00 2001 From: Klinge <34301334+klinge1011@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:35:39 +0800 Subject: [PATCH] fix(grid): avoid canvas resize observer loops --- apps/desktop/src/components/grid/DataGrid.vue | 64 +++++++++++++++---- .../grid/__tests__/DataGridSurfaces.spec.ts | 10 +++ .../useDataGridCanvasRuntime.spec.ts | 49 ++++++++++++++ 3 files changed, 110 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 97173a2418..c45a6bf054 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -4265,7 +4265,7 @@ function cellTextOverflowsElement(text: string, target: EventTarget | null): boo } function measureCellTextWidth(text: string, font: string): number { - const canvas = canvasRef.value ?? document.createElement("canvas"); + const canvas = activeCanvasSurface() ?? document.createElement("canvas"); const context = canvas.getContext("2d"); if (!context) return 0; context.save(); @@ -6878,6 +6878,17 @@ function dataGridRowStyle(item: RowItem): CSSProperties { } const canvasRef = ref(); +const canvasBackRef = ref(); +// WKWebView(macOS)会为 canvas 元素滞留幽灵合成层:屏幕显示旧帧位图,2D 缓冲重绘 +// (甚至改尺寸)都无法清除,只有把显示元素换掉才有效。双 canvas 交替翻转:绘制进 +// 隐藏的那一张,画完移交可见性,幽灵层随旧元素的 display:none 一并废弃。 +const canvasUsingBackSurface = ref(false); +function activeCanvasSurface(): HTMLCanvasElement | null { + return (canvasUsingBackSurface.value ? canvasBackRef.value : canvasRef.value) ?? null; +} +function inactiveCanvasSurface(): HTMLCanvasElement | null { + return (canvasUsingBackSurface.value ? canvasRef.value : canvasBackRef.value) ?? null; +} const canvasOverlayRef = ref(); const canvasViewportWidth = ref(0); const canvasViewportHeight = ref(0); @@ -6939,6 +6950,10 @@ function dataGridSelectionScroller(): HTMLElement | null { return canvasScrollerElement(); } +function canvasMeasurementSurface(): HTMLElement | null { + return canvasOverlayRef.value ?? null; +} + function dataGridCellFromClientPoint(clientX: number, clientY: number): { rowIndex: number; colIndex: number } | null { const scroller = dataGridSelectionScroller(); if (!scroller) return null; @@ -6985,9 +7000,9 @@ 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); + const measurementSurface = canvasMeasurementSurface(); + const surfaceEntry = measurementSurface ? entries?.find((entry) => entry.target === measurementSurface) : undefined; + if (surfaceEntry) canvasMeasuredDevicePixelSize.value = dataGridCanvasDevicePixelSize(surfaceEntry); canvasViewportWidth.value = scroller.clientWidth; canvasViewportHeight.value = scroller.clientHeight; canvasScrollTop.value = scroller.scrollTop; @@ -7048,7 +7063,7 @@ canvasRuntime = useDataGridCanvasRuntime({ draw: drawCanvasGrid, syncViewport: syncCanvasViewport, getViewport: canvasScrollerElement, - getSurface: () => canvasRef.value ?? null, + getSurface: canvasMeasurementSurface, refreshPixelRatio: () => { const next = currentCanvasDevicePixelRatio(); if (Math.abs(next - canvasDevicePixelRatio.value) > 0.001) { @@ -7074,7 +7089,7 @@ function canvasColumnAt(contentX: number): number { } function canvasHitTest(event: MouseEvent): { rowIndex: number; visibleColIdx: number; rowNumber: boolean } | null { - const canvas = canvasRef.value; + const canvas = activeCanvasSurface(); const scroller = canvasScrollerElement(); if (!canvas || !scroller) return null; const rect = canvas.getBoundingClientRect(); @@ -7189,7 +7204,8 @@ function onDomGridWheel(event: WheelEvent) { function onCanvasMouseMove(event: MouseEvent) { stopReleasedSelectionGesture(event); if (columnHeaderPointerInteractionActive()) { - if (canvasRef.value) canvasRef.value.style.cursor = "default"; + const cursorSurface = activeCanvasSurface(); + if (cursorSurface) cursorSurface.style.cursor = "default"; onCanvasMouseLeave(); return; } @@ -7203,10 +7219,11 @@ function onCanvasMouseMove(event: MouseEvent) { } : null; const actualColIdx = next ? visibleColumnIndexes.value[next.visibleColIdx] : undefined; - if (canvasRef.value) { + const cursorSurface = activeCanvasSurface(); + if (cursorSurface) { const overBooleanInteractive = booleanCellsUseCheckbox.value && hit != null && !hit.rowNumber && hitItem != null && actualColIdx !== undefined && isBooleanGridCell(hitItem, actualColIdx) && canEditCellItem(hitItem, actualColIdx) && booleanInteractiveHitFromCanvasEvent(hitItem, hit, actualColIdx, event); - canvasRef.value.style.cursor = hit?.rowNumber ? "default" : overBooleanInteractive ? "pointer" : hitItem && actualColIdx !== undefined ? "text" : "cell"; + cursorSurface.style.cursor = hit?.rowNumber ? "default" : overBooleanInteractive ? "pointer" : hitItem && actualColIdx !== undefined ? "text" : "cell"; } if (next?.rowIndex === canvasHoverCell.value?.rowIndex && next?.visibleColIdx === canvasHoverCell.value?.visibleColIdx) { return; @@ -7246,7 +7263,7 @@ function keepCanvasDetailHover() { function clearCanvasDetailHover(event?: MouseEvent) { const relatedTarget = event?.relatedTarget; - if (relatedTarget instanceof Node && (canvasOverlayRef.value?.contains(relatedTarget) || canvasRef.value?.contains(relatedTarget))) { + if (relatedTarget instanceof Node && (canvasOverlayRef.value?.contains(relatedTarget) || activeCanvasSurface()?.contains(relatedTarget) || canvasBackRef.value?.contains(relatedTarget))) { return; } onCanvasMouseLeave(); @@ -7254,7 +7271,7 @@ function clearCanvasDetailHover(event?: MouseEvent) { function booleanCheckboxHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { if (item.data[actualColIdx] === null) return false; - const canvas = canvasRef.value; + const canvas = activeCanvasSurface(); const canvasRect = canvas?.getBoundingClientRect(); const cellRect = canvasCellViewportRect(hit.rowIndex, hit.visibleColIdx); if (!canvasRect || !cellRect) return false; @@ -7271,7 +7288,7 @@ function booleanCheckboxHitFromCanvasEvent(item: RowItem, hit: { rowIndex: numbe function booleanNullTextHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { if (item.data[actualColIdx] !== null) return false; - const canvas = canvasRef.value; + const canvas = activeCanvasSurface(); const canvasRect = canvas?.getBoundingClientRect(); const cellRect = canvasCellViewportRect(hit.rowIndex, hit.visibleColIdx); if (!canvasRect || !cellRect) return false; @@ -7527,7 +7544,7 @@ const canvasRightAlignedActionCell = computed(() => { }); function drawCanvasGrid() { - const canvas = canvasRef.value; + const canvas = inactiveCanvasSurface(); const scroller = canvasScrollerElement(); if (!canvas || !scroller || !useCanvasGridRows.value) return; @@ -7571,6 +7588,12 @@ function drawCanvasGrid() { booleanDisplayMode: booleanDisplayMode.value, flatteningMultiLineEnabled: flatteningMultiLineEnabled.value, }); + flipCanvasSurface(); +} + +function flipCanvasSurface() { + // 双 canvas 翻转:刚绘制的帧离开前景层,幽灵层随隐藏一并废弃,新显画布接到下一帧 + canvasUsingBackSurface.value = !canvasUsingBackSurface.value; } watch( @@ -12933,6 +12956,21 @@ function currentGridContextMenuItems(): ContextMenuItem[] { :style="{ width: `${canvasSurfaceWidth}px`, height: `${canvasViewportHeight}px`, + display: canvasUsingBackSurface ? 'none' : '', + }" + @mousemove="onCanvasMouseMove" + @mouseleave="onCanvasMouseLeave" + @mousedown="onCanvasMouseDown" + @contextmenu="onCanvasContext" + @dblclick="onCanvasDblClick" + /> + { vi.clearAllMocks(); localStorage.removeItem("dbx-filter-builder-value-shortcut-hint-days"); }); +describe("DataGrid canvas surfaces", () => { + it("uses the stable overlay for viewport and device-pixel measurement", () => { + expect(dataGridSource).toContain("function canvasMeasurementSurface(): HTMLElement | null"); + expect(dataGridSource).toContain("return canvasOverlayRef.value ?? null;"); + expect(dataGridSource).toContain("const measurementSurface = canvasMeasurementSurface();"); + expect(dataGridSource).toContain("getSurface: canvasMeasurementSurface,"); + expect(dataGridSource).toContain("const canvas = inactiveCanvasSurface();"); + expect(dataGridSource).toContain("canvasUsingBackSurface.value = !canvasUsingBackSurface.value;"); + }); +}); describe("DataGridSearchBar", () => { it("focuses/selects the input and forwards keyboard, navigation, and suggestion interactions", async () => { diff --git a/apps/desktop/src/composables/__tests__/useDataGridCanvasRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridCanvasRuntime.spec.ts index 1cdefaedb4..e78cdd4c20 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridCanvasRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridCanvasRuntime.spec.ts @@ -53,6 +53,55 @@ describe("data grid visual runtimes", () => { expect(resize.observe).toHaveBeenNthCalledWith(2, surface, { box: "device-pixel-content-box" }); runtime.dispose(); }); + it("keeps viewport observation on a stable measurement surface while render surfaces flip", () => { + const viewport = { children: [] } as unknown as Element; + const measurementSurface = { children: [] } as unknown as Element; + const frontSurface = { children: [] } as unknown as Element; + const backSurface = { children: [] } as unknown as Element; + const observe = vi.fn(); + const disconnect = vi.fn(); + const syncViewport = vi.fn(); + let activeSurface = frontSurface; + let observerCallback: ResizeObserverCallback | null = null; + let observer: ResizeObserver | null = null; + const runtime = useDataGridCanvasRuntime({ + draw: () => { + activeSurface = activeSurface === frontSurface ? backSurface : frontSurface; + }, + syncViewport, + getViewport: () => viewport, + getSurface: () => measurementSurface, + createResizeObserver: (callback) => { + observerCallback = callback; + observer = { observe, disconnect } as unknown as ResizeObserver; + return observer; + }, + }); + + runtime.observeViewport(); + runtime.drawNow(); + runtime.drawNow(); + + expect(activeSurface).toBe(frontSurface); + expect(observe.mock.calls[0]?.[0]).toBe(viewport); + expect(observe.mock.calls[1]?.[0]).toBe(measurementSurface); + expect(observe.mock.calls[1]?.[1]).toEqual({ box: "device-pixel-content-box" }); + expect(observe.mock.calls.map(([target]) => target)).not.toContain(frontSurface); + expect(observe.mock.calls.map(([target]) => target)).not.toContain(backSurface); + expect(syncViewport).toHaveBeenCalledOnce(); + + const entry = { + target: measurementSurface, + contentRect: { width: 640, height: 320 }, + devicePixelContentBoxSize: [{ inlineSize: 1280, blockSize: 640 }], + } as unknown as ResizeObserverEntry; + observerCallback?.([entry], observer as ResizeObserver); + + expect(syncViewport).toHaveBeenCalledTimes(2); + expect(syncViewport).toHaveBeenLastCalledWith([entry]); + expect(dataGridCanvasDevicePixelSize(entry)).toEqual({ cssWidth: 640, cssHeight: 320, pixelWidth: 1280, pixelHeight: 640 }); + runtime.dispose(); + }); it("coalesces canvas frames and cancels all resources on dispose", () => { const frames = createFrameDriver();