feat(editor): automatically hide canvas optimized blocks (#10451)

Qualified DOM blocks can now be optimized away automatically.

<img alt="image.png" width="500" src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/lEGcysB4lFTEbCwZ8jMv/102bf813-154a-4816-9eb0-2c9c0ce01fe7.png">

Since this is under development, verifying state correctness is more important than rendering details.

This won't affect current production version since the passive APIs added to `GfxViewportElement` are disabled by the `enableOptimization = false` config.
This commit is contained in:
doodlewind
2025-02-27 02:10:49 +00:00
parent 903d260880
commit 3252dd7a31
2 changed files with 114 additions and 42 deletions

View File

@@ -4,10 +4,12 @@ import {
LifeCycleWatcherIdentifier, LifeCycleWatcherIdentifier,
StdIdentifier, StdIdentifier,
} from '@blocksuite/block-std'; } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; import {
GfxControllerIdentifier,
type GfxViewportElement,
} from '@blocksuite/block-std/gfx';
import type { Container, ServiceIdentifier } from '@blocksuite/global/di'; import type { Container, ServiceIdentifier } from '@blocksuite/global/di';
import { debounce, DisposableGroup } from '@blocksuite/global/utils'; import { debounce, DisposableGroup } from '@blocksuite/global/utils';
import { type Pane } from 'tweakpane';
import { import {
debugLog, debugLog,
@@ -28,12 +30,12 @@ interface Tile {
// With high enough zoom, fallback to DOM rendering // With high enough zoom, fallback to DOM rendering
const zoomThreshold = 1; const zoomThreshold = 1;
const debug = false; // Toggle for debug logs
export class ViewportTurboRendererExtension extends LifeCycleWatcher { export class ViewportTurboRendererExtension extends LifeCycleWatcher {
state: RenderingState = 'inactive'; state: RenderingState = 'inactive';
disposables = new DisposableGroup(); disposables = new DisposableGroup();
private layoutVersion = 0; private layoutVersion = 0;
private readonly debug = false; // Toggle for debug logs
static override setup(di: Container) { static override setup(di: Container) {
di.addImpl(ViewportTurboRendererIdentifier, this, [StdIdentifier]); di.addImpl(ViewportTurboRendererIdentifier, this, [StdIdentifier]);
@@ -43,7 +45,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
private readonly worker: Worker; private readonly worker: Worker;
private layoutCache: ViewportLayout | null = null; private layoutCache: ViewportLayout | null = null;
private tile: Tile | null = null; private tile: Tile | null = null;
private debugPane: Pane | null = null; private viewportElement: GfxViewportElement | null = null;
constructor(std: BlockStdScope) { constructor(std: BlockStdScope) {
super(std); super(std);
@@ -60,7 +62,8 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
initTweakpane(this, mountPoint as HTMLElement); initTweakpane(this, mountPoint as HTMLElement);
} }
this.viewport.elementReady.once(() => { this.viewport.elementReady.once(element => {
this.viewportElement = element;
syncCanvasSize(this.canvas, this.std.host); syncCanvasSize(this.canvas, this.std.host);
this.setState('pending'); this.setState('pending');
this.disposables.add( this.disposables.add(
@@ -81,10 +84,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
override unmounted() { override unmounted() {
this.debugLog('Unmounting renderer'); this.debugLog('Unmounting renderer');
this.clearTile(); this.clearTile();
if (this.debugPane) { this.clearOptimizedBlocks();
this.debugPane.dispose();
this.debugPane = null;
}
this.worker.terminate(); this.worker.terminate();
this.canvas.remove(); this.canvas.remove();
this.disposables.dispose(); this.disposables.dispose();
@@ -99,23 +99,31 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
if (this.state === 'inactive') return; if (this.state === 'inactive') return;
this.clearCanvas(); this.clearCanvas();
// -> pending
if (this.viewport.zoom > zoomThreshold) { if (this.viewport.zoom > zoomThreshold) {
this.debugLog('Zoom above threshold, falling back to DOM rendering'); this.debugLog('Zoom above threshold, falling back to DOM rendering');
this.setState('pending'); this.setState('pending');
return; this.toggleOptimization(false);
} else if (this.canUseBitmapCache()) { this.clearOptimizedBlocks();
}
// -> ready
else if (this.canUseBitmapCache()) {
this.debugLog('Using cached bitmap'); this.debugLog('Using cached bitmap');
this.drawCachedBitmap(this.layoutCache!);
this.setState('ready'); this.setState('ready');
} else { this.drawCachedBitmap(this.layoutCache!);
this.updateOptimizedBlocks();
}
// -> rendering
else {
if (!this.layoutCache) { if (!this.layoutCache) {
this.updateLayoutCache(); this.updateLayoutCache();
} }
const layout = this.layoutCache!; const layout = this.layoutCache!;
this.setState('rendering'); this.setState('rendering');
this.toggleOptimization(false);
await this.paintLayout(layout); await this.paintLayout(layout);
this.drawCachedBitmap(layout); this.drawCachedBitmap(layout);
// State will be updated to 'ready' in handlePaintedBitmap if successful this.updateOptimizedBlocks();
} }
} }
@@ -131,13 +139,14 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
this.layoutVersion++; this.layoutVersion++;
this.layoutCache = null; this.layoutCache = null;
this.clearTile(); this.clearTile();
this.clearCanvas(); // Should clear immediately after content updates this.clearCanvas();
this.clearOptimizedBlocks();
this.setState('pending'); this.setState('pending');
this.debugLog(`Invalidated renderer (layoutVersion=${this.layoutVersion})`); this.debugLog(`Invalidated renderer (layoutVersion=${this.layoutVersion})`);
} }
private debugLog(message: string): void { private debugLog(message: string) {
if (!this.debug) return; if (!debug) return;
debugLog(message, this.state); debugLog(message, this.state);
} }
@@ -148,11 +157,10 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
} }
private clearTile() { private clearTile() {
if (this.tile) { if (!this.tile) return;
this.tile.bitmap.close(); this.tile.bitmap.close();
this.tile = null; this.tile = null;
this.debugLog('Tile cleared'); this.debugLog('Tile cleared');
}
} }
private async paintLayout(layout: ViewportLayout): Promise<void> { private async paintLayout(layout: ViewportLayout): Promise<void> {
@@ -224,7 +232,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
if (!this.tile) { if (!this.tile) {
this.debugLog('No cached bitmap available, requesting refresh'); this.debugLog('No cached bitmap available, requesting refresh');
this.debouncedRefresh(); this.debouncedRefresh();
return; // version mismatch return;
} }
const bitmap = this.tile.bitmap; const bitmap = this.tile.bitmap;
@@ -248,16 +256,51 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
this.debugLog('Bitmap drawn to canvas'); this.debugLog('Bitmap drawn to canvas');
} }
setState(newState: RenderingState): void { setState(newState: RenderingState) {
if (this.state === newState) return; if (this.state === newState) return;
this.debugLog(`State change: ${this.state} -> ${newState}`); this.debugLog(`State change: ${this.state} -> ${newState}`);
this.state = newState; this.state = newState;
} }
canOptimizeDOM(): boolean { 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();
const blockIds = Array.from(blockElements).map(model => model.id);
this.viewportElement.updateOptimizedBlocks(blockIds, true);
this.debugLog(`Optimized ${blockIds.length} blocks`);
});
}
private clearOptimizedBlocks() {
if (this.viewportElement) {
this.viewportElement.clearOptimizedBlocks();
this.debugLog('Cleared optimized blocks');
}
}
canOptimize(): boolean {
const isReady = this.state === 'ready'; const isReady = this.state === 'ready';
const isBelowZoomThreshold = this.viewport.zoom <= zoomThreshold; const isBelowZoomThreshold = this.viewport.zoom <= zoomThreshold;
const result = isReady && isBelowZoomThreshold; const result = isReady && isBelowZoomThreshold;
return result; return result;
} }
private toggleOptimization(value: boolean) {
if (
this.viewportElement &&
this.viewportElement.enableOptimization !== value
) {
this.viewportElement.enableOptimization = value;
this.debugLog(`${value ? 'Enabled' : 'Disabled'} optimization`);
}
}
} }

View File

@@ -57,27 +57,30 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
} }
`; `;
optimizedBlocks = new Set<string>();
private readonly _hideOutsideBlock = () => { private readonly _hideOutsideBlock = () => {
if (this.getModelsInViewport && this.host) { if (!this.host) return;
const host = this.host;
const modelsInViewport = this.getModelsInViewport();
modelsInViewport.forEach(model => { const { host, optimizedBlocks, enableOptimization } = this;
const view = host.std.view.getBlock(model.id); const modelsInViewport = this.getModelsInViewport();
setDisplay(view, 'block'); modelsInViewport.forEach(model => {
const view = host.std.view.getBlock(model.id);
const canOptimize = optimizedBlocks.has(model.id) && enableOptimization;
const display = canOptimize ? 'none' : 'block';
setDisplay(view, display);
if (this._lastVisibleModels?.has(model)) { if (this._lastVisibleModels?.has(model)) {
this._lastVisibleModels!.delete(model); this._lastVisibleModels!.delete(model);
} }
}); });
this._lastVisibleModels?.forEach(model => { this._lastVisibleModels?.forEach(model => {
const view = host.std.view.getBlock(model.id); const view = host.std.view.getBlock(model.id);
setDisplay(view, 'none'); setDisplay(view, 'none');
}); });
this._lastVisibleModels = modelsInViewport; this._lastVisibleModels = modelsInViewport;
}
}; };
private _lastVisibleModels?: Set<GfxBlockElementModel>; private _lastVisibleModels?: Set<GfxBlockElementModel>;
@@ -154,7 +157,8 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
}; };
@property({ attribute: false }) @property({ attribute: false })
accessor getModelsInViewport: undefined | (() => Set<GfxBlockElementModel>); accessor getModelsInViewport: () => Set<GfxBlockElementModel> = () =>
new Set();
@property({ attribute: false }) @property({ attribute: false })
accessor host: undefined | EditorHost; accessor host: undefined | EditorHost;
@@ -167,4 +171,29 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
@property({ attribute: false }) @property({ attribute: false })
accessor viewport!: Viewport; accessor viewport!: Viewport;
@property({ attribute: false })
accessor enableOptimization: boolean = false;
updateOptimizedBlocks(blockIds: string[], optimized: boolean): void {
let changed = false;
blockIds.forEach(id => {
if (optimized && !this.optimizedBlocks.has(id)) {
this.optimizedBlocks.add(id);
changed = true;
} else if (!optimized && this.optimizedBlocks.has(id)) {
this.optimizedBlocks.delete(id);
changed = true;
}
});
if (changed) this._refreshViewport();
}
clearOptimizedBlocks(): void {
if (this.optimizedBlocks.size === 0) return;
this.optimizedBlocks.clear();
this._refreshViewport();
}
} }