feat(core): add tags and collections menu item (#10951)

Close [BS-2827](https://linear.app/affine-design/issue/BS-2827).

![截屏2025-03-18 15.22.02.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/18a3cdc1-e0dd-46e6-a113-e9e1ae27c98d.png)
This commit is contained in:
akumatus
2025-03-19 04:22:09 +00:00
parent 7f4b56e05c
commit efe7e1d527
2 changed files with 163 additions and 64 deletions

View File

@@ -1,24 +1,50 @@
import { toast } from '@affine/component';
import { ShadowlessElement } from '@blocksuite/affine/block-std';
import { type LinkedMenuGroup } from '@blocksuite/affine/blocks/root';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
import { openFileOrFiles } from '@blocksuite/affine/shared/utils';
import { SearchIcon, UploadIcon } from '@blocksuite/icons/lit';
import { openFileOrFiles, type Signal } from '@blocksuite/affine/shared/utils';
import {
CollectionsIcon,
SearchIcon,
TagsIcon,
UploadIcon,
} from '@blocksuite/icons/lit';
import type { DocMeta } from '@blocksuite/store';
import { css, html } from 'lit';
import { css, html, type TemplateResult } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import type { DocSearchMenuConfig } from '../chat-config';
import type { ChatChip } from '../chat-context';
enum AddPopoverMode {
Default = 'default',
Tags = 'tags',
Collections = 'collections',
}
export type MenuGroup = {
name: string;
items: MenuItem[] | Signal<MenuItem[]>;
maxDisplay?: number;
};
export type MenuItem = {
key: string;
name: string | TemplateResult<1>;
icon: TemplateResult<1>;
action: MenuAction;
suffix?: string | TemplateResult<1>;
};
export type MenuAction = () => Promise<void> | void;
export class ChatPanelAddPopover extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.add-popover {
width: 280px;
max-height: 240px;
max-height: 450px;
overflow-y: auto;
border: 0.5px solid var(--affine-border-color);
border-radius: 4px;
@@ -76,14 +102,65 @@ export class ChatPanelAddPopover extends SignalWatcher(
private accessor _query = '';
@state()
private accessor _docGroup: LinkedMenuGroup = {
private accessor _docGroup: MenuGroup = {
name: 'No Result',
items: [],
};
private readonly tcGroup: MenuGroup = {
name: 'Tag & Collection',
items: [
{
key: 'tags',
name: 'Tags',
icon: TagsIcon(),
action: () => {
this._mode = AddPopoverMode.Tags;
},
},
{
key: 'collections',
name: 'Collections',
icon: CollectionsIcon(),
action: () => {
this._mode = AddPopoverMode.Collections;
},
},
],
};
private readonly _addFileChip = async () => {
const file = await openFileOrFiles();
if (!file) return;
if (file.size > 50 * 1024 * 1024) {
toast('You can only upload files less than 50MB');
return;
}
this.addChip({
file,
state: 'processing',
});
this.abortController.abort();
};
private readonly uploadGroup: MenuGroup = {
name: 'Upload',
items: [
{
key: 'files',
name: 'Upload files (pdf, txt, csv)',
icon: UploadIcon(),
action: this._addFileChip,
},
],
};
@state()
private accessor _activatedItemIndex = 0;
@state()
private accessor _mode: AddPopoverMode = AddPopoverMode.Default;
@property({ attribute: false })
accessor docSearchMenuConfig!: DocSearchMenuConfig;
@@ -108,68 +185,90 @@ export class ChatPanelAddPopover extends SignalWatcher(
}
override render() {
const items = Array.isArray(this._docGroup.items)
? this._docGroup.items
: this._docGroup.items.value;
const groups = this._getMenuGroup();
return html`<div class="add-popover">
<div class="search-input-wrapper">
${SearchIcon()}
<input
class="search-input"
type="text"
placeholder="Search Doc"
.value=${this._query}
@input=${this._onInput}
/>
</div>
<div class="divider"></div>
<div class="search-group" style=${this._docGroup.styles ?? ''}>
${items.length > 0
? items.map(({ key, name, icon, action }, curIdx) => {
return html`<icon-button
width="280px"
height="30px"
data-id=${key}
.text=${name}
hover=${this._activatedItemIndex === curIdx}
@click=${() => action()?.catch(console.error)}
@mousemove=${() => (this._activatedItemIndex = curIdx)}
>
${icon}
</icon-button>`;
})
: html`<div class="no-result">No Result</div>`}
</div>
<div class="divider"></div>
<div class="upload-wrapper">
<icon-button
width="280px"
height="30px"
data-id="upload"
.text=${'Upload files (pdf, txt, csv)'}
hover=${this._activatedItemIndex === items.length + 1}
@click=${this._addFileChip}
@mousemove=${() => (this._activatedItemIndex = items.length + 1)}
>
${UploadIcon()}
</icon-button>
</div>
${this._renderSearchInput()} ${this._renderDivider()}
${this._renderMenuGroup(groups)}
</div>`;
}
private readonly _addFileChip = async () => {
const file = await openFileOrFiles();
if (!file) return;
if (file.size > 50 * 1024 * 1024) {
toast('You can only upload files less than 50MB');
return;
private _renderSearchInput() {
return html`<div class="search-input-wrapper">
${SearchIcon()}
<input
class="search-input"
type="text"
placeholder=${this._getPlaceholder()}
.value=${this._query}
@input=${this._onInput}
/>
</div>`;
}
private _getPlaceholder() {
switch (this._mode) {
case AddPopoverMode.Tags:
return 'Search Tag';
case AddPopoverMode.Collections:
return 'Search Collection';
default:
return 'Search docs, tags, collections';
}
this.addChip({
file,
state: 'processing',
}
private _renderDivider() {
return html`<div class="divider"></div>`;
}
private _renderMenuGroup(groups: MenuGroup[]) {
let startIndex = 0;
return groups.map((group, idx) => {
const items = Array.isArray(group.items)
? group.items
: group.items.value;
const menuGroup = html`<div class="menu-group">
${this._renderMenuItems(items, startIndex)}
${idx < groups.length - 1 ? this._renderDivider() : ''}
</div>`;
startIndex += items.length;
return menuGroup;
});
this.abortController.abort();
};
}
private _renderMenuItems(items: MenuItem[], startIndex: number) {
return html`<div>
${items.length > 0
? items.map(({ key, name, icon, action }, idx) => {
const curIdx = startIndex + idx;
return html`<icon-button
width="280px"
height="30px"
data-id=${key}
.text=${name}
hover=${this._activatedItemIndex === curIdx}
@click=${() => action()?.catch(console.error)}
@mousemove=${() => (this._activatedItemIndex = curIdx)}
>
${icon}
</icon-button>`;
})
: html`<div class="no-result">No Result</div>`}
</div>`;
}
private _getMenuGroup() {
switch (this._mode) {
case AddPopoverMode.Tags:
return [];
case AddPopoverMode.Collections:
return [];
default:
if (this._query) {
return [this._docGroup, this.uploadGroup];
}
return [this._docGroup, this.tcGroup, this.uploadGroup];
}
}
private _onInput(event: Event) {
this._query = (event.target as HTMLInputElement).value;

View File

@@ -1,6 +1,6 @@
import { ShadowlessElement } from '@blocksuite/affine/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { AddCollectionIcon } from '@blocksuite/icons/lit';
import { CollectionsIcon } from '@blocksuite/icons/lit';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
@@ -17,7 +17,7 @@ export class ChatPanelCollectionChip extends SignalWatcher(
const { state, collectionName } = this.chip;
const isLoading = state === 'processing';
const tooltip = getChipTooltip(state, collectionName, this.chip.tooltip);
const collectionIcon = AddCollectionIcon();
const collectionIcon = CollectionsIcon();
const icon = getChipIcon(state, collectionIcon);
return html`<chat-panel-chip