refactor(editor): edgeless text toolbar config extension (#10811)

This commit is contained in:
fundon
2025-03-20 02:08:15 +00:00
parent cdd405bbe5
commit 1acc7e5a9e
9 changed files with 527 additions and 43 deletions

View File

@@ -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}
>
</edgeless-color-picker-button>

View File

@@ -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<ToolbarGenericAction>(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,

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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<FontWeight>[];
const FONT_STYLE_LIST = [
{
value: FontStyle.Normal,
},
{
key: 'Italic',
value: FontStyle.Italic,
},
] as const satisfies MenuItem<FontStyle>[];
const FONT_SIZE_LIST = [
{ value: 16 },
{ value: 24 },
{ value: 32 },
{ value: 40 },
{ value: 64 },
{ value: 128 },
] as const satisfies MenuItem<number>[];
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<TextAlign>[];
export function createTextActions<
K extends abstract new (...args: any) => any,
T extends keyof SurfaceTextModelMap,
>(
klass: K,
type: T,
update: (
ctx: ToolbarContext,
model: InstanceType<K>,
props: Partial<TextStyleProps>
) => void = (ctx, model, props) =>
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, props),
mapInto: (model: InstanceType<K>) => TextStyleProps = model => model,
stash: <P extends keyof TextStyleProps>(
model: InstanceType<K>,
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`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="Font"
.tooltip="${'Font'}"
.justify="${'space-between'}"
.iconContainerWidth="${'40px'}"
>
<span class="label padding0" style=${styleMap(styleInfo)}
>Aa</span
>${ArrowDownSmallIcon()}
</editor-icon-button>
`}
>
<edgeless-font-family-panel
.value=${fontFamily}
.onSelect=${onPick}
></edgeless-font-family-panel>
</editor-menu-button>
`;
},
},
{
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`
<edgeless-color-picker-button
class="text-color"
.label="${'Text color'}"
.pick=${onPick}
.color=${color}
.theme=${theme}
.isText=${true}
.hollowCircle=${true}
.originalColor=${originalColor}
.palettes=${palettes}
.enableCustomColor=${enableCustomColor}
>
</edgeless-color-picker-button>
`;
},
},
{
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`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="Font style"
.tooltip="${'Font style'}"
.justify="${'space-between'}"
.iconContainerWidth="${'90px'}"
.disabled=${disabled}
>
<span class="label ellipsis">
${renderCurrentMenuItemWith(
FONT_WEIGHT_LIST,
fontWeight,
'key'
)}
${renderCurrentMenuItemWith(
FONT_STYLE_LIST,
fontStyle,
'key'
)}
</span>
${ArrowDownSmallIcon()}
</editor-icon-button>
`}
>
<edgeless-font-weight-and-style-panel
.fontFamily=${fontFamily}
.fontWeight=${fontWeight}
.fontStyle=${fontStyle}
.onSelect=${onPick}
></edgeless-font-weight-and-style-panel>
</editor-menu-button>
`;
},
},
{
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<number>) => {
e.stopPropagation();
const fontSize = e.detail;
for (const model of models) {
update(ctx, model, { fontSize });
}
};
return html`<affine-size-dropdown-menu
@select=${onPick}
.label="${'Font size'}"
.sizes=${FONT_SIZE_LIST}
.size$=${fontSize$}
></affine-size-dropdown-menu>`;
},
},
{
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<K> {
return model instanceof klass || ('type' in model && model.type === type);
}

View File

@@ -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;