diff --git a/package.json b/package.json index e2f0b1240879..c7937da01586 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@size-limit/preset-small-lib": "^11.1.4", "@types/node": "^22.13.10", "@types/react": "^19", + "@vitest/browser": "3.1.3", "es-module-lexer": "^1.5.4", "esbuild": "^0.25.0", "husky": "^9.1.5", diff --git a/packages/affine/gfx/connector/src/connector-manager.ts b/packages/affine/gfx/connector/src/connector-manager.ts index 55ed2bc233b6..8349f6b94aa3 100644 --- a/packages/affine/gfx/connector/src/connector-manager.ts +++ b/packages/affine/gfx/connector/src/connector-manager.ts @@ -6,8 +6,13 @@ import { ConnectorMode, GroupElementModel, type LocalConnectorElementModel, + ShapeElementModel, + ShapeType, } from '@blocksuite/affine-model'; -import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { + EditPropsStore, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; import { BlockSuiteError } from '@blocksuite/global/exceptions'; import type { IBound, IVec, IVec3 } from '@blocksuite/global/gfx'; import { @@ -28,6 +33,7 @@ import { Vec, } from '@blocksuite/global/gfx'; import { assertType } from '@blocksuite/global/utils'; +import type { BlockStdScope } from '@blocksuite/std'; import type { GfxController, GfxLocalElementModel, @@ -59,6 +65,19 @@ export const ConnectorEndpointLocations: IVec[] = [ [0, 0.5], ]; +export const ConnectorEndpointLocationsWithCenter: IVec[] = [ + // At top + [0.5, 0], + // At right + [1, 0.5], + // At bottom + [0.5, 1], + // At left + [0, 0.5], + // At center + [0.5, 0.5], +]; + export const ConnectorEndpointLocationsOnTriangle: IVec[] = [ // At top [0.5, 0], @@ -68,8 +87,131 @@ export const ConnectorEndpointLocationsOnTriangle: IVec[] = [ [0.5, 1], // At left [0.25, 0.5], + // At center + [0.5, 0.5], ]; +/** + * Checks if a GfxModel is a shape eligible for center anchor point. + * Only Rect (including roundedRect), Ellipse, Diamond, and Triangle are eligible. + * Triangle and other complex shapes are excluded. + */ +export function isCenterAnchorEligible(ele: GfxModel): boolean { + if (!(ele instanceof ShapeElementModel)) return false; + const { shapeType } = ele; + return ( + shapeType === ShapeType.Rect || + shapeType === ShapeType.Ellipse || + shapeType === ShapeType.Diamond || + shapeType === ShapeType.Triangle + ); +} + +/** + * Checks if the center anchor feature is enabled via the global toggle. + * Defaults to true (enabled) when no value is stored in localStorage. + */ +export function isCenterAnchorEnabled(std: BlockStdScope): boolean { + const store = std.getOptional(EditPropsStore); + if (!store) return true; + return store.getStorage('connectorCenterAnchor') ?? true; +} + +/** + * Checks if a normalized anchor position is the center point [0.5, 0.5]. + */ +function isCenterAnchorPosition(position: IVec): boolean { + return ( + almostEqual(position[0], 0.5, 0.001) && + almostEqual(position[1], 0.5, 0.001) + ); +} + +/** + * For a center-anchored connector endpoint, computes the perimeter intersection + * point so the connector visually terminates at the shape edge rather than + * penetrating to the center. The logical anchor remains [0.5, 0.5] but the + * visual endpoint is where the line from otherPoint to center crosses the + * shape boundary. + * + * Handles each eligible shape type (Rect, roundedRect, Ellipse, Diamond, Triangle) by + * using the shape's own getLineIntersections method, which dispatches to + * shape-specific geometry (polygon boundary for Rect/Diamond, ellipse math + * for Ellipse). For roundedRect, the polygon approximation of the bounding + * rectangle is used, which is consistent with existing edge anchor behavior. + * + * The function extends the ray beyond the center to handle cases where + * otherPoint is inside or very close to the shape, ensuring a boundary + * intersection is always found. + * + * Returns a new PointLocation at the perimeter with an appropriate tangent, + * or the original centerPoint if no intersection is found. + */ +function computePerimeterPointForCenterAnchor( + connectable: GfxModel, + centerPoint: PointLocation, + otherPoint: PointLocation +): PointLocation { + const bound = Bound.deserialize(connectable.xywh); + const center: IVec = [centerPoint[0], centerPoint[1]]; + const other: IVec = [otherPoint[0], otherPoint[1]]; + + // Compute direction from other point to center + let direction = Vec.sub(center, other); + const dirLen = Vec.len(direction); + + // If the other point is effectively at the center (e.g. both endpoints + // on the same shape center, or overlapping shapes), use a default + // direction pointing right — this gives a deterministic perimeter point + if (dirLen < 0.01) { + direction = [1, 0]; + } else { + direction = Vec.normalize(direction); + } + + // Extend the ray well beyond the center through the shape to guarantee + // it crosses the boundary, even when otherPoint is inside the shape. + // Use the shape's diagonal length as a generous extension distance. + const diagonal = Math.sqrt(bound.w * bound.w + bound.h * bound.h); + const rayStart = Vec.sub(center, Vec.mul(direction, diagonal)); + const rayEnd = Vec.add(center, Vec.mul(direction, diagonal)); + + // Use shape-specific line intersection (rect uses polygon, ellipse uses + // analytic ellipse math, diamond uses polygon) + const intersections = connectable.getLineIntersections(rayStart, rayEnd); + + if (intersections && intersections.length > 0) { + // Pick the intersection closest to the other point — this is the + // perimeter point that the connector should visually terminate at + // (the "entry" side of the shape from the other endpoint's perspective) + let bestIntersection = intersections[0]; + let bestDist = Vec.dist(bestIntersection, other); + + for (let i = 1; i < intersections.length; i++) { + const dist = Vec.dist(intersections[i], other); + if (dist < bestDist) { + bestDist = dist; + bestIntersection = intersections[i]; + } + } + + // The intersection PointLocation already carries an appropriate tangent + // from the shape's getLineIntersections (polygon tangent for Rect/Diamond, + // ellipse normal-derived tangent for Ellipse), which the connector + // routing engine uses for orthogonal/curve path computation. + return bestIntersection; + } + + // Fallback: use getNearestPoint on shape boundary from the other endpoint + const nearest = connectable.getNearestPoint(other); + if (nearest) { + const relPos = bound.toRelative(nearest); + return getConnectableRelativePosition(connectable, relPos); + } + + return centerPoint; +} + export function isConnectorWithLabel(model: GfxModel | GfxLocalElementModel) { return model instanceof ConnectorElementModel && model.hasLabel(); } @@ -130,7 +272,7 @@ export function isConnectorAndBindingsAllSelected( return false; } -export function getAnchors(ele: GfxModel) { +export function getAnchors(ele: GfxModel, includeCenterAnchor = true) { const bound = Bound.deserialize(ele.xywh); const offset = 10; const anchors: { point: PointLocation; coord: IVec }[] = []; @@ -155,6 +297,17 @@ export function getAnchors(ele: GfxModel) { ); anchors.push({ point: rst[0], coord: bound.toRelative(originPoint) }); }); + + // Add center anchor for eligible shapes (Rect, roundedRect, Ellipse, Diamond, Triangle) + if (includeCenterAnchor && isCenterAnchorEligible(ele)) { + const centerPoint = getPointFromBoundsWithRotation( + { ...bound, rotate }, + bound.center + ); + const centerPointLocation = new PointLocation(centerPoint); + anchors.push({ point: centerPointLocation, coord: [0.5, 0.5] }); + } + return anchors; } @@ -171,8 +324,12 @@ function getConnectableRelativePosition(connectable: GfxModel, position: IVec) { return location; } -export function getNearestConnectableAnchor(ele: Connectable, point: IVec) { - const anchors = getAnchors(ele); +export function getNearestConnectableAnchor( + ele: Connectable, + point: IVec, + includeCenterAnchor = true +) { + const anchors = getAnchors(ele, includeCenterAnchor); return closestPoint( anchors.map(a => a.point), point @@ -863,6 +1020,17 @@ export class ConnectionOverlay extends Overlay { targetBounds: IBound | null = null; + /** + * Checks whether the center anchor toggle is enabled. + * Returns true (enabled) by default if no stored value is found. + */ + get centerAnchorEnabled(): boolean { + const store = this.gfx.std.getOptional(EditPropsStore); + if (!store) return true; + const value = store.getStorage('connectorCenterAnchor'); + return value ?? true; + } + constructor(gfx: GfxController) { super(gfx); this._emphasisColor = this._getEmphasisColor(); @@ -976,7 +1144,7 @@ export class ConnectionOverlay extends Overlay { if (!rotateBound.expand(10).isPointInBound(point)) continue; // then check if closes to anchors - const anchors = getAnchors(connectable); + const anchors = getAnchors(connectable, this.centerAnchorEnabled); const len = anchors.length; const pointerViewCoord = context.viewport.toViewCoord(point[0], point[1]); @@ -1258,7 +1426,8 @@ export class ConnectorPathGenerator extends PathGenerator { if (!startPoint || !endPoint) return []; - return [startPoint, endPoint]; + // Adjust center-anchored endpoints to perimeter + return this._adjustCenterAnchorEndpoints(connector, startPoint, endPoint); } private _generateConnectorPath( @@ -1320,6 +1489,14 @@ export class ConnectorPathGenerator extends PathGenerator { if (!startPoint || !endPoint) return []; + // Adjust center-anchored endpoints to perimeter before computing + // control vectors, so tangent and position are correct + [startPoint, endPoint] = this._adjustCenterAnchorEndpoints( + connector, + startPoint, + endPoint + ); + if (source.id) { const startTangentVertical = Vec.rot(startPoint.tangent, -Math.PI / 2); startPoint.out = isVecZero(startTangentVertical) @@ -1386,14 +1563,98 @@ export class ConnectorPathGenerator extends PathGenerator { const eb = Bound.deserialize(end.xywh); const startPoint = getNearestConnectableAnchor(start, eb.center); const endPoint = getNearestConnectableAnchor(end, sb.center); - return (startPoint && endPoint && [startPoint, endPoint]) ?? []; + if (!startPoint || !endPoint) return []; + const [adjStart, adjEnd] = this._adjustCenterAnchorEndpoints( + connector, + startPoint, + endPoint + ); + return [adjStart, adjEnd]; } else { const endPoint = this._getConnectionPoint(connector, 'target'); const startPoint = this._getConnectionPoint(connector, 'source'); - return (startPoint && endPoint && [startPoint, endPoint]) ?? []; + if (!startPoint || !endPoint) return []; + const [adjStart, adjEnd] = this._adjustCenterAnchorEndpoints( + connector, + startPoint, + endPoint + ); + return [adjStart, adjEnd]; } } + /** + * Adjusts connector endpoints so that center-anchored endpoints visually + * terminate at the shape perimeter rather than at the center. + * + * For each endpoint, checks if the logical anchor is the center point + * [0.5, 0.5] on a center-eligible shape. If so, computes the perimeter + * intersection from the opposite endpoint toward the center, and replaces + * the endpoint with the perimeter point. + */ + private _adjustCenterAnchorEndpoints( + connector: ConnectorElementModel | LocalConnectorElementModel, + startPoint: PointLocation, + endPoint: PointLocation + ): [PointLocation, PointLocation] { + const { source, target } = connector; + const start = this._getConnectorEndElement(connector, 'source'); + const end = this._getConnectorEndElement(connector, 'target'); + + let adjustedStart = startPoint; + let adjustedEnd = endPoint; + + // Check if source is center-anchored + if (start && source.id /* && isCenterAnchorEligible(start) */) { + const isCenter = source.position + ? isCenterAnchorPosition(source.position) + : this._isPointAtShapeCenter(start, startPoint); + if (isCenter) { + adjustedStart = computePerimeterPointForCenterAnchor( + start, + startPoint, + endPoint + ); + } + } + + // Check if target is center-anchored + if (end && target.id && isCenterAnchorEligible(end)) { + const isCenter = target.position + ? isCenterAnchorPosition(target.position) + : this._isPointAtShapeCenter(end, endPoint); + if (isCenter) { + adjustedEnd = computePerimeterPointForCenterAnchor( + end, + endPoint, + // Use adjusted start if it was modified, for accurate direction + adjustedStart + ); + } + } + + return [adjustedStart, adjustedEnd]; + } + + /** + * Checks if a PointLocation is at the center of a shape (for detecting + * auto-selected center anchors when no explicit position is set). + */ + private _isPointAtShapeCenter( + connectable: GfxModel, + point: PointLocation + ): boolean { + const bound = Bound.deserialize(connectable.xywh); + const center = getPointFromBoundsWithRotation( + { ...bound, rotate: connectable.rotate }, + bound.center + ); + return ( + almostEqual(point[0], center[0], 1) && + almostEqual(point[1], center[1], 1) + ); + } + private _getConnectionPoint( connector: ConnectorElementModel | LocalConnectorElementModel, type: 'source' | 'target' diff --git a/packages/affine/gfx/connector/src/connector-tool.ts b/packages/affine/gfx/connector/src/connector-tool.ts index d601936ca21e..8f40ac47bed4 100644 --- a/packages/affine/gfx/connector/src/connector-tool.ts +++ b/packages/affine/gfx/connector/src/connector-tool.ts @@ -11,7 +11,10 @@ import { ShapeElementModel, ShapeType, } from '@blocksuite/affine-model'; -import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import { + EditPropsStore, + 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'; @@ -22,6 +25,8 @@ import { type ConnectionOverlay, ConnectorEndpointLocations, ConnectorEndpointLocationsOnTriangle, + ConnectorEndpointLocationsWithCenter, + isCenterAnchorEligible, } from './connector-manager'; enum ConnectorToolMode { @@ -197,11 +202,17 @@ export class ConnectorTool extends BaseTool { this._mode = ConnectorToolMode.Quick; this._sourceBounds = Bound.deserialize(element.xywh); this._sourceBounds.rotate = element.rotate; + const centerAnchorEnabled = + this.std.getOptional(EditPropsStore)?.getStorage( + 'connectorCenterAnchor' + ) ?? true; this._sourceLocations = element instanceof ShapeElementModel && element.shapeType === ShapeType.Triangle ? ConnectorEndpointLocationsOnTriangle - : ConnectorEndpointLocations; + : centerAnchorEnabled && isCenterAnchorEligible(element) + ? ConnectorEndpointLocationsWithCenter + : ConnectorEndpointLocations; this._source = { id: element.id, diff --git a/packages/affine/shared/src/services/edit-props-store.ts b/packages/affine/shared/src/services/edit-props-store.ts index 06ac58178efa..775de74ab700 100644 --- a/packages/affine/shared/src/services/edit-props-store.ts +++ b/packages/affine/shared/src/services/edit-props-store.ts @@ -44,6 +44,7 @@ const LocalPropsSchema = z.object({ presentNoFrameToastShown: z.boolean(), autoHideEmbedHTMLFullScreenToolbar: z.boolean(), + connectorCenterAnchor: z.boolean().default(false), }); type SessionProps = z.infer; @@ -137,6 +138,8 @@ export class EditPropsStore extends LifeCycleWatcher { return 'blocksuite:' + id + ':showBidirectional'; case 'autoHideEmbedHTMLFullScreenToolbar': return 'blocksuite:embedHTML:autoHideFullScreenToolbar'; + case 'connectorCenterAnchor': + return 'blocksuite:connector:centerAnchor'; default: return key; } diff --git a/packages/integration-test/src/__tests__/edgeless/connector-center-anchor.spec.ts b/packages/integration-test/src/__tests__/edgeless/connector-center-anchor.spec.ts new file mode 100644 index 000000000000..e9f629bd8b57 --- /dev/null +++ b/packages/integration-test/src/__tests__/edgeless/connector-center-anchor.spec.ts @@ -0,0 +1,270 @@ +import type { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks/root'; +import { + type ConnectorElementModel, + ConnectorMode, + type ShapeElementModel, + ShapeType, +} from '@blocksuite/affine/model'; +import { EditPropsStore } from '@blocksuite/affine/shared/services'; +import { + getAnchors, + isCenterAnchorEligible, + isCenterAnchorEnabled, +} from '@blocksuite/affine-gfx-connector'; +import { Bound } from '@blocksuite/global/gfx'; +import type { BlockStdScope } from '@blocksuite/std'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { wait } from '../utils/common.js'; +import { getDocRootBlock, getSurface } from '../utils/edgeless.js'; +import { setupEditor } from '../utils/setup.js'; + +describe('Connector center anchor', () => { + let edgelessRoot!: EdgelessRootBlockComponent; + let service!: EdgelessRootBlockComponent['service']; + let std!: BlockStdScope; + + beforeEach(async () => { + localStorage.removeItem('blocksuite:connector:centerAnchor'); + const cleanup = await setupEditor('edgeless'); + edgelessRoot = getDocRootBlock(window.doc, window.editor, 'edgeless'); + service = edgelessRoot.service; + std = edgelessRoot.std; + return cleanup; + }); + + test('eligible shapes: Rect, Ellipse, Diamond, Triangle', () => { + const eligibleTypes = [ + ShapeType.Rect, + ShapeType.Ellipse, + ShapeType.Diamond, + ShapeType.Triangle, + ]; + + for (const shapeType of eligibleTypes) { + const id = service.crud.addElement('shape', { shapeType }); + if (!id) throw new Error(`Failed to create shape ${shapeType}`); + const shape = service.crud.getElementById(id) as ShapeElementModel; + expect(isCenterAnchorEligible(shape)).toBe(true); + } + }); + + test('getAnchors returns 5 anchors with center', () => { + const id = service.crud.addElement('shape', { + shapeType: ShapeType.Rect, + xywh: '[0,0,100,100]', + }); + if (!id) throw new Error('Failed to create shape'); + const shape = service.crud.getElementById(id) as ShapeElementModel; + + const anchors = getAnchors(shape, true); + expect(anchors.length).toBe(5); + + const lastAnchor = anchors[anchors.length - 1]; + expect(lastAnchor.coord[0]).toBeCloseTo(0.5, 1); + expect(lastAnchor.coord[1]).toBeCloseTo(0.5, 1); + }); + + test('getAnchors returns 4 anchors without center', () => { + const id = service.crud.addElement('shape', { + shapeType: ShapeType.Rect, + xywh: '[0,0,100,100]', + }); + if (!id) throw new Error('Failed to create shape'); + const shape = service.crud.getElementById(id) as ShapeElementModel; + + const anchors = getAnchors(shape, false); + expect(anchors.length).toBe(4); + }); + + test('EditPropsStore toggle for connectorCenterAnchor', () => { + const store = std.get(EditPropsStore); + + // Default should be true (enabled) + expect(store.getStorage('connectorCenterAnchor') ?? true).toBe(true); + expect(isCenterAnchorEnabled(std)).toBe(true); + + // Disable center anchor + store.setStorage('connectorCenterAnchor', false); + expect(isCenterAnchorEnabled(std)).toBe(false); + + // Re-enable + store.setStorage('connectorCenterAnchor', true); + expect(isCenterAnchorEnabled(std)).toBe(true); + }); + + test('center-anchored connector creates a valid path', async () => { + const shape1Id = service.crud.addElement('shape', { + shapeType: ShapeType.Rect, + xywh: '[0,0,100,100]', + }); + const shape2Id = service.crud.addElement('shape', { + shapeType: ShapeType.Rect, + xywh: '[300,300,100,100]', + }); + if (!shape1Id || !shape2Id) throw new Error('Failed to create shapes'); + + const connId = service.crud.addElement('connector', { + mode: ConnectorMode.Orthogonal, + source: { id: shape1Id, position: [0.5, 0.5] }, + target: { id: shape2Id, position: [0.5, 0.5] }, + }); + if (!connId) throw new Error('Failed to create connector'); + + await wait(200); + + const connector = service.crud.getElementById( + connId + ) as ConnectorElementModel; + expect(connector.path.length).toBeGreaterThan(0); + }); + + test('endpoint on perimeter, not at shape center', async () => { + const shape1Id = service.crud.addElement('shape', { + shapeType: ShapeType.Rect, + xywh: '[0,0,100,100]', + }); + const shape2Id = service.crud.addElement('shape', { + shapeType: ShapeType.Rect, + xywh: '[300,300,100,100]', + }); + if (!shape1Id || !shape2Id) throw new Error('Failed to create shapes'); + + const connId = service.crud.addElement('connector', { + mode: ConnectorMode.Straight, + source: { id: shape1Id, position: [0.5, 0.5] }, + target: { id: shape2Id, position: [0.5, 0.5] }, + }); + if (!connId) throw new Error('Failed to create connector'); + + await wait(200); + + const connector = service.crud.getElementById( + connId + ) as ConnectorElementModel; + const path = connector.path; + expect(path.length).toBeGreaterThan(0); + + const connBound = Bound.deserialize(connector.xywh); + + // First point of the path (absolute coords) + const firstX = path[0][0] + connBound.x; + const firstY = path[0][1] + connBound.y; + + // Center of shape1 is (50, 50) + const shape1CenterX = 50; + const shape1CenterY = 50; + + // The endpoint should NOT be exactly at the shape center + // (it should be on the perimeter) + const isAtCenter = + Math.abs(firstX - shape1CenterX) < 1 && + Math.abs(firstY - shape1CenterY) < 1; + expect(isAtCenter).toBe(false); + }); + + test('routing modes with center anchor all produce valid paths', async () => { + const modes = [ + ConnectorMode.Straight, + ConnectorMode.Orthogonal, + ConnectorMode.Curve, + ]; + + for (const mode of modes) { + const shape1Id = service.crud.addElement('shape', { + shapeType: ShapeType.Rect, + xywh: '[0,0,100,100]', + }); + const shape2Id = service.crud.addElement('shape', { + shapeType: ShapeType.Rect, + xywh: '[300,300,100,100]', + }); + if (!shape1Id || !shape2Id) throw new Error('Failed to create shapes'); + + const connId = service.crud.addElement('connector', { + mode, + source: { id: shape1Id, position: [0.5, 0.5] }, + target: { id: shape2Id, position: [0.5, 0.5] }, + }); + if (!connId) throw new Error('Failed to create connector'); + + await wait(200); + + const connector = service.crud.getElementById( + connId + ) as ConnectorElementModel; + expect(connector.path.length).toBeGreaterThan(0); + } + }); + + test('two shapes both center-anchored connect correctly', async () => { + const shape1Id = service.crud.addElement('shape', { + shapeType: ShapeType.Ellipse, + xywh: '[0,0,120,80]', + }); + const shape2Id = service.crud.addElement('shape', { + shapeType: ShapeType.Diamond, + xywh: '[400,200,120,80]', + }); + if (!shape1Id || !shape2Id) throw new Error('Failed to create shapes'); + + const connId = service.crud.addElement('connector', { + mode: ConnectorMode.Orthogonal, + source: { id: shape1Id, position: [0.5, 0.5] }, + target: { id: shape2Id, position: [0.5, 0.5] }, + }); + if (!connId) throw new Error('Failed to create connector'); + + await wait(200); + + const connector = service.crud.getElementById( + connId + ) as ConnectorElementModel; + expect(connector.path.length).toBeGreaterThan(0); + expect(connector.source.id).toBe(shape1Id); + expect(connector.target.id).toBe(shape2Id); + }); +}); + +describe('DOM rendering of center-anchored connectors', () => { + beforeEach(async () => { + localStorage.removeItem('blocksuite:connector:centerAnchor'); + const cleanup = await setupEditor('edgeless', [], { + enableDomRenderer: true, + }); + return cleanup; + }); + + test('renders SVG for center-anchored connector', async () => { + const surfaceView = getSurface(window.doc, window.editor); + const surfaceModel = surfaceView.model; + + const shape1Id = surfaceModel.addElement({ + type: 'shape', + xywh: '[100,100,80,60]', + shapeType: ShapeType.Rect, + }); + + const shape2Id = surfaceModel.addElement({ + type: 'shape', + xywh: '[400,300,80,60]', + shapeType: ShapeType.Rect, + }); + + const connectorId = surfaceModel.addElement({ + type: 'connector', + source: { id: shape1Id, position: [0.5, 0.5] }, + target: { id: shape2Id, position: [0.5, 0.5] }, + }); + + await wait(200); + + const connectorElement = surfaceView?.renderRoot.querySelector( + `[data-element-id="${connectorId}"]` + ); + expect(connectorElement).not.toBeNull(); + + const svgElement = connectorElement?.querySelector('svg'); + expect(svgElement).not.toBeNull(); + }); +}); diff --git a/packages/playground/apps/_common/components/starter-debug-menu.ts b/packages/playground/apps/_common/components/starter-debug-menu.ts index 15abde883655..6c259a1003a4 100644 --- a/packages/playground/apps/_common/components/starter-debug-menu.ts +++ b/packages/playground/apps/_common/components/starter-debug-menu.ts @@ -34,7 +34,10 @@ import { PlainTextAdapterFactoryIdentifier, titleMiddleware, } from '@blocksuite/affine/shared/adapters'; -import { DocModeProvider } from '@blocksuite/affine/shared/services'; +import { + DocModeProvider, + EditPropsStore, +} from '@blocksuite/affine/shared/services'; import { ColorVariables, FontFamilyVariables, @@ -611,6 +614,17 @@ export class StarterDebugMenu extends ShadowlessElement { document.body.append(this.commentPanel); } + private _toggleConnectorCenterAnchor() { + const store = this.editor.std.getOptional(EditPropsStore); + if (!store) return; + const current = store.getStorage('connectorCenterAnchor') ?? true; + store.setStorage('connectorCenterAnchor', !current); + toast( + this.editor.host!, + `Connector center anchor: ${!current ? 'enabled' : 'disabled'}` + ); + } + private _toggleDarkMode() { this._setThemeMode(!this._dark); } @@ -908,6 +922,9 @@ export class StarterDebugMenu extends ShadowlessElement { Toggle Adapter Panel + + Toggle Connector Center Anchor + diff --git a/yarn.lock b/yarn.lock index eda2e66bc3b4..241c82e36c40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -242,6 +242,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.10.4": + version: 7.29.0 + resolution: "@babel/code-frame@npm:7.29.0" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.28.5" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10/199e15ff89007dd30675655eec52481cb245c9fdf4f81e4dc1f866603b0217b57aff25f5ffa0a95bbc8e31eb861695330cd7869ad52cc211aa63016320ef72c5 + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.27.2": version: 7.27.5 resolution: "@babel/compat-data@npm:7.27.5" @@ -342,6 +353,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10/8e5d9b0133702cfacc7f368bf792f0f8ac0483794877c6dca5fcb73810ee138e27527701826fb58a40a004f3a5ec0a2f3c3dd5e326d262530b119918f3132ba7 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-validator-option@npm:7.27.1" @@ -4117,6 +4135,13 @@ __metadata: languageName: node linkType: hard +"@polka/url@npm:^1.0.0-next.24": + version: 1.0.0-next.29 + resolution: "@polka/url@npm:1.0.0-next.29" + checksum: 10/69ca11ab15a4ffec7f0b07fcc4e1f01489b3d9683a7e1867758818386575c60c213401259ba3705b8a812228d17e2bfd18e6f021194d943fff4bca389c9d4f28 + languageName: node + linkType: hard + "@preact/signals-core@npm:^1.8.0": version: 1.9.0 resolution: "@preact/signals-core@npm:1.9.0" @@ -4488,6 +4513,31 @@ __metadata: languageName: node linkType: hard +"@testing-library/dom@npm:^10.4.0": + version: 10.4.1 + resolution: "@testing-library/dom@npm:10.4.1" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.3.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + picocolors: "npm:1.1.1" + pretty-format: "npm:^27.0.2" + checksum: 10/7f93e09ea015f151f8b8f42cbab0b2b858999b5445f15239a72a612ef7716e672b14c40c421218194cf191cbecbde0afa6f3dc2cc83dda93ff6a4fb0237df6e6 + languageName: node + linkType: hard + +"@testing-library/user-event@npm:^14.6.1": + version: 14.6.1 + resolution: "@testing-library/user-event@npm:14.6.1" + peerDependencies: + "@testing-library/dom": ">=7.21.4" + checksum: 10/34b74fff56a0447731a94b40d4cf246deb8dbc1c1e3aec93acd1c3377a760bb062e979f1572bb34ec164ad28ee2a391744b42d0d6d6cc16c4ce527e5e09610e1 + languageName: node + linkType: hard + "@toeverything/pdf-viewer-types@npm:0.1.1": version: 0.1.1 resolution: "@toeverything/pdf-viewer-types@npm:0.1.1" @@ -4566,6 +4616,13 @@ __metadata: languageName: node linkType: hard +"@types/aria-query@npm:^5.0.1": + version: 5.0.4 + resolution: "@types/aria-query@npm:5.0.4" + checksum: 10/c0084c389dc030daeaf0115a92ce43a3f4d42fc8fef2d0e22112d87a42798d4a15aac413019d4a63f868327d52ad6740ab99609462b442fe6b9286b172d2e82e + languageName: node + linkType: hard + "@types/body-parser@npm:*": version: 1.19.6 resolution: "@types/body-parser@npm:1.19.6" @@ -5070,6 +5127,33 @@ __metadata: languageName: node linkType: hard +"@vitest/browser@npm:3.1.3": + version: 3.1.3 + resolution: "@vitest/browser@npm:3.1.3" + dependencies: + "@testing-library/dom": "npm:^10.4.0" + "@testing-library/user-event": "npm:^14.6.1" + "@vitest/mocker": "npm:3.1.3" + "@vitest/utils": "npm:3.1.3" + magic-string: "npm:^0.30.17" + sirv: "npm:^3.0.1" + tinyrainbow: "npm:^2.0.0" + ws: "npm:^8.18.1" + peerDependencies: + playwright: "*" + vitest: 3.1.3 + webdriverio: ^7.0.0 || ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + playwright: + optional: true + safaridriver: + optional: true + webdriverio: + optional: true + checksum: 10/a52dbe3ec1ac32b7ac8046641e0bfc31e5c36490a861e7f9572cf04b26fc5ff3edcaccb4c64d7f3c71699f1a1e53dcadb3ef36ca6172367219e7453c2b3fff41 + languageName: node + linkType: hard + "@vitest/expect@npm:3.1.3": version: 3.1.3 resolution: "@vitest/expect@npm:3.1.3" @@ -5631,6 +5715,13 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^5.0.0": + version: 5.2.0 + resolution: "ansi-styles@npm:5.2.0" + checksum: 10/d7f4e97ce0623aea6bc0d90dcd28881ee04cba06c570b97fd3391bd7a268eedfd9d5e2dd4fdcbdd82b8105df5faf6f24aaedc08eaf3da898e702db5948f63469 + languageName: node + linkType: hard + "ansi-styles@npm:^6.0.0, ansi-styles@npm:^6.1.0, ansi-styles@npm:^6.2.1": version: 6.2.1 resolution: "ansi-styles@npm:6.2.1" @@ -5679,6 +5770,15 @@ __metadata: languageName: node linkType: hard +"aria-query@npm:5.3.0": + version: 5.3.0 + resolution: "aria-query@npm:5.3.0" + dependencies: + dequal: "npm:^2.0.3" + checksum: 10/c3e1ed127cc6886fea4732e97dd6d3c3938e64180803acfb9df8955517c4943760746ffaf4020ce8f7ffaa7556a3b5f85c3769a1f5ca74a1288e02d042f9ae4e + languageName: node + linkType: hard + "array-ify@npm:^1.0.0": version: 1.0.0 resolution: "array-ify@npm:1.0.0" @@ -5782,6 +5882,7 @@ __metadata: "@size-limit/preset-small-lib": "npm:^11.1.4" "@types/node": "npm:^22.13.10" "@types/react": "npm:^19" + "@vitest/browser": "npm:3.1.3" es-module-lexer: "npm:^1.5.4" esbuild: "npm:^0.25.0" husky: "npm:^9.1.5" @@ -6539,6 +6640,13 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 10/377b4a7f9eae0a5d72e1068c369c99e0e4ca17fdfd5219f3abd32a73a590749a267475a59d7b03a891f9b673c27429133a818c44b2e47e32fec024b34274e2ca + languageName: node + linkType: hard + "dompurify@npm:^3.2.4": version: 3.2.6 resolution: "dompurify@npm:3.2.6" @@ -9260,6 +9368,13 @@ __metadata: languageName: node linkType: hard +"mrmime@npm:^2.0.0": + version: 2.0.1 + resolution: "mrmime@npm:2.0.1" + checksum: 10/1f966e2c05b7264209c4149ae50e8e830908eb64dd903535196f6ad72681fa109b794007288a3c2814f7a1ecf9ca192769909c0c374d974d604a8de5fc095d4a + languageName: node + linkType: hard + "ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -9766,7 +9881,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.0.0, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": +"picocolors@npm:1.1.1, picocolors@npm:^1.0.0, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10/e1cf46bf84886c79055fdfa9dcb3e4711ad259949e3565154b004b260cd356c5d54b31a1437ce9782624bf766272fe6b0154f5f0c744fb7af5d454d2b60db045 @@ -9874,6 +9989,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^27.0.2": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^17.0.1" + checksum: 10/248990cbef9e96fb36a3e1ae6b903c551ca4ddd733f8d0912b9cc5141d3d0b3f9f8dfb4d799fb1c6723382c9c2083ffbfa4ad43ff9a0e7535d32d41fd5f01da6 + languageName: node + linkType: hard + "proc-log@npm:^5.0.0": version: 5.0.0 resolution: "proc-log@npm:5.0.0" @@ -9970,6 +10096,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^17.0.1": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 10/73b36281e58eeb27c9cc6031301b6ae19ecdc9f18ae2d518bdb39b0ac564e65c5779405d623f1df9abf378a13858b79442480244bd579968afc1faf9a2ce5e05 + languageName: node + linkType: hard + "react@npm:^19.1.0": version: 19.1.0 resolution: "react@npm:19.1.0" @@ -10486,6 +10619,17 @@ __metadata: languageName: node linkType: hard +"sirv@npm:^3.0.1": + version: 3.0.2 + resolution: "sirv@npm:3.0.2" + dependencies: + "@polka/url": "npm:^1.0.0-next.24" + mrmime: "npm:^2.0.0" + totalist: "npm:^3.0.0" + checksum: 10/259617f4ab57664be6d963f5b27b38a6351d3e91ce70d6726985d087b40efd595fcf7f72ae010babf5e0acb63bcb3e3d6db8de34604da1011be6e28ee32aa15d + languageName: node + linkType: hard + "size-limit@npm:11.2.0, size-limit@npm:^11.1.4": version: 11.2.0 resolution: "size-limit@npm:11.2.0" @@ -10982,6 +11126,13 @@ __metadata: languageName: node linkType: hard +"totalist@npm:^3.0.0": + version: 3.0.1 + resolution: "totalist@npm:3.0.1" + checksum: 10/5132d562cf88ff93fd710770a92f31dbe67cc19b5c6ccae2efc0da327f0954d211bbfd9456389655d726c624f284b4a23112f56d1da931ca7cfabbe1f45e778a + languageName: node + linkType: hard + "tough-cookie@npm:^4.1.4": version: 4.1.4 resolution: "tough-cookie@npm:4.1.4" @@ -11914,6 +12065,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.1": + version: 8.19.0 + resolution: "ws@npm:8.19.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/26e4901e93abaf73af9f26a93707c95b4845e91a7a347ec8c569e6e9be7f9df066f6c2b817b2d685544e208207898a750b78461e6e8d810c11a370771450c31b + languageName: node + linkType: hard + "y-indexeddb@npm:^9.0.12": version: 9.0.12 resolution: "y-indexeddb@npm:9.0.12"