From bd1b26a2302b4f8a03816f0e2b78a763c8c92f56 Mon Sep 17 00:00:00 2001
From: doodlewind <7312949+doodlewind@users.noreply.github.com>
Date: Mon, 3 Mar 2025 02:07:46 +0000
Subject: [PATCH] feat(editor): support zooming placeholder in turbo renderer
(#10504)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[Screen Recording 2025-02-28 at 4.32.20 PM.mov (uploaded via Graphite)
](https://app.graphite.dev/media/video/lEGcysB4lFTEbCwZ8jMv/6c08b827-8428-42f3-aa7a-a2366756bd16.mov)
This prevents painting task backlog during zooming by adding a fast placeholder painter, with a `zooming` state in renderer state machine.
---
.../src/edgeless/edgeless-root-block.ts | 2 +-
.../{dom-utils.ts => renderer-utils.ts} | 47 ++++++
.../shared/src/viewport-renderer/types.ts | 8 +-
.../viewport-renderer/viewport-renderer.ts | 135 +++++++++---------
.../framework/block-std/src/gfx/viewport.ts | 10 +-
5 files changed, 131 insertions(+), 71 deletions(-)
rename blocksuite/affine/shared/src/viewport-renderer/{dom-utils.ts => renderer-utils.ts} (70%)
diff --git a/blocksuite/affine/block-root/src/edgeless/edgeless-root-block.ts b/blocksuite/affine/block-root/src/edgeless/edgeless-root-block.ts
index fe1162f3dc..657342c0a3 100644
--- a/blocksuite/affine/block-root/src/edgeless/edgeless-root-block.ts
+++ b/blocksuite/affine/block-root/src/edgeless/edgeless-root-block.ts
@@ -447,7 +447,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
);
const zoom = normalizeWheelDeltaY(e.deltaY, viewport.zoom);
- viewport.setZoom(zoom, new Point(baseX, baseY));
+ viewport.setZoom(zoom, new Point(baseX, baseY), true);
e.stopPropagation();
}
// pan
diff --git a/blocksuite/affine/shared/src/viewport-renderer/dom-utils.ts b/blocksuite/affine/shared/src/viewport-renderer/renderer-utils.ts
similarity index 70%
rename from blocksuite/affine/shared/src/viewport-renderer/dom-utils.ts
rename to blocksuite/affine/shared/src/viewport-renderer/renderer-utils.ts
index 35b8aafbc2..bf068687ad 100644
--- a/blocksuite/affine/shared/src/viewport-renderer/dom-utils.ts
+++ b/blocksuite/affine/shared/src/viewport-renderer/renderer-utils.ts
@@ -115,3 +115,50 @@ export function debugLog(message: string, state: RenderingState) {
'color: inherit;'
);
}
+
+export function paintPlaceholder(
+ canvas: HTMLCanvasElement,
+ layout: ViewportLayout | null,
+ viewport: Viewport
+) {
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+ if (!layout) return;
+ const dpr = window.devicePixelRatio;
+ const layoutViewCoord = viewport.toViewCoord(layout.rect.x, layout.rect.y);
+
+ const offsetX = layoutViewCoord[0];
+ const offsetY = layoutViewCoord[1];
+ const colors = [
+ 'rgba(200, 200, 200, 0.7)',
+ 'rgba(180, 180, 180, 0.7)',
+ 'rgba(160, 160, 160, 0.7)',
+ ];
+
+ layout.paragraphs.forEach((paragraph, paragraphIndex) => {
+ ctx.fillStyle = colors[paragraphIndex % colors.length];
+ const renderedPositions = new Set();
+
+ paragraph.sentences.forEach(sentence => {
+ sentence.rects.forEach(textRect => {
+ const x =
+ ((textRect.rect.x - layout.rect.x) * viewport.zoom + offsetX) * dpr;
+ const y =
+ ((textRect.rect.y - layout.rect.y) * viewport.zoom + offsetY) * dpr;
+ dpr;
+ const width = textRect.rect.w * viewport.zoom * dpr;
+ const height = textRect.rect.h * viewport.zoom * dpr;
+
+ const posKey = `${x},${y}`;
+ if (renderedPositions.has(posKey)) return;
+ ctx.fillRect(x, y, width, height);
+ if (width > 10 && height > 5) {
+ ctx.strokeStyle = 'rgba(150, 150, 150, 0.3)';
+ ctx.strokeRect(x, y, width, height);
+ }
+
+ renderedPositions.add(posKey);
+ });
+ });
+ });
+}
diff --git a/blocksuite/affine/shared/src/viewport-renderer/types.ts b/blocksuite/affine/shared/src/viewport-renderer/types.ts
index 56fc4317e3..05ffa9cf99 100644
--- a/blocksuite/affine/shared/src/viewport-renderer/types.ts
+++ b/blocksuite/affine/shared/src/viewport-renderer/types.ts
@@ -37,7 +37,13 @@ export interface TextRect {
* Represents the rendering state of the ViewportTurboRenderer
* - inactive: Renderer is not active
* - pending: Bitmap is invalid or not yet available, falling back to DOM rendering
+ * - zooming: Zooming in or out, will use fast canvas placeholder rendering
* - rendering: Currently rendering to a bitmap (async operation in progress)
* - ready: Bitmap is valid and rendered, DOM elements can be safely removed
*/
-export type RenderingState = 'inactive' | 'pending' | 'rendering' | 'ready';
+export type RenderingState =
+ | 'inactive'
+ | 'pending'
+ | 'zooming'
+ | 'rendering'
+ | 'ready';
diff --git a/blocksuite/affine/shared/src/viewport-renderer/viewport-renderer.ts b/blocksuite/affine/shared/src/viewport-renderer/viewport-renderer.ts
index 13603e5b4a..c20be8cc58 100644
--- a/blocksuite/affine/shared/src/viewport-renderer/viewport-renderer.ts
+++ b/blocksuite/affine/shared/src/viewport-renderer/viewport-renderer.ts
@@ -1,5 +1,4 @@
import {
- type BlockStdScope,
LifeCycleWatcher,
LifeCycleWatcherIdentifier,
StdIdentifier,
@@ -15,46 +14,30 @@ import {
debugLog,
getViewportLayout,
initTweakpane,
+ paintPlaceholder,
syncCanvasSize,
-} from './dom-utils.js';
+} from './renderer-utils.js';
import type { RenderingState, ViewportLayout } from './types.js';
-export const ViewportTurboRendererIdentifier = LifeCycleWatcherIdentifier(
- 'ViewportTurboRenderer'
-) as ServiceIdentifier;
-
-interface Tile {
- bitmap: ImageBitmap;
- zoom: number;
-}
-
+const debug = false; // Toggle for debug logs
const zoomThreshold = 1; // With high enough zoom, fallback to DOM rendering
const debounceTime = 1000; // During this period, fallback to DOM
-const debug = false; // Toggle for debug logs
+const workerUrl = new URL('./painter.worker.ts', import.meta.url);
export class ViewportTurboRendererExtension extends LifeCycleWatcher {
- state: RenderingState = 'inactive';
- disposables = new DisposableGroup();
+ public state: RenderingState = 'inactive';
+ public readonly canvas: HTMLCanvasElement = document.createElement('canvas');
+ private readonly worker: Worker = new Worker(workerUrl, { type: 'module' });
+ private readonly disposables = new DisposableGroup();
+ private layoutCacheData: ViewportLayout | null = null;
private layoutVersion = 0;
+ private bitmap: ImageBitmap | null = null;
+ private viewportElement: GfxViewportElement | null = null;
static override setup(di: Container) {
di.addImpl(ViewportTurboRendererIdentifier, this, [StdIdentifier]);
}
- public readonly canvas: HTMLCanvasElement = document.createElement('canvas');
- private readonly worker: Worker;
- private layoutCacheData: ViewportLayout | null = null;
- private tile: Tile | null = null;
- private viewportElement: GfxViewportElement | null = null;
-
- constructor(std: BlockStdScope) {
- super(std);
- this.worker = new Worker(new URL('./painter.worker.ts', import.meta.url), {
- type: 'module',
- });
- this.debugLog('Initialized ViewportTurboRenderer');
- }
-
override mounted() {
const mountPoint = document.querySelector('.affine-edgeless-viewport');
if (mountPoint) {
@@ -75,6 +58,18 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
this.refresh().catch(console.error);
})
);
+
+ this.disposables.add({
+ dispose: this.viewport.zooming$.subscribe(isZooming => {
+ this.debugLog(`Zooming signal changed: ${isZooming}`);
+ if (isZooming) {
+ this.setState('zooming');
+ } else if (this.state === 'zooming') {
+ this.setState('pending');
+ this.refresh().catch(console.error);
+ }
+ }),
+ });
});
this.disposables.add(
@@ -87,7 +82,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
override unmounted() {
this.debugLog('Unmounting renderer');
- this.clearTile();
+ this.clearBitmap();
this.clearOptimizedBlocks();
this.worker.terminate();
this.canvas.remove();
@@ -125,19 +120,26 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
this.toggleOptimization(false);
this.clearOptimizedBlocks();
}
+ // -> zooming
+ else if (this.isZooming()) {
+ this.debugLog('Currently zooming, using placeholder rendering');
+ this.setState('zooming');
+ this.paintPlaceholder();
+ this.updateOptimizedBlocks();
+ }
// -> ready
else if (this.canUseBitmapCache()) {
this.debugLog('Using cached bitmap');
this.setState('ready');
- this.drawCachedBitmap(this.layoutCache);
+ this.drawCachedBitmap();
this.updateOptimizedBlocks();
}
// -> rendering
else {
this.setState('rendering');
this.toggleOptimization(false);
- await this.paintLayout(this.layoutCache);
- this.drawCachedBitmap(this.layoutCache);
+ await this.paintLayout();
+ this.drawCachedBitmap();
this.updateOptimizedBlocks();
}
}
@@ -153,7 +155,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
invalidate() {
this.layoutVersion++;
this.layoutCacheData = null;
- this.clearTile();
+ this.clearBitmap();
this.clearCanvas();
this.clearOptimizedBlocks();
this.setState('pending');
@@ -165,17 +167,18 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
debugLog(message, this.state);
}
- private clearTile() {
- if (!this.tile) return;
- this.tile.bitmap.close();
- this.tile = null;
- this.debugLog('Tile cleared');
+ private clearBitmap() {
+ if (!this.bitmap) return;
+ this.bitmap.close();
+ this.bitmap = null;
+ this.debugLog('Bitmap cleared');
}
- private async paintLayout(layout: ViewportLayout): Promise {
+ private async paintLayout(): Promise {
return new Promise(resolve => {
if (!this.worker) return;
+ const layout = this.layoutCache;
const dpr = window.devicePixelRatio;
const currentVersion = this.layoutVersion;
@@ -198,7 +201,10 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
this.debugLog(
`Bitmap painted successfully (version=${e.data.version})`
);
- this.handlePaintedBitmap(e.data.bitmap, resolve);
+ this.clearBitmap();
+ this.bitmap = e.data.bitmap;
+ this.setState('ready');
+ resolve();
} else {
this.debugLog(
`Received outdated bitmap (got=${e.data.version}, current=${this.layoutVersion})`
@@ -212,20 +218,14 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
});
}
- private handlePaintedBitmap(bitmap: ImageBitmap, resolve: () => void) {
- this.clearTile();
- this.tile = {
- bitmap,
- zoom: this.viewport.zoom,
- };
- this.setState('ready');
- resolve();
+ private canUseBitmapCache(): boolean {
+ // Never use bitmap cache during zooming
+ if (this.isZooming()) return false;
+ return !!(this.layoutCache && this.bitmap);
}
- private canUseBitmapCache(): boolean {
- return (
- !!this.layoutCache && !!this.tile && this.viewport.zoom === this.tile.zoom
- );
+ private isZooming(): boolean {
+ return this.viewport.zooming$.value;
}
private clearCanvas() {
@@ -235,14 +235,15 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
this.debugLog('Canvas cleared');
}
- private drawCachedBitmap(layout: ViewportLayout) {
- if (!this.tile) {
+ private drawCachedBitmap() {
+ if (!this.bitmap) {
this.debugLog('No cached bitmap available, requesting refresh');
this.debouncedRefresh();
return;
}
- const bitmap = this.tile.bitmap;
+ const layout = this.layoutCache;
+ const bitmap = this.bitmap;
const ctx = this.canvas.getContext('2d');
if (!ctx) return;
@@ -265,26 +266,22 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
setState(newState: RenderingState) {
if (this.state === newState) return;
- this.debugLog(`State change: ${this.state} -> ${newState}`);
this.state = newState;
+ this.debugLog(`State change: ${this.state} -> ${newState}`);
}
- canOptimize(): boolean {
- const isReady = this.state === 'ready';
+ private canOptimize(): boolean {
const isBelowZoomThreshold = this.viewport.zoom <= zoomThreshold;
- const result = isReady && isBelowZoomThreshold;
- return result;
+ return (
+ (this.state === 'ready' || this.state === 'zooming') &&
+ isBelowZoomThreshold
+ );
}
private updateOptimizedBlocks() {
requestAnimationFrame(() => {
if (!this.viewportElement || !this.layoutCache) return;
if (!this.canOptimize()) return;
- if (this.state !== 'ready') {
- this.debugLog('Unexpected state updating optimized blocks');
- console.warn('Unexpected state', this.tile, this.layoutCache);
- return;
- }
this.toggleOptimization(true);
const blockElements = this.viewportElement.getModelsInViewport();
@@ -316,4 +313,12 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
this.invalidate();
this.debouncedRefresh();
}
+
+ private paintPlaceholder() {
+ paintPlaceholder(this.canvas, this.layoutCache, this.viewport);
+ }
}
+
+export const ViewportTurboRendererIdentifier = LifeCycleWatcherIdentifier(
+ 'ViewportTurboRenderer'
+) as ServiceIdentifier;
diff --git a/blocksuite/framework/block-std/src/gfx/viewport.ts b/blocksuite/framework/block-std/src/gfx/viewport.ts
index 30815dab8f..83206c6040 100644
--- a/blocksuite/framework/block-std/src/gfx/viewport.ts
+++ b/blocksuite/framework/block-std/src/gfx/viewport.ts
@@ -78,7 +78,7 @@ export class Viewport {
() => {
this.zooming$.value = false;
},
- 100,
+ 200,
{ leading: false, trailing: true }
);
@@ -86,7 +86,7 @@ export class Viewport {
() => {
this.panning$.value = false;
},
- 100,
+ 200,
{ leading: false, trailing: true }
);
@@ -390,7 +390,7 @@ export class Viewport {
this._resizeObserver.observe(el);
}
- setZoom(zoom: number, focusPoint?: IPoint) {
+ setZoom(zoom: number, focusPoint?: IPoint, wheel = false) {
const prevZoom = this.zoom;
focusPoint = (focusPoint ?? this._center) as IPoint;
this._zoom = clamp(zoom, this.ZOOM_MIN, this.ZOOM_MAX);
@@ -401,7 +401,9 @@ export class Viewport {
Vec.toVec(focusPoint),
Vec.mul(offset, prevZoom / newZoom)
);
- this.zooming$.value = true;
+ if (wheel) {
+ this.zooming$.value = true;
+ }
this.setCenter(newCenter[0], newCenter[1]);
this.viewportUpdated.emit({
zoom: this.zoom,