Skip to content

Commit cdd5fd0

Browse files
authored
fix(engine-render): prevent viewport scroll trails (#7601)
1 parent 97b17b9 commit cdd5fd0

6 files changed

Lines changed: 195 additions & 26 deletions

File tree

packages/engine-render/src/__tests__/engine-scene-viewport.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,53 @@ describe('engine scene viewport extra', () => {
333333
engine.dispose();
334334
});
335335

336+
it('keeps shared viewport boundary pixels out of incremental scroll copies', () => {
337+
const { engine, scene, viewport } = createFixture();
338+
viewport.setViewportSize({ left: 46, top: 20, width: 260, height: 160 });
339+
const rowHeaderViewport = new Viewport('viewRowBottom', scene, {
340+
left: 0,
341+
top: 20,
342+
width: 47,
343+
height: 160,
344+
active: true,
345+
allowCache: true,
346+
});
347+
new Viewport('viewColumnRight', scene, {
348+
left: 46,
349+
top: 0,
350+
width: 260,
351+
height: 21,
352+
active: true,
353+
allowCache: true,
354+
});
355+
356+
viewport.updateScrollVal({ scrollX: 0, scrollY: 35, viewportScrollX: 0, viewportScrollY: 35 });
357+
rowHeaderViewport.updateScrollVal({ scrollX: 0, scrollY: 35, viewportScrollX: 0, viewportScrollY: 35 });
358+
scene.render();
359+
360+
const ctx = engine.getCanvas().getContext();
361+
const drawImageSpy = vi.spyOn(ctx, 'drawImage');
362+
const clearRectSpy = vi.spyOn(ctx, 'clearRect');
363+
364+
viewport.updateScrollVal({ scrollX: 0, scrollY: 0, viewportScrollX: 0, viewportScrollY: 0 });
365+
rowHeaderViewport.updateScrollVal({ scrollX: 0, scrollY: 0, viewportScrollX: 0, viewportScrollY: 0 });
366+
scene.makeDirtyForScrolling();
367+
scene.render();
368+
369+
const engineScrollCopies = drawImageSpy.mock.calls.filter(([source]) => source === ctx.canvas);
370+
const mainCopy = engineScrollCopies.find(([, sourceX, sourceY, sourceWidth]) =>
371+
sourceX === 47 && sourceY === 21 && Number(sourceWidth) > 200
372+
);
373+
expect(mainCopy).toBeDefined();
374+
expect(mainCopy?.[5]).toBe(47);
375+
expect(Number(mainCopy?.[6])).toBeGreaterThan(Number(mainCopy?.[2]));
376+
const copiedOffsetY = Number(mainCopy?.[6]) - Number(mainCopy?.[2]);
377+
expect(clearRectSpy.mock.calls).toContainEqual([47, 21, expect.any(Number), copiedOffsetY]);
378+
379+
scene.dispose();
380+
engine.dispose();
381+
});
382+
336383
it('uses a full render after an after-render observer mutates the engine canvas', () => {
337384
const { engine, scene, viewport } = createFixture();
338385
const layer = scene.getLayer(1);

packages/engine-render/src/components/sheets/watermark/__tests__/watermark-layer.spec.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { beforeEach, describe, expect, it, vi } from 'vitest';
17+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
18+
import { Layer } from '../../../../layer';
1819
import { IWatermarkTypeEnum } from '../type';
1920
import { WatermarkLayer } from '../watermark-layer';
2021

@@ -36,6 +37,9 @@ function createCtx(id = 'main') {
3637
return {
3738
getId: vi.fn(() => id),
3839
save: vi.fn(),
40+
beginPath: vi.fn(),
41+
rect: vi.fn(),
42+
clip: vi.fn(),
3943
restore: vi.fn(),
4044
} as any;
4145
}
@@ -45,6 +49,10 @@ describe('WatermarkLayer', () => {
4549
renderWatermarkMock.mockClear();
4650
});
4751

52+
afterEach(() => {
53+
vi.restoreAllMocks();
54+
});
55+
4856
it('renders configured text watermark with the latest user info', () => {
4957
const layer = new WatermarkLayer(createScene() as never);
5058
const config = {
@@ -137,4 +145,21 @@ describe('WatermarkLayer', () => {
137145

138146
expect(renderWatermarkMock).not.toHaveBeenCalled();
139147
});
148+
149+
it('clips incremental watermark rendering to dirty bounds', () => {
150+
const layer = new WatermarkLayer(createScene() as never);
151+
const ctx = createCtx();
152+
const options = {
153+
dirtyBounds: [{ left: 10, top: 20, right: 30, bottom: 50 }],
154+
preserveCache: true,
155+
viewportInfos: new Map(),
156+
};
157+
const baseRenderSpy = vi.spyOn(Layer.prototype, 'render').mockReturnThis();
158+
159+
layer.render(ctx, false, options);
160+
161+
expect(baseRenderSpy).toHaveBeenCalledWith(ctx, false, options);
162+
expect(ctx.rect).toHaveBeenCalledWith(10, 20, 20, 30);
163+
expect(ctx.clip).toHaveBeenCalledOnce();
164+
});
140165
});

packages/engine-render/src/components/sheets/watermark/watermark-layer.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import type { IUser, Nullable } from '@univerjs/core';
1818
import type { UniverRenderingContext } from '../../../context';
19+
import type { ILayerRenderOptions } from '../../../layer';
1920
import type { IWatermarkConfigWithType } from './type';
2021
import { Layer } from '../../../layer';
2122
import { IWatermarkTypeEnum } from './type';
@@ -26,11 +27,20 @@ export class WatermarkLayer extends Layer {
2627
private _image: Nullable<HTMLImageElement>;
2728
private _user: Nullable<IUser>;
2829

29-
override render(ctx?: UniverRenderingContext, isMaxLayer = false) {
30-
super.render(ctx, isMaxLayer);
30+
override render(ctx?: UniverRenderingContext, isMaxLayer = false, options: ILayerRenderOptions = {}) {
31+
super.render(ctx, isMaxLayer, options);
3132
const mainCtx = ctx || this.scene.getEngine()?.getCanvas().getContext();
3233
if (mainCtx && mainCtx.getId()) {
34+
mainCtx.save();
35+
if (options.dirtyBounds) {
36+
mainCtx.beginPath();
37+
for (const bound of options.dirtyBounds) {
38+
mainCtx.rect(bound.left, bound.top, bound.right - bound.left, bound.bottom - bound.top);
39+
}
40+
mainCtx.clip();
41+
}
3342
this._renderWatermark(mainCtx);
43+
mainCtx.restore();
3444
}
3545
return this;
3646
}

packages/engine-render/src/scene.ts

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const SCROLLBAR_SEEK_SETTLE_MS = 64;
4242
const SCROLLBAR_SEEK_EXPENSIVE_RENDER_MIN_MS = 32;
4343
const SCROLLBAR_SEEK_EXPENSIVE_RENDER_FRAME_INTERVALS = 2;
4444
const SCROLLBAR_SEEK_RENDER_COST_SAMPLE_WEIGHT = 0.25;
45+
const VIEWPORT_SHARED_EDGE_TOLERANCE = 1;
4546

4647
export interface ISceneInputControlOptions {
4748
enableDown: boolean;
@@ -108,6 +109,50 @@ function createScrollbarBounds(viewport: Viewport, contentBounds: IBoundRectNoAn
108109
return scrollbarBounds;
109110
}
110111

112+
// Viewports are rendered in order, so a later viewport owns any shared boundary pixel.
113+
// Excluding that pixel from earlier canvas copies prevents fixed headers from leaking into scrollable content.
114+
function trimSharedViewportEdges(bounds: IBoundRectNoAngle, followingViewportBounds: IBoundRectNoAngle[]) {
115+
const result = { ...bounds };
116+
117+
for (const other of followingViewportBounds) {
118+
const overlapWidth = Math.min(result.right, other.right) - Math.max(result.left, other.left);
119+
const overlapHeight = Math.min(result.bottom, other.bottom) - Math.max(result.top, other.top);
120+
if (overlapWidth <= 0 || overlapHeight <= 0) {
121+
continue;
122+
}
123+
124+
const width = result.right - result.left;
125+
const height = result.bottom - result.top;
126+
const coversWidth = overlapWidth >= width - VIEWPORT_SHARED_EDGE_TOLERANCE;
127+
const coversHeight = overlapHeight >= height - VIEWPORT_SHARED_EDGE_TOLERANCE;
128+
129+
if (coversHeight && other.left <= result.left && other.right > result.left) {
130+
result.left = Math.min(other.right, result.right);
131+
} else if (coversHeight && other.left < result.right && other.right >= result.right) {
132+
result.right = Math.max(other.left, result.left);
133+
}
134+
135+
if (coversWidth && other.top <= result.top && other.bottom > result.top) {
136+
result.top = Math.min(other.bottom, result.bottom);
137+
} else if (coversWidth && other.top < result.bottom && other.bottom >= result.bottom) {
138+
result.bottom = Math.max(other.top, result.top);
139+
}
140+
}
141+
142+
return result;
143+
}
144+
145+
function isInvalidScrollBounds(bounds: IBoundRectNoAngle, offsetX: number, offsetY: number) {
146+
const width = bounds.right - bounds.left;
147+
const height = bounds.bottom - bounds.top;
148+
return !Number.isFinite(offsetX) ||
149+
!Number.isFinite(offsetY) ||
150+
width <= 0 ||
151+
height <= 0 ||
152+
Math.abs(offsetX) >= width ||
153+
Math.abs(offsetY) >= height;
154+
}
155+
111156
function createViewportScrollRenderState(viewport: Viewport, scaleX: number, scaleY: number): IViewportScrollRenderState {
112157
const viewportInfo = viewport.calcViewportInfo();
113158
const { diffX = 0, diffY = 0, viewPortPosition } = viewportInfo;
@@ -128,24 +173,13 @@ function createViewportScrollRenderState(viewport: Viewport, scaleX: number, sca
128173
right: viewPortPosition.right - (scrollBar?.enableVertical ? scrollBar.totalSize : 0),
129174
bottom: viewPortPosition.bottom - (scrollBar?.enableHorizontal ? scrollBar.totalSize : 0),
130175
};
131-
const width = bounds.right - bounds.left;
132-
const height = bounds.bottom - bounds.top;
133-
const isInvalidScroll = !Number.isFinite(offsetX) ||
134-
!Number.isFinite(offsetY) ||
135-
width <= 0 ||
136-
height <= 0 ||
137-
Math.abs(offsetX) >= width ||
138-
Math.abs(offsetY) >= height;
139-
if (isInvalidScroll) {
176+
if (isInvalidScrollBounds(bounds, offsetX, offsetY)) {
140177
return { canPreserveEngine: false, dirtyBounds: [], viewportInfo };
141178
}
142179

143180
return {
144181
canPreserveEngine: true,
145-
dirtyBounds: [
146-
...createExposedScrollBounds(bounds, offsetX, offsetY),
147-
...createScrollbarBounds(viewport, bounds, viewPortPosition),
148-
],
182+
dirtyBounds: createScrollbarBounds(viewport, bounds, viewPortPosition),
149183
scrollRenderInfo: { bounds, offsetX, offsetY },
150184
viewportInfo,
151185
};
@@ -880,21 +914,31 @@ export class Scene extends Disposable {
880914
const { scaleX, scaleY } = this.getAncestorScale();
881915
let canPreserveEngine = true;
882916

883-
for (const viewport of this._viewports) {
884-
if (!viewport.shouldIntoRender()) {
885-
continue;
886-
}
917+
const viewportStates = this._viewports
918+
.filter((viewport) => viewport.shouldIntoRender())
919+
.map((viewport) => createViewportScrollRenderState(viewport, scaleX, scaleY));
887920

888-
const viewportState = createViewportScrollRenderState(viewport, scaleX, scaleY);
921+
for (const [index, viewportState] of viewportStates.entries()) {
889922
const { viewportInfo, scrollRenderInfo } = viewportState;
890-
viewportInfos.set(viewport.viewportKey, viewportInfo);
923+
viewportInfos.set(viewportInfo.viewportKey, viewportInfo);
891924
if (!viewportState.canPreserveEngine) {
892925
canPreserveEngine = false;
893926
continue;
894927
}
895928
dirtyBounds.push(...viewportState.dirtyBounds);
896929
if (scrollRenderInfo) {
897-
scrollRenderInfos.push(scrollRenderInfo);
930+
const followingViewportBounds = viewportStates
931+
.slice(index + 1)
932+
.map((state) => state.viewportInfo.viewPortPosition)
933+
.filter((bounds): bounds is IBoundRectNoAngle => bounds != null);
934+
const bounds = trimSharedViewportEdges(scrollRenderInfo.bounds, followingViewportBounds);
935+
const { offsetX, offsetY } = scrollRenderInfo;
936+
if (isInvalidScrollBounds(bounds, offsetX, offsetY)) {
937+
canPreserveEngine = false;
938+
continue;
939+
}
940+
dirtyBounds.push(...createExposedScrollBounds(bounds, offsetX, offsetY));
941+
scrollRenderInfos.push({ bounds, offsetX, offsetY });
898942
}
899943
}
900944

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Copyright 2023-present DreamNum Co., Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { ILayerRenderOptions, UniverRenderingContext } from '@univerjs/engine-render';
18+
import { Layer } from '@univerjs/engine-render';
19+
import { describe, expect, it, vi } from 'vitest';
20+
import { SelectionLayer } from '../selection-layer';
21+
22+
describe('SelectionLayer', () => {
23+
it('forwards incremental render options to the base layer', () => {
24+
const next = vi.fn();
25+
const scene = {
26+
getEngine: () => ({
27+
renderFrameTimeMetric$: { next },
28+
}),
29+
};
30+
const ctx = {} as UniverRenderingContext;
31+
const options: ILayerRenderOptions = {
32+
dirtyBounds: [{ left: 0, top: 0, right: 100, bottom: 24 }],
33+
preserveCache: true,
34+
viewportInfos: new Map(),
35+
};
36+
const render = vi.spyOn(Layer.prototype, 'render').mockReturnThis();
37+
const layer = new SelectionLayer(scene as never);
38+
39+
expect(layer.render(ctx, false, options)).toBe(layer);
40+
expect(render).toHaveBeenCalledWith(ctx, false, options);
41+
expect(next).toHaveBeenCalledWith(['selectionLayer', expect.any(Number)]);
42+
});
43+
});

packages/sheets-ui/src/services/selection/selection-layer.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@
1414
* limitations under the License.
1515
*/
1616

17-
import type { Engine, UniverRenderingContext } from '@univerjs/engine-render';
17+
import type { Engine, ILayerRenderOptions, UniverRenderingContext } from '@univerjs/engine-render';
1818
import { Tools } from '@univerjs/core';
1919
import { Layer } from '@univerjs/engine-render';
2020

2121
export class SelectionLayer extends Layer {
22-
override render(ctx?: UniverRenderingContext, isMaxLayer = false) {
22+
override render(ctx?: UniverRenderingContext, isMaxLayer = false, options: ILayerRenderOptions = {}) {
2323
const startTime = Tools.now();
24-
super.render(ctx, isMaxLayer);
24+
super.render(ctx, isMaxLayer, options);
2525
this._afterRender(startTime);
2626
return this;
2727
}

0 commit comments

Comments
 (0)