mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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:
@@ -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';
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user