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,5 +1,5 @@
import {
EdgelessCRUDExtension,
EdgelessCRUDIdentifier,
EdgelessLegacySlotIdentifier,
} from '@blocksuite/affine-block-surface';
import {
@@ -172,7 +172,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;
}
@@ -230,7 +232,7 @@ const builtinSurfaceToolbarConfig = {
const shadowType = e.detail;
for (const model of models) {
const edgeless = model.props.edgeless;
ctx.std.get(EdgelessCRUDExtension).updateElement(model.id, {
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
edgeless: {
...edgeless,
style: {
@@ -270,7 +272,7 @@ const builtinSurfaceToolbarConfig = {
const borderSize = value;
for (const model of models) {
const edgeless = model.props.edgeless;
ctx.std.get(EdgelessCRUDExtension).updateElement(model.id, {
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
edgeless: {
...edgeless,
style: {
@@ -286,7 +288,7 @@ const builtinSurfaceToolbarConfig = {
const borderStyle = value;
for (const model of models) {
const edgeless = model.props.edgeless;
ctx.std.get(EdgelessCRUDExtension).updateElement(model.id, {
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
edgeless: {
...edgeless,
style: {
@@ -328,7 +330,7 @@ const builtinSurfaceToolbarConfig = {
const borderRadius = e.detail;
for (const model of models) {
const edgeless = model.props.edgeless;
ctx.std.get(EdgelessCRUDExtension).updateElement(model.id, {
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
edgeless: {
...edgeless,
style: {
@@ -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)
@@ -1,5 +1,5 @@
import {
EdgelessCRUDExtension,
EdgelessCRUDIdentifier,
getSurfaceBlock,
type SurfaceBlockModel,
SurfaceElementModel,
@@ -457,7 +457,7 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
private readonly _disposable = new DisposableGroup();
override mounted() {
const crud = this.std.get(EdgelessCRUDExtension);
const crud = this.std.get(EdgelessCRUDIdentifier);
const { _disposable } = this;
const surfaceModel = getSurfaceBlock(this.std.store);
if (!surfaceModel) return;
@@ -17,17 +17,17 @@ export type LineDetailType =
const LINE_STYLE_LIST = [
{
name: 'Solid',
key: 'Solid',
value: StrokeStyle.Solid,
icon: StraightLineIcon(),
},
{
name: 'Dash',
key: 'Dash',
value: StrokeStyle.Dash,
icon: DashLineIcon(),
},
{
name: 'None',
key: 'None',
value: StrokeStyle.None,
icon: BanIcon(),
},
@@ -63,7 +63,7 @@ export class EdgelessLineStylesPanel extends LitElement {
${repeat(
LINE_STYLE_LIST.filter(item => lineStyles.includes(item.value)),
item => item.value,
({ name, icon, value }) => {
({ key, icon, value }) => {
const active = lineStyle === value;
const classInfo = {
'line-style-button': true,
@@ -74,7 +74,7 @@ export class EdgelessLineStylesPanel extends LitElement {
return html`
<editor-icon-button
class=${classMap(classInfo)}
.tooltip="${name}"
.tooltip="${key}"
.withHover=${active}
@click=${() => this.select({ type: 'style', value })}
>
@@ -15,9 +15,9 @@ export enum PointStyle {
export const PointStyleMap = createEnumMap(PointStyle);
export const DEFAULT_FRONT_END_POINT_STYLE = PointStyle.None;
export const DEFAULT_FRONT_ENDPOINT_STYLE = PointStyle.None;
export const DEFAULT_REAR_END_POINT_STYLE = PointStyle.Arrow;
export const DEFAULT_REAR_ENDPOINT_STYLE = PointStyle.Arrow;
export const CONNECTOR_LABEL_MAX_WIDTH = 280;
@@ -32,3 +32,5 @@ export enum ConnectorMode {
Orthogonal,
Curve,
}
export const DEFAULT_CONNECTOR_MODE = ConnectorMode.Curve;
@@ -29,6 +29,7 @@ import {
CONNECTOR_LABEL_MAX_WIDTH,
ConnectorLabelOffsetAnchor,
ConnectorMode,
DEFAULT_CONNECTOR_MODE,
DEFAULT_ROUGHNESS,
FontFamily,
FontStyle,
@@ -460,7 +461,7 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
accessor lableEditing: boolean = false;
@field()
accessor mode: ConnectorMode = ConnectorMode.Orthogonal;
accessor mode: ConnectorMode = DEFAULT_CONNECTOR_MODE;
@derive((path: PointLocation[], instance) => {
const { x, y } = instance;
@@ -1,8 +1,9 @@
import {
ColorSchema,
ConnectorMode,
DEFAULT_FRONT_END_POINT_STYLE,
DEFAULT_REAR_END_POINT_STYLE,
DEFAULT_CONNECTOR_MODE,
DEFAULT_FRONT_ENDPOINT_STYLE,
DEFAULT_REAR_ENDPOINT_STYLE,
DEFAULT_ROUGHNESS,
DefaultTheme,
EdgelessTextZodSchema,
@@ -61,13 +62,13 @@ export const ConnectorSchema = z
}),
})
.default({
frontEndpointStyle: DEFAULT_FRONT_END_POINT_STYLE,
rearEndpointStyle: DEFAULT_REAR_END_POINT_STYLE,
frontEndpointStyle: DEFAULT_FRONT_ENDPOINT_STYLE,
rearEndpointStyle: DEFAULT_REAR_ENDPOINT_STYLE,
stroke: DefaultTheme.connectorColor,
strokeStyle: StrokeStyle.Solid,
strokeWidth: LineWidth.Two,
rough: false,
mode: ConnectorMode.Curve,
mode: DEFAULT_CONNECTOR_MODE,
labelStyle: {
color: DefaultTheme.black,
fontSize: 16,