diff --git a/blocksuite/affine/blocks/surface/src/extensions/connector-dom-renderer.ts b/blocksuite/affine/blocks/surface/src/extensions/connector-dom-renderer.ts new file mode 100644 index 0000000000..3bd3f4140e --- /dev/null +++ b/blocksuite/affine/blocks/surface/src/extensions/connector-dom-renderer.ts @@ -0,0 +1,3 @@ +import { ConnectorDomRendererExtension } from '../renderer/dom-elements/index.js'; + +export { ConnectorDomRendererExtension }; diff --git a/blocksuite/affine/blocks/surface/src/extensions/index.ts b/blocksuite/affine/blocks/surface/src/extensions/index.ts index ea76916858..f7b2a8d33a 100644 --- a/blocksuite/affine/blocks/surface/src/extensions/index.ts +++ b/blocksuite/affine/blocks/surface/src/extensions/index.ts @@ -1,4 +1,5 @@ export * from './clipboard-config'; +export * from './connector-dom-renderer'; export * from './crud-extension'; export * from './dom-element-renderer'; export * from './edit-props-middleware-builder'; diff --git a/blocksuite/affine/blocks/surface/src/renderer/dom-elements/connector.ts b/blocksuite/affine/blocks/surface/src/renderer/dom-elements/connector.ts new file mode 100644 index 0000000000..fa633eb9a2 --- /dev/null +++ b/blocksuite/affine/blocks/surface/src/renderer/dom-elements/connector.ts @@ -0,0 +1,398 @@ +import type { ConnectorElementModel } from '@blocksuite/affine-model'; +import { ConnectorMode, DefaultTheme } from '@blocksuite/affine-model'; +import { + getBezierParameters, + type PointLocation, +} from '@blocksuite/global/gfx'; + +import { DomElementRendererExtension } from '../../extensions/dom-element-renderer.js'; +import type { DomRenderer } from '../dom-renderer.js'; +import type { DomElementRenderer } from './index.js'; + +/** + * DOM renderer for connector elements. + * Uses SVG to render connector paths, endpoints, and labels. + */ +export const connectorDomRenderer: DomElementRenderer = ( + elementModel, + domElement, + renderer +) => { + const { + mode, + path: points, + strokeStyle, + frontEndpointStyle, + rearEndpointStyle, + strokeWidth, + stroke, + w, + h, + } = elementModel; + + // Clear previous content + domElement.innerHTML = ''; + + // Points might not be built yet in some scenarios (undo/redo, copy/paste) + if (!points.length || points.length < 2) { + return; + } + + // Create SVG element + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.style.width = `${w * renderer.viewport.zoom}px`; + svg.style.height = `${h * renderer.viewport.zoom}px`; + svg.style.position = 'absolute'; + svg.style.top = '0'; + svg.style.left = '0'; + svg.style.pointerEvents = 'none'; + svg.style.overflow = 'visible'; + + const strokeColor = renderer.getColorValue( + stroke, + DefaultTheme.connectorColor, + true + ); + + // Render connector path + renderConnectorPath( + svg, + points, + mode, + strokeStyle, + strokeWidth, + strokeColor, + renderer.viewport.zoom + ); + + // Render endpoints + if (frontEndpointStyle && frontEndpointStyle !== 'None') { + renderEndpoint( + svg, + points, + frontEndpointStyle, + 'front', + strokeWidth, + strokeColor, + mode, + renderer.viewport.zoom + ); + } + + if (rearEndpointStyle && rearEndpointStyle !== 'None') { + renderEndpoint( + svg, + points, + rearEndpointStyle, + 'rear', + strokeWidth, + strokeColor, + mode, + renderer.viewport.zoom + ); + } + + // Render label if exists + if (elementModel.hasLabel()) { + renderConnectorLabel(elementModel, domElement, renderer); + } + + domElement.appendChild(svg); +}; + +function renderConnectorPath( + svg: SVGSVGElement, + points: PointLocation[], + mode: ConnectorMode, + strokeStyle: string, + strokeWidth: number, + strokeColor: string, + zoom: number +) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + + let pathData = ''; + + if (mode === ConnectorMode.Curve) { + // Bezier curve + const bezierParams = getBezierParameters(points); + const [p0, p1, p2, p3] = bezierParams; + pathData = `M ${p0[0]} ${p0[1]} C ${p1[0]} ${p1[1]} ${p2[0]} ${p2[1]} ${p3[0]} ${p3[1]}`; + } else { + // Straight or orthogonal lines + pathData = `M ${points[0][0]} ${points[0][1]}`; + for (let i = 1; i < points.length; i++) { + pathData += ` L ${points[i][0]} ${points[i][1]}`; + } + } + + path.setAttribute('d', pathData); + path.setAttribute('stroke', strokeColor); + path.setAttribute('stroke-width', (strokeWidth * zoom).toString()); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + + if (strokeStyle === 'dash') { + const dashArray = `${12 * zoom},${12 * zoom}`; + path.setAttribute('stroke-dasharray', dashArray); + } + + svg.appendChild(path); +} + +function renderEndpoint( + svg: SVGSVGElement, + points: PointLocation[], + endpointStyle: string, + position: 'front' | 'rear', + strokeWidth: number, + strokeColor: string, + mode: ConnectorMode, + zoom: number +) { + const pointIndex = position === 'rear' ? points.length - 1 : 0; + const point = points[pointIndex]; + const size = 15 * (strokeWidth / 2) * zoom; + + // Calculate tangent direction for endpoint orientation + let tangent: [number, number]; + if (mode === ConnectorMode.Curve) { + const bezierParams = getBezierParameters(points); + // For curve mode, use bezier tangent + if (position === 'rear') { + const lastIdx = points.length - 1; + const prevPoint = points[lastIdx - 1]; + tangent = [point[0] - prevPoint[0], point[1] - prevPoint[1]]; + } else { + const nextPoint = points[1]; + tangent = [nextPoint[0] - point[0], nextPoint[1] - point[1]]; + } + } else { + // For straight/orthogonal mode + if (position === 'rear') { + const prevPoint = points[points.length - 2]; + tangent = [point[0] - prevPoint[0], point[1] - prevPoint[1]]; + } else { + const nextPoint = points[1]; + tangent = [nextPoint[0] - point[0], nextPoint[1] - point[1]]; + } + } + + // Normalize tangent + const length = Math.sqrt(tangent[0] * tangent[0] + tangent[1] * tangent[1]); + if (length > 0) { + tangent[0] /= length; + tangent[1] /= length; + } + + // Adjust tangent direction for front endpoint + if (position === 'front') { + tangent[0] = -tangent[0]; + tangent[1] = -tangent[1]; + } + + switch (endpointStyle) { + case 'Arrow': + renderArrowEndpoint(svg, point, tangent, size, strokeColor, zoom); + break; + case 'Triangle': + renderTriangleEndpoint(svg, point, tangent, size, strokeColor, zoom); + break; + case 'Circle': + renderCircleEndpoint(svg, point, tangent, size, strokeColor, zoom); + break; + case 'Diamond': + renderDiamondEndpoint(svg, point, tangent, size, strokeColor, zoom); + break; + } +} + +function renderArrowEndpoint( + svg: SVGSVGElement, + point: PointLocation, + tangent: [number, number], + size: number, + color: string, + zoom: number +) { + const angle = Math.PI / 4; // 45 degrees + const arrowPath = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'path' + ); + + // Calculate arrow points + const cos1 = Math.cos(angle); + const sin1 = Math.sin(angle); + const cos2 = Math.cos(-angle); + const sin2 = Math.sin(-angle); + + const x1 = point[0] + size * (tangent[0] * cos1 - tangent[1] * sin1); + const y1 = point[1] + size * (tangent[0] * sin1 + tangent[1] * cos1); + const x2 = point[0] + size * (tangent[0] * cos2 - tangent[1] * sin2); + const y2 = point[1] + size * (tangent[0] * sin2 + tangent[1] * cos2); + + const pathData = `M ${x1} ${y1} L ${point[0]} ${point[1]} L ${x2} ${y2}`; + arrowPath.setAttribute('d', pathData); + arrowPath.setAttribute('stroke', color); + arrowPath.setAttribute('stroke-width', (2 * zoom).toString()); + arrowPath.setAttribute('fill', 'none'); + arrowPath.setAttribute('stroke-linecap', 'round'); + arrowPath.setAttribute('stroke-linejoin', 'round'); + + svg.appendChild(arrowPath); +} + +function renderTriangleEndpoint( + svg: SVGSVGElement, + point: PointLocation, + tangent: [number, number], + size: number, + color: string, + zoom: number +) { + const triangle = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'polygon' + ); + + const angle = Math.PI / 3; // 60 degrees + const cos1 = Math.cos(angle); + const sin1 = Math.sin(angle); + const cos2 = Math.cos(-angle); + const sin2 = Math.sin(-angle); + + const x1 = point[0] + size * (tangent[0] * cos1 - tangent[1] * sin1); + const y1 = point[1] + size * (tangent[0] * sin1 + tangent[1] * cos1); + const x2 = point[0] + size * (tangent[0] * cos2 - tangent[1] * sin2); + const y2 = point[1] + size * (tangent[0] * sin2 + tangent[1] * cos2); + + const points = `${point[0]},${point[1]} ${x1},${y1} ${x2},${y2}`; + triangle.setAttribute('points', points); + triangle.setAttribute('fill', color); + triangle.setAttribute('stroke', color); + triangle.setAttribute('stroke-width', (1 * zoom).toString()); + + svg.appendChild(triangle); +} + +function renderCircleEndpoint( + svg: SVGSVGElement, + point: PointLocation, + tangent: [number, number], + size: number, + color: string, + zoom: number +) { + const circle = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'circle' + ); + + const radius = size * 0.5; + const centerX = point[0] + radius * tangent[0]; + const centerY = point[1] + radius * tangent[1]; + + circle.setAttribute('cx', centerX.toString()); + circle.setAttribute('cy', centerY.toString()); + circle.setAttribute('r', radius.toString()); + circle.setAttribute('fill', color); + circle.setAttribute('stroke', color); + circle.setAttribute('stroke-width', (1 * zoom).toString()); + + svg.appendChild(circle); +} + +function renderDiamondEndpoint( + svg: SVGSVGElement, + point: PointLocation, + tangent: [number, number], + size: number, + color: string, + zoom: number +) { + const diamond = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'polygon' + ); + + // Calculate diamond points + const perpX = -tangent[1]; // Perpendicular to tangent + const perpY = tangent[0]; + + const halfSize = size * 0.5; + const x1 = point[0] + halfSize * tangent[0]; // Front point + const y1 = point[1] + halfSize * tangent[1]; + const x2 = point[0] + halfSize * perpX; // Right point + const y2 = point[1] + halfSize * perpY; + const x3 = point[0] - halfSize * tangent[0]; // Back point + const y3 = point[1] - halfSize * tangent[1]; + const x4 = point[0] - halfSize * perpX; // Left point + const y4 = point[1] - halfSize * perpY; + + const points = `${x1},${y1} ${x2},${y2} ${x3},${y3} ${x4},${y4}`; + diamond.setAttribute('points', points); + diamond.setAttribute('fill', color); + diamond.setAttribute('stroke', color); + diamond.setAttribute('stroke-width', (1 * zoom).toString()); + + svg.appendChild(diamond); +} + +function renderConnectorLabel( + elementModel: ConnectorElementModel, + domElement: HTMLElement, + renderer: DomRenderer +) { + if (!elementModel.text || !elementModel.labelXYWH) { + return; + } + + const labelElement = document.createElement('div'); + const [lx, ly, lw, lh] = elementModel.labelXYWH; + const { x, y } = elementModel; + + // Position label relative to the connector + const relativeX = (lx - x) * renderer.viewport.zoom; + const relativeY = (ly - y) * renderer.viewport.zoom; + + labelElement.style.position = 'absolute'; + labelElement.style.left = `${relativeX}px`; + labelElement.style.top = `${relativeY}px`; + labelElement.style.width = `${lw * renderer.viewport.zoom}px`; + labelElement.style.height = `${lh * renderer.viewport.zoom}px`; + labelElement.style.pointerEvents = 'auto'; + labelElement.style.display = 'flex'; + labelElement.style.alignItems = 'center'; + labelElement.style.justifyContent = 'center'; + labelElement.style.backgroundColor = 'white'; + labelElement.style.border = '1px solid #e0e0e0'; + labelElement.style.borderRadius = '4px'; + labelElement.style.padding = '2px 4px'; + labelElement.style.fontSize = `${(elementModel.labelStyle?.fontSize || 16) * renderer.viewport.zoom}px`; + labelElement.style.fontFamily = + elementModel.labelStyle?.fontFamily || 'Inter'; + labelElement.style.color = renderer.getColorValue( + elementModel.labelStyle?.color || DefaultTheme.black, + DefaultTheme.black, + true + ); + labelElement.style.textAlign = elementModel.labelStyle?.textAlign || 'center'; + labelElement.style.overflow = 'hidden'; + labelElement.style.whiteSpace = 'nowrap'; + labelElement.style.textOverflow = 'ellipsis'; + + // Set label text content + labelElement.textContent = elementModel.text.toString(); + + domElement.appendChild(labelElement); +} + +// Export the extension +import { DomElementRendererExtension } from '../../extensions/dom-element-renderer.js'; + +export const ConnectorDomRendererExtension = DomElementRendererExtension( + 'connector', + connectorDomRenderer +); diff --git a/blocksuite/affine/blocks/surface/src/renderer/dom-elements/index.ts b/blocksuite/affine/blocks/surface/src/renderer/dom-elements/index.ts index 057159445c..b38f45b8ba 100644 --- a/blocksuite/affine/blocks/surface/src/renderer/dom-elements/index.ts +++ b/blocksuite/affine/blocks/surface/src/renderer/dom-elements/index.ts @@ -29,3 +29,9 @@ export const DomElementRendererIdentifier = (type: string) => export type DomElementRenderer< T extends SurfaceElementModel = SurfaceElementModel, > = (elementModel: T, domElement: HTMLElement, renderer: DomRenderer) => void; + +// Export the connector DOM renderer +export { + connectorDomRenderer, + ConnectorDomRendererExtension, +} from './connector.js'; diff --git a/blocksuite/affine/blocks/surface/src/view.ts b/blocksuite/affine/blocks/surface/src/view.ts index 02c5cddfed..d596673e4d 100644 --- a/blocksuite/affine/blocks/surface/src/view.ts +++ b/blocksuite/affine/blocks/surface/src/view.ts @@ -7,6 +7,7 @@ import { literal } from 'lit/static-html.js'; import { effects } from './effects'; import { + ConnectorDomRendererExtension, EdgelessCRUDExtension, EdgelessLegacySlotExtension, EditPropsMiddlewareBuilder, @@ -26,6 +27,7 @@ export class SurfaceViewExtension extends ViewExtensionProvider { super.setup(context); context.register([ FlavourExtension('affine:surface'), + ConnectorDomRendererExtension, EdgelessCRUDExtension, EdgelessLegacySlotExtension, ExportManagerExtension,