mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-22 03:33:06 +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,
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user