Files
AFFiNE-Mirror/blocksuite/affine/block-root/src/widgets/linked-doc/mobile-linked-doc-menu.ts

252 lines
7.0 KiB
TypeScript

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<string>();
private _firstActionItem: LinkedMenuItem | null = null;
private readonly _linkedDocGroup$ = signal<LinkedMenuGroup[]>([]);
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`<div
class="mobile-linked-doc-menu-item"
@click=${() => {
this._expand.add(group.name);
this.requestUpdate();
}}
>
${MoreHorizontalIcon()}
<div class="text">${group.overflowText || 'more'}</div>
</div>`;
}
return html`
${repeat(items, item => item.key, this._renderItem)} ${moreItem}
`;
};
private readonly _renderItem = ({
key,
name,
icon,
action,
}: LinkedMenuItem) => {
return html`<button
class="mobile-linked-doc-menu-item"
data-id=${key}
@pointerup=${() => {
action()?.catch(console.error);
}}
>
${icon}
<div class="text">${name}</div>
</button>`;
};
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`<div class="divider"></div>`)}
`;
}
@property({ attribute: false })
accessor context!: LinkedDocContext;
@property({ attribute: false })
accessor rootComponent!: PageRootBlockComponent;
}