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:  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.
245 lines
6.7 KiB
TypeScript
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;
|
|
};
|
|
}
|