feat(editor): support zooming placeholder in turbo renderer (#10504)
[Screen Recording 2025-02-28 at 4.32.20 PM.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/lEGcysB4lFTEbCwZ8jMv/6c08b827-8428-42f3-aa7a-a2366756bd16.mov" />](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.
This commit is contained in:
@@ -447,7 +447,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
|
|||||||
);
|
);
|
||||||
|
|
||||||
const zoom = normalizeWheelDeltaY(e.deltaY, viewport.zoom);
|
const zoom = normalizeWheelDeltaY(e.deltaY, viewport.zoom);
|
||||||
viewport.setZoom(zoom, new Point(baseX, baseY));
|
viewport.setZoom(zoom, new Point(baseX, baseY), true);
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
// pan
|
// pan
|
||||||
|
|||||||
@@ -115,3 +115,50 @@ export function debugLog(message: string, state: RenderingState) {
|
|||||||
'color: inherit;'
|
'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<string>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,7 +37,13 @@ export interface TextRect {
|
|||||||
* Represents the rendering state of the ViewportTurboRenderer
|
* Represents the rendering state of the ViewportTurboRenderer
|
||||||
* - inactive: Renderer is not active
|
* - inactive: Renderer is not active
|
||||||
* - pending: Bitmap is invalid or not yet available, falling back to DOM rendering
|
* - 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)
|
* - rendering: Currently rendering to a bitmap (async operation in progress)
|
||||||
* - ready: Bitmap is valid and rendered, DOM elements can be safely removed
|
* - 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';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
type BlockStdScope,
|
|
||||||
LifeCycleWatcher,
|
LifeCycleWatcher,
|
||||||
LifeCycleWatcherIdentifier,
|
LifeCycleWatcherIdentifier,
|
||||||
StdIdentifier,
|
StdIdentifier,
|
||||||
@@ -15,46 +14,30 @@ import {
|
|||||||
debugLog,
|
debugLog,
|
||||||
getViewportLayout,
|
getViewportLayout,
|
||||||
initTweakpane,
|
initTweakpane,
|
||||||
|
paintPlaceholder,
|
||||||
syncCanvasSize,
|
syncCanvasSize,
|
||||||
} from './dom-utils.js';
|
} from './renderer-utils.js';
|
||||||
import type { RenderingState, ViewportLayout } from './types.js';
|
import type { RenderingState, ViewportLayout } from './types.js';
|
||||||
|
|
||||||
export const ViewportTurboRendererIdentifier = LifeCycleWatcherIdentifier(
|
const debug = false; // Toggle for debug logs
|
||||||
'ViewportTurboRenderer'
|
|
||||||
) as ServiceIdentifier<ViewportTurboRendererExtension>;
|
|
||||||
|
|
||||||
interface Tile {
|
|
||||||
bitmap: ImageBitmap;
|
|
||||||
zoom: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const zoomThreshold = 1; // With high enough zoom, fallback to DOM rendering
|
const zoomThreshold = 1; // With high enough zoom, fallback to DOM rendering
|
||||||
const debounceTime = 1000; // During this period, fallback to DOM
|
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 {
|
export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
||||||
state: RenderingState = 'inactive';
|
public state: RenderingState = 'inactive';
|
||||||
disposables = new DisposableGroup();
|
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 layoutVersion = 0;
|
||||||
|
private bitmap: ImageBitmap | null = null;
|
||||||
|
private viewportElement: GfxViewportElement | null = null;
|
||||||
|
|
||||||
static override setup(di: Container) {
|
static override setup(di: Container) {
|
||||||
di.addImpl(ViewportTurboRendererIdentifier, this, [StdIdentifier]);
|
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() {
|
override mounted() {
|
||||||
const mountPoint = document.querySelector('.affine-edgeless-viewport');
|
const mountPoint = document.querySelector('.affine-edgeless-viewport');
|
||||||
if (mountPoint) {
|
if (mountPoint) {
|
||||||
@@ -75,6 +58,18 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
|||||||
this.refresh().catch(console.error);
|
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(
|
this.disposables.add(
|
||||||
@@ -87,7 +82,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
|||||||
|
|
||||||
override unmounted() {
|
override unmounted() {
|
||||||
this.debugLog('Unmounting renderer');
|
this.debugLog('Unmounting renderer');
|
||||||
this.clearTile();
|
this.clearBitmap();
|
||||||
this.clearOptimizedBlocks();
|
this.clearOptimizedBlocks();
|
||||||
this.worker.terminate();
|
this.worker.terminate();
|
||||||
this.canvas.remove();
|
this.canvas.remove();
|
||||||
@@ -125,19 +120,26 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
|||||||
this.toggleOptimization(false);
|
this.toggleOptimization(false);
|
||||||
this.clearOptimizedBlocks();
|
this.clearOptimizedBlocks();
|
||||||
}
|
}
|
||||||
|
// -> zooming
|
||||||
|
else if (this.isZooming()) {
|
||||||
|
this.debugLog('Currently zooming, using placeholder rendering');
|
||||||
|
this.setState('zooming');
|
||||||
|
this.paintPlaceholder();
|
||||||
|
this.updateOptimizedBlocks();
|
||||||
|
}
|
||||||
// -> ready
|
// -> ready
|
||||||
else if (this.canUseBitmapCache()) {
|
else if (this.canUseBitmapCache()) {
|
||||||
this.debugLog('Using cached bitmap');
|
this.debugLog('Using cached bitmap');
|
||||||
this.setState('ready');
|
this.setState('ready');
|
||||||
this.drawCachedBitmap(this.layoutCache);
|
this.drawCachedBitmap();
|
||||||
this.updateOptimizedBlocks();
|
this.updateOptimizedBlocks();
|
||||||
}
|
}
|
||||||
// -> rendering
|
// -> rendering
|
||||||
else {
|
else {
|
||||||
this.setState('rendering');
|
this.setState('rendering');
|
||||||
this.toggleOptimization(false);
|
this.toggleOptimization(false);
|
||||||
await this.paintLayout(this.layoutCache);
|
await this.paintLayout();
|
||||||
this.drawCachedBitmap(this.layoutCache);
|
this.drawCachedBitmap();
|
||||||
this.updateOptimizedBlocks();
|
this.updateOptimizedBlocks();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +155,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
|||||||
invalidate() {
|
invalidate() {
|
||||||
this.layoutVersion++;
|
this.layoutVersion++;
|
||||||
this.layoutCacheData = null;
|
this.layoutCacheData = null;
|
||||||
this.clearTile();
|
this.clearBitmap();
|
||||||
this.clearCanvas();
|
this.clearCanvas();
|
||||||
this.clearOptimizedBlocks();
|
this.clearOptimizedBlocks();
|
||||||
this.setState('pending');
|
this.setState('pending');
|
||||||
@@ -165,17 +167,18 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
|||||||
debugLog(message, this.state);
|
debugLog(message, this.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearTile() {
|
private clearBitmap() {
|
||||||
if (!this.tile) return;
|
if (!this.bitmap) return;
|
||||||
this.tile.bitmap.close();
|
this.bitmap.close();
|
||||||
this.tile = null;
|
this.bitmap = null;
|
||||||
this.debugLog('Tile cleared');
|
this.debugLog('Bitmap cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async paintLayout(layout: ViewportLayout): Promise<void> {
|
private async paintLayout(): Promise<void> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
if (!this.worker) return;
|
if (!this.worker) return;
|
||||||
|
|
||||||
|
const layout = this.layoutCache;
|
||||||
const dpr = window.devicePixelRatio;
|
const dpr = window.devicePixelRatio;
|
||||||
const currentVersion = this.layoutVersion;
|
const currentVersion = this.layoutVersion;
|
||||||
|
|
||||||
@@ -198,7 +201,10 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
|||||||
this.debugLog(
|
this.debugLog(
|
||||||
`Bitmap painted successfully (version=${e.data.version})`
|
`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 {
|
} else {
|
||||||
this.debugLog(
|
this.debugLog(
|
||||||
`Received outdated bitmap (got=${e.data.version}, current=${this.layoutVersion})`
|
`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) {
|
private canUseBitmapCache(): boolean {
|
||||||
this.clearTile();
|
// Never use bitmap cache during zooming
|
||||||
this.tile = {
|
if (this.isZooming()) return false;
|
||||||
bitmap,
|
return !!(this.layoutCache && this.bitmap);
|
||||||
zoom: this.viewport.zoom,
|
|
||||||
};
|
|
||||||
this.setState('ready');
|
|
||||||
resolve();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private canUseBitmapCache(): boolean {
|
private isZooming(): boolean {
|
||||||
return (
|
return this.viewport.zooming$.value;
|
||||||
!!this.layoutCache && !!this.tile && this.viewport.zoom === this.tile.zoom
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearCanvas() {
|
private clearCanvas() {
|
||||||
@@ -235,14 +235,15 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
|||||||
this.debugLog('Canvas cleared');
|
this.debugLog('Canvas cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawCachedBitmap(layout: ViewportLayout) {
|
private drawCachedBitmap() {
|
||||||
if (!this.tile) {
|
if (!this.bitmap) {
|
||||||
this.debugLog('No cached bitmap available, requesting refresh');
|
this.debugLog('No cached bitmap available, requesting refresh');
|
||||||
this.debouncedRefresh();
|
this.debouncedRefresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bitmap = this.tile.bitmap;
|
const layout = this.layoutCache;
|
||||||
|
const bitmap = this.bitmap;
|
||||||
const ctx = this.canvas.getContext('2d');
|
const ctx = this.canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
@@ -265,26 +266,22 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
|||||||
|
|
||||||
setState(newState: RenderingState) {
|
setState(newState: RenderingState) {
|
||||||
if (this.state === newState) return;
|
if (this.state === newState) return;
|
||||||
this.debugLog(`State change: ${this.state} -> ${newState}`);
|
|
||||||
this.state = newState;
|
this.state = newState;
|
||||||
|
this.debugLog(`State change: ${this.state} -> ${newState}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
canOptimize(): boolean {
|
private canOptimize(): boolean {
|
||||||
const isReady = this.state === 'ready';
|
|
||||||
const isBelowZoomThreshold = this.viewport.zoom <= zoomThreshold;
|
const isBelowZoomThreshold = this.viewport.zoom <= zoomThreshold;
|
||||||
const result = isReady && isBelowZoomThreshold;
|
return (
|
||||||
return result;
|
(this.state === 'ready' || this.state === 'zooming') &&
|
||||||
|
isBelowZoomThreshold
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateOptimizedBlocks() {
|
private updateOptimizedBlocks() {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!this.viewportElement || !this.layoutCache) return;
|
if (!this.viewportElement || !this.layoutCache) return;
|
||||||
if (!this.canOptimize()) 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);
|
this.toggleOptimization(true);
|
||||||
const blockElements = this.viewportElement.getModelsInViewport();
|
const blockElements = this.viewportElement.getModelsInViewport();
|
||||||
@@ -316,4 +313,12 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
|||||||
this.invalidate();
|
this.invalidate();
|
||||||
this.debouncedRefresh();
|
this.debouncedRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private paintPlaceholder() {
|
||||||
|
paintPlaceholder(this.canvas, this.layoutCache, this.viewport);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ViewportTurboRendererIdentifier = LifeCycleWatcherIdentifier(
|
||||||
|
'ViewportTurboRenderer'
|
||||||
|
) as ServiceIdentifier<ViewportTurboRendererExtension>;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export class Viewport {
|
|||||||
() => {
|
() => {
|
||||||
this.zooming$.value = false;
|
this.zooming$.value = false;
|
||||||
},
|
},
|
||||||
100,
|
200,
|
||||||
{ leading: false, trailing: true }
|
{ leading: false, trailing: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export class Viewport {
|
|||||||
() => {
|
() => {
|
||||||
this.panning$.value = false;
|
this.panning$.value = false;
|
||||||
},
|
},
|
||||||
100,
|
200,
|
||||||
{ leading: false, trailing: true }
|
{ leading: false, trailing: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ export class Viewport {
|
|||||||
this._resizeObserver.observe(el);
|
this._resizeObserver.observe(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
setZoom(zoom: number, focusPoint?: IPoint) {
|
setZoom(zoom: number, focusPoint?: IPoint, wheel = false) {
|
||||||
const prevZoom = this.zoom;
|
const prevZoom = this.zoom;
|
||||||
focusPoint = (focusPoint ?? this._center) as IPoint;
|
focusPoint = (focusPoint ?? this._center) as IPoint;
|
||||||
this._zoom = clamp(zoom, this.ZOOM_MIN, this.ZOOM_MAX);
|
this._zoom = clamp(zoom, this.ZOOM_MIN, this.ZOOM_MAX);
|
||||||
@@ -401,7 +401,9 @@ export class Viewport {
|
|||||||
Vec.toVec(focusPoint),
|
Vec.toVec(focusPoint),
|
||||||
Vec.mul(offset, prevZoom / newZoom)
|
Vec.mul(offset, prevZoom / newZoom)
|
||||||
);
|
);
|
||||||
this.zooming$.value = true;
|
if (wheel) {
|
||||||
|
this.zooming$.value = true;
|
||||||
|
}
|
||||||
this.setCenter(newCenter[0], newCenter[1]);
|
this.setCenter(newCenter[0], newCenter[1]);
|
||||||
this.viewportUpdated.emit({
|
this.viewportUpdated.emit({
|
||||||
zoom: this.zoom,
|
zoom: this.zoom,
|
||||||
|
|||||||
Reference in New Issue
Block a user