Files
AFFiNE/blocksuite/blocks/src/root-block/widgets/linked-doc/linked-doc-popover.ts

376 lines
11 KiB
TypeScript

import { LoadingIcon } from '@blocksuite/affine-block-image';
import type { IconButton } from '@blocksuite/affine-components/icon-button';
import { MoreHorizontalIcon } from '@blocksuite/affine-components/icons';
import {
cleanSpecifiedTail,
getTextContentFromInlineRange,
} from '@blocksuite/affine-components/rich-text';
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
import {
createKeydownObserver,
getCurrentNativeRange,
getViewportElement,
} from '@blocksuite/affine-shared/utils';
import { PropTypes, requiredProperties } from '@blocksuite/block-std';
import {
SignalWatcher,
throttle,
WithDisposable,
} from '@blocksuite/global/utils';
import { effect } from '@preact/signals-core';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, queryAll, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { getPopperPosition } from '../../utils/position.js';
import type { LinkedDocContext, LinkedMenuGroup } from './config.js';
import { linkedDocPopoverStyles } from './styles.js';
import { resolveSignal } from './utils.js';
@requiredProperties({
context: PropTypes.object,
})
export class LinkedDocPopover extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = linkedDocPopoverStyles;
private readonly _abort = () => {
// remove popover dom
this.context.close();
// clear input query
cleanSpecifiedTail(
this.context.std.host,
this.context.inlineEditor,
this.context.triggerKey + (this._query || '')
);
};
private readonly _expanded = new Map<string, boolean>();
private _menusItemsEffectCleanup: () => void = () => {};
private readonly _updateLinkedDocGroup = async () => {
const query = this._query;
if (this._updateLinkedDocGroupAbortController) {
this._updateLinkedDocGroupAbortController.abort();
}
this._updateLinkedDocGroupAbortController = new AbortController();
if (query === null) {
this.context.close();
return;
}
this._linkedDocGroup = await this.context.config.getMenus(
query,
this._abort,
this.context.std.host,
this.context.inlineEditor,
this._updateLinkedDocGroupAbortController.signal
);
this._menusItemsEffectCleanup();
// need to rebind the effect because this._linkedDocGroup has changed.
this._menusItemsEffectCleanup = effect(() => {
this._updateAutoFocusedItem();
});
};
private readonly _updateAutoFocusedItem = () => {
if (!this._query) {
return;
}
const autoFocusedItem = this.context.config.autoFocusedItem?.(
this._linkedDocGroup,
this._query,
this.context.std.host,
this.context.inlineEditor
);
if (autoFocusedItem) {
this._activatedItemIndex = this._flattenActionList.findIndex(
item => item.key === autoFocusedItem.key
);
}
};
private _updateLinkedDocGroupAbortController: AbortController | null = null;
private get _actionGroup() {
return this._linkedDocGroup.map(group => {
return {
...group,
items: this._getActionItems(group),
};
});
}
private get _flattenActionList() {
return this._actionGroup
.map(group =>
group.items.map(item => ({ ...item, groupName: group.name }))
)
.flat();
}
private get _query() {
return getTextContentFromInlineRange(
this.context.inlineEditor,
this.context.startRange
);
}
private _getActionItems(group: LinkedMenuGroup) {
const isExpanded = !!this._expanded.get(group.name);
let items = resolveSignal(group.items);
const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay;
const isLoading = resolveSignal(group.loading);
items = isExpanded ? items : items.slice(0, group.maxDisplay);
if (isLoading) {
items = items.concat({
key: 'loading',
name: resolveSignal(group.loadingText) || 'loading',
icon: LoadingIcon,
action: () => {},
});
}
if (isOverflow && !isExpanded && group.maxDisplay) {
items = items.concat({
key: `${group.name} More`,
name: resolveSignal(group.overflowText) || 'more',
icon: MoreHorizontalIcon,
action: () => {
this._expanded.set(group.name, true);
this.requestUpdate();
},
});
}
return items;
}
private _isTextOverflowing(element: HTMLElement) {
return element.scrollWidth > element.clientWidth;
}
override connectedCallback() {
super.connectedCallback();
// init
this._updateLinkedDocGroup().catch(console.error);
this._disposables.addFromEvent(this, 'mousedown', e => {
// Prevent input from losing focus
e.preventDefault();
});
this._disposables.addFromEvent(window, 'mousedown', e => {
if (e.target === this) return;
// We don't clear the query when clicking outside the popover
this.context.close();
});
const keydownObserverAbortController = new AbortController();
this._disposables.add(() => keydownObserverAbortController.abort());
const { eventSource } = this.context.inlineEditor;
if (!eventSource) return;
createKeydownObserver({
target: eventSource,
signal: keydownObserverAbortController.signal,
onInput: isComposition => {
this._activatedItemIndex = 0;
if (isComposition) {
this._updateLinkedDocGroup().catch(console.error);
} else {
this.context.inlineEditor.slots.renderComplete.once(
this._updateLinkedDocGroup
);
}
},
onPaste: () => {
this._activatedItemIndex = 0;
setTimeout(() => {
this._updateLinkedDocGroup().catch(console.error);
}, 50);
},
onDelete: () => {
const curRange = this.context.inlineEditor.getInlineRange();
if (!this.context.startRange || !curRange) {
return;
}
if (curRange.index < this.context.startRange.index) {
this.context.close();
}
this._activatedItemIndex = 0;
this.context.inlineEditor.slots.renderComplete.once(
this._updateLinkedDocGroup
);
},
onMove: step => {
const itemLen = this._flattenActionList.length;
this._activatedItemIndex =
(itemLen + this._activatedItemIndex + step) % itemLen;
// Scroll to the active item
const item = this._flattenActionList[this._activatedItemIndex];
const shadowRoot = this.shadowRoot;
if (!shadowRoot) {
console.warn('Failed to find the shadow root!', this);
return;
}
const ele = shadowRoot.querySelector(
`icon-button[data-id="${item.key}"]`
);
if (!ele) {
console.warn('Failed to find the active item!', item);
return;
}
ele.scrollIntoView({
block: 'nearest',
});
},
onConfirm: () => {
this._flattenActionList[this._activatedItemIndex]
.action()
?.catch(console.error);
},
onAbort: () => {
this.context.close();
},
});
}
override disconnectedCallback() {
super.disconnectedCallback();
this._menusItemsEffectCleanup();
}
override render() {
const MAX_HEIGHT = 380;
const style = this._position
? styleMap({
transform: `translate(${this._position.x}, ${this._position.y})`,
maxHeight: `${Math.min(this._position.height, MAX_HEIGHT)}px`,
})
: styleMap({
visibility: 'hidden',
});
// XXX This is a side effect
let accIdx = 0;
return html`<div class="linked-doc-popover" style="${style}">
${this._actionGroup
.filter(group => group.items.length)
.map((group, idx) => {
return html`
<div class="divider" ?hidden=${idx === 0}></div>
<div class="group-title">${group.name}</div>
<div class="group" style=${group.styles ?? ''}>
${group.items.map(({ key, name, icon, action }) => {
accIdx++;
const curIdx = accIdx - 1;
const tooltip = this._showTooltip
? html`<affine-tooltip
tip-position=${'right'}
.tooltipStyle=${css`
* {
color: ${unsafeCSSVar('white')} !important;
}
`}
>${name}</affine-tooltip
>`
: nothing;
return html`<icon-button
width="280px"
height="30px"
data-id=${key}
.text=${name}
hover=${this._activatedItemIndex === curIdx}
@click=${() => {
action()?.catch(console.error);
}}
@mousemove=${() => {
// Use `mousemove` instead of `mouseover` to avoid navigate conflict with keyboard
this._activatedItemIndex = curIdx;
// show tooltip whether text length overflows
for (const button of this.iconButtons.values()) {
if (button.dataset.id == key && button.textElement) {
const isOverflowing = this._isTextOverflowing(
button.textElement
);
this._showTooltip = isOverflowing;
break;
}
}
}}
>
${icon} ${tooltip}
</icon-button>`;
})}
</div>
`;
})}
</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);
}, 10);
this.disposables.addFromEvent(window, 'resize', updatePosition);
const scrollContainer = getViewportElement(this.context.std.host);
if (scrollContainer) {
// Note: in edgeless mode, the scroll container is not exist!
this.disposables.addFromEvent(
scrollContainer,
'scroll',
updatePosition,
{
passive: true,
}
);
}
updatePosition();
}
}
@state()
private accessor _activatedItemIndex = 0;
@state()
private accessor _linkedDocGroup: LinkedMenuGroup[] = [];
@state()
private accessor _position: {
height: number;
x: string;
y: string;
} | null = null;
@state()
private accessor _showTooltip = false;
@property({ attribute: false })
accessor context!: LinkedDocContext;
@queryAll('icon-button')
accessor iconButtons!: NodeListOf<IconButton>;
@query('.linked-doc-popover')
accessor linkedDocElement: Element | null = null;
}