perf(editor): fallback to placeholder for canvas text (#12033)

### TL;DR

For canvas elements, this PR adds placeholders during zooming operations to improve performance.

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/lEGcysB4lFTEbCwZ8jMv/8c8daea8-1eb4-419b-a4f4-2a8847f40b7b.png)

### 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.

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/lEGcysB4lFTEbCwZ8jMv/961fb847-24b4-4a7f-b9dc-21b0a5edaaa1.png)

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:
doodlewind
2025-04-29 03:05:17 +00:00
parent d82d37b53d
commit be28038e94
9 changed files with 107 additions and 18 deletions

View File

@@ -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();
} }
} }

View File

@@ -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';

View File

@@ -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;
} }

View File

@@ -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) {

View File

@@ -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`

View File

@@ -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`

View File

@@ -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;
} }
/** /**

View File

@@ -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();

View File

@@ -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,