From 334912e85b45e7c2c76bd13e83d765ef34dd1d31 Mon Sep 17 00:00:00 2001
From: doodlewind <7312949+doodlewind@users.noreply.github.com>
Date: Sat, 8 Mar 2025 01:38:02 +0000
Subject: [PATCH] perf(editor): lazy DOM update with idle state in gfx viewport
(#10624)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 (uploaded via Graphite)
](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:

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.
---
.../src/viewport-renderer/renderer-utils.ts | 109 +++++++++++-------
.../shared/src/viewport-renderer/types.ts | 1 -
.../viewport-renderer/viewport-renderer.ts | 16 ---
.../block-std/src/gfx/viewport-element.ts | 73 +++++++-----
.../framework/block-std/src/gfx/viewport.ts | 48 +++++++-
.../src/view/element/gfx-block-component.ts | 4 +
.../blocksuite/ai/utils/selection-utils.ts | 3 +-
.../editor/edgeless/connector.tsx | 2 +-
.../editor/edgeless/mind-map.tsx | 7 +-
.../general-setting/editor/edgeless/pen.tsx | 3 +-
.../general-setting/editor/edgeless/shape.tsx | 2 +-
.../general-setting/editor/edgeless/utils.ts | 6 -
12 files changed, 176 insertions(+), 98 deletions(-)
diff --git a/blocksuite/affine/shared/src/viewport-renderer/renderer-utils.ts b/blocksuite/affine/shared/src/viewport-renderer/renderer-utils.ts
index bf068687ad..a6b9f94904 100644
--- a/blocksuite/affine/shared/src/viewport-renderer/renderer-utils.ts
+++ b/blocksuite/affine/shared/src/viewport-renderer/renderer-utils.ts
@@ -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,
]);
diff --git a/blocksuite/affine/shared/src/viewport-renderer/types.ts b/blocksuite/affine/shared/src/viewport-renderer/types.ts
index 05ffa9cf99..964482cddf 100644
--- a/blocksuite/affine/shared/src/viewport-renderer/types.ts
+++ b/blocksuite/affine/shared/src/viewport-renderer/types.ts
@@ -20,7 +20,6 @@ export interface SentenceLayout {
export interface ParagraphLayout {
sentences: SentenceLayout[];
- zoom: number;
}
export interface ViewportLayout {
diff --git a/blocksuite/affine/shared/src/viewport-renderer/viewport-renderer.ts b/blocksuite/affine/shared/src/viewport-renderer/viewport-renderer.ts
index 279e0b0266..ef7c68a292 100644
--- a/blocksuite/affine/shared/src/viewport-renderer/viewport-renderer.ts
+++ b/blocksuite/affine/shared/src/viewport-renderer/viewport-renderer.ts
@@ -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);
diff --git a/blocksuite/framework/block-std/src/gfx/viewport-element.ts b/blocksuite/framework/block-std/src/gfx/viewport-element.ts
index 7866137edf..ff6bd6d097 100644
--- a/blocksuite/framework/block-std/src/gfx/viewport-element.ts
+++ b/blocksuite/framework/block-std/src/gfx/viewport-element.ts
@@ -35,10 +35,21 @@ export function requestThrottledConnectedFrame<
}) as T;
}
-function setDisplay(view: BlockComponent | null, display: 'block' | 'none') {
+function setBlockState(view: BlockComponent | null, state: 'active' | 'idle') {
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;
transform: none;
}
- `;
- optimizedBlocks = new Set();
+ /* 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 = () => {
if (!this.host) return;
- const { host, optimizedBlocks, enableOptimization } = this;
+ const { host } = this;
const modelsInViewport = this.getModelsInViewport();
+
modelsInViewport.forEach(model => {
const view = host.std.view.getBlock(model.id);
- const canOptimize = optimizedBlocks.has(model.id) && enableOptimization;
- const display = canOptimize ? 'none' : 'block';
- setDisplay(view, display);
+ setBlockState(view, 'active');
if (this._lastVisibleModels?.has(model)) {
this._lastVisibleModels!.delete(model);
@@ -77,7 +99,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
this._lastVisibleModels?.forEach(model => {
const view = host.std.view.getBlock(model.id);
- setDisplay(view, 'none');
+ setBlockState(view, 'idle');
});
this._lastVisibleModels = modelsInViewport;
@@ -170,28 +192,25 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor viewport!: Viewport;
- @property({ attribute: false })
- accessor enableOptimization: boolean = false;
-
- updateOptimizedBlocks(blockIds: string[], optimized: boolean): void {
- let changed = false;
+ setBlocksActive(blockIds: string[]): void {
+ if (!this.host) return;
blockIds.forEach(id => {
- if (optimized && !this.optimizedBlocks.has(id)) {
- this.optimizedBlocks.add(id);
- changed = true;
- } else if (!optimized && this.optimizedBlocks.has(id)) {
- this.optimizedBlocks.delete(id);
- changed = true;
+ const view = this.host?.std.view.getBlock(id);
+ if (view) {
+ setBlockState(view, 'active');
}
});
-
- if (changed) this._refreshViewport();
}
- clearOptimizedBlocks(): void {
- if (this.optimizedBlocks.size === 0) return;
- this.optimizedBlocks.clear();
- this._refreshViewport();
+ setBlocksIdle(blockIds: string[]): void {
+ if (!this.host) return;
+
+ blockIds.forEach(id => {
+ const view = this.host?.std.view.getBlock(id);
+ if (view) {
+ setBlockState(view, 'idle');
+ }
+ });
}
}
diff --git a/blocksuite/framework/block-std/src/gfx/viewport.ts b/blocksuite/framework/block-std/src/gfx/viewport.ts
index 9d35019219..b0587e28a4 100644
--- a/blocksuite/framework/block-std/src/gfx/viewport.ts
+++ b/blocksuite/framework/block-std/src/gfx/viewport.ts
@@ -24,6 +24,29 @@ export const ZOOM_INITIAL = 1.0;
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 {
private _cachedBoundingClientRect: DOMRect | null = null;
@@ -461,8 +484,7 @@ export class Viewport {
}
toModelCoordFromClientCoord([x, y]: IVec): IVec {
- const { left, top } = this;
- return this.toModelCoord(x - left, y - top);
+ return clientToModelCoord(this, [x, y]);
}
toViewBound(bound: Bound) {
@@ -484,4 +506,26 @@ export class Viewport {
const { left, top } = this;
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;
+ }
+ }
}
diff --git a/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts b/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts
index 3bf504015c..bebbbfc2ab 100644
--- a/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts
+++ b/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts
@@ -18,6 +18,10 @@ export function isGfxBlockComponent(
export const GfxElementSymbol = Symbol('GfxElement');
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.transform = element.getCSSTransform();
}
diff --git a/packages/frontend/core/src/blocksuite/ai/utils/selection-utils.ts b/packages/frontend/core/src/blocksuite/ai/utils/selection-utils.ts
index d628ed2ae0..d373d01a3e 100644
--- a/packages/frontend/core/src/blocksuite/ai/utils/selection-utils.ts
+++ b/packages/frontend/core/src/blocksuite/ai/utils/selection-utils.ts
@@ -10,6 +10,7 @@ import {
getImageSelectionsCommand,
getSelectedBlocksCommand,
getSelectedModelsCommand,
+ getSurfaceBlock,
getTextSelectionCommand,
ImageBlockModel,
isCanvasElement,
@@ -197,7 +198,7 @@ export const stopPropagation = (e: Event) => {
export function getSurfaceElementFromEditor(editor: EditorHost) {
const { doc } = editor;
- const surfaceModel = doc.getBlockByFlavour('affine:surface')[0];
+ const surfaceModel = getSurfaceBlock(doc);
if (!surfaceModel) return null;
const surfaceId = surfaceModel.id;
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/connector.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/connector.tsx
index 542edb67a2..e093abb64f 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/connector.tsx
+++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/connector.tsx
@@ -15,6 +15,7 @@ import {
FontFamilyMap,
FontStyle,
FontWeightMap,
+ getSurfaceBlock,
PointStyle,
StrokeStyle,
TextAlign,
@@ -29,7 +30,6 @@ import { menuTrigger, settingWrapper } from '../style.css';
import { sortedFontWeightEntries, usePalettes } from '../utils';
import { Point } from './point';
import { EdgelessSnapshot } from './snapshot';
-import { getSurfaceBlock } from './utils';
enum ConnecterStyle {
General = 'general',
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/mind-map.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/mind-map.tsx
index 5480a9c62e..8d24604001 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/mind-map.tsx
+++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/mind-map.tsx
@@ -7,7 +7,11 @@ import {
import { SettingRow } from '@affine/component/setting-components';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
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 { useFramework, useLiveData } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
@@ -15,7 +19,6 @@ import { useCallback, useMemo } from 'react';
import { DropdownMenu } from '../menu';
import { menuTrigger, settingWrapper } from '../style.css';
import { EdgelessSnapshot } from './snapshot';
-import { getSurfaceBlock } from './utils';
const MINDMAP_STYLES = [
{
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/pen.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/pen.tsx
index 0bd5a399a0..287db0d4b6 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/pen.tsx
+++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/pen.tsx
@@ -2,7 +2,7 @@ import { MenuItem, MenuTrigger, Slider } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
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 { useFramework, useLiveData } from '@toeverything/infra';
import { isEqual } from 'lodash-es';
@@ -13,7 +13,6 @@ import { menuTrigger } from '../style.css';
import { usePalettes } from '../utils';
import { Point } from './point';
import { EdgelessSnapshot } from './snapshot';
-import { getSurfaceBlock } from './utils';
export const PenSettings = () => {
const t = useI18n();
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/shape.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/shape.tsx
index c6617ad526..40d7daed81 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/shape.tsx
+++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/shape.tsx
@@ -18,6 +18,7 @@ import {
FontStyle,
FontWeightMap,
getShapeName,
+ getSurfaceBlock,
ShapeStyle,
ShapeType,
StrokeStyle,
@@ -39,7 +40,6 @@ import { sortedFontWeightEntries, usePalettes } from '../utils';
import type { DocName } from './docs';
import { Point } from './point';
import { EdgelessSnapshot } from './snapshot';
-import { getSurfaceBlock } from './utils';
enum ShapeTextFontSize {
'16px' = '16',
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/utils.ts b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/utils.ts
index 821e12823e..7ae2efca38 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/utils.ts
+++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/edgeless/utils.ts
@@ -1,12 +1,6 @@
-import type { SurfaceBlockModel } from '@blocksuite/affine/block-std/gfx';
import type { FrameBlockModel } from '@blocksuite/affine/blocks';
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) {
const blocks = doc.getBlocksByFlavour('affine:frame');
return blocks.length !== 0 ? (blocks[0].model as FrameBlockModel) : null;