mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-22 20:41:50 +08:00
34039bc7d8
Based on this PR, all block types support zooming placeholder now.  <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Improvements** - Enhanced layout rendering accuracy for non-root nodes, leading to more precise placement and sizing. - Simplified placeholder painting logic for improved consistency, with all nodes now displayed using a color based on their depth. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
import type { Container } from '@blocksuite/global/di';
|
|
import { DisposableGroup } from '@blocksuite/global/disposable';
|
|
import { ConfigExtensionFactory } from '@blocksuite/std';
|
|
import {
|
|
type GfxController,
|
|
GfxExtension,
|
|
GfxExtensionIdentifier,
|
|
type GfxViewportElement,
|
|
} from '@blocksuite/std/gfx';
|
|
import {
|
|
BehaviorSubject,
|
|
distinctUntilChanged,
|
|
merge,
|
|
Subject,
|
|
take,
|
|
tap,
|
|
timer,
|
|
} from 'rxjs';
|
|
import { debounceTime } from 'rxjs/operators';
|
|
|
|
import {
|
|
debugLog,
|
|
getViewportLayoutTree,
|
|
paintPlaceholder,
|
|
syncCanvasSize,
|
|
} from './renderer-utils';
|
|
import type {
|
|
MessagePaint,
|
|
RendererOptions,
|
|
RenderingState,
|
|
TurboRendererConfig,
|
|
ViewportLayoutTree,
|
|
WorkerToHostMessage,
|
|
} from './types';
|
|
|
|
const debug = false; // Toggle for debug logs
|
|
|
|
const defaultOptions = {
|
|
zoomThreshold: 1, // With high enough zoom, fallback to DOM rendering
|
|
debounceTime: 1000, // During this period, fallback to DOM
|
|
enableBitmapRendering: false, // When enabled, the bitmap rendering will be used
|
|
postZoomDelay: 100,
|
|
};
|
|
|
|
export const TurboRendererConfigFactory =
|
|
ConfigExtensionFactory<TurboRendererConfig>('viewport-turbo-renderer');
|
|
|
|
/**
|
|
* Manages the Turbo Rendering process for the viewport, coordinating between the main thread and a painter worker.
|
|
* Turbo Rendering optimizes performance by rendering block content onto a canvas bitmap,
|
|
* falling back to standard DOM rendering during interactions.
|
|
*
|
|
* To add Turbo Rendering support for a new block type (e.g., 'affine:my-block'):
|
|
*
|
|
* 1. **In the block's package (e.g., `blocksuite/affine/blocks/my-block`):**
|
|
* a. Add `@blocksuite/affine/gfx/turbo-renderer` as a dependency in `package.json` and create a `src/turbo` directory.
|
|
* b. Implement the Layout Handler (e.g., `MyBlockLayoutHandlerExtension`) and Painter Worker (e.g., `MyBlockLayoutPainterExtension`). Refer to `ParagraphLayoutHandlerExtension` and `ParagraphLayoutPainterExtension` in `blocksuite/affine/blocks/block-paragraph` for implementation examples.
|
|
* c. Export the Layout Handler and Painter Worker extensions from the block package's main `src/index.ts` by adding these two explicit export statements:
|
|
* ```typescript
|
|
* export * from './turbo/my-block-layout-handler';
|
|
* export * from './turbo/my-block-painter.worker';
|
|
* ```
|
|
* d. Add an export mapping for the painter worker in `package.json` under the `exports` field (e.g., `"./turbo-painter": "./src/turbo/my-block-painter.worker.ts"`).
|
|
* e. Add a TypeScript project reference to `blocksuite/affine/gfx/turbo-renderer` in `tsconfig.json`.
|
|
*
|
|
* 2. **In the application integration point (e.g., `packages/frontend/core/src/blocksuite/extensions` and `blocksuite/integration-test/src/__tests__/utils/renderer-entry.ts`):**
|
|
* a. In `turbo-renderer.ts` (or the file setting up `TurboRendererConfigFactory`):
|
|
* - Import and add the new Layout Handler extension to the `patchTurboRendererExtension` array (or equivalent DI setup). See how `ParagraphLayoutHandlerExtension` is added as a reference.
|
|
* b. In `turbo-painter.worker.ts` (the painter worker entry point):
|
|
* - Import and add the new Painter Worker extension to the `ViewportLayoutPainter` constructor's extension array. See how `ParagraphLayoutPainterExtension` is added as a reference.
|
|
*
|
|
* 3. **Run `yarn affine init`** from the workspace root to update generated configuration files (`workspace.gen.ts`) and the lockfile (`yarn.lock`).
|
|
*
|
|
* **Note:** Always ensure the directory structure and export patterns match the `paragraph` block (`blocksuite/affine/blocks/block-paragraph`) for consistency.
|
|
*/
|
|
export class ViewportTurboRendererExtension extends GfxExtension {
|
|
static override key = 'viewportTurboRenderer';
|
|
|
|
public readonly state$ = new BehaviorSubject<RenderingState>('inactive');
|
|
public readonly canvas: HTMLCanvasElement = document.createElement('canvas');
|
|
public layoutCacheData: ViewportLayoutTree | null = null;
|
|
public optimizedBlockIds: string[] = [];
|
|
private readonly worker: Worker;
|
|
private readonly disposables = new DisposableGroup();
|
|
private layoutVersion = 0;
|
|
private bitmap: ImageBitmap | null = null;
|
|
private viewportElement: GfxViewportElement | null = null;
|
|
private readonly refresh$ = new Subject<void>();
|
|
private readonly isRecentlyZoomed$ = new BehaviorSubject<boolean>(false);
|
|
|
|
public get currentState(): RenderingState {
|
|
return this.state$.value;
|
|
}
|
|
|
|
constructor(gfx: GfxController) {
|
|
super(gfx);
|
|
|
|
const id = TurboRendererConfigFactory.identifier;
|
|
const config = this.std.getOptional(id);
|
|
if (!config) {
|
|
throw new Error('TurboRendererConfig not found');
|
|
}
|
|
this.worker = config.painterWorkerEntry();
|
|
|
|
// Set up state change logging
|
|
this.state$
|
|
.pipe(
|
|
distinctUntilChanged(),
|
|
tap(state => this.debugLog(`State changed to: ${state}`))
|
|
)
|
|
.subscribe();
|
|
|
|
// Set up debounced refresh
|
|
this.refresh$
|
|
.pipe(debounceTime(this.options.debounceTime))
|
|
.subscribe(() => {
|
|
this.refresh().catch(console.error);
|
|
});
|
|
}
|
|
|
|
static override extendGfx(gfx: GfxController) {
|
|
Object.defineProperty(gfx, 'turboRenderer', {
|
|
get() {
|
|
return gfx.std.get(ViewportTurboRendererIdentifier);
|
|
},
|
|
});
|
|
}
|
|
|
|
static override setup(di: Container) {
|
|
super.setup(di);
|
|
}
|
|
|
|
get options(): RendererOptions {
|
|
const id = TurboRendererConfigFactory.identifier;
|
|
const { options } = this.std.getOptional(id) || {};
|
|
return {
|
|
...defaultOptions,
|
|
...options,
|
|
};
|
|
}
|
|
|
|
override mounted() {
|
|
const mountPoint = document.querySelector('.affine-edgeless-viewport');
|
|
if (mountPoint) {
|
|
mountPoint.append(this.canvas);
|
|
}
|
|
|
|
this.viewport.elementReady.pipe(take(1)).subscribe(element => {
|
|
this.viewportElement = element;
|
|
syncCanvasSize(this.canvas, this.std.host);
|
|
this.state$.next('pending');
|
|
|
|
this.disposables.add(
|
|
this.viewport.sizeUpdated.subscribe(() => this.handleResize())
|
|
);
|
|
|
|
this.disposables.add(
|
|
this.viewport.viewportUpdated.subscribe(() => {
|
|
this.refresh().catch(console.error);
|
|
})
|
|
);
|
|
|
|
this.disposables.add(
|
|
this.viewport.zooming$
|
|
.pipe(
|
|
tap(isZooming => {
|
|
this.debugLog(`Zooming signal changed: ${isZooming}`);
|
|
if (isZooming) {
|
|
this.state$.next('zooming');
|
|
} else if (this.state$.value === 'zooming') {
|
|
this.clearOptimizedBlocks();
|
|
this.isRecentlyZoomed$.next(true);
|
|
this.disposables.add(
|
|
timer(defaultOptions.postZoomDelay).subscribe(() => {
|
|
this.isRecentlyZoomed$.next(false);
|
|
})
|
|
);
|
|
|
|
this.state$.next('pending');
|
|
this.refresh().catch(console.error);
|
|
}
|
|
})
|
|
)
|
|
.subscribe()
|
|
);
|
|
});
|
|
|
|
// Handle selection and block updates
|
|
const selectionUpdates$ = this.selection.slots.updated;
|
|
const blockUpdates$ = this.std.store.slots.blockUpdated;
|
|
|
|
// Combine all events that should trigger invalidation
|
|
this.disposables.add(
|
|
merge(selectionUpdates$, blockUpdates$).subscribe(() => this.invalidate())
|
|
);
|
|
}
|
|
|
|
override unmounted() {
|
|
this.debugLog('Unmounting renderer');
|
|
this.clearBitmap();
|
|
this.clearOptimizedBlocks();
|
|
this.worker.terminate();
|
|
this.canvas.remove();
|
|
this.disposables.dispose();
|
|
this.state$.next('inactive');
|
|
this.state$.complete();
|
|
this.refresh$.complete();
|
|
}
|
|
|
|
get viewport() {
|
|
return this.gfx.viewport;
|
|
}
|
|
|
|
get selection() {
|
|
return this.gfx.selection;
|
|
}
|
|
|
|
get layoutCache() {
|
|
if (this.layoutCacheData) return this.layoutCacheData;
|
|
const layoutTree = getViewportLayoutTree(this.std.host, this.viewport);
|
|
this.debugLog('Layout cache updated');
|
|
return (this.layoutCacheData = layoutTree);
|
|
}
|
|
|
|
async refresh() {
|
|
if (this.state$.value === 'inactive') return;
|
|
|
|
this.clearCanvas();
|
|
|
|
// Determine the next state based on current conditions
|
|
let nextState: RenderingState;
|
|
|
|
if (this.viewport.zoom > this.options.zoomThreshold) {
|
|
this.debugLog('Zoom above threshold, falling back to DOM rendering');
|
|
nextState = 'pending';
|
|
this.clearOptimizedBlocks();
|
|
} else if (this.isZooming()) {
|
|
this.debugLog('Currently zooming, using placeholder rendering');
|
|
nextState = 'zooming';
|
|
this.paintPlaceholder();
|
|
this.updateOptimizedBlocks();
|
|
} else if (this.canUseBitmapCache()) {
|
|
this.debugLog('Using cached bitmap');
|
|
nextState = 'ready';
|
|
this.drawCachedBitmap();
|
|
} else {
|
|
this.debugLog('Starting bitmap rendering');
|
|
nextState = 'rendering';
|
|
this.state$.next(nextState);
|
|
await this.paintLayout();
|
|
this.drawCachedBitmap();
|
|
// After rendering completes, transition to ready state
|
|
nextState = 'ready';
|
|
}
|
|
|
|
this.state$.next(nextState);
|
|
}
|
|
|
|
invalidate() {
|
|
this.layoutVersion++;
|
|
this.layoutCacheData = null;
|
|
this.clearBitmap();
|
|
this.clearCanvas();
|
|
this.clearOptimizedBlocks();
|
|
this.state$.next('pending');
|
|
this.debugLog(`Invalidated renderer (layoutVersion=${this.layoutVersion})`);
|
|
}
|
|
|
|
private debugLog(message: string) {
|
|
if (!debug) return;
|
|
debugLog(message, this.state$.value);
|
|
}
|
|
|
|
private clearBitmap() {
|
|
if (!this.bitmap) return;
|
|
this.bitmap.close();
|
|
this.bitmap = null;
|
|
this.debugLog('Bitmap cleared');
|
|
}
|
|
|
|
private async paintLayout(): Promise<void> {
|
|
return new Promise(resolve => {
|
|
if (!this.worker || !this.options.enableBitmapRendering) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const layout = this.layoutCache;
|
|
const dpr = window.devicePixelRatio;
|
|
const currentVersion = this.layoutVersion;
|
|
|
|
this.debugLog(`Requesting bitmap painting (version=${currentVersion})`);
|
|
const message: MessagePaint = {
|
|
type: 'paintLayout',
|
|
data: {
|
|
layout,
|
|
width: layout.overallRect.w,
|
|
height: layout.overallRect.h,
|
|
dpr,
|
|
zoom: this.viewport.zoom,
|
|
version: currentVersion,
|
|
},
|
|
};
|
|
this.worker.postMessage(message);
|
|
|
|
this.worker.onmessage = (e: MessageEvent<WorkerToHostMessage>) => {
|
|
if (e.data.type === 'bitmapPainted') {
|
|
if (e.data.version === this.layoutVersion) {
|
|
this.debugLog(
|
|
`Bitmap painted successfully (version=${e.data.version})`
|
|
);
|
|
this.clearBitmap();
|
|
this.bitmap = e.data.bitmap;
|
|
this.state$.next('ready');
|
|
resolve();
|
|
} else {
|
|
this.debugLog(
|
|
`Received outdated bitmap (got=${e.data.version}, current=${this.layoutVersion})`
|
|
);
|
|
e.data.bitmap.close();
|
|
this.state$.next('pending');
|
|
resolve();
|
|
}
|
|
} else if (e.data.type === 'paintError') {
|
|
this.debugLog(
|
|
`Paint error: ${e.data.error} for blockType: ${e.data.blockType}`
|
|
);
|
|
this.state$.next('pending');
|
|
resolve();
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
public canUseBitmapCache(): boolean {
|
|
if (!this.options.enableBitmapRendering || this.isZooming()) return false;
|
|
return !!(this.layoutCache && this.bitmap);
|
|
}
|
|
|
|
private isZooming(): boolean {
|
|
return this.viewport.zooming$.value;
|
|
}
|
|
|
|
private clearCanvas() {
|
|
const ctx = this.canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
this.debugLog('Canvas cleared');
|
|
}
|
|
|
|
private drawCachedBitmap() {
|
|
if (!this.options.enableBitmapRendering || !this.bitmap) {
|
|
this.debugLog(
|
|
'Bitmap drawing skipped (disabled or no cached bitmap available)'
|
|
);
|
|
return;
|
|
}
|
|
|
|
const layout = this.layoutCache;
|
|
const bitmap = this.bitmap;
|
|
const ctx = this.canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
this.clearCanvas();
|
|
|
|
const layoutViewCoord = this.viewport.toViewCoord(
|
|
layout.overallRect.x,
|
|
layout.overallRect.y
|
|
);
|
|
|
|
ctx.drawImage(
|
|
bitmap,
|
|
layoutViewCoord[0] * window.devicePixelRatio,
|
|
layoutViewCoord[1] * window.devicePixelRatio,
|
|
layout.overallRect.w * window.devicePixelRatio * this.viewport.zoom,
|
|
layout.overallRect.h * window.devicePixelRatio * this.viewport.zoom
|
|
);
|
|
|
|
this.debugLog('Bitmap drawn to canvas');
|
|
}
|
|
|
|
private canOptimize(): boolean {
|
|
if (this.isRecentlyZoomed$.value) return false;
|
|
|
|
const isBelowZoomThreshold =
|
|
this.viewport.zoom <= this.options.zoomThreshold;
|
|
return this.state$.value === 'zooming' && isBelowZoomThreshold;
|
|
}
|
|
|
|
private updateOptimizedBlocks() {
|
|
if (!this.canOptimize()) return;
|
|
requestAnimationFrame(() => {
|
|
if (!this.viewportElement || !this.layoutCache) return;
|
|
const blockElements = this.viewportElement.getModelsInViewport();
|
|
const blockIds = Array.from(blockElements).map(model => model.id);
|
|
|
|
// Set all previously optimized blocks to active first
|
|
if (this.optimizedBlockIds.length > 0) {
|
|
this.viewportElement.setBlocksActive(this.optimizedBlockIds);
|
|
}
|
|
// Now set the new blocks to idle (hidden)
|
|
this.optimizedBlockIds = blockIds;
|
|
this.viewportElement.setBlocksIdle(blockIds);
|
|
|
|
this.debugLog(`Optimized ${blockIds.length} blocks`);
|
|
});
|
|
}
|
|
|
|
private clearOptimizedBlocks() {
|
|
if (!this.viewportElement || this.optimizedBlockIds.length === 0) return;
|
|
|
|
this.viewportElement.setBlocksActive(this.optimizedBlockIds);
|
|
this.optimizedBlockIds = [];
|
|
this.debugLog('Cleared optimized blocks');
|
|
}
|
|
|
|
private handleResize() {
|
|
this.debugLog('Container resized, syncing canvas size');
|
|
syncCanvasSize(this.canvas, this.std.host);
|
|
this.invalidate();
|
|
this.refresh$.next();
|
|
}
|
|
|
|
private paintPlaceholder() {
|
|
paintPlaceholder(this.canvas, this.layoutCache, this.viewport);
|
|
}
|
|
}
|
|
|
|
export const ViewportTurboRendererIdentifier = GfxExtensionIdentifier(
|
|
'viewportTurboRenderer'
|
|
);
|