snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 00:31:07 +05:00
commit 50f788e799
7907 changed files with 988785 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
{
"name": "@blocksuite/affine-gfx-connector",
"description": "Gfx connector for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-gfx-text": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"yjs": "^13.6.27"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts",
"./store": "./src/store.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,2 @@
export * from './markdown';
export * from './plain-text';

View File

@@ -0,0 +1,26 @@
import { ElementToMarkdownAdapterExtension } from '@blocksuite/affine-block-surface';
import { getConnectorText } from '../text';
export const connectorToMarkdownAdapterMatcher =
ElementToMarkdownAdapterExtension({
name: 'connector',
match: elementModel => elementModel.type === 'connector',
toAST: elementModel => {
const text = getConnectorText(elementModel);
if (!text) {
return null;
}
const content = `Connector, with text label "${text}"`;
return {
type: 'paragraph',
children: [
{
type: 'text',
value: content,
},
],
};
},
});

View File

@@ -0,0 +1,14 @@
import { ElementToPlainTextAdapterExtension } from '@blocksuite/affine-block-surface';
import { getConnectorText } from '../text';
export const connectorToPlainTextAdapterMatcher =
ElementToPlainTextAdapterExtension({
name: 'connector',
match: elementModel => elementModel.type === 'connector',
toAST: elementModel => {
const text = getConnectorText(elementModel);
const content = `Connector, with text label "${text}"`;
return { content };
},
});

View File

@@ -0,0 +1,23 @@
import type { DeltaInsert } from '@blocksuite/affine/store';
export function getConnectorText(
elementModel: Record<string, unknown>
): string {
let text = '';
if (elementModel.type !== 'connector') {
return text;
}
if (
'text' in elementModel &&
typeof elementModel.text === 'object' &&
elementModel.text
) {
let delta: DeltaInsert[] = [];
if ('delta' in elementModel.text) {
delta = elementModel.text.delta as DeltaInsert[];
}
text = delta.map(d => d.insert).join('');
}
return text;
}

View File

@@ -0,0 +1,168 @@
import {
EdgelessLegacySlotIdentifier,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { Vec } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import {
type BlockComponent,
type BlockStdScope,
stdContext,
storeContext,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
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';
import type { ConnectionOverlay } from '../connector-manager';
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: storeContext,
})
accessor doc!: Store;
@property({ attribute: false })
accessor edgeless!: BlockComponent;
@consume({
context: stdContext,
})
accessor std!: BlockStdScope;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,237 @@
import {
CanvasElementType,
DefaultTool,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import {
type Connection,
type ConnectorElementModel,
ConnectorMode,
GroupElementModel,
ShapeElementModel,
ShapeType,
} from '@blocksuite/affine-model';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type { IBound, IVec } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
import type { PointerEventState } from '@blocksuite/std';
import { BaseTool, type GfxModel } from '@blocksuite/std/gfx';
import {
calculateNearestLocation,
type ConnectionOverlay,
ConnectorEndpointLocations,
ConnectorEndpointLocationsOnTriangle,
} from './connector-manager';
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;
}
this.gfx.tool.setTool(DefaultTool);
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();
this.gfx.tool.setTool(DefaultTool);
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);
}
getNextMode() {
// reorder the enum values
const modes = [
ConnectorMode.Curve,
ConnectorMode.Orthogonal,
ConnectorMode.Straight,
];
const currentIndex = modes.indexOf(this.activatedOption.mode);
const nextIndex = (currentIndex + 1) % modes.length;
return modes[nextIndex];
}
}

View File

@@ -0,0 +1,96 @@
import {
type SurfaceBlockModel,
type SurfaceMiddleware,
surfaceMiddlewareExtension,
} from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import type { GfxModel } from '@blocksuite/std/gfx';
import { ConnectorPathGenerator } from './connector-manager';
export const connectorWatcher: SurfaceMiddleware = (
surface: SurfaceBlockModel
) => {
const hasElementById = (id: string) =>
surface.hasElementById(id) || surface.store.hasBlock(id);
const elementGetter = (id: string) =>
surface.getElementById(id) ?? (surface.store.getModelById(id) as GfxModel);
const updateConnectorPath = (connector: ConnectorElementModel) => {
if (
((connector.source?.id && hasElementById(connector.source.id)) ||
(!connector.source?.id && connector.source?.position)) &&
((connector.target?.id && hasElementById(connector.target.id)) ||
(!connector.target?.id && connector.target?.position))
) {
ConnectorPathGenerator.updatePath(connector, null, elementGetter);
}
};
const pendingList = new Set<ConnectorElementModel>();
let pendingFlag = false;
const addToUpdateList = (connector: ConnectorElementModel) => {
pendingList.add(connector);
if (!pendingFlag) {
pendingFlag = true;
queueMicrotask(() => {
pendingList.forEach(updateConnectorPath);
pendingList.clear();
pendingFlag = false;
});
}
};
const disposables = [
surface.elementAdded.subscribe(({ id }) => {
const element = elementGetter(id);
if (!element) return;
if ('type' in element && element.type === 'connector') {
addToUpdateList(element as ConnectorElementModel);
} else {
surface.getConnectors(id).forEach(addToUpdateList);
}
}),
surface.elementUpdated.subscribe(({ id, props }) => {
const element = elementGetter(id);
if (props['xywh'] || props['rotate']) {
surface.getConnectors(id).forEach(addToUpdateList);
}
if (
'type' in element &&
element.type === 'connector' &&
(props['mode'] !== undefined || props['target'] || props['source'])
) {
addToUpdateList(element as ConnectorElementModel);
}
}),
surface.store.slots.blockUpdated.subscribe(payload => {
if (
payload.type === 'add' ||
(payload.type === 'update' && payload.props.key === 'xywh')
) {
surface.getConnectors(payload.id).forEach(addToUpdateList);
}
}),
];
surface
.getElementsByType('connector')
.forEach(connector =>
updateConnectorPath(connector as ConnectorElementModel)
);
return () => {
pendingFlag = false;
pendingList.clear();
disposables.forEach(d => d.unsubscribe());
};
};
export const connectorWatcherExtension = surfaceMiddlewareExtension(
'connector-watcher',
connectorWatcher
);

View File

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

View File

@@ -0,0 +1,44 @@
import { ConnectorElementModel } from '@blocksuite/affine-model';
import {
type DragExtensionInitializeContext,
InteractivityExtension,
} from '@blocksuite/std/gfx';
export class ConnectorFilter extends InteractivityExtension {
static override key = 'connector-filter';
override mounted() {
this.action.onDragInitialize((context: DragExtensionInitializeContext) => {
let hasConnectorFlag = false;
const elementSet = new Set(context.elements.map(elem => elem.id));
const elements = context.elements.filter(elem => {
if (elem instanceof ConnectorElementModel) {
const sourceElemNotFound =
elem.source.id && !elementSet.has(elem.source.id);
const targetElemNotFound =
elem.target.id && !elementSet.has(elem.target.id);
// If either source or target element is not found, then remove the connector
if (sourceElemNotFound || targetElemNotFound) {
return false;
}
hasConnectorFlag = true;
return true;
}
return true;
});
if (hasConnectorFlag) {
// connector needs to be updated first
elements.sort((a, _) => (a instanceof ConnectorElementModel ? -1 : 1));
}
context.elements = elements;
return {};
});
}
}

View File

@@ -0,0 +1 @@
export * from './connector-filter';

View File

@@ -0,0 +1,9 @@
export * from './adapter';
export * from './connector-manager';
export * from './connector-tool';
export * from './element-transform';
export * from './renderer';
export * from './text';
export * from './toolbar/config';
export * from './toolbar/quick-tool';
export * from './view/index';

View File

@@ -0,0 +1,416 @@
import {
DomElementRendererExtension,
type DomRenderer,
} from '@blocksuite/affine-block-surface';
import {
type ConnectorElementModel,
ConnectorMode,
DefaultTheme,
type LocalConnectorElementModel,
type PointStyle,
} from '@blocksuite/affine-model';
import { PointLocation, SVGPathBuilder } from '@blocksuite/global/gfx';
import { isConnectorWithLabel } from '../connector-manager';
import { DEFAULT_ARROW_SIZE } from './utils';
const SVG_NS = 'http://www.w3.org/2000/svg';
interface PathBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
type RetainedConnectorDom = {
defs: SVGDefsElement;
label: HTMLDivElement | null;
path: SVGPathElement;
svg: SVGSVGElement;
};
const retainedConnectorDom = new WeakMap<HTMLElement, RetainedConnectorDom>();
function calculatePathBounds(path: PointLocation[]): PathBounds {
if (path.length === 0) {
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
}
let minX = path[0][0];
let minY = path[0][1];
let maxX = path[0][0];
let maxY = path[0][1];
for (const point of path) {
minX = Math.min(minX, point[0]);
minY = Math.min(minY, point[1]);
maxX = Math.max(maxX, point[0]);
maxY = Math.max(maxY, point[1]);
}
return { minX, minY, maxX, maxY };
}
function createConnectorPath(
points: PointLocation[],
mode: ConnectorMode
): string {
if (points.length < 2) return '';
const pathBuilder = new SVGPathBuilder();
pathBuilder.moveTo(points[0][0], points[0][1]);
if (mode === ConnectorMode.Curve) {
// Use bezier curves
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
pathBuilder.curveTo(
prev.absOut[0],
prev.absOut[1],
curr.absIn[0],
curr.absIn[1],
curr[0],
curr[1]
);
}
} else {
// Use straight lines
for (let i = 1; i < points.length; i++) {
pathBuilder.lineTo(points[i][0], points[i][1]);
}
}
return pathBuilder.build();
}
function createArrowMarker(
id: string,
style: PointStyle,
color: string,
strokeWidth: number,
isStart: boolean = false
): SVGMarkerElement {
const marker = document.createElementNS(SVG_NS, 'marker');
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
marker.id = id;
marker.setAttribute('viewBox', '0 0 20 20');
marker.setAttribute('refX', isStart ? '20' : '0');
marker.setAttribute('refY', '10');
marker.setAttribute('markerWidth', String(size));
marker.setAttribute('markerHeight', String(size));
marker.setAttribute('orient', 'auto');
marker.setAttribute('markerUnits', 'strokeWidth');
switch (style) {
case 'Arrow': {
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute(
'd',
isStart ? 'M 20 5 L 10 10 L 20 15 Z' : 'M 0 5 L 10 10 L 0 15 Z'
);
path.setAttribute('fill', color);
path.setAttribute('stroke', color);
marker.append(path);
break;
}
case 'Triangle': {
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute(
'd',
isStart ? 'M 20 7 L 12 10 L 20 13 Z' : 'M 0 7 L 8 10 L 0 13 Z'
);
path.setAttribute('fill', color);
path.setAttribute('stroke', color);
marker.append(path);
break;
}
case 'Circle': {
const circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('cx', '10');
circle.setAttribute('cy', '10');
circle.setAttribute('r', '4');
circle.setAttribute('fill', color);
circle.setAttribute('stroke', color);
marker.append(circle);
break;
}
case 'Diamond': {
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', 'M 10 6 L 14 10 L 10 14 L 6 10 Z');
path.setAttribute('fill', color);
path.setAttribute('stroke', color);
marker.append(path);
break;
}
}
return marker;
}
function clearRetainedConnectorDom(element: HTMLElement) {
retainedConnectorDom.delete(element);
element.replaceChildren();
}
function getRetainedConnectorDom(element: HTMLElement): RetainedConnectorDom {
const existing = retainedConnectorDom.get(element);
if (existing) {
return existing;
}
const svg = document.createElementNS(SVG_NS, 'svg');
svg.style.position = 'absolute';
svg.style.overflow = 'visible';
svg.style.pointerEvents = 'none';
const defs = document.createElementNS(SVG_NS, 'defs');
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
svg.append(defs, path);
element.replaceChildren(svg);
const retained = {
svg,
defs,
path,
label: null,
};
retainedConnectorDom.set(element, retained);
return retained;
}
function getOrCreateLabelElement(retained: RetainedConnectorDom) {
if (retained.label) {
return retained.label;
}
const label = document.createElement('div');
retained.svg.insertAdjacentElement('afterend', label);
retained.label = label;
return label;
}
function renderConnectorLabel(
model: ConnectorElementModel,
retained: RetainedConnectorDom,
renderer: DomRenderer,
zoom: number
) {
if (!isConnectorWithLabel(model) || !model.labelXYWH) {
retained.label?.remove();
retained.label = null;
return;
}
const [lx, ly, lw, lh] = model.labelXYWH;
const {
labelStyle: {
color,
fontSize,
fontWeight,
fontStyle,
fontFamily,
textAlign,
},
} = model;
const labelElement = getOrCreateLabelElement(retained);
labelElement.style.position = 'absolute';
labelElement.style.left = `${lx * zoom}px`;
labelElement.style.top = `${ly * zoom}px`;
labelElement.style.width = `${lw * zoom}px`;
labelElement.style.height = `${lh * zoom}px`;
labelElement.style.pointerEvents = 'none';
labelElement.style.overflow = 'hidden';
labelElement.style.display = 'flex';
labelElement.style.alignItems = 'center';
labelElement.style.justifyContent =
textAlign === 'center'
? 'center'
: textAlign === 'right'
? 'flex-end'
: 'flex-start';
// Style the text
labelElement.style.color = renderer.getColorValue(
color,
DefaultTheme.black,
true
);
labelElement.style.fontSize = `${fontSize * zoom}px`;
labelElement.style.fontWeight = fontWeight;
labelElement.style.fontStyle = fontStyle;
labelElement.style.fontFamily = fontFamily;
labelElement.style.textAlign = textAlign;
labelElement.style.lineHeight = '1.2';
labelElement.style.whiteSpace = 'pre-wrap';
labelElement.style.wordWrap = 'break-word';
// Add text content
labelElement.textContent = model.text ? model.text.toString() : '';
}
/**
* Renders a ConnectorElementModel to a given HTMLElement using DOM/SVG.
* This function is intended to be registered via the DomElementRendererExtension.
*
* @param model - The connector element model containing rendering properties.
* @param element - The HTMLElement to apply the connector's styles to.
* @param renderer - The main DOMRenderer instance, providing access to viewport and color utilities.
*/
export const connectorBaseDomRenderer = (
model: ConnectorElementModel | LocalConnectorElementModel,
element: HTMLElement,
renderer: DomRenderer
): void => {
const { zoom } = renderer.viewport;
const {
mode,
path: points,
strokeStyle,
frontEndpointStyle,
rearEndpointStyle,
strokeWidth,
stroke,
} = model;
if (!points || points.length < 2) {
clearRetainedConnectorDom(element);
return;
}
const retained = getRetainedConnectorDom(element);
// Calculate bounds for the SVG viewBox
const pathBounds = calculatePathBounds(points);
const padding = Math.max(strokeWidth * 2, 20); // Add padding for arrows
const svgWidth = (pathBounds.maxX - pathBounds.minX + padding * 2) * zoom;
const svgHeight = (pathBounds.maxY - pathBounds.minY + padding * 2) * zoom;
const offsetX = pathBounds.minX - padding;
const offsetY = pathBounds.minY - padding;
const { defs, path, svg } = retained;
svg.style.position = 'absolute';
svg.style.left = `${offsetX * zoom}px`;
svg.style.top = `${offsetY * zoom}px`;
svg.style.width = `${svgWidth}px`;
svg.style.height = `${svgHeight}px`;
svg.style.overflow = 'visible';
svg.style.pointerEvents = 'none';
svg.setAttribute('viewBox', `0 0 ${svgWidth / zoom} ${svgHeight / zoom}`);
const strokeColor = renderer.getColorValue(
stroke,
DefaultTheme.connectorColor,
true
);
const markers: SVGMarkerElement[] = [];
let startMarkerId = '';
let endMarkerId = '';
if (frontEndpointStyle !== 'None') {
startMarkerId = `start-marker-${model.id}`;
markers.push(
createArrowMarker(
startMarkerId,
frontEndpointStyle,
strokeColor,
strokeWidth,
true
)
);
}
if (rearEndpointStyle !== 'None') {
endMarkerId = `end-marker-${model.id}`;
markers.push(
createArrowMarker(
endMarkerId,
rearEndpointStyle,
strokeColor,
strokeWidth,
false
)
);
}
defs.replaceChildren(...markers);
// Adjust points relative to the SVG coordinate system
const adjustedPoints = points.map(point => {
const adjustedPoint = new PointLocation([
point[0] - offsetX,
point[1] - offsetY,
]);
if (point.absIn) {
adjustedPoint.in = [
point.absIn[0] - offsetX - adjustedPoint[0],
point.absIn[1] - offsetY - adjustedPoint[1],
];
}
if (point.absOut) {
adjustedPoint.out = [
point.absOut[0] - offsetX - adjustedPoint[0],
point.absOut[1] - offsetY - adjustedPoint[1],
];
}
return adjustedPoint;
});
const pathData = createConnectorPath(adjustedPoints, mode);
path.setAttribute('d', pathData);
path.setAttribute('stroke', strokeColor);
path.setAttribute('stroke-width', String(strokeWidth));
if (strokeStyle === 'dash') {
path.setAttribute('stroke-dasharray', '12,12');
} else {
path.removeAttribute('stroke-dasharray');
}
if (startMarkerId) {
path.setAttribute('marker-start', `url(#${startMarkerId})`);
} else {
path.removeAttribute('marker-start');
}
if (endMarkerId) {
path.setAttribute('marker-end', `url(#${endMarkerId})`);
} else {
path.removeAttribute('marker-end');
}
// Set element size and position
element.style.width = `${model.w * zoom}px`;
element.style.height = `${model.h * zoom}px`;
element.style.overflow = 'visible';
element.style.pointerEvents = 'none';
};
export const connectorDomRenderer = (
model: ConnectorElementModel,
element: HTMLElement,
renderer: DomRenderer
): void => {
connectorBaseDomRenderer(model, element, renderer);
const retained = retainedConnectorDom.get(element);
if (!retained) return;
renderConnectorLabel(model, retained, renderer, renderer.viewport.zoom);
};
/**
* Extension to register the DOM-based renderer for 'connector' elements.
*/
export const ConnectorDomRendererExtension = DomElementRendererExtension(
'connector',
connectorDomRenderer
);

View File

@@ -0,0 +1,316 @@
import {
type CanvasRenderer,
type ElementRenderer,
ElementRendererExtension,
type RoughCanvas,
} from '@blocksuite/affine-block-surface';
import {
getFontString,
getLineHeight,
getTextWidth,
isRTL,
type TextDelta,
wrapTextDeltas,
} from '@blocksuite/affine-gfx-text';
import {
type ConnectorElementModel,
ConnectorMode,
DefaultTheme,
type LocalConnectorElementModel,
type PointStyle,
} from '@blocksuite/affine-model';
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
import {
getBezierParameters,
type PointLocation,
} from '@blocksuite/global/gfx';
import { deltaInsertsToChunks } from '@blocksuite/std/inline';
import { isConnectorWithLabel } from '../connector-manager';
import {
DEFAULT_ARROW_SIZE,
getArrowOptions,
renderArrow,
renderCircle,
renderDiamond,
renderTriangle,
} from './utils';
export const connector: ElementRenderer<
ConnectorElementModel | LocalConnectorElementModel
> = (model, ctx, matrix, renderer, rc) => {
const {
mode,
path: points,
strokeStyle,
frontEndpointStyle,
rearEndpointStyle,
strokeWidth,
} = model;
// points might not be build yet in some senarios
// eg. undo/redo, copy/paste
if (!points.length || points.length < 2) {
return;
}
ctx.setTransform(matrix);
const hasLabel = isConnectorWithLabel(model);
let dx = 0;
let dy = 0;
if (hasLabel) {
ctx.save();
const { deserializedXYWH, labelXYWH } = model as ConnectorElementModel;
const [x, y, w, h] = deserializedXYWH;
const [lx, ly, lw, lh] = labelXYWH!;
const offset = DEFAULT_ARROW_SIZE * strokeWidth;
dx = lx - x;
dy = ly - y;
const path = new Path2D();
path.rect(-offset / 2, -offset / 2, w + offset, h + offset);
path.rect(dx - 3 - 0.5, dy - 3 - 0.5, lw + 6 + 1, lh + 6 + 1);
ctx.clip(path, 'evenodd');
}
const strokeColor = renderer.getColorValue(
model.stroke,
DefaultTheme.connectorColor,
true
);
renderPoints(
model,
ctx,
rc,
points,
strokeStyle === 'dash',
mode === ConnectorMode.Curve,
strokeColor
);
renderEndpoint(
model,
points,
ctx,
rc,
'Front',
frontEndpointStyle,
strokeColor
);
renderEndpoint(
model,
points,
ctx,
rc,
'Rear',
rearEndpointStyle,
strokeColor
);
if (hasLabel) {
ctx.restore();
renderLabel(
model as ConnectorElementModel,
ctx,
matrix.translate(dx, dy),
renderer
);
}
};
export const ConnectorElementRendererExtension = ElementRendererExtension(
'connector',
connector
);
function renderPoints(
model: ConnectorElementModel | LocalConnectorElementModel,
ctx: CanvasRenderingContext2D,
rc: RoughCanvas,
points: PointLocation[],
dash: boolean,
curve: boolean,
stroke: string
) {
const { seed, strokeWidth, roughness, rough } = model;
if (rough) {
const options = {
seed,
roughness,
stroke,
strokeLineDash: dash ? [12, 12] : undefined,
strokeWidth,
};
if (curve) {
const b = getBezierParameters(points);
rc.path(
`M${b[0][0]},${b[0][1]} C${b[1][0]},${b[1][1]} ${b[2][0]},${b[2][1]} ${b[3][0]},${b[3][1]}`,
options
);
} else {
rc.linearPath(points as unknown as [number, number][], options);
}
} else {
ctx.save();
ctx.strokeStyle = stroke;
ctx.lineWidth = strokeWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
dash && ctx.setLineDash([12, 12]);
ctx.beginPath();
if (curve) {
points.forEach((point, index) => {
if (index === 0) {
ctx.moveTo(point[0], point[1]);
} else {
const last = points[index - 1];
ctx.bezierCurveTo(
last.absOut[0],
last.absOut[1],
point.absIn[0],
point.absIn[1],
point[0],
point[1]
);
}
});
} else {
points.forEach((point, index) => {
if (index === 0) {
ctx.moveTo(point[0], point[1]);
} else {
ctx.lineTo(point[0], point[1]);
}
});
}
ctx.stroke();
ctx.closePath();
ctx.restore();
}
}
function renderEndpoint(
model: ConnectorElementModel | LocalConnectorElementModel,
location: PointLocation[],
ctx: CanvasRenderingContext2D,
rc: RoughCanvas,
end: 'Front' | 'Rear',
style: PointStyle,
stroke: string
) {
const arrowOptions = getArrowOptions(end, model, stroke);
switch (style) {
case 'Arrow':
renderArrow(location, ctx, rc, arrowOptions);
break;
case 'Triangle':
renderTriangle(location, ctx, rc, arrowOptions);
break;
case 'Circle':
renderCircle(location, ctx, rc, arrowOptions);
break;
case 'Diamond':
renderDiamond(location, ctx, rc, arrowOptions);
break;
}
}
function renderLabel(
model: ConnectorElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer
) {
const {
text,
labelXYWH,
labelStyle: {
color,
fontSize,
fontWeight,
fontStyle,
fontFamily,
textAlign,
},
labelConstraints: { hasMaxWidth, maxWidth },
} = model;
const font = getFontString({
fontStyle,
fontWeight,
fontSize,
fontFamily,
});
const [, , w, h] = labelXYWH!;
const cx = w / 2;
const cy = h / 2;
ctx.setTransform(matrix);
if (renderer.usePlaceholder) {
ctx.fillStyle = getAffinePlaceholderFillColor(renderer.getColorScheme());
ctx.fillRect(0, 0, w, h);
return; // Skip actual label rendering
}
const deltas = wrapTextDeltas(text!, font, w);
const lines = deltaInsertsToChunks(deltas);
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
const textHeight = (lines.length - 1) * lineHeight * 0.5;
ctx.font = font;
ctx.textAlign = textAlign;
ctx.textBaseline = 'middle';
ctx.fillStyle = renderer.getColorValue(color, DefaultTheme.black, true);
let textMaxWidth = textAlign === 'center' ? 0 : getMaxTextWidth(lines, font);
if (hasMaxWidth && maxWidth > 0) {
textMaxWidth = Math.min(textMaxWidth, textMaxWidth);
}
for (const [index, line] of lines.entries()) {
for (const delta of line) {
const str = delta.insert;
const rtl = isRTL(str);
const shouldTemporarilyAttach = rtl && !ctx.canvas.isConnected;
if (shouldTemporarilyAttach) {
// to correctly render RTL text mixed with LTR, we have to append it
// to the DOM
document.body.append(ctx.canvas);
}
ctx.canvas.setAttribute('dir', rtl ? 'rtl' : 'ltr');
const x =
textMaxWidth *
(textAlign === 'center'
? 1
: textAlign === 'right'
? rtl
? -0.5
: 0.5
: rtl
? 0.5
: -0.5);
ctx.fillText(str, x + cx, index * lineHeight - textHeight + cy);
if (shouldTemporarilyAttach) {
ctx.canvas.remove();
}
}
}
}
function getMaxTextWidth(lines: TextDelta[][], font: string) {
return Math.max(
...lines.flatMap(line =>
line.map(delta => getTextWidth(delta.insert, font))
)
);
}

View File

@@ -0,0 +1,2 @@
export * from './dom-renderer';
export * from './element-renderer';

View File

@@ -0,0 +1,312 @@
import type { RoughCanvas } from '@blocksuite/affine-block-surface';
import {
type ConnectorElementModel,
ConnectorMode,
type LocalConnectorElementModel,
} from '@blocksuite/affine-model';
import type {
BezierCurveParameters,
IVec,
PointLocation,
} from '@blocksuite/global/gfx';
import {
getBezierParameters,
getBezierTangent,
Vec,
} from '@blocksuite/global/gfx';
type ConnectorEnd = 'Front' | 'Rear';
export const DEFAULT_ARROW_SIZE = 15;
export function getArrowPoints(
points: PointLocation[],
size = 10,
mode: ConnectorMode,
bezierParameters: BezierCurveParameters,
endPoint: ConnectorEnd = 'Rear',
radians: number = Math.PI / 4
) {
const anchorPoint = getPointWithTangent(
points,
mode,
endPoint,
bezierParameters
);
const unit = Vec.mul(anchorPoint.tangent, -1);
const angle = endPoint === 'Front' ? Math.PI : 0;
return {
points: [
Vec.add(Vec.mul(Vec.rot(unit, angle + radians), size), anchorPoint),
anchorPoint,
Vec.add(Vec.mul(Vec.rot(unit, angle - radians), size), anchorPoint),
],
};
}
export function getCircleCenterPoint(
points: PointLocation[],
radius = 5,
mode: ConnectorMode,
bezierParameters: BezierCurveParameters,
endPoint: ConnectorEnd = 'Rear'
) {
const anchorPoint = getPointWithTangent(
points,
mode,
endPoint,
bezierParameters
);
const unit = Vec.mul(anchorPoint.tangent, -1);
const angle = endPoint === 'Front' ? Math.PI : 0;
return Vec.add(Vec.mul(Vec.rot(unit, angle), radius), anchorPoint);
}
export function getPointWithTangent(
points: PointLocation[],
mode: ConnectorMode,
endPoint: ConnectorEnd,
bezierParameters: BezierCurveParameters
) {
const anchorIndex = endPoint === 'Rear' ? points.length - 1 : 0;
const pointToAnchorIndex =
endPoint === 'Rear' ? anchorIndex - 1 : anchorIndex + 1;
const anchorPoint = points[anchorIndex];
const pointToAnchor = points[pointToAnchorIndex];
const clone = anchorPoint.clone();
let tangent;
if (mode !== ConnectorMode.Curve) {
tangent =
endPoint === 'Rear'
? Vec.tangent(anchorPoint, pointToAnchor)
: Vec.tangent(pointToAnchor, anchorPoint);
} else {
tangent =
endPoint === 'Rear'
? getBezierTangent(bezierParameters, 1)
: getBezierTangent(bezierParameters, 0);
}
clone.tangent = tangent ?? [0, 0];
return clone;
}
export function getDiamondPoints(
point: PointLocation,
size = 10,
endPoint: ConnectorEnd = 'Rear'
) {
const unit = Vec.mul(point.tangent, -1);
const angle = endPoint === 'Front' ? Math.PI : 0;
const diamondPoints = [
Vec.add(Vec.mul(Vec.rot(unit, angle + Math.PI * 0.25), size), point),
point,
Vec.add(Vec.mul(Vec.rot(unit, angle - Math.PI * 0.25), size), point),
Vec.add(Vec.mul(Vec.rot(unit, angle), size * Math.sqrt(2)), point),
];
return {
points: diamondPoints,
};
}
export type ArrowOptions = ReturnType<typeof getArrowOptions>;
export function getArrowOptions(
end: ConnectorEnd,
model: ConnectorElementModel | LocalConnectorElementModel,
strokeColor: string
) {
const { seed, mode, rough, roughness, strokeWidth, path } = model;
return {
end,
seed,
mode,
rough,
roughness,
strokeWidth,
strokeColor,
fillColor: strokeColor,
fillStyle: 'solid',
bezierParameters: getBezierParameters(path),
};
}
export function getRcOptions(options: ArrowOptions) {
const { seed, roughness, strokeWidth, strokeColor, fillColor } = options;
return {
seed,
roughness,
stroke: strokeColor,
strokeWidth,
fill: fillColor,
fillStyle: 'solid',
};
}
export function renderRoundedPolygon(
ctx: CanvasRenderingContext2D,
points: IVec[],
color: string,
strokeWidth: number,
fill: boolean = true
) {
ctx.fillStyle = color;
ctx.strokeStyle = color;
ctx.lineWidth = strokeWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.save();
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
if (i === 0) {
ctx.moveTo(points[i][0], points[i][1]);
} else {
ctx.lineTo(points[i][0], points[i][1]);
}
}
if (fill) {
ctx.closePath();
ctx.fill();
}
ctx.stroke();
ctx.restore();
}
export function renderArrow(
points: PointLocation[],
ctx: CanvasRenderingContext2D,
rc: RoughCanvas,
options: ArrowOptions
) {
const { mode, end, bezierParameters, rough, strokeColor, strokeWidth } =
options;
const radians = Math.PI / 4;
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
const { points: arrowPoints } = getArrowPoints(
points,
size,
mode,
bezierParameters,
end,
radians
);
if (rough) {
rc.linearPath(arrowPoints as [number, number][], getRcOptions(options));
} else {
renderRoundedPolygon(ctx, arrowPoints, strokeColor, strokeWidth, false);
}
}
export function renderTriangle(
points: PointLocation[],
ctx: CanvasRenderingContext2D,
rc: RoughCanvas,
options: ArrowOptions
) {
const { mode, end, bezierParameters, rough, strokeColor, strokeWidth } =
options;
const radians = Math.PI / 6;
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
const { points: trianglePoints } = getArrowPoints(
points,
size,
mode,
bezierParameters,
end,
radians
);
if (rough) {
rc.polygon(
[
[trianglePoints[0][0], trianglePoints[0][1]],
[trianglePoints[1][0], trianglePoints[1][1]],
[trianglePoints[2][0], trianglePoints[2][1]],
],
getRcOptions(options)
);
} else {
renderRoundedPolygon(ctx, trianglePoints, strokeColor, strokeWidth);
}
}
export function renderDiamond(
points: PointLocation[],
ctx: CanvasRenderingContext2D,
rc: RoughCanvas,
options: ArrowOptions
) {
const { mode, end, rough, bezierParameters, strokeColor, strokeWidth } =
options;
const anchorPoint = getPointWithTangent(points, mode, end, bezierParameters);
const size = 10 * (strokeWidth / 2);
const { points: diamondPoints } = getDiamondPoints(anchorPoint, size, end);
if (rough) {
rc.polygon(
[
[diamondPoints[0][0], diamondPoints[0][1]],
[diamondPoints[1][0], diamondPoints[1][1]],
[diamondPoints[2][0], diamondPoints[2][1]],
[diamondPoints[3][0], diamondPoints[3][1]],
],
getRcOptions(options)
);
} else {
renderRoundedPolygon(ctx, diamondPoints, strokeColor, strokeWidth);
}
}
export function renderCircle(
points: PointLocation[],
ctx: CanvasRenderingContext2D,
rc: RoughCanvas,
options: ArrowOptions
) {
const {
bezierParameters,
mode,
end,
fillColor,
strokeColor,
strokeWidth,
rough,
} = options;
const radius = 5 * (strokeWidth / 2);
const centerPoint = getCircleCenterPoint(
points,
radius,
mode,
bezierParameters,
end
);
const cx = centerPoint[0];
const cy = centerPoint[1];
if (rough) {
// radius + 2 when render rough circle to avoid connector line cross the circle and make it looks bad
rc.circle(cx, cy, radius + 2, getRcOptions(options));
} else {
ctx.fillStyle = fillColor;
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
ctx.save();
ctx.beginPath();
ctx.ellipse(cx, cy, radius, radius, 0, 0, 2 * Math.PI);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
}
}

View File

@@ -0,0 +1,21 @@
import {
type StoreExtensionContext,
StoreExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import {
connectorToMarkdownAdapterMatcher,
connectorToPlainTextAdapterMatcher,
} from './adapter';
import { connectorWatcherExtension } from './connector-watcher';
export class ConnectorStoreExtension extends StoreExtensionProvider {
override name = 'affine-connector-gfx';
override setup(context: StoreExtensionContext) {
super.setup(context);
context.register(connectorToPlainTextAdapterMatcher);
context.register(connectorToMarkdownAdapterMatcher);
context.register(connectorWatcherExtension);
}
}

View File

@@ -0,0 +1,398 @@
import {
DefaultTool,
EdgelessCRUDIdentifier,
} from '@blocksuite/affine-block-surface';
import { getLineHeight } from '@blocksuite/affine-gfx-text';
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 { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound, type IVec, Vec } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import {
type BlockComponent,
type BlockStdScope,
ShadowlessElement,
stdContext,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
import { consume } from '@lit/context';
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 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);
gfx.tool.setTool(DefaultTool);
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;
mountElm.append(editor);
editor.updateComplete
.then(() => {
editor.inlineEditor?.focusEnd();
})
.catch(console.error);
}
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.std.get(EdgelessCRUDIdentifier);
}
get gfx() {
return this.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, isConnected } = this;
if (!connector || !isConnected) 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 { connector, selection, std } = this;
const dispatcher = std.event;
this._resizeObserver = new ResizeObserver(() => {
this._updateLabelRect();
this.requestUpdate();
});
this._resizeObserver.observe(this.richText);
this.connector.stash('labelXYWH');
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 = this.gfx.surface;
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.labelEditing = false;
connector.pop('labelXYWH');
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.labelEditing = 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 = 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.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;
@consume({
context: stdContext,
})
accessor std!: BlockStdScope;
@query('rich-text')
accessor richText!: RichText;
}

View File

@@ -0,0 +1 @@
export * from './text.js';

View File

@@ -0,0 +1,66 @@
import {
DefaultTool,
EdgelessCRUDIdentifier,
} from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { IVec } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
import type { BlockComponent } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/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);
gfx.tool.setTool(DefaultTool);
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;
mountElm.append(editor);
editor.updateComplete
.then(() => {
editor.inlineEditor?.focusEnd();
})
.catch(console.error);
}

View File

@@ -0,0 +1,436 @@
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 {
createTextActions,
normalizeTextBound,
} from '@blocksuite/affine-gfx-text';
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,
type ToolbarGenericAction,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
import {
getMostCommonResolvedValue,
getMostCommonValue,
} from '@blocksuite/affine-shared/utils';
import type { MenuItem } from '@blocksuite/affine-widget-edgeless-toolbar';
import {
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 { BlockFlavourIdentifier } from '@blocksuite/std';
import { html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { isConnectorWithLabel } from '../connector-manager';
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) => {
switch (e.type) {
case 'pick':
{
const color = e.detail.value;
const props = packColor(field, color);
const crud = ctx.std.get(EdgelessCRUDIdentifier);
models.forEach(model => {
crud.updateElement(model.id, props);
});
}
break;
case 'start':
ctx.store.captureSync();
models.forEach(model => {
model.stash(field);
});
break;
case 'end':
ctx.store.transact(() => {
models.forEach(model => {
model.pop(field);
});
});
break;
}
};
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',
when: ctx => ctx.features.getFlag('enable_edgeless_scribbled_style'),
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 (!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 = 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 });
}
}
export const connectorToolbarExtension = ToolbarModuleExtension({
id: BlockFlavourIdentifier('affine:surface:connector'),
config: connectorToolbarConfig,
});

View File

@@ -0,0 +1,58 @@
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';
import { ConnectorTool } from '../connector-tool';
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(ConnectorTool, {
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,
}),
],
},
});
};

View File

@@ -0,0 +1,153 @@
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 { 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';
import { ConnectorTool } from '../connector-tool';
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 = ConnectorTool;
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.store
.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;
}

View File

@@ -0,0 +1,81 @@
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';
import { ConnectorTool } from '../connector-tool';
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 = ConnectorTool;
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(ConnectorTool, {
mode,
});
}}
>
${IcomMap[mode]}
<toolbar-arrow-up-icon></toolbar-arrow-up-icon>
</edgeless-tool-icon-button>
`;
}
}

View File

@@ -0,0 +1,15 @@
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>`,
priority: 80,
};
}
);

View File

@@ -0,0 +1,40 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { ConnectionOverlay } from './connector-manager';
import { ConnectorTool } from './connector-tool';
import { effects } from './effects';
import { ConnectorFilter } from './element-transform';
import {
ConnectorDomRendererExtension,
ConnectorElementRendererExtension,
} from './renderer';
import { connectorToolbarExtension } from './toolbar/config';
import { connectorQuickTool } from './toolbar/quick-tool';
import { ConnectorElementView, ConnectorInteraction } from './view/view';
export class ConnectorViewExtension extends ViewExtensionProvider {
override name = 'affine-connector-gfx';
override effect(): void {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register(ConnectorElementView);
context.register(ConnectorElementRendererExtension);
context.register(ConnectorDomRendererExtension);
if (this.isEdgeless(context.scope)) {
context.register(ConnectorTool);
context.register(ConnectorFilter);
context.register(connectorQuickTool);
context.register(connectorToolbarExtension);
context.register(ConnectionOverlay);
context.register(ConnectorInteraction);
}
}
}

View File

@@ -0,0 +1 @@
export * from './view';

View File

@@ -0,0 +1,248 @@
import {
type ConnectorElementModel,
LocalShapeElementModel,
} from '@blocksuite/affine-model';
import { Bound, serializeXYWH, Vec } from '@blocksuite/global/gfx';
import type { PointerEventState } from '@blocksuite/std';
import {
type DragEndContext,
type DragMoveContext,
type DragStartContext,
generateKeyBetween,
GfxElementModelView,
GfxViewInteractionExtension,
} from '@blocksuite/std/gfx';
import { mountConnectorLabelEditor } from '../text/edgeless-connector-label-editor';
export class ConnectorElementView extends GfxElementModelView<ConnectorElementModel> {
static override type = 'connector';
override onDragStart = (context: DragStartContext) => {
super.onDragStart(context);
this.model.stash('labelXYWH');
};
override onDragEnd = (context: DragEndContext) => {
super.onDragEnd(context);
this.model.stash('labelXYWH');
};
override onDragMove = (context: DragMoveContext) => {
const { dx, dy, currentBound } = context;
this.model.moveTo(currentBound.moveDelta(dx, dy));
};
override onCreated(): void {
super.onCreated();
this._initLabelMoving();
}
private _initLabelMoving(): void {
let curLabelElement: LocalShapeElementModel | null = null;
if (this.model.isLocked()) {
return;
}
const enterLabelEditor = (evt: PointerEventState) => {
const edgeless = this.std.view.getBlock(this.std.store.root!.id);
if (edgeless && !this.model.isLocked()) {
mountConnectorLabelEditor(
this.model,
edgeless,
this.gfx.viewport.toModelCoord(evt.x, evt.y)
);
}
};
const getCurrentPosition = (evt: PointerEventState) => {
const [x, y] = this.gfx.viewport.toModelCoord(evt.x, evt.y);
return {
x,
y,
clientX: evt.raw.clientX,
clientY: evt.raw.clientY,
};
};
const watchEvent = (labelModel: LocalShapeElementModel) => {
const view = this.gfx.view.get(labelModel) as GfxElementModelView;
const connectorModel = this.model;
let labelBound: Bound | null = null;
let startPoint = {
x: 0,
y: 0,
clientX: 0,
clientY: 0,
};
let lastPoint = {
x: 0,
y: 0,
clientX: 0,
clientY: 0,
};
view.on('dblclick', evt => {
enterLabelEditor(evt);
});
view.on('dragstart', evt => {
startPoint = getCurrentPosition(evt);
labelBound = Bound.deserialize(labelModel.xywh);
connectorModel.stash('labelXYWH');
connectorModel.stash('labelOffset');
});
view.on('dragmove', evt => {
if (!labelBound) {
return;
}
lastPoint = getCurrentPosition(evt);
const newBound = labelBound.clone();
const delta = [lastPoint.x - startPoint.x, lastPoint.y - startPoint.y];
const center = connectorModel.getNearestPoint(
Vec.add(newBound.center, delta)
);
const distance = connectorModel.getOffsetDistanceByPoint(center);
newBound.center = center;
connectorModel.labelXYWH = newBound.toXYWH();
connectorModel.labelOffset = {
distance,
};
});
view.on('dragend', () => {
if (labelBound) {
labelBound = null;
connectorModel.pop('labelXYWH');
connectorModel.pop('labelOffset');
}
});
};
const updateLabelElement = () => {
if (!this.model.labelXYWH || !this.model.text) {
// Clean up existing label element if conditions are no longer met
if (curLabelElement) {
this.surface.deleteLocalElement(curLabelElement);
curLabelElement = null;
}
return;
}
const labelElement =
curLabelElement || new LocalShapeElementModel(this.surface);
labelElement.xywh = serializeXYWH(...this.model.labelXYWH);
labelElement.index = generateKeyBetween(this.model.index, null);
if (!curLabelElement) {
curLabelElement = labelElement;
labelElement.id = `#${this.model.id}-label`;
labelElement.creator = this.model;
labelElement.fillColor = 'transparent';
labelElement.strokeColor = 'transparent';
labelElement.strokeWidth = 0;
this.surface.addLocalElement(labelElement);
this.disposable.add(() => {
this.surface.deleteLocalElement(labelElement);
});
watchEvent(labelElement);
}
};
this.disposable.add(
this.model.propsUpdated.subscribe(payload => {
if (
payload.key === 'labelXYWH' ||
payload.key === 'text' ||
payload.key === 'index'
) {
updateLabelElement();
}
})
);
updateLabelElement();
this.on('dblclick', evt => {
if (!curLabelElement) {
enterLabelEditor(evt);
}
});
}
}
export const ConnectorInteraction =
GfxViewInteractionExtension<ConnectorElementView>(ConnectorElementView.type, {
handleResize: ({ model, gfx }) => {
const initialPath = model.absolutePath;
return {
beforeResize(context): void {
const { elements } = context;
// show the handles only when connector is selected along with
// its source and target elements
if (
elements.length === 1 ||
(model.source.id &&
!elements.some(el => el.model.id === model.source.id)) ||
(model.target.id &&
!elements.some(el => el.model.id === model.target.id))
) {
context.set({
allowedHandlers: [],
});
}
},
onResizeStart(): void {
model.stash('labelXYWH');
model.stash('source');
model.stash('target');
},
onResizeMove(context): void {
const { matrix } = context;
const props = model.resize(initialPath, matrix);
gfx.updateElement(model, props);
},
onResizeEnd(): void {
model.pop('labelXYWH');
model.pop('source');
model.pop('target');
},
};
},
handleRotate({ model, gfx }) {
const initialPath = model.absolutePath;
return {
onRotateStart(): void {
model.stash('labelXYWH');
model.stash('source');
model.stash('target');
},
onRotateMove(context): void {
const { matrix } = context;
const props = model.resize(initialPath, matrix);
gfx.updateElement(model, props);
},
onRotateEnd(): void {
model.pop('labelXYWH');
model.pop('source');
model.pop('target');
},
};
},
});

View File

@@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/surface" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../text" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../widgets/edgeless-toolbar" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}