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

View File

@@ -1,6 +1,6 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound } from '@blocksuite/global/gfx'; 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 { nothing } from 'lit';
import type { BlockService } from '../../extension/index.js'; import type { BlockService } from '../../extension/index.js';
@@ -23,7 +23,7 @@ export function isGfxBlockComponent(
export const GfxElementSymbol = Symbol('GfxElement'); export const GfxElementSymbol = Symbol('GfxElement');
function updateTransform(element: GfxBlockComponent) { function updateTransform(element: GfxBlockComponent) {
if (element.dataset.blockState === 'idle') return; if (element.transformState$.value === 'idle') return;
const { viewport } = element.gfx; const { viewport } = element.gfx;
element.dataset.viewportState = viewport.serializeRecord(); element.dataset.viewportState = viewport.serializeRecord();
@@ -31,6 +31,20 @@ function updateTransform(element: GfxBlockComponent) {
element.style.transform = element.getCSSTransform(); 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) { function handleGfxConnection(instance: GfxBlockComponent) {
instance.style.position = 'absolute'; 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< export abstract class GfxBlockComponent<
@@ -61,6 +80,8 @@ export abstract class GfxBlockComponent<
{ {
[GfxElementSymbol] = true; [GfxElementSymbol] = true;
readonly transformState$ = signal<'idle' | 'active'>('active');
get gfx() { get gfx() {
return this.std.get(GfxControllerIdentifier); return this.std.get(GfxControllerIdentifier);
} }
@@ -175,6 +196,8 @@ export function toGfxBlockComponent<
return class extends CustomBlock { return class extends CustomBlock {
[GfxElementSymbol] = true; [GfxElementSymbol] = true;
readonly transformState$ = signal<'idle' | 'active'>('active');
override selected$ = computed(() => { override selected$ = computed(() => {
const selection = this.std.selection.value.find( const selection = this.std.selection.value.find(
selection => selection.blockId === this.model?.id selection => selection.blockId === this.model?.id

View File

@@ -8,8 +8,10 @@ import {
dragBetweenViewCoords, dragBetweenViewCoords,
edgelessCommonSetup, edgelessCommonSetup,
enterPresentationMode, enterPresentationMode,
getSelectedBound,
locatorPresentationToolbarButton, locatorPresentationToolbarButton,
resizeElementByHandle, resizeElementByHandle,
selectElementInEdgeless,
selectNoteInEdgeless, selectNoteInEdgeless,
setEdgelessTool, setEdgelessTool,
Shape, Shape,
@@ -271,4 +273,44 @@ test.describe('presentation', () => {
const collapseButton = page.getByTestId('edgeless-note-collapse-button'); const collapseButton = page.getByTestId('edgeless-note-collapse-button');
await expect(collapseButton).not.toBeVisible(); await expect(collapseButton).not.toBeVisible();
}); });
test('note should be visible when enter presentation mode', async ({
page,
}) => {
await enterPlaygroundRoom(page);
const { noteId } = await initEmptyEdgelessState(page);
await switchEditorMode(page);
await selectNoteInEdgeless(page, noteId);
const noteBound = await getSelectedBound(page);
await pressEscape(page, 3);
const frame1 = await createFrame(
page,
[noteBound[0] - 10, noteBound[1] - 10],
[noteBound[0] + noteBound[2] + 10, noteBound[1] + noteBound[3] + 10]
);
await selectElementInEdgeless(page, [frame1]);
const frame1Bound = await getSelectedBound(page);
await pressEscape(page);
await createFrame(
page,
[frame1Bound[0] + frame1Bound[2] + 10, frame1Bound[1]],
[frame1Bound[0] + 2 * frame1Bound[2], frame1Bound[1] + frame1Bound[3]]
);
await enterPresentationMode(page);
const nextButton = locatorPresentationToolbarButton(page, 'next');
const prevButton = locatorPresentationToolbarButton(page, 'previous');
const note = page.locator('affine-edgeless-note');
await expect(note).toBeVisible();
await nextButton.click();
await expect(note).toBeHidden();
await prevButton.click();
await expect(note).toBeVisible();
});
}); });