mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-23 20:18:42 +08:00
feat(editor): gfx connector package (#11091)
This commit is contained in:
@@ -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>`,
|
||||
};
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user