mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
perf(editor): fallback to placeholder for canvas text (#12033)
### TL;DR For canvas elements, this PR adds placeholders during zooming operations to improve performance.  ### What changed? - Implemented placeholder rendering during zooming operations in the canvas renderer, but not only DOM. - Added a `forceFullRender` property to the `GfxCompatibleInterface` to allow elements to opt out of placeholder rendering - Set `forceFullRender = true` for connectors to ensure they always render properly, even during zooming - Connected the turbo renderer to the viewport's zooming state to automatically switch between full and placeholder rendering ### Why make this change? Rendering complex elements during zooming operations can cause performance issues and make the UI feel sluggish. Rendering connector label also leads to high cost DOM `set font` delays.  The turbo renderer improves performance by displaying simple placeholders for elements during zooming, while still rendering critical elements like connectors fully. This creates a smoother user experience while maintaining essential visual information. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a feature-flag-controlled "turbo" rendering mode that displays placeholder graphics during zooming for improved performance. - Added the ability to override placeholder rendering for specific elements, ensuring full rendering when required. - **Bug Fixes** - Enhanced rendering logic to ensure connectors always render fully, even during zoom operations. - **Documentation** - Updated API documentation to reflect new properties related to rendering behavior. - **Tests** - Improved tests to verify correct rendering behavior for connectors. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
import { type Color, ColorScheme } from '@blocksuite/affine-model';
|
import { type Color, ColorScheme } from '@blocksuite/affine-model';
|
||||||
|
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||||
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
||||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||||
import type { IBound } from '@blocksuite/global/gfx';
|
import type { IBound } from '@blocksuite/global/gfx';
|
||||||
import { getBoundWithRotation, intersects } from '@blocksuite/global/gfx';
|
import { getBoundWithRotation, intersects } from '@blocksuite/global/gfx';
|
||||||
import type { BlockStdScope } from '@blocksuite/std';
|
import type { BlockStdScope } from '@blocksuite/std';
|
||||||
import type {
|
import type {
|
||||||
|
GfxCompatibleInterface,
|
||||||
GridManager,
|
GridManager,
|
||||||
LayerManager,
|
LayerManager,
|
||||||
SurfaceBlockModel,
|
SurfaceBlockModel,
|
||||||
@@ -43,6 +45,8 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
private readonly _disposables = new DisposableGroup();
|
private readonly _disposables = new DisposableGroup();
|
||||||
|
|
||||||
|
private readonly _turboEnabled: () => boolean;
|
||||||
|
|
||||||
private readonly _overlays = new Set<Overlay>();
|
private readonly _overlays = new Set<Overlay>();
|
||||||
|
|
||||||
private _refreshRafId: number | null = null;
|
private _refreshRafId: number | null = null;
|
||||||
@@ -67,6 +71,8 @@ export class CanvasRenderer {
|
|||||||
removed: HTMLCanvasElement[];
|
removed: HTMLCanvasElement[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
usePlaceholder = false;
|
||||||
|
|
||||||
viewport: Viewport;
|
viewport: Viewport;
|
||||||
|
|
||||||
get stackingCanvas() {
|
get stackingCanvas() {
|
||||||
@@ -83,6 +89,12 @@ export class CanvasRenderer {
|
|||||||
this.layerManager = options.layerManager;
|
this.layerManager = options.layerManager;
|
||||||
this.grid = options.gridManager;
|
this.grid = options.gridManager;
|
||||||
this.provider = options.provider ?? {};
|
this.provider = options.provider ?? {};
|
||||||
|
|
||||||
|
this._turboEnabled = () => {
|
||||||
|
const featureFlagService = options.std.get(FeatureFlagService);
|
||||||
|
return featureFlagService.getFlag('enable_turbo_renderer');
|
||||||
|
};
|
||||||
|
|
||||||
this._initViewport();
|
this._initViewport();
|
||||||
|
|
||||||
options.enableStackingCanvas = options.enableStackingCanvas ?? false;
|
options.enableStackingCanvas = options.enableStackingCanvas ?? false;
|
||||||
@@ -213,6 +225,19 @@ export class CanvasRenderer {
|
|||||||
}, this._container);
|
}, this._container);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this._disposables.add(
|
||||||
|
this.viewport.zooming$.subscribe(isZooming => {
|
||||||
|
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
|
||||||
|
|
||||||
|
if (this.usePlaceholder !== shouldRenderPlaceholders) {
|
||||||
|
this.usePlaceholder = shouldRenderPlaceholders;
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.usePlaceholder = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _render() {
|
private _render() {
|
||||||
@@ -279,23 +304,30 @@ export class CanvasRenderer {
|
|||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
const display = (element.display ?? true) && !element.hidden;
|
const display = (element.display ?? true) && !element.hidden;
|
||||||
if (display && intersects(getBoundWithRotation(element), bound)) {
|
if (display && intersects(getBoundWithRotation(element), bound)) {
|
||||||
const renderFn = this.std.getOptional<ElementRenderer>(
|
if (
|
||||||
ElementRendererIdentifier(element.type)
|
this.usePlaceholder &&
|
||||||
);
|
!(element as GfxCompatibleInterface).forceFullRender
|
||||||
|
) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
|
||||||
|
const drawX = element.x - bound.x;
|
||||||
|
const drawY = element.y - bound.y;
|
||||||
|
ctx.fillRect(drawX, drawY, element.w, element.h);
|
||||||
|
ctx.restore();
|
||||||
|
} else {
|
||||||
|
ctx.save();
|
||||||
|
const renderFn = this.std.getOptional<ElementRenderer>(
|
||||||
|
ElementRendererIdentifier(element.type)
|
||||||
|
);
|
||||||
|
|
||||||
if (!renderFn) {
|
if (!renderFn) continue;
|
||||||
console.warn(`Cannot find renderer for ${element.type}`);
|
|
||||||
continue;
|
ctx.globalAlpha = element.opacity ?? 1;
|
||||||
|
const dx = element.x - bound.x;
|
||||||
|
const dy = element.y - bound.y;
|
||||||
|
renderFn(element, ctx, matrix.translate(dx, dy), this, rc, bound);
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
|
|
||||||
ctx.globalAlpha = element.opacity ?? 1;
|
|
||||||
const dx = element.x - bound.x;
|
|
||||||
const dy = element.y - bound.y;
|
|
||||||
|
|
||||||
renderFn(element, ctx, matrix.translate(dx, dy), this, rc, bound);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -249,13 +249,20 @@ function renderLabel(
|
|||||||
const [, , w, h] = labelXYWH!;
|
const [, , w, h] = labelXYWH!;
|
||||||
const cx = w / 2;
|
const cx = w / 2;
|
||||||
const cy = h / 2;
|
const cy = h / 2;
|
||||||
|
|
||||||
|
ctx.setTransform(matrix);
|
||||||
|
|
||||||
|
if (renderer.usePlaceholder) {
|
||||||
|
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
return; // Skip actual label rendering
|
||||||
|
}
|
||||||
|
|
||||||
const deltas = wrapTextDeltas(text!, font, w);
|
const deltas = wrapTextDeltas(text!, font, w);
|
||||||
const lines = deltaInsertsToChunks(deltas);
|
const lines = deltaInsertsToChunks(deltas);
|
||||||
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
|
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||||
const textHeight = (lines.length - 1) * lineHeight * 0.5;
|
const textHeight = (lines.length - 1) * lineHeight * 0.5;
|
||||||
|
|
||||||
ctx.setTransform(matrix);
|
|
||||||
|
|
||||||
ctx.font = font;
|
ctx.font = font;
|
||||||
ctx.textAlign = textAlign;
|
ctx.textAlign = textAlign;
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ export type ConnectorElementProps = BaseElementProps & {
|
|||||||
export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorElementProps> {
|
export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorElementProps> {
|
||||||
updatingPath = false;
|
updatingPath = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connectors should always render, even during zoom.
|
||||||
|
*/
|
||||||
|
forceFullRender = true;
|
||||||
|
|
||||||
override get connectable() {
|
override get connectable() {
|
||||||
return false as const;
|
return false as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface BlockSuiteFlags {
|
|||||||
enable_callout: boolean;
|
enable_callout: boolean;
|
||||||
enable_edgeless_scribbled_style: boolean;
|
enable_edgeless_scribbled_style: boolean;
|
||||||
enable_embed_doc_with_alias: boolean;
|
enable_embed_doc_with_alias: boolean;
|
||||||
|
enable_turbo_renderer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FeatureFlagService extends StoreExtension {
|
export class FeatureFlagService extends StoreExtension {
|
||||||
@@ -42,6 +43,7 @@ export class FeatureFlagService extends StoreExtension {
|
|||||||
enable_callout: false,
|
enable_callout: false,
|
||||||
enable_edgeless_scribbled_style: false,
|
enable_edgeless_scribbled_style: false,
|
||||||
enable_embed_doc_with_alias: false,
|
enable_embed_doc_with_alias: false,
|
||||||
|
enable_turbo_renderer: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ The bound of the element without considering the response extension.
|
|||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
|
### forceFullRender?
|
||||||
|
|
||||||
|
> `optional` **forceFullRender**: `boolean`
|
||||||
|
|
||||||
|
Whether to disable fallback rendering for this element, e.g., during zooming.
|
||||||
|
Defaults to false (fallback to placeholder rendering is enabled).
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
### lockedBySelf?
|
### lockedBySelf?
|
||||||
|
|
||||||
> `optional` **lockedBySelf**: `boolean`
|
> `optional` **lockedBySelf**: `boolean`
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ The bound of the element without considering the response extension.
|
|||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
|
### forceFullRender?
|
||||||
|
|
||||||
|
> `optional` **forceFullRender**: `boolean`
|
||||||
|
|
||||||
|
Whether to disable fallback rendering for this element, e.g., during zooming.
|
||||||
|
Defaults to false (fallback to placeholder rendering is enabled).
|
||||||
|
|
||||||
|
#### Inherited from
|
||||||
|
|
||||||
|
[`GfxCompatibleInterface`](GfxCompatibleInterface.md).[`forceFullRender`](GfxCompatibleInterface.md#forcefullrender)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
### lockedBySelf?
|
### lockedBySelf?
|
||||||
|
|
||||||
> `optional` **lockedBySelf**: `boolean`
|
> `optional` **lockedBySelf**: `boolean`
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ export interface GfxCompatibleInterface extends IBound, GfxElementGeometry {
|
|||||||
|
|
||||||
lock(): void;
|
lock(): void;
|
||||||
unlock(): void;
|
unlock(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to disable fallback rendering for this element, e.g., during zooming.
|
||||||
|
* Defaults to false (fallback to placeholder rendering is enabled).
|
||||||
|
*/
|
||||||
|
forceFullRender?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { SurfaceBlockModel } from '@blocksuite/affine/blocks/surface';
|
import type { SurfaceBlockModel } from '@blocksuite/affine/blocks/surface';
|
||||||
import type {
|
import type {
|
||||||
BrushElementModel,
|
BrushElementModel,
|
||||||
|
ConnectorElementModel,
|
||||||
GroupElementModel,
|
GroupElementModel,
|
||||||
} from '@blocksuite/affine/model';
|
} from '@blocksuite/affine/model';
|
||||||
import { beforeEach, describe, expect, test } from 'vitest';
|
import { beforeEach, describe, expect, test } from 'vitest';
|
||||||
@@ -160,6 +161,7 @@ describe('connector', () => {
|
|||||||
|
|
||||||
expect(model.getConnectors(id).map(el => el.id)).toEqual([connector.id]);
|
expect(model.getConnectors(id).map(el => el.id)).toEqual([connector.id]);
|
||||||
expect(model.getConnectors(id2).map(el => el.id)).toEqual([connector.id]);
|
expect(model.getConnectors(id2).map(el => el.id)).toEqual([connector.id]);
|
||||||
|
expect((connector as ConnectorElementModel).forceFullRender).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('multiple connectors are supported', () => {
|
test('multiple connectors are supported', () => {
|
||||||
@@ -194,6 +196,8 @@ describe('connector', () => {
|
|||||||
|
|
||||||
expect(model.getConnectors(id).map(c => c.id)).toEqual(connectors);
|
expect(model.getConnectors(id).map(c => c.id)).toEqual(connectors);
|
||||||
expect(model.getConnectors(id2).map(c => c.id)).toEqual(connectors);
|
expect(model.getConnectors(id2).map(c => c.id)).toEqual(connectors);
|
||||||
|
expect((connector as ConnectorElementModel).forceFullRender).toBe(true);
|
||||||
|
expect((connector2 as ConnectorElementModel).forceFullRender).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return null if connector are updated', () => {
|
test('should return null if connector are updated', () => {
|
||||||
@@ -213,6 +217,11 @@ describe('connector', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const connectorBeforeUpdate = model.getElementById(connectorId)!;
|
||||||
|
expect(
|
||||||
|
(connectorBeforeUpdate as ConnectorElementModel).forceFullRender
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
model.updateElement(connectorId, {
|
model.updateElement(connectorId, {
|
||||||
source: {
|
source: {
|
||||||
position: [0, 0],
|
position: [0, 0],
|
||||||
@@ -243,6 +252,11 @@ describe('connector', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const connectorBeforeDelete = model.getElementById(connectorId)!;
|
||||||
|
expect(
|
||||||
|
(connectorBeforeDelete as ConnectorElementModel).forceFullRender
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
model.deleteElement(connectorId);
|
model.deleteElement(connectorId);
|
||||||
|
|
||||||
await wait();
|
await wait();
|
||||||
|
|||||||
@@ -232,7 +232,8 @@ export const AFFINE_FLAGS = {
|
|||||||
defaultState: false,
|
defaultState: false,
|
||||||
},
|
},
|
||||||
enable_turbo_renderer: {
|
enable_turbo_renderer: {
|
||||||
category: 'affine',
|
category: 'blocksuite',
|
||||||
|
bsFlag: 'enable_turbo_renderer',
|
||||||
displayName: 'Enable Turbo Renderer',
|
displayName: 'Enable Turbo Renderer',
|
||||||
description: 'Enable experimental edgeless turbo renderer',
|
description: 'Enable experimental edgeless turbo renderer',
|
||||||
configurable: isCanaryBuild,
|
configurable: isCanaryBuild,
|
||||||
|
|||||||
Reference in New Issue
Block a user