From 1acc7e5a9e39cc89d95d2b977a04b497f36cc1ef Mon Sep 17 00:00:00 2001 From: fundon Date: Thu, 20 Mar 2025 02:08:15 +0000 Subject: [PATCH] refactor(editor): edgeless text toolbar config extension (#10811) --- .../src/edgeless/configs/toolbar/brush.ts | 8 +- .../src/edgeless/configs/toolbar/connector.ts | 70 +++- .../edgeless/configs/toolbar/edgeless-text.ts | 11 + .../src/edgeless/configs/toolbar/frame.ts | 8 +- .../src/edgeless/configs/toolbar/index.ts | 14 +- .../edgeless/configs/toolbar/text-common.ts | 377 ++++++++++++++++++ .../src/edgeless/configs/toolbar/text.ts | 54 ++- .../src/services/toolbar-service/context.ts | 16 +- .../affine/shared/src/utils/computing.ts | 12 +- 9 files changed, 527 insertions(+), 43 deletions(-) create mode 100644 blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/edgeless-text.ts create mode 100644 blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/text-common.ts diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/brush.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/brush.ts index e1c0842f10..8200bccd04 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/brush.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/brush.ts @@ -63,14 +63,14 @@ export const builtinBrushToolbarConfig = { .getFlag('enable_color_picker'); const theme = ctx.themeProvider.edgelessTheme; + const field = 'color'; const firstModel = models[0]; + const originalColor = firstModel[field]; const color = - getMostCommonResolvedValue(models, 'color', color => + getMostCommonResolvedValue(models, field, color => resolveColor(color, theme) ) ?? resolveColor(DefaultTheme.black, theme); const onPick = (e: PickColorEvent) => { - const field = 'color'; - if (e.type === 'pick') { const color = e.detail.value; for (const model of models) { @@ -94,7 +94,7 @@ export const builtinBrushToolbarConfig = { .pick=${onPick} .color=${color} .theme=${theme} - .originalColor=${firstModel.color} + .originalColor=${originalColor} .enableCustomColor=${enableCustomColor} > diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/connector.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/connector.ts index 67daeffe13..fff492cee3 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/connector.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/connector.ts @@ -1,4 +1,8 @@ -import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface'; +import { + ConnectorUtils, + EdgelessCRUDIdentifier, + TextUtils, +} from '@blocksuite/affine-block-surface'; import { packColor, type PickColorEvent, @@ -20,12 +24,14 @@ import { import { FeatureFlagService, type ToolbarContext, + type ToolbarGenericAction, type ToolbarModuleConfig, } from '@blocksuite/affine-shared/services'; import { getMostCommonResolvedValue, getMostCommonValue, } from '@blocksuite/affine-shared/utils'; +import { Bound } from '@blocksuite/global/gfx'; import { AddTextIcon, ConnectorCIcon, @@ -48,6 +54,7 @@ import { styleMap } from 'lit/directives/style-map.js'; import { EdgelessRootBlockComponent } from '../..'; import { mountConnectorLabelEditor } from '../../utils/text'; import { LINE_STYLE_LIST } from './consts'; +import { createTextActions } from './text-common'; import type { MenuItem } from './types'; import { renderMenu } from './utils'; @@ -129,19 +136,18 @@ export const builtinConnectorToolbarConfig = { .getFlag('enable_color_picker'); const theme = ctx.themeProvider.edgelessTheme; + const field = 'stroke'; const firstModel = models[0]; const strokeWidth = getMostCommonValue(models, 'strokeWidth') ?? LineWidth.Four; const strokeStyle = getMostCommonValue(models, 'strokeStyle') ?? StrokeStyle.Solid; const stroke = - getMostCommonResolvedValue(models, 'stroke', stroke => + getMostCommonResolvedValue(models, field, stroke => resolveColor(stroke, theme) ) ?? resolveColor(DefaultTheme.connectorColor, theme); const onPickColor = (e: PickColorEvent) => { - const field = 'stroke'; - if (e.type === 'pick') { const color = e.detail.value; for (const model of models) { @@ -341,14 +347,60 @@ export const builtinConnectorToolbarConfig = { mountConnectorLabelEditor(model, edgeless); }, }, - { - id: 'g.text', + // id: `g.text` + ...createTextActions( + ConnectorElementModel, + 'connector', + (ctx, model, props) => { + if (!ConnectorUtils.isConnectorWithLabel(model)) return; + + const labelStyle = { ...model.labelStyle, ...props }; + + // No need to adjust element bounds + if (props['textAlign']) { + ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, { + labelStyle, + }); + return; + } + + const { fontFamily, fontStyle, fontSize, fontWeight } = labelStyle; + const { + text, + labelXYWH, + labelConstraints: { hasMaxWidth, maxWidth }, + } = model; + 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; + + ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, { + labelStyle, + labelXYWH: bounds.toXYWH(), + }); + }, + model => model.labelStyle, + (model, type, _) => model[type]('labelStyle') + ).map(action => ({ + ...action, + id: `g.text-${action.id}`, when(ctx) { const models = ctx.getSurfaceModelsByType(ConnectorElementModel); - return models.length > 0 && !models.some(model => !model.text); + return models.length > 0 && models.every(model => model.hasLabel()); }, - // TODO(@fundon): text actoins - }, + })), ], when: ctx => ctx.getSurfaceModelsByType(ConnectorElementModel).length > 0, diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/edgeless-text.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/edgeless-text.ts new file mode 100644 index 0000000000..14e1418661 --- /dev/null +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/edgeless-text.ts @@ -0,0 +1,11 @@ +import { EdgelessTextBlockModel } from '@blocksuite/affine-model'; +import { type ToolbarModuleConfig } from '@blocksuite/affine-shared/services'; + +import { createTextActions } from './text-common'; + +export const builtinEdgelessTextToolbarConfig = { + // No need to adjust element bounds, which updates itself using ResizeObserver + actions: createTextActions(EdgelessTextBlockModel, 'edgeless-text'), + + when: ctx => ctx.getSurfaceModelsByType(EdgelessTextBlockModel).length > 0, +} as const satisfies ToolbarModuleConfig; diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/frame.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/frame.ts index 5b1f6c0799..63260d696d 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/frame.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/frame.ts @@ -139,22 +139,20 @@ const builtinSurfaceToolbarConfig = { const models = ctx.getSurfaceModelsByType(FrameBlockModel); if (!models.length) return null; + const theme = ctx.themeProvider.edgelessTheme; const enableCustomColor = ctx.std .get(FeatureFlagService) .getFlag('enable_color_picker'); - const theme = ctx.themeProvider.edgelessTheme; + const field = 'background'; const firstModel = models[0]; const background = getMostCommonResolvedValue( models.map(model => model.props), - 'background', + field, background => resolveColor(background, theme) ) ?? DefaultTheme.transparent; - const onPick = (e: PickColorEvent) => { - const field = 'background'; - if (e.type === 'pick') { const color = e.detail.value; for (const model of models) { diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/index.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/index.ts index bd1892d3d5..c2babcd369 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/index.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/index.ts @@ -5,6 +5,7 @@ import type { ExtensionType } from '@blocksuite/store'; import { builtinBrushToolbarConfig } from './brush'; import { builtinConnectorToolbarConfig } from './connector'; +import { builtinEdgelessTextToolbarConfig } from './edgeless-text'; import { createFrameToolbarConfig } from './frame'; import { builtinGroupToolbarConfig } from './group'; import { builtinMindmapToolbarConfig } from './mindmap'; @@ -36,13 +37,18 @@ export const EdgelessElementToolbarExtension: ExtensionType[] = [ }), ToolbarModuleExtension({ - id: BlockFlavourIdentifier('affine:surface:shape'), - config: builtinShapeToolbarConfig, + id: BlockFlavourIdentifier('affine:surface:text'), + config: builtinTextToolbarConfig, }), ToolbarModuleExtension({ - id: BlockFlavourIdentifier('affine:surface:text'), - config: builtinTextToolbarConfig, + id: BlockFlavourIdentifier('affine:surface:edgeless-text'), + config: builtinEdgelessTextToolbarConfig, + }), + + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('affine:surface:shape'), + config: builtinShapeToolbarConfig, }), ToolbarModuleExtension({ diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/text-common.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/text-common.ts new file mode 100644 index 0000000000..50a584f5ce --- /dev/null +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/text-common.ts @@ -0,0 +1,377 @@ +import { + EdgelessCRUDIdentifier, + TextUtils, +} from '@blocksuite/affine-block-surface'; +import { + packColor, + type PickColorEvent, +} from '@blocksuite/affine-components/color-picker'; +import { + DefaultTheme, + FontFamily, + FontStyle, + FontWeight, + resolveColor, + type SurfaceTextModelMap, + TextAlign, + type TextStyleProps, +} from '@blocksuite/affine-model'; +import { + FeatureFlagService, + type ToolbarActions, + type ToolbarContext, +} from '@blocksuite/affine-shared/services'; +import { + getMostCommonResolvedValue, + getMostCommonValue, +} from '@blocksuite/affine-shared/utils'; +import type { GfxModel } from '@blocksuite/block-std/gfx'; +import { + ArrowDownSmallIcon, + TextAlignCenterIcon, + TextAlignLeftIcon, + TextAlignRightIcon, +} from '@blocksuite/icons/lit'; +import { signal } from '@preact/signals-core'; +import { html } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { MenuItem } from './types'; +import { renderCurrentMenuItemWith, renderMenu } from './utils'; + +const FONT_WEIGHT_LIST = [ + { + key: 'Light', + value: FontWeight.Light, + }, + { + key: 'Regular', + value: FontWeight.Regular, + }, + { + key: 'Semibold', + value: FontWeight.SemiBold, + }, +] as const satisfies MenuItem[]; + +const FONT_STYLE_LIST = [ + { + value: FontStyle.Normal, + }, + { + key: 'Italic', + value: FontStyle.Italic, + }, +] as const satisfies MenuItem[]; + +const FONT_SIZE_LIST = [ + { value: 16 }, + { value: 24 }, + { value: 32 }, + { value: 40 }, + { value: 64 }, + { value: 128 }, +] as const satisfies MenuItem[]; + +const TEXT_ALIGN_LIST = [ + { + key: 'Left', + value: TextAlign.Left, + icon: TextAlignLeftIcon(), + }, + { + key: 'Center', + value: TextAlign.Center, + icon: TextAlignCenterIcon(), + }, + { + key: 'Right', + value: TextAlign.Right, + icon: TextAlignRightIcon(), + }, +] as const satisfies MenuItem[]; + +export function createTextActions< + K extends abstract new (...args: any) => any, + T extends keyof SurfaceTextModelMap, +>( + klass: K, + type: T, + update: ( + ctx: ToolbarContext, + model: InstanceType, + props: Partial + ) => void = (ctx, model, props) => + ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, props), + mapInto: (model: InstanceType) => TextStyleProps = model => model, + stash:

( + model: InstanceType, + type: 'stash' | 'pop', + field: P + ) => void = (model, type, field) => model[type](field) +) { + return [ + { + id: 'a.font', + content(ctx) { + const models = ctx.getSurfaceModelsByType(klass); + if (!models.length) return null; + const allowed = models.every(model => + isSurfaceTextModel(model, klass, type) + ); + if (!allowed) return null; + + const fontFamily = + getMostCommonValue(models.map(mapInto), 'fontFamily') ?? + FontFamily.Inter; + const styleInfo = { fontFamily: TextUtils.wrapFontFamily(fontFamily) }; + + const onPick = (fontFamily: FontFamily) => { + let fontWeight = + getMostCommonValue(models.map(mapInto), 'fontWeight') ?? + FontWeight.Regular; + let fontStyle = + getMostCommonValue(models.map(mapInto), 'fontStyle') ?? + FontStyle.Normal; + + if (!TextUtils.isFontWeightSupported(fontFamily, fontWeight)) { + fontWeight = FontWeight.Regular; + } + if (!TextUtils.isFontStyleSupported(fontFamily, fontStyle)) { + fontStyle = FontStyle.Normal; + } + + for (const model of models) { + update(ctx, model, { fontFamily, fontWeight, fontStyle }); + } + }; + + return html` + + Aa${ArrowDownSmallIcon()} + + `} + > + + + `; + }, + }, + { + id: 'b.text-color', + content(ctx) { + const models = ctx.getSurfaceModelsByType(klass); + if (!models.length) return null; + const allowed = models.every(model => + isSurfaceTextModel(model, klass, type) + ); + if (!allowed) return null; + + const enableCustomColor = ctx.std + .get(FeatureFlagService) + .getFlag('enable_color_picker'); + const theme = ctx.themeProvider.edgelessTheme; + + const palettes = + type === 'shape' + ? DefaultTheme.ShapeTextColorPalettes + : DefaultTheme.Palettes; + const defaultColor = + type === 'shape' + ? DefaultTheme.shapeTextColor + : DefaultTheme.textColor; + + const field = 'color'; + const firstModel = models[0]; + const originalColor = mapInto(firstModel)[field]; + const color = + getMostCommonResolvedValue(models, field, color => + resolveColor(color, theme) + ) ?? resolveColor(defaultColor, theme); + + const onPick = (e: PickColorEvent) => { + if (e.type === 'pick') { + const color = e.detail.value; + for (const model of models) { + const props = packColor(field, color); + update(ctx, model, props); + } + return; + } + + for (const model of models) { + stash(model, e.type === 'start' ? 'stash' : 'pop', field); + } + }; + + return html` + + + `; + }, + }, + { + id: 'c.font-style', + content(ctx) { + const models = ctx.getSurfaceModelsByType(klass); + if (!models.length) return null; + const allowed = models.every(model => + isSurfaceTextModel(model, klass, type) + ); + if (!allowed) return null; + + const fontFamily = + getMostCommonValue(models.map(mapInto), 'fontFamily') ?? + FontFamily.Inter; + const fontWeight = + getMostCommonValue(models.map(mapInto), 'fontWeight') ?? + FontWeight.Regular; + const fontStyle = + getMostCommonValue(models.map(mapInto), 'fontStyle') ?? + FontStyle.Normal; + const matchFontFaces = TextUtils.getFontFacesByFontFamily(fontFamily); + const disabled = + matchFontFaces.length === 1 && + matchFontFaces[0].style === fontStyle && + matchFontFaces[0].weight === fontWeight; + + const onPick = (fontWeight: FontWeight, fontStyle: FontStyle) => { + for (const model of models) { + update(ctx, model, { fontWeight, fontStyle }); + } + }; + + return html` + + + ${renderCurrentMenuItemWith( + FONT_WEIGHT_LIST, + fontWeight, + 'key' + )} + ${renderCurrentMenuItemWith( + FONT_STYLE_LIST, + fontStyle, + 'key' + )} + + ${ArrowDownSmallIcon()} + + `} + > + + + `; + }, + }, + { + id: 'd.font-size', + when: type !== 'edgeless-text', + content(ctx) { + const models = ctx.getSurfaceModelsByType(klass); + if (!models.length) return null; + const allowed = models.every(model => + isSurfaceTextModel(model, klass, type) + ); + if (!allowed) return null; + + const fontSize$ = signal( + Math.trunc( + getMostCommonValue(models.map(mapInto), 'fontSize') ?? + FONT_SIZE_LIST[0].value + ) + ); + + const onPick = (e: CustomEvent) => { + e.stopPropagation(); + + const fontSize = e.detail; + + for (const model of models) { + update(ctx, model, { fontSize }); + } + }; + + return html``; + }, + }, + { + id: 'e.alignment', + content(ctx) { + const models = ctx.getSurfaceModelsByType(klass); + if (!models.length) return null; + const allowed = models.every(model => + isSurfaceTextModel(model, klass, type) + ); + if (!allowed) return null; + + const textAlign = + getMostCommonValue(models.map(mapInto), 'textAlign') ?? + TextAlign.Left; + + const onPick = (textAlign: TextAlign) => { + for (const model of models) { + update(ctx, model, { textAlign }); + } + }; + + return renderMenu({ + label: 'Alignment', + items: TEXT_ALIGN_LIST, + currentValue: textAlign, + onPick, + }); + }, + }, + ] as const satisfies ToolbarActions; +} + +function isSurfaceTextModel< + K extends abstract new (...args: any) => any, + T extends keyof SurfaceTextModelMap, +>(model: GfxModel, klass: K, type: T): model is InstanceType { + return model instanceof klass || ('type' in model && model.type === type); +} diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/text.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/text.ts index bd87943e85..7f8e7beda6 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/text.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/text.ts @@ -1,11 +1,49 @@ -import type { ToolbarModuleConfig } from '@blocksuite/affine-shared/services'; +import { + EdgelessCRUDIdentifier, + TextUtils, +} from '@blocksuite/affine-block-surface'; +import { TextElementModel } from '@blocksuite/affine-model'; +import { type ToolbarModuleConfig } from '@blocksuite/affine-shared/services'; +import { Bound } from '@blocksuite/global/gfx'; + +import { createTextActions } from './text-common'; export const builtinTextToolbarConfig = { - actions: [ - { - id: 'a.test', - label: 'Text', - run() {}, - }, - ], + actions: createTextActions(TextElementModel, 'text', (ctx, model, props) => { + // No need to adjust element bounds + if (props['textAlign']) { + ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, props); + return; + } + + const { text: yText, hasMaxWidth } = model; + const textStyle = { + fontFamily: model.fontFamily, + fontStyle: model.fontStyle, + fontSize: model.fontSize, + fontWeight: model.fontWeight, + ...props, + }; + + const { fontFamily, fontStyle, fontSize, fontWeight } = textStyle; + + const bounds = TextUtils.normalizeTextBound( + { + yText, + fontFamily, + fontStyle, + fontSize, + fontWeight, + hasMaxWidth, + }, + Bound.fromXYWH(model.deserializedXYWH) + ); + + ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, { + ...textStyle, + xywh: bounds.serialize(), + }); + }), + + when: ctx => ctx.getSurfaceModelsByType(TextElementModel).length > 0, } as const satisfies ToolbarModuleConfig; diff --git a/blocksuite/affine/shared/src/services/toolbar-service/context.ts b/blocksuite/affine/shared/src/services/toolbar-service/context.ts index 3811f99b77..4698b28553 100644 --- a/blocksuite/affine/shared/src/services/toolbar-service/context.ts +++ b/blocksuite/affine/shared/src/services/toolbar-service/context.ts @@ -137,16 +137,18 @@ abstract class ToolbarContextBase { ); } + getSurfaceModels() { + if (this.hasSelectedSurfaceModels) { + const elements = this.elementsMap$.peek().get(this.flavour$.peek()); + return elements ?? []; + } + return []; + } + getSurfaceModelsByType any>( klass: T ) { - if (this.hasSelectedSurfaceModels) { - const elements = this.elementsMap$.peek().get(this.flavour$.peek()); - if (elements?.length) { - return elements.filter(e => this.matchModel(e, klass)); - } - } - return []; + return this.getSurfaceModels().filter(e => this.matchModel(e, klass)); } getSurfaceBlocksByType any>( diff --git a/blocksuite/affine/shared/src/utils/computing.ts b/blocksuite/affine/shared/src/utils/computing.ts index 843d0b51a7..ad2777c6b7 100644 --- a/blocksuite/affine/shared/src/utils/computing.ts +++ b/blocksuite/affine/shared/src/utils/computing.ts @@ -11,13 +11,13 @@ export function getMostCommonValue( return record?.[field]; } -export function getMostCommonResolvedValue< - T, - F extends Exclude, - U, ->(records: T[], field: F, resolve: (value: T[F]) => U) { +export function getMostCommonResolvedValue( + records: T[], + field: F, + resolve: (value: T[F]) => U +) { return getMostCommonValue( records.map(record => ({ [field]: resolve(record[field]) })), - field + String(field) ); }