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

View File

@@ -25,7 +25,9 @@ export class CodeLayoutHandlerExtension extends BlockLayoutHandlerExtension<Code
host: EditorHost,
viewportRecord: ViewportRecord
): 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;
const codeBlockElement = component.querySelector(

View File

@@ -25,7 +25,9 @@ export class ImageLayoutHandlerExtension extends BlockLayoutHandlerExtension<Ima
host: EditorHost,
viewportRecord: ViewportRecord
): 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;
const imageContainer = component.querySelector('.affine-image-container');

View File

@@ -27,7 +27,9 @@ export class ListLayoutHandlerExtension extends BlockLayoutHandlerExtension<List
host: EditorHost,
viewportRecord: ViewportRecord
): 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;
// Find the list items within this specific list component

View File

@@ -30,7 +30,9 @@ export class NoteLayoutHandlerExtension extends BlockLayoutHandlerExtension<Note
host: EditorHost,
viewportRecord: ViewportRecord
): 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;
// Get the note container element

View File

@@ -27,7 +27,10 @@ export class ParagraphLayoutHandlerExtension extends BlockLayoutHandlerExtension
host: EditorHost,
viewportRecord: ViewportRecord
): 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 =
'.affine-paragraph-rich-text-wrapper [data-v-text="true"]';
const paragraphNode = component.querySelector(paragraphSelector);

View File

@@ -58,7 +58,9 @@ export function getViewportLayoutTree(
const handler = providersArray.find(p => 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,
};
};

View File

@@ -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<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;
@@ -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<void> {
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');
}

View File

@@ -82,6 +82,7 @@ export interface BlockLayoutPainter {
export interface RendererOptions {
zoomThreshold: number;
debounceTime: number;
enableBitmapRendering?: boolean;
}
export interface TurboRendererConfig {

View File

@@ -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);
});
});