perf(sheets): optimize large sheet scrolling (#7507)

This commit is contained in:
Univer
2026-08-14 16:06:39 +08:00
committed by GitHub
parent 09a077211d
commit 4f74db67a8
26 changed files with 1476 additions and 161 deletions
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { Tools } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CURSOR_TYPE } from '../basics/const';
import { DeviceType, PointerInput } from '../basics/i-events';
@@ -308,6 +309,153 @@ describe('engine scene viewport extra', () => {
engine.dispose();
});
it('preserves engine and layer caches only for a pending scroll render', () => {
const { engine, scene } = createFixture();
const layer = scene.getLayer(1);
const renderSpy = vi.spyOn(layer, 'render');
scene.render();
renderSpy.mockClear();
scene.makeDirtyForScrolling();
expect(scene.isScrollRenderPending()).toBe(true);
scene.render();
expect(scene.isScrollRenderPending()).toBe(false);
expect(renderSpy).toHaveBeenLastCalledWith(undefined, false, expect.objectContaining({
preserveCache: true,
}));
scene.makeDirtyForScrolling();
scene.makeDirty(true);
scene.render();
expect(renderSpy).toHaveBeenLastCalledWith(undefined, false, undefined);
scene.dispose();
engine.dispose();
});
it('defers obsolete detail renders during a large scrollbar seek and paints the settled target', () => {
const { engine, scene, viewport } = createFixture();
const layer = scene.getLayer(1);
const renderSpy = vi.spyOn(layer, 'render');
const nowSpy = vi.spyOn(Tools, 'now');
const scrollbarRenderSpy = vi.spyOn(viewport, 'renderScrollbarOnly');
vi.spyOn(engine, 'getEstimatedFrameInterval').mockReturnValue(1000 / 120);
let now = 0;
nowSpy.mockImplementation(() => now);
scene.render();
renderSpy.mockClear();
scene.beginScrollbarDrag(viewport);
viewport.scrollToViewportPos({ viewportScrollY: 40 });
scene.updateScrollbarDrag(viewport);
scene.render();
expect(renderSpy).toHaveBeenCalledTimes(1);
renderSpy.mockClear();
now = 10;
viewport.scrollToViewportPos({ viewportScrollY: 240 });
scene.updateScrollbarDrag(viewport);
scene.render();
expect(renderSpy).not.toHaveBeenCalled();
expect(scrollbarRenderSpy).toHaveBeenCalled();
now = 90;
scene.render();
expect(renderSpy).toHaveBeenCalledTimes(1);
renderSpy.mockClear();
now = 100;
viewport.scrollToViewportPos({ viewportScrollY: 300 });
scene.updateScrollbarDrag(viewport);
scene.render();
expect(renderSpy).not.toHaveBeenCalled();
now = 164;
scene.render();
expect(renderSpy).toHaveBeenCalledTimes(1);
renderSpy.mockClear();
viewport.scrollToViewportPos({ viewportScrollY: 320 });
scene.updateScrollbarDrag(viewport);
scene.endScrollbarDrag(viewport);
scene.render();
expect(renderSpy).toHaveBeenCalledTimes(1);
scene.dispose();
engine.dispose();
});
it('keeps expensive content stable until a large scrollbar seek settles', () => {
const { engine, scene, viewport } = createFixture();
const layer = scene.getLayer(1);
const renderSpy = vi.spyOn(layer, 'render');
const nowSpy = vi.spyOn(Tools, 'now');
vi.spyOn(engine, 'getEstimatedFrameInterval').mockReturnValue(1000 / 120);
nowSpy.mockReturnValueOnce(0).mockReturnValueOnce(40);
scene.render();
renderSpy.mockClear();
let now = 50;
nowSpy.mockImplementation(() => now);
scene.beginScrollbarDrag(viewport);
viewport.scrollToViewportPos({ viewportScrollY: 240 });
scene.updateScrollbarDrag(viewport);
scene.render();
expect(renderSpy).not.toHaveBeenCalled();
now = 220;
viewport.scrollToViewportPos({ viewportScrollY: 300 });
scene.updateScrollbarDrag(viewport);
scene.render();
expect(renderSpy).not.toHaveBeenCalled();
now = 284;
scene.render();
expect(renderSpy).toHaveBeenCalledTimes(1);
scene.dispose();
engine.dispose();
});
it('scrolls independent viewport regions for a frozen sheet render', () => {
const { engine, scene, viewport, container } = createFixture();
viewport.setViewportSize({ left: 100, width: 200 });
const frozenViewport = new Viewport('viewMainLeft', scene, {
left: 0,
top: 0,
width: 100,
height: 180,
active: true,
allowCache: true,
});
scene.render();
const engineCtx = engine.getCanvas().getContext();
const drawImageSpy = vi.spyOn(engineCtx, 'drawImage');
const clearCanvasSpy = vi.spyOn(engine, 'clearCanvas');
drawImageSpy.mockClear();
viewport.scrollToViewportPos({ viewportScrollX: 20, viewportScrollY: 16 });
frozenViewport.updateScrollVal({
scrollX: 0,
scrollY: 16,
viewportScrollX: 0,
viewportScrollY: 16,
});
scene.makeDirtyForScrolling();
scene.render();
const engineScrollCopies = drawImageSpy.mock.calls.filter(([source]) => source === engineCtx.canvas);
expect(engineScrollCopies).toHaveLength(2);
expect(clearCanvasSpy).not.toHaveBeenCalled();
scene.dispose();
engine.dispose();
container.remove();
});
it('covers engine pointer handlers and input manager dispatch', () => {
const { engine, scene } = createFixture();
scene.attachControl();
@@ -410,6 +558,19 @@ describe('engine scene viewport extra', () => {
engine.dispose();
});
it('estimates the display frame interval without following isolated long frames', () => {
const engine = new Engine('unit-frame-interval', { elementWidth: 1, elementHeight: 1, dpr: 1 });
let timestamp = 0;
engine._endFrame(timestamp);
for (const interval of [8, 8, 8, 16]) {
timestamp += interval;
engine._endFrame(timestamp);
}
expect(engine.getEstimatedFrameInterval()).toBe(8);
engine.dispose();
});
it('covers transformer geometry and state helper branches', () => {
const sceneMock = {
ancestorScaleX: 2,
@@ -986,25 +1147,50 @@ describe('engine scene viewport extra', () => {
engine.dispose();
});
it('expands cache diff strips across both axes to avoid exposing blank cache gaps', () => {
it('expands cache diff strips only toward retained cache content', () => {
const { engine, scene, viewport } = createFixture();
viewport.bufferEdgeX = 10;
viewport.bufferEdgeY = 20;
const verticalDiff = (viewport as any)._calcDiffCacheBound(
const downwardDiff = (viewport as any)._calcDiffCacheBound(
{ left: 0, top: 0, right: 100, bottom: 100 },
{ left: 0, top: 50, right: 100, bottom: 150 }
);
expect(verticalDiff).toEqual([
{ left: -10, top: 80, right: 110, bottom: 170 },
expect(downwardDiff).toEqual([
{ left: 0, top: 80, right: 100, bottom: 150 },
]);
const horizontalDiff = (viewport as any)._calcDiffCacheBound(
const upwardDiff = (viewport as any)._calcDiffCacheBound(
{ left: 0, top: 50, right: 100, bottom: 150 },
{ left: 0, top: 0, right: 100, bottom: 100 }
);
expect(upwardDiff).toEqual([
{ left: 0, top: 0, right: 100, bottom: 70 },
]);
const rightwardDiff = (viewport as any)._calcDiffCacheBound(
{ left: 0, top: 0, right: 100, bottom: 100 },
{ left: 50, top: 0, right: 150, bottom: 100 }
);
expect(horizontalDiff).toEqual([
{ left: 90, top: -20, right: 160, bottom: 120 },
expect(rightwardDiff).toEqual([
{ left: 90, top: 0, right: 150, bottom: 100 },
]);
const leftwardDiff = (viewport as any)._calcDiffCacheBound(
{ left: 50, top: 0, right: 150, bottom: 100 },
{ left: 0, top: 0, right: 100, bottom: 100 }
);
expect(leftwardDiff).toEqual([
{ left: 0, top: 0, right: 60, bottom: 100 },
]);
const diagonalDiff = (viewport as any)._calcDiffCacheBound(
{ left: 0, top: 0, right: 100, bottom: 100 },
{ left: 50, top: 50, right: 150, bottom: 150 }
);
expect(diagonalDiff).toEqual([
{ left: 90, top: 50, right: 150, bottom: 150 },
{ left: 50, top: 80, right: 100, bottom: 150 },
]);
scene.dispose();
@@ -52,6 +52,7 @@ function createScene() {
makeDirty: vi.fn(),
};
const viewport = {
viewportKey: 'viewport',
shouldIntoRender: vi.fn(() => true),
render: vi.fn(),
};
@@ -81,10 +82,16 @@ function createScene() {
}
function createCtx() {
const canvas = document.createElement('canvas');
const transform = document.createElement('canvas').getContext('2d')!.getTransform();
return {
canvas,
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
getTransform: vi.fn(() => transform),
drawImage: vi.fn(),
@@ -139,7 +146,7 @@ describe('layer', () => {
const ctx = createCtx();
layer.render(ctx, true);
expect(ctx.save).toHaveBeenCalled();
expect(scene.__viewport.render).toHaveBeenCalledWith(ctx, [child, group], true);
expect(scene.__viewport.render).toHaveBeenCalledWith(ctx, [child, group], true, undefined);
expect(layer.isDirty()).toBe(false);
child.onTransformChange$.emitEvent({} as any);
@@ -150,6 +157,32 @@ describe('layer', () => {
layer.dispose();
});
it('clips scroll repaint and refreshes the stale layer cache before reuse', () => {
const scene = createScene();
const object = new TestObject('cached', 1);
const layer = new Layer(scene, [object], 1, true);
const ctx = createCtx();
const viewportInfo = { viewportKey: 'viewport' } as IViewportInfo;
const dirtyBounds = [{ left: 140, top: 0, right: 160, bottom: 90 }];
scene.__resizeCache();
layer.render(ctx, false, {
dirtyBounds,
preserveCache: true,
viewportInfos: new Map([['viewport', viewportInfo]]),
});
expect(scene.__viewport.render).toHaveBeenLastCalledWith(expect.anything(), [object], false, viewportInfo);
expect(ctx.rect).toHaveBeenCalledWith(140, 0, 20, 90);
expect(ctx.drawImage).not.toHaveBeenCalled();
scene.__viewport.render.mockClear();
layer.render(ctx);
expect(scene.__viewport.render).toHaveBeenCalledWith(expect.anything(), [object], false, undefined);
expect(ctx.drawImage).toHaveBeenCalledWith(expect.any(HTMLCanvasElement), 0, 0, 160, 90);
layer.dispose();
});
it('uses cache canvas rendering, resizing and disposal paths', () => {
const scene = createScene();
const object = new TestObject('cached', 1);
@@ -34,6 +34,7 @@ describe('performance monitor', () => {
expect(monitor.averageFPS).toBeGreaterThan(0);
expect(monitor.isSaturated).toBe(true);
expect(monitor.isEnabled).toBe(true);
expect(monitor.estimatedFrameInterval).toBeGreaterThan(0);
monitor.disable();
monitor.sampleFrame(1100);
@@ -48,6 +49,18 @@ describe('performance monitor', () => {
monitor.dispose();
});
it('estimates animation-frame cadence without following isolated long frames', () => {
const monitor = new PerformanceMonitor();
for (const timestamp of [0, 8, 16, 24, 40]) {
monitor.endFrame(timestamp);
}
expect(monitor.estimatedFrameInterval).toBe(8);
monitor.reset();
expect(monitor.estimatedFrameInterval).toBeCloseTo(16.67, 2);
});
it('uses performance.now when available and Date.now as fallback', () => {
const monitor = new PerformanceMonitor();
const performanceNow = vi.spyOn(performance, 'now').mockReturnValue(123);
@@ -24,6 +24,8 @@ export const DEFAULT_FRAME_LIST_SIZE = 60 * 60; // 1min
const DEFAULT_ONE_SEC_MS = 1000;
const DEFAULT_FRAME_TIME = 16.67; // 60FPS
const MIN_ANIMATION_FRAME_INTERVAL = 1000 / 240;
const ANIMATION_FRAME_INTERVAL_PERCENTILE = 0.25;
export interface IBasicFrameInfo {
FPS: number;
frameTime: number; // frame time in milliseconds
@@ -103,6 +105,14 @@ export class PerformanceMonitor extends Disposable {
return this._rollingFrameTime.history(0);
}
/**
* Estimates the browser animation-frame cadence while ignoring isolated long frames.
*/
get estimatedFrameInterval(): number {
const frameInterval = this._rollingFrameTime.percentile(ANIMATION_FRAME_INTERVAL_PERCENTILE);
return frameInterval > 0 ? Math.max(frameInterval, MIN_ANIMATION_FRAME_INTERVAL) : DEFAULT_FRAME_TIME;
}
/**
* Returns the average framerate in frames per second over the sliding window (or the subset of frames sampled so far)
*/
@@ -309,6 +319,23 @@ export class RollingAverage {
return this._samples[this._wrapPosition(i0 - i)];
}
percentile(percentile: number): number {
if (this._sampleCount === 0) {
return 0;
}
const sampleCount = Math.min(this._sampleCount, this._samples.length);
const samples = new Array<number>(sampleCount);
for (let i = 0; i < sampleCount; i++) {
samples[i] = this.history(i);
}
samples.sort((a, b) => a - b);
const normalizedPercentile = Math.min(1, Math.max(0, percentile));
const index = Math.min(sampleCount - 1, Math.floor(sampleCount * normalizedPercentile));
return samples[index];
}
/**
* Returns true if enough samples have been taken to completely fill the sliding window
* @return true if sample-set saturated
@@ -1321,4 +1321,57 @@ describe('spreadsheet integration', () => {
'rgba(24, 119, 242, 0.25)'
);
});
it('alternates scroll buffers independently for each viewport', () => {
const { spreadsheet, skeleton, scene, viewport, mainCanvas } = fixture;
const mainCtx = mainCanvas.getContext() as any;
const mainCache = viewport.canvas!;
mainCache.setSize(460, 280, 1);
const frozenViewport = new Viewport(SHEET_VIEWPORT_KEY.VIEW_MAIN_LEFT, scene, {
left: 0,
top: 0,
width: 80,
height: 240,
active: true,
allowCache: true,
});
const frozenCache = frozenViewport.canvas!;
frozenCache.setSize(120, 280, 1);
const paint = (viewportKey: string, cacheCanvas: Canvas) => spreadsheet.paintNewAreaForScrolling(
createViewportInfo(scene, cacheCanvas, {
viewportKey,
cacheCanvas,
diffX: 4,
diffY: 3,
diffBounds: [createBound(100, 60, 220, 140)],
diffCacheBounds: [createBound(100, 60, 220, 140)],
}),
{
cacheCanvas,
cacheCtx: cacheCanvas.getContext() as any,
mainCtx,
topOrigin: 0,
leftOrigin: 0,
bufferEdgeX: 8,
bufferEdgeY: 6,
rowHeaderWidthAndMarginLeft: skeleton.rowHeaderWidthAndMarginLeft,
columnHeaderHeightAndMarginTop: skeleton.columnHeaderHeightAndMarginTop,
scaleX: 1,
scaleY: 1,
}
);
const nextMainCache = paint(SHEET_VIEWPORT_KEY.VIEW_MAIN, mainCache);
const nextFrozenCache = paint(SHEET_VIEWPORT_KEY.VIEW_MAIN_LEFT, frozenCache);
expect(viewport.canvas).toBe(nextMainCache);
expect(frozenViewport.canvas).toBe(nextFrozenCache);
expect(nextMainCache).not.toBe(mainCache);
expect(nextFrozenCache).not.toBe(frozenCache);
expect(nextMainCache.getWidth()).toBe(460);
expect(nextFrozenCache.getWidth()).toBe(120);
expect(paint(SHEET_VIEWPORT_KEY.VIEW_MAIN, nextMainCache)).toBe(mainCache);
expect(paint(SHEET_VIEWPORT_KEY.VIEW_MAIN_LEFT, nextFrozenCache)).toBe(frozenCache);
});
});
@@ -53,6 +53,14 @@ function createCellInfo(overrides?: Partial<any>) {
};
}
function createStylesCache(cellData?: unknown) {
return {
fontMatrix: {
getValue: vi.fn(() => cellData === undefined ? undefined : { cellData }),
},
};
}
describe('marker extension', () => {
it('returns early in printing mode or missing worksheet', () => {
const marker = new Marker();
@@ -86,6 +94,7 @@ describe('marker extension', () => {
};
const skeleton = {
worksheet,
stylesCache: createStylesCache(),
rowColumnSegment: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 },
getCellWithCoordByIndex: vi.fn(() => createCellInfo()),
} as any;
@@ -137,6 +146,7 @@ describe('marker extension', () => {
};
const skeleton = {
worksheet,
stylesCache: createStylesCache(),
rowColumnSegment: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 1 },
getCellWithCoordByIndex: vi.fn((row: number, col: number) => {
if (col === 0) {
@@ -171,6 +181,7 @@ describe('marker extension', () => {
};
const skeleton = {
worksheet,
stylesCache: createStylesCache(),
rowColumnSegment: { startRow: 0, endRow: 9, startColumn: 0, endColumn: 0 },
getCellWithCoordByIndex: vi.fn((row: number) => createCellInfo({
mergeInfo: {
@@ -204,6 +215,7 @@ describe('marker extension', () => {
};
const skeleton = {
worksheet,
stylesCache: createStylesCache(),
rowColumnSegment: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 },
getCellWithCoordByIndex: vi.fn(() => createCellInfo()),
} as any;
@@ -213,4 +225,27 @@ describe('marker extension', () => {
expect(skeleton.getCellWithCoordByIndex).not.toHaveBeenCalled();
expect(ctx.fill).not.toHaveBeenCalled();
});
it('reuses intercepted marker data from the style cache', () => {
const marker = new Marker();
const ctx = createCtx();
const cachedCell = { markers: { tr: { size: 2, color: '#f00' } } };
const worksheet = {
getMergeData: vi.fn(() => []),
getRowVisible: vi.fn(() => true),
getColVisible: vi.fn(() => true),
getCell: vi.fn(() => ({ v: 'raw' })),
};
const skeleton = {
worksheet,
stylesCache: createStylesCache(cachedCell),
rowColumnSegment: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 },
getCellWithCoordByIndex: vi.fn(() => createCellInfo()),
} as any;
marker.draw(ctx, { scaleX: 1, scaleY: 1 } as any, skeleton, [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }]);
expect(worksheet.getCell).not.toHaveBeenCalled();
expect(ctx.fill).toHaveBeenCalledOnce();
});
});
@@ -72,7 +72,7 @@ export class Marker extends SheetExtension {
continue;
}
let cellData = worksheet.getCell(row, col);
let cellData = skeleton.stylesCache.fontMatrix.getValue(row, col)?.cellData ?? worksheet.getCell(row, col);
if (!hasMerge && !cellData?.markers) {
continue;
}
@@ -94,7 +94,7 @@ export class Marker extends SheetExtension {
col: mergeInfo.startColumn,
};
cellData = worksheet.getCell(mainCell.row, mainCell.col);
cellData = skeleton.stylesCache.fontMatrix.getValue(mainCell.row, mainCell.col)?.cellData ?? worksheet.getCell(mainCell.row, mainCell.col);
}
if (!this.isRenderDiffRangesByRow(mergeInfo.startRow, mergeInfo.endRow, diffRanges)) {
@@ -16,7 +16,6 @@
import type { IPosition, IRange, Nullable } from '@univerjs/core';
import type { IBoundRectNoAngle, IViewportInfo, Vector2 } from '../../basics/vector2';
import type { Canvas } from '../../canvas';
import type { UniverRenderingContext2D } from '../../context';
import type { Engine } from '../../engine';
import type { Scene } from '../../scene';
@@ -30,6 +29,7 @@ import type { SpreadsheetSkeleton } from './sheet.render-skeleton';
import { BooleanNumber, sortRules, Tools } from '@univerjs/core';
import { FIX_ONE_PIXEL_BLUR_OFFSET, RENDER_CLASS_TYPE } from '../../basics/const';
import { getColor } from '../../basics/tools';
import { Canvas } from '../../canvas';
import { Documents } from '../docs/document';
import { SpreadsheetExtensionRegistry } from '../extension';
import { sheetContentViewportKeys, sheetHeaderViewportKeys } from './constants';
@@ -265,6 +265,7 @@ function clipBlitRectToBounds(rect: IBlitRect, sourceWidth: number, sourceHeight
}
export class Spreadsheet extends SheetComponent {
private _scrollBufferCanvases = new Map<string, Canvas>();
private _backgroundExtension!: Background;
private _borderExtension!: Border;
@@ -321,6 +322,8 @@ export class Spreadsheet extends SheetComponent {
override dispose() {
super.dispose();
this._documents?.dispose();
this._scrollBufferCanvases.forEach((canvas) => canvas.dispose());
this._scrollBufferCanvases.clear();
// TODO: fix memory leak without reassigning these properties
this._documents = null as unknown as Documents;
@@ -497,7 +500,8 @@ export class Spreadsheet extends SheetComponent {
const bufferEdgeSizeX = bufferEdgeX * scaleX / window.devicePixelRatio;
const bufferEdgeSizeY = bufferEdgeY * scaleY / window.devicePixelRatio;
const cacheCtx = cacheCanvas.getContext();
let renderCacheCanvas = cacheCanvas;
let cacheCtx = renderCacheCanvas.getContext();
cacheCtx.save();
const isForceDirty = isViewportForceDirty || this.isForceDirty();
@@ -520,8 +524,8 @@ export class Spreadsheet extends SheetComponent {
} else if (diffBounds.length !== 0 || diffX !== 0 || diffY !== 0) {
// scrolling && no dirty
this.addRenderTagToScene('scrolling', true);
this.paintNewAreaForScrolling(viewportInfo, {
cacheCanvas,
renderCacheCanvas = this.paintNewAreaForScrolling(viewportInfo, {
cacheCanvas: renderCacheCanvas,
cacheCtx,
mainCtx,
topOrigin,
@@ -533,6 +537,9 @@ export class Spreadsheet extends SheetComponent {
columnHeaderHeightAndMarginTop,
rowHeaderWidthAndMarginLeft,
}, mergeRepairBounds);
cacheCtx.restore();
cacheCtx = renderCacheCanvas.getContext();
cacheCtx.save();
}
// support for browser native zoom (only windows has this problem)
const sourceLeft = bufferEdgeSizeX * Math.min(1, window.devicePixelRatio);
@@ -540,7 +547,7 @@ export class Spreadsheet extends SheetComponent {
const { left, top, right, bottom } = viewPortPosition;
const dw = right - left + rowHeaderWidthAndMarginLeft;
const dh = bottom - top + columnHeaderHeightAndMarginTop;
this._applyCache(cacheCanvas, mainCtx, sourceLeft, sourceTop, dw, dh, left, top, dw, dh);
this._applyCache(renderCacheCanvas, mainCtx, sourceLeft, sourceTop, dw, dh, left, top, dw, dh);
cacheCtx.restore();
}
@@ -598,21 +605,33 @@ export class Spreadsheet extends SheetComponent {
paintNewAreaForScrolling(viewportInfo: IViewportInfo, param: IPaintForScrolling, mergeRepairBounds: IBoundRectNoAngle[] = []) {
const { cacheCanvas, cacheCtx, mainCtx, topOrigin, leftOrigin, bufferEdgeX, bufferEdgeY, scaleX, scaleY, columnHeaderHeightAndMarginTop, rowHeaderWidthAndMarginLeft } = param;
const { diffX, diffY } = viewportInfo;
cacheCtx.save();
cacheCtx.setTransform(1, 0, 0, 1, 0, 0);
cacheCtx.globalCompositeOperation = 'copy';
cacheCtx.drawImage(cacheCanvas.getCanvasEle(), diffX * scaleX, diffY * scaleY);
cacheCtx.restore();
let renderCacheCanvas = cacheCanvas;
let renderCacheCtx = cacheCtx;
const viewport = this.getScene().getViewport(viewportInfo.viewportKey);
const canSwapCache = this._getAncestorParent()?.classType === RENDER_CLASS_TYPE.ENGINE && viewport?.canvas === cacheCanvas;
if (canSwapCache) {
renderCacheCanvas = this._getScrollBufferCanvas(viewportInfo.viewportKey, cacheCanvas);
renderCacheCtx = renderCacheCanvas.getContext();
this._copyCacheForScrolling(renderCacheCtx, cacheCanvas, diffX * scaleX, diffY * scaleY);
const previousBufferCanvas = viewport.swapCacheCanvas(renderCacheCanvas);
if (previousBufferCanvas) {
this._scrollBufferCanvases.set(viewportInfo.viewportKey, previousBufferCanvas);
}
} else {
this._copyCacheForScrolling(cacheCtx, cacheCanvas, diffX * scaleX, diffY * scaleY);
}
this._refreshIncrementalState = true;
// Reset the ctx position to the spreadsheet content origin before drawing.
// trasnlation should be (rowHeaderWidth, colHeaderHeight) at start.
const m = mainCtx.getTransform();
cacheCtx.setTransform(m.a, m.b, m.c, m.d, 0, 0);
renderCacheCtx.setTransform(m.a, m.b, m.c, m.d, 0, 0);
// leftOrigin is the offset of viewport relative to sheetcorner (without considering zoom)
// - (leftOrigin - bufferEdgeX) ----> simplified to - leftOrigin + bufferEdgeX
cacheCtx.translateWithPrecision(m.e / m.a - leftOrigin + bufferEdgeX, m.f / m.d - topOrigin + bufferEdgeY);
renderCacheCtx.translateWithPrecision(m.e / m.a - leftOrigin + bufferEdgeX, m.f / m.d - topOrigin + bufferEdgeY);
const repaintBounds = this._getRepaintBounds(viewportInfo, mergeRepairBounds);
if (repaintBounds.length) {
@@ -626,26 +645,54 @@ export class Spreadsheet extends SheetComponent {
const w = diffRight - diffLeft;
const h = diffBottom - diffTop; // w and h must exactly match the diffarea size, otherwise when scrolling back, the clear area will be too large, causing valid content from the previous frame to be erased
cacheCtx.clearRectByPrecision(x, y, w, h);
renderCacheCtx.clearRectByPrecision(x, y, w, h);
// cacheCtx.fillStyle = this.testGetRandomLightColor();
// cacheCtx.fillRectByPrecision(x, y, w, h); // x, y is diffBounds, means it's relative to scrolling distance.
cacheCtx.save();
cacheCtx.beginPath();
cacheCtx.rectByPrecision(x, y, w, h);
cacheCtx.closePath();
renderCacheCtx.save();
renderCacheCtx.beginPath();
renderCacheCtx.rectByPrecision(x, y, w, h);
renderCacheCtx.closePath();
// The reason for clipping here is to avoid duplicate drawing (otherwise the text would be jagged, especially on Windows)
cacheCtx.clip();
this.draw(cacheCtx, {
renderCacheCtx.clip();
this.draw(renderCacheCtx, {
...viewportInfo,
diffBounds: [diffBound],
}, repairsMerge);
cacheCtx.restore();
renderCacheCtx.restore();
}
}
// this.testShowRuler(cacheCtx, viewportInfo);
this._refreshIncrementalState = false;
return renderCacheCanvas;
}
private _copyCacheForScrolling(
targetCtx: UniverRenderingContext2D,
sourceCanvas: Canvas,
offsetX: number,
offsetY: number
) {
targetCtx.save();
targetCtx.setTransform(1, 0, 0, 1, 0, 0);
targetCtx.globalCompositeOperation = 'copy';
targetCtx.drawImage(sourceCanvas.getCanvasEle(), offsetX, offsetY);
targetCtx.restore();
}
private _getScrollBufferCanvas(viewportKey: string, sourceCanvas: Canvas) {
let scrollBufferCanvas = this._scrollBufferCanvases.get(viewportKey);
if (!scrollBufferCanvas) {
scrollBufferCanvas = new Canvas({ colorService: this.getScene()?.getEngine()?.canvasColorService });
this._scrollBufferCanvases.set(viewportKey, scrollBufferCanvas);
}
if (scrollBufferCanvas.getWidth() !== sourceCanvas.getWidth() ||
scrollBufferCanvas.getHeight() !== sourceCanvas.getHeight() ||
scrollBufferCanvas.getPixelRatio() !== sourceCanvas.getPixelRatio()) {
scrollBufferCanvas.setSize(sourceCanvas.getWidth(), sourceCanvas.getHeight(), sourceCanvas.getPixelRatio());
}
return scrollBufferCanvas;
}
/**
+4
View File
@@ -520,6 +520,10 @@ export class Engine extends Disposable {
return this._deltaTime;
}
getEstimatedFrameInterval(): number {
return this._performanceMonitor.estimatedFrameInterval;
}
/**
* Exec all function in _renderFrameTasks
*/
+102 -6
View File
@@ -15,6 +15,7 @@
*/
import type { Nullable } from '@univerjs/core';
import type { IBoundRectNoAngle, IViewportInfo } from './basics/vector2';
import type { UniverRenderingContext } from './context';
import type { Scene } from './scene';
import type { SceneViewer } from './scene-viewer';
@@ -23,10 +24,84 @@ import { BaseObject } from './base-object';
import { RENDER_CLASS_TYPE } from './basics/const';
import { Canvas } from './canvas';
export interface IScrollRenderInfo {
bounds: IBoundRectNoAngle;
offsetX: number;
offsetY: number;
}
export interface ILayerRenderOptions {
dirtyBounds?: IBoundRectNoAngle[];
preserveCache?: boolean;
viewportInfos?: Map<string, IViewportInfo>;
}
function clipContextToBounds(ctx: UniverRenderingContext, bounds: IBoundRectNoAngle[]) {
ctx.beginPath();
for (const bound of bounds) {
ctx.rect(bound.left, bound.top, bound.right - bound.left, bound.bottom - bound.top);
}
ctx.clip();
}
export function scrollAndClearCanvas(
ctx: UniverRenderingContext,
pixelRatio: number,
scrollRenderInfos: IScrollRenderInfo[],
dirtyBounds: IBoundRectNoAngle[]
) {
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = 'copy';
for (const { bounds, offsetX, offsetY } of scrollRenderInfos) {
const width = bounds.right - bounds.left;
const height = bounds.bottom - bounds.top;
const copyWidth = width - Math.abs(offsetX);
const copyHeight = height - Math.abs(offsetY);
if (copyWidth <= 0 || copyHeight <= 0) {
continue;
}
const sourceX = (bounds.left + Math.max(0, -offsetX)) * pixelRatio;
const sourceY = (bounds.top + Math.max(0, -offsetY)) * pixelRatio;
const targetX = (bounds.left + Math.max(0, offsetX)) * pixelRatio;
const targetY = (bounds.top + Math.max(0, offsetY)) * pixelRatio;
const pixelCopyWidth = copyWidth * pixelRatio;
const pixelCopyHeight = copyHeight * pixelRatio;
ctx.drawImage(
ctx.canvas,
sourceX,
sourceY,
pixelCopyWidth,
pixelCopyHeight,
targetX,
targetY,
pixelCopyWidth,
pixelCopyHeight
);
}
clearCanvasBounds(ctx, pixelRatio, dirtyBounds);
ctx.restore();
}
function clearCanvasBounds(ctx: UniverRenderingContext, pixelRatio: number, dirtyBounds: IBoundRectNoAngle[]) {
for (const bound of dirtyBounds) {
ctx.clearRect(
bound.left * pixelRatio,
bound.top * pixelRatio,
(bound.right - bound.left) * pixelRatio,
(bound.bottom - bound.top) * pixelRatio
);
}
}
export class Layer extends Disposable {
private _objects: BaseObject[] = [];
private _cacheCanvas: Nullable<Canvas>;
private _cacheValid = false;
protected _dirty: boolean = true;
@@ -64,6 +139,7 @@ export class Layer extends Disposable {
this._allowCache = false;
this._cacheCanvas?.dispose();
this._cacheCanvas = null;
this._cacheValid = false;
}
isAllowCache(): boolean {
@@ -188,6 +264,9 @@ export class Layer extends Disposable {
makeDirty(state: boolean = true) {
this._dirty = state;
if (state) {
this._cacheValid = false;
}
/**
* parent is SceneViewer, make it dirty
*/
@@ -215,11 +294,22 @@ export class Layer extends Disposable {
return this._dirty;
}
render(parentCtx?: UniverRenderingContext, isMaxLayer = false) {
render(parentCtx?: UniverRenderingContext, isMaxLayer = false, options: ILayerRenderOptions = {}) {
const mainCtx = parentCtx || this._scene.getEngine()?.getCanvas().getContext();
if (mainCtx) {
const { dirtyBounds, preserveCache = false, viewportInfos } = options;
if (this._allowCache && this._cacheCanvas) {
if (this.isDirty()) {
if (preserveCache && dirtyBounds) {
mainCtx.save();
clipContextToBounds(mainCtx, dirtyBounds);
this._draw(mainCtx, isMaxLayer, viewportInfos);
mainCtx.restore();
// The visible frame is current, but the offscreen layer cache still represents the pre-scroll frame.
this._cacheValid = false;
this.makeDirty(false);
return this;
}
if (this.isDirty() || !this._cacheValid) {
const ctx = this._cacheCanvas.getContext();
this._cacheCanvas.clear();
@@ -227,14 +317,18 @@ export class Layer extends Disposable {
ctx.save();
ctx.setTransform(mainCtx.getTransform());
this._draw(ctx, isMaxLayer);
this._draw(ctx, isMaxLayer, viewportInfos);
ctx.restore();
this._cacheValid = true;
}
this._applyCache(mainCtx);
} else {
mainCtx.save();
this._draw(mainCtx, isMaxLayer);
if (dirtyBounds) {
clipContextToBounds(mainCtx, dirtyBounds);
}
this._draw(mainCtx, isMaxLayer, viewportInfos);
mainCtx.restore();
}
}
@@ -264,13 +358,14 @@ export class Layer extends Disposable {
}
this._cacheCanvas = new Canvas({ colorService: engine?.canvasColorService });
this._cacheValid = false;
}
private _draw(mainCtx: UniverRenderingContext, isMaxLayer: boolean) {
private _draw(mainCtx: UniverRenderingContext, isMaxLayer: boolean, viewportInfos?: Map<string, IViewportInfo>) {
const viewports = this._scene.getViewports().filter((vp) => vp.shouldIntoRender());
const objects = this.getObjectsByOrder();
for (const [_index, vp] of viewports.entries()) {
vp.render(mainCtx, objects, isMaxLayer);
vp.render(mainCtx, objects, isMaxLayer, viewportInfos?.get(vp.viewportKey));
}
objects.forEach((o) => {
o.makeDirty(false);
@@ -315,5 +410,6 @@ export class Layer extends Disposable {
this._cacheCanvas?.dispose();
this._cacheCanvas = null;
this._cacheValid = false;
}
}
+323 -6
View File
@@ -19,24 +19,33 @@ import type { BaseObject } from './base-object';
import type { IDragEvent, IMouseEvent, IPointerEvent, IWheelEvent } from './basics/i-events';
import type { ISceneTransformState, ITransformChangeState } from './basics/interfaces';
import type { ITransformerConfig } from './basics/transformer-config';
import type { Vector2 } from './basics/vector2';
import type { IBoundRectNoAngle, IViewportInfo, Vector2 } from './basics/vector2';
import type { Canvas } from './canvas';
import type { UniverRenderingContext } from './context';
import type { Engine } from './engine';
import type { ILayerRenderOptions, IScrollRenderInfo } from './layer';
import type { SceneViewer } from './scene-viewer';
import type { Viewport } from './viewport';
import { Disposable, EventSubject, sortRules, sortRulesByDesc, toDisposable } from '@univerjs/core';
import { Disposable, EventSubject, sortRules, sortRulesByDesc, toDisposable, Tools } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { CURSOR_TYPE, RENDER_CLASS_TYPE } from './basics/const';
import { TRANSFORM_CHANGE_OBSERVABLE_TYPE } from './basics/interfaces';
import { precisionTo, requestNewFrame } from './basics/tools';
import { Transform } from './basics/transform';
import { Layer } from './layer';
import { Layer, scrollAndClearCanvas } from './layer';
import { InputManager } from './scene.input-manager';
import { Transformer } from './scene.transformer';
export const MAIN_VIEW_PORT_KEY = 'viewMain';
const SCROLLBAR_SEEK_SETTLE_MS = 64;
const SCROLLBAR_SEEK_MIN_PREVIEW_INTERVAL_MS = 100;
const SCROLLBAR_SEEK_MAX_PREVIEW_INTERVAL_MS = 240;
const SCROLLBAR_SEEK_FRAME_INTERVALS = 8;
const SCROLLBAR_SEEK_RENDER_COST_MULTIPLIER = 4;
const SCROLLBAR_SEEK_EXPENSIVE_RENDER_MIN_MS = 32;
const SCROLLBAR_SEEK_EXPENSIVE_RENDER_FRAME_INTERVALS = 2;
export interface ISceneInputControlOptions {
enableDown: boolean;
enableUp: boolean;
@@ -46,6 +55,105 @@ export interface ISceneInputControlOptions {
enableLeave: boolean;
}
interface ISceneScrollRenderState {
canPreserveEngine: boolean;
dirtyBounds: IBoundRectNoAngle[];
scrollRenderInfos: IScrollRenderInfo[];
viewportInfos: Map<string, IViewportInfo>;
}
interface IViewportScrollRenderState {
canPreserveEngine: boolean;
dirtyBounds: IBoundRectNoAngle[];
scrollRenderInfo?: IScrollRenderInfo;
viewportInfo: IViewportInfo;
}
interface IViewportScrollPosition {
viewportScrollX: number;
viewportScrollY: number;
}
function createExposedScrollBounds(bounds: IBoundRectNoAngle, offsetX: number, offsetY: number) {
const dirtyBounds: IBoundRectNoAngle[] = [];
if (offsetX > 0) {
dirtyBounds.push({ ...bounds, right: bounds.left + offsetX });
} else if (offsetX < 0) {
dirtyBounds.push({ ...bounds, left: bounds.right + offsetX });
}
if (offsetY > 0) {
dirtyBounds.push({ ...bounds, bottom: bounds.top + offsetY });
} else if (offsetY < 0) {
dirtyBounds.push({ ...bounds, top: bounds.bottom + offsetY });
}
return dirtyBounds;
}
function createScrollbarBounds(viewport: Viewport, contentBounds: IBoundRectNoAngle, viewportBounds: IBoundRectNoAngle) {
const scrollbarBounds: IBoundRectNoAngle[] = [];
const scrollBar = viewport.getScrollBar();
if (scrollBar?.enableVertical) {
scrollbarBounds.push({
left: contentBounds.right,
top: viewportBounds.top,
right: viewportBounds.right,
bottom: viewportBounds.bottom,
});
}
if (scrollBar?.enableHorizontal) {
scrollbarBounds.push({
left: viewportBounds.left,
top: contentBounds.bottom,
right: viewportBounds.right,
bottom: viewportBounds.bottom,
});
}
return scrollbarBounds;
}
function createViewportScrollRenderState(viewport: Viewport, scaleX: number, scaleY: number): IViewportScrollRenderState {
const viewportInfo = viewport.calcViewportInfo();
const { diffX = 0, diffY = 0, viewPortPosition } = viewportInfo;
if (viewportInfo.isDirty || viewportInfo.isForceDirty || viewPortPosition == null) {
return { canPreserveEngine: false, dirtyBounds: [], viewportInfo };
}
const offsetX = diffX * scaleX;
const offsetY = diffY * scaleY;
if (offsetX === 0 && offsetY === 0) {
return { canPreserveEngine: true, dirtyBounds: [], viewportInfo };
}
const scrollBar = viewport.getScrollBar();
const bounds = {
left: viewPortPosition.left,
top: viewPortPosition.top,
right: viewPortPosition.right - (scrollBar?.enableVertical ? scrollBar.totalSize : 0),
bottom: viewPortPosition.bottom - (scrollBar?.enableHorizontal ? scrollBar.totalSize : 0),
};
const width = bounds.right - bounds.left;
const height = bounds.bottom - bounds.top;
const isInvalidScroll = !Number.isFinite(offsetX) ||
!Number.isFinite(offsetY) ||
width <= 0 ||
height <= 0 ||
Math.abs(offsetX) >= width ||
Math.abs(offsetY) >= height;
if (isInvalidScroll) {
return { canPreserveEngine: false, dirtyBounds: [], viewportInfo };
}
return {
canPreserveEngine: true,
dirtyBounds: [
...createExposedScrollBounds(bounds, offsetX, offsetY),
...createScrollbarBounds(viewport, bounds, viewPortPosition),
],
scrollRenderInfo: { bounds, offsetX, offsetY },
viewportInfo,
};
}
export class Scene extends Disposable {
private _sceneKey: string = '';
/**
@@ -63,6 +171,14 @@ export class Scene extends Disposable {
private _layers: Layer[] = [];
private _viewports: Viewport[] = [];
private _preserveEngineOnRender = false;
private _scrollbarDragViewport: Nullable<Viewport> = null;
private _isScrollbarSeeking = false;
private _isScrollbarPreviewDirty = false;
private _lastScrollbarSeekInputAt = Number.NEGATIVE_INFINITY;
private _lastScrollbarSeekRenderAt = Number.NEGATIVE_INFINITY;
private _lastFullRenderDuration = 0;
private _renderedViewportScrollPositions = new Map<string, IViewportScrollPosition>();
private _cursor: CURSOR_TYPE = CURSOR_TYPE.DEFAULT;
private _defaultCursor: CURSOR_TYPE = CURSOR_TYPE.DEFAULT;
@@ -265,6 +381,7 @@ export class Scene extends Disposable {
}
makeDirty(state: boolean = true) {
this._preserveEngineOnRender = false;
this._layers.forEach((layer) => {
layer.makeDirty(state);
});
@@ -274,7 +391,68 @@ export class Scene extends Disposable {
return this;
}
makeDirtyForScrolling() {
this._preserveEngineOnRender = true;
this._layers.forEach((layer) => {
layer.makeDirty(true);
});
return this;
}
beginScrollbarDrag(viewport: Viewport) {
if (this._parent.classType !== RENDER_CLASS_TYPE.ENGINE) {
return;
}
this._scrollbarDragViewport = viewport;
this._isScrollbarSeeking = false;
this._isScrollbarPreviewDirty = false;
}
updateScrollbarDrag(viewport: Viewport) {
if (viewport !== this._scrollbarDragViewport) {
return;
}
const now = Tools.now();
this._lastScrollbarSeekInputAt = now;
this._isScrollbarPreviewDirty = true;
if (this._isScrollbarSeeking) {
return;
}
const renderedPosition = this._renderedViewportScrollPositions.get(viewport.viewportKey);
if (!renderedPosition) {
return;
}
const { scaleX, scaleY } = this.getAncestorScale();
const viewportWidth = (viewport.width ?? 0) / Math.max(Math.abs(scaleX), Number.EPSILON);
const viewportHeight = (viewport.height ?? 0) / Math.max(Math.abs(scaleY), Number.EPSILON);
const isOutsideRenderedViewport = Math.abs(viewport.viewportScrollX - renderedPosition.viewportScrollX) >= viewportWidth ||
Math.abs(viewport.viewportScrollY - renderedPosition.viewportScrollY) >= viewportHeight;
if (isOutsideRenderedViewport) {
this._isScrollbarSeeking = true;
this._lastScrollbarSeekRenderAt = now;
}
}
endScrollbarDrag(viewport: Viewport) {
if (viewport !== this._scrollbarDragViewport) {
return;
}
this._scrollbarDragViewport = null;
this._isScrollbarSeeking = false;
this._isScrollbarPreviewDirty = false;
}
isScrollRenderPending() {
return this._preserveEngineOnRender;
}
makeDirtyNoParent(state: boolean = true) {
this._preserveEngineOnRender = false;
this._layers.forEach((layer) => {
layer.makeDirty(state);
});
@@ -672,6 +850,12 @@ export class Scene extends Disposable {
const viewport = this._viewports[i];
if (viewport.viewportKey === key) {
this._viewports.splice(i, 1);
this._renderedViewportScrollPositions.delete(key);
if (viewport === this._scrollbarDragViewport) {
this._scrollbarDragViewport = null;
this._isScrollbarSeeking = false;
this._isScrollbarPreviewDirty = false;
}
return viewport;
}
}
@@ -693,20 +877,151 @@ export class Scene extends Disposable {
}
}
private _createScrollRenderState(): ISceneScrollRenderState {
const dirtyBounds: IBoundRectNoAngle[] = [];
const scrollRenderInfos: IScrollRenderInfo[] = [];
const viewportInfos = new Map<string, IViewportInfo>();
const { scaleX, scaleY } = this.getAncestorScale();
let canPreserveEngine = true;
for (const viewport of this._viewports) {
if (!viewport.shouldIntoRender()) {
continue;
}
const viewportState = createViewportScrollRenderState(viewport, scaleX, scaleY);
const { viewportInfo, scrollRenderInfo } = viewportState;
viewportInfos.set(viewport.viewportKey, viewportInfo);
if (!viewportState.canPreserveEngine) {
canPreserveEngine = false;
continue;
}
dirtyBounds.push(...viewportState.dirtyBounds);
if (scrollRenderInfo) {
scrollRenderInfos.push(scrollRenderInfo);
}
}
return { canPreserveEngine, dirtyBounds, scrollRenderInfos, viewportInfos };
}
private _renderScrollbarSeekPreview(canvas: Canvas) {
if (!this._isScrollbarPreviewDirty) {
return;
}
const ctx = canvas.getContext();
const pixelRatio = canvas.getPixelRatio();
for (const viewport of this._viewports) {
if (viewport.shouldIntoRender()) {
viewport.renderScrollbarOnly(ctx, pixelRatio);
}
}
this._isScrollbarPreviewDirty = false;
}
private _getScrollbarSeekPreviewInterval() {
const frameInterval = this.getEngine()?.getEstimatedFrameInterval() ?? 1000 / 60;
return Tools.clamp(
Math.max(
frameInterval * SCROLLBAR_SEEK_FRAME_INTERVALS,
this._lastFullRenderDuration * SCROLLBAR_SEEK_RENDER_COST_MULTIPLIER
),
SCROLLBAR_SEEK_MIN_PREVIEW_INTERVAL_MS,
SCROLLBAR_SEEK_MAX_PREVIEW_INTERVAL_MS
);
}
private _shouldDeferScrollbarSeekRender(now: number) {
if (!this._isScrollbarSeeking) {
return false;
}
const hasSettled = now - this._lastScrollbarSeekInputAt >= SCROLLBAR_SEEK_SETTLE_MS;
if (hasSettled) {
return false;
}
const frameInterval = this.getEngine()?.getEstimatedFrameInterval() ?? 1000 / 60;
const expensiveRenderThreshold = Math.max(
SCROLLBAR_SEEK_EXPENSIVE_RENDER_MIN_MS,
frameInterval * SCROLLBAR_SEEK_EXPENSIVE_RENDER_FRAME_INTERVALS
);
if (this._lastFullRenderDuration >= expensiveRenderThreshold) {
return true;
}
return now - this._lastScrollbarSeekRenderAt < this._getScrollbarSeekPreviewInterval();
}
private _recordRenderedViewportScrollPositions() {
for (const viewport of this._viewports) {
if (viewport.shouldIntoRender()) {
this._renderedViewportScrollPositions.set(viewport.viewportKey, {
viewportScrollX: viewport.viewportScrollX,
viewportScrollY: viewport.viewportScrollY,
});
}
}
}
render(parentCtx?: UniverRenderingContext) {
if (!this.isDirty()) {
return;
}
!parentCtx && this.getEngine()?.clearCanvas();
const layers = this._layers.sort(sortRules);
const canvasInstance = this.getEngine()?.getCanvas();
const shouldTryPreservingEngine = this._preserveEngineOnRender && parentCtx == null && canvasInstance != null;
const isScrollbarSeekRender = this._isScrollbarSeeking && parentCtx == null && canvasInstance != null;
const shouldMeasureFullRender = parentCtx == null && canvasInstance != null;
const fullRenderStartedAt = Tools.now();
if (isScrollbarSeekRender) {
this._renderScrollbarSeekPreview(canvasInstance);
}
if (isScrollbarSeekRender && this._shouldDeferScrollbarSeekRender(fullRenderStartedAt)) {
this.getEngine()?.renderFrameTags$.next(['scrollDetailDeferred', true]);
return;
}
this._preserveEngineOnRender = false;
let layerRenderOptions: ILayerRenderOptions | undefined;
if (shouldTryPreservingEngine) {
const scrollRenderState = this._createScrollRenderState();
const { canPreserveEngine, dirtyBounds, scrollRenderInfos, viewportInfos } = scrollRenderState;
if (canPreserveEngine) {
scrollAndClearCanvas(
canvasInstance.getContext(),
canvasInstance.getPixelRatio(),
scrollRenderInfos,
dirtyBounds
);
layerRenderOptions = {
dirtyBounds,
preserveCache: true,
viewportInfos,
};
} else {
this.getEngine()?.clearCanvas();
layerRenderOptions = { viewportInfos };
}
} else if (!parentCtx) {
this.getEngine()?.clearCanvas();
}
this._beforeRender$.next(canvasInstance);
for (let i = 0, len = layers.length; i < len; i++) {
layers[i].render(parentCtx, i === len - 1);
layers[i].render(parentCtx, i === len - 1, layerRenderOptions);
}
this._afterRender$.next(canvasInstance);
this._recordRenderedViewportScrollPositions();
if (shouldMeasureFullRender) {
this._lastFullRenderDuration = Tools.now() - fullRenderStartedAt;
if (isScrollbarSeekRender) {
this._lastScrollbarSeekRenderAt = fullRenderStartedAt;
}
}
}
async requestRender(parentCtx?: UniverRenderingContext) {
@@ -880,6 +1195,8 @@ export class Scene extends Disposable {
this.clearLayer();
this.clearViewports();
this._renderedViewportScrollPositions.clear();
this._scrollbarDragViewport = null;
this.detachControl();
this.onTransformChange$?.complete();
this._inputManager?.dispose();
@@ -173,6 +173,8 @@ describe('ScrollBar', () => {
const scrollBar = new ScrollBar(viewport, { mainScene: scene });
const scrollToBarPos = vi.spyOn(viewport, 'scrollToBarPos');
const scrollByBarDeltaValue = vi.spyOn(viewport, 'scrollByBarDeltaValue');
const beginScrollbarDrag = vi.spyOn(scene, 'beginScrollbarDrag');
const endScrollbarDrag = vi.spyOn(scene, 'endScrollbarDrag');
const setCapture = vi.spyOn(engine, 'setCapture').mockImplementation(() => {});
scrollBar.resize(160, 120, 640, 480);
@@ -195,6 +197,8 @@ describe('ScrollBar', () => {
expect(scrollToBarPos).toHaveBeenCalledWith({ y: expect.any(Number) });
expect(scrollByBarDeltaValue).toHaveBeenCalledWith({ x: 16 }, true, { isBarDragEnd: true });
expect(scrollByBarDeltaValue).toHaveBeenCalledWith({ y: 24 }, true, { isBarDragEnd: true });
expect(beginScrollbarDrag).toHaveBeenCalledTimes(2);
expect(endScrollbarDrag).toHaveBeenCalledTimes(2);
expect(setCapture).toHaveBeenCalled();
scrollBar.dispose();
@@ -203,6 +207,7 @@ describe('ScrollBar', () => {
it('coalesces rapid thumb drag deltas before scrolling the viewport', () => {
const scrollBar = new ScrollBar(viewport, { mainScene: scene });
const scrollByBarDeltaValue = vi.spyOn(viewport, 'scrollByBarDeltaValue');
const updateScrollbarDrag = vi.spyOn(scene, 'updateScrollbarDrag');
const rafCallbacks: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
rafCallbacks.push(callback);
@@ -222,52 +227,45 @@ describe('ScrollBar', () => {
expect(scrollByBarDeltaValue).toHaveBeenCalledOnce();
expect(scrollByBarDeltaValue).toHaveBeenCalledWith({ y: 24 }, true, { isBarDragging: true });
expect(updateScrollbarDrag).toHaveBeenCalledWith(viewport);
scrollBar.dispose();
});
it('throttles continued thumb drag scrolling while preserving the latest delta', () => {
vi.useFakeTimers();
try {
const scrollBar = new ScrollBar(viewport, { mainScene: scene });
const scrollByBarDeltaValue = vi.spyOn(viewport, 'scrollByBarDeltaValue');
const rafCallbacks: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
it('schedules continued thumb drag scrolling on the next display frame', () => {
const scrollBar = new ScrollBar(viewport, { mainScene: scene });
const scrollByBarDeltaValue = vi.spyOn(viewport, 'scrollByBarDeltaValue');
const rafCallbacks: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
scrollBar.resize(160, 120, 640, 480);
scrollBar.verticalThumbRect!.onPointerDown$.emitEvent({ offsetX: 154, offsetY: 24 } as any);
scene.onPointerMove$.emitEvent({ offsetX: 154, offsetY: 36 } as any);
scene.onPointerMove$.emitEvent({ offsetX: 154, offsetY: 48 } as any);
scrollBar.resize(160, 120, 640, 480);
scrollBar.verticalThumbRect!.onPointerDown$.emitEvent({ offsetX: 154, offsetY: 24 } as any);
scene.onPointerMove$.emitEvent({ offsetX: 154, offsetY: 36 } as any);
scene.onPointerMove$.emitEvent({ offsetX: 154, offsetY: 48 } as any);
rafCallbacks[0](16);
expect(scrollByBarDeltaValue).toHaveBeenCalledOnce();
expect(scrollByBarDeltaValue).toHaveBeenLastCalledWith({ y: 24 }, true, { isBarDragging: true });
rafCallbacks[0](16);
expect(scrollByBarDeltaValue).toHaveBeenCalledOnce();
expect(scrollByBarDeltaValue).toHaveBeenLastCalledWith({ y: 24 }, true, { isBarDragging: true });
scene.onPointerMove$.emitEvent({ offsetX: 154, offsetY: 60 } as any);
scene.onPointerMove$.emitEvent({ offsetX: 154, offsetY: 72 } as any);
scene.onPointerMove$.emitEvent({ offsetX: 154, offsetY: 60 } as any);
scene.onPointerMove$.emitEvent({ offsetX: 154, offsetY: 72 } as any);
expect(rafCallbacks).toHaveLength(1);
expect(scrollByBarDeltaValue).toHaveBeenCalledOnce();
expect(rafCallbacks).toHaveLength(2);
expect(scrollByBarDeltaValue).toHaveBeenCalledOnce();
vi.advanceTimersByTime(32);
expect(rafCallbacks).toHaveLength(2);
rafCallbacks[1](32);
expect(scrollByBarDeltaValue).toHaveBeenCalledTimes(2);
expect(scrollByBarDeltaValue).toHaveBeenLastCalledWith({ y: 24 }, true, { isBarDragging: true });
rafCallbacks[1](48);
expect(scrollByBarDeltaValue).toHaveBeenCalledTimes(2);
expect(scrollByBarDeltaValue).toHaveBeenLastCalledWith({ y: 24 }, true, { isBarDragging: true });
scene.onPointerUp$.emitEvent({ offsetX: 154, offsetY: 72 } as any);
expect(scrollByBarDeltaValue).toHaveBeenCalledTimes(3);
expect(scrollByBarDeltaValue).toHaveBeenLastCalledWith({ y: 0 }, true, { isBarDragEnd: true });
scene.onPointerUp$.emitEvent({ offsetX: 154, offsetY: 72 } as any);
expect(scrollByBarDeltaValue).toHaveBeenCalledTimes(3);
expect(scrollByBarDeltaValue).toHaveBeenLastCalledWith({ y: 0 }, true, { isBarDragEnd: true });
scrollBar.dispose();
} finally {
vi.useRealTimers();
}
scrollBar.dispose();
});
it('supports single-axis scrollbars and no-op resize guards', () => {
+10 -30
View File
@@ -62,7 +62,6 @@ const DEFAULT_TRACK_SIZE = 10;
const DEFAULT_TRACK_BORDER_SIZE = 1;
const DEFAULT_THUMB_MARGIN = 2;
const HOVER_THUMB_MARGIN = 1;
const BAR_DRAG_SCROLL_THROTTLE_MS = 32;
export class ScrollBar extends Disposable {
static readonly DEFAULT_TOTAL_SIZE = DEFAULT_TRACK_SIZE + DEFAULT_TRACK_BORDER_SIZE;
@@ -97,7 +96,6 @@ export class ScrollBar extends Disposable {
private _pendingBarDeltaX = 0;
private _pendingBarDeltaY = 0;
private _pendingBarScrollFrameId: number | null = null;
private _pendingBarScrollThrottleId: number | null = null;
private _horizonPointerMoveSub: Nullable<Subscription>;
private _horizonPointerUpSub: Nullable<Subscription>;
@@ -359,6 +357,7 @@ export class ScrollBar extends Disposable {
override dispose() {
super.dispose();
this._flushPendingBarScroll();
(this._mainScene || this._viewport.scene).endScrollbarDrag(this._viewport);
this.horizonScrollTrack?.dispose();
this.horizonThumbRect?.dispose();
this.verticalScrollTrack?.dispose();
@@ -384,28 +383,13 @@ export class ScrollBar extends Disposable {
this._pendingBarDeltaX += delta.x ?? 0;
this._pendingBarDeltaY += delta.y ?? 0;
if (this._pendingBarScrollFrameId !== null || this._pendingBarScrollThrottleId !== null) {
if (this._pendingBarScrollFrameId !== null) {
return;
}
this._requestPendingBarScrollFrame();
}
private _requestPendingBarScrollFrame() {
this._pendingBarScrollFrameId = requestAnimationFrame(() => {
this._pendingBarScrollFrameId = null;
this._applyPendingBarScroll({ isBarDragging: true });
if (!this._isHorizonMove && !this._isVerticalMove) {
return;
}
this._pendingBarScrollThrottleId = window.setTimeout(() => {
this._pendingBarScrollThrottleId = null;
if (this._pendingBarDeltaX !== 0 || this._pendingBarDeltaY !== 0) {
this._requestPendingBarScrollFrame();
}
}, BAR_DRAG_SCROLL_THROTTLE_MS);
});
}
@@ -414,11 +398,6 @@ export class ScrollBar extends Disposable {
cancelAnimationFrame(this._pendingBarScrollFrameId);
this._pendingBarScrollFrameId = null;
}
if (this._pendingBarScrollThrottleId !== null) {
clearTimeout(this._pendingBarScrollThrottleId);
this._pendingBarScrollThrottleId = null;
}
return this._applyPendingBarScroll({ isBarDragEnd });
}
@@ -435,6 +414,9 @@ export class ScrollBar extends Disposable {
...(x === 0 ? null : { x }),
...(y === 0 ? null : { y }),
}, true, options);
if (options?.isBarDragging) {
(this._mainScene || this._viewport.scene).updateScrollbarDrag(this._viewport);
}
return true;
}
@@ -703,7 +685,6 @@ export class ScrollBar extends Disposable {
}));
}
// events for pointerdown at scroll track
if (this.verticalScrollTrack) {
this._eventSub.add(this.verticalScrollTrack.onPointerDown$.subscribeEvent((evt: unknown, state: EventState) => {
const e = evt as IPointerEvent | IMouseEvent;
@@ -715,16 +696,14 @@ export class ScrollBar extends Disposable {
}));
}
// drag events for vertical scrollbar
// scene.input-manager@_onPointerDown --> base-object@triggerPointerDown!
if (this.verticalThumbRect) {
this._eventSub.add(this.verticalThumbRect.onPointerDown$.subscribeEvent((evt: unknown, state: EventState) => {
const e = evt as IPointerEvent | IMouseEvent;
const srcElement = this.verticalThumbRect;
this._isVerticalMove = true;
mainScene.beginScrollbarDrag(this._viewport);
this._lastX = e.offsetX;
this._lastY = e.offsetY;
// srcElement.fill = this._thumbHoverBackgroundColor!;
srcElement?.setProps({
fill: this._thumbActiveBackgroundColor!,
});
@@ -735,7 +714,6 @@ export class ScrollBar extends Disposable {
}));
}
// pointer down then move on scrollbar
this._verticalPointerMoveSub = mainScene.onPointerMove$.subscribeEvent((evt: unknown, _state: EventState) => {
const e = evt as IPointerEvent | IMouseEvent;
if (!this._isVerticalMove) {
@@ -759,6 +737,7 @@ export class ScrollBar extends Disposable {
this._viewport.scrollByBarDeltaValue({ y: 0 }, true, { isBarDragEnd: true });
}
this._isVerticalMove = false;
mainScene.endScrollbarDrag(this._viewport);
mainScene.releaseCapturedObject();
mainScene.enableObjectsEvent();
srcElement?.setProps({
@@ -841,6 +820,7 @@ export class ScrollBar extends Disposable {
this._eventSub.add(this.horizonThumbRect.onPointerDown$.subscribeEvent((evt: unknown, state: EventState) => {
const e = evt as IPointerEvent | IMouseEvent;
this._isHorizonMove = true;
mainScene.beginScrollbarDrag(this._viewport);
this._lastX = e.offsetX;
this._lastY = e.offsetY;
this.horizonThumbRect?.setProps({
@@ -865,8 +845,7 @@ export class ScrollBar extends Disposable {
this._lastX = e.offsetX;
mainScene.getEngine()?.setCapture();
});
this._horizonPointerUpSub = mainScene.onPointerUp$.subscribeEvent((evt: unknown, state: EventState) => {
;
this._horizonPointerUpSub = mainScene.onPointerUp$.subscribeEvent(() => {
if (!this._isHorizonMove) {
return;
}
@@ -875,6 +854,7 @@ export class ScrollBar extends Disposable {
this._viewport.scrollByBarDeltaValue({ x: 0 }, true, { isBarDragEnd: true });
}
this._isHorizonMove = false;
mainScene.endScrollbarDrag(this._viewport);
mainScene.releaseCapturedObject();
mainScene.enableObjectsEvent();
this.horizonThumbRect?.setProps({
+44 -13
View File
@@ -457,6 +457,12 @@ export class Viewport {
get canvas() { return this._cacheCanvas; }
swapCacheCanvas(cacheCanvas: UniverCanvas) {
const previousCacheCanvas = this._cacheCanvas;
this._cacheCanvas = cacheCanvas;
return previousCacheCanvas;
}
enable() {
this._active = true;
}
@@ -747,7 +753,12 @@ export class Viewport {
* @param objects
* @param isMaxLayer
*/
render(parentCtx?: UniverRenderingContext, objects: BaseObject[] = [], isMaxLayer = false): void {
render(
parentCtx?: UniverRenderingContext,
objects: BaseObject[] = [],
isMaxLayer = false,
viewportInfo?: IViewportInfo
): void {
if (!this.shouldIntoRender()) {
return;
}
@@ -773,7 +784,7 @@ export class Viewport {
// set scrolling state for mainCtx,
mainCtx.transform(tm[0], tm[1], tm[2], tm[3], tm[4], tm[5]);
const viewPortInfo = this.calcViewportInfo();
const viewPortInfo = viewportInfo ?? this.calcViewportInfo();
for (let i = 0, length = objects.length; i < length; i++) {
objects[i].render(mainCtx, viewPortInfo);
@@ -1488,13 +1499,15 @@ export class Viewport {
const additionalAreas: IBoundRectNoAngle[] = [];
// Extend each exposed strip only toward the retained cache content. Expanding the
// strip on its other edges repaints pixels that are already valid in the cache.
// curr has an extra part on the left compared to prev.
if (currBound.left < prevBound.left) {
additionalAreas.push({
top: currBound.top,
bottom: currBound.bottom,
left: currBound.left,
right: prevBound.left,
right: Math.min(currBound.right, prevBound.left + this.bufferEdgeX),
});
}
@@ -1503,7 +1516,7 @@ export class Viewport {
additionalAreas.push({
top: currBound.top,
bottom: currBound.bottom,
left: prevBound.right,
left: Math.max(currBound.left, prevBound.right - this.bufferEdgeX),
right: currBound.right,
});
}
@@ -1511,7 +1524,7 @@ export class Viewport {
if (currBound.top < prevBound.top) {
additionalAreas.push({
top: currBound.top,
bottom: prevBound.top,
bottom: Math.min(currBound.bottom, prevBound.top + this.bufferEdgeY),
left: Math.max(prevBound.left, currBound.left),
right: Math.min(prevBound.right, currBound.right),
});
@@ -1519,19 +1532,12 @@ export class Viewport {
if (currBound.bottom > prevBound.bottom) {
additionalAreas.push({
top: prevBound.bottom,
top: Math.max(currBound.top, prevBound.bottom - this.bufferEdgeY),
bottom: currBound.bottom,
left: Math.max(prevBound.left, currBound.left),
right: Math.min(prevBound.right, currBound.right),
});
}
for (const bound of additionalAreas) {
bound.left = bound.left - this.bufferEdgeX;
bound.right = bound.right + this.bufferEdgeX;
bound.top = bound.top - this.bufferEdgeY;
bound.bottom = bound.bottom + this.bufferEdgeY;
}
return additionalAreas;
}
@@ -1547,6 +1553,31 @@ export class Viewport {
}
}
renderScrollbarOnly(ctx: UniverRenderingContext, pixelRatio: number) {
const scrollBar = this._scrollBar;
if (!scrollBar) {
return;
}
const width = this.width ?? 0;
const height = this.height ?? 0;
ctx.save();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
if (scrollBar.enableVertical) {
ctx.clearRect(this.left + width - scrollBar.totalSize, this.top, scrollBar.totalSize, height);
}
if (scrollBar.enableHorizontal) {
ctx.clearRect(this.left, this.top + height - scrollBar.totalSize, width, scrollBar.totalSize);
}
ctx.restore();
ctx.save();
const scrollbarTM = this.getScrollBarTransForm().getMatrix();
ctx.transform(scrollbarTM[0], scrollbarTM[1], scrollbarTM[2], scrollbarTM[3], scrollbarTM[4], scrollbarTM[5]);
this._drawScrollbar(ctx);
ctx.restore();
}
setViewportSize(props?: IViewProps) {
if (Tools.isDefine(props?.top)) {
this.top = props.top;
@@ -16,7 +16,7 @@
import type { IRange } from '@univerjs/core';
import { INTERCEPTOR_POINT } from '@univerjs/sheets';
import { DEFAULT_PADDING, DEFAULT_WIDTH } from '@univerjs/sheets-conditional-formatting';
import { CFRuleType, ConditionalFormattingIcon, DataBar, dataBarUKey, DEFAULT_PADDING, DEFAULT_WIDTH, IconUKey } from '@univerjs/sheets-conditional-formatting';
import { Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SheetsCfRenderController } from '../cf.render.controller';
@@ -27,23 +27,52 @@ afterEach(() => {
interface ITestConditionFormattingRule {
ranges: IRange[];
rule?: { type: CFRuleType };
}
interface ITestInterceptor {
handler: (cell: unknown, context: unknown, next: (cell: unknown) => unknown) => unknown;
}
interface ITestDirtyEvent {
cfId?: string;
rule?: ITestConditionFormattingRule;
subUnitId: string;
unitId: string;
}
function createController() {
let interceptor: any;
const ruleChange$ = new Subject<any>();
const markDirty$ = new Subject<any>();
let interceptor: ITestInterceptor;
const ruleChange$ = new Subject<ITestDirtyEvent>();
const markDirty$ = new Subject<ITestDirtyEvent>();
const reCalculate = vi.fn();
const makeDirty = vi.fn();
const resetRangeCache = vi.fn();
const dataBar = new DataBar();
const icon = new ConditionalFormattingIcon();
const setDataBarRenderRangeResolver = vi.spyOn(dataBar, 'setRenderRangeResolver');
const setIconRenderRangeResolver = vi.spyOn(icon, 'setRenderRangeResolver');
const getRulesByRanges = vi.fn(() => [] as Array<{ ranges: IRange[]; rule: { type: CFRuleType } }>);
const renderCreated$ = new Subject<unknown>();
const renderDisposed$ = new Subject<string>();
const rowColumnSegment = { startRow: 0, endRow: 10, startColumn: 0, endColumn: 10 };
const getRule = vi.fn<() => ITestConditionFormattingRule>(() => ({
ranges: [{ startRow: 2, endRow: 4, startColumn: 1, endColumn: 3 }],
}));
const getSubunitRules = vi.fn<() => ITestConditionFormattingRule[]>(() => []);
const getExtensionByKey = vi.fn((key: string) => key === dataBarUKey ? dataBar : key === IconUKey ? icon : undefined);
const render = {
unitId: 'unit-1',
type: 'UNIVER_SHEET',
with: vi.fn(() => ({ getCurrentSkeleton: vi.fn(() => ({ resetRangeCache, rowColumnSegment })), reCalculate })),
mainComponent: {
makeDirty,
getExtensionByKey,
},
};
const controller = new SheetsCfRenderController(
{
intercept: vi.fn((point, config) => {
intercept: vi.fn((point, config: ITestInterceptor) => {
expect(point).toBe(INTERCEPTOR_POINT.CELL_CONTENT);
interceptor = config;
return { dispose: vi.fn() };
@@ -64,19 +93,47 @@ function createController() {
})),
} as never,
{
getRenderUnitById: vi.fn(() => ({
with: vi.fn(() => ({ getCurrentSkeleton: vi.fn(() => ({ resetRangeCache, rowColumnSegment })), reCalculate })),
mainComponent: { makeDirty },
})),
created$: renderCreated$,
disposed$: renderDisposed$,
getAllRenderersOfType: vi.fn(() => [render]),
getRenderUnitById: vi.fn(() => render),
} as never,
{ markDirty$ } as never,
{ $ruleChange: ruleChange$, getRule, getSubunitRules } as never
{ $ruleChange: ruleChange$, getRule, getSubunitRules } as never,
{ getRulesByRanges } as never
);
return { controller, getRule, getSubunitRules, interceptor: () => interceptor, markDirty$, makeDirty, reCalculate, resetRangeCache, rowColumnSegment, ruleChange$ };
return { controller, getExtensionByKey, getRule, getRulesByRanges, getSubunitRules, interceptor: () => interceptor, markDirty$, makeDirty, reCalculate, resetRangeCache, rowColumnSegment, ruleChange$, setDataBarRenderRangeResolver, setIconRenderRangeResolver };
}
describe('SheetsCfRenderController', () => {
it('falls back to source rules when the range index has not registered an icon set', () => {
const { controller, getExtensionByKey, getRulesByRanges, getSubunitRules, setDataBarRenderRangeResolver, setIconRenderRangeResolver } = createController();
const ranges = [{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 }];
const dataBarResolver = setDataBarRenderRangeResolver.mock.calls[0][0];
const iconResolver = setIconRenderRangeResolver.mock.calls[0][0];
expect(getExtensionByKey).toHaveBeenCalledTimes(2);
expect(getExtensionByKey).toHaveBeenCalledWith(dataBarUKey);
expect(getExtensionByKey).toHaveBeenCalledWith(IconUKey);
expect(dataBarResolver).not.toBeNull();
expect(iconResolver).not.toBeNull();
if (!dataBarResolver || !iconResolver) {
throw new Error('Expected conditional-formatting render range resolvers to be bound');
}
getRulesByRanges.mockReturnValue([{ ranges, rule: { type: CFRuleType.dataBar } }]);
expect(dataBarResolver('unit-1', 'sheet-1', ranges)).toEqual([expect.objectContaining(ranges[0])]);
getRulesByRanges.mockReturnValue([]);
getSubunitRules.mockReturnValue([{ ranges, rule: { type: CFRuleType.iconSet } }]);
expect(iconResolver('unit-1', 'sheet-1', ranges)).toEqual([expect.objectContaining(ranges[0])]);
controller.dispose();
expect(setDataBarRenderRangeResolver).toHaveBeenLastCalledWith(null);
expect(setIconRenderRangeResolver).toHaveBeenLastCalledWith(null);
});
it('composes conditional-formatting style, data bar and icon set into rendered cell data', () => {
const { controller, interceptor } = createController();
const rawCell = { v: 10, s: 'style-1' };
@@ -92,7 +149,12 @@ describe('SheetsCfRenderController', () => {
get: vi.fn(() => ({ fs: 12 })),
})),
},
}, (cell: unknown) => cell);
}, (cell: unknown) => cell) as {
dataBar: unknown;
fontRenderExtension: unknown;
iconSet: unknown;
s: unknown;
};
expect(result).not.toBe(rawCell);
expect(result.s).toEqual({ fs: 12, bg: { rgb: '#00ff00' } });
@@ -124,7 +186,7 @@ describe('SheetsCfRenderController', () => {
it('resets changed ranges found from conditional-formatting dirty cfIds', async () => {
vi.useFakeTimers();
const { controller, getRule, markDirty$, resetRangeCache, rowColumnSegment } = createController();
const { controller, getRule, markDirty$, resetRangeCache } = createController();
markDirty$.next({ unitId: 'unit-1', subUnitId: 'sheet-1', cfId: 'cf-1' });
await vi.advanceTimersByTimeAsync(20);
@@ -15,11 +15,23 @@
*/
import type { ICellDataForSheetInterceptor, IRange, Workbook } from '@univerjs/core';
import type { IConditionalFormattingCellData, IConditionFormattingRule } from '@univerjs/sheets-conditional-formatting';
import { Disposable, Inject, InterceptorEffectEnum, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import type { IConditionalFormattingCellData, IConditionalFormattingRenderRangeResolver, IConditionFormattingRule } from '@univerjs/sheets-conditional-formatting';
import { Disposable, getIntersectRange, Inject, InterceptorEffectEnum, IUniverInstanceService, Rectangle, UniverInstanceType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { INTERCEPTOR_POINT, SheetInterceptorService } from '@univerjs/sheets';
import { ConditionalFormattingRuleModel, ConditionalFormattingService, ConditionalFormattingViewModel, DEFAULT_PADDING, DEFAULT_WIDTH } from '@univerjs/sheets-conditional-formatting';
import {
CFRuleType,
ConditionalFormattingIcon,
ConditionalFormattingRangeIndexModel,
ConditionalFormattingRuleModel,
ConditionalFormattingService,
ConditionalFormattingViewModel,
DataBar,
dataBarUKey,
DEFAULT_PADDING,
DEFAULT_WIDTH,
IconUKey,
} from '@univerjs/sheets-conditional-formatting';
import {
SheetSkeletonManagerService,
} from '@univerjs/sheets-ui';
@@ -37,24 +49,106 @@ export class SheetsCfRenderController extends Disposable {
dispose: () => boolean;
}>> = new Map();
private _boundRenderExtensions = new Map<string, Set<DataBar | ConditionalFormattingIcon>>();
constructor(
@Inject(SheetInterceptorService) private _sheetInterceptorService: SheetInterceptorService,
@Inject(ConditionalFormattingService) private _conditionalFormattingService: ConditionalFormattingService,
@Inject(IUniverInstanceService) private _univerInstanceService: IUniverInstanceService,
@Inject(IRenderManagerService) private _renderManagerService: IRenderManagerService,
@Inject(ConditionalFormattingViewModel) private _conditionalFormattingViewModel: ConditionalFormattingViewModel,
@Inject(ConditionalFormattingRuleModel) private _conditionalFormattingRuleModel: ConditionalFormattingRuleModel
@Inject(ConditionalFormattingRuleModel) private _conditionalFormattingRuleModel: ConditionalFormattingRuleModel,
@Inject(ConditionalFormattingRangeIndexModel) private _conditionalFormattingRangeIndexModel: ConditionalFormattingRangeIndexModel
) {
super();
this._initViewModelInterceptor();
this._initRenderRangeResolvers();
this._initSkeleton();
queueMicrotask(() => this._markActiveSheetRulesDirty());
this.disposeWithMe(() => {
this._ruleChangeCacheMap.clear();
this._boundRenderExtensions.forEach((extensions) => {
extensions.forEach((extension) => extension.setRenderRangeResolver(null));
});
this._boundRenderExtensions.clear();
});
}
private _initRenderRangeResolvers() {
const dataBarResolver = this._createRenderRangeResolver(CFRuleType.dataBar);
const iconResolver = this._createRenderRangeResolver(CFRuleType.iconSet);
const bind = (unitId: string) => {
this._unbindRenderRangeResolvers(unitId);
const mainComponent = this._renderManagerService.getRenderUnitById(unitId)?.mainComponent;
if (!mainComponent || !('getExtensionByKey' in mainComponent)) {
return;
}
const extensions = new Set<DataBar | ConditionalFormattingIcon>();
const dataBar = mainComponent.getExtensionByKey(dataBarUKey);
if (dataBar instanceof DataBar) {
dataBar.setRenderRangeResolver(dataBarResolver);
extensions.add(dataBar);
}
const icon = mainComponent.getExtensionByKey(IconUKey);
if (icon instanceof ConditionalFormattingIcon) {
icon.setRenderRangeResolver(iconResolver);
extensions.add(icon);
}
if (extensions.size) {
this._boundRenderExtensions.set(unitId, extensions);
}
};
this._renderManagerService.getAllRenderersOfType(UniverInstanceType.UNIVER_SHEET)
.forEach((render) => bind(render.unitId));
this.disposeWithMe(
this._renderManagerService.created$.subscribe((render) => {
if (render.type === UniverInstanceType.UNIVER_SHEET) {
bind(render.unitId);
}
})
);
this.disposeWithMe(
this._renderManagerService.disposed$.subscribe((unitId) => this._unbindRenderRangeResolvers(unitId))
);
}
private _createRenderRangeResolver(ruleType: CFRuleType): IConditionalFormattingRenderRangeResolver {
return (unitId, subUnitId, ranges) => {
const intersections: IRange[] = [];
const indexedRules = this._conditionalFormattingRangeIndexModel
.getRulesByRanges(unitId, subUnitId, ranges)
.filter((rule) => rule.rule.type === ruleType);
const matchingRules = indexedRules.length
? indexedRules
: (this._conditionalFormattingRuleModel.getSubunitRules(unitId, subUnitId) ?? [])
.filter((rule) => rule.rule.type === ruleType);
ranges.forEach((range) => {
matchingRules.forEach((rule) => {
rule.ranges.forEach((ruleRange) => {
const intersection = getIntersectRange(range, ruleRange);
if (intersection) {
intersections.push(intersection);
}
});
});
});
return intersections.length > 1 ? Rectangle.mergeRanges(intersections) : intersections;
};
}
private _unbindRenderRangeResolvers(unitId: string) {
const extensions = this._boundRenderExtensions.get(unitId);
extensions?.forEach((extension) => extension.setRenderRangeResolver(null));
this._boundRenderExtensions.delete(unitId);
}
private _collectDirtyRanges(items: Array<{ cfId?: string; rule?: IConditionFormattingRule; subUnitId: string; unitId: string }>, unitId: string, subUnitId: string): IRange[] {
const ranges: IRange[] = [];
@@ -41,11 +41,13 @@ export class ConditionalFormattingViewModel extends Disposable {
private _cellCache = new LRUMap<string, { cfId: string; result: any; priority: number }[]>(CONDITIONAL_FORMATTING_VIEWPORT_CACHE_LENGTH);
private _markDirty$ = new Subject<{ cfId: string; unitId: string; subUnitId: string; isImmediately?: boolean }>();
private _cacheClear$ = new Subject<void>();
/**
* The rendering layer listens to this variable to determine whether a reRender is necessary.
* @memberof ConditionalFormattingViewModel
*/
public markDirty$ = this._markDirty$.asObservable();
public cacheClear$ = this._cacheClear$.asObservable();
constructor(
@Inject(Injector) private _injector: Injector,
@Inject(ConditionalFormattingRuleModel) private _conditionalFormattingRuleModel: ConditionalFormattingRuleModel,
@@ -171,6 +173,7 @@ export class ConditionalFormattingViewModel extends Disposable {
clearCache() {
this._calculateUnitManagers.clear();
this._cellCache.clear();
this._cacheClear$.next();
}
private _handleCustomFormulasSeparately() {
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import type { IRange } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { IIconSetType } from '../../models/icon-map';
import { DataBar } from '../data-bar.render';
@@ -72,9 +73,12 @@ function createSkeleton(cells: Record<string, unknown>, hiddenRows = new Set<num
return {
rowColumnSegment: { startRow: 0, endRow: 2, startColumn: 0, endColumn: 2 },
worksheet: {
getUnitId: () => 'unit-1',
getSheetId: () => 'sheet-1',
getMergeData: () => [],
getRowVisible: (row: number) => !hiddenRows.has(row),
getColVisible: (col: number) => !hiddenCols.has(col),
getCell: (row: number, col: number) => cells[`${row},${col}`],
getCell: vi.fn((row: number, col: number) => cells[`${row},${col}`]),
},
getCellWithCoordByIndex: (row: number, col: number) => createCellBounds(row, col),
} as any;
@@ -136,6 +140,60 @@ describe('conditional formatting render extensions', () => {
expect(ctx.beginPath).not.toHaveBeenCalled();
});
it('skips data bar cell visits only when a configured rule-range resolver confirms no matching rule', () => {
const ctx = createCtx();
const skeleton = createSkeleton({
'0,0': {
dataBar: {
color: '#2f56ef',
value: 75,
startPoint: 25,
isGradient: false,
isShowValue: true,
},
},
});
const dataBar = new DataBar();
const resolver = vi.fn(() => []);
dataBar.setRenderRangeResolver(resolver);
const ranges = [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }];
dataBar.draw(ctx, { scaleX: 1, scaleY: 1 }, skeleton, ranges);
expect(resolver).toHaveBeenCalledWith('unit-1', 'sheet-1', ranges);
expect(skeleton.worksheet.getCell).not.toHaveBeenCalled();
expect(ctx.save).not.toHaveBeenCalled();
});
it('falls back to the original data bar path when indexed ranges are unavailable or merges exist', () => {
const ctx = createCtx();
const skeleton = createSkeleton({
'0,0': {
dataBar: {
color: '#2f56ef',
value: 75,
startPoint: 25,
isGradient: false,
isShowValue: true,
},
},
});
const dataBar = new DataBar();
const resolver = vi.fn<() => IRange[] | null>(() => null);
dataBar.setRenderRangeResolver(resolver);
const ranges = [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }];
dataBar.draw(ctx, { scaleX: 1, scaleY: 1 }, skeleton, ranges);
expect(ctx.fill).toHaveBeenCalledTimes(1);
resolver.mockReturnValue([]);
skeleton.worksheet.getMergeData = () => [ranges[0]] as never;
dataBar.draw(ctx, { scaleX: 1, scaleY: 1 }, skeleton, ranges);
expect(resolver).toHaveBeenCalledTimes(1);
expect(ctx.fill).toHaveBeenCalledTimes(2);
});
it('draws configured conditional-formatting icons and skips unavailable icons', () => {
const ctx = createCtx();
const icon = new ConditionalFormattingIcon();
@@ -211,4 +269,19 @@ describe('conditional formatting render extensions', () => {
]);
expect(ctx.drawImage).not.toHaveBeenCalled();
});
it('skips icon cell visits when a configured rule-range resolver confirms no matching rule', () => {
const ctx = createCtx();
const skeleton = createSkeleton({});
const icon = new ConditionalFormattingIcon();
const resolver = vi.fn(() => []);
icon.setRenderRangeResolver(resolver);
const ranges = [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 2 }];
icon.draw(ctx, { scaleX: 1, scaleY: 1 }, skeleton, ranges);
expect(resolver).toHaveBeenCalledWith('unit-1', 'sheet-1', ranges);
expect(skeleton.worksheet.getCell).not.toHaveBeenCalled();
expect(ctx.save).not.toHaveBeenCalled();
});
});
@@ -16,7 +16,7 @@
import type { IRange, IScale } from '@univerjs/core';
import type { SpreadsheetSkeleton, UniverRenderingContext } from '@univerjs/engine-render';
import type { IDataBarCellData } from './type';
import type { IConditionalFormattingRenderRangeResolver, IDataBarCellData } from './type';
import { Range } from '@univerjs/core';
import { FIX_ONE_PIXEL_BLUR_OFFSET, SheetExtension, SpreadsheetExtensionRegistry } from '@univerjs/engine-render';
@@ -36,11 +36,16 @@ const stringifyRange = (range: IRange) => {
export class DataBar extends SheetExtension {
private _paddingRightAndLeft = 2;
private _paddingTopAndBottom = 2;
private _renderRangeResolver: IConditionalFormattingRenderRangeResolver | null = null;
override uKey = dataBarUKey;
override Z_INDEX = EXTENSION_Z_INDEX;
_radius = 1;
setRenderRangeResolver(resolver: IConditionalFormattingRenderRangeResolver | null) {
this._renderRangeResolver = resolver;
}
// eslint-disable-next-line max-lines-per-function
override draw(
ctx: UniverRenderingContext,
@@ -53,7 +58,13 @@ export class DataBar extends SheetExtension {
return false;
}
const mergeCellRendered = new Set<string>();
const renderRanges = diffRanges?.length ? diffRanges : [spreadsheetSkeleton.rowColumnSegment];
const sourceRanges = diffRanges?.length ? diffRanges : [spreadsheetSkeleton.rowColumnSegment];
const renderRanges = this._renderRangeResolver && worksheet.getMergeData().length === 0
? this._renderRangeResolver(worksheet.getUnitId(), worksheet.getSheetId(), sourceRanges) ?? sourceRanges
: sourceRanges;
if (!renderRanges.length) {
return;
}
ctx.save();
// ctx.globalCompositeOperation = 'destination-over';
// eslint-disable-next-line max-lines-per-function
@@ -141,6 +152,11 @@ export class DataBar extends SheetExtension {
ctx.restore();
}
override dispose(): void {
this._renderRangeResolver = null;
super.dispose();
}
private _drawRectWithRoundedCorner(ctx: UniverRenderingContext, x: number, y: number, width: number, height: number, topLeftRadius: boolean, topRightRadius: boolean, bottomRightRadius: boolean, bottomLeftRadius: boolean) {
const radius = this._radius;
if (!height || !width) {
@@ -16,7 +16,7 @@
import type { IRange, IScale } from '@univerjs/core';
import type { SpreadsheetSkeleton, UniverRenderingContext } from '@univerjs/engine-render';
import type { IIconSetCellData } from './type';
import type { IConditionalFormattingRenderRangeResolver, IIconSetCellData } from './type';
import { Range } from '@univerjs/core';
import { SheetExtension, SpreadsheetExtensionRegistry } from '@univerjs/engine-render';
import { iconMap, IIconSetType } from '../models/icon-map';
@@ -37,6 +37,7 @@ export class ConditionalFormattingIcon extends SheetExtension {
private _width = DEFAULT_WIDTH;
private _imageMap: Map<string, HTMLImageElement> = new Map();
private _renderRangeResolver: IConditionalFormattingRenderRangeResolver | null = null;
override uKey = IconUKey;
override Z_INDEX = EXTENSION_Z_INDEX;
@@ -46,6 +47,10 @@ export class ConditionalFormattingIcon extends SheetExtension {
this._init();
}
setRenderRangeResolver(resolver: IConditionalFormattingRenderRangeResolver | null) {
this._renderRangeResolver = resolver;
}
override draw(
ctx: UniverRenderingContext,
_parentScale: IScale,
@@ -57,7 +62,13 @@ export class ConditionalFormattingIcon extends SheetExtension {
return false;
}
const mergeCellRendered = new Set<string>();
const renderRanges = diffRanges?.length ? diffRanges : [spreadsheetSkeleton.rowColumnSegment];
const sourceRanges = diffRanges?.length ? diffRanges : [spreadsheetSkeleton.rowColumnSegment];
const renderRanges = this._renderRangeResolver && worksheet.getMergeData().length === 0
? this._renderRangeResolver(worksheet.getUnitId(), worksheet.getSheetId(), sourceRanges) ?? sourceRanges
: sourceRanges;
if (!renderRanges.length) {
return;
}
ctx.save();
// ctx.globalCompositeOperation = 'destination-over';
renderRanges.forEach((range) => {
@@ -116,6 +127,11 @@ export class ConditionalFormattingIcon extends SheetExtension {
ctx.restore();
}
override dispose(): void {
this._renderRangeResolver = null;
super.dispose();
}
private _init() {
for (const type in iconMap) {
const list = iconMap[type as IIconSetType];
@@ -14,9 +14,15 @@
* limitations under the License.
*/
import type { ICellData } from '@univerjs/core';
import type { ICellData, IRange } from '@univerjs/core';
import type { IIconSetType } from '../models/icon-map';
export type IConditionalFormattingRenderRangeResolver = (
unitId: string,
subUnitId: string,
ranges: IRange[]
) => IRange[] | null;
export interface IDataBarRenderParams {
color: string;
value: number; // -100 - 100.
@@ -15,7 +15,8 @@
*/
import { Injector } from '@univerjs/core';
import { beforeEach, describe, expect, it } from 'vitest';
import { Subject } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CFRuleType } from '../../base/const';
import { ConditionalFormattingRuleModel } from '../../models/conditional-formatting-rule-model';
import { ConditionalFormattingViewModel } from '../../models/conditional-formatting-view-model';
@@ -25,22 +26,33 @@ describe('ConditionalFormattingStyleComposer', () => {
let service: ConditionalFormattingStyleComposer;
const rules = new Map<string, unknown>();
let cellCfs: Array<{ cfId: string; result: unknown; priority: number }>;
let ruleChange$: Subject<void>;
let markDirty$: Subject<void>;
let cacheClear$: Subject<void>;
const getCellCfs = vi.fn(() => cellCfs);
class TestConditionalFormattingRuleModel {
$ruleChange = ruleChange$;
getRule(_unitId: string, _subUnitId: string, cfId: string) {
return rules.get(cfId);
}
}
class TestConditionalFormattingViewModel {
getCellCfs() {
return cellCfs;
}
markDirty$ = markDirty$;
cacheClear$ = cacheClear$;
getCellCfs = getCellCfs;
}
beforeEach(() => {
rules.clear();
cellCfs = [];
ruleChange$ = new Subject();
markDirty$ = new Subject();
cacheClear$ = new Subject();
getCellCfs.mockClear();
const injector = new Injector();
injector.add([ConditionalFormattingRuleModel, { useClass: TestConditionalFormattingRuleModel as never }]);
injector.add([ConditionalFormattingViewModel, { useClass: TestConditionalFormattingViewModel as never }]);
@@ -61,6 +73,36 @@ describe('ConditionalFormattingStyleComposer', () => {
});
});
it('composes highlight, data bar, and icon set rules on the same cell', () => {
const dataBar = {
color: '#38bdf8',
isGradient: false,
isShowValue: true,
startPoint: 0,
value: 50,
};
const iconSet = {
iconId: '0',
iconType: '3Arrows',
isShowValue: true,
};
rules.set('highlight', { stopIfTrue: false, rule: { type: CFRuleType.highlightCell } });
rules.set('data-bar', { stopIfTrue: false, rule: { type: CFRuleType.dataBar } });
rules.set('icon-set', { stopIfTrue: false, rule: { type: CFRuleType.iconSet } });
cellCfs = [
{ cfId: 'icon-set', result: iconSet, priority: 1 },
{ cfId: 'data-bar', result: dataBar, priority: 2 },
{ cfId: 'highlight', result: { bg: { rgb: '#fff7cc' } }, priority: 3 },
];
expect(service.composeStyle('book-1', 'sheet-1', 1, 1)).toEqual({
style: { bg: { rgb: '#fff7cc' } },
dataBar,
iconSet,
isShowValue: true,
});
});
it('stops evaluating lower-priority rules after a matched stop-if-true rule', () => {
rules.set('stop', { stopIfTrue: true, rule: { type: CFRuleType.highlightCell } });
rules.set('ignored', { stopIfTrue: false, rule: { type: CFRuleType.colorScale } });
@@ -73,4 +115,26 @@ describe('ConditionalFormattingStyleComposer', () => {
style: { bg: { rgb: '#ff0000' } },
});
});
it('reuses the composed result until conditional formatting changes', () => {
rules.set('highlight', { stopIfTrue: false, rule: { type: CFRuleType.highlightCell } });
cellCfs = [{ cfId: 'highlight', result: { cl: { rgb: '#333333' } }, priority: 1 }];
const first = service.composeStyle('book-1', 'sheet-1', 1, 1);
const second = service.composeStyle('book-1', 'sheet-1', 1, 1);
expect(second).toBe(first);
expect(getCellCfs).toHaveBeenCalledOnce();
markDirty$.next();
service.composeStyle('book-1', 'sheet-1', 1, 1);
expect(getCellCfs).toHaveBeenCalledTimes(2);
ruleChange$.next();
service.composeStyle('book-1', 'sheet-1', 1, 1);
expect(getCellCfs).toHaveBeenCalledTimes(3);
cacheClear$.next();
service.composeStyle('book-1', 'sheet-1', 1, 1);
expect(getCellCfs).toHaveBeenCalledTimes(4);
});
});
@@ -16,25 +16,43 @@
import type { IConditionFormattingRule, IHighlightCell } from '../models/type';
import type { IDataBarCellData, IDataBarRenderParams, IIconSetCellData, IIconSetRenderParams } from '../render/type';
import { Inject, merge } from '@univerjs/core';
import { Disposable, Inject, LRUMap, merge } from '@univerjs/core';
import { CFRuleType } from '../base/const';
import { ConditionalFormattingRuleModel } from '../models/conditional-formatting-rule-model';
import { ConditionalFormattingViewModel } from '../models/conditional-formatting-view-model';
import { CONDITIONAL_FORMATTING_VIEWPORT_CACHE_LENGTH, ConditionalFormattingViewModel } from '../models/conditional-formatting-view-model';
type IComposedStyle = { style?: IHighlightCell['style'] } & IDataBarCellData & IIconSetCellData & { isShowValue: boolean };
export class ConditionalFormattingStyleComposer extends Disposable {
private _cache = new LRUMap<string, IComposedStyle | null>(CONDITIONAL_FORMATTING_VIEWPORT_CACHE_LENGTH);
export class ConditionalFormattingStyleComposer {
constructor(
@Inject(ConditionalFormattingRuleModel) private _conditionalFormattingRuleModel: ConditionalFormattingRuleModel,
@Inject(ConditionalFormattingViewModel) private _conditionalFormattingViewModel: ConditionalFormattingViewModel
) {
// empty
super();
this.disposeWithMe(this._conditionalFormattingRuleModel.$ruleChange.subscribe(() => this._cache.clear()));
this.disposeWithMe(this._conditionalFormattingViewModel.markDirty$.subscribe(() => this._cache.clear()));
this.disposeWithMe(this._conditionalFormattingViewModel.cacheClear$.subscribe(() => this._cache.clear()));
}
override dispose(): void {
this._cache.clear();
super.dispose();
}
// Conditional formats need to be evaluated in priority order.
// Evaluation of subsequent rules stops only if the current rule is matched and stopIfTrue=true.
composeStyle(unitId: string, subUnitId: string, row: number, col: number) {
const cacheKey = `${unitId}_${subUnitId}_${row}_${col}`;
if (this._cache.has(cacheKey)) {
return this._cache.get(cacheKey) ?? null;
}
const cellCfs = this._conditionalFormattingViewModel.getCellCfs(unitId, subUnitId, row, col);
if (!cellCfs?.length) {
this._cache.set(cacheKey, null);
return null;
}
@@ -58,6 +76,7 @@ export class ConditionalFormattingStyleComposer {
}
if (!matchedRules.length) {
this._cache.set(cacheKey, null);
return null;
}
@@ -65,13 +84,14 @@ export class ConditionalFormattingStyleComposer {
? matchedRules.slice(0, stopIfTrueIndex + 1)
: matchedRules;
const result = {} as { style?: IHighlightCell['style'] } & IDataBarCellData & IIconSetCellData & { isShowValue: boolean };
const result = {} as IComposedStyle;
for (let i = effectiveRules.length - 1; i >= 0; i--) {
const { rule, cacheItem } = effectiveRules[i];
this._mergeComposeResult(result, rule, cacheItem.result);
}
this._cache.set(cacheKey, result);
return result;
}
@@ -66,6 +66,8 @@ export interface IFakeViewport {
scrollX: number;
scrollY: number;
isActive: boolean;
isDirty: boolean;
isForceDirty: boolean;
left: number;
top: number;
width: number;
@@ -75,6 +77,7 @@ export interface IFakeViewport {
scrollAnimationFrameId: number | null;
isWheelPreventDefaultX: boolean;
isWheelPreventDefaultY: boolean;
shouldIntoRender(): boolean;
scene?: IFakeScene;
_paddingStartX: number;
_paddingStartY: number;
@@ -109,6 +112,8 @@ export function createFakeViewport(viewportKey: string, options?: Partial<IFakeV
scrollX: 0,
scrollY: 0,
isActive: true,
isDirty: false,
isForceDirty: false,
left: 0,
top: 0,
width: 800,
@@ -118,6 +123,7 @@ export function createFakeViewport(viewportKey: string, options?: Partial<IFakeV
scrollAnimationFrameId: null,
isWheelPreventDefaultX: false,
isWheelPreventDefaultY: false,
shouldIntoRender: () => viewport.isActive && viewport.width > 1 && viewport.height > 1,
_paddingStartX: 0,
_paddingStartY: 0,
_scrollBar: { ratioScrollX: 1, ratioScrollY: 1 },
@@ -197,6 +203,10 @@ export interface IFakeScene {
addObjects(objs: unknown[], layer?: number): void;
enableLayerCache(...layers: number[]): void;
makeDirty(dirty: boolean): void;
makeDirtyForScrolling(): void;
isDirty(): boolean;
isScrollRenderPending(): boolean;
getLayers(): Array<{ getObjectsByOrder(): Array<{ isDirty(): boolean }> }>;
getViewport(key: unknown): IFakeViewport | null;
getMainViewport(): IFakeViewport;
getViewports(): IFakeViewport[];
@@ -253,6 +263,10 @@ export function createFakeScene(
enableObjectsEvent: () => { },
enableLayerCache: () => { },
makeDirty: () => { },
makeDirtyForScrolling: () => { },
isDirty: () => false,
isScrollRenderPending: () => false,
getLayers: () => Array.from(layers.values()) as Array<{ getObjectsByOrder(): Array<{ isDirty(): boolean }> }>,
getViewport: (key) => viewportMap.get(key) ?? null,
getMainViewport: () => viewportMap.get(SHEET_VIEWPORT_KEY.VIEW_MAIN)!,
getViewports: () => Array.from(viewportMap.values()),
@@ -524,6 +538,9 @@ export function createRenderTestBed(options?: { workbookData?: IWorkbookData; de
const mainComponent = {
zIndex: 1,
isDirty: () => false,
isForceDirty: () => false,
getSkeleton: () => ({ worksheet: { getMergeData: () => [] } }),
makeForceDirty: () => { },
onPointerDown$: createTestEvent<any>(),
};
@@ -17,7 +17,7 @@
import { FOCUSING_SHEET, ICommandService } from '@univerjs/core';
import { RENDER_CLASS_TYPE, SHEET_VIEWPORT_KEY } from '@univerjs/engine-render';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ScrollCommand, SetScrollRelativeCommand } from '../../../commands/commands/set-scroll.command';
import { SheetScrollManagerService } from '../../../services/scroll-manager.service';
import { SheetsScrollRenderController } from '../scroll.render-controller';
@@ -45,7 +45,28 @@ function createScrollManagerServiceMock() {
return service;
}
let pendingAnimationFrames: FrameRequestCallback[] = [];
function flushWheelFrame() {
const callback = pendingAnimationFrames.shift();
expect(callback).toBeDefined();
callback!(performance.now());
}
describe('SheetsScrollRenderController', () => {
beforeEach(() => {
pendingAnimationFrames = [];
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
pendingAnimationFrames.push(callback);
return pendingAnimationFrames.length;
}));
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('executes relative scroll command on mousewheel when focused', () => {
const scrollManagerService = createScrollManagerServiceMock();
@@ -65,6 +86,7 @@ describe('SheetsScrollRenderController', () => {
{ ctrlKey: false, shiftKey: false, deltaX: 7, deltaY: 10, preventDefault },
{ stopPropagation: () => { } }
);
flushWheelFrame();
expect(executeSpy).toHaveBeenCalledWith(SetScrollRelativeCommand.id, { offsetX: 7, offsetY: 10 });
@@ -72,6 +94,51 @@ describe('SheetsScrollRenderController', () => {
void _controller;
});
it('uses the scroll fast path only while the engine scene is clean', () => {
const scrollManagerService = createScrollManagerServiceMock();
const testBed = createRenderTestBed({
dependencies: [[SheetScrollManagerService, { useValue: scrollManagerService }]],
parentClassType: RENDER_CLASS_TYPE.ENGINE,
});
const { context, scene, contextService } = testBed;
vi.spyOn(testBed.get(ICommandService), 'executeCommand').mockResolvedValue(true);
const preserveCacheSpy = vi.spyOn(scene, 'makeDirtyForScrolling');
const makeDirtySpy = vi.spyOn(scene, 'makeDirty');
const mainLayer = {
getObjectsByOrder: () => [context.mainComponent],
};
(scene as any).getLayers = () => [mainLayer];
contextService.setContextValue(FOCUSING_SHEET, true);
const controller = testBed.injector.createInstance(SheetsScrollRenderController, context as any);
scene.onMouseWheel$.emit(
{ ctrlKey: false, shiftKey: false, deltaX: 0, deltaY: 10, preventDefault: vi.fn() },
{ stopPropagation: vi.fn() }
);
flushWheelFrame();
expect(preserveCacheSpy).toHaveBeenCalledWith();
scene.isDirty = () => true;
scene.onMouseWheel$.emit(
{ ctrlKey: false, shiftKey: false, deltaX: 0, deltaY: 10, preventDefault: vi.fn() },
{ stopPropagation: vi.fn() }
);
flushWheelFrame();
expect(makeDirtySpy).toHaveBeenCalledWith(true);
expect(preserveCacheSpy).toHaveBeenCalledTimes(1);
scene.isDirty = () => false;
(context.mainComponent as any).isDirty = () => true;
scene.onMouseWheel$.emit(
{ ctrlKey: false, shiftKey: false, deltaX: 0, deltaY: 10, preventDefault: vi.fn() },
{ stopPropagation: vi.fn() }
);
flushWheelFrame();
expect(makeDirtySpy).toHaveBeenCalledTimes(2);
void controller;
});
it('uses shift-wheel horizontal scrolling and prevents default on scrollable viewport', () => {
const scrollManagerService = createScrollManagerServiceMock();
const testBed = createRenderTestBed({
@@ -95,6 +162,7 @@ describe('SheetsScrollRenderController', () => {
{ ctrlKey: false, shiftKey: true, deltaX: 3, deltaY: 7, preventDefault },
{ stopPropagation }
);
flushWheelFrame();
expect(executeSpy).toHaveBeenCalledWith(SetScrollRelativeCommand.id, { offsetX: 21, offsetY: 0 });
expect(preventDefault).toHaveBeenCalled();
@@ -137,6 +205,7 @@ describe('SheetsScrollRenderController', () => {
{ ctrlKey: false, shiftKey: false, deltaX: 0, deltaY: -40, preventDefault },
{ stopPropagation: () => { } }
);
flushWheelFrame();
expect(viewMain.limitedScroll).toHaveBeenCalledWith(0, 2);
expect(preventDefault).toHaveBeenCalled();
@@ -165,12 +234,13 @@ describe('SheetsScrollRenderController', () => {
{ ctrlKey: false, shiftKey: false, deltaX: 12, deltaY: 20, preventDefault },
{ stopPropagation: () => { } }
);
flushWheelFrame();
expect(executeSpy).toHaveBeenCalledWith(SetScrollRelativeCommand.id, { offsetX: 6, offsetY: 10 });
void controller;
});
it('locks out minor cross-axis touchpad jitter while wheel scrolling', () => {
it('locks cross-axis jitter and coalesces wheel events into one engine frame', () => {
const scrollManagerService = createScrollManagerServiceMock();
const testBed = createRenderTestBed({
dependencies: [[SheetScrollManagerService, { useValue: scrollManagerService }]],
@@ -192,9 +262,10 @@ describe('SheetsScrollRenderController', () => {
{ ctrlKey: false, shiftKey: false, deltaX: 20, deltaY: 8, preventDefault },
{ stopPropagation: () => { } }
);
flushWheelFrame();
expect(executeSpy).toHaveBeenNthCalledWith(1, SetScrollRelativeCommand.id, { offsetX: 0, offsetY: 20 });
expect(executeSpy).toHaveBeenNthCalledWith(2, SetScrollRelativeCommand.id, { offsetX: 20, offsetY: 0 });
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(executeSpy).toHaveBeenCalledWith(SetScrollRelativeCommand.id, { offsetX: 20, offsetY: 20 });
void controller;
});
@@ -46,6 +46,10 @@ const WHEEL_CROSS_AXIS_LOCK_RATIO = 2;
* This controller handles scroll logic in sheet interaction.
*/
export class SheetsScrollRenderController extends Disposable implements IRenderModule {
private _pendingWheelOffsetX = 0;
private _pendingWheelOffsetY = 0;
private _pendingWheelFrameId: number | null = null;
constructor(
private readonly _context: IRenderContext<Workbook>,
@Inject(Injector) private readonly _injector: Injector,
@@ -70,6 +74,12 @@ export class SheetsScrollRenderController extends Disposable implements IRenderM
const viewMain = scene.getViewport(SHEET_VIEWPORT_KEY.VIEW_MAIN);
if (!viewMain) return;
this.disposeWithMe(toDisposable(() => {
if (this._pendingWheelFrameId != null) {
cancelAnimationFrame(this._pendingWheelFrameId);
this._pendingWheelFrameId = null;
}
}));
this.disposeWithMe(
scene.onMouseWheel$.subscribeEvent((evt: IWheelEvent, state) => {
if (evt.ctrlKey || !this._contextService.getContextValue(FOCUSING_SHEET)) {
@@ -103,16 +113,21 @@ export class SheetsScrollRenderController extends Disposable implements IRenderM
}
}
// add offset on scroll position to check whether scrolling is reaching limit
const targetViewportScrollX = viewMain.viewportScrollX + offsetX;
const targetViewportScrollY = viewMain.viewportScrollY + offsetY;
this._pendingWheelOffsetX += offsetX;
this._pendingWheelOffsetY += offsetY;
if (this._pendingWheelFrameId == null) {
this._pendingWheelFrameId = requestAnimationFrame(() => {
this._pendingWheelFrameId = null;
this._flushPendingWheelScroll();
});
}
const targetViewportScrollX = viewMain.viewportScrollX + this._pendingWheelOffsetX;
const targetViewportScrollY = viewMain.viewportScrollY + this._pendingWheelOffsetY;
const { x: targetScrollX, y: targetScrollY } = viewMain.transViewportScroll2ScrollValue(
targetViewportScrollX,
targetViewportScrollY
);
this._commandService.executeCommand(SetScrollRelativeCommand.id, { offsetX, offsetY });
this._context.scene.makeDirty(true);
const isLimitedStore = viewMain.limitedScroll(targetScrollX, targetScrollY);
// if viewport still have space to scroll, prevent default event. (DO NOT move canvas element)
@@ -131,6 +146,44 @@ export class SheetsScrollRenderController extends Disposable implements IRenderM
);
}
private _flushPendingWheelScroll() {
const offsetX = this._pendingWheelOffsetX;
const offsetY = this._pendingWheelOffsetY;
if (offsetX === 0 && offsetY === 0) {
return;
}
this._pendingWheelOffsetX = 0;
this._pendingWheelOffsetY = 0;
const canUseScrollFastPath = this._canUseScrollFastPath();
this._commandService.executeCommand(SetScrollRelativeCommand.id, { offsetX, offsetY });
this._markSceneDirtyForScrolling(canUseScrollFastPath);
}
private _canUseScrollFastPath() {
const { scene } = this._context;
const spreadsheet = this._getSheetObject()?.spreadsheet;
return scene.getParent().classType === RENDER_CLASS_TYPE.ENGINE &&
!scene.isDirty() &&
spreadsheet != null &&
!spreadsheet.isDirty() &&
!spreadsheet.isForceDirty() &&
spreadsheet.getSkeleton()?.worksheet.getMergeData().length === 0 &&
scene.getViewports().every((viewport) =>
!viewport.shouldIntoRender() || (!viewport.isDirty && !viewport.isForceDirty)
) &&
scene.getLayers().every((layer) => layer.getObjectsByOrder().every((object) => !object.isDirty()));
}
private _markSceneDirtyForScrolling(canUseScrollFastPath: boolean) {
const { scene } = this._context;
if (canUseScrollFastPath) {
scene.makeDirtyForScrolling();
} else {
scene.makeDirty(true);
}
}
// eslint-disable-next-line max-lines-per-function
private _scrollBarEventListener() {
const { scene } = this._context;