mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-22 19:53:48 +08:00
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:
@@ -31,10 +31,10 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
|||||||
) {
|
) {
|
||||||
private get _isShowCollapsedContent() {
|
private get _isShowCollapsedContent() {
|
||||||
return (
|
return (
|
||||||
this.model.props.edgeless.collapse &&
|
!!this.model.props.edgeless.collapse &&
|
||||||
this.gfx.selection.has(this.model.id) &&
|
this.selected$.value &&
|
||||||
!this._dragging &&
|
!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
|
sel => sel.type === 'surface' && sel.blockId === this.model.id
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
if (this._hoverTimeout) {
|
||||||
|
clearTimeout(this._hoverTimeout);
|
||||||
|
this._hoverTimeout = null;
|
||||||
|
}
|
||||||
this._isHover = true;
|
this._isHover = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _leaved() {
|
private _hoverTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
if (this._isHover) {
|
|
||||||
|
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;
|
this._isHover = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +165,14 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
|||||||
this.disposables.addFromEvent(this, 'keydown', this._handleKeyDown);
|
this.disposables.addFromEvent(this, 'keydown', this._handleKeyDown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
if (this._hoverTimeout) {
|
||||||
|
clearTimeout(this._hoverTimeout);
|
||||||
|
this._hoverTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get edgelessSlots() {
|
get edgelessSlots() {
|
||||||
return this.std.get(EdgelessLegacySlotIdentifier);
|
return this.std.get(EdgelessLegacySlotIdentifier);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '../view';
|
} from '../view';
|
||||||
import { PropTypes, requiredProperties } from '../view/decorators/required';
|
import { PropTypes, requiredProperties } from '../view/decorators/required';
|
||||||
import { GfxControllerIdentifier } from './identifiers';
|
import { GfxControllerIdentifier } from './identifiers';
|
||||||
import type { GfxBlockElementModel } from './model/gfx-block-model';
|
import { GfxBlockElementModel } from './model/gfx-block-model';
|
||||||
import { Viewport } from './viewport';
|
import { Viewport } from './viewport';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,14 +66,17 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
private readonly _hideOutsideBlock = () => {
|
private readonly _hideOutsideNoEditingBlock = () => {
|
||||||
if (!this.host) return;
|
if (!this.host) return;
|
||||||
|
|
||||||
const gfx = this.host.std.get(GfxControllerIdentifier);
|
const gfx = this.host.std.get(GfxControllerIdentifier);
|
||||||
const modelsInViewport = this.getModelsInViewport();
|
const nextVisibleModels = new Set([
|
||||||
|
...this.getModelsInViewport(),
|
||||||
|
...this._getEditingModels(),
|
||||||
|
]);
|
||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
modelsInViewport.forEach(model => {
|
nextVisibleModels.forEach(model => {
|
||||||
const view = gfx.view.get(model);
|
const view = gfx.view.get(model);
|
||||||
if (isGfxBlockComponent(view)) {
|
if (isGfxBlockComponent(view)) {
|
||||||
view.transformState$.value = 'active';
|
view.transformState$.value = 'active';
|
||||||
@@ -92,7 +95,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this._lastVisibleModels = modelsInViewport;
|
this._lastVisibleModels = nextVisibleModels;
|
||||||
};
|
};
|
||||||
|
|
||||||
private _lastVisibleModels?: Set<GfxBlockElementModel>;
|
private _lastVisibleModels?: Set<GfxBlockElementModel>;
|
||||||
@@ -103,7 +106,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
|||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
private readonly _refreshViewport = requestThrottledConnectedFrame(() => {
|
private readonly _refreshViewport = requestThrottledConnectedFrame(() => {
|
||||||
this._hideOutsideBlock();
|
this._hideOutsideNoEditingBlock();
|
||||||
}, this);
|
}, this);
|
||||||
|
|
||||||
private _updatingChildrenFlag = false;
|
private _updatingChildrenFlag = false;
|
||||||
@@ -119,7 +122,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
|||||||
delete this.scheduleUpdateChildren;
|
delete this.scheduleUpdateChildren;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._hideOutsideBlock();
|
this._hideOutsideNoEditingBlock();
|
||||||
this.disposables.add(
|
this.disposables.add(
|
||||||
this.viewport.viewportUpdated.subscribe(() => viewportUpdateCallback())
|
this.viewport.viewportUpdated.subscribe(() => viewportUpdateCallback())
|
||||||
);
|
);
|
||||||
@@ -166,6 +169,18 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
|||||||
return promise;
|
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 })
|
@property({ attribute: false })
|
||||||
accessor getModelsInViewport: () => Set<GfxBlockElementModel> = () =>
|
accessor getModelsInViewport: () => Set<GfxBlockElementModel> = () =>
|
||||||
new Set();
|
new Set();
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import {
|
|||||||
dragBetweenViewCoords,
|
dragBetweenViewCoords,
|
||||||
getSelectedBound,
|
getSelectedBound,
|
||||||
getSelectedBoundCount,
|
getSelectedBoundCount,
|
||||||
|
getViewportCenter,
|
||||||
locatorComponentToolbar,
|
locatorComponentToolbar,
|
||||||
locatorEdgelessZoomToolButton,
|
locatorEdgelessZoomToolButton,
|
||||||
|
resizeElementByHandle,
|
||||||
selectNoteInEdgeless,
|
selectNoteInEdgeless,
|
||||||
setEdgelessTool,
|
setEdgelessTool,
|
||||||
|
setViewportCenter,
|
||||||
switchEditorMode,
|
switchEditorMode,
|
||||||
triggerComponentToolbarAction,
|
triggerComponentToolbarAction,
|
||||||
zoomOutByKeyboard,
|
zoomOutByKeyboard,
|
||||||
@@ -34,6 +37,7 @@ import {
|
|||||||
pressArrowUp,
|
pressArrowUp,
|
||||||
pressBackspace,
|
pressBackspace,
|
||||||
pressEnter,
|
pressEnter,
|
||||||
|
pressEscape,
|
||||||
pressTab,
|
pressTab,
|
||||||
selectAllByKeyboard,
|
selectAllByKeyboard,
|
||||||
type,
|
type,
|
||||||
@@ -576,3 +580,111 @@ test('should not select doc only note', async ({ page }) => {
|
|||||||
);
|
);
|
||||||
expect(await getSelectedBoundCount(page)).toBe(0);
|
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('%', ''));
|
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(
|
export async function optionMouseDrag(
|
||||||
page: Page,
|
page: Page,
|
||||||
start: number[],
|
start: number[],
|
||||||
|
|||||||
Reference in New Issue
Block a user