mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-27 02:42:25 +08:00
fix(editor): turbo renderer placeholder for built in template (#11793)
Fixed compat error for new built-in template with test updated.  <!-- 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:
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export interface BlockLayoutPainter {
|
||||
export interface RendererOptions {
|
||||
zoomThreshold: number;
|
||||
debounceTime: number;
|
||||
enableBitmapRendering?: boolean;
|
||||
}
|
||||
|
||||
export interface TurboRendererConfig {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user