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
@@ -1,10 +1,12 @@
import { type Color, ColorScheme } from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { IBound } from '@blocksuite/global/gfx';
import { getBoundWithRotation, intersects } from '@blocksuite/global/gfx';
import type { BlockStdScope } from '@blocksuite/std';
import type {
GfxCompatibleInterface,
GridManager,
LayerManager,
SurfaceBlockModel,
@@ -43,6 +45,8 @@ export class CanvasRenderer {
private readonly _disposables = new DisposableGroup();
private readonly _turboEnabled: () => boolean;
private readonly _overlays = new Set<Overlay>();
private _refreshRafId: number | null = null;
@@ -67,6 +71,8 @@ export class CanvasRenderer {
removed: HTMLCanvasElement[];
}>();
usePlaceholder = false;
viewport: Viewport;
get stackingCanvas() {
@@ -83,6 +89,12 @@ export class CanvasRenderer {
this.layerManager = options.layerManager;
this.grid = options.gridManager;
this.provider = options.provider ?? {};
this._turboEnabled = () => {
const featureFlagService = options.std.get(FeatureFlagService);
return featureFlagService.getFlag('enable_turbo_renderer');
};
this._initViewport();
options.enableStackingCanvas = options.enableStackingCanvas ?? false;
@@ -213,6 +225,19 @@ export class CanvasRenderer {
}, 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() {
@@ -279,23 +304,30 @@ export class CanvasRenderer {
for (const element of elements) {
const display = (element.display ?? true) && !element.hidden;
if (display && intersects(getBoundWithRotation(element), bound)) {
const renderFn = this.std.getOptional<ElementRenderer>(
ElementRendererIdentifier(element.type)
);
if (
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) {
console.warn(`Cannot find renderer for ${element.type}`);
continue;
if (!renderFn) 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 cx = w / 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 lines = deltaInsertsToChunks(deltas);
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
const textHeight = (lines.length - 1) * lineHeight * 0.5;
ctx.setTransform(matrix);
ctx.font = font;
ctx.textAlign = textAlign;
ctx.textBaseline = 'middle';
@@ -106,6 +106,11 @@ export type ConnectorElementProps = BaseElementProps & {
export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorElementProps> {
updatingPath = false;
/**
* Connectors should always render, even during zoom.
*/
forceFullRender = true;
override get connectable() {
return false as const;
}
@@ -19,6 +19,7 @@ export interface BlockSuiteFlags {
enable_callout: boolean;
enable_edgeless_scribbled_style: boolean;
enable_embed_doc_with_alias: boolean;
enable_turbo_renderer: boolean;
}
export class FeatureFlagService extends StoreExtension {
@@ -42,6 +43,7 @@ export class FeatureFlagService extends StoreExtension {
enable_callout: false,
enable_edgeless_scribbled_style: false,
enable_embed_doc_with_alias: false,
enable_turbo_renderer: false,
});
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?
> `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?
> `optional` **lockedBySelf**: `boolean`
@@ -84,6 +84,12 @@ export interface GfxCompatibleInterface extends IBound, GfxElementGeometry {
lock(): 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 {
BrushElementModel,
ConnectorElementModel,
GroupElementModel,
} from '@blocksuite/affine/model';
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(id2).map(el => el.id)).toEqual([connector.id]);
expect((connector as ConnectorElementModel).forceFullRender).toBe(true);
});
test('multiple connectors are supported', () => {
@@ -194,6 +196,8 @@ describe('connector', () => {
expect(model.getConnectors(id).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', () => {
@@ -213,6 +217,11 @@ describe('connector', () => {
},
});
const connectorBeforeUpdate = model.getElementById(connectorId)!;
expect(
(connectorBeforeUpdate as ConnectorElementModel).forceFullRender
).toBe(true);
model.updateElement(connectorId, {
source: {
position: [0, 0],
@@ -243,6 +252,11 @@ describe('connector', () => {
},
});
const connectorBeforeDelete = model.getElementById(connectorId)!;
expect(
(connectorBeforeDelete as ConnectorElementModel).forceFullRender
).toBe(true);
model.deleteElement(connectorId);
await wait();
@@ -232,7 +232,8 @@ export const AFFINE_FLAGS = {
defaultState: false,
},
enable_turbo_renderer: {
category: 'affine',
category: 'blocksuite',
bsFlag: 'enable_turbo_renderer',
displayName: 'Enable Turbo Renderer',
description: 'Enable experimental edgeless turbo renderer',
configurable: isCanaryBuild,