fix(editor): wrong position of remote selection and at menu in edgeless (#10137)

Close [BS-2552](https://linear.app/affine-design/issue/BS-2552/menu-loading-时滚动,定位错误), [BS-2490](https://linear.app/affine-design/issue/BS-2490/note-block-的menu的输入阴影错位), [BS-2300](https://linear.app/affine-design/issue/BS-2300/at-menu的输入阴影在暗黑模式看不见)

### What  Changes
- fix the position of remote selection mask and @ menu input mask in edgeless
- fix the position of @ menu is no updated during edgeless viewport change
- update @ menu mask color in dark mode

### Before
https://github.com/user-attachments/assets/f44f618e-a791-497a-9f53-74824fe48dea

### After
https://github.com/user-attachments/assets/5d87b999-deae-4435-9b8b-4cdf55393395
This commit is contained in:
L-Sun
2025-02-13 01:56:00 +00:00
parent f0a99851aa
commit 9a17422d36
7 changed files with 160 additions and 114 deletions

View File

@@ -70,6 +70,7 @@ export function compareTopAndBottomSpace(
/**
* Get the position of the popper element with flip.
* return null if the reference rect is all zero.
*/
export function getPopperPosition(
popper: {
@@ -93,6 +94,14 @@ export function getPopperPosition(
);
const referenceRect = reference.getBoundingClientRect();
if (
referenceRect.x === 0 &&
referenceRect.y === 0 &&
referenceRect.width === 0 &&
referenceRect.height === 0
)
return null;
const positioningPoint = {
x: referenceRect.x,
y: referenceRect.y + (placement === 'bottom' ? referenceRect.height : 0),

View File

@@ -7,6 +7,7 @@ import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { BLOCK_ID_ATTR, WidgetComponent } from '@blocksuite/block-std';
import { GfxController } from '@blocksuite/block-std/gfx';
import { IS_MOBILE } from '@blocksuite/global/env';
import {
INLINE_ROOT_ATTR,
@@ -15,7 +16,6 @@ import {
} from '@blocksuite/inline';
import { signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
@@ -37,6 +37,40 @@ export class AffineLinkedDocWidget extends WidgetComponent<
> {
static override styles = linkedDocWidgetStyles;
private _context: LinkedDocContext | null = null;
private readonly _inputRects$ = signal<SelectionRect[]>([]);
private readonly _mode$ = signal<'desktop' | 'mobile' | 'none'>('none');
private _updateInputRects() {
if (!this._context) return;
const { inlineEditor, startRange, triggerKey } = this._context;
const currentInlineRange = inlineEditor.getInlineRange();
if (!currentInlineRange) return;
const startIndex = startRange.index - triggerKey.length;
const range = inlineEditor.toDomRange({
index: startIndex,
length: currentInlineRange.index - startIndex,
});
if (!range) return;
this._inputRects$.value = getRangeRects(
range,
getViewportElement(this.host)
);
}
private get _isCursorAtEnd() {
if (!this._context) return false;
const { inlineEditor } = this._context;
const currentInlineRange = inlineEditor.getInlineRange();
if (!currentInlineRange) return false;
return currentInlineRange.index === inlineEditor.yTextLength;
}
private readonly _renderLinkedDocMenu = () => {
if (!this.block.rootComponent) return nothing;
@@ -52,13 +86,13 @@ export class AffineLinkedDocWidget extends WidgetComponent<
></affine-linked-doc-popover>`;
};
@state()
private accessor _inputRects: SelectionRect[] = [];
private _renderInputMask() {
return html`${repeat(
this._inputRects,
this._inputRects$.value,
({ top, left, width, height }, index) => {
const last = index === this._inputRects.length - 1;
const last =
index === this._inputRects$.value.length - 1 && this._isCursorAtEnd;
const padding = 2;
return html`<div
class="input-mask"
@@ -73,29 +107,7 @@ export class AffineLinkedDocWidget extends WidgetComponent<
)}`;
}
private readonly _mode$ = signal<'desktop' | 'mobile' | 'none'>('none');
get config(): LinkedWidgetConfig {
return {
triggerKeys: ['@', '[[', '【【'],
ignoreBlockTypes: ['affine:code'],
ignoreSelector:
'edgeless-text-editor, edgeless-shape-text-editor, edgeless-group-title-editor, edgeless-frame-title-editor, edgeless-connector-label-editor',
convertTriggerKey: true,
getMenus,
mobile: {
useScreenHeight: false,
scrollContainer: getViewportElement(this.std.host) ?? window,
scrollTopOffset: 46,
},
...this.std.getConfig('affine:page')?.linkedWidget,
};
}
private _context: LinkedDocContext | null = null;
override connectedCallback() {
super.connectedCallback();
private _watchInput() {
this.handleEvent('beforeInput', ctx => {
if (this._mode$.peek() !== 'none') return;
@@ -184,6 +196,40 @@ export class AffineLinkedDocWidget extends WidgetComponent<
});
}
private _watchViewportChange() {
const gfx = this.std.getOptional(GfxController);
if (!gfx) return;
this.disposables.add(
gfx.viewport.viewportUpdated.on(() => {
this._updateInputRects();
})
);
}
get config(): LinkedWidgetConfig {
return {
triggerKeys: ['@', '[[', '【【'],
ignoreBlockTypes: ['affine:code'],
ignoreSelector:
'edgeless-text-editor, edgeless-shape-text-editor, edgeless-group-title-editor, edgeless-frame-title-editor, edgeless-connector-label-editor',
convertTriggerKey: true,
getMenus,
mobile: {
useScreenHeight: false,
scrollContainer: getViewportElement(this.std.host) ?? window,
scrollTopOffset: 46,
},
...this.std.getConfig('affine:page')?.linkedWidget,
};
}
override connectedCallback() {
super.connectedCallback();
this._watchInput();
this._watchViewportChange();
}
show(props?: {
inlineEditor?: InlineEditor;
primaryTriggerKey?: string;
@@ -229,14 +275,7 @@ export class AffineLinkedDocWidget extends WidgetComponent<
}
const disposable = inlineEditor.slots.renderComplete.on(() => {
const currentInlineRange = inlineEditor.getInlineRange();
if (!currentInlineRange) return;
const range = inlineEditor.toDomRange({
index: inlineRange.index,
length: currentInlineRange.index - inlineRange.index,
});
if (!range) return;
this._inputRects = getRangeRects(range, getViewportElement(host));
this._updateInputRects();
});
this._context = {
std: this.std,
@@ -246,12 +285,14 @@ export class AffineLinkedDocWidget extends WidgetComponent<
config: this.config,
close: () => {
disposable.dispose();
this._inputRects = [];
this._inputRects$.value = [];
this._mode$.value = 'none';
this._context = null;
},
};
this._updateInputRects();
const enableMobile = this.doc
.get(FeatureFlagService)
.getFlag('enable_mobile_linked_doc_menu');

View File

@@ -12,6 +12,7 @@ import {
getViewportElement,
} from '@blocksuite/affine-shared/utils';
import { PropTypes, requiredProperties } from '@blocksuite/block-std';
import { GfxController } from '@blocksuite/block-std/gfx';
import {
SignalWatcher,
throttle,
@@ -317,18 +318,13 @@ export class LinkedDocPopover extends SignalWatcher(
</div>`;
}
updatePosition(position: { height: number; x: string; y: string }) {
this._position = position;
}
override willUpdate() {
if (!this.hasUpdated) {
const curRange = getCurrentNativeRange();
if (!curRange) return;
const updatePosition = throttle(() => {
const position = getPopperPosition(this, curRange);
this.updatePosition(position);
this._position = getPopperPosition(this, curRange);
}, 10);
this.disposables.addFromEvent(window, 'resize', updatePosition);
@@ -344,6 +340,12 @@ export class LinkedDocPopover extends SignalWatcher(
}
);
}
const gfx = this.context.std.getOptional(GfxController);
if (gfx) {
this.disposables.add(gfx.viewport.viewportUpdated.on(updatePosition));
}
updatePosition();
}
}

View File

@@ -2,20 +2,16 @@ import {
type AffineInlineEditor,
getInlineEditorByModel,
} from '@blocksuite/affine-components/rich-text';
import { getCurrentNativeRange } from '@blocksuite/affine-shared/utils';
import type { UIEventStateContext } from '@blocksuite/block-std';
import { TextSelection, WidgetComponent } from '@blocksuite/block-std';
import {
assertExists,
assertType,
debounce,
DisposableGroup,
throttle,
} from '@blocksuite/global/utils';
import { InlineEditor } from '@blocksuite/inline';
import type { RootBlockComponent } from '../../types.js';
import { getPopperPosition } from '../../utils/position.js';
import {
defaultSlashMenuConfig,
type SlashMenuActionItem,
@@ -56,9 +52,6 @@ const showSlashMenu = debounce(
config: SlashMenuStaticConfig;
triggerKey: string;
}) => {
const curRange = getCurrentNativeRange();
if (!curRange) return;
globalAbortController = abortController;
const disposables = new DisposableGroup();
abortController.signal.addEventListener('abort', () =>
@@ -76,25 +69,10 @@ const showSlashMenu = debounce(
slashMenu.config = config;
slashMenu.triggerKey = triggerKey;
// Handle position
const updatePosition = throttle(() => {
const slashMenuElement = slashMenu.slashMenuElement;
assertExists(
slashMenuElement,
'You should render the slash menu node even if no position'
);
const position = getPopperPosition(slashMenuElement, curRange);
slashMenu.updatePosition(position);
}, 10);
disposables.addFromEvent(window, 'resize', updatePosition);
// FIXME(Flrande): It is not a best practice,
// but merely a temporary measure for reusing previous components.
// Mount
container.append(slashMenu);
// Wait for the Node to be mounted
setTimeout(updatePosition);
return slashMenu;
},
100

View File

@@ -8,17 +8,23 @@ import {
} from '@blocksuite/affine-components/rich-text';
import {
createKeydownObserver,
getCurrentNativeRange,
isControlledKeyboardEvent,
isFuzzyMatch,
substringMatchScore,
} from '@blocksuite/affine-shared/utils';
import { assertExists, WithDisposable } from '@blocksuite/global/utils';
import {
assertExists,
throttle,
WithDisposable,
} from '@blocksuite/global/utils';
import { autoPlacement, offset } from '@floating-ui/dom';
import { html, LitElement, nothing, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { getPopperPosition } from '../../utils/position.js';
import type {
SlashMenuActionItem,
SlashMenuContext,
@@ -139,10 +145,6 @@ export class SlashMenu extends WithDisposable(LitElement) {
this._queryState = this._filteredItems.length === 0 ? 'no_result' : 'on';
};
updatePosition = (position: { x: string; y: string; height: number }) => {
this._position = position;
};
private get _query() {
return getTextContentFromInlineRange(this.inlineEditor, this._startRange);
}
@@ -247,6 +249,24 @@ export class SlashMenu extends WithDisposable(LitElement) {
});
}
protected override willUpdate() {
if (!this.hasUpdated) {
const currRage = getCurrentNativeRange();
if (!currRage) {
this.abortController.abort();
return;
}
// Handle position
const updatePosition = throttle(() => {
this._position = getPopperPosition(this, currRage);
}, 10);
this.disposables.addFromEvent(window, 'resize', updatePosition);
updatePosition();
}
}
override render() {
const slashMenuStyles = this._position
? {
@@ -291,9 +311,6 @@ export class SlashMenu extends WithDisposable(LitElement) {
@property({ attribute: false })
accessor context!: SlashMenuContext;
@query('inner-slash-menu')
accessor slashMenuElement!: HTMLElement;
@property({ attribute: false })
accessor triggerKey!: string;
}