feat(editor): add animation for switching to edgeless mode firstly (#10021)

Close [BS-2327](https://linear.app/affine-design/issue/BS-2327/page-block-%E5%9C%A8-edgeless-%E5%88%87%E6%8D%A2%E7%BC%A9%E6%94%BE%E5%8A%A8%E7%94%BB)

### What Changes:
- Add a zoom animation when switching to edgeless mode firstly
- Move viewport record from `sessionStorage` to `localStorage`

https://github.com/user-attachments/assets/dac11aab-76bd-44b1-8c0e-4a8a10919841
This commit is contained in:
L-Sun
2025-02-10 07:41:10 +00:00
parent 1d1eab8139
commit 9f56a21d8a
10 changed files with 120 additions and 39 deletions

View File

@@ -64,9 +64,6 @@ globalStyle(`${edgelessNoteContainer} > doc-title`, {
globalStyle(`${edgelessNoteContainer} > doc-title .doc-title-container`, { globalStyle(`${edgelessNoteContainer} > doc-title .doc-title-container`, {
padding: '26px 0px', padding: '26px 0px',
fontSize: cssVar('fontTitle'),
fontWeight: 700,
lineHeight: '44px',
}); });
export const pageContent = style({ export const pageContent = style({

View File

@@ -21,6 +21,12 @@ export type LastProps = z.infer<typeof NodePropsSchema>;
export type LastPropsKey = keyof LastProps; export type LastPropsKey = keyof LastProps;
const SessionPropsSchema = z.object({ const SessionPropsSchema = z.object({
templateCache: z.string(),
remoteColor: z.string(),
showBidirectional: z.boolean(),
});
const LocalPropsSchema = z.object({
viewport: z.union([ viewport: z.union([
z.object({ z.object({
centerX: z.number(), centerX: z.number(),
@@ -34,12 +40,6 @@ const SessionPropsSchema = z.object({
.optional(), .optional(),
}), }),
]), ]),
templateCache: z.string(),
remoteColor: z.string(),
showBidirectional: z.boolean(),
});
const LocalPropsSchema = z.object({
presentBlackBackground: z.boolean(), presentBlackBackground: z.boolean(),
presentFillScreen: z.boolean(), presentFillScreen: z.boolean(),
presentHideToolbar: z.boolean(), presentHideToolbar: z.boolean(),

View File

@@ -6,19 +6,25 @@ import {
EdgelessLegacySlotIdentifier, EdgelessLegacySlotIdentifier,
normalizeWheelDeltaY, normalizeWheelDeltaY,
} from '@blocksuite/affine-block-surface'; } from '@blocksuite/affine-block-surface';
import type {
RootBlockModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import { import {
type NoteBlockModel,
NoteDisplayMode,
type RootBlockModel,
type ShapeElementModel,
} from '@blocksuite/affine-model';
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
import {
DocModeProvider,
EditorSettingProvider, EditorSettingProvider,
EditPropsStore, EditPropsStore,
FeatureFlagService,
FontLoaderService, FontLoaderService,
ThemeProvider, ThemeProvider,
} from '@blocksuite/affine-shared/services'; } from '@blocksuite/affine-shared/services';
import type { Viewport } from '@blocksuite/affine-shared/types'; import type { Viewport } from '@blocksuite/affine-shared/types';
import { import {
isTouchPadPinchEvent, isTouchPadPinchEvent,
matchFlavours,
requestConnectedFrame, requestConnectedFrame,
requestThrottledConnectedFrame, requestThrottledConnectedFrame,
} from '@blocksuite/affine-shared/utils'; } from '@blocksuite/affine-shared/utils';
@@ -340,11 +346,65 @@ export class EdgelessRootBlockComponent extends BlockComponent<
private _initViewport() { private _initViewport() {
const { std, gfx } = this; const { std, gfx } = this;
const pageBlockViewportFitAnimation = () => {
const primaryMode = std.get(DocModeProvider).getPrimaryMode(this.doc.id);
const note = this.model.children.find(
(child): child is NoteBlockModel =>
matchFlavours(child, ['affine:note']) &&
child.displayMode !== NoteDisplayMode.EdgelessOnly
);
if (primaryMode !== 'page' || !note || note.edgeless.collapse)
return false;
const leftPadding = parseInt(
window
.getComputedStyle(this)
.getPropertyValue('--affine-editor-side-padding')
.replace('px', '')
);
if (isNaN(leftPadding)) return false;
let editorWidth = parseInt(
window
.getComputedStyle(this)
.getPropertyValue('--affine-editor-width')
.replace('px', '')
);
if (isNaN(editorWidth)) return false;
const containerWidth = this.getBoundingClientRect().width;
const leftMargin =
containerWidth > editorWidth ? (containerWidth - editorWidth) / 2 : 0;
const pageTitleAnchor = gfx.viewport.toModelCoord(
leftPadding + leftMargin,
0
);
const noteBound = Bound.deserialize(note.xywh);
const edgelessTitleAnchor = Vec.add(noteBound.tl, [
EDGELESS_BLOCK_CHILD_PADDING,
12,
]);
const center = Vec.sub(edgelessTitleAnchor, pageTitleAnchor);
gfx.viewport.setCenter(center[0], center[1]);
gfx.viewport.smoothZoom(0.65, undefined, 15);
return true;
};
const run = () => { const run = () => {
const storedViewport = std.get(EditPropsStore).getStorage('viewport'); const storedViewport = std.get(EditPropsStore).getStorage('viewport');
if (!storedViewport) { if (!storedViewport) {
this.gfx.fitToScreen(); const enablePageBlock = this.std
.get(FeatureFlagService)
.getFlag('enable_page_block');
if (!(enablePageBlock && pageBlockViewportFitAnimation())) {
this.gfx.fitToScreen();
}
return; return;
} }

View File

@@ -365,14 +365,13 @@ export class Viewport {
}); });
} }
smoothTranslate(x: number, y: number) { smoothTranslate(x: number, y: number, numSteps = 10) {
const { center } = this; const { center } = this;
const delta = { x: x - center.x, y: y - center.y }; const delta = { x: x - center.x, y: y - center.y };
const innerSmoothTranslate = () => { const innerSmoothTranslate = () => {
if (this._rafId) cancelAnimationFrame(this._rafId); if (this._rafId) cancelAnimationFrame(this._rafId);
this._rafId = requestAnimationFrame(() => { this._rafId = requestAnimationFrame(() => {
const rate = 10; const step = { x: delta.x / numSteps, y: delta.y / numSteps };
const step = { x: delta.x / rate, y: delta.y / rate };
const nextCenter = { const nextCenter = {
x: this.centerX + step.x, x: this.centerX + step.x,
y: this.centerY + step.y, y: this.centerY + step.y,
@@ -389,15 +388,14 @@ export class Viewport {
innerSmoothTranslate(); innerSmoothTranslate();
} }
smoothZoom(zoom: number, focusPoint?: IPoint) { smoothZoom(zoom: number, focusPoint?: IPoint, numSteps = 10) {
const delta = zoom - this.zoom; const delta = zoom - this.zoom;
if (this._rafId) cancelAnimationFrame(this._rafId); if (this._rafId) cancelAnimationFrame(this._rafId);
const innerSmoothZoom = () => { const innerSmoothZoom = () => {
this._rafId = requestAnimationFrame(() => { this._rafId = requestAnimationFrame(() => {
const sign = delta > 0 ? 1 : -1; const sign = delta > 0 ? 1 : -1;
const total = 10; const step = delta / numSteps;
const step = delta / total;
const nextZoom = cutoff(this.zoom + step, zoom, sign); const nextZoom = cutoff(this.zoom + step, zoom, sign);
this.setZoom(nextZoom, focusPoint); this.setZoom(nextZoom, focusPoint);

View File

@@ -71,11 +71,11 @@ export class Bound implements IBound {
y: number; y: number;
get bl() { get bl(): IVec {
return [this.x, this.y + this.h]; return [this.x, this.y + this.h];
} }
get br() { get br(): IVec {
return [this.x + this.w, this.y + this.h]; return [this.x + this.w, this.y + this.h];
} }
@@ -155,7 +155,7 @@ export class Bound implements IBound {
return [this.x, this.y]; return [this.x, this.y];
} }
get tr() { get tr(): IVec {
return [this.x + this.w, this.y]; return [this.x + this.w, this.y];
} }

View File

@@ -236,7 +236,7 @@ test.describe('edgeless note element toolbar', () => {
await locateModeSwitchButton(page, 'page').click(); await locateModeSwitchButton(page, 'page').click();
expect(notes).toHaveCount(2); expect(notes).toHaveCount(2);
await locateModeSwitchButton(page, 'edgeless').click(); await clickEdgelessModeButton(page);
await clickView(page, [100, 100]); await clickView(page, [100, 100]);
await displayInPage.click(); await displayInPage.click();
await locateModeSwitchButton(page, 'page').click(); await locateModeSwitchButton(page, 'page').click();
@@ -246,7 +246,7 @@ test.describe('edgeless note element toolbar', () => {
const undoButton = page.getByTestId('undo-display-in-page'); const undoButton = page.getByTestId('undo-display-in-page');
const viewTocButton = page.getByTestId('view-in-toc'); const viewTocButton = page.getByTestId('view-in-toc');
await locateModeSwitchButton(page, 'edgeless').click(); await clickEdgelessModeButton(page);
await waitForEditorLoad(page); await waitForEditorLoad(page);
await clickView(page, [100, 100]); await clickView(page, [100, 100]);
await displayInPage.click(); await displayInPage.click();
@@ -259,7 +259,7 @@ test.describe('edgeless note element toolbar', () => {
await waitForEditorLoad(page); await waitForEditorLoad(page);
expect(notes).toHaveCount(1); expect(notes).toHaveCount(1);
await locateModeSwitchButton(page, 'edgeless').click(); await clickEdgelessModeButton(page);
await waitForEditorLoad(page); await waitForEditorLoad(page);
await clickView(page, [100, 100]); await clickView(page, [100, 100]);
await displayInPage.click(); await displayInPage.click();

View File

@@ -6,7 +6,9 @@ import {
createEdgelessNoteBlock, createEdgelessNoteBlock,
focusDocTitle, focusDocTitle,
getEdgelessSelectedIds, getEdgelessSelectedIds,
getViewportCenter,
locateElementToolbar, locateElementToolbar,
setViewportCenter,
} from '@affine-test/kit/utils/editor'; } from '@affine-test/kit/utils/editor';
import { import {
pressBackspace, pressBackspace,
@@ -207,7 +209,8 @@ test.describe('TOC display', () => {
}) => { }) => {
await clickEdgelessModeButton(page); await clickEdgelessModeButton(page);
const toc = await openTocPanel(page); const toc = await openTocPanel(page);
await createEdgelessNoteBlock(page, [100, 100]); const viewportCenter = await getViewportCenter(page);
await createEdgelessNoteBlock(page, [viewportCenter.x, viewportCenter.y]);
const card = locateCards(toc, 'edgeless'); const card = locateCards(toc, 'edgeless');
await changeNoteDisplayMode(card, 'doc'); await changeNoteDisplayMode(card, 'doc');
@@ -337,6 +340,7 @@ test.describe('TOC and edgeless selection', () => {
page, page,
}) => { }) => {
await clickEdgelessModeButton(page); await clickEdgelessModeButton(page);
await setViewportCenter(page, [0, 0]);
await selectAllByKeyboard(page); await selectAllByKeyboard(page);
await pressBackspace(page); await pressBackspace(page);
await createEdgelessNoteBlock(page, [100, 100]); await createEdgelessNoteBlock(page, [100, 100]);
@@ -490,6 +494,7 @@ test.describe('advanced visibility control', () => {
page, page,
}) => { }) => {
await clickEdgelessModeButton(page); await clickEdgelessModeButton(page);
await setViewportCenter(page, [0, 0]);
await createEdgelessNoteBlock(page, [100, 100]); await createEdgelessNoteBlock(page, [100, 100]);
await type(page, 'hello'); await type(page, 'hello');
await clickView(page, [200, 200]); await clickView(page, [200, 200]);

View File

@@ -1,5 +1,5 @@
import { test } from '@affine-test/kit/playwright'; import { test } from '@affine-test/kit/playwright';
import { locateModeSwitchButton } from '@affine-test/kit/utils/editor'; import { clickEdgelessModeButton } from '@affine-test/kit/utils/editor';
import { import {
pasteByKeyboard, pasteByKeyboard,
writeTextToClipboard, writeTextToClipboard,
@@ -506,7 +506,7 @@ test('the viewport should be fit when the linked document is with edgeless mode'
}) => { }) => {
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await locateModeSwitchButton(page, 'edgeless').click(); await clickEdgelessModeButton(page);
const note = page.locator('affine-edgeless-note'); const note = page.locator('affine-edgeless-note');
const noteBoundingBox = await note.boundingBox(); const noteBoundingBox = await note.boundingBox();
@@ -570,7 +570,7 @@ test('should show edgeless content when switching card view of linked mode doc i
}) => { }) => {
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await locateModeSwitchButton(page, 'edgeless').click(); await clickEdgelessModeButton(page);
const note = page.locator('affine-edgeless-note'); const note = page.locator('affine-edgeless-note');
const noteBoundingBox = await note.boundingBox(); const noteBoundingBox = await note.boundingBox();
@@ -596,7 +596,7 @@ test('should show edgeless content when switching card view of linked mode doc i
const url = new URL(page.url()); const url = new URL(page.url());
await clickNewPageButton(page); await clickNewPageButton(page);
await locateModeSwitchButton(page, 'edgeless').click(); await clickEdgelessModeButton(page);
await page.mouse.move(x, y); await page.mouse.move(x, y);
await writeTextToClipboard(page, url.toString()); await writeTextToClipboard(page, url.toString());

View File

@@ -36,6 +36,8 @@ export async function ensureInPageMode(page: Page) {
export async function ensureInEdgelessMode(page: Page) { export async function ensureInEdgelessMode(page: Page) {
await expect(locateModeSwitchButton(page, 'edgeless', true)).toBeVisible(); await expect(locateModeSwitchButton(page, 'edgeless', true)).toBeVisible();
// wait zoom animation
await page.waitForTimeout(500);
} }
export async function getPageMode(page: Page): Promise<'page' | 'edgeless'> { export async function getPageMode(page: Page): Promise<'page' | 'edgeless'> {
@@ -80,6 +82,32 @@ export async function getEdgelessSelectedIds(page: Page, editorIndex = 0) {
}); });
} }
export async function getViewportCenter(page: Page, editorIndex = 0) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate((container: AffineEditorContainer) => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
return root.gfx.viewport.center;
});
}
export async function setViewportCenter(
page: Page,
center: IVec,
editorIndex = 0
) {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate((container: AffineEditorContainer, center) => {
const root = container.querySelector('affine-edgeless-root');
if (!root) {
throw new Error('Edgeless root not found');
}
root.gfx.viewport.setCenter(center[0], center[1]);
}, center);
}
/** /**
* Convert a canvas point to view coordinate * Convert a canvas point to view coordinate
* @param point the coordinate on the canvas * @param point the coordinate on the canvas

View File

@@ -33,13 +33,6 @@ export async function waitForAllPagesLoad(page: Page) {
} }
export async function clickNewPageButton(page: Page, title?: string) { export async function clickNewPageButton(page: Page, title?: string) {
// FiXME: when the page is in edgeless mode, clickNewPageButton will create a new edgeless page
const edgelessPage = page.locator('edgeless-editor');
if (await edgelessPage.isVisible()) {
await page.getByTestId('switch-page-mode-button').click({
delay: 100,
});
}
// fixme(himself65): if too fast, the page will crash // fixme(himself65): if too fast, the page will crash
await page.getByTestId('sidebar-new-page-button').click({ await page.getByTestId('sidebar-new-page-button').click({
delay: 100, delay: 100,