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

View File

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