diff --git a/blocksuite/affine/blocks/code/src/turbo/code-layout-handler.ts b/blocksuite/affine/blocks/code/src/turbo/code-layout-handler.ts index 3a43ba2b85..0c6fc807d3 100644 --- a/blocksuite/affine/blocks/code/src/turbo/code-layout-handler.ts +++ b/blocksuite/affine/blocks/code/src/turbo/code-layout-handler.ts @@ -25,7 +25,9 @@ export class CodeLayoutHandlerExtension extends BlockLayoutHandlerExtension p.blockType === model.flavour); // Determine the correct viewport state to use - const component = host.std.view.getBlock(model.id) as GfxBlockComponent; + const component = host.std.view.getBlock( + model.id + ) as GfxBlockComponent | null; const currentViewportState = component?.dataset.viewportState; const effectiveViewportState = currentViewportState ?? ancestorViewportState; @@ -77,14 +79,24 @@ export function getViewportLayoutTree( : defaultViewportState; const layoutData = handler?.queryLayout(model, host, viewportRecord); + let layout: BlockLayout = baseLayout; if (handler && layoutData) { - const { rect } = handler.calculateBound(layoutData); - baseLayout.rect = rect; - layoutMinX = Math.min(layoutMinX, rect.x); - layoutMinY = Math.min(layoutMinY, rect.y); - layoutMaxX = Math.max(layoutMaxX, rect.x + rect.w); - layoutMaxY = Math.max(layoutMaxY, rect.y + rect.h); + const { rect: calculatedRect } = handler.calculateBound(layoutData); + layout = { + ...layoutData, + ...baseLayout, + rect: calculatedRect, + }; + layoutMinX = Math.min(layoutMinX, calculatedRect.x); + layoutMinY = Math.min(layoutMinY, calculatedRect.y); + layoutMaxX = Math.max(layoutMaxX, calculatedRect.x + calculatedRect.w); + layoutMaxY = Math.max(layoutMaxY, calculatedRect.y + calculatedRect.h); + } else { + layoutMinX = Math.min(layoutMinX, baseLayout.rect.x); + layoutMinY = Math.min(layoutMinY, baseLayout.rect.y); + layoutMaxX = Math.max(layoutMaxX, baseLayout.rect.x + baseLayout.rect.w); + layoutMaxY = Math.max(layoutMaxY, baseLayout.rect.y + baseLayout.rect.h); } const children: BlockLayoutTreeNode[] = []; @@ -95,12 +107,10 @@ export function getViewportLayoutTree( } } - // Create node for this block - ALWAYS return a node - // Return the node structure including the layout (either real or fallback) return { blockId: model.id, type: model.flavour, - layout: layoutData ? { ...baseLayout, ...layoutData } : baseLayout, + layout, children, }; }; diff --git a/blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts b/blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts index e6333c18a1..d71155c4f3 100644 --- a/blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts +++ b/blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts @@ -14,6 +14,7 @@ import { Subject, take, tap, + timer, } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; @@ -34,9 +35,11 @@ import type { const debug = false; // Toggle for debug logs -const defaultOptions: RendererOptions = { +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 = @@ -76,12 +79,14 @@ export class ViewportTurboRendererExtension extends GfxExtension { public readonly state$ = new BehaviorSubject('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(); + private readonly isRecentlyZoomed$ = new BehaviorSubject(false); public get currentState(): RenderingState { return this.state$.value; @@ -163,6 +168,14 @@ export class ViewportTurboRendererExtension extends GfxExtension { 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); } @@ -230,14 +243,12 @@ export class ViewportTurboRendererExtension extends GfxExtension { this.debugLog('Using cached bitmap'); nextState = 'ready'; this.drawCachedBitmap(); - this.updateOptimizedBlocks(); } else { this.debugLog('Starting bitmap rendering'); nextState = 'rendering'; this.state$.next(nextState); await this.paintLayout(); this.drawCachedBitmap(); - this.updateOptimizedBlocks(); // After rendering completes, transition to ready state nextState = 'ready'; } @@ -269,7 +280,10 @@ export class ViewportTurboRendererExtension extends GfxExtension { private async paintLayout(): Promise { return new Promise(resolve => { - if (!this.worker) return; + if (!this.worker || !this.options.enableBitmapRendering) { + resolve(); + return; + } const layout = this.layoutCache; const dpr = window.devicePixelRatio; @@ -318,9 +332,8 @@ export class ViewportTurboRendererExtension extends GfxExtension { }); } - private canUseBitmapCache(): boolean { - // Never use bitmap cache during zooming - if (this.isZooming()) return false; + public canUseBitmapCache(): boolean { + if (!this.options.enableBitmapRendering || this.isZooming()) return false; return !!(this.layoutCache && this.bitmap); } @@ -336,9 +349,10 @@ export class ViewportTurboRendererExtension extends GfxExtension { } private drawCachedBitmap() { - if (!this.bitmap) { - this.debugLog('No cached bitmap available, requesting refresh'); - this.refresh$.next(); + if (!this.options.enableBitmapRendering || !this.bitmap) { + this.debugLog( + 'Bitmap drawing skipped (disabled or no cached bitmap available)' + ); return; } @@ -366,26 +380,37 @@ export class ViewportTurboRendererExtension extends GfxExtension { } private canOptimize(): boolean { + if (this.isRecentlyZoomed$.value) return false; + const isBelowZoomThreshold = this.viewport.zoom <= this.options.zoomThreshold; - return ( - (this.state$.value === 'ready' || this.state$.value === 'zooming') && - isBelowZoomThreshold - ); + return this.state$.value === 'zooming' && isBelowZoomThreshold; } private updateOptimizedBlocks() { + if (!this.canOptimize()) return; requestAnimationFrame(() => { if (!this.viewportElement || !this.layoutCache) return; - if (!this.canOptimize()) 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'); } diff --git a/blocksuite/affine/gfx/turbo-renderer/src/types.ts b/blocksuite/affine/gfx/turbo-renderer/src/types.ts index efb79be69b..a9f57e6247 100644 --- a/blocksuite/affine/gfx/turbo-renderer/src/types.ts +++ b/blocksuite/affine/gfx/turbo-renderer/src/types.ts @@ -82,6 +82,7 @@ export interface BlockLayoutPainter { export interface RendererOptions { zoomThreshold: number; debounceTime: number; + enableBitmapRendering?: boolean; } export interface TurboRendererConfig { diff --git a/blocksuite/integration-test/src/__tests__/edgeless/turbo-renderer.spec.ts b/blocksuite/integration-test/src/__tests__/edgeless/turbo-renderer.spec.ts index 3ac3b4f58e..78591e511b 100644 --- a/blocksuite/integration-test/src/__tests__/edgeless/turbo-renderer.spec.ts +++ b/blocksuite/integration-test/src/__tests__/edgeless/turbo-renderer.spec.ts @@ -49,22 +49,36 @@ describe('viewport turbo renderer', () => { expect(renderer.state$.value).toBe('pending'); }); - test('zooming should change state to zooming', async () => { + test('zooming should change internal state and populate optimized block ids', async () => { const renderer = getRenderer(); + addSampleNotes(doc, 1); + await wait(); + expect(renderer.optimizedBlockIds.length).toBe(0); + renderer.viewport.zooming$.next(true); await wait(); + expect(renderer.state$.value).toBe('zooming'); - renderer.viewport.zooming$.next(false); + + const canUseCache = renderer.canUseBitmapCache(); + expect(canUseCache).toBe(false); + + await renderer.refresh(); await wait(); + expect(renderer.optimizedBlockIds.length).toBe(1); + + renderer.viewport.zooming$.next(false); + await wait(renderer.options.debounceTime + 100); + expect(renderer.state$.value).not.toBe('zooming'); + expect(renderer.optimizedBlockIds.length).toBe(0); }); test('state transitions between pending and ready', async () => { const renderer = getRenderer(); - // Initial state should be pending after adding content addSampleNotes(doc, 1); - await wait(100); // Short wait for initial processing + await wait(); expect(renderer.state$.value).toBe('pending'); // Ensure zooming is off and wait for debounce + buffer @@ -99,11 +113,11 @@ describe('viewport turbo renderer', () => { const renderer = getRenderer(); addSampleNotes(doc, 1); await wait(); - expect(renderer.layoutCacheData).toBeNull(); // Check internal state before access + expect(renderer.layoutCacheData).toBeNull(); - const _cache = renderer.layoutCache; // Access getter to populate cache + const _cache = renderer.layoutCache; noop(_cache); - expect(renderer.layoutCacheData).not.toBeNull(); // Check internal state after access - expect(renderer.layoutCache?.roots.length).toBeGreaterThan(0); // Check public getter result + expect(renderer.layoutCacheData).not.toBeNull(); + expect(renderer.layoutCache?.roots.length).toBeGreaterThan(0); }); });