mirror of
https://github.com/dream-num/univer.git
synced 2026-09-01 15:29:43 +08:00
perf(sheets): optimize large sheet scrolling (#7507)
This commit is contained in:
@@ -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
|
||||
|
||||
+53
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -520,6 +520,10 @@ export class Engine extends Disposable {
|
||||
return this._deltaTime;
|
||||
}
|
||||
|
||||
getEstimatedFrameInterval(): number {
|
||||
return this._performanceMonitor.estimatedFrameInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exec all function in _renderFrameTasks
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
+75
-13
@@ -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() {
|
||||
|
||||
+74
-1
@@ -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.
|
||||
|
||||
+68
-4
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+25
-5
@@ -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>(),
|
||||
};
|
||||
|
||||
+75
-4
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user