diff --git a/packages/blocks/src/root-block/widgets/format-bar/components/config-renderer.ts b/packages/blocks/src/root-block/widgets/format-bar/components/config-renderer.ts new file mode 100644 index 000000000000..8d357cee4316 --- /dev/null +++ b/packages/blocks/src/root-block/widgets/format-bar/components/config-renderer.ts @@ -0,0 +1,73 @@ +import { html, type TemplateResult } from 'lit'; + +import { isFormatSupported } from '../../../../note-block/commands/utils.js'; +import type { AffineFormatBarWidget } from '../format-bar.js'; +import { HighlightButton } from './highlight/highlight-button.js'; +import { ParagraphButton } from './paragraph-button.js'; + +export function ConfigRenderer(formatBar: AffineFormatBarWidget) { + return formatBar.configItems + .filter(item => { + if (item.type === 'paragraph-action') { + return false; + } + if (item.type === 'highlighter-dropdown') { + const [supported] = isFormatSupported(formatBar.std).run(); + return supported; + } + if (item.type === 'inline-action') { + return item.showWhen(formatBar); + } + return true; + }) + .map(item => { + let template: TemplateResult | null = null; + switch (item.type) { + case 'divider': + template = html`
`; + break; + case 'highlighter-dropdown': { + template = HighlightButton(formatBar); + break; + } + case 'paragraph-dropdown': + template = ParagraphButton(formatBar); + break; + case 'inline-action': { + template = html` { + item.action(formatBar); + formatBar.requestUpdate(); + }} + > + ${typeof item.icon === 'function' ? item.icon() : item.icon} + ${item.name} + `; + break; + } + default: + template = null; + } + + return [template, item] as const; + }) + .filter(([template]) => template !== null && template !== undefined) + .filter(([_, item], index, list) => { + if (item.type === 'divider') { + if (index === 0) { + return false; + } + if (index === list.length - 1) { + return false; + } + if (list[index - 1][1].type === 'divider') { + return false; + } + } + return true; + }) + .map(([template]) => template); +} diff --git a/packages/blocks/src/root-block/widgets/format-bar/format-bar.ts b/packages/blocks/src/root-block/widgets/format-bar/format-bar.ts index e60b4cf87c6f..3fceedd83f4f 100644 --- a/packages/blocks/src/root-block/widgets/format-bar/format-bar.ts +++ b/packages/blocks/src/root-block/widgets/format-bar/format-bar.ts @@ -9,6 +9,7 @@ import { inline, offset, type Placement, + type ReferenceElement, shift, } from '@floating-ui/dom'; import { html, nothing, type TemplateResult } from 'lit'; @@ -17,10 +18,8 @@ import { customElement, query, state } from 'lit/decorators.js'; import { HoverController } from '../../../_common/components/index.js'; import { stopPropagation } from '../../../_common/utils/event.js'; import { matchFlavours } from '../../../_common/utils/model.js'; -import { isFormatSupported } from '../../../note-block/commands/utils.js'; import { isRootElement } from '../../../root-block/utils/guard.js'; -import { HighlightButton } from './components/highlight/highlight-button.js'; -import { ParagraphButton } from './components/paragraph-button.js'; +import { ConfigRenderer } from './components/config-renderer.js'; import { defaultConfig, type FormatBarConfigItem } from './config.js'; import { formatBarStyle } from './styles.js'; @@ -40,9 +39,6 @@ export const AFFINE_FORMAT_BAR_WIDGET = 'affine-format-bar-widget'; @customElement(AFFINE_FORMAT_BAR_WIDGET) export class AffineFormatBarWidget extends WidgetElement { - @state() - configItems: FormatBarConfigItem[] = defaultConfig; - static override styles = formatBarStyle; private static readonly _customElements: Set = @@ -92,13 +88,16 @@ export class AffineFormatBarWidget extends WidgetElement { @query(`.${AFFINE_FORMAT_BAR_WIDGET}`) formatBarElement?: HTMLElement; - private get _selectionManager() { - return this.host.selection; - } + @state() + configItems: FormatBarConfigItem[] = defaultConfig; @state() private _dragging = false; + private get _selectionManager() { + return this.host.selection; + } + private _displayType: 'text' | 'block' | 'native' | 'none' = 'none'; get displayType() { return this._displayType; @@ -112,14 +111,15 @@ export class AffineFormatBarWidget extends WidgetElement { get nativeRange() { const sl = document.getSelection(); if (!sl || sl.rangeCount === 0) return null; - const range = sl.getRangeAt(0); - return range; + return sl.getRangeAt(0); } private _abortController = new AbortController(); private _placement: Placement = 'top'; + private _floatDisposables: DisposableGroup | null = null; + private _reset() { this._displayType = 'none'; this._selectedBlockElements = []; @@ -153,25 +153,8 @@ export class AffineFormatBarWidget extends WidgetElement { return !readonly && this.displayType !== 'none' && !this._dragging; } - override connectedCallback() { - super.connectedCallback(); - this._abortController = new AbortController(); - + private _calculatePlacement() { const rootElement = this.blockElement; - assertExists(rootElement); - const widgets = rootElement.widgets; - - // check if the host use the format bar widget - if (!Object.hasOwn(widgets, AFFINE_FORMAT_BAR_WIDGET)) { - return; - } - - // check if format bar widget support the host - if (!isRootElement(rootElement)) { - throw new Error( - `format bar not support rootElement: ${rootElement.constructor.name} but its widgets has format bar` - ); - } this.handleEvent('pointerMove', ctx => { this._dragging = ctx.get('pointerState').dragging; @@ -216,121 +199,116 @@ export class AffineFormatBarWidget extends WidgetElement { this.disposables.add( this._selectionManager.slots.changed.on(async () => { await this.host.updateComplete; - const textSelection = rootElement.selection.find('text'); - const blockSelections = rootElement.selection.filter('block'); - - if (textSelection) { - const block = this.host.view.viewFromPath( - 'block', - textSelection.path - ); - if ( - !textSelection.isCollapsed() && - block && - block.model.role === 'content' - ) { - this._displayType = 'text'; - assertExists(rootElement.host.rangeManager); - - this.host.std.command - .chain() - .getTextSelection() - .getSelectedBlocks({ - types: ['text'], - }) - .inline(ctx => { - const { selectedBlocks } = ctx; - assertExists(selectedBlocks); - this._selectedBlockElements = selectedBlocks; - }) - .run(); - } else { + + const update = () => { + const textSelection = rootElement.selection.find('text'); + const blockSelections = rootElement.selection.filter('block'); + + if (textSelection) { + const block = this.host.view.viewFromPath( + 'block', + textSelection.path + ); + if ( + !textSelection.isCollapsed() && + block && + block.model.role === 'content' + ) { + this._displayType = 'text'; + assertExists(rootElement.host.rangeManager); + + this.host.std.command + .chain() + .getTextSelection() + .getSelectedBlocks({ + types: ['text'], + }) + .inline(ctx => { + const { selectedBlocks } = ctx; + assertExists(selectedBlocks); + this._selectedBlockElements = selectedBlocks; + }) + .run(); + + return; + } + this._reset(); + return; } - } else if (blockSelections.length > 0) { - this._displayType = 'block'; - this._selectedBlockElements = blockSelections - .map(selection => { - const path = selection.path; - return this.blockElement.host.view.viewFromPath('block', path); - }) - .filter((el): el is BlockElement => !!el); - } else { + + if (blockSelections.length > 0) { + this._displayType = 'block'; + this._selectedBlockElements = blockSelections + .map(selection => { + const path = selection.path; + return this.blockElement.host.view.viewFromPath('block', path); + }) + .filter((el): el is BlockElement => !!el); + + return; + } + this._reset(); - } + }; + update(); this.requestUpdate(); }) ); this.disposables.addFromEvent(document, 'selectionchange', () => { const databaseSelection = this.host.selection.find('database'); + if (!databaseSelection) { + return; + } + const reset = () => { this._reset(); this.requestUpdate(); }; - if (databaseSelection) { - const viewSelection = databaseSelection.viewSelection; - // check table selection - if (viewSelection.type === 'table' && !viewSelection.isEditing) - return reset(); - // check kanban selection - if ( - (viewSelection.type === 'kanban' && - viewSelection.selectionType !== 'cell') || - !viewSelection.isEditing - ) - return reset(); + const viewSelection = databaseSelection.viewSelection; + // check table selection + if (viewSelection.type === 'table' && !viewSelection.isEditing) + return reset(); + // check kanban selection + if ( + (viewSelection.type === 'kanban' && + viewSelection.selectionType !== 'cell') || + !viewSelection.isEditing + ) + return reset(); - const range = this.nativeRange; + const range = this.nativeRange; - if (!range || range.collapsed) return reset(); - this._displayType = 'native'; - this.requestUpdate(); - } + if (!range || range.collapsed) return reset(); + this._displayType = 'native'; + this.requestUpdate(); }); } - private _floatDisposables: DisposableGroup | null = null; - - override updated() { - if (!this._shouldDisplay()) { - if (this._floatDisposables) { - this._floatDisposables.dispose(); - } - return; - } - - this._floatDisposables = new DisposableGroup(); - + private _listenFloatingElement() { const formatQuickBarElement = this.formatBarElement; assertExists(formatQuickBarElement, 'format quick bar should exist'); - if (this.displayType === 'text' || this.displayType === 'native') { - const range = this.nativeRange; - if (!range) { + + const listenFloatingElement = ( + getElement: () => ReferenceElement | void + ) => { + const initialElement = getElement(); + if (!initialElement) { return; } - const visualElement = { - getBoundingClientRect: () => range.getBoundingClientRect(), - getClientRects: () => range.getClientRects(), - }; + assertExists(this._floatDisposables); HoverController.globalAbortController?.abort(); this._floatDisposables.add( autoUpdate( - visualElement, + initialElement, formatQuickBarElement, () => { - // Why not use `range` and `visualElement` directly: - // https://github.com/toeverything/blocksuite/issues/5144 - const latestRange = this.nativeRange; - if (!latestRange) { - return; - } - const latestVisualElement = { - getBoundingClientRect: () => latestRange.getBoundingClientRect(), - getClientRects: () => latestRange.getClientRects(), - }; - computePosition(latestVisualElement, formatQuickBarElement, { + const element = getElement(); + if (!element) return; + + computePosition(element, formatQuickBarElement, { placement: this._placement, middleware: [ offset(10), @@ -353,7 +331,9 @@ export class AffineFormatBarWidget extends WidgetElement { } ) ); - } else if (this.displayType === 'block') { + }; + + const getReferenceElementFromBlock = () => { const firstBlockElement = this._selectedBlockElements[0]; let rect = firstBlockElement?.getBoundingClientRect(); @@ -374,42 +354,68 @@ export class AffineFormatBarWidget extends WidgetElement { rect = new DOMRect(rect.left, rect.top, elRect.right, rect.bottom); } }); - const visualElement = { + return { getBoundingClientRect: () => rect, getClientRects: () => this._selectedBlockElements.map(el => el.getBoundingClientRect()), }; + }; - HoverController.globalAbortController?.abort(); - this._floatDisposables.add( - autoUpdate( - visualElement, - formatQuickBarElement, - () => { - computePosition(visualElement, formatQuickBarElement, { - placement: this._placement, - middleware: [ - offset(10), - inline(), - shift({ - padding: 6, - }), - ], - }) - .then(({ x, y }) => { - formatQuickBarElement.style.display = 'flex'; - formatQuickBarElement.style.top = `${y}px`; - formatQuickBarElement.style.left = `${x}px`; - }) - .catch(console.error); - }, - { - // follow edgeless viewport update - animationFrame: true, - } - ) + const getReferenceElementFromText = () => { + const range = this.nativeRange; + if (!range) { + return; + } + return { + getBoundingClientRect: () => range.getBoundingClientRect(), + getClientRects: () => range.getClientRects(), + }; + }; + + switch (this.displayType) { + case 'text': + case 'native': + return listenFloatingElement(getReferenceElementFromText); + case 'block': + return listenFloatingElement(getReferenceElementFromBlock); + default: + return; + } + } + + override connectedCallback() { + super.connectedCallback(); + this._abortController = new AbortController(); + + const rootElement = this.blockElement; + assertExists(rootElement); + const widgets = rootElement.widgets; + + // check if the host use the format bar widget + if (!Object.hasOwn(widgets, AFFINE_FORMAT_BAR_WIDGET)) { + return; + } + + // check if format bar widget support the host + if (!isRootElement(rootElement)) { + throw new Error( + `format bar not support rootElement: ${rootElement.constructor.name} but its widgets has format bar` ); } + + this._calculatePlacement(); + } + + override updated() { + if (!this._shouldDisplay()) { + if (this._floatDisposables) { + this._floatDisposables.dispose(); + } + return; + } + + this._floatDisposables = new DisposableGroup(); + this._listenFloatingElement(); } override disconnectedCallback() { @@ -423,70 +429,7 @@ export class AffineFormatBarWidget extends WidgetElement { return nothing; } - const items = this.configItems - .filter(item => { - if (item.type === 'paragraph-action') { - return false; - } - if (item.type === 'highlighter-dropdown') { - const [supported] = isFormatSupported(this.std).run(); - return supported; - } - if (item.type === 'inline-action') { - return item.showWhen(this); - } - return true; - }) - .map(item => { - let template: TemplateResult | null = null; - switch (item.type) { - case 'divider': - template = html`
`; - break; - case 'highlighter-dropdown': { - template = HighlightButton(this); - break; - } - case 'paragraph-dropdown': - template = ParagraphButton(this); - break; - case 'inline-action': { - template = html` { - item.action(this); - this.requestUpdate(); - }} - > - ${typeof item.icon === 'function' ? item.icon() : item.icon} - ${item.name} - `; - break; - } - default: - template = null; - } - - return [template, item] as const; - }) - .filter(([template]) => template !== null && template !== undefined) - .filter(([_, item], index, list) => { - if (item.type === 'divider') { - if (index === 0) { - return false; - } - if (index === list.length - 1) { - return false; - } - if (list[index - 1][1].type === 'divider') { - return false; - } - } - return true; - }) - .map(([template]) => template); + const items = ConfigRenderer(this); return html`