import { assertExists } from '@blockeditor/global/utils'; import { WithDisposable } from '@blockeditor/lit'; import { type BlockModel } from '@blockeditor/store'; import { html, LitElement, nothing } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import { cleanSpecifiedTail, createKeydownObserver, } from '../../../_common/components/utils.js'; import { getRichTextByModel, isControlledKeyboardEvent, } from '../../../_common/utils/index.js'; import { isFuzzyMatch } from '../../../_common/utils/string.js'; import type { PageBlockComponent } from '../../../page-block/types.js'; import { styles } from './styles.js'; import { collectGroupNames, type InternSlashItem as InternalSlashItem, type SlashItem, type SlashMenuOptions, } from './utils.js'; @customElement('affine-slash-menu') export class SlashMenu extends WithDisposable(LitElement) { static override styles = styles; @property({ attribute: false }) pageElement!: PageBlockComponent; @property({ attribute: false }) model!: BlockModel; @property({ attribute: false }) options!: SlashMenuOptions; @property({ attribute: false }) triggerKey!: string; @query('.slash-menu') slashMenuElement?: HTMLElement; @state() private _leftPanelActivated = false; @state() private _activatedItemIndex = 0; @state() private _filterItems: InternalSlashItem[] = []; @state() private _hide = false; @state() private _position: { x: string; y: string; height: number; } | null = null; abortController = new AbortController(); get host() { return this.pageElement.host; } /** * Does not include the slash character */ private _searchString = ''; override connectedCallback() { super.connectedCallback(); this._disposables.addFromEvent(window, 'mousedown', this._onClickAway); this._disposables.addFromEvent(this, 'mousedown', e => { // Prevent input from losing focus e.preventDefault(); }); this._filterItems = this._updateItem(''); const richText = getRichTextByModel(this.host, this.model); if (!richText) { console.warn( 'Slash Menu may not work properly! No rich text found for model', this.model ); return; } const inlineEditor = richText.inlineEditor; assertExists(inlineEditor, 'RichText InlineEditor not found'); /** * Handle arrow key * * The slash menu will be closed in the following keyboard cases: * - Press the space key * - Press the backspace key and the search string is empty * - Press the escape key * - When the search item is empty, the slash menu will be hidden temporarily, * and if the following key is not the backspace key, the slash menu will be closed */ createKeydownObserver({ target: inlineEditor.eventSource, inlineEditor, abortController: this.abortController, interceptor: (e, next) => { if (e.key === '/') { // Can not stopPropagation here, // otherwise the rich text will not be able to trigger a new the slash menu return; } if (this._hide && e.key !== 'Backspace') { // if the following key is not the backspace key, // the slash menu will be closed this.abortController.abort(); return; } if (e.key === ' ') { this._hide = true; next(); return; } if (this._hide) { this._hide = false; } const isControlled = isControlledKeyboardEvent(e); const isShift = e.shiftKey; if (e.key === 'ArrowLeft' && !isControlled && !isShift) { e.stopPropagation(); e.preventDefault(); // If the left panel is hidden, should not activate it if (this._searchString.length) return; this._leftPanelActivated = true; return; } if (e.key === 'ArrowRight' && !isControlled && !isShift) { e.stopPropagation(); e.preventDefault(); this._leftPanelActivated = false; return; } next(); }, onUpdateQuery: val => { const newFilteredItems = this._updateItem(val); this._filterItems = newFilteredItems; if (!newFilteredItems.length) { this._hide = true; } }, onMove: step => { const configLen = this._filterItems.length; if (this._leftPanelActivated) { const groupNames = collectGroupNames(this._filterItems); const nowGroupIdx = groupNames.findIndex( groupName => groupName === this._filterItems[this._activatedItemIndex].groupName ); const targetGroup = groupNames[ (nowGroupIdx + step + groupNames.length) % groupNames.length ]; this._handleClickCategory(targetGroup); return; } let ejectedCnt = configLen; do { this._activatedItemIndex = (this._activatedItemIndex + step + configLen) % configLen; // Skip disabled items } while ( this._filterItems[this._activatedItemIndex].disabled && // If all items are disabled, the loop will never end ejectedCnt-- ); this._scrollToItem(this._filterItems[this._activatedItemIndex], false); }, onConfirm: () => { this._handleClickItem(this._activatedItemIndex); }, onEsc: () => { this.abortController.abort(); }, }); } updatePosition(position: { x: string; y: string; height: number }) { this._position = position; } // Handle click outside private _onClickAway = () => { // if (e.target === this) return; if (!this._hide) return; // If the slash menu is hidden, click anywhere will close the slash menu this.abortController.abort(); }; private _updateItem(query: string): InternalSlashItem[] { this._searchString = query; this._activatedItemIndex = 0; // Activate the right panel when search string is not empty if (this._leftPanelActivated) { this._leftPanelActivated = false; } const searchStr = this._searchString.toLowerCase(); let allMenus = this.options.menus .map(group => typeof group.items === 'function' ? group .items({ pageElement: this.pageElement, model: this.model }) .map(item => ({ ...item, groupName: group.name })) : group.items.map(item => ({ ...item, groupName: group.name })) ) .flat(); allMenus = allMenus.filter(({ showWhen = () => true }) => showWhen(this.model, this.pageElement) ); if (!searchStr) { return allMenus; } return allMenus.filter(({ name, alias = [] }) => [name, ...alias].some(str => isFuzzyMatch(str, searchStr)) ); } private _scrollToItem(item: SlashItem, force = true) { const shadowRoot = this.shadowRoot; if (!shadowRoot) { return; } const ele = shadowRoot.querySelector(`icon-button[text="${item.name}"]`); if (!ele) { return; } if (force) { // set parameter to `true` to align to top ele.scrollIntoView(true); return; } ele.scrollIntoView({ block: 'nearest', }); } private _handleClickItem(index: number) { if ( this._leftPanelActivated || index < 0 || index >= this._filterItems.length ) { return; } // Need to remove the search string // We must to do clean the slash string before we do the action // Otherwise, the action may change the model and cause the slash string to be changed cleanSpecifiedTail( this.host, this.model, this.triggerKey + this._searchString ); this.abortController.abort(); const { action } = this._filterItems[index]; action({ pageElement: this.pageElement, model: this.model })?.catch( console.error ); } private _handleClickCategory(groupName: string) { const item = this._filterItems.find(item => item.groupName === groupName); if (!item) return; this._scrollToItem(item); this._activatedItemIndex = this._filterItems.findIndex( i => i.name === item.name ); } private _categoryTemplate() { const showCategory = !this._searchString.length; const activatedGroupName = this._filterItems[this._activatedItemIndex]?.groupName; const groups = collectGroupNames(this._filterItems); return html`