snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (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 / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
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 / 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 / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (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 3s
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 / 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 / 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 / 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 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 (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (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 (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
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 / 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 (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
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Copilot Test Automatically / Setup Test (push) Failing after 1s
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (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 / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
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 / 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 / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (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 3s
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 / 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 / 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 / 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 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 (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (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 (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
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 / 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 (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
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Copilot Test Automatically / Setup Test (push) Failing after 1s
This commit is contained in:
44
blocksuite/affine/gfx/connector/package.json
Normal file
44
blocksuite/affine/gfx/connector/package.json
Normal 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"
|
||||
}
|
||||
2
blocksuite/affine/gfx/connector/src/adapter/index.ts
Normal file
2
blocksuite/affine/gfx/connector/src/adapter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './markdown';
|
||||
export * from './plain-text';
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
23
blocksuite/affine/gfx/connector/src/adapter/text.ts
Normal file
23
blocksuite/affine/gfx/connector/src/adapter/text.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
1454
blocksuite/affine/gfx/connector/src/connector-manager.ts
Normal file
1454
blocksuite/affine/gfx/connector/src/connector-manager.ts
Normal file
File diff suppressed because it is too large
Load Diff
237
blocksuite/affine/gfx/connector/src/connector-tool.ts
Normal file
237
blocksuite/affine/gfx/connector/src/connector-tool.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
96
blocksuite/affine/gfx/connector/src/connector-watcher.ts
Normal file
96
blocksuite/affine/gfx/connector/src/connector-watcher.ts
Normal 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
|
||||
);
|
||||
26
blocksuite/affine/gfx/connector/src/effects.ts
Normal file
26
blocksuite/affine/gfx/connector/src/effects.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './connector-filter';
|
||||
9
blocksuite/affine/gfx/connector/src/index.ts
Normal file
9
blocksuite/affine/gfx/connector/src/index.ts
Normal 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';
|
||||
416
blocksuite/affine/gfx/connector/src/renderer/dom-renderer.ts
Normal file
416
blocksuite/affine/gfx/connector/src/renderer/dom-renderer.ts
Normal 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
|
||||
);
|
||||
316
blocksuite/affine/gfx/connector/src/renderer/element-renderer.ts
Normal file
316
blocksuite/affine/gfx/connector/src/renderer/element-renderer.ts
Normal 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))
|
||||
)
|
||||
);
|
||||
}
|
||||
2
blocksuite/affine/gfx/connector/src/renderer/index.ts
Normal file
2
blocksuite/affine/gfx/connector/src/renderer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dom-renderer';
|
||||
export * from './element-renderer';
|
||||
312
blocksuite/affine/gfx/connector/src/renderer/utils.ts
Normal file
312
blocksuite/affine/gfx/connector/src/renderer/utils.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
21
blocksuite/affine/gfx/connector/src/store.ts
Normal file
21
blocksuite/affine/gfx/connector/src/store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
1
blocksuite/affine/gfx/connector/src/text/index.ts
Normal file
1
blocksuite/affine/gfx/connector/src/text/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './text.js';
|
||||
66
blocksuite/affine/gfx/connector/src/text/text.ts
Normal file
66
blocksuite/affine/gfx/connector/src/text/text.ts
Normal 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);
|
||||
}
|
||||
436
blocksuite/affine/gfx/connector/src/toolbar/config.ts
Normal file
436
blocksuite/affine/gfx/connector/src/toolbar/config.ts
Normal 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,
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
153
blocksuite/affine/gfx/connector/src/toolbar/connector-menu.ts
Normal file
153
blocksuite/affine/gfx/connector/src/toolbar/connector-menu.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
15
blocksuite/affine/gfx/connector/src/toolbar/quick-tool.ts
Normal file
15
blocksuite/affine/gfx/connector/src/toolbar/quick-tool.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
);
|
||||
40
blocksuite/affine/gfx/connector/src/view.ts
Normal file
40
blocksuite/affine/gfx/connector/src/view.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
blocksuite/affine/gfx/connector/src/view/index.ts
Normal file
1
blocksuite/affine/gfx/connector/src/view/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './view';
|
||||
248
blocksuite/affine/gfx/connector/src/view/view.ts
Normal file
248
blocksuite/affine/gfx/connector/src/view/view.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
22
blocksuite/affine/gfx/connector/tsconfig.json
Normal file
22
blocksuite/affine/gfx/connector/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user