diff --git a/packages/blocks/src/__internal__/rich-text/reference-node.ts b/packages/blocks/src/__internal__/rich-text/reference-node.ts index 917d731efba7..fec59b3fc595 100644 --- a/packages/blocks/src/__internal__/rich-text/reference-node.ts +++ b/packages/blocks/src/__internal__/rich-text/reference-node.ts @@ -21,6 +21,7 @@ const DEFAULT_PAGE_NAME = 'Untitled'; export type RefNodeSlots = { pageLinkClicked: Slot<{ pageId: string; blockId?: string }>; + tagClicked: Slot<{ tagId: string }>; }; @customElement('affine-reference') diff --git a/packages/blocks/src/page-block/default/backlink-popover.ts b/packages/blocks/src/page-block/default/backlink-popover.ts deleted file mode 100644 index 383973233b2c..000000000000 --- a/packages/blocks/src/page-block/default/backlink-popover.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { - ArrowDownIcon, - DualLinkIcon16, - LinkedPageIcon, - PageIcon, -} from '@blocksuite/global/config'; -import { WithDisposable } from '@blocksuite/lit'; -import { assertExists, type Page } from '@blocksuite/store'; -import { css, html, LitElement } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; - -import type { AffineTextAttributes } from '../../__internal__/rich-text/virgo/types.js'; -import type { BlockHost } from '../../__internal__/utils/types.js'; - -const styles = css` - :host { - position: relative; - } - - .btn { - box-sizing: border-box; - display: inline-flex; - align-items: center; - border: none; - padding: 1px 4px; - border-radius: 5px; - gap: 4px; - background: transparent; - cursor: pointer; - - user-select: none; - font-family: var(--affine-font-family); - fill: var(--affine-text-secondary-color); - color: var(--affine-text-secondary-color); - pointer-events: auto; - } - - .btn > span { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - - .btn:hover { - background: var(--affine-hover-color); - } - - .btn:active { - background: var(--affine-hover-color); - } - - .backlink-popover { - position: absolute; - left: 0; - bottom: -8px; - - display: flex; - flex-direction: column; - padding: 8px 4px; - background: var(--affine-white); - box-shadow: var(--affine-menu-shadow); - border-radius: 12px; - transform: translateY(100%); - z-index: 1; - } - - .backlink-popover .group-title { - color: var(--affine-text-secondary-color); - margin: 8px 12px; - } - - .backlink-popover icon-button { - padding: 8px; - justify-content: flex-start; - gap: 8px; - } - - ::-webkit-scrollbar { - -webkit-appearance: none; - width: 4px; - } - ::-webkit-scrollbar-thumb { - border-radius: 2px; - background-color: #b1b1b1; - } -`; - -export type BackLink = { - pageId: string; - blockId: string; - type: NonNullable['type']; -}; - -@customElement('backlink-button') -export class BacklinkButton extends WithDisposable(LitElement) { - static override styles = styles; - - @property({ attribute: false }) - page?: Page; - - @property({ attribute: false }) - host!: BlockHost; - - @state() - private _backlinks: BackLink[] = []; - - @state() - private _showPopover = false; - - override connectedCallback() { - super.connectedCallback(); - this.tabIndex = 0; - - const page = this.page; - assertExists(page); - const backlinkIndexer = page.workspace.indexer.backlink; - this._backlinks = backlinkIndexer.getBacklink(page.id); - backlinkIndexer.slots.indexUpdated.on(() => { - this._backlinks = backlinkIndexer.getBacklink(page.id); - if (!this._backlinks.length) { - this._showPopover = false; - } - }); - this._disposables.addFromEvent(window, 'mousedown', this._onClickAway); - } - - // Handle click outside - private _onClickAway = (e: Event) => { - if (e.target === this) return; - if (!this._showPopover) return; - this._showPopover = false; - }; - - onClick() { - this._showPopover = !this._showPopover; - } - - override render() { - // Only show linked page backlinks - const linkedBacklinks = this._backlinks.filter( - ({ type }) => type === 'LinkedPage' - ); - if (!linkedBacklinks.length) { - return null; - } - return html`
- ${DualLinkIcon16}Backlinks (${linkedBacklinks.length})${ArrowDownIcon} -
- ${this._showPopover - ? backlinkPopover(this.host, linkedBacklinks) - : null}`; - } -} - -const DEFAULT_PAGE_NAME = 'Untitled'; - -function backlinkPopover(host: BlockHost, backlinks: BackLink[]) { - const metas = host.page.workspace.meta.pageMetas; - return html``; -} - -declare global { - interface HTMLElementTagNameMap { - 'backlink-button': BacklinkButton; - } -} diff --git a/packages/blocks/src/page-block/default/default-page-block.ts b/packages/blocks/src/page-block/default/default-page-block.ts index e916de236126..d95b7615ce30 100644 --- a/packages/blocks/src/page-block/default/default-page-block.ts +++ b/packages/blocks/src/page-block/default/default-page-block.ts @@ -169,6 +169,7 @@ export class DefaultPageBlockComponent pageId: string; blockId?: string; }>(), + tagClicked: new Slot<{ tagId: string }>(), }; @query('.affine-default-page-block-title') @@ -545,15 +546,6 @@ export class DefaultPageBlockComponent child => this.root.renderModel(child) )}`; - const renderMetaData = this.page.workspace.awarenessStore.getFlag( - 'enable_page_tags' - ) - ? html` ` - : null; - return html`
@@ -564,11 +556,10 @@ export class DefaultPageBlockComponent ? 'affine-default-page-block-title-empty' : ''}" >
- ${renderMetaData} - + >
${content} diff --git a/packages/blocks/src/page-block/default/meta-data/backlink/backlink-popover.ts b/packages/blocks/src/page-block/default/meta-data/backlink/backlink-popover.ts new file mode 100644 index 000000000000..98453baa81ff --- /dev/null +++ b/packages/blocks/src/page-block/default/meta-data/backlink/backlink-popover.ts @@ -0,0 +1,155 @@ +import { DualLinkIcon16 } from '@blocksuite/global/config'; +import { WithDisposable } from '@blocksuite/lit'; +import { css, html, LitElement } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import type { BacklinkData } from './backlink.js'; +import { DEFAULT_PAGE_NAME } from './backlink.js'; + +const styles = css` + :host { + position: relative; + display: flex; + } + + .btn { + padding: 0 12px; + box-sizing: border-box; + display: inline-flex; + align-items: center; + border: none; + height: 30px; + border-radius: 8px; + gap: 4px; + background: transparent; + cursor: pointer; + + user-select: none; + font-family: var(--affine-font-family); + fill: var(--affine-text-secondary-color); + color: var(--affine-text-secondary-color); + pointer-events: auto; + } + + .btn > span { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .btn:hover { + background: var(--affine-hover-color); + } + + .btn:active { + background: var(--affine-hover-color); + } + + .backlink-popover { + position: absolute; + left: 0; + bottom: 0; + transform: translateY(100%); + z-index: 1; + padding-top: 8px; + } + + .menu { + display: flex; + flex-direction: column; + padding: 8px 4px; + background: var(--affine-white); + box-shadow: var(--affine-menu-shadow); + border-radius: 12px; + } + + .backlink-popover .group-title { + color: var(--affine-text-secondary-color); + margin: 8px 12px; + } + + .backlink-popover icon-button { + padding: 8px; + justify-content: flex-start; + gap: 8px; + } + + ::-webkit-scrollbar { + -webkit-appearance: none; + width: 4px; + } + + ::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: #b1b1b1; + } +`; + +@customElement('backlink-button') +export class BacklinkButton extends WithDisposable(LitElement) { + static override styles = styles; + + @property({ attribute: false }) + private backlinks: BacklinkData[] = []; + + @state() + private _showPopover = false; + + override connectedCallback() { + super.connectedCallback(); + this.tabIndex = 0; + this._disposables.addFromEvent(window, 'mousedown', this._onClickAway); + } + + // Handle click outside + private _onClickAway = (e: Event) => { + if (e.target === this) return; + if (!this._showPopover) return; + this._showPopover = false; + }; + + onClick() { + this._showPopover = !this._showPopover; + } + + override render() { + // Only show linked page backlinks + const backlinks = this.backlinks; + if (!backlinks.length) { + return null; + } + return html` +
+ ${DualLinkIcon16}Backlinks (${backlinks.length}) + ${this._showPopover ? backlinkPopover(backlinks) : null} +
+ `; + } +} + +function backlinkPopover(backlinks: BacklinkData[]) { + return html` `; +} + +declare global { + interface HTMLElementTagNameMap { + 'backlink-button': BacklinkButton; + } +} diff --git a/packages/blocks/src/page-block/default/meta-data/backlink/backlink.ts b/packages/blocks/src/page-block/default/meta-data/backlink/backlink.ts new file mode 100644 index 000000000000..64ebd88d5e62 --- /dev/null +++ b/packages/blocks/src/page-block/default/meta-data/backlink/backlink.ts @@ -0,0 +1,62 @@ +import { LinkedPageIcon, PageIcon } from '@blocksuite/global/config'; +import type { Disposable } from '@blocksuite/global/utils'; +import type { PageMeta } from '@blocksuite/store'; +import type { TemplateResult } from 'lit'; + +import type { BlockHost } from '../../../../__internal__/index.js'; +import type { AffineTextAttributes } from '../../../../__internal__/rich-text/virgo/types.js'; + +export type BackLink = { + pageId: string; + blockId: string; + type: NonNullable['type']; +}; + +export const listenBacklinkList = ( + host: BlockHost, + cb: (list: BacklinkData[]) => void +): Disposable => { + const metaMap = Object.fromEntries( + host.page.workspace.meta.pageMetas.map(v => [v.id, v]) + ); + const page = host.page; + const toData = (backlink: BackLink): BacklinkData => { + const pageMeta = metaMap[backlink.pageId]; + if (!pageMeta) { + console.warn('Unexpected page meta not found', backlink.pageId); + } + return { + ...backlink, + ...pageMeta, + icon: backlink.type === 'LinkedPage' ? LinkedPageIcon : PageIcon, + jump: () => { + if (backlink.pageId === page.id) { + // On the current page, no need to jump + // TODO jump to block + return; + } + host.slots.pageLinkClicked.emit({ + pageId: backlink.pageId, + blockId: backlink.blockId, + }); + }, + }; + }; + const backlinkIndexer = page.workspace.indexer.backlink; + const getList = () => { + return backlinkIndexer + .getBacklink(page.id) + .filter(v => v.type === 'LinkedPage') + .map(toData); + }; + cb(getList()); + return backlinkIndexer.slots.indexUpdated.on(() => { + cb(getList()); + }); +}; +export type BacklinkData = BackLink & + PageMeta & { + jump: () => void; + icon: TemplateResult; + }; +export const DEFAULT_PAGE_NAME = 'Untitled'; diff --git a/packages/blocks/src/page-block/default/meta-data/meta-data.ts b/packages/blocks/src/page-block/default/meta-data/meta-data.ts index c11924a553f9..4d7f3c7da48c 100644 --- a/packages/blocks/src/page-block/default/meta-data/meta-data.ts +++ b/packages/blocks/src/page-block/default/meta-data/meta-data.ts @@ -1,47 +1,194 @@ import '../../../components/tags/multi-tag-select.js'; import '../../../components/tags/multi-tag-view.js'; -import { ShadowlessElement, WithDisposable } from '@blocksuite/lit'; +import { + AddCursorIcon, + ArrowDownSmallIcon, + DualLinkIcon16, + TagsIcon, +} from '@blocksuite/global/config'; +import { WithDisposable } from '@blocksuite/lit'; import type { Page } from '@blocksuite/store'; -import { css, html } from 'lit'; +import { css, html, LitElement } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; import type { BlockHost } from '../../../__internal__/index.js'; import type { SelectTag } from '../../../components/tags/multi-tag-select.js'; import { popTagSelect } from '../../../components/tags/multi-tag-select.js'; +import type { BacklinkData } from './backlink/backlink.js'; +import { DEFAULT_PAGE_NAME, listenBacklinkList } from './backlink/backlink.js'; @customElement('affine-page-meta-data') -export class PageMetaData extends WithDisposable(ShadowlessElement) { +export class PageMetaData extends WithDisposable(LitElement) { static override styles = css` - affine-page-meta-data .meta-hover:hover { - background-color: var(--affine-hover-color); + .meta-data { + border-radius: 8px; + display: flex; + align-items: center; + height: 30px; cursor: pointer; + justify-content: space-between; + margin: 0 -12px; + } + + .meta-data-content { + display: flex; + align-items: center; + gap: 8px; + color: var(--affine-text-secondary-color); + } + + .meta-data:hover { + background-color: var(--affine-hover-color); + } + + .tags-inline { + display: flex; + align-items: center; + gap: 4px; + font-size: 14px; } - affine-page-meta-data .meta-data { + .tags-inline .tag-list { + display: flex; + align-items: center; + } + + .tag-inline { + max-width: 100px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .expand { + display: flex; + align-items: center; + justify-content: center; + margin-right: 12px; + } + + .meta-data-expanded { + padding: 10px 24px; + margin: 0 -24px; display: flex; flex-direction: column; - border-bottom: 1px solid var(--affine-divider-color); - padding: 8px 0; - margin-bottom: 8px; + gap: 10px; + background-color: var(--affine-hover-color-filled); + border-radius: 8px; } - affine-page-meta-data .meta-data-item { - width: 100%; + .meta-data-expanded-title { display: flex; + justify-content: space-between; + font-weight: 600; + font-size: 14px; + color: var(--affine-text-secondary-color); + align-items: center; } - affine-page-meta-data .meta-data .meta-data-type { - width: 200px; - padding: 4px; + .meta-data-expanded-title .close { + transform: rotate(180deg); border-radius: 4px; + display: flex; + align-items: center; + cursor: pointer; } - affine-page-meta-data .meta-data .meta-data-value { + .meta-data-expanded-title .close:hover { + background-color: var(--affine-hover-color); + } + + .meta-data-expanded-content { + display: flex; + flex-direction: column; + gap: 24px; + } + + .meta-data-expanded-item { + display: flex; + gap: 8px; + } + + .meta-data-expanded-item .type { + height: 23px; + display: flex; + align-items: center; + } + .meta-data-expanded-item .type svg { + fill: var(--affine-icon-color); + } + + .meta-data-expanded-item .value { flex: 1; - padding: 4px; + } + + .add-tag { + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + } + .add-tag svg { + fill: var(--affine-text-secondary-color); + } + + .add-tag:hover { + background-color: var(--affine-hover-color); + } + + .tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .tag { + padding: 4px 10px; + border-radius: 8px; + color: var(--affine-text-primary-color); + font-size: 12px; + line-height: 12px; + display: flex; + align-items: center; + font-weight: 400; + cursor: pointer; + } + .backlinks { + display: flex; + gap: 8px; + flex-direction: column; + } + .backlinks .title { + height: 28px; + color: var(--affine-text-secondary-color); + } + .backlinks .link { + display: flex; + align-items: center; + gap: 5px; + font-size: 15px; + cursor: pointer; + width: max-content; border-radius: 4px; + padding: 0 8px 0 4px; + margin: 0 -8px 0 -4px; + } + .backlinks .link:hover { + background-color: var(--affine-hover-color); + } + .backlinks .link svg { + fill: var(--affine-icon-color); + } + + .link-title { + border-bottom: 0.5px solid var(--affine-divider-color); + } + .backlinks .link:hover .link-title { + border-bottom-color: transparent; } `; @@ -56,14 +203,15 @@ export class PageMetaData extends WithDisposable(ShadowlessElement) { } get options() { - return this.meta.allPagesMeta.tags.options; + return this.meta.properties.tags.options; } set options(tags: SelectTag[]) { - this.page.workspace.meta.setAllPagesMeta({ - ...this.meta.allPagesMeta, + this.tags = this.tags.filter(v => tags.find(x => x.id === v)); + this.page.workspace.meta.setProperties({ + ...this.meta.properties, tags: { - ...this.meta.allPagesMeta.tags, + ...this.meta.properties.tags, options: tags, }, }); @@ -77,8 +225,16 @@ export class PageMetaData extends WithDisposable(ShadowlessElement) { this.page.meta.tags = tags; } + @state() + backlinkList!: BacklinkData[]; + override connectedCallback() { super.connectedCallback(); + this._disposables.add( + listenBacklinkList(this.host, list => { + this.backlinkList = list; + }) + ); this._disposables.add( this.meta.pageMetasUpdated.on(() => { this.requestUpdate(); @@ -89,7 +245,7 @@ export class PageMetaData extends WithDisposable(ShadowlessElement) { @state() showSelect = false; _selectTags = (evt: MouseEvent) => { - popTagSelect(evt.currentTarget as HTMLElement, { + popTagSelect(this.shadowRoot?.querySelector('.tags') ?? this, { value: this.tags, onChange: tags => (this.tags = tags), options: this.options, @@ -97,32 +253,123 @@ export class PageMetaData extends WithDisposable(ShadowlessElement) { }); }; - items() { - return [ - { - type: html`Tags`, - clickValue: this._selectTags, - value: this.tags.length - ? html` ` - : html`Empty`, - }, - ]; - } + private renderBacklinkInline = () => { + const click = (e: MouseEvent) => { + e.stopPropagation(); + }; + return html` + + `; + }; + private renderTagsInline = () => { + const tags = this.tags; + const optionMap = Object.fromEntries(this.options.map(v => [v.id, v])); + return html`
+ ${TagsIcon} + ${tags.length > 0 + ? html`
+ ${repeat( + tags.slice(0, 3), + id => id, + (id, i) => { + const tag = optionMap[id]; + if (!tag) { + return null; + } + return html`
+ ${i !== 0 ? ', ' : ''} +
+
${tag.value}
`; + } + )} + ${tags.length > 3 ? html`, and ${tags.length - 3} more` : ''} +
` + : 'Tags'} +
`; + }; + + @state() + expanded = false; + private _toggle = () => { + this.expanded = !this.expanded; + }; + private renderBacklinkExpanded = () => { + const backlinkList = this.backlinkList; + if (!backlinkList.length) { + return null; + } + const renderLink = (link: BacklinkData) => { + return html``; + }; + return html``; + }; + private renderTagsExpanded = () => { + const optionMap = Object.fromEntries(this.options.map(v => [v.id, v])); + return html` `; + }; override render() { - const items = this.items(); - return html`