From 365a0a605bc12cdeb556e5c70e0a038ee93014a6 Mon Sep 17 00:00:00 2001 From: L-Sun Date: Tue, 29 Apr 2025 13:24:56 +0000 Subject: [PATCH] feat(editor): improve visibility of hidden content of edgeless note (#12068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close [BS-3066](https://linear.app/affine-design/issue/BS-3066/优化长note的展示和折叠) - Enhanced the visibility behavior of hidden content in edgeless notes by: - Showing hidden content when a note is being edited, even when it's outside the viewport - Improving hover behavior with a delay when leaving from the bottom of the note - Adding proper cleanup of hover timeouts when the component is disconnected - Optimizing the viewport element to keep editing blocks or elements visible ## Testing - Added new E2E test cases covering: - Hover behavior on selected notes - Content visibility during editing - Viewport scrolling behavior - Edge cases for content visibility ## Impact This change improves the user experience when working with collapsed notes in edgeless mode by making the content more accessible and preventing accidental content hiding during editing. ## Summary by CodeRabbit - **Bug Fixes** - Improved visibility of hidden content in edgeless notes when hovering near the bottom edge or editing the note, especially after resizing or clipping. - **New Features** - Enhanced hover behavior with delayed clearing based on mouse position to improve user experience. - **Tests** - Added new tests verifying hidden content visibility in edgeless notes during hover and editing, simulating diverse user interactions. - **Chores** - Added utilities to get and set viewport center for improved test control. --- .../blocks/note/src/note-edgeless-block.ts | 39 +++++- .../framework/std/src/gfx/viewport-element.ts | 29 +++-- .../blocksuite/e2e/edgeless/note/note.spec.ts | 112 ++++++++++++++++++ .../blocksuite/e2e/utils/actions/edgeless.ts | 19 +++ 4 files changed, 187 insertions(+), 12 deletions(-) diff --git a/blocksuite/affine/blocks/note/src/note-edgeless-block.ts b/blocksuite/affine/blocks/note/src/note-edgeless-block.ts index 8fe95bba20..f2d7ad4b1e 100644 --- a/blocksuite/affine/blocks/note/src/note-edgeless-block.ts +++ b/blocksuite/affine/blocks/note/src/note-edgeless-block.ts @@ -31,10 +31,10 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent( ) { private get _isShowCollapsedContent() { return ( - this.model.props.edgeless.collapse && - this.gfx.selection.has(this.model.id) && + !!this.model.props.edgeless.collapse && + this.selected$.value && !this._dragging && - (this._isResizing || this._isHover) + (this._isResizing || this._isHover || this._editing) ); } @@ -93,12 +93,33 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent( sel => sel.type === 'surface' && sel.blockId === this.model.id ) ) { + if (this._hoverTimeout) { + clearTimeout(this._hoverTimeout); + this._hoverTimeout = null; + } this._isHover = true; } } - private _leaved() { - if (this._isHover) { + private _hoverTimeout: ReturnType | null = null; + + private _leaved(e: MouseEvent) { + if (this._hoverTimeout) { + clearTimeout(this._hoverTimeout); + this._hoverTimeout = null; + } + const rect = this.getBoundingClientRect(); + const threshold = -10; + const leavedFromBottom = + e.clientY - rect.bottom > threshold && + rect.left < e.clientX && + e.clientX < rect.right; + + if (leavedFromBottom) { + this._hoverTimeout = setTimeout(() => { + this._isHover = false; + }, 300); + } else { this._isHover = false; } } @@ -144,6 +165,14 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent( this.disposables.addFromEvent(this, 'keydown', this._handleKeyDown); } + override disconnectedCallback() { + super.disconnectedCallback(); + if (this._hoverTimeout) { + clearTimeout(this._hoverTimeout); + this._hoverTimeout = null; + } + } + get edgelessSlots() { return this.std.get(EdgelessLegacySlotIdentifier); } diff --git a/blocksuite/framework/std/src/gfx/viewport-element.ts b/blocksuite/framework/std/src/gfx/viewport-element.ts index 382246da93..b10a660650 100644 --- a/blocksuite/framework/std/src/gfx/viewport-element.ts +++ b/blocksuite/framework/std/src/gfx/viewport-element.ts @@ -10,7 +10,7 @@ import { } from '../view'; import { PropTypes, requiredProperties } from '../view/decorators/required'; import { GfxControllerIdentifier } from './identifiers'; -import type { GfxBlockElementModel } from './model/gfx-block-model'; +import { GfxBlockElementModel } from './model/gfx-block-model'; import { Viewport } from './viewport'; /** @@ -66,14 +66,17 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) { } `; - private readonly _hideOutsideBlock = () => { + private readonly _hideOutsideNoEditingBlock = () => { if (!this.host) return; const gfx = this.host.std.get(GfxControllerIdentifier); - const modelsInViewport = this.getModelsInViewport(); + const nextVisibleModels = new Set([ + ...this.getModelsInViewport(), + ...this._getEditingModels(), + ]); batch(() => { - modelsInViewport.forEach(model => { + nextVisibleModels.forEach(model => { const view = gfx.view.get(model); if (isGfxBlockComponent(view)) { view.transformState$.value = 'active'; @@ -92,7 +95,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) { }); }); - this._lastVisibleModels = modelsInViewport; + this._lastVisibleModels = nextVisibleModels; }; private _lastVisibleModels?: Set; @@ -103,7 +106,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) { }[] = []; private readonly _refreshViewport = requestThrottledConnectedFrame(() => { - this._hideOutsideBlock(); + this._hideOutsideNoEditingBlock(); }, this); private _updatingChildrenFlag = false; @@ -119,7 +122,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) { delete this.scheduleUpdateChildren; } - this._hideOutsideBlock(); + this._hideOutsideNoEditingBlock(); this.disposables.add( this.viewport.viewportUpdated.subscribe(() => viewportUpdateCallback()) ); @@ -166,6 +169,18 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) { return promise; }; + private _getEditingModels(): Set { + if (!this.host) return new Set(); + const gfx = this.host.std.get(GfxControllerIdentifier); + return new Set( + gfx.selection.surfaceSelections + .filter(s => s.editing) + .flatMap(({ elements }) => elements) + .map(id => gfx.getElementById(id)) + .filter(e => e instanceof GfxBlockElementModel) + ); + } + @property({ attribute: false }) accessor getModelsInViewport: () => Set = () => new Set(); diff --git a/tests/blocksuite/e2e/edgeless/note/note.spec.ts b/tests/blocksuite/e2e/edgeless/note/note.spec.ts index bb601b223c..bb1873b121 100644 --- a/tests/blocksuite/e2e/edgeless/note/note.spec.ts +++ b/tests/blocksuite/e2e/edgeless/note/note.spec.ts @@ -10,10 +10,13 @@ import { dragBetweenViewCoords, getSelectedBound, getSelectedBoundCount, + getViewportCenter, locatorComponentToolbar, locatorEdgelessZoomToolButton, + resizeElementByHandle, selectNoteInEdgeless, setEdgelessTool, + setViewportCenter, switchEditorMode, triggerComponentToolbarAction, zoomOutByKeyboard, @@ -34,6 +37,7 @@ import { pressArrowUp, pressBackspace, pressEnter, + pressEscape, pressTab, selectAllByKeyboard, type, @@ -576,3 +580,111 @@ test('should not select doc only note', async ({ page }) => { ); expect(await getSelectedBoundCount(page)).toBe(0); }); + +test.describe('visibility of hidden content of edgeless note', () => { + test.beforeEach(async ({ page }) => { + await enterPlaygroundRoom(page); + await initEmptyEdgelessState(page); + await switchEditorMode(page); + + const note = page.locator('affine-edgeless-note'); + await note.click({ clickCount: 3 }); + await type(page, 'hello'); + await pressEnter(page, 30); + await type(page, 'world'); + await pressEscape(page, 3); + + const vpCenter = await getViewportCenter(page); + vpCenter[1] += 1000; + await setViewportCenter(page, vpCenter); + + await note.click(); + await resizeElementByHandle(page, { x: 0, y: -300 }, 'bottom-right'); + }); + + test('should hide content when note is not selected or hovered when selected', async ({ + page, + }) => { + const note = page.locator('affine-edgeless-note'); + const lastParagraph = page.locator('affine-paragraph').last(); + + await pressEscape(page, 3); + await expect(lastParagraph).not.toBeInViewport(); + + const noteBound = await note.boundingBox(); + if (!noteBound) { + test.fail(); + return; + } + await note.click(); + // move out to right side + await page.mouse.move( + noteBound.x + noteBound.width + 10, + noteBound.y + noteBound.height - 10 + ); + await expect(lastParagraph).not.toBeInViewport(); + }); + + test('should show hidden content when hover on selected note', async ({ + page, + }) => { + const note = page.locator('affine-edgeless-note'); + const lastParagraph = page.locator('affine-paragraph').last(); + + const noteBound = await note.boundingBox(); + if (!noteBound) { + test.fail(); + return; + } + + // move in right side + await page.mouse.move( + noteBound.x + noteBound.width - 10, + noteBound.y + noteBound.height - 10 + ); + await expect(lastParagraph).toBeInViewport(); + + // move to hidden content + await page.mouse.move( + noteBound.x + noteBound.width - 10, + noteBound.y + noteBound.height + 100 + ); + await expect(lastParagraph).toBeInViewport(); + }); + + test('should show hidden content when the note is being edited', async ({ + page, + }) => { + const note = page.locator('affine-edgeless-note'); + const lastParagraph = page.locator('affine-paragraph').last(); + + await note.click({ clickCount: 3 }); + await page.locator('affine-paragraph').nth(22).click(); + await type(page, 'test'); + + await expect(lastParagraph).toBeInViewport(); + + await note.click({ clickCount: 3 }); + await page.locator('affine-paragraph').nth(22).click(); + + const noteBound = await note.boundingBox(); + if (!noteBound) { + test.fail(); + return; + } + await page.mouse.move( + noteBound.x + noteBound.width - 10, + noteBound.y + noteBound.height - 10 + ); + await expect(lastParagraph).toBeInViewport(); + + await note.click({ clickCount: 3 }); + await page.locator('affine-paragraph').nth(22).click(); + + await page.mouse.wheel(0, 200); + await expect( + lastParagraph, + 'editing note but out of viewport should also show hidden content' + ).toBeInViewport(); + }); +}); diff --git a/tests/blocksuite/e2e/utils/actions/edgeless.ts b/tests/blocksuite/e2e/utils/actions/edgeless.ts index 95f9f1d0dc..812839b80b 100644 --- a/tests/blocksuite/e2e/utils/actions/edgeless.ts +++ b/tests/blocksuite/e2e/utils/actions/edgeless.ts @@ -970,6 +970,25 @@ export async function getZoomLevel(page: Page) { return Number(text.replace('%', '')); } +export async function getViewportCenter(page: Page): Promise<[number, number]> { + return page.evaluate(() => { + const target = document.querySelector('affine-edgeless-root'); + if (!target) { + throw new Error('Missing edgeless page'); + } + return [target.gfx.viewport.centerX, target.gfx.viewport.centerY]; + }); +} +export async function setViewportCenter(page: Page, center: [number, number]) { + await page.evaluate(center => { + const target = document.querySelector('affine-edgeless-root'); + if (!target) { + throw new Error('Missing edgeless page'); + } + target.gfx.viewport.setCenter(center[0], center[1]); + }, center); +} + export async function optionMouseDrag( page: Page, start: number[],