refactor(editor): extract ai item component (#9283)

This commit is contained in:
Saul-Mirone
2024-12-24 09:41:45 +00:00
parent 190e7e6f30
commit cd830d6f81
19 changed files with 46 additions and 36 deletions

View File

@@ -1,150 +0,0 @@
import { createLitPortal } from '@blocksuite/affine-components/portal';
import {
EditorHost,
PropTypes,
requiredProperties,
} from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { flip, offset } from '@floating-ui/dom';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { AIItem } from './ai-item.js';
import {
SUBMENU_OFFSET_CROSS_AXIS,
SUBMENU_OFFSET_MAIN_AXIS,
} from './const.js';
import type { AIItemConfig, AIItemGroupConfig } from './types.js';
@requiredProperties({ host: PropTypes.instanceOf(EditorHost) })
export class AIItemList extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
user-select: none;
}
.group-name {
display: flex;
padding: 4px calc(var(--item-padding, 8px) + 4px);
align-items: center;
color: var(--affine-text-secondary-color);
text-align: justify;
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 500;
line-height: 20px;
width: 100%;
box-sizing: border-box;
}
`;
private _abortController: AbortController | null = null;
private _activeSubMenuItem: AIItemConfig | null = null;
private readonly _closeSubMenu = () => {
if (this._abortController) {
this._abortController.abort();
this._abortController = null;
}
this._activeSubMenuItem = null;
};
private readonly _itemClassName = (item: AIItemConfig) => {
return 'ai-item-' + item.name.split(' ').join('-').toLocaleLowerCase();
};
private readonly _openSubMenu = (item: AIItemConfig) => {
if (!item.subItem || item.subItem.length === 0) {
this._closeSubMenu();
return;
}
if (item === this._activeSubMenuItem) {
return;
}
const aiItem = this.shadowRoot?.querySelector(
`.${this._itemClassName(item)}`
) as AIItem | null;
if (!aiItem || !aiItem.menuItem) return;
this._closeSubMenu();
this._activeSubMenuItem = item;
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this._closeSubMenu();
});
const aiItemContainer = aiItem.menuItem;
const subMenuOffset = {
mainAxis: item.subItemOffset?.[0] ?? SUBMENU_OFFSET_MAIN_AXIS,
crossAxis: item.subItemOffset?.[1] ?? SUBMENU_OFFSET_CROSS_AXIS,
};
createLitPortal({
template: html`<ai-sub-item-list
.item=${item}
.host=${this.host}
.onClick=${this.onClick}
.abortController=${this._abortController}
></ai-sub-item-list>`,
container: aiItemContainer,
positionStrategy: 'fixed',
computePosition: {
referenceElement: aiItemContainer,
placement: 'right-start',
middleware: [flip(), offset(subMenuOffset)],
autoUpdate: true,
},
abortController: this._abortController,
closeOnClickAway: true,
});
};
override render() {
return html`${repeat(this.groups, group => {
return html`
${group.name
? html`<div class="group-name">
${group.name.toLocaleUpperCase()}
</div>`
: nothing}
${repeat(
group.items,
item =>
html`<ai-item
.onClick=${this.onClick}
.item=${item}
.host=${this.host}
class=${this._itemClassName(item)}
@mouseover=${() => {
this._openSubMenu(item);
}}
></ai-item>`
)}
`;
})}`;
}
@property({ attribute: false })
accessor groups: AIItemGroupConfig[] = [];
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor onClick: (() => void) | undefined = undefined;
}
declare global {
interface HTMLElementTagNameMap {
'ai-item-list': AIItemList;
}
}

View File

@@ -1,66 +0,0 @@
import { ArrowRightIcon, EnterIcon } from '@blocksuite/affine-components/icons';
import {
EditorHost,
PropTypes,
requiredProperties,
} from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { menuItemStyles } from './styles.js';
import type { AIItemConfig } from './types.js';
@requiredProperties({
host: PropTypes.instanceOf(EditorHost),
item: PropTypes.object,
})
export class AIItem extends WithDisposable(LitElement) {
static override styles = css`
${menuItemStyles}
`;
override render() {
const { item } = this;
const className = item.name.split(' ').join('-').toLocaleLowerCase();
return html`<div
class="menu-item ${className}"
@pointerdown=${(e: MouseEvent) => e.stopPropagation()}
@click=${() => {
this.onClick?.();
if (typeof item.handler === 'function') {
item.handler(this.host);
}
}}
>
<span class="item-icon">${item.icon}</span>
<div class="item-name">
${item.name}${item.beta
? html`<div class="item-beta">(Beta)</div>`
: nothing}
</div>
${item.subItem
? html`<span class="arrow-right-icon">${ArrowRightIcon}</span>`
: html`<span class="enter-icon">${EnterIcon}</span>`}
</div>`;
}
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor item!: AIItemConfig;
@query('.menu-item')
accessor menuItem: HTMLDivElement | null = null;
@property({ attribute: false })
accessor onClick: (() => void) | undefined;
}
declare global {
interface HTMLElementTagNameMap {
'ai-item': AIItem;
}
}

View File

@@ -1,90 +0,0 @@
import { EnterIcon } from '@blocksuite/affine-components/icons';
import {
EditorHost,
PropTypes,
requiredProperties,
} from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { menuItemStyles } from './styles.js';
import type { AIItemConfig, AISubItemConfig } from './types.js';
@requiredProperties({
host: PropTypes.instanceOf(EditorHost),
item: PropTypes.object,
})
export class AISubItemList extends WithDisposable(LitElement) {
static override styles = css`
.ai-sub-menu {
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 8px;
min-width: 240px;
max-height: 320px;
overflow-y: auto;
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
border-radius: 8px;
z-index: var(--affine-z-index-popover);
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
color: var(--affine-text-primary-color);
text-align: justify;
font-feature-settings:
'clig' off,
'liga' off;
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
line-height: 22px;
user-select: none;
}
${menuItemStyles}
`;
private readonly _handleClick = (subItem: AISubItemConfig) => {
this.onClick?.();
if (subItem.handler) {
// TODO: add parameters to ai handler
subItem.handler(this.host);
}
this.abortController.abort();
};
override render() {
if (!this.item.subItem || this.item.subItem.length <= 0) return nothing;
return html`<div class="ai-sub-menu">
${this.item.subItem?.map(
subItem =>
html`<div
class="menu-item"
@click=${() => this._handleClick(subItem)}
>
<div class="item-name">${subItem.type}</div>
<span class="enter-icon">${EnterIcon}</span>
</div>`
)}
</div>`;
}
@property({ attribute: false })
accessor abortController: AbortController = new AbortController();
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor item!: AIItemConfig;
@property({ attribute: false })
accessor onClick: (() => void) | undefined;
}
declare global {
interface HTMLElementTagNameMap {
'ai-sub-item-list': AISubItemList;
}
}

View File

@@ -1,2 +0,0 @@
export const SUBMENU_OFFSET_MAIN_AXIS = 12;
export const SUBMENU_OFFSET_CROSS_AXIS = -60;

View File

@@ -1,2 +0,0 @@
export * from './ai-item-list.js';
export * from './types.js';

View File

@@ -1,71 +0,0 @@
import { css } from 'lit';
export const menuItemStyles = css`
.menu-item {
position: relative;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
padding: 4px var(--item-padding, 12px);
gap: 4px;
align-self: stretch;
border-radius: 4px;
box-sizing: border-box;
}
.menu-item:hover {
background: var(--affine-hover-color);
cursor: pointer;
}
.item-icon {
display: flex;
color: var(--item-icon-color, var(--affine-brand-color));
}
.menu-item:hover .item-icon {
color: var(--item-icon-hover-color, var(--affine-brand-color));
}
.menu-item.discard:hover {
background: var(--affine-background-error-color);
.item-name,
.item-icon,
.enter-icon {
color: var(--affine-error-color);
}
}
.item-name {
display: flex;
padding: 0px 4px;
align-items: baseline;
flex: 1 0 0;
color: var(--affine-text-primary-color);
text-align: start;
white-space: nowrap;
font-feature-settings:
'clig' off,
'liga' off;
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
line-height: 22px;
}
.item-beta {
color: var(--affine-text-secondary-color);
font-size: var(--affine-font-xs);
font-weight: 500;
margin-left: 0.5em;
}
.enter-icon,
.arrow-right-icon {
color: var(--affine-icon-color);
display: flex;
}
.enter-icon {
opacity: 0;
}
.arrow-right-icon,
.menu-item:hover .enter-icon {
opacity: 1;
}
`;

View File

@@ -1,68 +0,0 @@
import type { DocMode } from '@blocksuite/affine-model';
import type { Chain, EditorHost, InitCommandCtx } from '@blocksuite/block-std';
import type { TemplateResult } from 'lit';
export interface AIItemGroupConfig {
name?: string;
items: AIItemConfig[];
}
export interface AIItemConfig {
name: string;
icon: TemplateResult | (() => HTMLElement);
showWhen?: (
chain: Chain<InitCommandCtx>,
editorMode: DocMode,
host: EditorHost
) => boolean;
subItem?: AISubItemConfig[];
subItemOffset?: [number, number];
handler?: (host: EditorHost) => void;
beta?: boolean;
}
export interface AISubItemConfig {
type: string;
handler?: (host: EditorHost) => void;
}
abstract class BaseAIError extends Error {
abstract readonly type: AIErrorType;
}
export enum AIErrorType {
GeneralNetworkError = 'GeneralNetworkError',
PaymentRequired = 'PaymentRequired',
Unauthorized = 'Unauthorized',
}
export class UnauthorizedError extends BaseAIError {
readonly type = AIErrorType.Unauthorized;
constructor() {
super('Unauthorized');
}
}
// user has used up the quota
export class PaymentRequiredError extends BaseAIError {
readonly type = AIErrorType.PaymentRequired;
constructor() {
super('Payment required');
}
}
// general 500x error
export class GeneralNetworkError extends BaseAIError {
readonly type = AIErrorType.GeneralNetworkError;
constructor(message: string = 'Network error') {
super(message);
}
}
export type AIError =
| UnauthorizedError
| PaymentRequiredError
| GeneralNetworkError;

View File

@@ -1 +0,0 @@
export * from './ai-item/index.js';