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: ![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. --- .../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;