Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/components/editors/edgeless-data-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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`
<div
class="line-controller curve-midpoint"
style=${styleMap(style)}
></div>
`;
}

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(
Expand All @@ -141,6 +235,7 @@ export class EdgelessConnectorHandle extends WithDisposable(LitElement) {
style=${styleMap(startStyle)}
></div>
<div class="line-controller line-end" style=${styleMap(endStyle)}></div>
${this._renderCurveMidpointHandle(zoom)}
`;
}

Expand Down
44 changes: 42 additions & 2 deletions packages/affine/gfx/connector/src/connector-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand All @@ -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
) {
Expand Down
16 changes: 14 additions & 2 deletions packages/affine/gfx/connector/src/connector-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
8 changes: 8 additions & 0 deletions packages/affine/gfx/connector/src/view/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ export const ConnectorInteraction =
model.stash('labelXYWH');
model.stash('source');
model.stash('target');
model.stash('curveControlPoint');

},

onResizeMove(context): void {
Expand All @@ -218,6 +220,8 @@ export const ConnectorInteraction =
model.pop('labelXYWH');
model.pop('source');
model.pop('target');
model.pop('curveControlPoint');

},
};
},
Expand All @@ -229,6 +233,8 @@ export const ConnectorInteraction =
model.stash('labelXYWH');
model.stash('source');
model.stash('target');
model.stash('curveControlPoint');

},

onRotateMove(context): void {
Expand All @@ -242,6 +248,8 @@ export const ConnectorInteraction =
model.pop('labelXYWH');
model.pop('source');
model.pop('target');
model.pop('curveControlPoint');

},
};
},
Expand Down
9 changes: 9 additions & 0 deletions packages/affine/gfx/connector/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
26 changes: 26 additions & 0 deletions packages/affine/model/src/elements/connector/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,11 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
};
}

// Translate curveControlPoint by the same offset
if (this.curveControlPoint) {
this.curveControlPoint = Vec.add(this.curveControlPoint, offset) as IVec;
}

// Updates Connector's Label position.
if (this.hasLabel()) {
const [x, y, w, h] = this.labelXYWH!;
Expand All @@ -352,6 +357,7 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
const props: {
source?: Connection;
target?: Connection;
curveControlPoint?: IVec | null;
} = {};

if (!this.source.id) {
Expand All @@ -367,6 +373,15 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
};
}

// Proportionally transform curveControlPoint via the same matrix
if (this.curveControlPoint) {
const { x, y } = new DOMPoint(
this.curveControlPoint[0],
this.curveControlPoint[1]
).matrixTransform(matrix);
props.curveControlPoint = [x, y];
}

return props;
}

Expand Down Expand Up @@ -405,6 +420,17 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
@local()
accessor absolutePath: PointLocation[] = [];

/**
* Absolute control point (in model/world coordinates) for Curve connectors.
* When set, the cubic Bézier is recalculated so that the curve passes
* through this point at t = 0.5. Stored via @field() for Yjs/CRDT
* persistence. `null` means "use default automatic tangents".
*
* Cleared when connector mode changes.
*/
@field()
accessor curveControlPoint: IVec | null = null;

@field('None' as PointStyle)
accessor frontEndpointStyle!: PointStyle;

Expand Down