Files
AFFiNE/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts
doodlewind 334912e85b 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.
2025-03-08 01:38:02 +00:00

245 lines
6.7 KiB
TypeScript

import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound } from '@blocksuite/global/gfx';
import { computed } from '@preact/signals-core';
import { nothing } from 'lit';
import type { BlockService } from '../../extension/index.js';
import { GfxControllerIdentifier } from '../../gfx/identifiers.js';
import type { GfxBlockElementModel } from '../../gfx/index.js';
import { SurfaceSelection } from '../../selection/index.js';
import { BlockComponent } from './block-component.js';
export function isGfxBlockComponent(
element: unknown
): element is GfxBlockComponent {
return (element as GfxBlockComponent)?.[GfxElementSymbol] === true;
}
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();
}
function handleGfxConnection(instance: GfxBlockComponent) {
instance.style.position = 'absolute';
instance.disposables.add(
instance.gfx.viewport.viewportUpdated.on(() => {
updateTransform(instance);
})
);
instance.disposables.add(
instance.doc.slots.blockUpdated.on(({ type, id }) => {
if (id === instance.model.id && type === 'update') {
updateTransform(instance);
}
})
);
updateTransform(instance);
}
export abstract class GfxBlockComponent<
Model extends GfxBlockElementModel = GfxBlockElementModel,
Service extends BlockService = BlockService,
WidgetName extends string = string,
> extends BlockComponent<Model, Service, WidgetName> {
[GfxElementSymbol] = true;
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
override connectedCallback(): void {
super.connectedCallback();
handleGfxConnection(this);
}
getCSSTransform() {
const viewport = this.gfx.viewport;
const { translateX, translateY, zoom } = viewport;
const bound = Bound.deserialize(this.model.xywh);
const scaledX = bound.x * zoom;
const scaledY = bound.y * zoom;
const deltaX = scaledX - bound.x;
const deltaY = scaledY - bound.y;
return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`;
}
getRenderingRect() {
const { xywh$ } = this.model;
if (!xywh$) {
throw new BlockSuiteError(
ErrorCode.GfxBlockElementError,
`Error on rendering '${this.model.flavour}': Gfx block's model should have 'xywh' property.`
);
}
const [x, y, w, h] = JSON.parse(xywh$.value);
return { x, y, w, h, zIndex: this.toZIndex() };
}
override renderBlock() {
const { x, y, w, h, zIndex } = this.getRenderingRect();
if (this.style.left !== `${x}px`) this.style.left = `${x}px`;
if (this.style.top !== `${y}px`) this.style.top = `${y}px`;
if (this.style.width !== `${w}px`) this.style.width = `${w}px`;
if (this.style.height !== `${h}px`) this.style.height = `${h}px`;
if (this.style.zIndex !== zIndex) this.style.zIndex = zIndex;
return this.renderGfxBlock();
}
renderGfxBlock(): unknown {
return nothing;
}
renderPageContent(): unknown {
return nothing;
}
override async scheduleUpdate() {
const parent = this.parentElement;
if (this.hasUpdated || !parent || !('scheduleUpdateChildren' in parent)) {
return super.scheduleUpdate();
} else {
await (parent.scheduleUpdateChildren as (id: string) => Promise<void>)(
this.model.id
);
return super.scheduleUpdate();
}
}
toZIndex(): string {
return this.gfx.layer.getZIndex(this.model).toString() ?? '0';
}
updateZIndex(): void {
this.style.zIndex = this.toZIndex();
}
}
export function toGfxBlockComponent<
Model extends GfxBlockElementModel,
Service extends BlockService,
WidgetName extends string,
B extends typeof BlockComponent<Model, Service, WidgetName>,
>(CustomBlock: B) {
// @ts-expect-error ignore
return class extends CustomBlock {
[GfxElementSymbol] = true;
override selected$ = computed(() => {
const selection = this.std.selection.value.find(
selection => selection.blockId === this.model?.id
);
if (!selection) return false;
return selection.is(SurfaceSelection);
});
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
override connectedCallback(): void {
super.connectedCallback();
handleGfxConnection(this);
}
// eslint-disable-next-line sonarjs/no-identical-functions
getCSSTransform() {
const viewport = this.gfx.viewport;
const { translateX, translateY, zoom } = viewport;
const bound = Bound.deserialize(this.model.xywh);
const scaledX = bound.x * zoom;
const scaledY = bound.y * zoom;
const deltaX = scaledX - bound.x;
const deltaY = scaledY - bound.y;
return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`;
}
// eslint-disable-next-line sonarjs/no-identical-functions
getRenderingRect(): {
x: number;
y: number;
w: number | string;
h: number | string;
zIndex: string;
} {
const { xywh$ } = this.model;
if (!xywh$) {
throw new BlockSuiteError(
ErrorCode.GfxBlockElementError,
`Error on rendering '${this.model.flavour}': Gfx block's model should have 'xywh' property.`
);
}
const [x, y, w, h] = JSON.parse(xywh$.value);
return { x, y, w, h, zIndex: this.toZIndex() };
}
override renderBlock() {
const { x, y, w, h, zIndex } = this.getRenderingRect();
this.style.left = `${x}px`;
this.style.top = `${y}px`;
this.style.width = typeof w === 'number' ? `${w}px` : w;
this.style.height = typeof h === 'number' ? `${h}px` : h;
this.style.zIndex = zIndex;
return this.renderGfxBlock();
}
renderGfxBlock(): unknown {
return this.renderPageContent();
}
renderPageContent() {
return super.renderBlock();
}
// eslint-disable-next-line sonarjs/no-identical-functions
override async scheduleUpdate() {
const parent = this.parentElement;
if (this.hasUpdated || !parent || !('scheduleUpdateChildren' in parent)) {
return super.scheduleUpdate();
} else {
await (parent.scheduleUpdateChildren as (id: string) => Promise<void>)(
this.model.id
);
return super.scheduleUpdate();
}
}
toZIndex(): string {
return this.gfx.layer.getZIndex(this.model).toString() ?? '0';
}
updateZIndex(): void {
this.style.zIndex = this.toZIndex();
}
} as B & {
new (...args: any[]): GfxBlockComponent;
};
}