From df2ecf2bec4bfd1d854665fafa264aa1f16855ee Mon Sep 17 00:00:00 2001 From: Yifeng Wang Date: Sat, 24 May 2025 17:30:47 +0800 Subject: [PATCH] feat(editor): support connector dom renderer --- .../src/element-renderer/connector-dom.ts | 11 + .../element-renderer/connector-dom/index.ts | 359 ++++++++++++++++++ blocksuite/affine/gfx/connector/src/index.ts | 1 + blocksuite/affine/gfx/connector/src/view.ts | 2 + .../__tests__/edgeless/connector-dom.spec.ts | 158 ++++++++ 5 files changed, 531 insertions(+) create mode 100644 blocksuite/affine/gfx/connector/src/element-renderer/connector-dom.ts create mode 100644 blocksuite/affine/gfx/connector/src/element-renderer/connector-dom/index.ts create mode 100644 blocksuite/integration-test/src/__tests__/edgeless/connector-dom.spec.ts 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 0000000000..6b75e48adc --- /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 0000000000..9f43ed1d4d --- /dev/null +++ b/blocksuite/affine/gfx/connector/src/element-renderer/connector-dom/index.ts @@ -0,0 +1,359 @@ +import type { DomRenderer } from '@blocksuite/affine-block-surface'; +import { + type ConnectorElementModel, + ConnectorMode, + DefaultTheme, + type PointStyle, +} from '@blocksuite/affine-model'; +import { PointLocation } 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 ''; + + let pathData = `M ${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]; + pathData += ` C ${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++) { + pathData += ` L ${points[i][0]} ${points[i][1]}`; + } + } + + return pathData; +} + +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 611986b9f7..b01e967865 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 2b08376011..2d372d00d1 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/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 0000000000..1f4412d35f --- /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(); + }); +});