From c23b8f604b4bf7703748fe6f18e969c890399535 Mon Sep 17 00:00:00 2001 From: L-Sun Date: Wed, 26 Mar 2025 09:29:04 +0000 Subject: [PATCH] fix(editor): incorrect edgeless viewport in peek view (#11040) ### What Changes - Fixed incorrect edgeless viewport display in peek view - Moved page block viewport fit animation logic from `EdgelessRootBlockComponent` to note config extension - Disabled page block viewport fit animation in peek view, using default `fitToScreen` instead - @doodlewind Fixed viewport resizing issues by adding a immediate update mechanism to ensure proper rendering during peek view operations. The setViewportByBound is only called once during peek view initialization, so there are barely perf overhead. - Updated related test cases - Refactored peek view test cases to make them clearer and more reliable - Added new test helper function `getViewportBound` for getting viewport boundary information --- .../affine/blocks/block-note/src/config.ts | 4 + .../src/edgeless/edgeless-root-block.ts | 84 ++++--------- .../blocks/block-root/src/edgeless/index.ts | 1 + .../framework/block-std/src/gfx/viewport.ts | 114 +++++++++++++----- .../block-suite-editor/lit-adaper.tsx | 6 +- .../extensions/note-config/index.tsx | 74 +++++++++++- .../view/doc-preview/doc-peek-view.tsx | 3 +- tests/affine-local/e2e/peek-view.spec.ts | 94 +++++++-------- tests/kit/src/utils/editor.ts | 11 ++ 9 files changed, 246 insertions(+), 145 deletions(-) diff --git a/blocksuite/affine/blocks/block-note/src/config.ts b/blocksuite/affine/blocks/block-note/src/config.ts index 69e6edcc9f..531679d2c0 100644 --- a/blocksuite/affine/blocks/block-note/src/config.ts +++ b/blocksuite/affine/blocks/block-note/src/config.ts @@ -13,6 +13,10 @@ type NoteBlockContext = { export type NoteConfig = { edgelessNoteHeader: (context: NoteBlockContext) => TemplateResult; pageBlockTitle: (context: NoteBlockContext) => TemplateResult; + /** + * @returns if the viewport fit animation executed + */ + pageBlockViewportFitAnimation?: (context: NoteBlockContext) => boolean; }; export const NoteConfigExtension = diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-root-block.ts b/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-root-block.ts index b358385c49..dab8e56ca9 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-root-block.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-root-block.ts @@ -1,3 +1,4 @@ +import { NoteConfigExtension } from '@blocksuite/affine-block-note'; import type { SurfaceBlockComponent, SurfaceBlockModel, @@ -11,13 +12,10 @@ import { isSingleMindMapNode } from '@blocksuite/affine-gfx-mindmap'; import { mountShapeTextEditor } from '@blocksuite/affine-gfx-shape'; import { NoteBlockModel, - NoteDisplayMode, type RootBlockModel, type ShapeElementModel, } from '@blocksuite/affine-model'; -import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts'; import { - DocModeProvider, EditorSettingProvider, EditPropsStore, FontLoaderService, @@ -310,71 +308,33 @@ export class EdgelessRootBlockComponent extends BlockComponent< private _initViewport() { 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 => - matchModels(child, [NoteBlockModel]) && - child.props.displayMode !== NoteDisplayMode.EdgelessOnly - ); - - if (primaryMode !== 'page' || !note || note.props.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 animationFn = std.getOptional( + NoteConfigExtension.identifier + )?.pageBlockViewportFitAnimation; + if (animationFn) { + const pageBlock = this.model.children.find( + (child): child is NoteBlockModel => + matchModels(child, [NoteBlockModel]) && child.isPageBlock() + ); + if (pageBlock && animationFn({ std: this.std, note: pageBlock })) { + return; + } + } + const storedViewport = std.get(EditPropsStore).getStorage('viewport'); - if (!storedViewport) { - if (!pageBlockViewportFitAnimation()) { - this.gfx.fitToScreen(); + if (storedViewport) { + if ('xywh' in storedViewport) { + const bound = Bound.deserialize(storedViewport.xywh); + gfx.viewport.setViewportByBound(bound, storedViewport.padding); + } else { + const { zoom, centerX, centerY } = storedViewport; + gfx.viewport.setViewport(zoom, [centerX, centerY]); } return; } - if ('xywh' in storedViewport) { - const bound = Bound.deserialize(storedViewport.xywh); - gfx.viewport.setViewportByBound(bound, storedViewport.padding); - } else { - const { zoom, centerX, centerY } = storedViewport; - gfx.viewport.setViewport(zoom, [centerX, centerY]); - } + this.gfx.fitToScreen(); }; run(); diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/index.ts b/blocksuite/affine/blocks/block-root/src/edgeless/index.ts index bdd6869aa7..75a41f94aa 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/index.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/index.ts @@ -9,3 +9,4 @@ export * from './gfx-tool'; export * from './utils/clipboard-utils.js'; export { sortEdgelessElements } from './utils/clone-utils.js'; export { isCanvasElement } from './utils/query.js'; +export { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts'; diff --git a/blocksuite/framework/block-std/src/gfx/viewport.ts b/blocksuite/framework/block-std/src/gfx/viewport.ts index d2746e99a1..73c2ef41df 100644 --- a/blocksuite/framework/block-std/src/gfx/viewport.ts +++ b/blocksuite/framework/block-std/src/gfx/viewport.ts @@ -129,28 +129,45 @@ export class Viewport { .pipe(debounceTime(200)) .subscribe(({ width, height, left, top }) => { if (!this._shell || !this._initialTopLeft) return; - - const [initialTopLeftX, initialTopLeftY] = this._initialTopLeft; - const newCenterX = initialTopLeftX + width / (2 * this.zoom); - const newCenterY = initialTopLeftY + height / (2 * this.zoom); - - this.setCenter(newCenterX, newCenterY); - this._width = width; - this._height = height; - this._left = left; - this._top = top; - this._isResizing = false; - this._initialTopLeft = null; - - this.sizeUpdated.next({ - left, - top, - width, - height, - }); + this._completeResize(width, height, left, top); }); } + private _completeResize( + width: number, + height: number, + left: number, + top: number + ) { + if (!this._initialTopLeft) return; + + const [initialTopLeftX, initialTopLeftY] = this._initialTopLeft; + const newCenterX = initialTopLeftX + width / (2 * this.zoom); + const newCenterY = initialTopLeftY + height / (2 * this.zoom); + + this.setCenter(newCenterX, newCenterY); + this._width = width; + this._height = height; + this._left = left; + this._top = top; + this._isResizing = false; + this._initialTopLeft = null; + + this.sizeUpdated.next({ + left, + top, + width, + height, + }); + } + + private _forceCompleteResize() { + if (this._isResizing && this._shell) { + const { width, height, left, top } = this.boundingClientRect; + this._completeResize(width, height, left, top); + } + } + get boundingClientRect() { if (!this._shell) return new DOMRect(0, 0, 0, 0); if (!this._cachedBoundingClientRect) { @@ -341,7 +358,17 @@ export class Viewport { }); } - setCenter(centerX: number, centerY: number) { + /** + * Set the center of the viewport. + * @param centerX The new x coordinate of the center of the viewport. + * @param centerY The new y coordinate of the center of the viewport. + * @param forceUpdate Whether to force complete any pending resize operations before setting the viewport. + */ + setCenter(centerX: number, centerY: number, forceUpdate = false) { + if (forceUpdate && this._isResizing) { + this._forceCompleteResize(); + } + this._center.x = centerX; this._center.y = centerY; this.panning$.value = true; @@ -369,11 +396,24 @@ export class Viewport { }); } + /** + * Set the viewport to the new zoom and center. + * @param newZoom The new zoom value. + * @param newCenter The new center of the viewport. + * @param smooth Whether to animate the zooming and panning. + * @param forceUpdate Whether to force complete any pending resize operations before setting the viewport. + */ setViewport( newZoom: number, newCenter = Vec.toVec(this.center), - smooth = false + smooth = false, + forceUpdate = smooth ) { + // Force complete any pending resize operations if forceUpdate is true + if (forceUpdate && this._isResizing) { + this._forceCompleteResize(); + } + const preZoom = this._zoom; if (smooth) { const cofficient = preZoom / newZoom; @@ -390,7 +430,7 @@ export class Viewport { } else { this._center.x = newCenter[0]; this._center.y = newCenter[1]; - this.setZoom(newZoom); + this.setZoom(newZoom, undefined, false, forceUpdate); } } @@ -401,11 +441,13 @@ export class Viewport { * the value may be reduced if there is not enough space for the padding. * Use decimal less than 1 to represent percentage padding. e.g. [0.1, 0.1, 0.1, 0.1] means 10% padding. * @param smooth whether to animate the zooming + * @param forceUpdate whether to force complete any pending resize operations before setting the viewport */ setViewportByBound( bound: Bound, padding: [number, number, number, number] = [0, 0, 0, 0], - smooth = false + smooth = false, + forceUpdate = smooth ) { let [pt, pr, pb, pl] = padding; @@ -440,7 +482,7 @@ export class Viewport { bound.y + (bound.h + pb / zoom) / 2 - pt / zoom / 2, ] as IVec; - this.setViewport(zoom, center, smooth); + this.setViewport(zoom, center, smooth, forceUpdate); } /** This is the outer container of the viewport, which is the host of the viewport element */ @@ -460,7 +502,23 @@ export class Viewport { this._resizeObserver.observe(el); } - setZoom(zoom: number, focusPoint?: IPoint, wheel = false) { + /** + * Set the viewport to the new zoom. + * @param zoom The new zoom value. + * @param focusPoint The point to focus on after zooming, default is the center of the viewport. + * @param wheel Whether the zoom is caused by wheel event. + * @param forceUpdate Whether to force complete any pending resize operations before setting the viewport. + */ + setZoom( + zoom: number, + focusPoint?: IPoint, + wheel = false, + forceUpdate = false + ) { + if (forceUpdate && this._isResizing) { + this._forceCompleteResize(); + } + const prevZoom = this.zoom; focusPoint = (focusPoint ?? this._center) as IPoint; this._zoom = clamp(zoom, this.ZOOM_MIN, this.ZOOM_MAX); @@ -474,7 +532,7 @@ export class Viewport { if (wheel) { this.zooming$.value = true; } - this.setCenter(newCenter[0], newCenter[1]); + this.setCenter(newCenter[0], newCenter[1], forceUpdate); this.viewportUpdated.next({ zoom: this.zoom, center: Vec.toVec(this.center) as IVec, @@ -497,7 +555,7 @@ export class Viewport { const signY = delta.y > 0 ? 1 : -1; nextCenter.x = cutoff(nextCenter.x, x, signX); nextCenter.y = cutoff(nextCenter.y, y, signY); - this.setCenter(nextCenter.x, nextCenter.y); + this.setCenter(nextCenter.x, nextCenter.y, true); if (nextCenter.x != x || nextCenter.y != y) innerSmoothTranslate(); }); @@ -515,7 +573,7 @@ export class Viewport { const step = delta / numSteps; const nextZoom = cutoff(this.zoom + step, zoom, sign); - this.setZoom(nextZoom, focusPoint); + this.setZoom(nextZoom, focusPoint, undefined, true); if (nextZoom != zoom) innerSmoothZoom(); }); diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx index 57dee736ed..59c31ae1bb 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx @@ -19,6 +19,7 @@ import { EditorSettingService } from '@affine/core/modules/editor-setting'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { JournalService } from '@affine/core/modules/journal'; import { toURLSearchParams } from '@affine/core/modules/navigation'; +import { useInsidePeekView } from '@affine/core/modules/peek-view'; import { PeekViewService } from '@affine/core/modules/peek-view/services/peek-view'; import { MemberSearchService } from '@affine/core/modules/permissions'; import { WorkspaceService } from '@affine/core/modules/workspace'; @@ -147,6 +148,8 @@ const usePatchSpecs = (mode: DocMode) => { const enableAI = useEnableAI(); + const insidePeekView = useInsidePeekView(); + const enableTurboRenderer = useLiveData( featureFlagService.flags.enable_turbo_renderer.$ ); @@ -165,7 +168,7 @@ const usePatchSpecs = (mode: DocMode) => { builder.extend( [ patchReferenceRenderer(reactToLit, referenceRenderer), - patchForEdgelessNoteConfig(framework, reactToLit), + patchForEdgelessNoteConfig(framework, reactToLit, insidePeekView), patchNotificationService(confirmModal), patchPeekViewService(peekViewService), patchOpenDocExtension(), @@ -207,6 +210,7 @@ const usePatchSpecs = (mode: DocMode) => { enableAI, reactToLit, referenceRenderer, + insidePeekView, confirmModal, peekViewService, docService, diff --git a/packages/frontend/core/src/blocksuite/extensions/note-config/index.tsx b/packages/frontend/core/src/blocksuite/extensions/note-config/index.tsx index 6dbefe057a..44176401ae 100644 --- a/packages/frontend/core/src/blocksuite/extensions/note-config/index.tsx +++ b/packages/frontend/core/src/blocksuite/extensions/note-config/index.tsx @@ -1,6 +1,13 @@ import type { ElementOrFactory } from '@affine/component'; import { JournalService } from '@affine/core/modules/journal'; +import { GfxControllerIdentifier } from '@blocksuite/affine/block-std/gfx'; import { NoteConfigExtension } from '@blocksuite/affine/blocks/note'; +import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine/blocks/root'; +import { Bound, Vec } from '@blocksuite/affine/global/gfx'; +import { + DocModeProvider, + EditPropsStore, +} from '@blocksuite/affine/shared/services'; import type { FrameworkProvider } from '@toeverything/infra'; import { html, type TemplateResult } from 'lit'; @@ -9,7 +16,8 @@ import { EdgelessNoteHeader } from './edgeless-note-header'; export function patchForEdgelessNoteConfig( framework: FrameworkProvider, - reactToLit: (element: ElementOrFactory) => TemplateResult + reactToLit: (element: ElementOrFactory) => TemplateResult, + insidePeekView: boolean ) { return NoteConfigExtension({ edgelessNoteHeader: ({ note }) => @@ -23,5 +31,69 @@ export function patchForEdgelessNoteConfig( return html``; } }, + pageBlockViewportFitAnimation: insidePeekView + ? undefined + : ({ std, note }) => { + const storedViewport = std.get(EditPropsStore).getStorage('viewport'); + // if there is a stored viewport, don't run the animation + // in other word, this doc has been opened before + if (storedViewport) return false; + + if (!std.store.root) return false; + const rootView = std.view.getBlock(std.store.root.id); + if (!rootView) return false; + + const gfx = std.get(GfxControllerIdentifier); + const primaryMode = std + .get(DocModeProvider) + .getPrimaryMode(std.store.id); + + if (primaryMode !== 'page' || !note || note.props.edgeless.collapse) { + return false; + } + + const leftPadding = parseInt( + window + .getComputedStyle(rootView) + .getPropertyValue('--affine-editor-side-padding') + .replace('px', '') + ); + if (isNaN(leftPadding)) { + return false; + } + + let editorWidth = parseInt( + window + .getComputedStyle(rootView) + .getPropertyValue('--affine-editor-width') + .replace('px', '') + ); + if (isNaN(editorWidth)) { + return false; + } + + const containerWidth = rootView.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], true); + gfx.viewport.smoothZoom(0.65, undefined, 15); + + return true; + }, }); } diff --git a/packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx b/packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx index 2b7ef44f20..0aeb8d7929 100644 --- a/packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx @@ -58,7 +58,8 @@ function fitViewport( viewport.setViewportByBound( Bound.deserialize(newViewport.xywh), newViewport.padding, - false + false, + true ); } else { gfx.fitToScreen({ diff --git a/tests/affine-local/e2e/peek-view.spec.ts b/tests/affine-local/e2e/peek-view.spec.ts index e02df2eaba..5888efb671 100644 --- a/tests/affine-local/e2e/peek-view.spec.ts +++ b/tests/affine-local/e2e/peek-view.spec.ts @@ -1,12 +1,14 @@ import { test } from '@affine-test/kit/playwright'; import { - clickEdgelessModeButton, - clickPageModeButton, + getSelectedXYWH, + getViewportBound, } from '@affine-test/kit/utils/editor'; +import { pressEnter, pressEscape } from '@affine-test/kit/utils/keyboard'; import { openHomePage } from '@affine-test/kit/utils/load-page'; import { clickNewPageButton, createLinkedPage, + type, waitForEmptyEditor, } from '@affine-test/kit/utils/page-logic'; import { expect } from '@playwright/test'; @@ -103,78 +105,66 @@ test('can open peek view via db+click link card', async ({ page }) => { }); test('can open peek view for embedded frames', async ({ page }) => { - await page.keyboard.press('Enter'); + const frameInViewport = async () => { + const peekView = page.locator('[data-testid="peek-view-modal"]'); + // wait for peek view ani + const frameTitle = peekView.locator('edgeless-editor affine-frame-title'); + await frameTitle.waitFor({ state: 'visible' }); - // create a frame - await page.keyboard.insertText('```frame\nTest Frame\n```'); + await frameTitle.click(); + const frameXYWH = await getSelectedXYWH(page, 0, 1); + const viewportBound = await getViewportBound(page, 1); + return ( + frameXYWH[0] >= viewportBound[0] && + frameXYWH[1] >= viewportBound[1] && + frameXYWH[0] + frameXYWH[2] <= viewportBound[0] + viewportBound[2] && + frameXYWH[1] + frameXYWH[3] <= viewportBound[1] + viewportBound[3] + ); + }; - await clickEdgelessModeButton(page); + await pressEnter(page); - // select the note - await page - .locator('affine-edgeless-note:has-text("Test Frame")') - .click({ force: true }); - // enter F to create a frame - await page.keyboard.press('f'); + // create a blank frame using slash command + await type(page, '/frame'); + await pressEnter(page); - // close affine-banner - await page.locator('[data-testid=local-demo-tips-close-button]').click(); - - const toolbar = page.locator('affine-toolbar-widget editor-toolbar'); - - // insert the frame to page - await toolbar.getByLabel('Insert into Page').click(); - - // switch back to page mode - await clickPageModeButton(page); - - // hover the frame to trigger surface-ref-toolbar - await page.locator('affine-surface-ref .affine-surface-ref').hover(); + const surfaceRef = page.locator('affine-surface-ref'); + const peekView = page.locator('[data-testid="peek-view-modal"]'); + await expect(surfaceRef).toBeVisible(); + await surfaceRef.hover(); await page .locator('affine-surface-ref-toolbar editor-menu-button[aria-label="Open"]') .click(); - await page .locator( 'affine-surface-ref-toolbar editor-menu-action[aria-label="Open in center peek"]' ) .click(); - // verify peek view is opened - await expect(page.getByTestId('peek-view-modal')).toBeVisible(); - - // check if page is in edgeless mode - await expect( - page.locator('edgeless-editor').locator('affine-frame') - ).toBeInViewport(); - - // close peek view - await page.keyboard.press('Escape'); + await expect(peekView).toBeVisible(); + expect(await frameInViewport()).toBe(true); + await pressEscape(page); + await expect(peekView).toBeHidden(); // check if can open peek view by shift+click - await page - .locator('affine-surface-ref .affine-surface-ref') - .click({ modifiers: ['Shift'] }); + await surfaceRef.click({ modifiers: ['Shift'] }); - // check if page is in edgeless mode - await expect( - page.locator('edgeless-editor').locator('affine-frame') - ).toBeInViewport(); - - // close peek view - await page.keyboard.press('Escape'); + await expect(peekView).toBeVisible(); + expect(await frameInViewport()).toBe(true); + await pressEscape(page); + await expect(peekView).toBeHidden(); // check if can open peek view by double click - await page.locator('affine-surface-ref .affine-surface-ref').dblclick(); - // check if page is in edgeless mode - await expect( - page.locator('edgeless-editor').locator('affine-frame') - ).toBeInViewport(); + await surfaceRef.dblclick(); + await expect(peekView).toBeVisible(); + expect(await frameInViewport()).toBe(true); + await pressEscape(page); + await expect(peekView).toBeHidden(); // can close modal when navigate await openHomePage(page); - await expect(page.getByTestId('peek-view-modal')).not.toBeVisible(); + await expect(peekView).toBeHidden(); }); test.skip('can open peek view for fav link', async ({ page }) => { diff --git a/tests/kit/src/utils/editor.ts b/tests/kit/src/utils/editor.ts index 63904f7c10..2fa085fea0 100644 --- a/tests/kit/src/utils/editor.ts +++ b/tests/kit/src/utils/editor.ts @@ -109,6 +109,17 @@ export async function getViewportCenter(page: Page, editorIndex = 0) { }); } +export async function getViewportBound(page: Page, editorIndex = 0) { + const container = locateEditorContainer(page, editorIndex); + return container.evaluate(container => { + const root = container.querySelector('affine-edgeless-root'); + if (!root) { + throw new Error('Edgeless root not found'); + } + return root.gfx.viewport.viewportBounds.toXYWH(); + }); +} + export async function setViewportCenter( page: Page, center: IVec,