/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { ConnectorUtils, normalizeShapeBound, TextUtils, } from '@blocksuite/affine-block-surface'; import { SmallArrowDownIcon, TextAlignCenterIcon, TextAlignLeftIcon, TextAlignRightIcon, } from '@blocksuite/affine-components/icons'; import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar'; import { type ColorScheme, ConnectorElementModel, EdgelessTextBlockModel, FontFamily, FontStyle, FontWeight, LINE_COLORS, ShapeElementModel, TextAlign, TextElementModel, type TextStyleProps, } from '@blocksuite/affine-model'; import { Bound, countBy, maxBy, WithDisposable, } from '@blocksuite/global/utils'; import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; import { property, query } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; import { join } from 'lit/directives/join.js'; import { when } from 'lit/directives/when.js'; import type { EdgelessColorPickerButton, PickColorEvent, } from '../../edgeless/components/color-picker/index.js'; import { packColor, packColorsWithColorScheme, } from '../../edgeless/components/color-picker/utils.js'; import { type ColorEvent, GET_DEFAULT_LINE_COLOR, } from '../../edgeless/components/panel/color-panel.js'; import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; const FONT_SIZE_LIST = [ { value: 16 }, { value: 24 }, { value: 32 }, { value: 40 }, { value: 64 }, { value: 128 }, ] as const; const FONT_WEIGHT_CHOOSE: [FontWeight, () => string][] = [ [FontWeight.Light, () => 'Light'], [FontWeight.Regular, () => 'Regular'], [FontWeight.SemiBold, () => 'Semibold'], ] as const; const FONT_STYLE_CHOOSE: [FontStyle, () => string | typeof nothing][] = [ [FontStyle.Normal, () => nothing], [FontStyle.Italic, () => 'Italic'], ] as const; const TEXT_ALIGN_CHOOSE: [TextAlign, () => TemplateResult<1>][] = [ [TextAlign.Left, () => TextAlignLeftIcon], [TextAlign.Center, () => TextAlignCenterIcon], [TextAlign.Right, () => TextAlignRightIcon], ] as const; function countByField>( elements: BlockSuite.EdgelessTextModelType[], field: K ) { return countBy(elements, element => extractField(element, field)); } function extractField>( element: BlockSuite.EdgelessTextModelType, field: K ) { //TODO: It's not a very good handling method. // The edgeless-change-text-menu should be refactored into a widget to allow external registration of its own logic. if (element instanceof EdgelessTextBlockModel) { return field === 'fontSize' ? null : (element[field as keyof EdgelessTextBlockModel] as TextStyleProps[K]); } return ( element instanceof ConnectorElementModel ? element.labelStyle[field] : element[field] ) as TextStyleProps[K]; } function getMostCommonValue>( elements: BlockSuite.EdgelessTextModelType[], field: K ) { const values = countByField(elements, field); return maxBy(Object.entries(values), ([_k, count]) => count); } function getMostCommonAlign(elements: BlockSuite.EdgelessTextModelType[]) { const max = getMostCommonValue(elements, 'textAlign'); return max ? (max[0] as TextAlign) : TextAlign.Left; } function getMostCommonColor( elements: BlockSuite.EdgelessTextModelType[], colorScheme: ColorScheme ): string { const colors = countBy(elements, (ele: BlockSuite.EdgelessTextModelType) => { const color = ele instanceof ConnectorElementModel ? ele.labelStyle.color : ele.color; return typeof color === 'object' ? (color[colorScheme] ?? color.normal ?? null) : color; }); const max = maxBy(Object.entries(colors), ([_k, count]) => count); return max ? (max[0] as string) : GET_DEFAULT_LINE_COLOR(colorScheme); } function getMostCommonFontFamily(elements: BlockSuite.EdgelessTextModelType[]) { const max = getMostCommonValue(elements, 'fontFamily'); return max ? (max[0] as FontFamily) : FontFamily.Inter; } function getMostCommonFontSize(elements: BlockSuite.EdgelessTextModelType[]) { const max = getMostCommonValue(elements, 'fontSize'); return max ? Number(max[0]) : FONT_SIZE_LIST[0].value; } function getMostCommonFontStyle(elements: BlockSuite.EdgelessTextModelType[]) { const max = getMostCommonValue(elements, 'fontStyle'); return max ? (max[0] as FontStyle) : FontStyle.Normal; } function getMostCommonFontWeight(elements: BlockSuite.EdgelessTextModelType[]) { const max = getMostCommonValue(elements, 'fontWeight'); return max ? (max[0] as FontWeight) : FontWeight.Regular; } function buildProps( element: BlockSuite.EdgelessTextModelType, props: { [K in keyof TextStyleProps]?: TextStyleProps[K] } ) { if (element instanceof ConnectorElementModel) { return { labelStyle: { ...element.labelStyle, ...props, }, }; } return { ...props }; } export class EdgelessChangeTextMenu extends WithDisposable(LitElement) { static override styles = css` :host { display: inherit; align-items: inherit; justify-content: inherit; gap: inherit; height: 100%; } `; private readonly _setFontFamily = (fontFamily: FontFamily) => { const currentFontWeight = getMostCommonFontWeight(this.elements); const fontWeight = TextUtils.isFontWeightSupported( fontFamily, currentFontWeight ) ? currentFontWeight : FontWeight.Regular; const currentFontStyle = getMostCommonFontStyle(this.elements); const fontStyle = TextUtils.isFontStyleSupported( fontFamily, currentFontStyle ) ? currentFontStyle : FontStyle.Normal; const props = { fontFamily, fontWeight, fontStyle }; this.elements.forEach(element => { this.service.updateElement(element.id, buildProps(element, props)); this._updateElementBound(element); }); }; private readonly _setFontSize = (fontSize: number) => { const props = { fontSize }; this.elements.forEach(element => { this.service.updateElement(element.id, buildProps(element, props)); this._updateElementBound(element); }); }; private readonly _setFontWeightAndStyle = ( fontWeight: FontWeight, fontStyle: FontStyle ) => { const props = { fontWeight, fontStyle }; this.elements.forEach(element => { this.service.updateElement(element.id, buildProps(element, props)); this._updateElementBound(element); }); }; private readonly _setTextAlign = (textAlign: TextAlign) => { const props = { textAlign }; this.elements.forEach(element => { this.service.updateElement(element.id, buildProps(element, props)); }); }; private readonly _setTextColor = ({ detail: color }: ColorEvent) => { const props = { color }; this.elements.forEach(element => { this.service.updateElement(element.id, buildProps(element, props)); }); }; private readonly _updateElementBound = ( element: BlockSuite.EdgelessTextModelType ) => { const elementType = this.elementType; if (elementType === 'text' && element instanceof TextElementModel) { // the change of font family will change the bound of the text const { text: yText, fontFamily, fontStyle, fontSize, fontWeight, hasMaxWidth, } = element; const newBound = TextUtils.normalizeTextBound( { yText, fontFamily, fontStyle, fontSize, fontWeight, hasMaxWidth, }, Bound.fromXYWH(element.deserializedXYWH) ); this.service.updateElement(element.id, { xywh: newBound.serialize(), }); } else if ( elementType === 'connector' && ConnectorUtils.isConnectorWithLabel(element) ) { const { text, labelXYWH, labelStyle: { fontFamily, fontStyle, fontSize, fontWeight }, labelConstraints: { hasMaxWidth, maxWidth }, } = element as ConnectorElementModel; const prevBounds = Bound.fromXYWH(labelXYWH || [0, 0, 16, 16]); const center = prevBounds.center; const bounds = TextUtils.normalizeTextBound( { yText: text!, fontFamily, fontStyle, fontSize, fontWeight, hasMaxWidth, maxWidth, }, prevBounds ); bounds.center = center; this.service.updateElement(element.id, { labelXYWH: bounds.toXYWH(), }); } else if ( elementType === 'shape' && element instanceof ShapeElementModel ) { const newBound = normalizeShapeBound( element, Bound.fromXYWH(element.deserializedXYWH) ); this.service.updateElement(element.id, { xywh: newBound.serialize(), }); } // no need to update the bound of edgeless text block, which updates itself using ResizeObserver }; pickColor = (event: PickColorEvent) => { if (event.type === 'pick') { this.elements.forEach(element => { const props = packColor('color', { ...event.detail }); this.service.updateElement(element.id, buildProps(element, props)); this._updateElementBound(element); }); return; } const key = this.elementType === 'connector' ? 'labelStyle' : 'color'; this.elements.forEach(ele => { // @ts-expect-error: FIXME ele[event.type === 'start' ? 'stash' : 'pop'](key); }); }; get service() { return this.edgeless.service; } override render() { const colorScheme = this.edgeless.surface.renderer.getColorScheme(); const elements = this.elements; const selectedAlign = getMostCommonAlign(elements); const selectedColor = getMostCommonColor(elements, colorScheme); const selectedFontFamily = getMostCommonFontFamily(elements); const selectedFontSize = Math.trunc(getMostCommonFontSize(elements)); const selectedFontStyle = getMostCommonFontStyle(elements); const selectedFontWeight = getMostCommonFontWeight(elements); const matchFontFaces = TextUtils.getFontFacesByFontFamily(selectedFontFamily); const fontStyleBtnDisabled = matchFontFaces.length === 1 && matchFontFaces[0].style === selectedFontStyle && matchFontFaces[0].weight === selectedFontWeight; return join( [ html` Aa${SmallArrowDownIcon} `} > `, when( this.edgeless.doc.awarenessStore.getFlag('enable_color_picker'), () => { const { type, colors } = packColorsWithColorScheme( colorScheme, selectedColor, elements[0] instanceof ConnectorElementModel ? elements[0].labelStyle.color : elements[0].color ); return html` `; }, () => html` `} > ` ), html` ${choose(selectedFontWeight, FONT_WEIGHT_CHOOSE)} ${choose(selectedFontStyle, FONT_STYLE_CHOOSE)} ${SmallArrowDownIcon} `} > `, this.elementType === 'edgeless-text' ? nothing : html` ${selectedFontSize} ${SmallArrowDownIcon} `} > `, html` ${choose(selectedAlign, TEXT_ALIGN_CHOOSE)}${SmallArrowDownIcon} `} > `, ].filter(b => b !== nothing), renderToolbarSeparator ); } @property({ attribute: false }) accessor edgeless!: EdgelessRootBlockComponent; @property({ attribute: false }) accessor elements!: BlockSuite.EdgelessTextModelType[]; @property({ attribute: false }) accessor elementType!: BlockSuite.EdgelessTextModelKeyType; @query('edgeless-color-picker-button.text-color') accessor textColorButton!: EdgelessColorPickerButton; }