fix(editor): turbo renderer placeholder for built in template (#11793)

Fixed compat error for new built-in template with test updated.

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/lEGcysB4lFTEbCwZ8jMv/f8c69d3f-9602-4509-994b-7243b26b4656.png)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Added an option to enable or disable bitmap rendering in the renderer settings.
  - Introduced a cooldown period after zooming before block optimization resumes, improving rendering performance and stability.

- **Bug Fixes**
  - Improved handling of cases where block components may be missing, preventing potential runtime errors.

- **Tests**
  - Expanded and refined tests to verify zooming behavior, bitmap caching, and internal state transitions for enhanced reliability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
doodlewind
2025-04-24 02:40:04 +00:00
parent 7fceb4cbd1
commit 2d3130eac9
9 changed files with 100 additions and 39 deletions
@@ -25,7 +25,9 @@ export class CodeLayoutHandlerExtension extends BlockLayoutHandlerExtension<Code
host: EditorHost, host: EditorHost,
viewportRecord: ViewportRecord viewportRecord: ViewportRecord
): CodeLayout | null { ): CodeLayout | null {
const component = host.std.view.getBlock(model.id) as GfxBlockComponent; const component = host.std.view.getBlock(
model.id
) as GfxBlockComponent | null;
if (!component) return null; if (!component) return null;
const codeBlockElement = component.querySelector( const codeBlockElement = component.querySelector(
@@ -25,7 +25,9 @@ export class ImageLayoutHandlerExtension extends BlockLayoutHandlerExtension<Ima
host: EditorHost, host: EditorHost,
viewportRecord: ViewportRecord viewportRecord: ViewportRecord
): ImageLayout | null { ): ImageLayout | null {
const component = host.std.view.getBlock(model.id) as GfxBlockComponent; const component = host.std.view.getBlock(
model.id
) as GfxBlockComponent | null;
if (!component) return null; if (!component) return null;
const imageContainer = component.querySelector('.affine-image-container'); const imageContainer = component.querySelector('.affine-image-container');
@@ -27,7 +27,9 @@ export class ListLayoutHandlerExtension extends BlockLayoutHandlerExtension<List
host: EditorHost, host: EditorHost,
viewportRecord: ViewportRecord viewportRecord: ViewportRecord
): ListLayout | null { ): ListLayout | null {
const component = host.std.view.getBlock(model.id) as GfxBlockComponent; const component = host.std.view.getBlock(
model.id
) as GfxBlockComponent | null;
if (!component) return null; if (!component) return null;
// Find the list items within this specific list component // Find the list items within this specific list component
@@ -30,7 +30,9 @@ export class NoteLayoutHandlerExtension extends BlockLayoutHandlerExtension<Note
host: EditorHost, host: EditorHost,
viewportRecord: ViewportRecord viewportRecord: ViewportRecord
): NoteLayout | null { ): NoteLayout | null {
const component = host.std.view.getBlock(model.id) as GfxBlockComponent; const component = host.std.view.getBlock(
model.id
) as GfxBlockComponent | null;
if (!component) return null; if (!component) return null;
// Get the note container element // Get the note container element
@@ -27,7 +27,10 @@ export class ParagraphLayoutHandlerExtension extends BlockLayoutHandlerExtension
host: EditorHost, host: EditorHost,
viewportRecord: ViewportRecord viewportRecord: ViewportRecord
): ParagraphLayout | null { ): ParagraphLayout | null {
const component = host.std.view.getBlock(model.id) as GfxBlockComponent; const component = host.std.view.getBlock(
model.id
) as GfxBlockComponent | null;
if (!component) return null;
const paragraphSelector = const paragraphSelector =
'.affine-paragraph-rich-text-wrapper [data-v-text="true"]'; '.affine-paragraph-rich-text-wrapper [data-v-text="true"]';
const paragraphNode = component.querySelector(paragraphSelector); const paragraphNode = component.querySelector(paragraphSelector);
@@ -58,7 +58,9 @@ export function getViewportLayoutTree(
const handler = providersArray.find(p => p.blockType === model.flavour); const handler = providersArray.find(p => p.blockType === model.flavour);
// Determine the correct viewport state to use // 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 currentViewportState = component?.dataset.viewportState;
const effectiveViewportState = const effectiveViewportState =
currentViewportState ?? ancestorViewportState; currentViewportState ?? ancestorViewportState;
@@ -77,14 +79,24 @@ export function getViewportLayoutTree(
: defaultViewportState; : defaultViewportState;
const layoutData = handler?.queryLayout(model, host, viewportRecord); const layoutData = handler?.queryLayout(model, host, viewportRecord);
let layout: BlockLayout = baseLayout;
if (handler && layoutData) { if (handler && layoutData) {
const { rect } = handler.calculateBound(layoutData); const { rect: calculatedRect } = handler.calculateBound(layoutData);
baseLayout.rect = rect; layout = {
layoutMinX = Math.min(layoutMinX, rect.x); ...layoutData,
layoutMinY = Math.min(layoutMinY, rect.y); ...baseLayout,
layoutMaxX = Math.max(layoutMaxX, rect.x + rect.w); rect: calculatedRect,
layoutMaxY = Math.max(layoutMaxY, rect.y + rect.h); };
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[] = []; 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 { return {
blockId: model.id, blockId: model.id,
type: model.flavour, type: model.flavour,
layout: layoutData ? { ...baseLayout, ...layoutData } : baseLayout, layout,
children, children,
}; };
}; };
@@ -14,6 +14,7 @@ import {
Subject, Subject,
take, take,
tap, tap,
timer,
} from 'rxjs'; } from 'rxjs';
import { debounceTime } from 'rxjs/operators'; import { debounceTime } from 'rxjs/operators';
@@ -34,9 +35,11 @@ import type {
const debug = false; // Toggle for debug logs const debug = false; // Toggle for debug logs
const defaultOptions: RendererOptions = { const defaultOptions = {
zoomThreshold: 1, // With high enough zoom, fallback to DOM rendering zoomThreshold: 1, // With high enough zoom, fallback to DOM rendering
debounceTime: 1000, // During this period, fallback to DOM debounceTime: 1000, // During this period, fallback to DOM
enableBitmapRendering: false, // When enabled, the bitmap rendering will be used
postZoomDelay: 100,
}; };
export const TurboRendererConfigFactory = export const TurboRendererConfigFactory =
@@ -76,12 +79,14 @@ export class ViewportTurboRendererExtension extends GfxExtension {
public readonly state$ = new BehaviorSubject<RenderingState>('inactive'); public readonly state$ = new BehaviorSubject<RenderingState>('inactive');
public readonly canvas: HTMLCanvasElement = document.createElement('canvas'); public readonly canvas: HTMLCanvasElement = document.createElement('canvas');
public layoutCacheData: ViewportLayoutTree | null = null; public layoutCacheData: ViewportLayoutTree | null = null;
public optimizedBlockIds: string[] = [];
private readonly worker: Worker; private readonly worker: Worker;
private readonly disposables = new DisposableGroup(); private readonly disposables = new DisposableGroup();
private layoutVersion = 0; private layoutVersion = 0;
private bitmap: ImageBitmap | null = null; private bitmap: ImageBitmap | null = null;
private viewportElement: GfxViewportElement | null = null; private viewportElement: GfxViewportElement | null = null;
private readonly refresh$ = new Subject<void>(); private readonly refresh$ = new Subject<void>();
private readonly isRecentlyZoomed$ = new BehaviorSubject<boolean>(false);
public get currentState(): RenderingState { public get currentState(): RenderingState {
return this.state$.value; return this.state$.value;
@@ -163,6 +168,14 @@ export class ViewportTurboRendererExtension extends GfxExtension {
if (isZooming) { if (isZooming) {
this.state$.next('zooming'); this.state$.next('zooming');
} else if (this.state$.value === '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.state$.next('pending');
this.refresh().catch(console.error); this.refresh().catch(console.error);
} }
@@ -230,14 +243,12 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.debugLog('Using cached bitmap'); this.debugLog('Using cached bitmap');
nextState = 'ready'; nextState = 'ready';
this.drawCachedBitmap(); this.drawCachedBitmap();
this.updateOptimizedBlocks();
} else { } else {
this.debugLog('Starting bitmap rendering'); this.debugLog('Starting bitmap rendering');
nextState = 'rendering'; nextState = 'rendering';
this.state$.next(nextState); this.state$.next(nextState);
await this.paintLayout(); await this.paintLayout();
this.drawCachedBitmap(); this.drawCachedBitmap();
this.updateOptimizedBlocks();
// After rendering completes, transition to ready state // After rendering completes, transition to ready state
nextState = 'ready'; nextState = 'ready';
} }
@@ -269,7 +280,10 @@ export class ViewportTurboRendererExtension extends GfxExtension {
private async paintLayout(): Promise<void> { private async paintLayout(): Promise<void> {
return new Promise(resolve => { return new Promise(resolve => {
if (!this.worker) return; if (!this.worker || !this.options.enableBitmapRendering) {
resolve();
return;
}
const layout = this.layoutCache; const layout = this.layoutCache;
const dpr = window.devicePixelRatio; const dpr = window.devicePixelRatio;
@@ -318,9 +332,8 @@ export class ViewportTurboRendererExtension extends GfxExtension {
}); });
} }
private canUseBitmapCache(): boolean { public canUseBitmapCache(): boolean {
// Never use bitmap cache during zooming if (!this.options.enableBitmapRendering || this.isZooming()) return false;
if (this.isZooming()) return false;
return !!(this.layoutCache && this.bitmap); return !!(this.layoutCache && this.bitmap);
} }
@@ -336,9 +349,10 @@ export class ViewportTurboRendererExtension extends GfxExtension {
} }
private drawCachedBitmap() { private drawCachedBitmap() {
if (!this.bitmap) { if (!this.options.enableBitmapRendering || !this.bitmap) {
this.debugLog('No cached bitmap available, requesting refresh'); this.debugLog(
this.refresh$.next(); 'Bitmap drawing skipped (disabled or no cached bitmap available)'
);
return; return;
} }
@@ -366,26 +380,37 @@ export class ViewportTurboRendererExtension extends GfxExtension {
} }
private canOptimize(): boolean { private canOptimize(): boolean {
if (this.isRecentlyZoomed$.value) return false;
const isBelowZoomThreshold = const isBelowZoomThreshold =
this.viewport.zoom <= this.options.zoomThreshold; this.viewport.zoom <= this.options.zoomThreshold;
return ( return this.state$.value === 'zooming' && isBelowZoomThreshold;
(this.state$.value === 'ready' || this.state$.value === 'zooming') &&
isBelowZoomThreshold
);
} }
private updateOptimizedBlocks() { private updateOptimizedBlocks() {
if (!this.canOptimize()) return;
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!this.viewportElement || !this.layoutCache) return; if (!this.viewportElement || !this.layoutCache) return;
if (!this.canOptimize()) return;
const blockElements = this.viewportElement.getModelsInViewport(); const blockElements = this.viewportElement.getModelsInViewport();
const blockIds = Array.from(blockElements).map(model => model.id); 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`); this.debugLog(`Optimized ${blockIds.length} blocks`);
}); });
} }
private clearOptimizedBlocks() { private clearOptimizedBlocks() {
if (!this.viewportElement || this.optimizedBlockIds.length === 0) return;
this.viewportElement.setBlocksActive(this.optimizedBlockIds);
this.optimizedBlockIds = [];
this.debugLog('Cleared optimized blocks'); this.debugLog('Cleared optimized blocks');
} }
@@ -82,6 +82,7 @@ export interface BlockLayoutPainter {
export interface RendererOptions { export interface RendererOptions {
zoomThreshold: number; zoomThreshold: number;
debounceTime: number; debounceTime: number;
enableBitmapRendering?: boolean;
} }
export interface TurboRendererConfig { export interface TurboRendererConfig {
@@ -49,22 +49,36 @@ describe('viewport turbo renderer', () => {
expect(renderer.state$.value).toBe('pending'); 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(); const renderer = getRenderer();
addSampleNotes(doc, 1);
await wait();
expect(renderer.optimizedBlockIds.length).toBe(0);
renderer.viewport.zooming$.next(true); renderer.viewport.zooming$.next(true);
await wait(); await wait();
expect(renderer.state$.value).toBe('zooming'); expect(renderer.state$.value).toBe('zooming');
renderer.viewport.zooming$.next(false);
const canUseCache = renderer.canUseBitmapCache();
expect(canUseCache).toBe(false);
await renderer.refresh();
await wait(); 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.state$.value).not.toBe('zooming');
expect(renderer.optimizedBlockIds.length).toBe(0);
}); });
test('state transitions between pending and ready', async () => { test('state transitions between pending and ready', async () => {
const renderer = getRenderer(); const renderer = getRenderer();
// Initial state should be pending after adding content
addSampleNotes(doc, 1); addSampleNotes(doc, 1);
await wait(100); // Short wait for initial processing await wait();
expect(renderer.state$.value).toBe('pending'); expect(renderer.state$.value).toBe('pending');
// Ensure zooming is off and wait for debounce + buffer // Ensure zooming is off and wait for debounce + buffer
@@ -99,11 +113,11 @@ describe('viewport turbo renderer', () => {
const renderer = getRenderer(); const renderer = getRenderer();
addSampleNotes(doc, 1); addSampleNotes(doc, 1);
await wait(); 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); noop(_cache);
expect(renderer.layoutCacheData).not.toBeNull(); // Check internal state after access expect(renderer.layoutCacheData).not.toBeNull();
expect(renderer.layoutCache?.roots.length).toBeGreaterThan(0); // Check public getter result expect(renderer.layoutCache?.roots.length).toBeGreaterThan(0);
}); });
}); });