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 index 68dbc3f466..44c44fb280 100644 --- a/blocksuite/affine/gfx/connector/src/element-renderer/connector-dom/index.ts +++ b/blocksuite/affine/gfx/connector/src/element-renderer/connector-dom/index.ts @@ -5,7 +5,7 @@ import { DefaultTheme, type PointStyle, } from '@blocksuite/affine-model'; -import { PointLocation } from '@blocksuite/global/gfx'; +import { PointLocation, SVGPathBuilder } from '@blocksuite/global/gfx'; import { isConnectorWithLabel } from '../../connector-manager.js'; import { DEFAULT_ARROW_SIZE } from '../utils.js'; @@ -17,60 +17,6 @@ interface PathBounds { maxY: number; } -interface PathCommand { - command: string; - coordinates: number[]; -} - -class SVGPathBuilder { - private commands: PathCommand[] = []; - - moveTo(x: number, y: number): this { - this.commands.push({ - command: 'M', - coordinates: [x, y], - }); - return this; - } - - lineTo(x: number, y: number): this { - this.commands.push({ - command: 'L', - coordinates: [x, y], - }); - return this; - } - - 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; - } - - build(): string { - const pathSegments = this.commands.map(cmd => { - const coords = cmd.coordinates.join(' '); - return `${cmd.command} ${coords}`; - }); - - return pathSegments.join(' '); - } - - clear(): this { - this.commands = []; - return this; - } -} - function calculatePathBounds(path: PointLocation[]): PathBounds { if (path.length === 0) { return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; 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 e2f4991053..2308a42d9e 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 0000000000..78622044e5 --- /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 37b0ea9b72..062ddbb3a1 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 0000000000..0ef80c928f --- /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(); + } +}