feat(editor): gfx connector package (#11091)

This commit is contained in:
Saul-Mirone
2025-03-22 14:39:05 +00:00
parent d398ee4dfa
commit 1f0fc9d47a
38 changed files with 284 additions and 137 deletions
@@ -0,0 +1,167 @@
import {
type ConnectionOverlay,
EdgelessLegacySlotIdentifier,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import {
type BlockComponent,
type BlockStdScope,
docContext,
stdContext,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { Vec } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import type { Store } from '@blocksuite/store';
import { consume } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
const SIZE = 12;
const HALF_SIZE = SIZE / 2;
export class EdgelessConnectorHandle extends WithDisposable(LitElement) {
static override styles = css`
.line-controller {
position: absolute;
width: ${SIZE}px;
height: ${SIZE}px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid var(--affine-text-emphasis-color);
background-color: var(--affine-background-primary-color);
cursor: pointer;
z-index: 10;
pointer-events: all;
/**
* Fix: pointerEvent stops firing after a short time.
* When a gesture is started, the browser intersects the touch-action values of the touched element and its ancestors,
* up to the one that implements the gesture (in other words, the first containing scrolling element)
* https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action
*/
touch-action: none;
}
.line-controller-hidden {
display: none;
}
`;
private _lastZoom = 1;
get connectionOverlay() {
return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay;
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get slots() {
return this.std.get(EdgelessLegacySlotIdentifier);
}
private _bindEvent() {
const slots = this.slots;
this._disposables.addFromEvent(this._startHandler, 'pointerdown', e => {
slots.elementResizeStart.next();
this._capPointerDown(e, 'source');
});
this._disposables.addFromEvent(this._endHandler, 'pointerdown', e => {
slots.elementResizeStart.next();
this._capPointerDown(e, 'target');
});
this._disposables.add(() => {
this.connectionOverlay.clear();
});
}
private _capPointerDown(e: PointerEvent, connection: 'target' | 'source') {
const { gfx, connector, slots, _disposables } = this;
e.stopPropagation();
_disposables.addFromEvent(document, 'pointermove', e => {
const point = gfx.viewport.toModelCoordFromClientCoord([e.x, e.y]);
const isStartPointer = connection === 'source';
const otherSideId = connector[isStartPointer ? 'target' : 'source'].id;
connector[connection] = this.connectionOverlay.renderConnector(
point,
otherSideId ? [otherSideId] : []
);
this.requestUpdate();
});
_disposables.addFromEvent(document, 'pointerup', () => {
this.doc.captureSync();
_disposables.dispose();
this._disposables = new DisposableGroup();
this._bindEvent();
slots.elementResizeEnd.next();
});
}
override firstUpdated() {
const { gfx } = this;
const { viewport } = gfx;
this._lastZoom = viewport.zoom;
viewport.viewportUpdated.subscribe(() => {
if (viewport.zoom !== this._lastZoom) {
this._lastZoom = viewport.zoom;
this.requestUpdate();
}
});
this._bindEvent();
}
override render() {
const { gfx } = this;
// path is relative to the element's xywh
const { path } = this.connector;
const zoom = gfx.viewport.zoom;
const startPoint = Vec.subScalar(Vec.mul(path[0], zoom), HALF_SIZE);
const endPoint = Vec.subScalar(
Vec.mul(path[path.length - 1], zoom),
HALF_SIZE
);
const startStyle = {
transform: `translate3d(${startPoint[0]}px,${startPoint[1]}px,0)`,
};
const endStyle = {
transform: `translate3d(${endPoint[0]}px,${endPoint[1]}px,0)`,
};
return html`
<div
class="line-controller line-start"
style=${styleMap(startStyle)}
></div>
<div class="line-controller line-end" style=${styleMap(endStyle)}></div>
`;
}
@query('.line-end')
private accessor _endHandler!: HTMLDivElement;
@query('.line-start')
private accessor _startHandler!: HTMLDivElement;
@property({ attribute: false })
accessor connector!: ConnectorElementModel;
@consume({
context: docContext,
})
accessor doc!: Store;
@property({ attribute: false })
accessor edgeless!: BlockComponent;
@consume({
context: stdContext,
})
accessor std!: BlockStdScope;
}
@@ -0,0 +1,233 @@
import {
calculateNearestLocation,
CanvasElementType,
type ConnectionOverlay,
ConnectorEndpointLocations,
ConnectorEndpointLocationsOnTriangle,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import type {
Connection,
ConnectorElementModel,
ConnectorMode,
} from '@blocksuite/affine-model';
import {
GroupElementModel,
ShapeElementModel,
ShapeType,
} from '@blocksuite/affine-model';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type { PointerEventState } from '@blocksuite/block-std';
import { BaseTool, type GfxModel } from '@blocksuite/block-std/gfx';
import type { IBound, IVec } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
enum ConnectorToolMode {
// Dragging connect
Dragging,
// Quick connect
Quick,
}
export type ConnectorToolOptions = {
mode: ConnectorMode;
};
export class ConnectorTool extends BaseTool<ConnectorToolOptions> {
static override toolName: string = 'connector';
// Likes pressing `ESC`
private _allowCancel = false;
private _connector: ConnectorElementModel | null = null;
private _mode: ConnectorToolMode = ConnectorToolMode.Dragging;
private _source: Connection | null = null;
private _sourceBounds: IBound | null = null;
private _sourceLocations: IVec[] = ConnectorEndpointLocations;
private _startPoint: IVec | null = null;
private get _overlay() {
return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay;
}
private _createConnector() {
if (!(this._source && this._startPoint) || !this.gfx.surface) {
this._source = null;
this._startPoint = null;
return;
}
this.doc.captureSync();
const id = this.gfx.surface.addElement({
type: CanvasElementType.CONNECTOR,
mode: this.activatedOption.mode,
controllers: [],
source: this._source,
target: { position: this._startPoint },
});
this.gfx.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:draw',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: CanvasElementType.CONNECTOR,
});
const connector = this.gfx.getElementById(id);
if (!connector) {
this._source = null;
this._startPoint = null;
return;
}
this._connector = connector as ConnectorElementModel;
}
override click() {
if (this._mode === ConnectorToolMode.Dragging) return;
if (!this._connector) return;
const { id, source, target } = this._connector;
let focusedId = id;
if (source?.id && !target?.id) {
focusedId = source.id;
this._allowCancel = true;
}
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.gfx.tool.setTool('default');
this.gfx.selection.set({ elements: [focusedId] });
}
override deactivate() {
const id = this._connector?.id;
if (this._allowCancel && id) {
this.gfx.surface?.deleteElement(id);
}
this._overlay?.clear();
this._mode = ConnectorToolMode.Dragging;
this._connector = null;
this._source = null;
this._sourceBounds = null;
this._startPoint = null;
this._allowCancel = false;
}
override dragEnd() {
if (this._mode === ConnectorToolMode.Quick) return;
if (!this._connector) return;
const connector = this._connector;
this.doc.captureSync();
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.gfx.tool.setTool('default');
this.gfx.selection.set({ elements: [connector.id] });
}
override dragMove(e: PointerEventState) {
this.findTargetByPoint([e.x, e.y]);
}
override dragStart() {
if (this._mode === ConnectorToolMode.Quick) return;
this._createConnector();
}
findTargetByPoint(point: IVec) {
if (!this._connector || !this.gfx.surface) return;
const { _connector } = this;
point = this.gfx.viewport.toModelCoord(point[0], point[1]);
const excludedIds = [];
if (_connector.source?.id) {
excludedIds.push(_connector.source.id);
}
const target = this._overlay?.renderConnector(point, excludedIds);
this.gfx.updateElement(_connector, { target });
}
override pointerDown(e: PointerEventState) {
this._startPoint = this.gfx.viewport.toModelCoord(e.x, e.y);
this._source = this._overlay?.renderConnector(this._startPoint) ?? null;
}
override pointerMove(e: PointerEventState) {
if (this._mode === ConnectorToolMode.Dragging) return;
if (!this._sourceBounds) return;
if (!this._connector) return;
const sourceId = this._connector.source?.id;
if (!sourceId) return;
const point = this.gfx.viewport.toModelCoord(e.x, e.y);
const target = this._overlay!.renderConnector(point, [sourceId]);
this._allowCancel = !target.id;
this._connector.source.position = calculateNearestLocation(
point,
this._sourceBounds,
this._sourceLocations
);
this.gfx.updateElement(this._connector, {
target,
source: this._connector.source,
});
}
override pointerUp(_: PointerEventState): void {
this._overlay?.clear();
}
quickConnect(point: IVec, element: GfxModel) {
this._startPoint = this.gfx.viewport.toModelCoord(point[0], point[1]);
this._mode = ConnectorToolMode.Quick;
this._sourceBounds = Bound.deserialize(element.xywh);
this._sourceBounds.rotate = element.rotate;
this._sourceLocations =
element instanceof ShapeElementModel &&
element.shapeType === ShapeType.Triangle
? ConnectorEndpointLocationsOnTriangle
: ConnectorEndpointLocations;
this._source = {
id: element.id,
position: calculateNearestLocation(
this._startPoint,
this._sourceBounds,
this._sourceLocations
),
};
this._allowCancel = true;
this._createConnector();
if (element instanceof GroupElementModel && this._overlay) {
this._overlay.sourceBounds = this._sourceBounds;
}
this.findTargetByPoint(point);
}
}
declare module '@blocksuite/block-std/gfx' {
interface GfxToolsMap {
connector: ConnectorTool;
}
interface GfxToolsOption {
connector: ConnectorToolOptions;
}
}
@@ -0,0 +1,26 @@
import { EdgelessConnectorHandle } from './components/connector-handle';
import { EdgelessConnectorLabelEditor } from './text/edgeless-connector-label-editor';
import { EdgelessConnectorMenu } from './toolbar/connector-menu';
import { EdgelessConnectorToolButton } from './toolbar/connector-tool-button';
export function effects() {
customElements.define(
'edgeless-connector-tool-button',
EdgelessConnectorToolButton
);
customElements.define('edgeless-connector-menu', EdgelessConnectorMenu);
customElements.define(
'edgeless-connector-label-editor',
EdgelessConnectorLabelEditor
);
customElements.define('edgeless-connector-handle', EdgelessConnectorHandle);
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-connector-tool-button': EdgelessConnectorToolButton;
'edgeless-connector-menu': EdgelessConnectorMenu;
'edgeless-connector-label-editor': EdgelessConnectorLabelEditor;
'edgeless-connector-handle': EdgelessConnectorHandle;
}
}
@@ -0,0 +1,4 @@
export * from './connector-tool';
export * from './text';
export * from './toolbar/config';
export * from './toolbar/quick-tool';
@@ -0,0 +1,334 @@
import {
EdgelessCRUDIdentifier,
getSurfaceBlock,
TextUtils,
} from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import type { RichText } from '@blocksuite/affine-rich-text';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { almostEqual } from '@blocksuite/affine-shared/utils';
import { type BlockComponent, ShadowlessElement } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std/inline';
import { Bound, Vec } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import { css, html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import * as Y from 'yjs';
const HORIZONTAL_PADDING = 2;
const VERTICAL_PADDING = 2;
const BORDER_WIDTH = 1;
export class EdgelessConnectorLabelEditor extends WithDisposable(
ShadowlessElement
) {
static override styles = css`
.edgeless-connector-label-editor {
position: absolute;
left: 0;
top: 0;
transform-origin: center;
z-index: 10;
padding: ${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px;
border: ${BORDER_WIDTH}px solid var(--affine-primary-color, #1e96eb);
background: var(--affine-background-primary-color, #fff);
border-radius: 2px;
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
box-sizing: border-box;
overflow: visible;
.inline-editor {
white-space: pre-wrap !important;
outline: none;
}
.inline-editor span {
word-break: normal !important;
overflow-wrap: anywhere !important;
}
.edgeless-connector-label-editor-placeholder {
pointer-events: none;
color: var(--affine-text-disable-color);
white-space: nowrap;
}
}
`;
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
get gfx() {
return this.edgeless.std.get(GfxControllerIdentifier);
}
get selection() {
return this.gfx.selection;
}
private _isComposition = false;
private _keeping = false;
private _resizeObserver: ResizeObserver | null = null;
private readonly _updateLabelRect = () => {
const { connector, edgeless } = this;
if (!connector || !edgeless) return;
if (!this.inlineEditorContainer) return;
const newWidth = this.inlineEditorContainer.scrollWidth;
const newHeight = this.inlineEditorContainer.scrollHeight;
const center = connector.getPointByOffsetDistance(
connector.labelOffset.distance
);
const bounds = Bound.fromCenter(center, newWidth, newHeight);
const labelXYWH = bounds.toXYWH();
if (
!connector.labelXYWH ||
labelXYWH.some((p, i) => !almostEqual(p, connector.labelXYWH![i]))
) {
this.crud.updateElement(connector.id, {
labelXYWH,
});
}
};
get inlineEditor() {
return this.richText.inlineEditor;
}
get inlineEditorContainer() {
return this.inlineEditor?.rootElement;
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override disconnectedCallback() {
super.disconnectedCallback();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
}
override firstUpdated() {
const { edgeless, connector, selection } = this;
const dispatcher = edgeless.std.event;
const store = edgeless.std.store;
this._resizeObserver = new ResizeObserver(() => {
this._updateLabelRect();
this.requestUpdate();
});
this._resizeObserver.observe(this.richText);
this.updateComplete
.then(() => {
if (!this.inlineEditor) return;
this.inlineEditor.selectAll();
this.inlineEditor.slots.renderComplete.subscribe(() => {
this.requestUpdate();
});
this.disposables.add(
dispatcher.add('keyDown', ctx => {
const state = ctx.get('keyboardState');
const { key, ctrlKey, metaKey, altKey, shiftKey, isComposing } =
state.raw;
const onlyCmd = (ctrlKey || metaKey) && !altKey && !shiftKey;
const isModEnter = onlyCmd && key === 'Enter';
const isEscape = key === 'Escape';
if (!isComposing && (isModEnter || isEscape)) {
this.inlineEditorContainer?.blur();
selection.set({
elements: [connector.id],
editing: false,
});
return true;
}
return false;
})
);
const surface = getSurfaceBlock(store);
if (surface) {
this.disposables.add(
surface.elementUpdated.subscribe(({ id }) => {
if (id === connector.id) this.requestUpdate();
})
);
}
this.disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(() => {
this.requestUpdate();
})
);
this.disposables.add(dispatcher.add('click', () => true));
this.disposables.add(dispatcher.add('doubleClick', () => true));
this.disposables.add(() => {
if (connector.text) {
const text = connector.text.toString();
const trimed = text.trim();
const len = trimed.length;
if (len === 0) {
// reset
this.crud.updateElement(connector.id, {
text: undefined,
labelXYWH: undefined,
labelOffset: undefined,
});
} else if (len < text.length) {
this.crud.updateElement(connector.id, {
// @TODO: trim in Y.Text?
text: new Y.Text(trimed),
});
}
}
connector.lableEditing = false;
selection.set({
elements: [],
editing: false,
});
});
if (!this.inlineEditorContainer) return;
this.disposables.addFromEvent(
this.inlineEditorContainer,
'blur',
() => {
if (this._keeping) return;
this.remove();
}
);
this.disposables.addFromEvent(
this.inlineEditorContainer,
'compositionstart',
() => {
this._isComposition = true;
this.requestUpdate();
}
);
this.disposables.addFromEvent(
this.inlineEditorContainer,
'compositionend',
() => {
this._isComposition = false;
this.requestUpdate();
}
);
connector.lableEditing = true;
})
.catch(console.error);
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
override render() {
const { connector } = this;
const {
labelOffset: { distance },
labelStyle: {
fontFamily,
fontSize,
fontStyle,
fontWeight,
textAlign,
color: labelColor,
},
labelConstraints: { hasMaxWidth, maxWidth },
} = connector;
const lineHeight = TextUtils.getLineHeight(
fontFamily,
fontSize,
fontWeight
);
const { translateX, translateY, zoom } = this.gfx.viewport;
const [x, y] = Vec.mul(connector.getPointByOffsetDistance(distance), zoom);
const transformOperation = [
'translate(-50%, -50%)',
`translate(${translateX}px, ${translateY}px)`,
`translate(${x}px, ${y}px)`,
`scale(${zoom})`,
];
const isEmpty = !connector.text?.length && !this._isComposition;
const color = this.edgeless.std
.get(ThemeProvider)
.generateColorProperty(labelColor, '#000000');
return html`
<div
class="edgeless-connector-label-editor"
style=${styleMap({
fontFamily: `"${fontFamily}"`,
fontSize: `${fontSize}px`,
fontStyle,
fontWeight,
textAlign,
lineHeight: `${lineHeight}px`,
maxWidth: hasMaxWidth
? `${maxWidth + BORDER_WIDTH * 2 + HORIZONTAL_PADDING * 2}px`
: 'initial',
color,
transform: transformOperation.join(' '),
})}
>
<rich-text
.yText=${connector.text}
.enableFormat=${false}
style=${isEmpty
? styleMap({
position: 'absolute',
left: 0,
top: 0,
padding: `${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px`,
})
: nothing}
></rich-text>
${isEmpty
? html`
<span class="edgeless-connector-label-editor-placeholder">
Add text
</span>
`
: nothing}
</div>
`;
}
setKeeping(keeping: boolean) {
this._keeping = keeping;
}
@property({ attribute: false })
accessor connector!: ConnectorElementModel;
@property({ attribute: false })
accessor edgeless!: BlockComponent;
@query('rich-text')
accessor richText!: RichText;
}
@@ -0,0 +1 @@
export * from './text.js';
@@ -0,0 +1,65 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import type { BlockComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { IVec } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
import * as Y from 'yjs';
import { EdgelessConnectorLabelEditor } from './edgeless-connector-label-editor';
export function mountConnectorLabelEditor(
connector: ConnectorElementModel,
edgeless: BlockComponent,
point?: IVec
) {
const mountElm = edgeless.querySelector('.edgeless-mount-point');
if (!mountElm) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
"edgeless block's mount point does not exist"
);
}
const gfx = edgeless.std.get(GfxControllerIdentifier);
// @ts-expect-error FIXME: resolve after gfx tool refactor
gfx.tool.setTool('default');
gfx.selection.set({
elements: [connector.id],
editing: true,
});
if (!connector.text) {
const text = new Y.Text();
const labelOffset = connector.labelOffset;
let labelXYWH = connector.labelXYWH ?? [0, 0, 16, 16];
if (point) {
const center = connector.getNearestPoint(point);
const distance = connector.getOffsetDistanceByPoint(center as IVec);
const bounds = Bound.fromXYWH(labelXYWH);
bounds.center = center;
labelOffset.distance = distance;
labelXYWH = bounds.toXYWH();
}
edgeless.std.get(EdgelessCRUDIdentifier).updateElement(connector.id, {
text,
labelXYWH,
labelOffset: { ...labelOffset },
});
}
const editor = new EdgelessConnectorLabelEditor();
editor.connector = connector;
editor.edgeless = edgeless;
mountElm.append(editor);
editor.updateComplete
.then(() => {
editor.inlineEditor?.focusEnd();
})
.catch(console.error);
}
@@ -0,0 +1,419 @@
import {
ConnectorUtils,
EdgelessCRUDIdentifier,
TextUtils,
} 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 type {
ToolbarContext,
ToolbarGenericAction,
ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import {
getMostCommonResolvedValue,
getMostCommonValue,
} from '@blocksuite/affine-shared/utils';
import type { MenuItem } from '@blocksuite/affine-widget-edgeless-toolbar';
import {
createTextActions,
getRootBlock,
LINE_STYLE_LIST,
renderMenu,
} from '@blocksuite/affine-widget-edgeless-toolbar';
import { Bound } from '@blocksuite/global/gfx';
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 { mountConnectorLabelEditor } from '../text';
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 connectorToolbarConfig = {
actions: [
{
id: 'a.stroke-color',
content(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return null;
const enableCustomColor = ctx.features.getFlag('enable_color_picker');
const theme = ctx.theme.edgeless$.value;
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, field, stroke =>
resolveColor(stroke, theme)
) ?? resolveColor(DefaultTheme.connectorColor, theme);
const onPickColor = (e: PickColorEvent) => {
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}
>
<edgeless-line-styles-panel
slot="other"
style=${styleMap({
display: 'flex',
alignSelf: 'stretch',
gap: '8px',
})}
@select=${onPickStrokeStyle}
.lineSize=${strokeWidth}
.lineStyle=${strokeStyle}
></edgeless-line-styles-panel>
<editor-toolbar-separator
slot="separator"
data-orientation="horizontal"
></editor-toolbar-separator>
</edgeless-color-picker-button>
`;
},
},
{
id: 'b.style',
// 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.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.text',
tooltip: 'Add text',
icon: AddTextIcon(),
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;
const rootBlock = getRootBlock(ctx);
if (!rootBlock) return;
mountConnectorLabelEditor(model, rootBlock);
},
},
// 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.every(model => model.hasLabel());
},
})),
],
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,56 @@
import { menu } from '@blocksuite/affine-components/context-menu';
import { ConnectorMode } from '@blocksuite/affine-model';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import type { DenseMenuBuilder } from '@blocksuite/affine-widget-edgeless-toolbar';
import {
ConnectorCIcon,
ConnectorEIcon,
ConnectorLIcon,
} from '@blocksuite/icons/lit';
export const buildConnectorDenseMenu: DenseMenuBuilder = (edgeless, gfx) => {
const prevMode =
edgeless.std.get(EditPropsStore).lastProps$.value.connector.mode;
const isSelected = gfx.tool.currentToolName$.peek() === 'connector';
const createSelect =
(mode: ConnectorMode, record = true) =>
() => {
gfx.tool.setTool('connector', {
mode,
});
record &&
edgeless.std.get(EditPropsStore).recordLastProps('connector', { mode });
};
const iconSize = { width: '20', height: '20' };
return menu.subMenu({
name: 'Connector',
prefix: ConnectorCIcon(iconSize),
select: createSelect(prevMode, false),
isSelected,
options: {
items: [
menu.action({
name: 'Curve',
prefix: ConnectorCIcon(iconSize),
select: createSelect(ConnectorMode.Curve),
isSelected: isSelected && prevMode === ConnectorMode.Curve,
}),
menu.action({
name: 'Elbowed',
prefix: ConnectorEIcon(iconSize),
select: createSelect(ConnectorMode.Orthogonal),
isSelected: isSelected && prevMode === ConnectorMode.Orthogonal,
}),
menu.action({
name: 'Straight',
prefix: ConnectorLIcon(iconSize),
select: createSelect(ConnectorMode.Straight),
isSelected: isSelected && prevMode === ConnectorMode.Straight,
}),
],
},
});
};
@@ -0,0 +1,152 @@
import {
ConnectorMode,
DefaultTheme,
type LineWidth,
} from '@blocksuite/affine-model';
import {
EditPropsStore,
FeatureFlagService,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
import { EdgelessToolbarToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import { SignalWatcher } from '@blocksuite/global/lit';
import {
ConnectorCIcon,
ConnectorEIcon,
ConnectorLIcon,
} from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
function ConnectorModeButtonGroup(
mode: ConnectorMode,
setConnectorMode: (props: Record<string, unknown>) => void
) {
/**
* There is little hacky on rendering tooltip.
* We don't want either tooltip overlap the top button or tooltip on left.
* So we put the lower button's tooltip as the first element of the button group container
*/
return html`
<div class="connector-mode-button-group">
<edgeless-tool-icon-button
.active=${mode === ConnectorMode.Curve}
.activeMode=${'background'}
.tooltip=${'Curve'}
.iconSize=${'20px'}
@click=${() => setConnectorMode({ mode: ConnectorMode.Curve })}
>
${ConnectorCIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.active=${mode === ConnectorMode.Orthogonal}
.activeMode=${'background'}
.tooltip=${'Elbowed'}
.iconSize=${'20px'}
@click=${() => setConnectorMode({ mode: ConnectorMode.Orthogonal })}
>
${ConnectorEIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.active=${mode === ConnectorMode.Straight}
.activeMode=${'background'}
.tooltip=${'Straight'}
.iconSize=${'20px'}
@click=${() => setConnectorMode({ mode: ConnectorMode.Straight })}
>
${ConnectorLIcon()}
</edgeless-tool-icon-button>
</div>
`;
}
export class EdgelessConnectorMenu extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
position: absolute;
display: flex;
z-index: -1;
}
.connector-submenu-content {
display: flex;
height: 24px;
align-items: center;
justify-content: center;
}
.connector-mode-button-group {
display: flex;
justify-content: center;
align-items: center;
gap: 14px;
}
.connector-mode-button-group > edgeless-tool-icon-button svg {
fill: var(--affine-icon-color);
}
.submenu-divider {
width: 1px;
height: 24px;
margin: 0 16px;
background-color: var(--affine-border-color);
display: inline-block;
}
`;
private readonly _props$ = computed(() => {
const { mode, stroke, strokeWidth } =
this.edgeless.std.get(EditPropsStore).lastProps$.value.connector;
return { mode, stroke, strokeWidth };
});
private readonly _theme$ = computed(() => {
return this.edgeless.std.get(ThemeProvider).theme$.value;
});
override type: GfxToolsFullOptionValue['type'] = 'connector';
override render() {
const { stroke, strokeWidth, mode } = this._props$.value;
const connectorModeButtonGroup = ConnectorModeButtonGroup(
mode,
this.onChange
);
return html`
<edgeless-slide-menu>
<div class="connector-submenu-content">
${connectorModeButtonGroup}
<div class="submenu-divider"></div>
<edgeless-line-width-panel
.selectedSize=${strokeWidth}
@select=${(e: CustomEvent<LineWidth>) =>
this.onChange({ strokeWidth: e.detail })}
>
</edgeless-line-width-panel>
<div class="submenu-divider"></div>
<edgeless-color-panel
class="one-way"
.value=${stroke}
.theme=${this._theme$.value}
.palettes=${DefaultTheme.StrokeColorShortPalettes}
.hasTransparent=${!this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_color_picker')}
@select=${(e: ColorEvent) =>
this.onChange({ stroke: e.detail.value })}
></edgeless-color-panel>
</div>
</edgeless-slide-menu>
`;
}
@property({ attribute: false })
accessor onChange!: (props: Record<string, unknown>) => void;
}
@@ -0,0 +1,77 @@
import { ConnectorMode, getConnectorModeName } from '@blocksuite/affine-model';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import { QuickToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
import { SignalWatcher } from '@blocksuite/global/lit';
import {
ConnectorCIcon,
ConnectorEIcon,
ConnectorLIcon,
} from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
const IcomMap = {
[ConnectorMode.Straight]: ConnectorLIcon(),
[ConnectorMode.Orthogonal]: ConnectorEIcon(),
[ConnectorMode.Curve]: ConnectorCIcon(),
};
export class EdgelessConnectorToolButton extends QuickToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
display: flex;
}
`;
private readonly _mode$ = computed(() => {
return this.edgeless.std.get(EditPropsStore).lastProps$.value.connector
.mode;
});
override type = 'connector' as const;
private _toggleMenu() {
if (this.tryDisposePopper()) return;
const menu = this.createPopper('edgeless-connector-menu', this);
menu.element.edgeless = this.edgeless;
menu.element.onChange = (props: Record<string, unknown>) => {
this.edgeless.std.get(EditPropsStore).recordLastProps('connector', props);
this.setEdgelessTool(this.type, {
mode: this._mode$.value,
});
};
}
override render() {
const { active } = this;
const mode = this._mode$.value;
return html`
<edgeless-tool-icon-button
class="edgeless-connector-button"
.tooltip=${this.popper
? ''
: html`<affine-tooltip-content-with-shortcut
data-tip="${getConnectorModeName(mode)}"
data-shortcut="${'C'}"
></affine-tooltip-content-with-shortcut>`}
.tooltipOffset=${17}
.active=${active}
.iconContainerPadding=${6}
.iconSize=${'24px'}
@click=${() => {
// don't update tool before toggling menu
this._toggleMenu();
this.gfx.tool.setTool('connector', {
mode,
});
}}
>
${IcomMap[mode]}
<toolbar-arrow-up-icon></toolbar-arrow-up-icon>
</edgeless-tool-icon-button>
`;
}
}
@@ -0,0 +1,14 @@
import { QuickToolExtension } from '@blocksuite/affine-widget-edgeless-toolbar';
import { html } from 'lit';
export const connectorQuickTool = QuickToolExtension(
'connector',
({ block }) => {
return {
type: 'connector',
content: html`<edgeless-connector-tool-button
.edgeless=${block}
></edgeless-connector-tool-button>`,
};
}
);