Files
AFFiNE-Mirror/blocksuite/framework/std/src/view/element/gfx-block-component.ts
L-Sun c9e14ac0db fix(editor): sync gfx block transform update with RAF to prevent stale transform (#11322)
Close [BS-2866](https://linear.app/affine-design/issue/BS-2866/presentation-mode中的note消失)

## Problem
When using RequestAnimationFrame (RAF) for GFX block updates, there was a timing issue where the transform update would lag behind the RAF callback, causing the block to display with the previous frame's transform state.

## Solution
1. Refactored the block state management to use signals for better reactivity
2. Moved block visibility state management from `viewport-element.ts` to `gfx-block-component.ts`
3. Added `transformState$` signal to track block state
4. Synchronized transform updates with RAF using `effect` to ensure updates happen in the correct frame
5. Added test case to verify note visibility in presentation mode
2025-03-31 12:47:01 +00:00

325 lines
8.6 KiB
TypeScript

import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound } from '@blocksuite/global/gfx';
import { computed, effect, signal } from '@preact/signals-core';
import { nothing } from 'lit';
import type { BlockService } from '../../extension/index.js';
import type {
DragMoveContext,
GfxViewTransformInterface,
SelectedContext,
} from '../../gfx/element-transform/view-transform.js';
import { GfxControllerIdentifier } from '../../gfx/identifiers.js';
import { type GfxBlockElementModel } from '../../gfx/model/gfx-block-model.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.transformState$.value === 'idle') return;
const { viewport } = element.gfx;
element.dataset.viewportState = viewport.serializeRecord();
element.style.transformOrigin = '0 0';
element.style.transform = element.getCSSTransform();
}
function updateBlockVisibility(view: GfxBlockComponent) {
if (view.transformState$.value === 'active') {
view.style.visibility = 'visible';
view.style.pointerEvents = 'auto';
view.classList.remove('block-idle');
view.classList.add('block-active');
} else {
view.style.visibility = 'hidden';
view.style.pointerEvents = 'none';
view.classList.remove('block-active');
view.classList.add('block-idle');
}
}
function handleGfxConnection(instance: GfxBlockComponent) {
instance.style.position = 'absolute';
instance.disposables.add(
instance.gfx.viewport.viewportUpdated.subscribe(() => {
updateTransform(instance);
})
);
instance.disposables.add(
instance.doc.slots.blockUpdated.subscribe(({ type, id }) => {
if (id === instance.model.id && type === 'update') {
updateTransform(instance);
}
})
);
instance.disposables.add(
effect(() => {
updateBlockVisibility(instance);
updateTransform(instance);
})
);
}
export abstract class GfxBlockComponent<
Model extends GfxBlockElementModel = GfxBlockElementModel,
Service extends BlockService = BlockService,
WidgetName extends string = string,
>
extends BlockComponent<Model, Service, WidgetName>
implements GfxViewTransformInterface
{
[GfxElementSymbol] = true;
readonly transformState$ = signal<'idle' | 'active'>('active');
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
override connectedCallback(): void {
super.connectedCallback();
handleGfxConnection(this);
}
onDragMove = ({ dx, dy, currentBound }: DragMoveContext) => {
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
};
onDragStart() {
this.model.stash('xywh');
}
onDragEnd() {
this.model.pop('xywh');
}
onSelected(context: SelectedContext) {
if (context.multiSelect) {
this.gfx.selection.toggle(this.model);
} else {
this.gfx.selection.set({ elements: [this.model.id] });
}
}
onRotate() {}
onResize() {}
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;
readonly transformState$ = signal<'idle' | 'active'>('active');
override selected$ = computed(() => {
const selection = this.std.selection.value.find(
selection => selection.blockId === this.model?.id
);
if (!selection) return false;
return selection.is(SurfaceSelection);
});
onDragMove({ dx, dy, currentBound }: DragMoveContext) {
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
}
onDragStart() {
this.model.stash('xywh');
}
onDragEnd() {
this.model.pop('xywh');
}
// eslint-disable-next-line sonarjs/no-identical-functions
onSelected(context: SelectedContext) {
if (context.multiSelect) {
this.gfx.selection.toggle(this.model);
} else {
this.gfx.selection.set({ elements: [this.model.id] });
}
}
onRotate() {}
onResize() {}
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;
};
}