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 { EditorHost, GfxBlockComponent } from '@blocksuite/block-std';
import { type Viewport } from '@blocksuite/block-std/gfx'; import {
clientToModelCoord,
GfxBlockElementModel,
GfxControllerIdentifier,
type Viewport,
} from '@blocksuite/block-std/gfx';
import { Pane } from 'tweakpane'; import { Pane } from 'tweakpane';
import { getSentenceRects, segmentSentences } from './text-utils.js'; import { getSentenceRects, segmentSentences } from './text-utils.js';
@@ -23,14 +28,64 @@ export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) {
canvas.style.pointerEvents = 'none'; 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( export function getViewportLayout(
host: EditorHost, host: EditorHost,
viewport: Viewport viewport: Viewport
): ViewportLayout { ): ViewportLayout {
const paragraphBlocks = host.querySelectorAll(
'.affine-paragraph-rich-text-wrapper [data-v-text="true"]'
);
const zoom = viewport.zoom; const zoom = viewport.zoom;
let layoutMinX = Infinity; let layoutMinX = Infinity;
@@ -38,43 +93,19 @@ export function getViewportLayout(
let layoutMaxX = -Infinity; let layoutMaxX = -Infinity;
let layoutMaxY = -Infinity; let layoutMaxY = -Infinity;
const paragraphs: ParagraphLayout[] = Array.from(paragraphBlocks).map(p => { const paragraphs = getParagraphs(host);
const sentences = segmentSentences(p.textContent || ''); paragraphs.forEach(paragraph => {
const sentenceLayouts = sentences.map(sentence => { paragraph.sentences.forEach(sentence => {
const rects = getSentenceRects(p, sentence); sentence.rects.forEach(r => {
rects.forEach(({ rect }) => { layoutMinX = Math.min(layoutMinX, r.rect.x);
layoutMinX = Math.min(layoutMinX, rect.x); layoutMinY = Math.min(layoutMinY, r.rect.y);
layoutMinY = Math.min(layoutMinY, rect.y); layoutMaxX = Math.max(layoutMaxX, r.rect.x + r.rect.w);
layoutMaxX = Math.max(layoutMaxX, rect.x + rect.w); layoutMaxY = Math.max(layoutMaxY, r.rect.y + r.rect.h);
layoutMaxY = Math.max(layoutMaxY, rect.y + 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, layoutMinX,
layoutMinY, layoutMinY,
]); ]);

View File

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

View File

@@ -118,7 +118,6 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
if (this.viewport.zoom > zoomThreshold) { if (this.viewport.zoom > zoomThreshold) {
this.debugLog('Zoom above threshold, falling back to DOM rendering'); this.debugLog('Zoom above threshold, falling back to DOM rendering');
this.setState('pending'); this.setState('pending');
this.toggleOptimization(false);
this.clearOptimizedBlocks(); this.clearOptimizedBlocks();
} }
// -> zooming // -> zooming
@@ -138,7 +137,6 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
// -> rendering // -> rendering
else { else {
this.setState('rendering'); this.setState('rendering');
this.toggleOptimization(false);
await this.paintLayout(); await this.paintLayout();
this.drawCachedBitmap(); this.drawCachedBitmap();
this.updateOptimizedBlocks(); this.updateOptimizedBlocks();
@@ -280,30 +278,16 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
if (!this.viewportElement || !this.layoutCache) return; if (!this.viewportElement || !this.layoutCache) return;
if (!this.canOptimize()) return; if (!this.canOptimize()) return;
this.toggleOptimization(true);
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);
this.viewportElement.updateOptimizedBlocks(blockIds, true);
this.debugLog(`Optimized ${blockIds.length} blocks`); this.debugLog(`Optimized ${blockIds.length} blocks`);
}); });
} }
private clearOptimizedBlocks() { private clearOptimizedBlocks() {
if (!this.viewportElement) return;
this.viewportElement.clearOptimizedBlocks();
this.debugLog('Cleared optimized blocks'); 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() { private handleResize() {
this.debugLog('Container resized, syncing canvas size'); this.debugLog('Container resized, syncing canvas size');
syncCanvasSize(this.canvas, this.std.host); syncCanvasSize(this.canvas, this.std.host);

View File

@@ -35,10 +35,21 @@ export function requestThrottledConnectedFrame<
}) as T; }) as T;
} }
function setDisplay(view: BlockComponent | null, display: 'block' | 'none') { function setBlockState(view: BlockComponent | null, state: 'active' | 'idle') {
if (!view) return; if (!view) return;
if (view.style.display !== display) {
view.style.display = display; if (state === 'active') {
view.style.visibility = 'visible';
view.style.pointerEvents = 'auto';
view.classList.remove('block-idle');
view.classList.add('block-active');
view.dataset.blockState = 'active';
} else {
view.style.visibility = 'hidden';
view.style.pointerEvents = 'none';
view.classList.remove('block-active');
view.classList.add('block-idle');
view.dataset.blockState = 'idle';
} }
} }
@@ -55,20 +66,31 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
display: block; display: block;
transform: none; transform: none;
} }
`;
optimizedBlocks = new Set<string>(); /* CSS for idle blocks that are hidden but maintain layout */
.block-idle {
visibility: hidden;
pointer-events: none;
will-change: transform;
contain: size layout style;
}
/* CSS for active blocks participating in viewport transformations */
.block-active {
visibility: visible;
pointer-events: auto;
}
`;
private readonly _hideOutsideBlock = () => { private readonly _hideOutsideBlock = () => {
if (!this.host) return; if (!this.host) return;
const { host, optimizedBlocks, enableOptimization } = this; const { host } = this;
const modelsInViewport = this.getModelsInViewport(); const modelsInViewport = this.getModelsInViewport();
modelsInViewport.forEach(model => { modelsInViewport.forEach(model => {
const view = host.std.view.getBlock(model.id); const view = host.std.view.getBlock(model.id);
const canOptimize = optimizedBlocks.has(model.id) && enableOptimization; setBlockState(view, 'active');
const display = canOptimize ? 'none' : 'block';
setDisplay(view, display);
if (this._lastVisibleModels?.has(model)) { if (this._lastVisibleModels?.has(model)) {
this._lastVisibleModels!.delete(model); this._lastVisibleModels!.delete(model);
@@ -77,7 +99,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
this._lastVisibleModels?.forEach(model => { this._lastVisibleModels?.forEach(model => {
const view = host.std.view.getBlock(model.id); const view = host.std.view.getBlock(model.id);
setDisplay(view, 'none'); setBlockState(view, 'idle');
}); });
this._lastVisibleModels = modelsInViewport; this._lastVisibleModels = modelsInViewport;
@@ -170,28 +192,25 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
@property({ attribute: false }) @property({ attribute: false })
accessor viewport!: Viewport; accessor viewport!: Viewport;
@property({ attribute: false }) setBlocksActive(blockIds: string[]): void {
accessor enableOptimization: boolean = false; if (!this.host) return;
updateOptimizedBlocks(blockIds: string[], optimized: boolean): void {
let changed = false;
blockIds.forEach(id => { blockIds.forEach(id => {
if (optimized && !this.optimizedBlocks.has(id)) { const view = this.host?.std.view.getBlock(id);
this.optimizedBlocks.add(id); if (view) {
changed = true; setBlockState(view, 'active');
} else if (!optimized && this.optimizedBlocks.has(id)) {
this.optimizedBlocks.delete(id);
changed = true;
} }
}); });
if (changed) this._refreshViewport();
} }
clearOptimizedBlocks(): void { setBlocksIdle(blockIds: string[]): void {
if (this.optimizedBlocks.size === 0) return; if (!this.host) return;
this.optimizedBlocks.clear();
this._refreshViewport(); blockIds.forEach(id => {
const view = this.host?.std.view.getBlock(id);
if (view) {
setBlockState(view, 'idle');
}
});
} }
} }

View File

@@ -24,6 +24,29 @@ export const ZOOM_INITIAL = 1.0;
export const FIT_TO_SCREEN_PADDING = 100; export const FIT_TO_SCREEN_PADDING = 100;
export interface ViewportRecord {
left: number;
top: number;
viewportX: number;
viewportY: number;
zoom: number;
viewScale: number;
}
export function clientToModelCoord(
viewport: ViewportRecord,
clientCoord: [number, number]
): IVec {
const { left, top, viewportX, viewportY, zoom, viewScale } = viewport;
const [clientX, clientY] = clientCoord;
const viewportInternalX = clientX - left;
const viewportInternalY = clientY - top;
const modelX = viewportX + viewportInternalX / zoom / viewScale;
const modelY = viewportY + viewportInternalY / zoom / viewScale;
return [modelX, modelY];
}
export class Viewport { export class Viewport {
private _cachedBoundingClientRect: DOMRect | null = null; private _cachedBoundingClientRect: DOMRect | null = null;
@@ -461,8 +484,7 @@ export class Viewport {
} }
toModelCoordFromClientCoord([x, y]: IVec): IVec { toModelCoordFromClientCoord([x, y]: IVec): IVec {
const { left, top } = this; return clientToModelCoord(this, [x, y]);
return this.toModelCoord(x - left, y - top);
} }
toViewBound(bound: Bound) { toViewBound(bound: Bound) {
@@ -484,4 +506,26 @@ export class Viewport {
const { left, top } = this; const { left, top } = this;
return [x - left, y - top]; return [x - left, y - top];
} }
serializeRecord() {
return JSON.stringify({
left: this.left,
top: this.top,
viewportX: this.viewportX,
viewportY: this.viewportY,
zoom: this.zoom,
viewScale: this.viewScale,
});
}
deserializeRecord(record?: string) {
try {
const result = JSON.parse(record || '{}') as ViewportRecord;
if (!('zoom' in result)) return null;
return result;
} catch (error) {
console.error('Failed to deserialize viewport record:', error);
return null;
}
}
} }

View File

@@ -18,6 +18,10 @@ export function isGfxBlockComponent(
export const GfxElementSymbol = Symbol('GfxElement'); export const GfxElementSymbol = Symbol('GfxElement');
function updateTransform(element: GfxBlockComponent) { function updateTransform(element: GfxBlockComponent) {
if (element.dataset.blockState === 'idle') return;
const { viewport } = element.gfx;
element.dataset.viewportState = viewport.serializeRecord();
element.style.transformOrigin = '0 0'; element.style.transformOrigin = '0 0';
element.style.transform = element.getCSSTransform(); element.style.transform = element.getCSSTransform();
} }

View File

@@ -10,6 +10,7 @@ import {
getImageSelectionsCommand, getImageSelectionsCommand,
getSelectedBlocksCommand, getSelectedBlocksCommand,
getSelectedModelsCommand, getSelectedModelsCommand,
getSurfaceBlock,
getTextSelectionCommand, getTextSelectionCommand,
ImageBlockModel, ImageBlockModel,
isCanvasElement, isCanvasElement,
@@ -197,7 +198,7 @@ export const stopPropagation = (e: Event) => {
export function getSurfaceElementFromEditor(editor: EditorHost) { export function getSurfaceElementFromEditor(editor: EditorHost) {
const { doc } = editor; const { doc } = editor;
const surfaceModel = doc.getBlockByFlavour('affine:surface')[0]; const surfaceModel = getSurfaceBlock(doc);
if (!surfaceModel) return null; if (!surfaceModel) return null;
const surfaceId = surfaceModel.id; const surfaceId = surfaceModel.id;

View File

@@ -15,6 +15,7 @@ import {
FontFamilyMap, FontFamilyMap,
FontStyle, FontStyle,
FontWeightMap, FontWeightMap,
getSurfaceBlock,
PointStyle, PointStyle,
StrokeStyle, StrokeStyle,
TextAlign, TextAlign,
@@ -29,7 +30,6 @@ import { menuTrigger, settingWrapper } from '../style.css';
import { sortedFontWeightEntries, usePalettes } from '../utils'; import { sortedFontWeightEntries, usePalettes } from '../utils';
import { Point } from './point'; import { Point } from './point';
import { EdgelessSnapshot } from './snapshot'; import { EdgelessSnapshot } from './snapshot';
import { getSurfaceBlock } from './utils';
enum ConnecterStyle { enum ConnecterStyle {
General = 'general', General = 'general',

View File

@@ -7,7 +7,11 @@ import {
import { SettingRow } from '@affine/component/setting-components'; import { SettingRow } from '@affine/component/setting-components';
import { EditorSettingService } from '@affine/core/modules/editor-setting'; import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { LayoutType, MindmapStyle } from '@blocksuite/affine/blocks'; import {
getSurfaceBlock,
LayoutType,
MindmapStyle,
} from '@blocksuite/affine/blocks';
import type { Store } from '@blocksuite/affine/store'; import type { Store } from '@blocksuite/affine/store';
import { useFramework, useLiveData } from '@toeverything/infra'; import { useFramework, useLiveData } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
@@ -15,7 +19,6 @@ import { useCallback, useMemo } from 'react';
import { DropdownMenu } from '../menu'; import { DropdownMenu } from '../menu';
import { menuTrigger, settingWrapper } from '../style.css'; import { menuTrigger, settingWrapper } from '../style.css';
import { EdgelessSnapshot } from './snapshot'; import { EdgelessSnapshot } from './snapshot';
import { getSurfaceBlock } from './utils';
const MINDMAP_STYLES = [ const MINDMAP_STYLES = [
{ {

View File

@@ -2,7 +2,7 @@ import { MenuItem, MenuTrigger, Slider } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components'; import { SettingRow } from '@affine/component/setting-components';
import { EditorSettingService } from '@affine/core/modules/editor-setting'; import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { DefaultTheme } from '@blocksuite/affine/blocks'; import { DefaultTheme, getSurfaceBlock } from '@blocksuite/affine/blocks';
import type { Store } from '@blocksuite/affine/store'; import type { Store } from '@blocksuite/affine/store';
import { useFramework, useLiveData } from '@toeverything/infra'; import { useFramework, useLiveData } from '@toeverything/infra';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
@@ -13,7 +13,6 @@ import { menuTrigger } from '../style.css';
import { usePalettes } from '../utils'; import { usePalettes } from '../utils';
import { Point } from './point'; import { Point } from './point';
import { EdgelessSnapshot } from './snapshot'; import { EdgelessSnapshot } from './snapshot';
import { getSurfaceBlock } from './utils';
export const PenSettings = () => { export const PenSettings = () => {
const t = useI18n(); const t = useI18n();

View File

@@ -18,6 +18,7 @@ import {
FontStyle, FontStyle,
FontWeightMap, FontWeightMap,
getShapeName, getShapeName,
getSurfaceBlock,
ShapeStyle, ShapeStyle,
ShapeType, ShapeType,
StrokeStyle, StrokeStyle,
@@ -39,7 +40,6 @@ import { sortedFontWeightEntries, usePalettes } from '../utils';
import type { DocName } from './docs'; import type { DocName } from './docs';
import { Point } from './point'; import { Point } from './point';
import { EdgelessSnapshot } from './snapshot'; import { EdgelessSnapshot } from './snapshot';
import { getSurfaceBlock } from './utils';
enum ShapeTextFontSize { enum ShapeTextFontSize {
'16px' = '16', '16px' = '16',

View File

@@ -1,12 +1,6 @@
import type { SurfaceBlockModel } from '@blocksuite/affine/block-std/gfx';
import type { FrameBlockModel } from '@blocksuite/affine/blocks'; import type { FrameBlockModel } from '@blocksuite/affine/blocks';
import type { Store } from '@blocksuite/affine/store'; import type { Store } from '@blocksuite/affine/store';
export function getSurfaceBlock(doc: Store) {
const blocks = doc.getBlocksByFlavour('affine:surface');
return blocks.length !== 0 ? (blocks[0].model as SurfaceBlockModel) : null;
}
export function getFrameBlock(doc: Store) { export function getFrameBlock(doc: Store) {
const blocks = doc.getBlocksByFlavour('affine:frame'); const blocks = doc.getBlocksByFlavour('affine:frame');
return blocks.length !== 0 ? (blocks[0].model as FrameBlockModel) : null; return blocks.length !== 0 ? (blocks[0].model as FrameBlockModel) : null;