feat(editor): improve visibility of hidden content of edgeless note (#12068)

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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
L-Sun
2025-04-29 13:24:56 +00:00
parent 8b2a01d4cf
commit 365a0a605b
4 changed files with 187 additions and 12 deletions
@@ -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<typeof setTimeout> | 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);
}
@@ -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<GfxBlockElementModel>;
@@ -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<GfxBlockElementModel> {
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<GfxBlockElementModel> = () =>
new Set();
@@ -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();
});
});
@@ -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[],