diff --git a/docs/components/editors/edgeless-data-structure.md b/docs/components/editors/edgeless-data-structure.md index 9c30b98bab5b..08d3c0102d7b 100644 --- a/docs/components/editors/edgeless-data-structure.md +++ b/docs/components/editors/edgeless-data-structure.md @@ -2,7 +2,7 @@ ## Fundamentals -In BlockSuite, documents in edgeless mode are isomorphic to those in page mode. Edgeless documents are also composed of blocks, with no data conversion occurring during mode switching. +In BlockSuite, documents in edgeless mode are isomorphic to those in page mode. Edgeless documents are also composed of blocks, with no data conversion occurring during mode switching.www By default, the root block of a rich text document contains a single note block child, with a block tree structure like this: diff --git a/packages/affine/gfx/connector/src/components/connector-handle.ts b/packages/affine/gfx/connector/src/components/connector-handle.ts index f72fd7de7f7c..3971b794c115 100644 --- a/packages/affine/gfx/connector/src/components/connector-handle.ts +++ b/packages/affine/gfx/connector/src/components/connector-handle.ts @@ -2,9 +2,11 @@ import { EdgelessLegacySlotIdentifier, OverlayIdentifier, } from '@blocksuite/affine-block-surface'; +import { ConnectorMode } from '@blocksuite/affine-model'; import type { ConnectorElementModel } from '@blocksuite/affine-model'; import { DisposableGroup } from '@blocksuite/global/disposable'; -import { Vec } from '@blocksuite/global/gfx'; +import type { IVec } from '@blocksuite/global/gfx'; +import { getBezierParameters, getBezierPoint, Vec } from '@blocksuite/global/gfx'; import { WithDisposable } from '@blocksuite/global/lit'; import { type BlockComponent, @@ -15,7 +17,7 @@ import { import { GfxControllerIdentifier } from '@blocksuite/std/gfx'; import type { Store } from '@blocksuite/store'; import { consume } from '@lit/context'; -import { css, html, LitElement } from 'lit'; +import { css, html, LitElement, nothing } from 'lit'; import { property, query } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; @@ -77,9 +79,76 @@ export class EdgelessConnectorHandle extends WithDisposable(LitElement) { }); this._disposables.add(() => { this.connectionOverlay.clear(); + this._curveHandleDisposables.dispose(); }); } + private _curveHandleDisposables = new DisposableGroup(); + + /** + * Binds a pointerdown handler to the curve midpoint handle so the user + * can drag it to set a custom control point. The dragged position is + * stored as an absolute IVec on `connector.curveControlPoint` via @field(). + * + * Called after every render; previous listeners are cleaned up first. + */ + private _bindCurveMidpointEvents() { + this._curveHandleDisposables.dispose(); + this._curveHandleDisposables = new DisposableGroup(); + + const el = this.shadowRoot?.querySelector( + '.curve-midpoint' + ) as HTMLElement | null; + if (!el) return; + + this._curveHandleDisposables.addFromEvent( + el, + 'pointerdown', + (e: PointerEvent) => { + e.stopPropagation(); + this._startCurveMidpointDrag(e); + } + ); + } + + /** + * Handles the full drag lifecycle (pointerdown → pointermove → pointerup) + * for the curve midpoint handle. On each move the pointer position is + * converted to absolute model coordinates and written to + * `connector.curveControlPoint`. The entire drag is grouped as a single + * undo entry via `doc.captureSync()` on pointerup (same pattern as + * endpoint drags). + */ + private _startCurveMidpointDrag(_startEvent: PointerEvent) { + const { gfx, connector, slots, _disposables } = this; + + slots.elementResizeStart.next(); + + _disposables.addFromEvent(document, 'pointermove', (e: PointerEvent) => { + // Convert client coordinates to absolute model coordinates + const modelPoint: IVec = gfx.viewport.toModelCoordFromClientCoord([ + e.clientX, + e.clientY, + ]); + + // Store the absolute control point on the model (persisted via @field) + connector.curveControlPoint = modelPoint; + this.requestUpdate(); + }); + + _disposables.addFromEvent(document, 'pointerup', () => { + this.doc.captureSync(); + _disposables.dispose(); + this._disposables = new DisposableGroup(); + this._bindEvent(); + slots.elementResizeEnd.next(); + }); + } + + override updated() { + this._bindCurveMidpointEvents(); + } + private _capPointerDown(e: PointerEvent, connection: 'target' | 'source') { const { gfx, connector, slots, _disposables } = this; e.stopPropagation(); @@ -119,10 +188,35 @@ export class EdgelessConnectorHandle extends WithDisposable(LitElement) { this._bindEvent(); } + private _renderCurveMidpointHandle(zoom: number) { + const { path } = this.connector; + if (this.connector.mode !== ConnectorMode.Curve || path.length < 2) { + return nothing; + } + + const bezierParams = getBezierParameters(path); + const midpoint = getBezierPoint(bezierParams, 0.5); + if (!midpoint) return nothing; + + const screenPoint = Vec.subScalar(Vec.mul(midpoint, zoom), HALF_SIZE); + const style = { + transform: `translate3d(${screenPoint[0]}px,${screenPoint[1]}px,0)`, + }; + + return html` +
+ `; + } + override render() { const { gfx } = this; // path is relative to the element's xywh const { path } = this.connector; + if (!path || path.length < 2) return nothing; + const zoom = gfx.viewport.zoom; const startPoint = Vec.subScalar(Vec.mul(path[0], zoom), HALF_SIZE); const endPoint = Vec.subScalar( @@ -141,6 +235,7 @@ export class EdgelessConnectorHandle extends WithDisposable(LitElement) { style=${styleMap(startStyle)} > + ${this._renderCurveMidpointHandle(zoom)} `; } diff --git a/packages/affine/gfx/connector/src/connector-manager.ts b/packages/affine/gfx/connector/src/connector-manager.ts index 55ed2bc233b6..22e88b5e7d18 100644 --- a/packages/affine/gfx/connector/src/connector-manager.ts +++ b/packages/affine/gfx/connector/src/connector-manager.ts @@ -1190,6 +1190,7 @@ export class ConnectorPathGenerator extends PathGenerator { const instance = new ConnectorPathGenerator({ getElementById: elementGetter ?? (() => null), }); + const points = path ?? instance._generateConnectorPath(connector) ?? []; const bound = connector.mode === ConnectorMode.Curve @@ -1348,7 +1349,7 @@ export class ConnectorPathGenerator extends PathGenerator { ) ); } - return [startPoint, endPoint]; + return this._applyCurveControlPoint(connector, startPoint, endPoint); } else { startPoint = this._getConnectionPoint(connector, 'source'); endPoint = this._getConnectionPoint(connector, 'target'); @@ -1365,10 +1366,49 @@ export class ConnectorPathGenerator extends PathGenerator { startPoint.out = [0, Vec.mul(Vec.sub(endPoint, startPoint), 2 / 3)[1]]; endPoint.in = [0, Vec.mul(Vec.sub(startPoint, endPoint), 2 / 3)[1]]; } - return [startPoint, endPoint]; + return this._applyCurveControlPoint(connector, startPoint, endPoint); } } + /** + * If the connector has a curveControlPoint, recalculate absIn/absOut so the + * cubic Bézier passes exactly through the control point at t = 0.5. + * + * Given endpoints P0 (start) and P3 (end), and desired passthrough C: + * P1 = (4C − P3) / 3 → startPoint.out = P1 − P0 + * P2 = (4C − P0) / 3 → endPoint.in = P2 − P3 + * + * Proof: B(0.5) = (P0 + 3P1 + 3P2 + P3)/8 + * = (P0 + (4C−P3) + (4C−P0) + P3)/8 = 8C/8 = C ✓ + */ + private _applyCurveControlPoint( + connector: ConnectorElementModel | LocalConnectorElementModel, + startPoint: PointLocation, + endPoint: PointLocation + ): PointLocation[] { + if ( + connector instanceof ConnectorElementModel && + connector.curveControlPoint + ) { + const C = connector.curveControlPoint; + const P0: IVec = [startPoint[0], startPoint[1]]; + const P3: IVec = [endPoint[0], endPoint[1]]; + + // out = P1 - P0 = (4C - P3 - 3P0) / 3 + startPoint.out = Vec.div( + Vec.sub(Vec.sub(Vec.mul(C, 4), P3), Vec.mul(P0, 3)), + 3 + ); + + // in = P2 - P3 = (4C - P0 - 3P3) / 3 + endPoint.in = Vec.div( + Vec.sub(Vec.sub(Vec.mul(C, 4), P0), Vec.mul(P3, 3)), + 3 + ); + } + return [startPoint, endPoint]; + } + private _generateStraightConnectorPath( connector: ConnectorElementModel | LocalConnectorElementModel ) { diff --git a/packages/affine/gfx/connector/src/connector-watcher.ts b/packages/affine/gfx/connector/src/connector-watcher.ts index b58c0275c7be..cb0865dab2f6 100644 --- a/packages/affine/gfx/connector/src/connector-watcher.ts +++ b/packages/affine/gfx/connector/src/connector-watcher.ts @@ -62,9 +62,21 @@ export const connectorWatcher: SurfaceMiddleware = ( if ( 'type' in element && element.type === 'connector' && - (props['mode'] !== undefined || props['target'] || props['source']) + (props['mode'] !== undefined || + props['target'] || + props['source'] || + props['curveControlPoint'] !== undefined) ) { - addToUpdateList(element as ConnectorElementModel); + const connector = element as ConnectorElementModel; + + // Clear custom handle data when connector mode changes + if (props['mode'] !== undefined) { + if (connector.curveControlPoint !== null) { + connector.curveControlPoint = null; + } + } + + addToUpdateList(connector); } }), surface.store.slots.blockUpdated.subscribe(payload => { diff --git a/packages/affine/gfx/connector/src/view/view.ts b/packages/affine/gfx/connector/src/view/view.ts index eea605f2db5d..5d5bb289f384 100644 --- a/packages/affine/gfx/connector/src/view/view.ts +++ b/packages/affine/gfx/connector/src/view/view.ts @@ -205,6 +205,8 @@ export const ConnectorInteraction = model.stash('labelXYWH'); model.stash('source'); model.stash('target'); + model.stash('curveControlPoint'); + }, onResizeMove(context): void { @@ -218,6 +220,8 @@ export const ConnectorInteraction = model.pop('labelXYWH'); model.pop('source'); model.pop('target'); + model.pop('curveControlPoint'); + }, }; }, @@ -229,6 +233,8 @@ export const ConnectorInteraction = model.stash('labelXYWH'); model.stash('source'); model.stash('target'); + model.stash('curveControlPoint'); + }, onRotateMove(context): void { @@ -242,6 +248,8 @@ export const ConnectorInteraction = model.pop('labelXYWH'); model.pop('source'); model.pop('target'); + model.pop('curveControlPoint'); + }, }; }, diff --git a/packages/affine/gfx/connector/vitest.config.ts b/packages/affine/gfx/connector/vitest.config.ts new file mode 100644 index 000000000000..be46a7f0bca7 --- /dev/null +++ b/packages/affine/gfx/connector/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + root: './packages/affine/gfx/connector', + include: ['src/__tests__/**/*.unit.spec.ts'], + testTimeout: 1000, + }, +}); diff --git a/packages/affine/model/src/elements/connector/connector.ts b/packages/affine/model/src/elements/connector/connector.ts index ccc86d65d3a7..58d478e59d5f 100644 --- a/packages/affine/model/src/elements/connector/connector.ts +++ b/packages/affine/model/src/elements/connector/connector.ts @@ -338,6 +338,11 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel