import { cleanSpecifiedTail, getTextContentFromInlineRange, } from '@blocksuite/affine-components/rich-text'; import { VirtualKeyboardProvider } from '@blocksuite/affine-shared/services'; import { createKeydownObserver, getViewportElement, } from '@blocksuite/affine-shared/utils'; import { PropTypes, requiredProperties } from '@blocksuite/block-std'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; import { MoreHorizontalIcon } from '@blocksuite/icons/lit'; import { signal } from '@preact/signals-core'; import { html, LitElement, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { join } from 'lit/directives/join.js'; import { repeat } from 'lit/directives/repeat.js'; import { PageRootBlockComponent } from '../../index.js'; import type { LinkedDocContext, LinkedMenuGroup, LinkedMenuItem, } from './config.js'; import { mobileLinkedDocMenuStyles } from './styles.js'; import { resolveSignal } from './utils.js'; export const AFFINE_MOBILE_LINKED_DOC_MENU = 'affine-mobile-linked-doc-menu'; @requiredProperties({ context: PropTypes.object, rootComponent: PropTypes.instanceOf(PageRootBlockComponent), }) export class AffineMobileLinkedDocMenu extends SignalWatcher( WithDisposable(LitElement) ) { static override styles = mobileLinkedDocMenuStyles; private readonly _expand = new Set(); private _firstActionItem: LinkedMenuItem | null = null; private readonly _linkedDocGroup$ = signal([]); private readonly _renderGroup = (group: LinkedMenuGroup) => { let items = resolveSignal(group.items); const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay; const expanded = this._expand.has(group.name); let moreItem = null; if (!expanded && isOverflow) { items = items.slice(0, group.maxDisplay); moreItem = html`
{ this._expand.add(group.name); this.requestUpdate(); }} > ${MoreHorizontalIcon()}
${group.overflowText || 'more'}
`; } return html` ${repeat(items, item => item.key, this._renderItem)} ${moreItem} `; }; private readonly _renderItem = ({ key, name, icon, action, }: LinkedMenuItem) => { return html``; }; private readonly _scrollInputToTop = () => { const { inlineEditor } = this.context; const { scrollContainer, scrollTopOffset } = this.context.config.mobile; let container = null; let containerScrollTop = 0; if (typeof scrollContainer === 'string') { container = document.querySelector(scrollContainer); containerScrollTop = container?.scrollTop ?? 0; } else if (scrollContainer instanceof HTMLElement) { container = scrollContainer; containerScrollTop = scrollContainer.scrollTop; } else if (scrollContainer === window) { container = window; containerScrollTop = scrollContainer.scrollY; } else { container = getViewportElement(this.context.std.host); containerScrollTop = container?.scrollTop ?? 0; } let offset = 0; if (typeof scrollTopOffset === 'function') { offset = scrollTopOffset(); } else { offset = scrollTopOffset ?? 0; } if (!inlineEditor.rootElement || !container) return; container.scrollTo({ top: inlineEditor.rootElement.getBoundingClientRect().top + containerScrollTop - offset, behavior: 'smooth', }); }; private readonly _updateLinkedDocGroup = async () => { if (this._updateLinkedDocGroupAbortController) { this._updateLinkedDocGroupAbortController.abort(); } this._updateLinkedDocGroupAbortController = new AbortController(); this._linkedDocGroup$.value = await this.context.config.getMenus( this._query ?? '', () => { this.context.close(); cleanSpecifiedTail( this.context.std.host, this.context.inlineEditor, this.context.triggerKey + (this._query ?? '') ); }, this.context.std.host, this.context.inlineEditor, this._updateLinkedDocGroupAbortController.signal ); }; private _updateLinkedDocGroupAbortController: AbortController | null = null; private get _query() { return getTextContentFromInlineRange( this.context.inlineEditor, this.context.startRange ); } get keyboard() { return this.context.std.get(VirtualKeyboardProvider); } override connectedCallback() { super.connectedCallback(); const { inlineEditor, close } = this.context; this._updateLinkedDocGroup().catch(console.error); // prevent editor blur when click menu this._disposables.addFromEvent(this, 'pointerdown', e => { e.preventDefault(); }); // close menu when click outside this.disposables.addFromEvent( window, 'pointerdown', e => { if (e.target === this) return; close(); }, true ); // bind some key events { const { eventSource } = inlineEditor; if (!eventSource) return; const keydownObserverAbortController = new AbortController(); this._disposables.add(() => keydownObserverAbortController.abort()); createKeydownObserver({ target: eventSource, signal: keydownObserverAbortController.signal, onInput: isComposition => { if (isComposition) { this._updateLinkedDocGroup().catch(console.error); } else { inlineEditor.slots.renderComplete.once(this._updateLinkedDocGroup); } }, onDelete: () => { inlineEditor.slots.renderComplete.once(() => { const curRange = inlineEditor.getInlineRange(); if (!this.context.startRange || !curRange) return; if (curRange.index < this.context.startRange.index) { this.context.close(); } this._updateLinkedDocGroup().catch(console.error); }); }, onConfirm: () => { this._firstActionItem?.action()?.catch(console.error); }, onAbort: () => { this.context.close(); }, }); } } override firstUpdated() { if (!this.keyboard.visible$.value) { this.keyboard.show(); } this._scrollInputToTop(); } override render() { const groups = this._linkedDocGroup$.value; if (groups.length === 0) { return nothing; } this._firstActionItem = resolveSignal(groups[0].items)[0]; this.style.bottom = `${this.keyboard.height$.value}px`; return html` ${join(groups.map(this._renderGroup), html`
`)} `; } @property({ attribute: false }) accessor context!: LinkedDocContext; @property({ attribute: false }) accessor rootComponent!: PageRootBlockComponent; }