refactor(editor): edgeless connector toolbar config extension (#10798)

This commit is contained in:
fundon
2025-03-19 14:50:55 +00:00
parent b099546164
commit 7f34667b78
12 changed files with 476 additions and 54 deletions
@@ -1,51 +1,372 @@
import type { ToolbarModuleConfig } from '@blocksuite/affine-shared/services';
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import {
packColor,
type PickColorEvent,
} from '@blocksuite/affine-components/color-picker';
import type { LineDetailType } from '@blocksuite/affine-components/edgeless-line-styles-panel';
import {
ConnectorElementModel,
type ConnectorElementProps,
type ConnectorLabelProps,
ConnectorMode,
DEFAULT_FRONT_ENDPOINT_STYLE,
DEFAULT_REAR_ENDPOINT_STYLE,
DefaultTheme,
LineWidth,
PointStyle,
resolveColor,
StrokeStyle,
} from '@blocksuite/affine-model';
import {
FeatureFlagService,
type ToolbarContext,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import {
getMostCommonResolvedValue,
getMostCommonValue,
} from '@blocksuite/affine-shared/utils';
import {
AddTextIcon,
ConnectorCIcon,
ConnectorEIcon,
ConnectorLIcon,
EndPointArrowIcon,
EndPointCircleIcon,
EndPointDiamondIcon,
EndPointTriangleIcon,
FlipDirectionIcon,
StartPointArrowIcon,
StartPointCircleIcon,
StartPointDiamondIcon,
StartPointIcon,
StartPointTriangleIcon,
} from '@blocksuite/icons/lit';
import { html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { EdgelessRootBlockComponent } from '../..';
import { mountConnectorLabelEditor } from '../../utils/text';
import { LINE_STYLE_LIST } from './consts';
import type { MenuItem } from './types';
import { renderMenu } from './utils';
const FRONT_ENDPOINT_STYLE_LIST = [
{
value: PointStyle.None,
icon: StartPointIcon(),
},
{
value: PointStyle.Arrow,
icon: StartPointArrowIcon(),
},
{
value: PointStyle.Triangle,
icon: StartPointTriangleIcon(),
},
{
value: PointStyle.Circle,
icon: StartPointCircleIcon(),
},
{
value: PointStyle.Diamond,
icon: StartPointDiamondIcon(),
},
] as const satisfies MenuItem<PointStyle>[];
const REAR_ENDPOINT_STYLE_LIST = [
{
value: PointStyle.Diamond,
icon: EndPointDiamondIcon(),
},
{
value: PointStyle.Circle,
icon: EndPointCircleIcon(),
},
{
value: PointStyle.Triangle,
icon: EndPointTriangleIcon(),
},
{
value: PointStyle.Arrow,
icon: EndPointArrowIcon(),
},
{
value: PointStyle.None,
icon: StartPointIcon(),
},
] as const satisfies MenuItem<PointStyle>[];
const CONNECTOR_MODE_LIST = [
{
key: 'Curve',
value: ConnectorMode.Curve,
icon: ConnectorCIcon(),
},
{
key: 'Elbowed',
value: ConnectorMode.Orthogonal,
icon: ConnectorEIcon(),
},
{
key: 'Straight',
value: ConnectorMode.Straight,
icon: ConnectorLIcon(),
},
] as const satisfies MenuItem<ConnectorMode>[];
export const builtinConnectorToolbarConfig = {
actions: [
{
id: 'a.stroke-color',
tooltip: 'Stroke style',
run() {},
content(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return null;
const enableCustomColor = ctx.std
.get(FeatureFlagService)
.getFlag('enable_color_picker');
const theme = ctx.themeProvider.edgelessTheme;
const firstModel = models[0];
const strokeWidth =
getMostCommonValue(models, 'strokeWidth') ?? LineWidth.Four;
const strokeStyle =
getMostCommonValue(models, 'strokeStyle') ?? StrokeStyle.Solid;
const stroke =
getMostCommonResolvedValue(models, 'stroke', 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) {
const props = packColor(field, color);
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, props);
}
return;
}
for (const model of models) {
model[e.type === 'start' ? 'stash' : 'pop'](field);
}
};
const onPickStrokeStyle = (e: CustomEvent<LineDetailType>) => {
e.stopPropagation();
const { type, value } = e.detail;
if (type === 'size') {
updateModelsWith(ctx, models, 'strokeWidth', value);
return;
}
updateModelsWith(ctx, models, 'strokeStyle', value);
};
return html`
<edgeless-color-picker-button
class="stroke-color"
.label="${'Stroke style'}"
.pick=${onPickColor}
.color=${stroke}
.theme=${theme}
.hollowCircle=${true}
.originalColor=${firstModel.stroke}
.enableCustomColor=${enableCustomColor}
>
<affine-edgeless-line-styles-panel
slot="other"
style=${styleMap({
display: 'flex',
alignSelf: 'stretch',
gap: '8px',
})}
@select=${onPickStrokeStyle}
.lineSize=${strokeWidth}
.lineStyle=${strokeStyle}
></affine-edgeless-line-styles-panel>
<editor-toolbar-separator
slot="separator"
data-orientation="horizontal"
></editor-toolbar-separator>
</edgeless-color-picker-button>
`;
},
},
{
id: 'b.style',
tooltip: 'Style',
run() {},
// TODO(@fundon): should add a feature flag
when: false,
content(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return null;
const field = 'rough';
const rough = getMostCommonValue(models, field) ?? false;
const onPick = (value: boolean) => {
updateModelsWith(ctx, models, field, value);
};
return renderMenu({
label: 'Style',
items: LINE_STYLE_LIST,
currentValue: rough,
onPick,
});
},
},
{
id: 'c.start-point-style',
icon: StartPointIcon(),
tooltip: 'Start point style',
run() {},
},
{
id: 'd.flip-direction',
icon: FlipDirectionIcon(),
tooltip: 'Flip direction',
run() {},
},
{
id: 'e.end-point-style',
icon: StartPointIcon(),
tooltip: 'End point style',
run() {},
},
{
id: 'f.connector-shape',
icon: ConnectorCIcon(),
tooltip: 'Connector shape',
run() {},
id: 'c.endpoint-style',
actions: [
{
id: 'a.start-point-style',
content(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return null;
const field = 'frontEndpointStyle';
const pointStyle =
getMostCommonValue(models, field) ?? DEFAULT_FRONT_ENDPOINT_STYLE;
const onPick = (value: PointStyle) => {
updateModelsWith(ctx, models, field, value);
};
return renderMenu({
label: 'Start point style',
items: FRONT_ENDPOINT_STYLE_LIST,
currentValue: pointStyle,
onPick,
});
},
},
{
id: 'b.flip-direction',
icon: FlipDirectionIcon(),
tooltip: 'Flip direction',
run(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return;
const frontEndpointStyle =
getMostCommonValue(models, 'frontEndpointStyle') ??
DEFAULT_FRONT_ENDPOINT_STYLE;
const rearEndpointStyle =
getMostCommonValue(models, 'rearEndpointStyle') ??
DEFAULT_REAR_ENDPOINT_STYLE;
if (frontEndpointStyle === rearEndpointStyle) return;
for (const model of models) {
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
frontEndpointStyle: rearEndpointStyle,
rearEndpointStyle: frontEndpointStyle,
});
}
},
},
{
id: 'c.end-point-style',
content(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return null;
const field = 'rearEndpointStyle';
const pointStyle =
getMostCommonValue(models, field) ?? DEFAULT_REAR_ENDPOINT_STYLE;
const onPick = (value: PointStyle) => {
updateModelsWith(ctx, models, field, value);
};
return renderMenu({
label: 'End point style',
items: REAR_ENDPOINT_STYLE_LIST,
currentValue: pointStyle,
onPick,
});
},
},
{
id: 'd.connector-shape',
content(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return null;
const field = 'mode';
const mode =
getMostCommonValue(models, field) ?? ConnectorMode.Orthogonal;
const onPick = (value: ConnectorMode) => {
updateModelsWith(ctx, models, field, value);
};
return renderMenu({
label: 'Shape',
tooltip: 'Connector shape',
items: CONNECTOR_MODE_LIST,
currentValue: mode,
onPick,
});
},
},
],
},
{
id: 'g.add-text',
icon: AddTextIcon(),
run() {},
when(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
return models.length === 1 && !models[0].text;
},
run(ctx) {
const model = ctx.getCurrentModelByType(ConnectorElementModel);
if (!model) return;
const rootModel = ctx.store.root;
if (!rootModel) return;
// TODO(@fundon): it should be simple
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
mountConnectorLabelEditor(model, edgeless);
},
},
{
id: 'g.text',
when(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
return models.length > 0 && !models.some(model => !model.text);
},
// TODO(@fundon): text actoins
},
],
when: ctx => ctx.getSurfaceModelsByType(ConnectorElementModel).length > 0,
} as const satisfies ToolbarModuleConfig;
function updateModelsWith<
T extends keyof Omit<ConnectorElementProps, keyof ConnectorLabelProps>,
>(
ctx: ToolbarContext,
models: ConnectorElementModel[],
field: T,
value: ConnectorElementProps[T]
) {
ctx.store.captureSync();
for (const model of models) {
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, { [field]: value });
}
}
@@ -0,0 +1,16 @@
import { StyleGeneralIcon, StyleScribbleIcon } from '@blocksuite/icons/lit';
import type { MenuItem } from './types';
export const LINE_STYLE_LIST = [
{
key: 'General',
value: false,
icon: StyleGeneralIcon(),
},
{
key: 'Scribbled',
value: true,
icon: StyleScribbleIcon(),
},
] as const satisfies MenuItem<boolean>[];
@@ -1,5 +1,5 @@
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
import { EdgelessCRUDExtension } from '@blocksuite/affine-block-surface';
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import {
packColor,
type PickColorEvent,
@@ -159,7 +159,9 @@ const builtinSurfaceToolbarConfig = {
const color = e.detail.value;
for (const model of models) {
const props = packColor(field, color);
ctx.std.get(EdgelessCRUDExtension).updateElement(model.id, props);
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, props);
}
return;
}
@@ -0,0 +1,16 @@
import type { TemplateResult } from 'lit';
export type MenuItem<T> = {
key?: string;
value: T;
icon?: TemplateResult;
};
export type Menu<T> = {
label: string;
icon?: TemplateResult;
tooltip?: string;
items: MenuItem<T>[];
currentValue: T;
onPick: (value: T) => void;
};
@@ -0,0 +1,61 @@
import { ArrowDownSmallIcon } from '@blocksuite/icons/lit';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { repeat } from 'lit/directives/repeat.js';
import type { Menu, MenuItem } from './types';
export function renderCurrentMenuItemWith<T, F extends keyof MenuItem<T>>(
items: MenuItem<T>[],
currentValue: T,
field: F
) {
return items.find(({ value }) => value === currentValue)?.[field];
}
export function renderMenu<T>({
label,
tooltip,
icon,
items,
currentValue,
onPick,
}: Menu<T>) {
return html`
<editor-menu-button
.button=${html`
<editor-icon-button
aria-label="${label}"
.tooltip="${tooltip ?? label}"
>
${icon ?? renderCurrentMenuItemWith(items, currentValue, 'icon')}
${ArrowDownSmallIcon()}
</editor-icon-button>
`}
>
${renderMenuItems(items, currentValue, onPick)}
</editor-menu-button>
`;
}
export function renderMenuItems<T>(
items: MenuItem<T>[],
currentValue: T,
onPick: (value: T) => void
) {
return repeat(
items,
item => item.value,
({ key, value, icon }) => html`
<editor-icon-button
aria-label="${ifDefined(key)}"
.tooltip="${ifDefined(key)}"
.active="${currentValue === value}"
.activeMode="${'background'}"
@click=${() => onPick(value)}
>
${icon}
</editor-icon-button>
`
);
}
@@ -12,8 +12,8 @@ import {
ConnectorEndpoint,
type ConnectorLabelProps,
ConnectorMode,
DEFAULT_FRONT_END_POINT_STYLE,
DEFAULT_REAR_END_POINT_STYLE,
DEFAULT_FRONT_ENDPOINT_STYLE,
DEFAULT_REAR_ENDPOINT_STYLE,
DefaultTheme,
LineWidth,
PointStyle,
@@ -341,12 +341,12 @@ export class EdgelessChangeConnectorButton extends WithDisposable(LitElement) {
const selectedStartPointStyle = getMostCommonEndpointStyle(
elements,
ConnectorEndpoint.Front,
DEFAULT_FRONT_END_POINT_STYLE
DEFAULT_FRONT_ENDPOINT_STYLE
);
const selectedEndPointStyle = getMostCommonEndpointStyle(
elements,
ConnectorEndpoint.Rear,
DEFAULT_REAR_END_POINT_STYLE
DEFAULT_REAR_ENDPOINT_STYLE
);
const enableCustomColor = this.edgeless.doc
.get(FeatureFlagService)