perf(editor): lazy DOM update with idle state in gfx viewport (#10624)

Currently, `GfxViewportElement` hides DOM blocks outside the viewport using `display: none` to optimize performance. However, this approach presents two issues:

1. Even when hidden, all top-level blocks still undergo frequent CSS transform updates during viewport panning and zooming.
2. Hidden blocks cannot access DOM layout information, preventing `TurboRenderer` from updating the complete canvas bitmap.

To address this, this PR introduces a refactoring that divides all top-level edgeless blocks into two states: `idle` and `active`. The improvements are as follows:

1. Blocks outside the viewport are set to the `idle` state, meaning they no longer update their DOM during viewport panning or zooming. Only `active` blocks within the viewport are updated frame by frame.
2. For `idle` blocks, the hiding method switches from `display: none` to `visibility: hidden`, ensuring their layout information remains accessible to `TurboRenderer`.

[Screen Recording 2025-03-07 at 3.23.56 PM.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/lEGcysB4lFTEbCwZ8jMv/4bac640b-f5b6-4b0b-904d-5899f96cf375.mov" />](https://app.graphite.dev/media/video/lEGcysB4lFTEbCwZ8jMv/4bac640b-f5b6-4b0b-904d-5899f96cf375.mov)

While this minimizes DOM updates, it introduces a trade-off: `idle` blocks retain an outdated layout state. Since their positions are updated using a lazy update strategy, their layout state remains frozen at the moment they were last moved out of the viewport:

![idle-issue.jpg](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/lEGcysB4lFTEbCwZ8jMv/9c8c2150-69d4-416b-b46e-8473a7fdf339.jpg)

To resolve this, the PR serializes and stores the viewport field of the block at that moment on the `idle` block itself. This allows the correct layout, positioned in the model coordinate system, to be restored from the stored data.
This commit is contained in:
doodlewind
2025-03-08 01:38:02 +00:00
parent dc047aa1a4
commit 334912e85b
12 changed files with 176 additions and 98 deletions

View File

@@ -1,5 +1,10 @@
import type { EditorHost } from '@blocksuite/block-std';
import { type Viewport } from '@blocksuite/block-std/gfx';
import type { EditorHost, GfxBlockComponent } from '@blocksuite/block-std';
import {
clientToModelCoord,
GfxBlockElementModel,
GfxControllerIdentifier,
type Viewport,
} from '@blocksuite/block-std/gfx';
import { Pane } from 'tweakpane';
import { getSentenceRects, segmentSentences } from './text-utils.js';
@@ -23,14 +28,64 @@ export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) {
canvas.style.pointerEvents = 'none';
}
function getParagraphs(host: EditorHost) {
const gfx = host.std.get(GfxControllerIdentifier);
const models = gfx.gfxElements.filter(e => e instanceof GfxBlockElementModel);
const components = models
.map(model => gfx.view.get(model.id))
.filter(Boolean) as GfxBlockComponent[];
const paragraphs: ParagraphLayout[] = [];
const selector = '.affine-paragraph-rich-text-wrapper [data-v-text="true"]';
components.forEach(component => {
const paragraphNodes = component.querySelectorAll(selector);
const viewportRecord = component.gfx.viewport.deserializeRecord(
component.dataset.viewportState
);
if (!viewportRecord) return;
const { zoom, viewScale } = viewportRecord;
paragraphNodes.forEach(paragraphNode => {
const paragraph: ParagraphLayout = {
sentences: [],
};
const sentences = segmentSentences(paragraphNode.textContent || '');
paragraph.sentences = sentences.map(sentence => {
const sentenceRects = getSentenceRects(paragraphNode, sentence);
const rects = sentenceRects.map(({ rect }) => {
const [modelX, modelY] = clientToModelCoord(viewportRecord, [
rect.x,
rect.y,
]);
return {
text: sentence,
...rect,
rect: {
x: modelX,
y: modelY,
w: rect.w / zoom / viewScale,
h: rect.h / zoom / viewScale,
},
};
});
return {
text: sentence,
rects,
};
});
paragraphs.push(paragraph);
});
});
return paragraphs;
}
export function getViewportLayout(
host: EditorHost,
viewport: Viewport
): ViewportLayout {
const paragraphBlocks = host.querySelectorAll(
'.affine-paragraph-rich-text-wrapper [data-v-text="true"]'
);
const zoom = viewport.zoom;
let layoutMinX = Infinity;
@@ -38,43 +93,19 @@ export function getViewportLayout(
let layoutMaxX = -Infinity;
let layoutMaxY = -Infinity;
const paragraphs: ParagraphLayout[] = Array.from(paragraphBlocks).map(p => {
const sentences = segmentSentences(p.textContent || '');
const sentenceLayouts = sentences.map(sentence => {
const rects = getSentenceRects(p, sentence);
rects.forEach(({ 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 paragraphs = getParagraphs(host);
paragraphs.forEach(paragraph => {
paragraph.sentences.forEach(sentence => {
sentence.rects.forEach(r => {
layoutMinX = Math.min(layoutMinX, r.rect.x);
layoutMinY = Math.min(layoutMinY, r.rect.y);
layoutMaxX = Math.max(layoutMaxX, r.rect.x + r.rect.w);
layoutMaxY = Math.max(layoutMaxY, r.rect.y + r.rect.h);
});
return {
text: sentence,
rects: rects.map(rect => {
const [x, y] = viewport.toModelCoordFromClientCoord([
rect.rect.x,
rect.rect.y,
]);
return {
...rect,
rect: {
x,
y,
w: rect.rect.w / zoom / viewport.viewScale,
h: rect.rect.h / zoom / viewport.viewScale,
},
};
}),
};
});
return {
sentences: sentenceLayouts,
zoom,
};
});
const layoutModelCoord = viewport.toModelCoordFromClientCoord([
const layoutModelCoord = clientToModelCoord(viewport, [
layoutMinX,
layoutMinY,
]);

View File

@@ -20,7 +20,6 @@ export interface SentenceLayout {
export interface ParagraphLayout {
sentences: SentenceLayout[];
zoom: number;
}
export interface ViewportLayout {

View File

@@ -118,7 +118,6 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
if (this.viewport.zoom > zoomThreshold) {
this.debugLog('Zoom above threshold, falling back to DOM rendering');
this.setState('pending');
this.toggleOptimization(false);
this.clearOptimizedBlocks();
}
// -> zooming
@@ -138,7 +137,6 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
// -> rendering
else {
this.setState('rendering');
this.toggleOptimization(false);
await this.paintLayout();
this.drawCachedBitmap();
this.updateOptimizedBlocks();
@@ -280,30 +278,16 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
if (!this.viewportElement || !this.layoutCache) return;
if (!this.canOptimize()) return;
this.toggleOptimization(true);
const blockElements = this.viewportElement.getModelsInViewport();
const blockIds = Array.from(blockElements).map(model => model.id);
this.viewportElement.updateOptimizedBlocks(blockIds, true);
this.debugLog(`Optimized ${blockIds.length} blocks`);
});
}
private clearOptimizedBlocks() {
if (!this.viewportElement) return;
this.viewportElement.clearOptimizedBlocks();
this.debugLog('Cleared optimized blocks');
}
private toggleOptimization(value: boolean) {
if (
this.viewportElement &&
this.viewportElement.enableOptimization !== value
) {
this.viewportElement.enableOptimization = value;
this.debugLog(`${value ? 'Enabled' : 'Disabled'} optimization`);
}
}
private handleResize() {
this.debugLog('Container resized, syncing canvas size');
syncCanvasSize(this.canvas, this.std.host);