diff --git a/blocksuite/affine/gfx/connector/src/element-renderer/connector-dom.ts b/blocksuite/affine/gfx/connector/src/element-renderer/connector-dom.ts new file mode 100644 index 000000000..6b75e48ad --- /dev/null +++ b/blocksuite/affine/gfx/connector/src/element-renderer/connector-dom.ts @@ -0,0 +1,11 @@ +import { DomElementRendererExtension } from '@blocksuite/affine-block-surface'; + +import { connectorDomRenderer } from './connector-dom/index.js'; + +/** + * Extension to register the DOM-based renderer for 'connector' elements. + */ +export const ConnectorDomRendererExtension = DomElementRendererExtension( + 'connector', + connectorDomRenderer +); diff --git a/blocksuite/affine/gfx/connector/src/element-renderer/connector-dom/index.ts b/blocksuite/affine/gfx/connector/src/element-renderer/connector-dom/index.ts new file mode 100644 index 000000000..44c44fb28 --- /dev/null +++ b/blocksuite/affine/gfx/connector/src/element-renderer/connector-dom/index.ts @@ -0,0 +1,367 @@ +import type { DomRenderer } from '@blocksuite/affine-block-surface'; +import { + type ConnectorElementModel, + ConnectorMode, + DefaultTheme, + type PointStyle, +} from '@blocksuite/affine-model'; +import { PointLocation, SVGPathBuilder } from '@blocksuite/global/gfx'; + +import { isConnectorWithLabel } from '../../connector-manager.js'; +import { DEFAULT_ARROW_SIZE } from '../utils.js'; + +interface PathBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +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( + 'http://www.w3.org/2000/svg', + '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( + 'http://www.w3.org/2000/svg', + '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( + 'http://www.w3.org/2000/svg', + '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( + 'http://www.w3.org/2000/svg', + '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( + 'http://www.w3.org/2000/svg', + '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 renderConnectorLabel( + model: ConnectorElementModel, + container: HTMLElement, + renderer: DomRenderer, + zoom: number +) { + if (!isConnectorWithLabel(model) || !model.labelXYWH) { + return; + } + + const [lx, ly, lw, lh] = model.labelXYWH; + const { + labelStyle: { + color, + fontSize, + fontWeight, + fontStyle, + fontFamily, + textAlign, + }, + } = model; + + // Create label element + const labelElement = document.createElement('div'); + 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 + if (model.text) { + labelElement.textContent = model.text.toString(); + } + + container.append(labelElement); +} + +/** + * 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 connectorDomRenderer = ( + model: ConnectorElementModel, + element: HTMLElement, + renderer: DomRenderer +): void => { + const { zoom } = renderer.viewport; + const { + mode, + path: points, + strokeStyle, + frontEndpointStyle, + rearEndpointStyle, + strokeWidth, + stroke, + } = model; + + // Clear previous content + element.innerHTML = ''; + + // Early return if no path points + if (!points || points.length < 2) { + return; + } + + // 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; + + // Create SVG element + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + 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}`); + + // Create defs for markers + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + svg.append(defs); + + const strokeColor = renderer.getColorValue( + stroke, + DefaultTheme.connectorColor, + true + ); + + // Create markers for endpoints + let startMarkerId = ''; + let endMarkerId = ''; + + if (frontEndpointStyle !== 'None') { + startMarkerId = `start-marker-${model.id}`; + const startMarker = createArrowMarker( + startMarkerId, + frontEndpointStyle, + strokeColor, + strokeWidth, + true + ); + defs.append(startMarker); + } + + if (rearEndpointStyle !== 'None') { + endMarkerId = `end-marker-${model.id}`; + const endMarker = createArrowMarker( + endMarkerId, + rearEndpointStyle, + strokeColor, + strokeWidth, + false + ); + defs.append(endMarker); + } + + // Create path element + const pathElement = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'path' + ); + + // 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); + pathElement.setAttribute('d', pathData); + pathElement.setAttribute('stroke', strokeColor); + pathElement.setAttribute('stroke-width', String(strokeWidth)); + pathElement.setAttribute('fill', 'none'); + pathElement.setAttribute('stroke-linecap', 'round'); + pathElement.setAttribute('stroke-linejoin', 'round'); + + // Apply stroke style + if (strokeStyle === 'dash') { + pathElement.setAttribute('stroke-dasharray', '12,12'); + } + + // Apply markers + if (startMarkerId) { + pathElement.setAttribute('marker-start', `url(#${startMarkerId})`); + } + if (endMarkerId) { + pathElement.setAttribute('marker-end', `url(#${endMarkerId})`); + } + + svg.append(pathElement); + element.append(svg); + + // 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'; + + // Set z-index for layering + element.style.zIndex = renderer.layerManager.getZIndex(model).toString(); + + // Render label if present + renderConnectorLabel(model, element, renderer, zoom); +}; diff --git a/blocksuite/affine/gfx/connector/src/index.ts b/blocksuite/affine/gfx/connector/src/index.ts index 611986b9f..b01e96786 100644 --- a/blocksuite/affine/gfx/connector/src/index.ts +++ b/blocksuite/affine/gfx/connector/src/index.ts @@ -2,6 +2,7 @@ export * from './adapter'; export * from './connector-manager'; export * from './connector-tool'; export * from './element-renderer'; +export { ConnectorDomRendererExtension } from './element-renderer/connector-dom'; export * from './element-transform'; export * from './text'; export * from './toolbar/config'; diff --git a/blocksuite/affine/gfx/connector/src/view.ts b/blocksuite/affine/gfx/connector/src/view.ts index 2b0837601..2d372d00d 100644 --- a/blocksuite/affine/gfx/connector/src/view.ts +++ b/blocksuite/affine/gfx/connector/src/view.ts @@ -7,6 +7,7 @@ import { ConnectionOverlay } from './connector-manager'; import { ConnectorTool } from './connector-tool'; import { effects } from './effects'; import { ConnectorElementRendererExtension } from './element-renderer'; +import { ConnectorDomRendererExtension } from './element-renderer/connector-dom'; import { ConnectorFilter } from './element-transform'; import { connectorToolbarExtension } from './toolbar/config'; import { connectorQuickTool } from './toolbar/quick-tool'; @@ -24,6 +25,7 @@ export class ConnectorViewExtension extends ViewExtensionProvider { super.setup(context); context.register(ConnectorElementView); context.register(ConnectorElementRendererExtension); + context.register(ConnectorDomRendererExtension); if (this.isEdgeless(context.scope)) { context.register(ConnectorTool); context.register(ConnectorFilter); diff --git a/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts b/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts index e2f499105..2308a42d9 100644 --- a/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts +++ b/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts @@ -1,6 +1,7 @@ import type { DomRenderer } from '@blocksuite/affine-block-surface'; import type { ShapeElementModel } from '@blocksuite/affine-model'; import { DefaultTheme } from '@blocksuite/affine-model'; +import { SVGShapeBuilder } from '@blocksuite/global/gfx'; import { manageClassNames, setStyles } from './utils'; @@ -122,25 +123,22 @@ export const shapeDomRenderer = ( element.style.backgroundColor = 'transparent'; // Host element is transparent const strokeW = model.strokeWidth; - const halfStroke = strokeW / 2; // Calculate half stroke width for point adjustment let svgPoints = ''; if (model.shapeType === 'diamond') { - // Adjusted points for diamond - svgPoints = [ - `${unscaledWidth / 2},${halfStroke}`, - `${unscaledWidth - halfStroke},${unscaledHeight / 2}`, - `${unscaledWidth / 2},${unscaledHeight - halfStroke}`, - `${halfStroke},${unscaledHeight / 2}`, - ].join(' '); + // Generate diamond points using shared utility + svgPoints = SVGShapeBuilder.diamond( + unscaledWidth, + unscaledHeight, + strokeW + ); } else { - // triangle - // Adjusted points for triangle - svgPoints = [ - `${unscaledWidth / 2},${halfStroke}`, - `${unscaledWidth - halfStroke},${unscaledHeight - halfStroke}`, - `${halfStroke},${unscaledHeight - halfStroke}`, - ].join(' '); + // triangle - generate triangle points using shared utility + svgPoints = SVGShapeBuilder.triangle( + unscaledWidth, + unscaledHeight, + strokeW + ); } // Determine if stroke should be visible and its color diff --git a/blocksuite/framework/global/src/__tests__/svg-path.unit.spec.ts b/blocksuite/framework/global/src/__tests__/svg-path.unit.spec.ts new file mode 100644 index 000000000..78622044e --- /dev/null +++ b/blocksuite/framework/global/src/__tests__/svg-path.unit.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'vitest'; + +import { SVGPathBuilder, SVGShapeBuilder } from '../gfx/svg-path.js'; + +describe('SVGPathBuilder', () => { + test('should build a simple path', () => { + const pathBuilder = new SVGPathBuilder(); + const result = pathBuilder.moveTo(10, 20).lineTo(30, 40).build(); + + expect(result).toBe('M 10 20 L 30 40'); + }); + + test('should build a path with curves', () => { + const pathBuilder = new SVGPathBuilder(); + const result = pathBuilder + .moveTo(0, 0) + .curveTo(10, 0, 10, 10, 20, 10) + .build(); + + expect(result).toBe('M 0 0 C 10 0 10 10 20 10'); + }); + + test('should build a closed path', () => { + const pathBuilder = new SVGPathBuilder(); + const result = pathBuilder + .moveTo(0, 0) + .lineTo(10, 0) + .lineTo(5, 10) + .closePath() + .build(); + + expect(result).toBe('M 0 0 L 10 0 L 5 10 Z'); + }); + + test('should clear commands', () => { + const pathBuilder = new SVGPathBuilder(); + pathBuilder.moveTo(10, 20).lineTo(30, 40); + pathBuilder.clear(); + const result = pathBuilder.moveTo(0, 0).build(); + + expect(result).toBe('M 0 0'); + }); +}); + +describe('SVGShapeBuilder', () => { + test('should generate diamond polygon points', () => { + const result = SVGShapeBuilder.diamond(100, 80, 2); + expect(result).toBe('50,1 99,40 50,79 1,40'); + }); + + test('should generate triangle polygon points', () => { + const result = SVGShapeBuilder.triangle(100, 80, 2); + expect(result).toBe('50,1 99,79 1,79'); + }); + + test('should generate diamond path', () => { + const result = SVGShapeBuilder.diamondPath(100, 80, 2); + expect(result).toBe('M 50 1 L 99 40 L 50 79 L 1 40 Z'); + }); + + test('should generate triangle path', () => { + const result = SVGShapeBuilder.trianglePath(100, 80, 2); + expect(result).toBe('M 50 1 L 99 79 L 1 79 Z'); + }); + + test('should handle zero stroke width', () => { + const diamondResult = SVGShapeBuilder.diamond(100, 80, 0); + expect(diamondResult).toBe('50,0 100,40 50,80 0,40'); + + const triangleResult = SVGShapeBuilder.triangle(100, 80, 0); + expect(triangleResult).toBe('50,0 100,80 0,80'); + }); +}); diff --git a/blocksuite/framework/global/src/gfx/index.ts b/blocksuite/framework/global/src/gfx/index.ts index 37b0ea9b7..062ddbb3a 100644 --- a/blocksuite/framework/global/src/gfx/index.ts +++ b/blocksuite/framework/global/src/gfx/index.ts @@ -4,4 +4,5 @@ export * from './math.js'; export * from './model/index.js'; export * from './perfect-freehand/index.js'; export * from './polyline.js'; +export * from './svg-path.js'; export * from './xywh.js'; diff --git a/blocksuite/framework/global/src/gfx/svg-path.ts b/blocksuite/framework/global/src/gfx/svg-path.ts new file mode 100644 index 000000000..0ef80c928 --- /dev/null +++ b/blocksuite/framework/global/src/gfx/svg-path.ts @@ -0,0 +1,160 @@ +interface PathCommand { + command: string; + coordinates: number[]; +} + +/** + * A utility class for building SVG path strings using command-based API. + * Supports moveTo, lineTo, curveTo operations and can build complete path strings. + */ +export class SVGPathBuilder { + private commands: PathCommand[] = []; + + /** + * Move to a specific point without drawing + */ + moveTo(x: number, y: number): this { + this.commands.push({ + command: 'M', + coordinates: [x, y], + }); + return this; + } + + /** + * Draw a line to a specific point + */ + lineTo(x: number, y: number): this { + this.commands.push({ + command: 'L', + coordinates: [x, y], + }); + return this; + } + + /** + * Draw a cubic Bézier curve + */ + curveTo( + cp1x: number, + cp1y: number, + cp2x: number, + cp2y: number, + x: number, + y: number + ): this { + this.commands.push({ + command: 'C', + coordinates: [cp1x, cp1y, cp2x, cp2y, x, y], + }); + return this; + } + + /** + * Close the current path + */ + closePath(): this { + this.commands.push({ + command: 'Z', + coordinates: [], + }); + return this; + } + + /** + * Build the complete SVG path string + */ + build(): string { + const pathSegments = this.commands.map(cmd => { + const coords = cmd.coordinates.join(' '); + return coords ? `${cmd.command} ${coords}` : cmd.command; + }); + + return pathSegments.join(' '); + } + + /** + * Clear all commands and reset the builder + */ + clear(): this { + this.commands = []; + return this; + } +} + +/** + * Create SVG polygon points string for common shapes + */ +export class SVGShapeBuilder { + /** + * Generate diamond (rhombus) polygon points + */ + static diamond( + width: number, + height: number, + strokeWidth: number = 0 + ): string { + const halfStroke = strokeWidth / 2; + return [ + `${width / 2},${halfStroke}`, + `${width - halfStroke},${height / 2}`, + `${width / 2},${height - halfStroke}`, + `${halfStroke},${height / 2}`, + ].join(' '); + } + + /** + * Generate triangle polygon points + */ + static triangle( + width: number, + height: number, + strokeWidth: number = 0 + ): string { + const halfStroke = strokeWidth / 2; + return [ + `${width / 2},${halfStroke}`, + `${width - halfStroke},${height - halfStroke}`, + `${halfStroke},${height - halfStroke}`, + ].join(' '); + } + + /** + * Generate diamond path using SVGPathBuilder + */ + static diamondPath( + width: number, + height: number, + strokeWidth: number = 0 + ): string { + const halfStroke = strokeWidth / 2; + const pathBuilder = new SVGPathBuilder(); + + return pathBuilder + .moveTo(width / 2, halfStroke) + .lineTo(width - halfStroke, height / 2) + .lineTo(width / 2, height - halfStroke) + .lineTo(halfStroke, height / 2) + .closePath() + .build(); + } + + /** + * Generate triangle path using SVGPathBuilder + */ + static trianglePath( + width: number, + height: number, + strokeWidth: number = 0 + ): string { + const halfStroke = strokeWidth / 2; + const pathBuilder = new SVGPathBuilder(); + + return pathBuilder + .moveTo(width / 2, halfStroke) + .lineTo(width - halfStroke, height - halfStroke) + .lineTo(halfStroke, height - halfStroke) + .closePath() + .build(); + } +} diff --git a/blocksuite/integration-test/src/__tests__/edgeless/connector-dom.spec.ts b/blocksuite/integration-test/src/__tests__/edgeless/connector-dom.spec.ts new file mode 100644 index 000000000..1f4412d35 --- /dev/null +++ b/blocksuite/integration-test/src/__tests__/edgeless/connector-dom.spec.ts @@ -0,0 +1,158 @@ +import { DomRenderer } from '@blocksuite/affine-block-surface'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { wait } from '../utils/common.js'; +import { getSurface } from '../utils/edgeless.js'; +import { setupEditor } from '../utils/setup.js'; + +describe('Connector rendering with DOM renderer', () => { + beforeEach(async () => { + const cleanup = await setupEditor('edgeless', [], { + enableDomRenderer: true, + }); + return cleanup; + }); + + test('should use DomRenderer when enable_dom_renderer flag is true', async () => { + const surface = getSurface(doc, editor); + expect(surface).not.toBeNull(); + expect(surface?.renderer).toBeInstanceOf(DomRenderer); + }); + + test('should render a connector element as a DOM node', async () => { + const surfaceView = getSurface(window.doc, window.editor); + const surfaceModel = surfaceView.model; + + // Create two shapes to connect + const shape1Id = surfaceModel.addElement({ + type: 'shape', + xywh: '[100, 100, 80, 60]', + }); + + const shape2Id = surfaceModel.addElement({ + type: 'shape', + xywh: '[300, 200, 80, 60]', + }); + + // Create a connector between the shapes + const connectorProps = { + type: 'connector', + source: { id: shape1Id }, + target: { id: shape2Id }, + stroke: '#000000', + strokeWidth: 2, + }; + const connectorId = surfaceModel.addElement(connectorProps); + + await wait(100); + + const connectorElement = surfaceView?.renderRoot.querySelector( + `[data-element-id="${connectorId}"]` + ); + + expect(connectorElement).not.toBeNull(); + expect(connectorElement).toBeInstanceOf(HTMLElement); + + // Check if SVG element is present for connector rendering + const svgElement = connectorElement?.querySelector('svg'); + expect(svgElement).not.toBeNull(); + }); + + test('should render connector with different stroke styles', async () => { + const surfaceView = getSurface(window.doc, window.editor); + const surfaceModel = surfaceView.model; + + // Create a dashed connector + const connectorProps = { + type: 'connector', + source: { position: [100, 100] }, + target: { position: [200, 200] }, + strokeStyle: 'dash', + stroke: '#ff0000', + strokeWidth: 4, + }; + const connectorId = surfaceModel.addElement(connectorProps); + + // Wait for path generation and rendering + await wait(500); + + const connectorElement = surfaceView?.renderRoot.querySelector( + `[data-element-id="${connectorId}"]` + ); + + expect(connectorElement).not.toBeNull(); + + const svgElement = connectorElement?.querySelector('svg'); + expect(svgElement).not.toBeNull(); + + // Find the main path element (not the ones inside markers) + const pathElements = svgElement?.querySelectorAll('path'); + // The main connector path should be the last one (after marker paths) + const pathElement = pathElements?.[pathElements.length - 1]; + + expect(pathElement).not.toBeNull(); + + // Check stroke-dasharray attribute + const strokeDasharray = pathElement!.getAttribute('stroke-dasharray'); + expect(strokeDasharray).toBe('12,12'); + }); + + test('should render connector with arrow endpoints', async () => { + const surfaceView = getSurface(window.doc, window.editor); + const surfaceModel = surfaceView.model; + + const connectorProps = { + type: 'connector', + source: { position: [100, 100] }, + target: { position: [200, 200] }, + frontEndpointStyle: 'Triangle', + rearEndpointStyle: 'Arrow', + }; + const connectorId = surfaceModel.addElement(connectorProps); + + await wait(100); + + const connectorElement = surfaceView?.renderRoot.querySelector( + `[data-element-id="${connectorId}"]` + ); + + expect(connectorElement).not.toBeNull(); + + // Check for markers in defs + const defsElement = connectorElement?.querySelector('defs'); + expect(defsElement).not.toBeNull(); + + const markers = defsElement?.querySelectorAll('marker'); + expect(markers?.length).toBeGreaterThan(0); + }); + + test('should remove connector DOM node when element is deleted', async () => { + const surfaceView = getSurface(window.doc, window.editor); + const surfaceModel = surfaceView.model; + + expect(surfaceView.renderer).toBeInstanceOf(DomRenderer); + + const connectorProps = { + type: 'connector', + source: { position: [50, 50] }, + target: { position: [150, 150] }, + }; + const connectorId = surfaceModel.addElement(connectorProps); + + await wait(100); + + let connectorElement = surfaceView.renderRoot.querySelector( + `[data-element-id="${connectorId}"]` + ); + expect(connectorElement).not.toBeNull(); + + surfaceModel.deleteElement(connectorId); + + await wait(100); + + connectorElement = surfaceView.renderRoot.querySelector( + `[data-element-id="${connectorId}"]` + ); + expect(connectorElement).toBeNull(); + }); +});