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
This commit is contained in:
L-Sun
2025-03-31 12:47:01 +00:00
parent fec698fd8b
commit c9e14ac0db
3 changed files with 108 additions and 47 deletions

View File

@@ -1,15 +1,17 @@
import { WithDisposable } from '@blocksuite/global/lit';
import { batch } from '@preact/signals-core';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { PropTypes, requiredProperties } from '../view/decorators/required.js';
import {
type BlockComponent,
type EditorHost,
isGfxBlockComponent,
ShadowlessElement,
} from '../view/index.js';
import type { GfxBlockElementModel } from './model/gfx-block-model.js';
import { Viewport } from './viewport.js';
} from '../view';
import { PropTypes, requiredProperties } from '../view/decorators/required';
import { GfxControllerIdentifier } from './identifiers';
import type { GfxBlockElementModel } from './model/gfx-block-model';
import { Viewport } from './viewport';
/**
* A wrapper around `requestConnectedFrame` that only calls at most once in one frame
@@ -35,24 +37,6 @@ export function requestThrottledConnectedFrame<
}) as T;
}
function setBlockState(view: BlockComponent | null, state: 'active' | 'idle') {
if (!view) return;
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';
}
}
@requiredProperties({
viewport: PropTypes.instanceOf(Viewport),
})
@@ -85,21 +69,27 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
private readonly _hideOutsideBlock = () => {
if (!this.host) return;
const { host } = this;
const gfx = this.host.std.get(GfxControllerIdentifier);
const modelsInViewport = this.getModelsInViewport();
modelsInViewport.forEach(model => {
const view = host.std.view.getBlock(model.id);
setBlockState(view, 'active');
batch(() => {
modelsInViewport.forEach(model => {
const view = gfx.view.get(model);
if (isGfxBlockComponent(view)) {
view.transformState$.value = 'active';
}
if (this._lastVisibleModels?.has(model)) {
this._lastVisibleModels!.delete(model);
}
});
if (this._lastVisibleModels?.has(model)) {
this._lastVisibleModels!.delete(model);
}
});
this._lastVisibleModels?.forEach(model => {
const view = host.std.view.getBlock(model.id);
setBlockState(view, 'idle');
this._lastVisibleModels?.forEach(model => {
const view = gfx.view.get(model);
if (isGfxBlockComponent(view)) {
view.transformState$.value = 'idle';
}
});
});
this._lastVisibleModels = modelsInViewport;
@@ -194,23 +184,29 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
setBlocksActive(blockIds: string[]): void {
if (!this.host) return;
const gfx = this.host.std.get(GfxControllerIdentifier);
blockIds.forEach(id => {
const view = this.host?.std.view.getBlock(id);
if (view) {
setBlockState(view, 'active');
}
batch(() => {
blockIds.forEach(id => {
const view = gfx.view.get(id);
if (isGfxBlockComponent(view)) {
view.transformState$.value = 'active';
}
});
});
}
setBlocksIdle(blockIds: string[]): void {
if (!this.host) return;
const gfx = this.host.std.get(GfxControllerIdentifier);
blockIds.forEach(id => {
const view = this.host?.std.view.getBlock(id);
if (view) {
setBlockState(view, 'idle');
}
batch(() => {
blockIds.forEach(id => {
const view = gfx.view.get(id);
if (isGfxBlockComponent(view)) {
view.transformState$.value = 'idle';
}
});
});
}
}

View File

@@ -1,6 +1,6 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound } from '@blocksuite/global/gfx';
import { computed } from '@preact/signals-core';
import { computed, effect, signal } from '@preact/signals-core';
import { nothing } from 'lit';
import type { BlockService } from '../../extension/index.js';
@@ -23,7 +23,7 @@ export function isGfxBlockComponent(
export const GfxElementSymbol = Symbol('GfxElement');
function updateTransform(element: GfxBlockComponent) {
if (element.dataset.blockState === 'idle') return;
if (element.transformState$.value === 'idle') return;
const { viewport } = element.gfx;
element.dataset.viewportState = viewport.serializeRecord();
@@ -31,6 +31,20 @@ function updateTransform(element: GfxBlockComponent) {
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';
@@ -48,7 +62,12 @@ function handleGfxConnection(instance: GfxBlockComponent) {
})
);
updateTransform(instance);
instance.disposables.add(
effect(() => {
updateBlockVisibility(instance);
updateTransform(instance);
})
);
}
export abstract class GfxBlockComponent<
@@ -61,6 +80,8 @@ export abstract class GfxBlockComponent<
{
[GfxElementSymbol] = true;
readonly transformState$ = signal<'idle' | 'active'>('active');
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
@@ -175,6 +196,8 @@ export function toGfxBlockComponent<
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