From 547d95cc0d2d1582c8e5107d310dfabd8e8d71c8 Mon Sep 17 00:00:00 2001 From: Mathieu Jolly Date: Thu, 19 Mar 2026 21:43:15 +0100 Subject: [PATCH 01/10] feat(edgeless): fisrt version of the polygonne shape feature --- .../gfx/connector/src/connector-manager.ts | 86 +++ .../gfx/connector/src/connector-tool.ts | 33 +- .../shape/src/draggable/shape-tool-button.ts | 27 +- .../src/element-renderer/shape-dom/index.ts | 46 +- .../shape/src/element-renderer/shape/index.ts | 2 + .../src/element-renderer/shape/polygon.ts | 130 ++++ .../shape/src/element-renderer/shape/utils.ts | 96 +++ packages/affine/gfx/shape/src/element-view.ts | 335 ++++++++- packages/affine/gfx/shape/src/index.ts | 1 + .../affine/gfx/shape/src/overlay/factory.ts | 3 + .../affine/gfx/shape/src/overlay/index.ts | 2 + .../src/overlay/polygon-drawing-overlay.ts | 195 ++++++ .../overlay/polygon-vertex-editing-overlay.ts | 590 ++++++++++++++++ .../affine/gfx/shape/src/overlay/polygon.ts | 28 + .../affine/gfx/shape/src/overlay/utils.ts | 3 + packages/affine/gfx/shape/src/polygon-tool.ts | 402 +++++++++++ packages/affine/gfx/shape/src/shape-tool.ts | 1 + .../affine/gfx/shape/src/toolbar/icons.ts | 44 ++ .../shape/src/toolbar/shape-menu-config.ts | 9 + packages/affine/gfx/shape/src/view.ts | 2 + packages/affine/model/src/consts/shape.ts | 1 + .../model/src/elements/shape/api/index.ts | 2 + .../model/src/elements/shape/api/polygon.ts | 167 +++++ .../affine/model/src/elements/shape/shape.ts | 56 ++ .../affine/shared/src/utils/zod-schema.ts | 1 + .../src/__tests__/polygon-crdt.unit.spec.ts | 650 ++++++++++++++++++ .../edgeless/polygon-persistence.spec.ts | 395 +++++++++++ 27 files changed, 3290 insertions(+), 17 deletions(-) create mode 100644 packages/affine/gfx/shape/src/element-renderer/shape/polygon.ts create mode 100644 packages/affine/gfx/shape/src/overlay/polygon-drawing-overlay.ts create mode 100644 packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts create mode 100644 packages/affine/gfx/shape/src/overlay/polygon.ts create mode 100644 packages/affine/gfx/shape/src/polygon-tool.ts create mode 100644 packages/affine/model/src/elements/shape/api/polygon.ts create mode 100644 packages/framework/store/src/__tests__/polygon-crdt.unit.spec.ts create mode 100644 packages/integration-test/src/__tests__/edgeless/polygon-persistence.spec.ts diff --git a/packages/affine/gfx/connector/src/connector-manager.ts b/packages/affine/gfx/connector/src/connector-manager.ts index 55ed2bc233b6..8b84b0385ee0 100644 --- a/packages/affine/gfx/connector/src/connector-manager.ts +++ b/packages/affine/gfx/connector/src/connector-manager.ts @@ -6,6 +6,8 @@ import { ConnectorMode, GroupElementModel, type LocalConnectorElementModel, + ShapeElementModel, + ShapeType, } from '@blocksuite/affine-model'; import { ThemeProvider } from '@blocksuite/affine-shared/services'; import { BlockSuiteError } from '@blocksuite/global/exceptions'; @@ -136,6 +138,90 @@ export function getAnchors(ele: GfxModel) { const anchors: { point: PointLocation; coord: IVec }[] = []; const rotate = ele.rotate; + // For polygon shapes, generate anchors at each vertex and edge midpoint + if ( + ele instanceof ShapeElementModel && + ele.shapeType === ShapeType.Polygon + ) { + const verts: number[][] = ele.vertices ?? [ + [0.5, 0], + [1, 0.38], + [0.81, 1], + [0.19, 1], + [0, 0.38], + ]; + + // Compute the polygon centroid in normalized space for outward normal calc + let cx = 0, + cy = 0; + for (const v of verts) { + cx += v[0]; + cy += v[1]; + } + cx /= verts.length; + cy /= verts.length; + + for (let i = 0; i < verts.length; i++) { + const curr = verts[i]; + const next = verts[(i + 1) % verts.length]; + + // --- Vertex anchor --- + const vertCoord: IVec = [curr[0], curr[1]]; + const vertAbs: IVec = [ + bound.x + vertCoord[0] * bound.w, + bound.y + vertCoord[1] * bound.h, + ]; + const vertRotated = getPointFromBoundsWithRotation( + { ...bound, rotate }, + vertAbs + ); + // Tangent at vertex: outward direction from centroid through vertex + const vOutX = curr[0] - cx; + const vOutY = curr[1] - cy; + const vOutLen = Math.sqrt(vOutX * vOutX + vOutY * vOutY) || 1; + const vertTangent: IVec = [vOutX / vOutLen, vOutY / vOutLen]; + anchors.push({ + point: new PointLocation(vertRotated, vertTangent), + coord: vertCoord, + }); + + // --- Edge midpoint anchor --- + const midCoord: IVec = [ + (curr[0] + next[0]) / 2, + (curr[1] + next[1]) / 2, + ]; + const midAbs: IVec = [ + bound.x + midCoord[0] * bound.w, + bound.y + midCoord[1] * bound.h, + ]; + const midRotated = getPointFromBoundsWithRotation( + { ...bound, rotate }, + midAbs + ); + // Tangent at edge midpoint: outward normal of the edge + // Edge direction (curr → next), then rotate 90° to get normal + const edx = (next[0] - curr[0]) * bound.w; + const edy = (next[1] - curr[1]) * bound.h; + // Perpendicular candidates: (edy, -edx) and (-edy, edx) + // Pick the one pointing away from centroid + let nx = edy, + ny = -edx; + const toMidX = midCoord[0] - cx; + const toMidY = midCoord[1] - cy; + if (nx * toMidX + ny * toMidY < 0) { + nx = -nx; + ny = -ny; + } + const nLen = Math.sqrt(nx * nx + ny * ny) || 1; + const midTangent: IVec = [nx / nLen, ny / nLen]; + anchors.push({ + point: new PointLocation(midRotated, midTangent), + coord: midCoord, + }); + } + return anchors; + } + ( [ [bound.center[0], bound.y - offset], diff --git a/packages/affine/gfx/connector/src/connector-tool.ts b/packages/affine/gfx/connector/src/connector-tool.ts index d601936ca21e..cf365ae3814f 100644 --- a/packages/affine/gfx/connector/src/connector-tool.ts +++ b/packages/affine/gfx/connector/src/connector-tool.ts @@ -197,11 +197,36 @@ export class ConnectorTool extends BaseTool { this._mode = ConnectorToolMode.Quick; this._sourceBounds = Bound.deserialize(element.xywh); this._sourceBounds.rotate = element.rotate; - this._sourceLocations = + + if ( element instanceof ShapeElementModel && - element.shapeType === ShapeType.Triangle - ? ConnectorEndpointLocationsOnTriangle - : ConnectorEndpointLocations; + element.shapeType === ShapeType.Polygon + ) { + // Use polygon vertices and edge midpoints as endpoint locations + const verts: number[][] = element.vertices ?? [ + [0.5, 0], + [1, 0.38], + [0.81, 1], + [0.19, 1], + [0, 0.38], + ]; + const locations: IVec[] = []; + for (let i = 0; i < verts.length; i++) { + locations.push([verts[i][0], verts[i][1]]); + const next = verts[(i + 1) % verts.length]; + locations.push([ + (verts[i][0] + next[0]) / 2, + (verts[i][1] + next[1]) / 2, + ]); + } + this._sourceLocations = locations; + } else { + this._sourceLocations = + element instanceof ShapeElementModel && + element.shapeType === ShapeType.Triangle + ? ConnectorEndpointLocationsOnTriangle + : ConnectorEndpointLocations; + } this._source = { id: element.id, diff --git a/packages/affine/gfx/shape/src/draggable/shape-tool-button.ts b/packages/affine/gfx/shape/src/draggable/shape-tool-button.ts index 1e3442a1f6d3..bd592c90b8e5 100644 --- a/packages/affine/gfx/shape/src/draggable/shape-tool-button.ts +++ b/packages/affine/gfx/shape/src/draggable/shape-tool-button.ts @@ -3,6 +3,7 @@ import { EdgelessToolbarToolMixin } from '@blocksuite/affine-widget-edgeless-too import { SignalWatcher } from '@blocksuite/global/lit'; import { css, html, LitElement } from 'lit'; +import { PolygonTool } from '../polygon-tool.js'; import { ShapeTool } from '../shape-tool.js'; import type { DraggableShape } from './utils.js'; @@ -23,32 +24,40 @@ export class EdgelessShapeToolButton extends EdgelessToolbarToolMixin( `; private readonly _handleShapeClick = (shape: DraggableShape) => { - this.setEdgelessTool(this.type, { - shapeName: shape.name, - }); + if (shape.name === ShapeType.Polygon) { + this.setEdgelessTool(PolygonTool); + } else { + this.setEdgelessTool(ShapeTool, { + shapeName: shape.name, + }); + } if (!this.popper) this._toggleMenu(); }; private readonly _handleWrapperClick = () => { if (this.tryDisposePopper()) return; - this.setEdgelessTool(this.type, { + this.setEdgelessTool(ShapeTool, { shapeName: ShapeType.Rect, }); if (!this.popper) this._toggleMenu(); }; - override type = ShapeTool; + override type = [ShapeTool, PolygonTool]; private _toggleMenu() { this.createPopper('edgeless-shape-menu', this, { setProps: ele => { ele.edgeless = this.edgeless; ele.onChange = (shapeName: ShapeName) => { - this.setEdgelessTool(this.type, { - shapeName, - }); - this._updateOverlay(); + if (shapeName === ShapeType.Polygon) { + this.setEdgelessTool(PolygonTool); + } else { + this.setEdgelessTool(ShapeTool, { + shapeName, + }); + this._updateOverlay(); + } }; }, }); diff --git a/packages/affine/gfx/shape/src/element-renderer/shape-dom/index.ts b/packages/affine/gfx/shape/src/element-renderer/shape-dom/index.ts index 2308a42d9efd..bb7563097dc3 100644 --- a/packages/affine/gfx/shape/src/element-renderer/shape-dom/index.ts +++ b/packages/affine/gfx/shape/src/element-renderer/shape-dom/index.ts @@ -14,7 +14,11 @@ function applyShapeSpecificStyles( element.style.removeProperty('clip-path'); element.style.removeProperty('border-radius'); // Clear DOM for shapes that don't use SVG, or if type changes from SVG-based to non-SVG-based - if (model.shapeType !== 'diamond' && model.shapeType !== 'triangle') { + if ( + model.shapeType !== 'diamond' && + model.shapeType !== 'triangle' && + model.shapeType !== 'polygon' + ) { while (element.firstChild) element.firstChild.remove(); } @@ -37,6 +41,21 @@ function applyShapeSpecificStyles( case 'triangle': element.style.clipPath = 'polygon(50% 0%, 100% 100%, 0% 100%)'; break; + case 'polygon': { + // Build CSS clip-path from normalized vertices + const vertices = model.vertices ?? [ + [0.5, 0], + [1, 0.38], + [0.81, 1], + [0.19, 1], + [0, 0.38], + ]; + const clipPoints = vertices + .map(v => `${(v[0] * 100).toFixed(1)}% ${(v[1] * 100).toFixed(1)}%`) + .join(', '); + element.style.clipPath = `polygon(${clipPoints})`; + break; + } } // No 'else' needed to clear styles, as they are reset at the beginning of the function. } @@ -117,7 +136,11 @@ export const shapeDomRenderer = ( // Apply shape-specific clipping, border-radius, and potentially clear innerHTML applyShapeSpecificStyles(model, element, zoom); - if (model.shapeType === 'diamond' || model.shapeType === 'triangle') { + if ( + model.shapeType === 'diamond' || + model.shapeType === 'triangle' || + model.shapeType === 'polygon' + ) { // For diamond and triangle, fill and border are handled by inline SVG element.style.border = 'none'; // Ensure no standard CSS border interferes element.style.backgroundColor = 'transparent'; // Host element is transparent @@ -132,13 +155,30 @@ export const shapeDomRenderer = ( unscaledHeight, strokeW ); - } else { + } else if (model.shapeType === 'triangle') { // triangle - generate triangle points using shared utility svgPoints = SVGShapeBuilder.triangle( unscaledWidth, unscaledHeight, strokeW ); + } else if (model.shapeType === 'polygon') { + // Generate polygon points from normalized vertices + const vertices = model.vertices ?? [ + [0.5, 0], + [1, 0.38], + [0.81, 1], + [0.19, 1], + [0, 0.38], + ]; + const halfStroke = strokeW / 2; + svgPoints = vertices + .map(v => { + const px = halfStroke + v[0] * (unscaledWidth - strokeW); + const py = halfStroke + v[1] * (unscaledHeight - strokeW); + return `${px},${py}`; + }) + .join(' '); } // Determine if stroke should be visible and its color diff --git a/packages/affine/gfx/shape/src/element-renderer/shape/index.ts b/packages/affine/gfx/shape/src/element-renderer/shape/index.ts index 5bc892828a4b..124e9e29bdb6 100644 --- a/packages/affine/gfx/shape/src/element-renderer/shape/index.ts +++ b/packages/affine/gfx/shape/src/element-renderer/shape/index.ts @@ -24,6 +24,7 @@ import { deltaInsertsToChunks } from '@blocksuite/std/inline'; import { diamond } from './diamond.js'; import { ellipse } from './ellipse.js'; +import { polygon } from './polygon.js'; import { rect } from './rect.js'; import { triangle } from './triangle.js'; import { type Colors, horizontalOffset, verticalOffset } from './utils.js'; @@ -43,6 +44,7 @@ const shapeRenderers: Record< rect, triangle, ellipse, + polygon, }; export const shape: ElementRenderer = ( diff --git a/packages/affine/gfx/shape/src/element-renderer/shape/polygon.ts b/packages/affine/gfx/shape/src/element-renderer/shape/polygon.ts new file mode 100644 index 000000000000..c1b322acf15a --- /dev/null +++ b/packages/affine/gfx/shape/src/element-renderer/shape/polygon.ts @@ -0,0 +1,130 @@ +import type { + CanvasRenderer, + RoughCanvas, +} from '@blocksuite/affine-block-surface'; +import type { + LocalShapeElementModel, + ShapeElementModel, +} from '@blocksuite/affine-model'; + +import { type Colors, drawGeneralShape } from './utils.js'; + +export function polygon( + model: ShapeElementModel | LocalShapeElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer, + rc: RoughCanvas, + colors: Colors +) { + const { + seed, + strokeWidth, + filled, + strokeStyle, + roughness, + rotate, + shapeStyle, + } = model; + const [, , w, h] = model.deserializedXYWH; + const renderOffset = Math.max(strokeWidth, 0) / 2; + const renderWidth = w - renderOffset * 2; + const renderHeight = h - renderOffset * 2; + const cx = renderWidth / 2; + const cy = renderHeight / 2; + + const { fillColor, strokeColor } = colors; + + ctx.setTransform( + matrix + .translateSelf(renderOffset, renderOffset) + .translateSelf(cx, cy) + .rotateSelf(rotate) + .translateSelf(-cx, -cy) + ); + + // Get vertices - use default pentagon if not set + const vertices = + 'vertices' in model && model.vertices + ? model.vertices + : [ + [0.5, 0], + [1, 0.38], + [0.81, 1], + [0.19, 1], + [0, 0.38], + ]; + + if (shapeStyle === 'General') { + drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor); + } else { + // Convert normalized vertices to absolute coordinates for rough.js + const absPoints: [number, number][] = vertices.map( + v => [v[0] * renderWidth, v[1] * renderHeight] as [number, number] + ); + + const smoothFlags = + 'smoothFlags' in model && model.smoothFlags + ? model.smoothFlags + : null; + + const hasBezier = smoothFlags && smoothFlags.some(f => f); + + if (hasBezier) { + // For Bezier polygons with rough.js, build the path manually + // since rough.js polygon() only supports straight lines. + // We use rough.path() with an SVG path string. + const count = absPoints.length; + let pathD = `M ${absPoints[0][0]} ${absPoints[0][1]} `; + for (let i = 0; i < count; i++) { + const next = (i + 1) % count; + const currSmooth = smoothFlags[i] ?? false; + const nextSmooth = smoothFlags[next] ?? false; + + const [cx, cy] = absPoints[i]; + const [nx, ny] = absPoints[next]; + + if (!currSmooth && !nextSmooth) { + pathD += `L ${nx} ${ny} `; + } else { + let cp1x: number, cp1y: number; + if (currSmooth) { + cp1x = cx + (nx - cx) / 3; + cp1y = cy + (ny - cy) / 3; + } else { + cp1x = cx; + cp1y = cy; + } + let cp2x: number, cp2y: number; + if (nextSmooth) { + cp2x = nx + (cx - nx) / 3; + cp2y = ny + (cy - ny) / 3; + } else { + cp2x = nx; + cp2y = ny; + } + pathD += `C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${nx} ${ny} `; + } + } + pathD += 'Z'; + + rc.path(pathD, { + seed, + roughness: shapeStyle === 'Scribbled' ? roughness : 0, + strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined, + stroke: strokeStyle === 'none' ? 'none' : strokeColor, + strokeWidth, + fill: filled ? fillColor : undefined, + }); + } else { + rc.polygon(absPoints, { + seed, + roughness: shapeStyle === 'Scribbled' ? roughness : 0, + strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined, + stroke: strokeStyle === 'none' ? 'none' : strokeColor, + strokeWidth, + fill: filled ? fillColor : undefined, + }); + } + } +} diff --git a/packages/affine/gfx/shape/src/element-renderer/shape/utils.ts b/packages/affine/gfx/shape/src/element-renderer/shape/utils.ts index 77991c020051..25ef745eb817 100644 --- a/packages/affine/gfx/shape/src/element-renderer/shape/utils.ts +++ b/packages/affine/gfx/shape/src/element-renderer/shape/utils.ts @@ -49,6 +49,10 @@ export function drawGeneralShape( break; case 'triangle': drawTriangle(ctx, 0, 0, w, h); + break; + case 'polygon': + drawPolygon(ctx, w, h, shapeModel); + break; } ctx.lineWidth = shapeModel.strokeWidth; @@ -58,10 +62,15 @@ export function drawGeneralShape( switch (shapeModel.strokeStyle) { case 'none': ctx.strokeStyle = 'transparent'; + ctx.setLineDash([]); break; case 'dash': ctx.setLineDash([12, 12]); break; + default: + // 'solid' — ensure no dash pattern is active + ctx.setLineDash([]); + break; } if (shapeModel.shadow) { @@ -164,6 +173,93 @@ function drawTriangle( ctx.closePath(); } +function drawPolygon( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + shapeModel: ShapeElementModel | LocalShapeElementModel +) { + // Default pentagon vertices if none set + const vertices = + 'vertices' in shapeModel && shapeModel.vertices + ? shapeModel.vertices + : [ + [0.5, 0], + [1, 0.38], + [0.81, 1], + [0.19, 1], + [0, 0.38], + ]; + + const smoothFlags = + 'smoothFlags' in shapeModel && shapeModel.smoothFlags + ? shapeModel.smoothFlags + : null; + + const count = vertices.length; + if (count === 0) return; + + ctx.beginPath(); + + if (!smoothFlags || smoothFlags.every(f => !f)) { + // No Bezier smoothing - draw straight lines + ctx.moveTo(vertices[0][0] * width, vertices[0][1] * height); + for (let i = 1; i < count; i++) { + ctx.lineTo(vertices[i][0] * width, vertices[i][1] * height); + } + } else { + // Some vertices have Bezier smoothing + // For each edge (from vertex i to vertex i+1): + // - If vertex i is smooth, the outgoing control point is at 1/3 toward next + // - If vertex i+1 is smooth, the incoming control point is at 1/3 toward prev + // - If both endpoints are sharp, draw a straight line + // - If either endpoint is smooth, draw a cubic Bezier + + const abs = (v: number[]): [number, number] => [v[0] * width, v[1] * height]; + + ctx.moveTo(vertices[0][0] * width, vertices[0][1] * height); + + for (let i = 0; i < count; i++) { + const next = (i + 1) % count; + const currSmooth = smoothFlags[i] ?? false; + const nextSmooth = smoothFlags[next] ?? false; + + const [cx, cy] = abs(vertices[i]); + const [nx, ny] = abs(vertices[next]); + + if (!currSmooth && !nextSmooth) { + // Both sharp: straight line + ctx.lineTo(nx, ny); + } else { + // At least one endpoint is smooth: draw cubic Bezier + // Control point near current vertex + let cp1x: number, cp1y: number; + if (currSmooth) { + cp1x = cx + (nx - cx) / 3; + cp1y = cy + (ny - cy) / 3; + } else { + cp1x = cx; + cp1y = cy; + } + + // Control point near next vertex + let cp2x: number, cp2y: number; + if (nextSmooth) { + cp2x = nx + (cx - nx) / 3; + cp2y = ny + (cy - ny) / 3; + } else { + cp2x = nx; + cp2y = ny; + } + + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, nx, ny); + } + } + } + + ctx.closePath(); +} + export function horizontalOffset( width: number, textAlign: TextAlign, diff --git a/packages/affine/gfx/shape/src/element-view.ts b/packages/affine/gfx/shape/src/element-view.ts index ade802b9617f..b9158c5ac2c0 100644 --- a/packages/affine/gfx/shape/src/element-view.ts +++ b/packages/affine/gfx/shape/src/element-view.ts @@ -1,19 +1,62 @@ -import { ShapeElementModel } from '@blocksuite/affine-model'; +import { + type SurfaceBlockComponent, +} from '@blocksuite/affine-block-surface'; +import { ShapeElementModel, ShapeType } from '@blocksuite/affine-model'; import { GfxElementModelView, GfxViewInteractionExtension, } from '@blocksuite/std/gfx'; +import type { + DragEndContext, + DragMoveContext, + DragStartContext, +} from '@blocksuite/std/gfx'; import { normalizeShapeBound } from './element-renderer'; +import { PolygonVertexEditingOverlay } from './overlay/polygon-vertex-editing-overlay'; import { mountShapeTextEditor } from './text/edgeless-shape-text-editor'; export class ShapeElementView extends GfxElementModelView { static override type: string = 'shape'; + /** Overlay for polygon vertex editing (only active for polygon shapes). */ + private _vertexEditingOverlay: PolygonVertexEditingOverlay | null = null; + + private get _surfaceComponent() { + return this.gfx.surfaceComponent as SurfaceBlockComponent | null; + } + override onCreated(): void { super.onCreated(); this._initDblClickToEdit(); + this._initPolygonVertexEditing(); + } + + override onDestroyed(): void { + this._escapeKeyDisposer?.(); + this._escapeKeyDisposer = null; + this._removeVertexEditingOverlay(); + super.onDestroyed(); + } + + /** + * Override framework drag handlers to suppress default element movement + * when the user is dragging a polygon vertex in editing mode. + */ + override onDragStart(ctx: DragStartContext): void { + if (this._isDraggingVertex) return; // suppress default stash/move + super.onDragStart(ctx); + } + + override onDragMove(ctx: DragMoveContext): void { + if (this._isDraggingVertex) return; // suppress default element move + super.onDragMove(ctx); + } + + override onDragEnd(ctx: DragEndContext): void { + if (this._isDraggingVertex) return; // suppress default pop + super.onDragEnd(ctx); } private _initDblClickToEdit(): void { @@ -25,10 +68,300 @@ export class ShapeElementView extends GfxElementModelView { !this.model.isLocked() && this.model instanceof ShapeElementModel ) { + // For polygon shapes, double-click enters vertex editing mode + if ( + this.model.shapeType === ShapeType.Polygon && + this._vertexEditingOverlay + ) { + this._enterVertexEditingMode(); + return; + } + mountShapeTextEditor(this.model, edgeless); } }); } + + /** Whether a vertex is currently being dragged (suppresses default drag). */ + private _isDraggingVertex = false; + + /** Disposer for the Escape key listener used in vertex editing mode. */ + private _escapeKeyDisposer: (() => void) | null = null; + + /** + * Enter polygon vertex editing mode: set overlay to editing, update + * selection state to editing, and listen for Escape/Delete keys. + */ + private _enterVertexEditingMode(): void { + if (!this._vertexEditingOverlay) return; + + this._vertexEditingOverlay.isEditing = true; + + // Set selection editing state so framework knows we are editing + this.gfx.selection.set({ + elements: [this.model.id], + editing: true, + }); + + this._surfaceComponent?.refresh(); + + // Listen for keyboard events during editing mode + const keydownHandler = (evt: KeyboardEvent) => { + if (evt.key === 'Escape') { + evt.preventDefault(); + evt.stopPropagation(); + this._exitVertexEditingMode(); + return; + } + + // Delete or Backspace deletes the hovered vertex + if ( + (evt.key === 'Delete' || evt.key === 'Backspace') && + this._vertexEditingOverlay + ) { + const idx = this._vertexEditingOverlay.hoveredVertexIndex; + if (idx >= 0) { + evt.preventDefault(); + evt.stopPropagation(); + this.gfx.doc.captureSync(); + this.model.stash('xywh'); + this.model.stash('vertices'); + if (this.model.smoothFlags) { + this.model.stash('smoothFlags'); + } + const deleted = this._vertexEditingOverlay.deleteVertex(idx); + this.model.pop('xywh'); + this.model.pop('vertices'); + if (this.model.smoothFlags) { + this.model.pop('smoothFlags'); + } + if (deleted) { + this._vertexEditingOverlay.hoveredVertexIndex = -1; + this._surfaceComponent?.refresh(); + } + } + return; + } + + // 'B' key toggles Bezier smoothing on the hovered vertex + if ( + (evt.key === 'b' || evt.key === 'B') && + this._vertexEditingOverlay + ) { + const idx = this._vertexEditingOverlay.hoveredVertexIndex; + if (idx >= 0) { + evt.preventDefault(); + evt.stopPropagation(); + this.gfx.doc.captureSync(); + if (this.model.smoothFlags) { + this.model.stash('smoothFlags'); + } + this._vertexEditingOverlay.toggleVertexSmooth(idx); + if (this.model.smoothFlags) { + this.model.pop('smoothFlags'); + } + this._surfaceComponent?.refresh(); + } + return; + } + }; + document.addEventListener('keydown', keydownHandler, { capture: true }); + this._escapeKeyDisposer = () => { + document.removeEventListener('keydown', keydownHandler, { + capture: true, + }); + }; + } + + /** + * Exit polygon vertex editing mode: clear overlay editing state, + * reset selection to non-editing, and clean up key listener. + */ + private _exitVertexEditingMode(): void { + if (!this._vertexEditingOverlay) return; + + this._vertexEditingOverlay.isEditing = false; + this._vertexEditingOverlay.hoveredVertexIndex = -1; + this._vertexEditingOverlay.activeVertexIndex = -1; + this._vertexEditingOverlay.clearSnapGuides(); + + // Keep element selected but exit editing + this.gfx.selection.set({ + elements: [this.model.id], + editing: false, + }); + + this._surfaceComponent?.refresh(); + + this._escapeKeyDisposer?.(); + this._escapeKeyDisposer = null; + } + + /** + * Set up selection-based lifecycle for the polygon vertex editing overlay. + * The overlay is shown when a polygon is selected and removed when + * deselected or when a non-polygon shape is selected. + */ + private _initPolygonVertexEditing(): void { + if (this.model.shapeType !== ShapeType.Polygon) return; + + // Subscribe to selection changes to manage overlay lifecycle + this.disposable.add( + this.gfx.selection.slots.updated.subscribe(() => { + const selectedIds = this.gfx.selection.selectedIds; + const isSelected = + selectedIds.length === 1 && selectedIds[0] === this.model.id; + + if (isSelected) { + this._ensureVertexEditingOverlay(); + } else { + // Clean up escape key listener when deselected + this._escapeKeyDisposer?.(); + this._escapeKeyDisposer = null; + this._removeVertexEditingOverlay(); + } + }) + ); + + // Listen for pointer move to update hover state on the overlay + this.on('pointermove', (e) => { + if (!this._vertexEditingOverlay) return; + + const [mx, my] = this.gfx.viewport.toModelCoord(e.x, e.y); + this._vertexEditingOverlay.cursorModelPos = [mx, my]; + + const hitIdx = this._vertexEditingOverlay.hitTestVertex(mx, my); + let needRefresh = false; + + if (hitIdx !== this._vertexEditingOverlay.hoveredVertexIndex) { + this._vertexEditingOverlay.hoveredVertexIndex = hitIdx; + needRefresh = true; + } + + // Also check midpoint hover (only when editing and not hovering a vertex) + if (this._vertexEditingOverlay.isEditing && hitIdx < 0) { + const midIdx = this._vertexEditingOverlay.hitTestMidpoint(mx, my); + if (midIdx !== this._vertexEditingOverlay.hoveredMidpointIndex) { + this._vertexEditingOverlay.hoveredMidpointIndex = midIdx; + needRefresh = true; + } + } else if (this._vertexEditingOverlay.hoveredMidpointIndex >= 0) { + this._vertexEditingOverlay.hoveredMidpointIndex = -1; + needRefresh = true; + } + + if (needRefresh) { + this._surfaceComponent?.refresh(); + } + }); + + // Click on edge midpoint to insert a new vertex (in editing mode) + this.on('click', (e) => { + if (!this._vertexEditingOverlay) return; + if (!this._vertexEditingOverlay.isEditing) return; + + const [mx, my] = this.gfx.viewport.toModelCoord(e.x, e.y); + + // Only handle midpoint clicks when not clicking on a vertex + const hitVertex = this._vertexEditingOverlay.hitTestVertex(mx, my); + if (hitVertex >= 0) return; + + const midIdx = this._vertexEditingOverlay.hitTestMidpoint(mx, my); + if (midIdx >= 0) { + this.gfx.doc.captureSync(); + this.model.stash('vertices'); + if (this.model.smoothFlags) { + this.model.stash('smoothFlags'); + } + const newIdx = this._vertexEditingOverlay.insertVertexAtMidpoint(midIdx); + this.model.pop('vertices'); + if (this.model.smoothFlags) { + this.model.pop('smoothFlags'); + } + if (newIdx >= 0) { + this._vertexEditingOverlay.hoveredVertexIndex = newIdx; + this._vertexEditingOverlay.hoveredMidpointIndex = -1; + this._surfaceComponent?.refresh(); + } + } + }); + + this.on('pointerleave', () => { + if (!this._vertexEditingOverlay) return; + if (this._vertexEditingOverlay.activeVertexIndex >= 0) return; + + this._vertexEditingOverlay.hoveredVertexIndex = -1; + this._vertexEditingOverlay.cursorModelPos = null; + this._surfaceComponent?.refresh(); + }); + + // Vertex dragging: start drag on pointer down over a vertex + this.on('dragstart', (e) => { + if (!this._vertexEditingOverlay) return; + if (!this._vertexEditingOverlay.isEditing) return; + + const [mx, my] = this.gfx.viewport.toModelCoord(e.x, e.y); + const hitIdx = this._vertexEditingOverlay.hitTestVertex(mx, my); + + if (hitIdx >= 0) { + this._isDraggingVertex = true; + this._vertexEditingOverlay.activeVertexIndex = hitIdx; + this.model.stash('xywh'); + this.model.stash('vertices'); + this._surfaceComponent?.refresh(); + } + }); + + this.on('dragmove', (e) => { + if (!this._vertexEditingOverlay) return; + if (!this._isDraggingVertex) return; + + const [mx, my] = this.gfx.viewport.toModelCoord(e.x, e.y); + this._vertexEditingOverlay.moveVertex( + this._vertexEditingOverlay.activeVertexIndex, + mx, + my + ); + this._surfaceComponent?.refresh(); + }); + + this.on('dragend', () => { + if (!this._vertexEditingOverlay) return; + if (!this._isDraggingVertex) return; + + this._isDraggingVertex = false; + this._vertexEditingOverlay.activeVertexIndex = -1; + this._vertexEditingOverlay.clearSnapGuides(); + this.model.pop('xywh'); + this.model.pop('vertices'); + this._surfaceComponent?.refresh(); + }); + } + + private _ensureVertexEditingOverlay(): void { + if (this._vertexEditingOverlay) return; + + const overlay = new PolygonVertexEditingOverlay(this.gfx); + overlay.setElement(this.model.id); + + this._vertexEditingOverlay = overlay; + this._surfaceComponent?.renderer.addOverlay(overlay); + } + + private _removeVertexEditingOverlay(): void { + if (!this._vertexEditingOverlay) return; + + this._vertexEditingOverlay.isEditing = false; + this._vertexEditingOverlay.dispose(); + this._surfaceComponent?.renderer.removeOverlay(this._vertexEditingOverlay); + this._vertexEditingOverlay = null; + this._surfaceComponent?.renderer.refresh(); + } + + /** Get the vertex editing overlay (used by interaction handlers). */ + get vertexEditingOverlay(): PolygonVertexEditingOverlay | null { + return this._vertexEditingOverlay; + } } export const ShapeViewInteraction = diff --git a/packages/affine/gfx/shape/src/index.ts b/packages/affine/gfx/shape/src/index.ts index b1ed1db8806d..b8e2796da20a 100644 --- a/packages/affine/gfx/shape/src/index.ts +++ b/packages/affine/gfx/shape/src/index.ts @@ -4,6 +4,7 @@ export * from './draggable'; export * from './element-renderer'; export * from './element-view'; export * from './overlay'; +export * from './polygon-tool'; export * from './shape-tool'; export * from './text'; export * from './toolbar'; diff --git a/packages/affine/gfx/shape/src/overlay/factory.ts b/packages/affine/gfx/shape/src/overlay/factory.ts index 73ccccfdf980..a13b8a293a29 100644 --- a/packages/affine/gfx/shape/src/overlay/factory.ts +++ b/packages/affine/gfx/shape/src/overlay/factory.ts @@ -4,6 +4,7 @@ import type { XYWH } from '@blocksuite/global/gfx'; import { DiamondShape } from './diamond'; import { EllipseShape } from './ellipse'; +import { PolygonShape } from './polygon'; import { RectShape } from './rect'; import { RoundedRectShape } from './rounded-rect'; import type { Shape } from './shape'; @@ -27,6 +28,8 @@ export class ShapeFactory { return new EllipseShape(xywh, type, options, shapeStyle); case 'roundedRect': return new RoundedRectShape(xywh, type, options, shapeStyle); + case 'polygon': + return new PolygonShape(xywh, type, options, shapeStyle); default: throw new Error(`Unknown shape type: ${type}`); } diff --git a/packages/affine/gfx/shape/src/overlay/index.ts b/packages/affine/gfx/shape/src/overlay/index.ts index 1756a62c2ddd..8a1c91b4f165 100644 --- a/packages/affine/gfx/shape/src/overlay/index.ts +++ b/packages/affine/gfx/shape/src/overlay/index.ts @@ -1,3 +1,5 @@ export * from './factory'; +export * from './polygon-drawing-overlay'; +export * from './polygon-vertex-editing-overlay'; export * from './shape'; export * from './shape-overlay'; diff --git a/packages/affine/gfx/shape/src/overlay/polygon-drawing-overlay.ts b/packages/affine/gfx/shape/src/overlay/polygon-drawing-overlay.ts new file mode 100644 index 000000000000..02d5010f3975 --- /dev/null +++ b/packages/affine/gfx/shape/src/overlay/polygon-drawing-overlay.ts @@ -0,0 +1,195 @@ +import { + type RoughCanvas, + ToolOverlay, +} from '@blocksuite/affine-block-surface'; +import type { StrokeStyle } from '@blocksuite/affine-model'; +import type { GfxController } from '@blocksuite/std/gfx'; + +/** + * Radius of the vertex indicator circles drawn at each placed vertex. + */ +const VERTEX_INDICATOR_RADIUS = 4; + +/** + * Radius of the snap indicator shown when cursor is near the first vertex. + */ +const CLOSE_SNAP_INDICATOR_RADIUS = 8; + +/** + * Distance threshold to show the snap-close indicator (model coords). + */ +const CLOSE_SNAP_DISTANCE = 10; + +/** + * Overlay that renders the in-progress polygon while the user is placing + * vertices with the PolygonTool. It draws: + * + * - Filled polygon preview (with partial transparency) + * - Edges between placed vertices using the current stroke style + * - A "rubber band" dashed line from the last vertex to the cursor + * - Small circles at each vertex position + * - A snap indicator when the cursor is near the first vertex + */ +export class PolygonDrawingOverlay extends ToolOverlay { + /** Currently placed vertices in model coordinates. */ + vertices: [number, number][] = []; + + /** Current cursor position in model coordinates. */ + cursorPos: [number, number] | null = null; + + /** Whether a drawing session is in progress. */ + isDrawing = false; + + private _strokeColor: string; + private _fillColor: string; + private _strokeStyle: StrokeStyle; + private _strokeWidth: number; + + constructor( + gfx: GfxController, + options: { + strokeColor: string; + fillColor: string; + strokeStyle?: StrokeStyle; + strokeWidth?: number; + } + ) { + super(gfx); + this._strokeColor = options.strokeColor; + this._fillColor = options.fillColor; + this._strokeStyle = options.strokeStyle ?? ('solid' as StrokeStyle); + this._strokeWidth = options.strokeWidth ?? 4; + } + + /** + * Apply the line dash pattern matching the current stroke style. + */ + private _applyStrokeDash(ctx: CanvasRenderingContext2D): void { + switch (this._strokeStyle) { + case 'dash': + ctx.setLineDash([12, 12]); + break; + case 'none': + ctx.setLineDash([]); + break; + default: + // solid + ctx.setLineDash([]); + break; + } + } + + /** + * Returns the effective stroke color, taking `none` stroke style into + * account (renders as transparent). + */ + private _effectiveStrokeColor(): string { + return this._strokeStyle === 'none' ? 'transparent' : this._strokeColor; + } + + override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas): void { + const { vertices, cursorPos, isDrawing } = this; + ctx.globalAlpha = this.globalAlpha; + + if (!isDrawing || vertices.length === 0) return; + + const effectiveStroke = this._effectiveStrokeColor(); + const strokeWidth = Math.max(this._strokeWidth, 1); + + // Build the preview path (placed vertices + cursor position) + const allPoints: [number, number][] = [...vertices]; + if (cursorPos) { + allPoints.push(cursorPos); + } + + // Draw the filled polygon preview (semi-transparent) + if (allPoints.length >= 3) { + ctx.beginPath(); + ctx.moveTo(allPoints[0][0], allPoints[0][1]); + for (let i = 1; i < allPoints.length; i++) { + ctx.lineTo(allPoints[i][0], allPoints[i][1]); + } + ctx.closePath(); + ctx.fillStyle = this._fillColor; + ctx.globalAlpha = this.globalAlpha * 0.15; + ctx.fill(); + ctx.globalAlpha = this.globalAlpha; + } + + // Draw edges between placed vertices using the current stroke style + if (vertices.length >= 2) { + ctx.beginPath(); + ctx.moveTo(vertices[0][0], vertices[0][1]); + for (let i = 1; i < vertices.length; i++) { + ctx.lineTo(vertices[i][0], vertices[i][1]); + } + ctx.strokeStyle = effectiveStroke; + ctx.lineWidth = strokeWidth; + this._applyStrokeDash(ctx); + ctx.stroke(); + ctx.setLineDash([]); + } + + // Draw rubber band line from last vertex to cursor (always dashed for UX clarity) + if (cursorPos && vertices.length >= 1) { + const last = vertices[vertices.length - 1]; + ctx.beginPath(); + ctx.moveTo(last[0], last[1]); + ctx.lineTo(cursorPos[0], cursorPos[1]); + ctx.strokeStyle = this._strokeColor; + ctx.lineWidth = 1.5; + ctx.setLineDash([6, 4]); + ctx.stroke(); + ctx.setLineDash([]); + + // Also draw a dashed line from cursor back to first vertex (closing preview) + if (vertices.length >= 2) { + ctx.beginPath(); + ctx.moveTo(cursorPos[0], cursorPos[1]); + ctx.lineTo(vertices[0][0], vertices[0][1]); + ctx.strokeStyle = this._strokeColor; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.globalAlpha = this.globalAlpha * 0.4; + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = this.globalAlpha; + } + } + + // Draw vertex indicator circles + for (let i = 0; i < vertices.length; i++) { + const [vx, vy] = vertices[i]; + ctx.beginPath(); + ctx.arc(vx, vy, VERTEX_INDICATOR_RADIUS, 0, Math.PI * 2); + ctx.fillStyle = i === 0 ? this._strokeColor : '#ffffff'; + ctx.fill(); + ctx.strokeStyle = this._strokeColor; + ctx.lineWidth = 1.5; + ctx.setLineDash([]); + ctx.stroke(); + } + + // Draw close-snap indicator when cursor is near the first vertex + if (cursorPos && vertices.length >= 3) { + const [fx, fy] = vertices[0]; + const dist = Math.sqrt( + (cursorPos[0] - fx) ** 2 + (cursorPos[1] - fy) ** 2 + ); + // Scale thresholds by zoom so the indicator is consistent at all zoom levels + const zoom = this.gfx.viewport.zoom; + const snapDist = CLOSE_SNAP_DISTANCE / zoom; + if (dist < snapDist) { + const indicatorRadius = CLOSE_SNAP_INDICATOR_RADIUS / zoom; + ctx.beginPath(); + ctx.arc(fx, fy, indicatorRadius, 0, Math.PI * 2); + ctx.strokeStyle = this._strokeColor; + ctx.lineWidth = 2 / zoom; + ctx.setLineDash([]); + ctx.globalAlpha = this.globalAlpha * 0.6; + ctx.stroke(); + ctx.globalAlpha = this.globalAlpha; + } + } + } +} diff --git a/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts b/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts new file mode 100644 index 000000000000..643e01dde094 --- /dev/null +++ b/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts @@ -0,0 +1,590 @@ +import { + type RoughCanvas, + ToolOverlay, +} from '@blocksuite/affine-block-surface'; +import { ShapeElementModel, ShapeType } from '@blocksuite/affine-model'; +import { Bound } from '@blocksuite/global/gfx'; +import type { GfxController } from '@blocksuite/std/gfx'; + +// ─── Visual constants ─────────────────────────────────────────────────────── + +/** Radius of a normal vertex handle (screen pixels, divided by zoom). */ +const VERTEX_HANDLE_RADIUS = 5; + +/** Radius of a hovered vertex handle (screen pixels, divided by zoom). */ +const VERTEX_HANDLE_HOVER_RADIUS = 7; + +/** Radius of a dragged vertex handle (screen pixels, divided by zoom). */ +const VERTEX_HANDLE_ACTIVE_RADIUS = 8; + +/** Hit-test distance to consider the cursor "over" a vertex (screen px). */ +const VERTEX_HIT_DISTANCE = 10; + +/** Snap distance in model coordinates for coordinate alignment guides. */ +const SNAP_GUIDE_DISTANCE = 6; + +/** Color for default vertex handles. */ +const HANDLE_FILL_COLOR = '#ffffff'; + +/** Color for hovered vertex handles. */ +const HANDLE_HOVER_FILL_COLOR = '#d0ebff'; + +/** Color for actively dragged vertex handles. */ +const HANDLE_ACTIVE_FILL_COLOR = '#339af0'; + +/** Snap guide line color. */ +const SNAP_GUIDE_COLOR = '#f06595'; + +/** Edge midpoint handle radius. */ +const MIDPOINT_HANDLE_RADIUS = 3.5; + +/** Hovered edge midpoint handle radius. */ +const MIDPOINT_HANDLE_HOVER_RADIUS = 5; + +/** Color for edge midpoint handles. */ +const MIDPOINT_FILL_COLOR = '#e9ecef'; + +/** Color for hovered edge midpoint handles. */ +const MIDPOINT_HOVER_FILL_COLOR = '#d0ebff'; + +/** Hit-test distance for edge midpoint handles (screen px). */ +const MIDPOINT_HIT_DISTANCE = 10; + +/** Bezier control handle radius. */ +const BEZIER_HANDLE_RADIUS = 4; + +/** Bezier control handle color. */ +const BEZIER_HANDLE_COLOR = '#e64980'; + +/** Bezier control line color. */ +const BEZIER_LINE_COLOR = '#e64980'; + +// ─── Overlay class ────────────────────────────────────────────────────────── + +/** + * Overlay rendered on top of a selected polygon shape that provides: + * + * - Vertex handle circles at each polygon vertex (with hover/active states) + * - Edge midpoint indicators + * - Coordinate snapping guides while dragging a vertex + * - Real-time polygon path update feedback during vertex drag + * + * This overlay works alongside the standard bounding-box resize handles + * provided by the framework. Vertex editing is activated when the user + * double-clicks a selected polygon (entering editing mode) or when a + * polygon is first selected (non-editing mode shows smaller handles). + */ +export class PolygonVertexEditingOverlay extends ToolOverlay { + // ── Linked polygon element ────────────────────────────────────────── + + /** ID of the polygon element this overlay is attached to. */ + private _elementId: string | null = null; + + // ── Interaction state ─────────────────────────────────────────────── + + /** Index of the currently hovered vertex (-1 = none). */ + hoveredVertexIndex = -1; + + /** Index of the currently hovered edge midpoint (-1 = none). */ + hoveredMidpointIndex = -1; + + /** Index of the vertex currently being dragged (-1 = none). */ + activeVertexIndex = -1; + + /** Whether vertex editing mode is enabled (double-click to enter). */ + isEditing = false; + + /** Current cursor position in model coordinates (for hover testing). */ + cursorModelPos: [number, number] | null = null; + + /** Index of the vertex whose Bezier control handle is being dragged (-1 = none). */ + activeBezierHandleIndex = -1; + + // ── Snap guide state ──────────────────────────────────────────────── + + /** Horizontal snap guide Y coordinate (null = hidden). */ + snapGuideY: number | null = null; + + /** Vertical snap guide X coordinate (null = hidden). */ + snapGuideX: number | null = null; + + constructor(gfx: GfxController) { + super(gfx); + } + + /** Attach this overlay to a specific polygon element. */ + setElement(elementId: string) { + this._elementId = elementId; + } + + /** Get the linked ShapeElementModel if it's a polygon. */ + private _getPolygonModel(): ShapeElementModel | null { + if (!this._elementId) return null; + const el = this.gfx.getElementById(this._elementId); + if ( + el instanceof ShapeElementModel && + el.shapeType === ShapeType.Polygon + ) { + return el; + } + return null; + } + + /** + * Convert a normalized [0-1] vertex to absolute model coordinates + * based on the polygon's bounding box. + */ + private _toAbsolute( + nv: number[], + bound: Bound + ): [number, number] { + return [bound.x + nv[0] * bound.w, bound.y + nv[1] * bound.h]; + } + + /** + * Test if a model-coordinate point is within hit distance of a vertex. + */ + hitTestVertex( + modelX: number, + modelY: number + ): number { + const model = this._getPolygonModel(); + if (!model || !model.vertices) return -1; + + const bound = Bound.deserialize(model.xywh); + const zoom = this.gfx.viewport.zoom; + const hitDist = VERTEX_HIT_DISTANCE / zoom; + + for (let i = 0; i < model.vertices.length; i++) { + const [ax, ay] = this._toAbsolute(model.vertices[i], bound); + const dx = modelX - ax; + const dy = modelY - ay; + if (Math.sqrt(dx * dx + dy * dy) < hitDist) { + return i; + } + } + return -1; + } + + /** + * Move a vertex to a new absolute position, computing snap guides + * against other vertices. Updates the model's normalized vertices + * and bounding box. + */ + moveVertex( + vertexIndex: number, + modelX: number, + modelY: number + ): void { + const model = this._getPolygonModel(); + if (!model || !model.vertices) return; + + const bound = Bound.deserialize(model.xywh); + const zoom = this.gfx.viewport.zoom; + const snapDist = SNAP_GUIDE_DISTANCE / zoom; + + // Collect absolute positions of all other vertices for snapping + const otherAbsolute: [number, number][] = []; + for (let i = 0; i < model.vertices.length; i++) { + if (i === vertexIndex) continue; + otherAbsolute.push(this._toAbsolute(model.vertices[i], bound)); + } + + // Attempt coordinate snapping + let snappedX = modelX; + let snappedY = modelY; + this.snapGuideX = null; + this.snapGuideY = null; + + for (const [ox, oy] of otherAbsolute) { + if (Math.abs(modelX - ox) < snapDist) { + snappedX = ox; + this.snapGuideX = ox; + } + if (Math.abs(modelY - oy) < snapDist) { + snappedY = oy; + this.snapGuideY = oy; + } + } + + // Compute new absolute vertices array + const newAbsVertices: [number, number][] = model.vertices.map( + (v, i) => + i === vertexIndex + ? ([snappedX, snappedY] as [number, number]) + : (this._toAbsolute(v, bound) as [number, number]) + ); + + // Re-compute bounding box from new absolute positions + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const [vx, vy] of newAbsVertices) { + if (vx < minX) minX = vx; + if (vy < minY) minY = vy; + if (vx > maxX) maxX = vx; + if (vy > maxY) maxY = vy; + } + + const w = Math.max(maxX - minX, 1); + const h = Math.max(maxY - minY, 1); + + // Re-normalize vertices to new bounding box + const normalized = newAbsVertices.map(([vx, vy]) => [ + (vx - minX) / w, + (vy - minY) / h, + ]); + + const newBound = new Bound(minX, minY, w, h); + + // Update model + model.xywh = newBound.serialize(); + model.vertices = normalized; + } + + /** Clear snap guides (call on drag end). */ + clearSnapGuides() { + this.snapGuideX = null; + this.snapGuideY = null; + } + + /** + * Test if a model-coordinate point is within hit distance of an edge midpoint. + * Returns the edge index (i.e., the midpoint between vertex i and vertex i+1), + * or -1 if not near any midpoint. + */ + hitTestMidpoint( + modelX: number, + modelY: number + ): number { + const model = this._getPolygonModel(); + if (!model || !model.vertices) return -1; + + const bound = Bound.deserialize(model.xywh); + const zoom = this.gfx.viewport.zoom; + const hitDist = MIDPOINT_HIT_DISTANCE / zoom; + + for (let i = 0; i < model.vertices.length; i++) { + const [ax, ay] = this._toAbsolute(model.vertices[i], bound); + const nextIdx = (i + 1) % model.vertices.length; + const [bx, by] = this._toAbsolute(model.vertices[nextIdx], bound); + const mx = (ax + bx) / 2; + const my = (ay + by) / 2; + + const dx = modelX - mx; + const dy = modelY - my; + if (Math.sqrt(dx * dx + dy * dy) < hitDist) { + return i; + } + } + return -1; + } + + /** + * Insert a new vertex at the midpoint of edge `edgeIndex` (between vertex + * edgeIndex and edgeIndex+1). Returns the index of the newly inserted vertex. + */ + insertVertexAtMidpoint(edgeIndex: number): number { + const model = this._getPolygonModel(); + if (!model || !model.vertices) return -1; + + const vertices = [...model.vertices]; + const nextIdx = (edgeIndex + 1) % vertices.length; + + // Compute the midpoint in normalized space + const midNorm = [ + (vertices[edgeIndex][0] + vertices[nextIdx][0]) / 2, + (vertices[edgeIndex][1] + vertices[nextIdx][1]) / 2, + ]; + + // Insert after edgeIndex + const insertIdx = edgeIndex + 1; + vertices.splice(insertIdx, 0, midNorm); + + // Update smoothFlags if present + if (model.smoothFlags) { + const flags = [...model.smoothFlags]; + flags.splice(insertIdx, 0, false); + model.smoothFlags = flags; + } + + model.vertices = vertices; + return insertIdx; + } + + /** + * Delete the vertex at the given index. Returns true if successful. + * Requires at least 3 vertices to remain after deletion. + */ + deleteVertex(vertexIndex: number): boolean { + const model = this._getPolygonModel(); + if (!model || !model.vertices) return false; + if (model.vertices.length <= 3) return false; // minimum polygon is a triangle + + const vertices = [...model.vertices]; + vertices.splice(vertexIndex, 1); + + // Update smoothFlags if present + if (model.smoothFlags) { + const flags = [...model.smoothFlags]; + flags.splice(vertexIndex, 1); + model.smoothFlags = flags; + } + + // Re-compute bounding box from remaining vertices + const bound = Bound.deserialize(model.xywh); + const absVertices = vertices.map(v => this._toAbsolute(v, bound)); + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const [vx, vy] of absVertices) { + if (vx < minX) minX = vx; + if (vy < minY) minY = vy; + if (vx > maxX) maxX = vx; + if (vy > maxY) maxY = vy; + } + + const w = Math.max(maxX - minX, 1); + const h = Math.max(maxY - minY, 1); + + const normalized = absVertices.map(([vx, vy]) => [ + (vx - minX) / w, + (vy - minY) / h, + ]); + + const newBound = new Bound(minX, minY, w, h); + model.xywh = newBound.serialize(); + model.vertices = normalized; + return true; + } + + /** + * Toggle smooth (Bezier) flag for a vertex. When smooth is true, the + * edges adjacent to this vertex are rendered as Bezier curves with + * automatically computed control points. + */ + toggleVertexSmooth(vertexIndex: number): void { + const model = this._getPolygonModel(); + if (!model || !model.vertices) return; + + const count = model.vertices.length; + let flags = model.smoothFlags ? [...model.smoothFlags] : new Array(count).fill(false); + + // Ensure flags array matches vertex count + while (flags.length < count) flags.push(false); + if (flags.length > count) flags = flags.slice(0, count); + + flags[vertexIndex] = !flags[vertexIndex]; + model.smoothFlags = flags; + } + + /** + * Check if a vertex has Bezier smoothing enabled. + */ + isVertexSmooth(vertexIndex: number): boolean { + const model = this._getPolygonModel(); + if (!model || !model.smoothFlags) return false; + return !!model.smoothFlags[vertexIndex]; + } + + /** + * Get the computed Bezier control points for a smooth vertex. + * Returns [cp1, cp2] where cp1 is the control point for the incoming edge + * and cp2 is for the outgoing edge, both in absolute model coordinates. + * Returns null if the vertex is not smooth. + */ + getBezierControlPoints( + vertexIndex: number + ): { cp1: [number, number]; cp2: [number, number] } | null { + const model = this._getPolygonModel(); + if (!model || !model.vertices) return null; + if (!model.smoothFlags || !model.smoothFlags[vertexIndex]) return null; + + const bound = Bound.deserialize(model.xywh); + const vertices = model.vertices; + const count = vertices.length; + + const prev = this._toAbsolute(vertices[(vertexIndex - 1 + count) % count], bound); + const curr = this._toAbsolute(vertices[vertexIndex], bound); + const next = this._toAbsolute(vertices[(vertexIndex + 1) % count], bound); + + // Compute control points as 1/3 of the distance along the tangent + // that bisects the angle formed by prev-curr-next + const dx1 = prev[0] - curr[0]; + const dy1 = prev[1] - curr[1]; + const dx2 = next[0] - curr[0]; + const dy2 = next[1] - curr[1]; + + const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1) || 1; + const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2) || 1; + + // Control points at 1/3 of the edge length toward the neighbours + const cpDist1 = len1 / 3; + const cpDist2 = len2 / 3; + + const cp1: [number, number] = [ + curr[0] + (dx1 / len1) * cpDist1, + curr[1] + (dy1 / len1) * cpDist1, + ]; + const cp2: [number, number] = [ + curr[0] + (dx2 / len2) * cpDist2, + curr[1] + (dy2 / len2) * cpDist2, + ]; + + return { cp1, cp2 }; + } + + // ── Rendering ─────────────────────────────────────────────────────── + + override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas): void { + const model = this._getPolygonModel(); + if (!model || !model.vertices) return; + if (!this.isEditing && this.hoveredVertexIndex < 0) return; + + const bound = Bound.deserialize(model.xywh); + const zoom = this.gfx.viewport.zoom; + const vertices = model.vertices; + + ctx.save(); + ctx.globalAlpha = this.globalAlpha; + + // ── Draw snap guides ──────────────────────────────────────────── + if (this.activeVertexIndex >= 0) { + ctx.setLineDash([4 / zoom, 3 / zoom]); + ctx.lineWidth = 1 / zoom; + ctx.strokeStyle = SNAP_GUIDE_COLOR; + + if (this.snapGuideX !== null) { + ctx.beginPath(); + ctx.moveTo(this.snapGuideX, bound.y - 20 / zoom); + ctx.lineTo(this.snapGuideX, bound.y + bound.h + 20 / zoom); + ctx.stroke(); + } + if (this.snapGuideY !== null) { + ctx.beginPath(); + ctx.moveTo(bound.x - 20 / zoom, this.snapGuideY); + ctx.lineTo(bound.x + bound.w + 20 / zoom, this.snapGuideY); + ctx.stroke(); + } + ctx.setLineDash([]); + } + + // ── Draw Bezier control handles ──────────────────────────────── + if (this.isEditing && model.smoothFlags) { + for (let i = 0; i < vertices.length; i++) { + if (!model.smoothFlags[i]) continue; + const cp = this.getBezierControlPoints(i); + if (!cp) continue; + + const [ax, ay] = this._toAbsolute(vertices[i], bound); + const cpR = BEZIER_HANDLE_RADIUS / zoom; + + // Draw control lines + ctx.beginPath(); + ctx.moveTo(cp.cp1[0], cp.cp1[1]); + ctx.lineTo(ax, ay); + ctx.lineTo(cp.cp2[0], cp.cp2[1]); + ctx.strokeStyle = BEZIER_LINE_COLOR; + ctx.lineWidth = 1 / zoom; + ctx.setLineDash([3 / zoom, 2 / zoom]); + ctx.stroke(); + ctx.setLineDash([]); + + // Draw control point handles + for (const cp_pt of [cp.cp1, cp.cp2]) { + ctx.beginPath(); + ctx.arc(cp_pt[0], cp_pt[1], cpR, 0, Math.PI * 2); + ctx.fillStyle = BEZIER_HANDLE_COLOR; + ctx.fill(); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1 / zoom; + ctx.stroke(); + } + } + } + + // ── Draw edge midpoint handles ────────────────────────────────── + if (this.isEditing) { + for (let i = 0; i < vertices.length; i++) { + const [ax, ay] = this._toAbsolute(vertices[i], bound); + const nextIdx = (i + 1) % vertices.length; + const [bx, by] = this._toAbsolute(vertices[nextIdx], bound); + const mx = (ax + bx) / 2; + const my = (ay + by) / 2; + + const isHoveredMid = i === this.hoveredMidpointIndex; + const midR = (isHoveredMid ? MIDPOINT_HANDLE_HOVER_RADIUS : MIDPOINT_HANDLE_RADIUS) / zoom; + const midFill = isHoveredMid ? MIDPOINT_HOVER_FILL_COLOR : MIDPOINT_FILL_COLOR; + + ctx.beginPath(); + ctx.arc(mx, my, midR, 0, Math.PI * 2); + ctx.fillStyle = midFill; + ctx.fill(); + ctx.strokeStyle = isHoveredMid ? '#228be6' : '#868e96'; + ctx.lineWidth = (isHoveredMid ? 1.5 : 1) / zoom; + ctx.stroke(); + } + } + + // ── Draw vertex handles ───────────────────────────────────────── + for (let i = 0; i < vertices.length; i++) { + const [ax, ay] = this._toAbsolute(vertices[i], bound); + const isHovered = i === this.hoveredVertexIndex; + const isActive = i === this.activeVertexIndex; + + let radius: number; + let fillColor: string; + let strokeWidth: number; + + if (isActive) { + radius = VERTEX_HANDLE_ACTIVE_RADIUS / zoom; + fillColor = HANDLE_ACTIVE_FILL_COLOR; + strokeWidth = 2 / zoom; + } else if (isHovered) { + radius = VERTEX_HANDLE_HOVER_RADIUS / zoom; + fillColor = HANDLE_HOVER_FILL_COLOR; + strokeWidth = 2 / zoom; + } else { + radius = VERTEX_HANDLE_RADIUS / zoom; + fillColor = HANDLE_FILL_COLOR; + strokeWidth = 1.5 / zoom; + } + + // Outer stroke + ctx.beginPath(); + ctx.arc(ax, ay, radius, 0, Math.PI * 2); + ctx.fillStyle = fillColor; + ctx.fill(); + ctx.strokeStyle = '#228be6'; + ctx.lineWidth = strokeWidth; + ctx.stroke(); + + // Smooth vertex indicator (diamond shape on top of circle) + if (this.isEditing && model.smoothFlags && model.smoothFlags[i]) { + const dSize = 3.5 / zoom; + ctx.beginPath(); + ctx.moveTo(ax, ay - dSize); + ctx.lineTo(ax + dSize, ay); + ctx.lineTo(ax, ay + dSize); + ctx.lineTo(ax - dSize, ay); + ctx.closePath(); + ctx.fillStyle = BEZIER_HANDLE_COLOR; + ctx.fill(); + } + + // Index label for editing mode (only when editing and zoomed in enough) + if (this.isEditing && zoom >= 1.5) { + ctx.font = `${10 / zoom}px sans-serif`; + ctx.fillStyle = '#228be6'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(`${i}`, ax, ay - radius - 2 / zoom); + } + } + + ctx.restore(); + } +} diff --git a/packages/affine/gfx/shape/src/overlay/polygon.ts b/packages/affine/gfx/shape/src/overlay/polygon.ts new file mode 100644 index 000000000000..d890d08389f7 --- /dev/null +++ b/packages/affine/gfx/shape/src/overlay/polygon.ts @@ -0,0 +1,28 @@ +import type { RoughCanvas } from '@blocksuite/affine-block-surface'; + +import { Shape } from './shape'; +import { drawGeneralShape } from './utils'; + +export class PolygonShape extends Shape { + draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void { + const [x, y, w, h] = this.xywh; + + // Default pentagon vertices (normalized 0-1) + const vertices = [ + [0.5, 0], + [1, 0.38], + [0.81, 1], + [0.19, 1], + [0, 0.38], + ]; + + if (this.shapeStyle === 'Scribbled') { + const absPoints: [number, number][] = vertices.map( + v => [x + v[0] * w, y + v[1] * h] as [number, number] + ); + rc.polygon(absPoints, this.options); + } else { + drawGeneralShape(ctx, 'polygon', this.xywh, this.options); + } + } +} diff --git a/packages/affine/gfx/shape/src/overlay/utils.ts b/packages/affine/gfx/shape/src/overlay/utils.ts index 1e60faaf17a4..7f833d11e446 100644 --- a/packages/affine/gfx/shape/src/overlay/utils.ts +++ b/packages/affine/gfx/shape/src/overlay/utils.ts @@ -32,6 +32,9 @@ export const drawGeneralShape = ( case 'roundedRect': drawRoundedRect(ctx, xywh); break; + case 'polygon': + shapeMethods.polygon.draw(ctx, bound); + break; default: throw new Error(`Unknown shape type: ${type}`); } diff --git a/packages/affine/gfx/shape/src/polygon-tool.ts b/packages/affine/gfx/shape/src/polygon-tool.ts new file mode 100644 index 000000000000..ec9cfe3987de --- /dev/null +++ b/packages/affine/gfx/shape/src/polygon-tool.ts @@ -0,0 +1,402 @@ +import { + CanvasElementType, + DefaultTool, + EXCLUDING_MOUSE_OUT_CLASS_LIST, + type SurfaceBlockComponent, +} from '@blocksuite/affine-block-surface'; +import { + DefaultTheme, + ShapeType, + type StrokeStyle, +} from '@blocksuite/affine-model'; +import { + EditPropsStore, + TelemetryProvider, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { hasClassNameInList } from '@blocksuite/affine-shared/utils'; +import { Bound } from '@blocksuite/global/gfx'; +import type { PointerEventState } from '@blocksuite/std'; +import { BaseTool, type GfxController } from '@blocksuite/std/gfx'; + +import { PolygonDrawingOverlay } from './overlay/polygon-drawing-overlay.js'; + +/** + * Minimum distance (in model coords) between vertices to prevent + * accidental double-placement. + */ +const MIN_VERTEX_DISTANCE = 5; + +/** + * Distance threshold (in model coords) to snap to the first vertex + * and close the polygon. + */ +const CLOSE_SNAP_DISTANCE = 10; + +export type PolygonToolOption = Record; + +/** + * PolygonTool allows users to draw arbitrary polygons by clicking to place + * vertices on the edgeless canvas. The polygon is finalised by either: + * - Double-clicking (places the last vertex and finishes) + * - Clicking near the first vertex (closes the polygon) + * - Pressing Escape (cancels the current drawing) + * - Pressing Enter (finishes the polygon if >= 3 vertices) + * + * During drawing, a live overlay previews the in-progress polygon including + * a "rubber band" line from the last placed vertex to the current cursor. + */ +export class PolygonTool extends BaseTool { + static override toolName: string = 'polygon'; + + /** Placed vertices in model (canvas) coordinates. */ + private _vertices: [number, number][] = []; + + /** Current cursor position in model coordinates, for the rubber-band line. */ + private _cursorPos: [number, number] | null = null; + + /** Whether we are currently in a drawing session. */ + private _isDrawing = false; + + /** Overlay for rendering the in-progress polygon preview. */ + private _drawingOverlay: PolygonDrawingOverlay | null = null; + + private get _surfaceComponent() { + return this.gfx.surfaceComponent as SurfaceBlockComponent | null; + } + + constructor(gfx: GfxController) { + super(gfx); + } + + override activate() { + this._reset(); + this._createOverlay(); + } + + override deactivate() { + this._reset(); + this._removeOverlay(); + } + + override mounted() { + // Listen for Escape and Enter keys during drawing + const keydownHandler = (evt: KeyboardEvent) => { + if (!this.active || !this._isDrawing) return; + + if (evt.key === 'Escape') { + evt.preventDefault(); + this._cancelDrawing(); + } else if (evt.key === 'Enter') { + evt.preventDefault(); + this._finishDrawing(); + } + }; + + document.addEventListener('keydown', keydownHandler); + this.disposable.add(() => { + document.removeEventListener('keydown', keydownHandler); + }); + } + + /** + * Each click places a new vertex. If the click is close to the first + * vertex and we have >= 3 vertices, the polygon is closed and finalised. + */ + override click(e: PointerEventState): void { + const [mx, my] = this.gfx.viewport.toModelCoord(e.point.x, e.point.y); + + if (!this._isDrawing) { + // Start a new polygon drawing session + this._isDrawing = true; + this._vertices = [[mx, my]]; + this._cursorPos = [mx, my]; + this._updateOverlay(); + return; + } + + // Scale thresholds by zoom so they feel consistent at all zoom levels + const zoom = this.gfx.viewport.zoom; + const closeSnapDist = CLOSE_SNAP_DISTANCE / zoom; + const minVertexDist = MIN_VERTEX_DISTANCE / zoom; + + // Check if clicking near the first vertex to close the polygon + if (this._vertices.length >= 3) { + const [fx, fy] = this._vertices[0]; + const dist = Math.sqrt((mx - fx) ** 2 + (my - fy) ** 2); + if (dist < closeSnapDist) { + this._finishDrawing(); + return; + } + } + + // Prevent placing vertices too close together + if (this._vertices.length > 0) { + const last = this._vertices[this._vertices.length - 1]; + const dist = Math.sqrt((mx - last[0]) ** 2 + (my - last[1]) ** 2); + if (dist < minVertexDist) { + return; + } + } + + // Place a new vertex + this._vertices.push([mx, my]); + this._updateOverlay(); + } + + /** + * Double-click finishes the polygon, placing the final vertex + * at the double-click position (if valid). + */ + override doubleClick(e: PointerEventState): void { + if (!this._isDrawing) return; + + const [mx, my] = this.gfx.viewport.toModelCoord(e.point.x, e.point.y); + + // Add the final vertex if it's not too close to the last one + if (this._vertices.length > 0) { + const last = this._vertices[this._vertices.length - 1]; + const dist = Math.sqrt((mx - last[0]) ** 2 + (my - last[1]) ** 2); + const minVertexDist = MIN_VERTEX_DISTANCE / this.gfx.viewport.zoom; + if (dist >= minVertexDist) { + this._vertices.push([mx, my]); + } + } + + this._finishDrawing(); + } + + /** + * Track cursor position for the rubber-band preview line. + */ + override pointerMove(e: PointerEventState): void { + const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); + this._cursorPos = [x, y]; + + if (this._drawingOverlay) { + this._drawingOverlay.globalAlpha = 1; + } + + this._updateOverlay(); + } + + override pointerOut(e: PointerEventState): void { + if ( + e.raw.relatedTarget && + hasClassNameInList( + e.raw.relatedTarget as Element, + EXCLUDING_MOUSE_OUT_CLASS_LIST + ) + ) + return; + + if (this._drawingOverlay && !this._isDrawing) { + this._drawingOverlay.globalAlpha = 0; + this._surfaceComponent?.refresh(); + } + } + + /** + * Prevent default drag behavior while drawing polygon. + * We consume drag events to avoid interference with the canvas. + */ + override dragStart(_e: PointerEventState): void { + // Do nothing - polygon tool uses clicks, not drags + } + + override dragMove(_e: PointerEventState): void { + // Do nothing + } + + override dragEnd(_e: PointerEventState): void { + // Do nothing + } + + /** + * Finish the polygon: compute bounding box, normalize vertices, and create + * the shape element on the surface. + */ + private _finishDrawing() { + if (this._vertices.length < 3) { + this._cancelDrawing(); + return; + } + + const vertices = this._vertices; + + // Compute bounding box of all vertices + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const [vx, vy] of vertices) { + if (vx < minX) minX = vx; + if (vy < minY) minY = vy; + if (vx > maxX) maxX = vx; + if (vy > maxY) maxY = vy; + } + + const w = maxX - minX; + const h = maxY - minY; + + // Guard against degenerate polygons + if (w < 1 || h < 1) { + this._cancelDrawing(); + return; + } + + // Normalize vertices to [0, 1] relative to bounding box + const normalizedVertices = vertices.map(([vx, vy]) => [ + (vx - minX) / w, + (vy - minY) / h, + ]); + + const bound = new Bound(minX, minY, w, h); + + // Get last-used shape attributes for styling + const _shapeName = ShapeType.Polygon; + void ( + this.std.get(EditPropsStore).lastProps$.value[`shape:${_shapeName}`] ?? + this.std.get(EditPropsStore).lastProps$.value['shape:rect'] + ); + + this.doc.captureSync(); + + const id = this.gfx.surface!.addElement({ + type: CanvasElementType.SHAPE, + shapeType: ShapeType.Polygon, + xywh: bound.serialize(), + radius: 0, + vertices: normalizedVertices, + isClosed: true, + smoothFlags: null, + }); + + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:draw', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: CanvasElementType.SHAPE, + other: { + shapeName: 'polygon', + }, + }); + + // Select the newly created element and switch to default tool + const element = this.gfx.getElementById(id); + if (element) { + this.controller.setTool(DefaultTool); + this.gfx.selection.set({ + elements: [element.id], + editing: false, + }); + } + + this._reset(); + this._removeOverlay(); + } + + /** + * Cancel the current drawing session and reset state. + */ + private _cancelDrawing() { + this._reset(); + this._updateOverlay(); + } + + /** + * Reset the drawing state. + */ + private _reset() { + this._vertices = []; + this._cursorPos = null; + this._isDrawing = false; + } + + /** + * Create the overlay for previewing the in-progress polygon. + */ + private _createOverlay() { + this._removeOverlay(); + + const strokeColor = this._getStrokeColor(); + const fillColor = this._getFillColor(); + const { strokeStyle, strokeWidth } = this._getStrokeProps(); + + this._drawingOverlay = new PolygonDrawingOverlay(this.gfx, { + strokeColor, + fillColor, + strokeStyle, + strokeWidth, + }); + this._surfaceComponent?.renderer.addOverlay(this._drawingOverlay); + } + + /** + * Remove the drawing overlay. + */ + private _removeOverlay() { + if (!this._drawingOverlay) return; + this._drawingOverlay.dispose(); + this._surfaceComponent?.renderer.removeOverlay(this._drawingOverlay); + this._drawingOverlay = null; + this._surfaceComponent?.renderer.refresh(); + } + + /** + * Update the overlay with current vertices and cursor position. + */ + private _updateOverlay() { + if (!this._drawingOverlay) return; + this._drawingOverlay.vertices = this._vertices; + this._drawingOverlay.cursorPos = this._cursorPos; + this._drawingOverlay.isDrawing = this._isDrawing; + this._surfaceComponent?.refresh(); + } + + private _getStrokeColor(): string { + const shapeName = ShapeType.Polygon; + const props = + this.std.get(EditPropsStore).lastProps$.value[`shape:${shapeName}`] ?? + this.std.get(EditPropsStore).lastProps$.value['shape:rect']; + + return this.std + .get(ThemeProvider) + .getColorValue( + props?.strokeColor, + DefaultTheme.shapeStrokeColor, + true + ); + } + + private _getStrokeProps(): { + strokeStyle: StrokeStyle; + strokeWidth: number; + } { + const shapeName = ShapeType.Polygon; + const props = + this.std.get(EditPropsStore).lastProps$.value[`shape:${shapeName}`] ?? + this.std.get(EditPropsStore).lastProps$.value['shape:rect']; + + return { + strokeStyle: (props?.strokeStyle as StrokeStyle) ?? ('solid' as StrokeStyle), + strokeWidth: (props?.strokeWidth as number) ?? 4, + }; + } + + private _getFillColor(): string { + const shapeName = ShapeType.Polygon; + const props = + this.std.get(EditPropsStore).lastProps$.value[`shape:${shapeName}`] ?? + this.std.get(EditPropsStore).lastProps$.value['shape:rect']; + + return this.std + .get(ThemeProvider) + .getColorValue( + props?.fillColor, + DefaultTheme.shapeFillColor, + true + ); + } +} diff --git a/packages/affine/gfx/shape/src/shape-tool.ts b/packages/affine/gfx/shape/src/shape-tool.ts index 8210448aeab4..49d7ab12e1c1 100644 --- a/packages/affine/gfx/shape/src/shape-tool.ts +++ b/packages/affine/gfx/shape/src/shape-tool.ts @@ -355,6 +355,7 @@ export class ShapeTool extends BaseTool { ShapeType.Ellipse, ShapeType.Diamond, ShapeType.Triangle, + ShapeType.Polygon, 'roundedRect', ]; diff --git a/packages/affine/gfx/shape/src/toolbar/icons.ts b/packages/affine/gfx/shape/src/toolbar/icons.ts index 348cdd327c2e..c5484c3b762f 100644 --- a/packages/affine/gfx/shape/src/toolbar/icons.ts +++ b/packages/affine/gfx/shape/src/toolbar/icons.ts @@ -212,6 +212,50 @@ export const ScribbledRoundedRectangleIcon = html` `; +export const GeneralPolygonIcon = html` + +`; + +export const ScribbledPolygonIcon = html` + +`; + +export const polygonSvg = html` + + `; + export const ellipseSvg = html` = { triangle, ellipse, diamond, + polygon, }; diff --git a/packages/affine/model/src/elements/shape/api/polygon.ts b/packages/affine/model/src/elements/shape/api/polygon.ts new file mode 100644 index 000000000000..4008b4f53257 --- /dev/null +++ b/packages/affine/model/src/elements/shape/api/polygon.ts @@ -0,0 +1,167 @@ +import type { IBound, IVec } from '@blocksuite/global/gfx'; +import { + Bound, + getCenterAreaBounds, + getPointsFromBoundWithRotation, + linePolygonIntersects, + pointInPolygon, + PointLocation, + pointOnPolygonStoke, + polygonGetPointTangent, + polygonNearestPoint, + rotatePoints, +} from '@blocksuite/global/gfx'; +import type { PointTestOptions } from '@blocksuite/std/gfx'; + +import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js'; +import type { ShapeElementModel } from '../shape.js'; + +/** + * Default polygon vertices (a regular pentagon) used when no custom vertices + * are provided. Stored as normalized [0-1] coordinates relative to the + * bounding box. + */ +const DEFAULT_POLYGON_VERTICES: number[][] = [ + [0.5, 0], + [1, 0.38], + [0.81, 1], + [0.19, 1], + [0, 0.38], +]; + +/** + * Returns the absolute points for a polygon shape given its bound and vertices. + * If the element has custom vertices, they are denormalized from [0-1] space + * to the actual bounding box coordinates. Otherwise, the default pentagon + * vertices are used. + */ +function getPolygonVertices( + element: ShapeElementModel | { vertices?: number[][] | null } +): (bound: IBound) => IVec[] { + const verts = + 'vertices' in element && element.vertices + ? element.vertices + : DEFAULT_POLYGON_VERTICES; + + return ({ x, y, w, h }: IBound): IVec[] => { + return verts.map(v => [x + v[0] * w, y + v[1] * h]); + }; +} + +export const polygon = { + points(bound: IBound, element?: ShapeElementModel): IVec[] { + const verts = + element?.vertices ?? DEFAULT_POLYGON_VERTICES; + const { x, y, w, h } = bound; + return verts.map(v => [x + v[0] * w, y + v[1] * h]); + }, + + draw( + ctx: CanvasRenderingContext2D, + { x, y, w, h, rotate = 0 }: IBound, + vertices?: number[][] | null + ) { + const verts = vertices ?? DEFAULT_POLYGON_VERTICES; + const cx = x + w / 2; + const cy = y + h / 2; + + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate((rotate * Math.PI) / 180); + ctx.translate(-cx, -cy); + + ctx.beginPath(); + const absPoints = verts.map(v => [x + v[0] * w, y + v[1] * h]); + if (absPoints.length > 0) { + ctx.moveTo(absPoints[0][0], absPoints[0][1]); + for (let i = 1; i < absPoints.length; i++) { + ctx.lineTo(absPoints[i][0], absPoints[i][1]); + } + } + ctx.closePath(); + + ctx.restore(); + }, + + includesPoint( + this: ShapeElementModel, + x: number, + y: number, + options: PointTestOptions + ) { + const point: IVec = [x, y]; + const pointsFn = getPolygonVertices(this); + const points = getPointsFromBoundWithRotation(this, pointsFn); + + let hit = pointOnPolygonStoke( + point, + points, + (options?.hitThreshold ?? 1) / (options?.zoom ?? 1) + ); + + if (!hit) { + if (!options.ignoreTransparent || this.filled) { + hit = pointInPolygon([x, y], points); + } else { + // If shape is not filled or transparent + const text = this.text; + if (!text || !text.length) { + // Check the center area of the shape + const centralBounds = getCenterAreaBounds( + this, + DEFAULT_CENTRAL_AREA_RATIO + ); + const centralPoints = getPointsFromBoundWithRotation( + centralBounds, + pointsFn + ); + hit = pointInPolygon([x, y], centralPoints); + } else if (this.textBound) { + hit = pointInPolygon( + point, + getPointsFromBoundWithRotation( + this, + () => Bound.from(this.textBound!).points + ) + ); + } + } + } + + return hit; + }, + + containsBound(bounds: Bound, element: ShapeElementModel): boolean { + const pointsFn = getPolygonVertices(element); + const points = getPointsFromBoundWithRotation(element, pointsFn); + return points.some(point => bounds.containsPoint(point)); + }, + + getNearestPoint(point: IVec, element: ShapeElementModel) { + const pointsFn = getPolygonVertices(element); + const points = getPointsFromBoundWithRotation(element, pointsFn); + return polygonNearestPoint(points, point); + }, + + getLineIntersections(start: IVec, end: IVec, element: ShapeElementModel) { + const pointsFn = getPolygonVertices(element); + const points = getPointsFromBoundWithRotation(element, pointsFn); + return linePolygonIntersects(start, end, points); + }, + + getRelativePointLocation(position: IVec, element: ShapeElementModel) { + const bound = Bound.deserialize(element.xywh); + const point = bound.getRelativePoint(position); + const verts = element.vertices ?? DEFAULT_POLYGON_VERTICES; + let points: IVec[] = verts.map(v => [ + bound.x + v[0] * bound.w, + bound.y + v[1] * bound.h, + ]); + points.push(point); + + points = rotatePoints(points, bound.center, element.rotate); + const rotatePoint = points.pop() as IVec; + const tangent = polygonGetPointTangent(points, rotatePoint); + return new PointLocation(rotatePoint, tangent); + }, +}; diff --git a/packages/affine/model/src/elements/shape/shape.ts b/packages/affine/model/src/elements/shape/shape.ts index 81446124a50b..2f525d937d1d 100644 --- a/packages/affine/model/src/elements/shape/shape.ts +++ b/packages/affine/model/src/elements/shape/shape.ts @@ -44,6 +44,26 @@ export type ShapeProps = BaseElementProps & { // https://github.com/rough-stuff/rough/wiki#roughness roughness?: number; + /** + * Vertices for polygon shapes, stored as normalized [0-1] coordinates + * relative to the bounding box. + */ + vertices?: number[][] | null; + + /** + * Whether the polygon is closed (last vertex connects back to first). + * Defaults to true for completed polygons. + */ + isClosed?: boolean; + + /** + * Per-vertex smooth flags for Bezier curve conversion. + * Each entry corresponds to a vertex in `vertices` by index. + * When true, the vertex uses smooth Bezier curves instead of a sharp corner. + * null or undefined means all vertices are sharp (no smoothing). + */ + smoothFlags?: boolean[] | null; + text?: Y.Text; textHorizontalAlign?: TextAlign; textVerticalAlign?: TextVerticalAlign; @@ -181,6 +201,33 @@ export class ShapeElementModel extends GfxPrimitiveElementModel { @field(TextVerticalAlign.Center as TextVerticalAlign) accessor textVerticalAlign!: TextVerticalAlign; + /** + * Vertices for polygon shapes, stored as normalized [0-1] coordinates + * relative to the bounding box. Each vertex is [x, y] where (0,0) is + * the top-left corner and (1,1) is the bottom-right corner. + * Only used when shapeType === ShapeType.Polygon. + */ + @field() + accessor vertices: number[][] | null = null; + + /** + * Whether the polygon is closed (last vertex connects back to first). + * Defaults to true for completed polygons. + * Only used when shapeType === ShapeType.Polygon. + */ + @field() + accessor isClosed: boolean = true; + + /** + * Per-vertex smooth flags for Bezier curve conversion. + * Each entry corresponds to a vertex in `vertices` by index. + * When true, the vertex uses smooth Bezier curves instead of a sharp corner. + * null means all vertices are sharp (no smoothing). + * Only used when shapeType === ShapeType.Polygon. + */ + @field() + accessor smoothFlags: boolean[] | null = null; + @field() accessor xywh: SerializedXYWH = '[0,0,100,100]'; } @@ -257,4 +304,13 @@ export class LocalShapeElementModel extends GfxLocalElementModel { @prop() accessor textVerticalAlign: TextVerticalAlign = TextVerticalAlign.Center; + + @prop() + accessor vertices: number[][] | null = null; + + @prop() + accessor isClosed: boolean = true; + + @prop() + accessor smoothFlags: boolean[] | null = null; } diff --git a/packages/affine/shared/src/utils/zod-schema.ts b/packages/affine/shared/src/utils/zod-schema.ts index 67d1ce186939..08ed85800552 100644 --- a/packages/affine/shared/src/utils/zod-schema.ts +++ b/packages/affine/shared/src/utils/zod-schema.ts @@ -188,6 +188,7 @@ export const NodePropsSchema = z.object({ 'shape:ellipse': ShapeSchema, 'shape:rect': ShapeSchema, 'shape:triangle': ShapeSchema, + 'shape:polygon': ShapeSchema, 'shape:roundedRect': RoundedShapeSchema, }); diff --git a/packages/framework/store/src/__tests__/polygon-crdt.unit.spec.ts b/packages/framework/store/src/__tests__/polygon-crdt.unit.spec.ts new file mode 100644 index 000000000000..16553043369d --- /dev/null +++ b/packages/framework/store/src/__tests__/polygon-crdt.unit.spec.ts @@ -0,0 +1,650 @@ +import { describe, expect, test } from 'vitest'; +import * as Y from 'yjs'; + +import { createYProxy } from '../reactive/index.js'; + +/** + * Tests for real-time collaboration correctness of polygon vertex data. + * + * Polygon vertices are stored as nested arrays (number[][]) inside a Y.Map, + * matching how ShapeElementModel stores them via the @field() decorator. + * These tests verify that concurrent edits from multiple CRDT peers + * converge to a consistent state. + */ + +/** + * Helper: create a peer Y.Doc with a shape element map containing polygon vertices. + * Mimics how ShapeElementModel stores data in Yjs. + */ +function createPeerWithPolygon( + vertices: number[][], + options?: { shapeType?: string } +) { + const doc = new Y.Doc(); + const elementsMap = doc.getMap('elements'); + const shapeMap = new Y.Map(); + + shapeMap.set('shapeType', options?.shapeType ?? 'polygon'); + shapeMap.set('xywh', '[0,0,200,200]'); + + // Store vertices as a Y.Array of Y.Arrays (matching how createYProxy handles nested arrays) + const yVertices = new Y.Array(); + for (const vertex of vertices) { + const yVertex = new Y.Array(); + yVertex.push(vertex); + yVertices.push([yVertex]); + } + shapeMap.set('vertices', yVertices); + + elementsMap.set('shape:0', shapeMap); + + return { doc, elementsMap, shapeMap, yVertices }; +} + +/** + * Helper: sync two Y.Docs bidirectionally. + */ +function syncDocs(doc1: Y.Doc, doc2: Y.Doc) { + const update1 = Y.encodeStateAsUpdate(doc1); + const update2 = Y.encodeStateAsUpdate(doc2); + Y.applyUpdate(doc2, update1); + Y.applyUpdate(doc1, update2); +} + +/** + * Helper: sync doc1 → doc2 (one-way). + */ +function syncOneWay(source: Y.Doc, target: Y.Doc) { + const update = Y.encodeStateAsUpdate(source); + Y.applyUpdate(target, update); +} + +/** + * Helper: extract vertices from a shape Y.Map as plain arrays. + */ +function getVerticesFromMap(shapeMap: Y.Map): number[][] { + const yVertices = shapeMap.get('vertices') as Y.Array>; + if (!yVertices) return []; + return yVertices.toJSON() as number[][]; +} + +describe('polygon CRDT convergence', () => { + describe('basic sync', () => { + test('vertices sync from one peer to another', () => { + const triangleVertices = [ + [0.5, 0], + [1, 1], + [0, 1], + ]; + + const peer1 = createPeerWithPolygon(triangleVertices); + + // Create peer2 by applying peer1's state + const peer2Doc = new Y.Doc(); + syncOneWay(peer1.doc, peer2Doc); + + const peer2Elements = peer2Doc.getMap('elements'); + const peer2Shape = peer2Elements.get('shape:0') as Y.Map; + + expect(peer2Shape).toBeDefined(); + expect(peer2Shape.get('shapeType')).toBe('polygon'); + expect(getVerticesFromMap(peer2Shape)).toEqual(triangleVertices); + }); + + test('vertex modifications sync between peers', () => { + const initialVertices = [ + [0.5, 0], + [1, 1], + [0, 1], + ]; + + const peer1 = createPeerWithPolygon(initialVertices); + const peer2Doc = new Y.Doc(); + syncOneWay(peer1.doc, peer2Doc); + + // Peer1 modifies vertex 0 position (drag vertex) + const peer1Vertices = peer1.shapeMap.get('vertices') as Y.Array< + Y.Array + >; + peer1.doc.transact(() => { + const vertex0 = peer1Vertices.get(0) as Y.Array; + vertex0.delete(0, vertex0.length); + vertex0.push([0.6, 0.1]); + }); + + // Sync peer1 → peer2 + syncOneWay(peer1.doc, peer2Doc); + + const peer2Shape = peer2Doc + .getMap('elements') + .get('shape:0') as Y.Map; + const peer2Vertices = getVerticesFromMap(peer2Shape); + expect(peer2Vertices[0]).toEqual([0.6, 0.1]); + expect(peer2Vertices[1]).toEqual([1, 1]); + expect(peer2Vertices[2]).toEqual([0, 1]); + }); + }); + + describe('concurrent edits', () => { + test('concurrent vertex position edits converge to same state', () => { + const initialVertices = [ + [0.5, 0], + [1, 1], + [0, 1], + ]; + + // Set up two peers with same initial state + const peer1 = createPeerWithPolygon(initialVertices); + const peer2Doc = new Y.Doc(); + syncOneWay(peer1.doc, peer2Doc); + + const peer2Elements = peer2Doc.getMap('elements'); + const peer2Shape = peer2Elements.get('shape:0') as Y.Map; + const peer2VerticesY = peer2Shape.get('vertices') as Y.Array< + Y.Array + >; + + // Peer1 edits vertex 0 (drag it to a new position) + peer1.doc.transact(() => { + const vertex0 = ( + peer1.shapeMap.get('vertices') as Y.Array> + ).get(0) as Y.Array; + vertex0.delete(0, vertex0.length); + vertex0.push([0.3, 0.2]); + }); + + // Peer2 edits vertex 2 concurrently (drag a different vertex) + peer2Doc.transact(() => { + const vertex2 = peer2VerticesY.get(2) as Y.Array; + vertex2.delete(0, vertex2.length); + vertex2.push([0.1, 0.9]); + }); + + // Sync both ways + syncDocs(peer1.doc, peer2Doc); + + // Both peers should converge to the same state + const peer1Final = getVerticesFromMap(peer1.shapeMap); + const peer2Final = getVerticesFromMap(peer2Shape); + + expect(peer1Final).toEqual(peer2Final); + // Peer1's edit to vertex 0 + expect(peer1Final[0]).toEqual([0.3, 0.2]); + // Vertex 1 unchanged + expect(peer1Final[1]).toEqual([1, 1]); + // Peer2's edit to vertex 2 + expect(peer1Final[2]).toEqual([0.1, 0.9]); + }); + + test('concurrent edits to the SAME vertex converge deterministically', () => { + const initialVertices = [ + [0.5, 0], + [1, 1], + [0, 1], + ]; + + const peer1 = createPeerWithPolygon(initialVertices); + const peer2Doc = new Y.Doc(); + syncOneWay(peer1.doc, peer2Doc); + + const peer2Shape = peer2Doc + .getMap('elements') + .get('shape:0') as Y.Map; + const peer2VerticesY = peer2Shape.get('vertices') as Y.Array< + Y.Array + >; + + // Both peers concurrently edit vertex 0 + peer1.doc.transact(() => { + const vertex0 = ( + peer1.shapeMap.get('vertices') as Y.Array> + ).get(0) as Y.Array; + vertex0.delete(0, vertex0.length); + vertex0.push([0.3, 0.2]); + }); + + peer2Doc.transact(() => { + const vertex0 = peer2VerticesY.get(0) as Y.Array; + vertex0.delete(0, vertex0.length); + vertex0.push([0.7, 0.8]); + }); + + // Sync both ways + syncDocs(peer1.doc, peer2Doc); + + // Both should converge to the same state (Yjs determines winner) + const peer1Final = getVerticesFromMap(peer1.shapeMap); + const peer2Final = getVerticesFromMap(peer2Shape); + + expect(peer1Final).toEqual(peer2Final); + // Both vertex 0 values should be identical (exact value depends on Yjs conflict resolution) + expect(peer1Final[0]).toEqual(peer2Final[0]); + }); + + test('concurrent vertex addition and removal converge', () => { + const initialVertices = [ + [0.25, 0], + [0.75, 0], + [1, 0.5], + [0.5, 1], + [0, 0.5], + ]; + + const peer1 = createPeerWithPolygon(initialVertices); + const peer2Doc = new Y.Doc(); + syncOneWay(peer1.doc, peer2Doc); + + const peer2Shape = peer2Doc + .getMap('elements') + .get('shape:0') as Y.Map; + + // Peer1 adds a new vertex at the end + peer1.doc.transact(() => { + const vertices = peer1.shapeMap.get('vertices') as Y.Array< + Y.Array + >; + const newVertex = new Y.Array(); + newVertex.push([0.1, 0.3]); + vertices.push([newVertex]); + }); + + // Peer2 removes vertex at index 2 + peer2Doc.transact(() => { + const vertices = peer2Shape.get('vertices') as Y.Array< + Y.Array + >; + vertices.delete(2, 1); + }); + + // Sync both ways + syncDocs(peer1.doc, peer2Doc); + + // Both peers should converge + const peer1Final = getVerticesFromMap(peer1.shapeMap); + const peer2Final = getVerticesFromMap(peer2Shape); + + expect(peer1Final).toEqual(peer2Final); + // Original had 5 vertices, one removed (-1), one added (+1) = 5 + expect(peer1Final.length).toBe(5); + }); + }); + + describe('property-level updates via Y.Map', () => { + test('replacing entire vertices array syncs correctly', () => { + const initialVertices = [ + [0.5, 0], + [1, 1], + [0, 1], + ]; + + const peer1 = createPeerWithPolygon(initialVertices); + const peer2Doc = new Y.Doc(); + syncOneWay(peer1.doc, peer2Doc); + + // Peer1 replaces the entire vertices array (like when polygon is rebuilt) + const newVertices = [ + [0.2, 0], + [0.8, 0], + [1, 0.6], + [0.5, 1], + [0, 0.6], + ]; + peer1.doc.transact(() => { + const newYVertices = new Y.Array(); + for (const v of newVertices) { + const yv = new Y.Array(); + yv.push(v); + newYVertices.push([yv]); + } + peer1.shapeMap.set('vertices', newYVertices); + }); + + syncOneWay(peer1.doc, peer2Doc); + + const peer2Shape = peer2Doc + .getMap('elements') + .get('shape:0') as Y.Map; + expect(getVerticesFromMap(peer2Shape)).toEqual(newVertices); + }); + + test('smoothFlags sync alongside vertices', () => { + const vertices = [ + [0.5, 0], + [1, 1], + [0, 1], + ]; + const peer1 = createPeerWithPolygon(vertices); + + // Add smoothFlags + peer1.doc.transact(() => { + const flags = new Y.Array(); + flags.push([false, true, false]); + peer1.shapeMap.set('smoothFlags', flags); + }); + + const peer2Doc = new Y.Doc(); + syncOneWay(peer1.doc, peer2Doc); + + const peer2Shape = peer2Doc + .getMap('elements') + .get('shape:0') as Y.Map; + const peer2Flags = peer2Shape.get('smoothFlags') as Y.Array; + expect(peer2Flags.toJSON()).toEqual([false, true, false]); + + // Peer1 toggles a smooth flag + peer1.doc.transact(() => { + const flags = peer1.shapeMap.get('smoothFlags') as Y.Array; + flags.delete(0, 1); + flags.insert(0, [true]); + }); + + syncOneWay(peer1.doc, peer2Doc); + + const updatedFlags = ( + peer2Shape.get('smoothFlags') as Y.Array + ).toJSON(); + expect(updatedFlags).toEqual([true, true, false]); + }); + + test('concurrent smoothFlags and vertex edits converge', () => { + const vertices = [ + [0.5, 0], + [1, 1], + [0, 1], + ]; + const peer1 = createPeerWithPolygon(vertices); + + // Set initial smoothFlags + peer1.doc.transact(() => { + const flags = new Y.Array(); + flags.push([false, false, false]); + peer1.shapeMap.set('smoothFlags', flags); + }); + + const peer2Doc = new Y.Doc(); + syncOneWay(peer1.doc, peer2Doc); + + const peer2Shape = peer2Doc + .getMap('elements') + .get('shape:0') as Y.Map; + + // Peer1 moves vertex 1 + peer1.doc.transact(() => { + const verts = peer1.shapeMap.get('vertices') as Y.Array< + Y.Array + >; + const v1 = verts.get(1) as Y.Array; + v1.delete(0, v1.length); + v1.push([0.9, 0.8]); + }); + + // Peer2 toggles smoothFlag for vertex 2 + peer2Doc.transact(() => { + const flags = peer2Shape.get('smoothFlags') as Y.Array; + flags.delete(2, 1); + flags.insert(2, [true]); + }); + + // Sync both ways + syncDocs(peer1.doc, peer2Doc); + + const peer1Vertices = getVerticesFromMap(peer1.shapeMap); + const peer2Vertices = getVerticesFromMap(peer2Shape); + expect(peer1Vertices).toEqual(peer2Vertices); + expect(peer1Vertices[1]).toEqual([0.9, 0.8]); + + const peer1Flags = ( + peer1.shapeMap.get('smoothFlags') as Y.Array + ).toJSON(); + const peer2Flags = ( + peer2Shape.get('smoothFlags') as Y.Array + ).toJSON(); + expect(peer1Flags).toEqual(peer2Flags); + expect(peer1Flags[2]).toBe(true); + }); + }); + + describe('three-peer convergence', () => { + test('three peers editing different vertices all converge', () => { + const initialVertices = [ + [0.2, 0], + [0.8, 0], + [1, 0.6], + [0.5, 1], + [0, 0.6], + ]; + + const peer1 = createPeerWithPolygon(initialVertices); + const peer2Doc = new Y.Doc(); + const peer3Doc = new Y.Doc(); + + // Sync initial state to all peers + syncOneWay(peer1.doc, peer2Doc); + syncOneWay(peer1.doc, peer3Doc); + + const peer2Shape = peer2Doc + .getMap('elements') + .get('shape:0') as Y.Map; + const peer3Shape = peer3Doc + .getMap('elements') + .get('shape:0') as Y.Map; + + // All three peers edit different vertices concurrently + peer1.doc.transact(() => { + const verts = peer1.shapeMap.get('vertices') as Y.Array< + Y.Array + >; + const v0 = verts.get(0) as Y.Array; + v0.delete(0, v0.length); + v0.push([0.25, 0.05]); + }); + + peer2Doc.transact(() => { + const verts = peer2Shape.get('vertices') as Y.Array>; + const v2 = verts.get(2) as Y.Array; + v2.delete(0, v2.length); + v2.push([0.95, 0.55]); + }); + + peer3Doc.transact(() => { + const verts = peer3Shape.get('vertices') as Y.Array>; + const v4 = verts.get(4) as Y.Array; + v4.delete(0, v4.length); + v4.push([0.05, 0.55]); + }); + + // Full mesh sync: each pair syncs + syncDocs(peer1.doc, peer2Doc); + syncDocs(peer1.doc, peer3Doc); + syncDocs(peer2Doc, peer3Doc); + + // All three peers should converge + const peer1Final = getVerticesFromMap(peer1.shapeMap); + const peer2Final = getVerticesFromMap(peer2Shape); + const peer3Final = getVerticesFromMap(peer3Shape); + + expect(peer1Final).toEqual(peer2Final); + expect(peer2Final).toEqual(peer3Final); + + // Verify each peer's edit is reflected + expect(peer1Final[0]).toEqual([0.25, 0.05]); + expect(peer1Final[2]).toEqual([0.95, 0.55]); + expect(peer1Final[4]).toEqual([0.05, 0.55]); + // Unmodified vertices remain + expect(peer1Final[1]).toEqual([0.8, 0]); + expect(peer1Final[3]).toEqual([0.5, 1]); + }); + }); + + describe('createYProxy integration', () => { + test('polygon vertices via createYProxy sync like @field() decorator', () => { + // This test simulates how @field() works: storing data in Y.Map + // and accessing it via createYProxy + const doc1 = new Y.Doc(); + const map1 = doc1.getMap('shape'); + + // Set initial polygon data as plain objects (createYProxy converts them) + doc1.transact(() => { + map1.set('shapeType', 'polygon'); + map1.set( + 'vertices', + Y.Array.from([ + Y.Array.from([0.5, 0]), + Y.Array.from([1, 1]), + Y.Array.from([0, 1]), + ]) + ); + }); + + const proxy1 = createYProxy>(map1); + + // Verify proxy reads correctly + expect(proxy1.shapeType).toBe('polygon'); + expect(proxy1.vertices.length).toBe(3); + + // Create peer2 and sync + const doc2 = new Y.Doc(); + syncOneWay(doc1, doc2); + + const map2 = doc2.getMap('shape'); + const proxy2 = createYProxy>(map2); + + expect(proxy2.shapeType).toBe('polygon'); + expect(proxy2.vertices.length).toBe(3); + + // Modify via proxy on peer1 (simulates user dragging a vertex) + doc1.transact(() => { + const verts = map1.get('vertices') as Y.Array>; + const v0 = verts.get(0); + v0.delete(0, v0.length); + v0.push([0.6, 0.1]); + }); + + syncOneWay(doc1, doc2); + + // Peer2 should see the update + const v2Verts = (map2.get('vertices') as Y.Array>).toJSON(); + expect(v2Verts[0]).toEqual([0.6, 0.1]); + }); + + test('xywh and vertices update atomically within transaction', () => { + // When a polygon is resized, both xywh and vertices may be updated + const doc1 = new Y.Doc(); + const map1 = doc1.getMap('shape'); + + doc1.transact(() => { + map1.set('shapeType', 'polygon'); + map1.set('xywh', '[0,0,200,200]'); + map1.set( + 'vertices', + Y.Array.from([ + Y.Array.from([0.5, 0]), + Y.Array.from([1, 1]), + Y.Array.from([0, 1]), + ]) + ); + }); + + const doc2 = new Y.Doc(); + syncOneWay(doc1, doc2); + + // Track updates received by peer2 + let updateCount = 0; + doc2.on('update', () => { + updateCount++; + }); + + // Peer1: resize polygon (update xywh) and adjust vertices in a single transaction + doc1.transact(() => { + map1.set('xywh', '[10,10,300,300]'); + // Vertices stay normalized but we might adjust them for a non-uniform resize + const verts = map1.get('vertices') as Y.Array>; + const v1 = verts.get(1); + v1.delete(0, v1.length); + v1.push([0.9, 0.95]); + }); + + syncOneWay(doc1, doc2); + + const map2 = doc2.getMap('shape'); + expect(map2.get('xywh')).toBe('[10,10,300,300]'); + const v2Verts = (map2.get('vertices') as Y.Array>).toJSON(); + expect(v2Verts[1]).toEqual([0.9, 0.95]); + }); + }); + + describe('edge cases', () => { + test('empty polygon (no vertices) syncs correctly', () => { + const doc1 = new Y.Doc(); + const map1 = doc1.getMap('shape'); + doc1.transact(() => { + map1.set('shapeType', 'polygon'); + map1.set('vertices', new Y.Array()); + }); + + const doc2 = new Y.Doc(); + syncOneWay(doc1, doc2); + + const map2 = doc2.getMap('shape'); + const verts = (map2.get('vertices') as Y.Array).toJSON(); + expect(verts).toEqual([]); + }); + + test('polygon with many vertices (complex shape) syncs correctly', () => { + // Star polygon with 10 vertices + const starVertices: number[][] = []; + for (let i = 0; i < 10; i++) { + const angle = (Math.PI * 2 * i) / 10 - Math.PI / 2; + const r = i % 2 === 0 ? 0.5 : 0.2; + starVertices.push([0.5 + r * Math.cos(angle), 0.5 + r * Math.sin(angle)]); + } + + const peer1 = createPeerWithPolygon(starVertices); + const peer2Doc = new Y.Doc(); + syncOneWay(peer1.doc, peer2Doc); + + const peer2Shape = peer2Doc + .getMap('elements') + .get('shape:0') as Y.Map; + const synced = getVerticesFromMap(peer2Shape); + + expect(synced.length).toBe(10); + for (let i = 0; i < 10; i++) { + expect(synced[i][0]).toBeCloseTo(starVertices[i][0], 10); + expect(synced[i][1]).toBeCloseTo(starVertices[i][1], 10); + } + }); + + test('rapid sequential edits from one peer sync correctly', () => { + const peer1 = createPeerWithPolygon([ + [0.5, 0], + [1, 1], + [0, 1], + ]); + + // Simulate rapid dragging: many small position updates + for (let i = 0; i < 20; i++) { + peer1.doc.transact(() => { + const verts = peer1.shapeMap.get('vertices') as Y.Array< + Y.Array + >; + const v0 = verts.get(0) as Y.Array; + v0.delete(0, v0.length); + v0.push([0.5 + i * 0.01, i * 0.02]); + }); + } + + const peer2Doc = new Y.Doc(); + syncOneWay(peer1.doc, peer2Doc); + + const peer2Shape = peer2Doc + .getMap('elements') + .get('shape:0') as Y.Map; + const synced = getVerticesFromMap(peer2Shape); + + // Should reflect the final state after all 20 edits + expect(synced[0][0]).toBeCloseTo(0.5 + 19 * 0.01, 10); + expect(synced[0][1]).toBeCloseTo(19 * 0.02, 10); + }); + }); +}); diff --git a/packages/integration-test/src/__tests__/edgeless/polygon-persistence.spec.ts b/packages/integration-test/src/__tests__/edgeless/polygon-persistence.spec.ts new file mode 100644 index 000000000000..3263a004cd84 --- /dev/null +++ b/packages/integration-test/src/__tests__/edgeless/polygon-persistence.spec.ts @@ -0,0 +1,395 @@ +import type { SurfaceBlockModel } from '@blocksuite/affine/blocks/surface'; +import type { ShapeElementModel } from '@blocksuite/affine/model'; +import { ShapeType } from '@blocksuite/affine/model'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { setupEditor } from '../utils/setup.js'; + +let model: SurfaceBlockModel; + +beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + const models = doc.getModelsByFlavour( + 'affine:surface' + ) as SurfaceBlockModel[]; + + model = models[0]; + + return cleanup; +}); + +describe('polygon round-trip persistence', () => { + test('polygon element with vertices persists and restores correctly', () => { + const vertices = [ + [0.5, 0], + [1, 0.38], + [0.81, 1], + [0.19, 1], + [0, 0.38], + ]; + + const id = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[100,100,200,200]', + vertices, + }); + + const element = model.getElementById(id) as ShapeElementModel; + + expect(element).not.toBeNull(); + expect(element.shapeType).toBe(ShapeType.Polygon); + expect(element.vertices).toEqual(vertices); + expect(element.xywh).toBe('[100,100,200,200]'); + }); + + test('polygon vertices are stored in the YMap and survive reload', () => { + const vertices = [ + [0.2, 0.1], + [0.8, 0.1], + [0.9, 0.5], + [0.5, 0.9], + [0.1, 0.5], + ]; + + const id = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[50,50,300,300]', + vertices, + }); + + const element = model.getElementById(id) as ShapeElementModel; + + // Verify the data is stored in the underlying YMap (Yjs persistence layer) + const yMap = element.yMap; + expect(yMap.get('shapeType')).toBe(ShapeType.Polygon); + + const storedVertices = yMap.get('vertices'); + expect(storedVertices).toBeDefined(); + + // Verify element model reads back correctly + expect(element.vertices).toEqual(vertices); + expect(element.vertices!.length).toBe(5); + }); + + test('isClosed flag persists correctly', () => { + const vertices = [ + [0, 0], + [1, 0], + [1, 1], + ]; + + // Create a closed polygon (default) + const closedId = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[0,0,100,100]', + vertices, + isClosed: true, + }); + + const closedElement = model.getElementById(closedId) as ShapeElementModel; + expect(closedElement.isClosed).toBe(true); + expect(closedElement.yMap.get('isClosed')).toBe(true); + + // Create an open polygon + const openId = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[200,0,100,100]', + vertices, + isClosed: false, + }); + + const openElement = model.getElementById(openId) as ShapeElementModel; + expect(openElement.isClosed).toBe(false); + expect(openElement.yMap.get('isClosed')).toBe(false); + }); + + test('smoothFlags per-vertex array persists correctly', () => { + const vertices = [ + [0.5, 0], + [1, 0.5], + [0.5, 1], + [0, 0.5], + ]; + const smoothFlags = [false, true, false, true]; + + const id = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[0,0,200,200]', + vertices, + smoothFlags, + }); + + const element = model.getElementById(id) as ShapeElementModel; + expect(element.smoothFlags).toEqual(smoothFlags); + expect(element.smoothFlags!.length).toBe(4); + expect(element.smoothFlags![0]).toBe(false); + expect(element.smoothFlags![1]).toBe(true); + expect(element.smoothFlags![2]).toBe(false); + expect(element.smoothFlags![3]).toBe(true); + }); + + test('null smoothFlags defaults correctly', () => { + const vertices = [ + [0, 0], + [1, 0], + [0.5, 1], + ]; + + const id = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[0,0,100,100]', + vertices, + // smoothFlags not provided — should default to null + }); + + const element = model.getElementById(id) as ShapeElementModel; + expect(element.smoothFlags).toBeNull(); + }); + + test('all polygon-specific properties persist together', () => { + const vertices = [ + [0.1, 0.2], + [0.9, 0.2], + [0.8, 0.8], + [0.5, 1.0], + [0.2, 0.8], + ]; + const smoothFlags = [true, false, true, false, true]; + + const id = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[10,20,300,250]', + vertices, + isClosed: true, + smoothFlags, + filled: true, + fillColor: '#ff0000', + strokeColor: '#000000', + strokeWidth: 3, + }); + + const element = model.getElementById(id) as ShapeElementModel; + + // Polygon-specific fields + expect(element.shapeType).toBe(ShapeType.Polygon); + expect(element.vertices).toEqual(vertices); + expect(element.isClosed).toBe(true); + expect(element.smoothFlags).toEqual(smoothFlags); + + // Standard shape styling fields + expect(element.filled).toBe(true); + expect(element.fillColor).toBe('#ff0000'); + expect(element.strokeColor).toBe('#000000'); + expect(element.strokeWidth).toBe(3); + expect(element.xywh).toBe('[10,20,300,250]'); + }); + + test('updateElement round-trip for vertices', () => { + const initialVertices = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ]; + + const id = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[0,0,100,100]', + vertices: initialVertices, + }); + + const element = model.getElementById(id) as ShapeElementModel; + expect(element.vertices).toEqual(initialVertices); + + // Update vertices (simulate vertex drag) + const updatedVertices = [ + [0, 0], + [0.8, 0.1], + [1, 1], + [0.1, 0.9], + ]; + model.updateElement(id, { vertices: updatedVertices }); + + expect(element.vertices).toEqual(updatedVertices); + expect(element.vertices!.length).toBe(4); + }); + + test('updateElement round-trip for smoothFlags', () => { + const vertices = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ]; + + const id = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[0,0,100,100]', + vertices, + smoothFlags: null, + }); + + const element = model.getElementById(id) as ShapeElementModel; + expect(element.smoothFlags).toBeNull(); + + // Enable smoothing on vertices 1 and 3 + model.updateElement(id, { + smoothFlags: [false, true, false, true], + }); + + expect(element.smoothFlags).toEqual([false, true, false, true]); + + // Update to all smooth + model.updateElement(id, { + smoothFlags: [true, true, true, true], + }); + + expect(element.smoothFlags).toEqual([true, true, true, true]); + + // Reset to null (all sharp) + model.updateElement(id, { + smoothFlags: null, + }); + + expect(element.smoothFlags).toBeNull(); + }); + + test('updateElement round-trip for isClosed toggle', () => { + const vertices = [ + [0, 0], + [1, 0], + [0.5, 1], + ]; + + const id = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[0,0,100,100]', + vertices, + isClosed: true, + }); + + const element = model.getElementById(id) as ShapeElementModel; + expect(element.isClosed).toBe(true); + + model.updateElement(id, { isClosed: false }); + expect(element.isClosed).toBe(false); + + model.updateElement(id, { isClosed: true }); + expect(element.isClosed).toBe(true); + }); + + test('polygon with many vertices persists correctly', () => { + // Create a polygon with many vertices (e.g., approximating a circle) + const numVertices = 20; + const vertices: number[][] = []; + for (let i = 0; i < numVertices; i++) { + const angle = (2 * Math.PI * i) / numVertices; + vertices.push([ + 0.5 + 0.5 * Math.cos(angle), + 0.5 + 0.5 * Math.sin(angle), + ]); + } + + const smoothFlags = Array.from({ length: numVertices }, (_, i) => i % 2 === 0); + + const id = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[0,0,400,400]', + vertices, + smoothFlags, + isClosed: true, + }); + + const element = model.getElementById(id) as ShapeElementModel; + + expect(element.vertices!.length).toBe(numVertices); + expect(element.smoothFlags!.length).toBe(numVertices); + + // Verify each vertex value is preserved with precision + for (let i = 0; i < numVertices; i++) { + expect(element.vertices![i][0]).toBeCloseTo(vertices[i][0], 10); + expect(element.vertices![i][1]).toBeCloseTo(vertices[i][1], 10); + expect(element.smoothFlags![i]).toBe(i % 2 === 0); + } + }); + + test('polygon with minimum vertices (triangle) persists correctly', () => { + const vertices = [ + [0.5, 0], + [1, 1], + [0, 1], + ]; + + const id = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[0,0,100,100]', + vertices, + isClosed: true, + smoothFlags: [false, false, false], + }); + + const element = model.getElementById(id) as ShapeElementModel; + + expect(element.vertices).toEqual(vertices); + expect(element.vertices!.length).toBe(3); + expect(element.isClosed).toBe(true); + expect(element.smoothFlags).toEqual([false, false, false]); + }); + + test('delete and re-add polygon element', () => { + const vertices = [ + [0.25, 0], + [0.75, 0], + [1, 0.5], + [0.75, 1], + [0.25, 1], + [0, 0.5], + ]; + + const id = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[0,0,200,200]', + vertices, + smoothFlags: [true, false, true, false, true, false], + }); + + expect(model.getElementById(id)).not.toBeNull(); + + model.deleteElement(id); + expect(model.getElementById(id)).toBeNull(); + + // Re-add with different data + const newVertices = [ + [0, 0], + [1, 0], + [1, 1], + ]; + + const newId = model.addElement({ + type: 'shape', + shapeType: ShapeType.Polygon, + xywh: '[50,50,150,150]', + vertices: newVertices, + smoothFlags: [false, true, false], + isClosed: false, + }); + + const newElement = model.getElementById(newId) as ShapeElementModel; + expect(newElement.vertices).toEqual(newVertices); + expect(newElement.smoothFlags).toEqual([false, true, false]); + expect(newElement.isClosed).toBe(false); + }); +}); From 9f45ec41617d40f58700b2006f3dc67ffde37905 Mon Sep 17 00:00:00 2001 From: Mathieu Jolly Date: Fri, 20 Mar 2026 07:37:20 +0100 Subject: [PATCH 02/10] feat(edgeless): polygone behavior copy extisting shapes behavior --- .claude/settings.local.json | 5 + .../src/__tests__/math-utils.unit.spec.ts | 264 ++++++ .../blocks/surface/src/tool/default-tool.ts | 27 +- .../gfx/connector/src/connector-watcher.ts | 90 +- packages/affine/gfx/shape/package.json | 1 + packages/affine/gfx/shape/src/element-view.ts | 332 ++++++-- .../affine/gfx/shape/src/toolbar/config.ts | 25 +- .../model/src/elements/shape/api/polygon.ts | 65 +- packages/framework/global/src/gfx/math.ts | 22 + .../__tests__/polygon-vertex-ops.unit.spec.ts | 785 ++++++++++++++++++ 10 files changed, 1511 insertions(+), 105 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 packages/framework/store/src/__tests__/polygon-vertex-ops.unit.spec.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000000..d8cc4683f7e1 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "allow": ["Bash(npx tsc:*)", "Bash(npx vitest:*)", "Bash(python:*)"] + } +} diff --git a/packages/affine/blocks/surface/src/__tests__/math-utils.unit.spec.ts b/packages/affine/blocks/surface/src/__tests__/math-utils.unit.spec.ts index f9fd55b0f372..dc3d21e52feb 100644 --- a/packages/affine/blocks/surface/src/__tests__/math-utils.unit.spec.ts +++ b/packages/affine/blocks/surface/src/__tests__/math-utils.unit.spec.ts @@ -7,6 +7,8 @@ import { linePolygonIntersects, linePolylineIntersects, pointAlmostEqual, + pointInPolygon, + pointOnPolygonStoke, polygonGetPointTangent, rotatePoints, toDegree, @@ -155,3 +157,265 @@ describe('Line', () => { expect(toDegree(Math.PI * 2)).toBe(360); }); }); + +/** + * Tests for pointInPolygon (winding-number algorithm). + * + * The winding-number algorithm correctly classifies points inside and outside + * both convex and concave (non-convex) polygons. This is essential for + * accurate drag-to-move hit-testing on edgeless polygon shapes: a click + * anywhere inside the visible polygon body must register as a hit even when + * the polygon is concave (e.g. an L-shape or star). + */ +describe('pointInPolygon – winding-number hit-testing', () => { + // ── Convex polygon tests ────────────────────────────────────────────────── + + describe('convex square [0,0]→[10,10]', () => { + const square: IVec[] = [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ]; + + it('detects a point at the centre as inside', () => { + expect(pointInPolygon([5, 5], square)).toBe(true); + }); + + it('detects a point near a corner (but inside) as inside', () => { + expect(pointInPolygon([1, 1], square)).toBe(true); + expect(pointInPolygon([9, 9], square)).toBe(true); + }); + + it('detects a point well outside as outside', () => { + expect(pointInPolygon([15, 5], square)).toBe(false); + expect(pointInPolygon([-1, 5], square)).toBe(false); + expect(pointInPolygon([5, -1], square)).toBe(false); + expect(pointInPolygon([5, 11], square)).toBe(false); + }); + + it('detects a point diagonally outside as outside', () => { + expect(pointInPolygon([11, 11], square)).toBe(false); + expect(pointInPolygon([-1, -1], square)).toBe(false); + }); + }); + + describe('convex triangle', () => { + // Right-angled triangle with vertices at (0,0), (10,0), (0,10) + const triangle: IVec[] = [ + [0, 0], + [10, 0], + [0, 10], + ]; + + it('detects centroid as inside', () => { + // Centroid ≈ (3.33, 3.33) + expect(pointInPolygon([3, 3], triangle)).toBe(true); + }); + + it('detects hypotenuse interior side as inside', () => { + expect(pointInPolygon([2, 2], triangle)).toBe(true); + }); + + it('detects point beyond hypotenuse as outside', () => { + // Point (7,7) is beyond the hypotenuse x+y=10 + expect(pointInPolygon([7, 7], triangle)).toBe(false); + }); + }); + + describe('convex pentagon (default polygon shape)', () => { + // Normalised pentagon vertices used as default polygon in the editor, + // scaled to a 100×100 bounding box so absolute coords are easy to reason about. + const scaleVerts = (verts: number[][], w: number, h: number): IVec[] => + verts.map(v => [v[0] * w, v[1] * h]); + + const normalised = [ + [0.5, 0], + [1, 0.38], + [0.81, 1], + [0.19, 1], + [0, 0.38], + ]; + const pentagon = scaleVerts(normalised, 100, 100); + + it('detects the geometric centre as inside', () => { + expect(pointInPolygon([50, 50], pentagon)).toBe(true); + }); + + it('detects a point clearly outside the bounding box as outside', () => { + expect(pointInPolygon([150, 50], pentagon)).toBe(false); + expect(pointInPolygon([50, 150], pentagon)).toBe(false); + }); + + it('detects a point near a top corner of the bbox but outside the polygon as outside', () => { + // Top-left corner [0,0] is outside the pentagon (first vertex is at [50,0]) + expect(pointInPolygon([2, 2], pentagon)).toBe(false); + // Top-right corner [100,0] is outside + expect(pointInPolygon([98, 2], pentagon)).toBe(false); + }); + }); + + // ── Concave polygon tests ───────────────────────────────────────────────── + + describe('concave L-shape polygon', () => { + /** + * L-shaped polygon (concave), defined clockwise: + * + * (0,0)──(6,0) + * | | + * (0,4) (6,4)──(10,4) + * | | + * (0,10)────────(10,10) + * + * The "notch" region (x∈[6,10], y∈[0,4]) is OUTSIDE the polygon. + */ + const lShape: IVec[] = [ + [0, 0], + [6, 0], + [6, 4], + [10, 4], + [10, 10], + [0, 10], + ]; + + it('detects a point in the vertical bar of the L as inside', () => { + expect(pointInPolygon([3, 5], lShape)).toBe(true); + }); + + it('detects a point in the horizontal bar of the L as inside', () => { + expect(pointInPolygon([8, 7], lShape)).toBe(true); + }); + + it('detects the concave notch region as outside', () => { + // The notch: x∈(6,10), y∈(0,4) — this is the "missing" piece of the L + expect(pointInPolygon([8, 2], lShape)).toBe(false); + }); + + it('detects a point far outside as outside', () => { + expect(pointInPolygon([11, 5], lShape)).toBe(false); + expect(pointInPolygon([5, 11], lShape)).toBe(false); + }); + + it('detects the inner concave corner region correctly', () => { + // Just inside the inner concave corner + expect(pointInPolygon([5, 5], lShape)).toBe(true); + // Just inside the lower-right region + expect(pointInPolygon([9, 9], lShape)).toBe(true); + }); + }); + + describe('concave star-like polygon (5-pointed)', () => { + /** + * A simple star approximation using 10 vertices alternating between an + * outer radius (r=5) and inner radius (r=2) centred at (5,5). + * This is a complex concave shape with multiple concavities — a stress + * test for the winding-number algorithm. + */ + const starVertices: IVec[] = []; + for (let i = 0; i < 10; i++) { + const angle = (Math.PI * 2 * i) / 10 - Math.PI / 2; + const r = i % 2 === 0 ? 5 : 2; // outer or inner radius + starVertices.push([5 + r * Math.cos(angle), 5 + r * Math.sin(angle)]); + } + + it('detects the centre of the star as inside', () => { + expect(pointInPolygon([5, 5], starVertices)).toBe(true); + }); + + it('detects the tip of a point (outer vertex) neighbourhood as inside', () => { + // Slightly inside a spike tip — just past the outer vertex + expect(pointInPolygon([5, 0.3], starVertices)).toBe(true); + }); + + it('detects the concave indentation between spikes as outside', () => { + // Point between two spikes is outside the star + // Between spike at top (angle=-90°) and spike at upper-right (angle=-54°) + // the inner vertex is at angle ≈ -72° from center with r=2 + // A point at angle -72° with r=3 (between inner=2 and outer=5) is outside + const angle = -Math.PI / 2 + (2 * Math.PI) / 10; // -72 degrees + const r = 3.5; // between inner (2) and outer (5) + const px = 5 + r * Math.cos(angle); + const py = 5 + r * Math.sin(angle); + expect(pointInPolygon([px, py], starVertices)).toBe(false); + }); + + it('detects a point far outside the bounding box as outside', () => { + expect(pointInPolygon([15, 5], starVertices)).toBe(false); + }); + }); + + describe('bounding-box vs polygon accuracy', () => { + /** + * These tests specifically verify that the winding-number algorithm gives + * different results from a naïve bounding-box containment check. + * A bounding-box check would return true for the notch of a concave polygon; + * the winding-number algorithm correctly returns false. + */ + const lShape: IVec[] = [ + [0, 0], + [6, 0], + [6, 4], + [10, 4], + [10, 10], + [0, 10], + ]; + + it('returns false for a point inside the bbox but outside the concave notch', () => { + // Bounding box is [0,0]→[10,10]. Point (8,2) is inside the bbox but + // outside the L-shape polygon (it is in the notch). + const p: IVec = [8, 2]; + // Sanity: confirm it IS within the axis-aligned bounding box + expect(p[0] >= 0 && p[0] <= 10 && p[1] >= 0 && p[1] <= 10).toBe(true); + // But the winding-number algorithm correctly reports it as outside + expect(pointInPolygon(p, lShape)).toBe(false); + }); + }); +}); + +/** + * Tests for pointOnPolygonStoke (distance-to-edge hit-testing). + * + * Used as the first check in polygon.includesPoint to detect clicks on the + * polygon's stroke/outline before falling back to interior testing. + */ +describe('pointOnPolygonStoke', () => { + const square: IVec[] = [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ]; + + it('detects a point exactly on an edge as on-stroke', () => { + // Point on top edge + expect(pointOnPolygonStoke([5, 0], square, 1)).toBe(true); + // Point on right edge + expect(pointOnPolygonStoke([10, 5], square, 1)).toBe(true); + // Point on bottom edge + expect(pointOnPolygonStoke([5, 10], square, 1)).toBe(true); + // Point on left edge + expect(pointOnPolygonStoke([0, 5], square, 1)).toBe(true); + }); + + it('detects a point within threshold of an edge as on-stroke', () => { + // 0.5 units inside the top edge, threshold=1 + expect(pointOnPolygonStoke([5, 0.5], square, 1)).toBe(true); + }); + + it('detects a point beyond threshold as not on-stroke', () => { + // 2 units inside the top edge, threshold=1 + expect(pointOnPolygonStoke([5, 2], square, 1)).toBe(false); + // Interior point well away from all edges + expect(pointOnPolygonStoke([5, 5], square, 1)).toBe(false); + }); + + it('detects a point outside the polygon but close to an edge as on-stroke', () => { + // 0.5 units outside the top edge, threshold=1 + expect(pointOnPolygonStoke([5, -0.5], square, 1)).toBe(true); + }); + + it('threshold=0 only matches points exactly on the edge (within floating-point)', () => { + expect(pointOnPolygonStoke([5, 0], square, 0)).toBe(true); + expect(pointOnPolygonStoke([5, 1], square, 0)).toBe(false); + }); +}); diff --git a/packages/affine/blocks/surface/src/tool/default-tool.ts b/packages/affine/blocks/surface/src/tool/default-tool.ts index 70ca5f00e3e7..a8e667abbd7a 100644 --- a/packages/affine/blocks/surface/src/tool/default-tool.ts +++ b/packages/affine/blocks/surface/src/tool/default-tool.ts @@ -153,7 +153,32 @@ export class DefaultTool extends BaseTool { private _determineDragType(evt: PointerEventState): DefaultModeDragType { const { x, y } = this.controller.lastMousePos$.peek(); - if (this.selection.isInSelectedRect(x, y)) { + + // Use each selected element's own hit-test (includesPoint) instead of the + // common axis-aligned bounding-box (isInSelectedRect). For polygon shapes + // this delegates to the winding-number algorithm, so a click inside a + // concave "notch" (inside the bbox but outside the polygon body) correctly + // falls through to handleElementSelection rather than triggering a move. + // For all other shapes the per-element check is equivalent to – or + // stricter than – the old bounding-box approach and is therefore safe. + // + // ignoreTransparent: false ensures that already-selected transparent + // (unfilled) shapes can still be dragged by clicking anywhere inside their + // visible extent, preserving the intent of the old bounding-box check. + const hitsSelected = this.selection.selectedElements.some(el => + el.includesPoint( + x, + y, + { + hitThreshold: 10, + zoom: this.gfx.viewport.zoom, + ignoreTransparent: false, + }, + this.std.host + ) + ); + + if (hitsSelected) { return this.selection.editing ? DefaultModeDragType.NativeEditing : DefaultModeDragType.ContentMoving; diff --git a/packages/affine/gfx/connector/src/connector-watcher.ts b/packages/affine/gfx/connector/src/connector-watcher.ts index b58c0275c7be..746030453d46 100644 --- a/packages/affine/gfx/connector/src/connector-watcher.ts +++ b/packages/affine/gfx/connector/src/connector-watcher.ts @@ -3,10 +3,87 @@ import { type SurfaceMiddleware, surfaceMiddlewareExtension, } from '@blocksuite/affine-block-surface'; -import type { ConnectorElementModel } from '@blocksuite/affine-model'; +import { + type ConnectorElementModel, + ShapeElementModel, + ShapeType, +} from '@blocksuite/affine-model'; +import { Bound, Vec } from '@blocksuite/global/gfx'; +import type { IVec } from '@blocksuite/global/gfx'; import type { GfxModel } from '@blocksuite/std/gfx'; -import { ConnectorPathGenerator } from './connector-manager'; +import { ConnectorPathGenerator, getAnchors } from './connector-manager'; + +/** + * Re-anchor connector endpoints that are attached to the given polygon element. + * + * When polygon vertices are edited (moved, added, or deleted), the normalized + * bounding-box positions stored in `connector.source.position` / + * `connector.target.position` may no longer correspond to any vertex or edge + * midpoint of the updated polygon. This function projects each stored + * position into absolute model space, finds the nearest valid anchor on the + * updated polygon boundary (vertex or edge midpoint), and writes the new + * normalized coordinate back to the connector, persisting it in the CRDT. + * + * Must be called BEFORE `addToUpdateList` so that the path generator uses + * the freshly re-anchored positions when it runs in the next microtask. + */ +function reanchorConnectorsForPolygon( + surface: SurfaceBlockModel, + polygonId: string, + elementGetter: (id: string) => GfxModel | null +): void { + const element = elementGetter(polygonId); + if ( + !(element instanceof ShapeElementModel) || + element.shapeType !== ShapeType.Polygon + ) { + return; + } + + // Compute the polygon's current anchor points (vertices + edge midpoints). + const anchors = getAnchors(element); + if (anchors.length === 0) return; + + const bound = Bound.deserialize(element.xywh); + const connectors = surface.getConnectors(polygonId); + + for (const connector of connectors) { + for (const endpointType of ['source', 'target'] as const) { + const connection = connector[endpointType]; + + // Only process endpoints that are explicitly anchored to this polygon + // at a specific normalized position. + if (connection?.id !== polygonId) continue; + if (!connection.position) continue; + + // Convert the stored normalized [0-1] position to absolute model coords. + const [nx, ny] = connection.position; + const absPos: IVec = [ + bound.x + nx * bound.w, + bound.y + ny * bound.h, + ]; + + // Find the nearest anchor on the updated polygon boundary. + let nearestCoord: [number, number] = connection.position as [number, number]; + let minDist = Infinity; + for (const anchor of anchors) { + const d = Vec.dist(absPos, anchor.point as IVec); + if (d < minDist) { + minDist = d; + nearestCoord = anchor.coord as [number, number]; + } + } + + // Persist the new anchor position only if it actually changed. + const [oldNx, oldNy] = connection.position; + const [newNx, newNy] = nearestCoord; + if (Math.abs(oldNx - newNx) > 1e-6 || Math.abs(oldNy - newNy) > 1e-6) { + connector[endpointType] = { ...connection, position: nearestCoord }; + } + } + } +} export const connectorWatcher: SurfaceMiddleware = ( surface: SurfaceBlockModel @@ -55,7 +132,14 @@ export const connectorWatcher: SurfaceMiddleware = ( surface.elementUpdated.subscribe(({ id, props }) => { const element = elementGetter(id); - if (props['xywh'] || props['rotate']) { + if (props['vertices']) { + // When polygon vertices change, re-anchor connected connectors to the + // nearest valid boundary point BEFORE scheduling the path update, so + // the path generator uses the corrected anchor positions. + reanchorConnectorsForPolygon(surface, id, elementGetter); + } + + if (props['xywh'] || props['rotate'] || props['vertices']) { surface.getConnectors(id).forEach(addToUpdateList); } diff --git a/packages/affine/gfx/shape/package.json b/packages/affine/gfx/shape/package.json index 7beb5eb519ce..ebaac0adf24b 100644 --- a/packages/affine/gfx/shape/package.json +++ b/packages/affine/gfx/shape/package.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "@blocksuite/affine-block-surface": "workspace:*", + "@blocksuite/affine-gfx-connector": "workspace:*", "@blocksuite/affine-components": "workspace:*", "@blocksuite/affine-ext-loader": "workspace:*", "@blocksuite/affine-gfx-text": "workspace:*", diff --git a/packages/affine/gfx/shape/src/element-view.ts b/packages/affine/gfx/shape/src/element-view.ts index b9158c5ac2c0..7ca5421f469b 100644 --- a/packages/affine/gfx/shape/src/element-view.ts +++ b/packages/affine/gfx/shape/src/element-view.ts @@ -1,7 +1,15 @@ import { type SurfaceBlockComponent, + type SurfaceBlockModel, } from '@blocksuite/affine-block-surface'; -import { ShapeElementModel, ShapeType } from '@blocksuite/affine-model'; +import { ConnectorPathGenerator } from '@blocksuite/affine-gfx-connector'; +import { + type ConnectorElementModel, + ShapeElementModel, + ShapeType, +} from '@blocksuite/affine-model'; +import { Bound } from '@blocksuite/global/gfx'; +import type { GfxModel } from '@blocksuite/std/gfx'; import { GfxElementModelView, GfxViewInteractionExtension, @@ -41,21 +49,170 @@ export class ShapeElementView extends GfxElementModelView { } /** - * Override framework drag handlers to suppress default element movement - * when the user is dragging a polygon vertex in editing mode. + * Override framework drag handlers to wire both polygon-body drag-to-move + * and vertex drag through the same pipeline. + * + * Design rationale + * ──────────────── + * `GfxViewEventManager.dispatch('dragstart')` returns `true` whenever the + * view has *any* 'dragstart' listener registered – regardless of whether + * the listener actually does anything. When it returns `true`, DefaultTool + * sees `handledByView = true` and skips the default drag-to-move path. + * + * The old approach registered a 'dragstart' handler in _enterVertexEditingMode + * and removed it in _exitVertexEditingMode. That worked for the non-editing + * case but had a bug in editing mode: clicking on the polygon BODY (not a + * vertex) still had handledByView=true so the polygon could not be moved + * while vertex editing was active. + * + * The new approach: + * 1. A 'pointerdown' handler (always registered for polygons) records + * which vertex was pressed → `_pendingVertexIndex`. + * 2. NO 'dragstart' listener is ever registered on this view. + * ⇒ dispatch('dragstart') always returns false + * ⇒ handledByView is always false + * ⇒ DefaultTool always proceeds to call handleElementMove() + * ⇒ InteractivityManager eventually calls view.onDragStart/Move/End + * 3. Here in onDragStart we inspect _pendingVertexIndex and branch: + * • ≥ 0 AND overlay.isEditing → vertex drag (suppress super) + * • otherwise → element move (call super) + * + * This guarantees that: + * • Clicking anywhere inside the polygon body (PIP-hit via includesPoint) + * always initiates a normal element move – both in normal mode and in + * vertex-editing mode. + * • Clicking a vertex handle in editing mode initiates a vertex drag. */ override onDragStart(ctx: DragStartContext): void { - if (this._isDraggingVertex) return; // suppress default stash/move + // If in vertex editing mode and a vertex was pressed, start vertex drag. + if ( + this._vertexEditingOverlay?.isEditing && + this._pendingVertexIndex >= 0 + ) { + const verts = this.model.vertices; + if (verts && this._pendingVertexIndex < verts.length) { + this._isDraggingVertex = true; + this._vertexEditingOverlay.activeVertexIndex = this._pendingVertexIndex; + + // Stash so intermediate vertex moves do not flood the CRDT. + this.model.stash('xywh'); + this.model.stash('vertices'); + + // Record the vertex's absolute model position so that onDragMove can + // convert the framework's cumulative dx/dy back to an absolute + // target coordinate for moveVertex(). + const bound = Bound.deserialize(this.model.xywh); + const v = verts[this._pendingVertexIndex]; + this._vertexDragStartModelCoord = [ + bound.x + v[0] * bound.w, + bound.y + v[1] * bound.h, + ]; + + this._surfaceComponent?.refresh(); + return; // suppress default element-move stash + } + } + + // Default: let the framework move the whole element. + // This runs for: + // • Non-editing mode – click anywhere in polygon body (PIP tested) + // • Editing mode – click on body but not on a vertex handle super.onDragStart(ctx); } override onDragMove(ctx: DragMoveContext): void { - if (this._isDraggingVertex) return; // suppress default element move + if (this._isDraggingVertex) { + if (this._vertexEditingOverlay && this._vertexDragStartModelCoord) { + // Convert the cumulative delta provided by the framework into the + // absolute model-space target position for the vertex. + const targetX = this._vertexDragStartModelCoord[0] + ctx.dx; + const targetY = this._vertexDragStartModelCoord[1] + ctx.dy; + this._vertexEditingOverlay.moveVertex( + this._vertexEditingOverlay.activeVertexIndex, + targetX, + targetY + ); + + // Sub-AC 5b: Recalculate connector paths in real-time as polygon + // vertices are dragged. + // + // Two complementary mechanisms keep connectors in sync: + // + // 1. Implicit path (already in place): + // moveVertex() updates stashed `xywh` and `vertices` → the stash + // setter fires `surface.elementUpdated` synchronously → the + // connector-watcher reacts and schedules `queueMicrotask` → + // microtask runs before the next RAF → canvas renders with + // up-to-date connector paths. + // + // 2. Explicit path (added here for determinism): + // Directly call ConnectorPathGenerator.updatePath() for each + // connector attached to this polygon. This is synchronous, so + // connector paths are guaranteed to be current before refresh() + // even if the microtask scheduling is delayed for any reason. + // The connector-watcher's subsequent microtask is a harmless + // no-op because it will compute the same path. + this._syncConnectorPaths(); + + this._surfaceComponent?.refresh(); + } + return; + } + super.onDragMove(ctx); } + /** + * Synchronously recalculate and apply the path for every connector + * attached to this polygon shape. + * + * Called from `onDragMove` during polygon vertex drag so that connector + * routing stays visually correct on every drag frame (Sub-AC 5b). + * + * Uses `ConnectorPathGenerator.updatePath()` — the same function used by + * the connector-watcher middleware — so routing logic is consistent. + * + * The cast to `SurfaceBlockModel` (affine) is required because + * `GfxPrimitiveElementModel.surface` is typed as the framework base class + * which does not declare `getConnectors()`. At runtime this is always the + * affine SurfaceBlockModel that does have the method. + */ + private _syncConnectorPaths(): void { + // Cast to the affine SurfaceBlockModel which provides getConnectors(). + const surface = this.model.surface as unknown as SurfaceBlockModel; + const connectors = surface.getConnectors( + this.model.id + ) as ConnectorElementModel[]; + + if (connectors.length === 0) return; + + const elementGetter = (id: string): GfxModel | null => + (surface.getElementById(id) ?? + surface.store.getModelById(id)) as GfxModel | null; + + for (const connector of connectors) { + ConnectorPathGenerator.updatePath(connector, null, elementGetter); + } + } + override onDragEnd(ctx: DragEndContext): void { - if (this._isDraggingVertex) return; // suppress default pop + if (this._isDraggingVertex) { + this._isDraggingVertex = false; + this._vertexDragStartModelCoord = null; + this._pendingVertexIndex = -1; + + if (this._vertexEditingOverlay) { + this._vertexEditingOverlay.activeVertexIndex = -1; + this._vertexEditingOverlay.clearSnapGuides(); + } + + // Commit the final vertex / bounding-box state to the CRDT. + this.model.pop('xywh'); + this.model.pop('vertices'); + this._surfaceComponent?.refresh(); + return; + } + super.onDragEnd(ctx); } @@ -68,24 +225,41 @@ export class ShapeElementView extends GfxElementModelView { !this.model.isLocked() && this.model instanceof ShapeElementModel ) { - // For polygon shapes, double-click enters vertex editing mode - if ( - this.model.shapeType === ShapeType.Polygon && - this._vertexEditingOverlay - ) { - this._enterVertexEditingMode(); - return; + // All shapes (including polygon) open the text editor on double-click. + // Vertex editing for polygons is accessed via the toolbar "Edit vertices" + // button instead. + // + // Text-editing mode and vertex-editing mode are mutually exclusive: + // if vertex editing is active, exit it before opening the text editor. + if (this._vertexEditingOverlay?.isEditing) { + this._exitVertexEditingMode(); } - mountShapeTextEditor(this.model, edgeless); } }); } - /** Whether a vertex is currently being dragged (suppresses default drag). */ + /** + * Whether a vertex is currently being dragged. + * Set true in onDragStart when _pendingVertexIndex ≥ 0; reset in onDragEnd. + */ private _isDraggingVertex = false; - /** Disposer for the Escape key listener used in vertex editing mode. */ + /** + * The vertex index pressed on the last pointerdown (-1 = none). + * Examined in onDragStart to determine whether to enter vertex-drag mode. + * Reset on each pointerdown and when drag ends. + */ + private _pendingVertexIndex = -1; + + /** + * Absolute model-space position of the dragged vertex at drag start. + * Lets onDragMove convert the framework's cumulative (dx, dy) deltas into + * absolute target coordinates for moveVertex(). + */ + private _vertexDragStartModelCoord: [number, number] | null = null; + + /** Disposer for the Escape/Delete/B key listener used in vertex editing mode. */ private _escapeKeyDisposer: (() => void) | null = null; /** @@ -109,7 +283,11 @@ export class ShapeElementView extends GfxElementModelView { const keydownHandler = (evt: KeyboardEvent) => { if (evt.key === 'Escape') { evt.preventDefault(); - evt.stopPropagation(); + // Use stopImmediatePropagation to prevent ALL other document-level + // listeners (including the global edgeless Escape handler that would + // clear the entire selection) from receiving this event. We only + // want to exit vertex-editing mode while keeping the element selected. + evt.stopImmediatePropagation(); this._exitVertexEditingMode(); return; } @@ -153,13 +331,14 @@ export class ShapeElementView extends GfxElementModelView { evt.preventDefault(); evt.stopPropagation(); this.gfx.doc.captureSync(); - if (this.model.smoothFlags) { - this.model.stash('smoothFlags'); - } + // Always stash smoothFlags before toggling, even when it is currently + // null. toggleVertexSmooth() may transition smoothFlags from null to + // a boolean array; stash/pop must bracket the mutation symmetrically + // so that elementUpdated is emitted with props['smoothFlags'] in all + // cases (null → array, array → modified array). + this.model.stash('smoothFlags'); this._vertexEditingOverlay.toggleVertexSmooth(idx); - if (this.model.smoothFlags) { - this.model.pop('smoothFlags'); - } + this.model.pop('smoothFlags'); this._surfaceComponent?.refresh(); } return; @@ -171,6 +350,13 @@ export class ShapeElementView extends GfxElementModelView { capture: true, }); }; + + // Vertex drag is now handled entirely through onDragStart/Move/End via + // the _pendingVertexIndex set in the 'pointerdown' handler registered by + // _initPolygonVertexEditing(). No 'dragstart'/'dragmove'/'dragend' + // handlers are registered here so that dispatch('dragstart') always + // returns false, keeping handledByView=false and letting DefaultTool + // invoke handleElementMove → view.onDragStart/Move/End for all drags. } /** @@ -195,6 +381,11 @@ export class ShapeElementView extends GfxElementModelView { this._escapeKeyDisposer?.(); this._escapeKeyDisposer = null; + + // Reset any pending or active vertex drag state. + this._pendingVertexIndex = -1; + this._isDraggingVertex = false; + this._vertexDragStartModelCoord = null; } /** @@ -223,6 +414,28 @@ export class ShapeElementView extends GfxElementModelView { }) ); + // ── Vertex press tracking ───────────────────────────────────────────── + // Record which vertex (if any) the pointer pressed on. onDragStart reads + // _pendingVertexIndex to decide whether to enter vertex-drag mode. + // + // We do NOT use a 'dragstart' handler for this because registering one + // would cause GfxViewEventManager.dispatch('dragstart') to return true, + // making DefaultTool see handledByView=true and skip handleElementMove + // entirely – which would break body drag-to-move. Using 'pointerdown' + // avoids that: pointerdown dispatch does not affect the handledByView + // flag checked by DefaultTool.dragStart(). + this.on('pointerdown', (e) => { + if (!this._vertexEditingOverlay?.isEditing) { + // Outside editing mode no vertex drag is possible. + this._pendingVertexIndex = -1; + return; + } + + const [mx, my] = this.gfx.viewport.toModelCoord(e.x, e.y); + this._pendingVertexIndex = + this._vertexEditingOverlay.hitTestVertex(mx, my); + }); + // Listen for pointer move to update hover state on the overlay this.on('pointermove', (e) => { if (!this._vertexEditingOverlay) return; @@ -269,15 +482,17 @@ export class ShapeElementView extends GfxElementModelView { const midIdx = this._vertexEditingOverlay.hitTestMidpoint(mx, my); if (midIdx >= 0) { this.gfx.doc.captureSync(); + // Stash xywh alongside vertices so that elementUpdated includes + // props['xywh'] even though the bounding box doesn't change. + // This allows the connector-watcher (which checks props['xywh']) to + // react to vertex-count changes and re-anchor connected connectors. + this.model.stash('xywh'); this.model.stash('vertices'); - if (this.model.smoothFlags) { - this.model.stash('smoothFlags'); - } + this.model.stash('smoothFlags'); const newIdx = this._vertexEditingOverlay.insertVertexAtMidpoint(midIdx); + this.model.pop('xywh'); this.model.pop('vertices'); - if (this.model.smoothFlags) { - this.model.pop('smoothFlags'); - } + this.model.pop('smoothFlags'); if (newIdx >= 0) { this._vertexEditingOverlay.hoveredVertexIndex = newIdx; this._vertexEditingOverlay.hoveredMidpointIndex = -1; @@ -294,48 +509,6 @@ export class ShapeElementView extends GfxElementModelView { this._vertexEditingOverlay.cursorModelPos = null; this._surfaceComponent?.refresh(); }); - - // Vertex dragging: start drag on pointer down over a vertex - this.on('dragstart', (e) => { - if (!this._vertexEditingOverlay) return; - if (!this._vertexEditingOverlay.isEditing) return; - - const [mx, my] = this.gfx.viewport.toModelCoord(e.x, e.y); - const hitIdx = this._vertexEditingOverlay.hitTestVertex(mx, my); - - if (hitIdx >= 0) { - this._isDraggingVertex = true; - this._vertexEditingOverlay.activeVertexIndex = hitIdx; - this.model.stash('xywh'); - this.model.stash('vertices'); - this._surfaceComponent?.refresh(); - } - }); - - this.on('dragmove', (e) => { - if (!this._vertexEditingOverlay) return; - if (!this._isDraggingVertex) return; - - const [mx, my] = this.gfx.viewport.toModelCoord(e.x, e.y); - this._vertexEditingOverlay.moveVertex( - this._vertexEditingOverlay.activeVertexIndex, - mx, - my - ); - this._surfaceComponent?.refresh(); - }); - - this.on('dragend', () => { - if (!this._vertexEditingOverlay) return; - if (!this._isDraggingVertex) return; - - this._isDraggingVertex = false; - this._vertexEditingOverlay.activeVertexIndex = -1; - this._vertexEditingOverlay.clearSnapGuides(); - this.model.pop('xywh'); - this.model.pop('vertices'); - this._surfaceComponent?.refresh(); - }); } private _ensureVertexEditingOverlay(): void { @@ -355,6 +528,13 @@ export class ShapeElementView extends GfxElementModelView { this._vertexEditingOverlay.dispose(); this._surfaceComponent?.renderer.removeOverlay(this._vertexEditingOverlay); this._vertexEditingOverlay = null; + + // Reset vertex drag state in case the overlay is removed during an + // active drag (e.g. element deselected externally while dragging). + this._pendingVertexIndex = -1; + this._isDraggingVertex = false; + this._vertexDragStartModelCoord = null; + this._surfaceComponent?.renderer.refresh(); } @@ -362,6 +542,16 @@ export class ShapeElementView extends GfxElementModelView { get vertexEditingOverlay(): PolygonVertexEditingOverlay | null { return this._vertexEditingOverlay; } + + /** + * Public entry point for entering polygon vertex editing mode. + * Called by the "Edit vertices" toolbar button. + */ + enterVertexEditingMode(): void { + if (this.model.shapeType !== ShapeType.Polygon) return; + this._ensureVertexEditingOverlay(); + this._enterVertexEditingMode(); + } } export const ShapeViewInteraction = diff --git a/packages/affine/gfx/shape/src/toolbar/config.ts b/packages/affine/gfx/shape/src/toolbar/config.ts index 5a6a146c6a71..d6f2d9d067c5 100644 --- a/packages/affine/gfx/shape/src/toolbar/config.ts +++ b/packages/affine/gfx/shape/src/toolbar/config.ts @@ -35,12 +35,13 @@ import { renderMenu, } from '@blocksuite/affine-widget-edgeless-toolbar'; import { Bound } from '@blocksuite/global/gfx'; -import { AddTextIcon, ShapeIcon } from '@blocksuite/icons/lit'; +import { AddTextIcon, EditIcon, ShapeIcon } from '@blocksuite/icons/lit'; import { BlockFlavourIdentifier } from '@blocksuite/std'; import { html } from 'lit'; import isEqual from 'lodash-es/isEqual'; import { normalizeShapeBound } from '../element-renderer'; +import { ShapeElementView } from '../element-view'; import type { ShapeToolOption } from '../shape-tool'; import { mountShapeTextEditor } from '../text/edgeless-shape-text-editor'; import { ShapeComponentConfig } from './shape-menu-config'; @@ -276,6 +277,28 @@ export const shapeToolbarConfig = { mountShapeTextEditor(model, rootBlock); }, }, + { + id: 'f1.edit-vertices', + tooltip: 'Edit vertices', + icon: EditIcon(), + when(ctx) { + const models = ctx.getSurfaceModelsByType(ShapeElementModel); + return ( + models.length === 1 && + models[0].shapeType === ShapeType.Polygon && + !hasGrouped(models[0]) + ); + }, + run(ctx) { + const model = ctx.getCurrentModelByType(ShapeElementModel); + if (!model) return; + + const view = ctx.gfx.view.get(model.id); + if (view instanceof ShapeElementView) { + view.enterVertexEditingMode(); + } + }, + }, // id: `g.text` ...createTextActions(ShapeElementModel, 'shape', (ctx, model, props) => { // No need to adjust element bounds diff --git a/packages/affine/model/src/elements/shape/api/polygon.ts b/packages/affine/model/src/elements/shape/api/polygon.ts index 4008b4f53257..1eaca40a222e 100644 --- a/packages/affine/model/src/elements/shape/api/polygon.ts +++ b/packages/affine/model/src/elements/shape/api/polygon.ts @@ -1,7 +1,6 @@ import type { IBound, IVec } from '@blocksuite/global/gfx'; import { Bound, - getCenterAreaBounds, getPointsFromBoundWithRotation, linePolygonIntersects, pointInPolygon, @@ -12,8 +11,6 @@ import { rotatePoints, } from '@blocksuite/global/gfx'; import type { PointTestOptions } from '@blocksuite/std/gfx'; - -import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js'; import type { ShapeElementModel } from '../shape.js'; /** @@ -83,6 +80,33 @@ export const polygon = { ctx.restore(); }, + /** + * Determines whether the given model-space point `(x, y)` is considered + * a "hit" on this polygon shape. + * + * Hit-testing is performed in two passes: + * + * 1. **Stroke pass** – `pointOnPolygonStoke` checks whether the point lies + * within `hitThreshold / zoom` model units of any polygon edge. This + * enables selecting a polygon by clicking on its visible outline. + * + * 2. **Interior pass** – `pointInPolygon` uses the **winding-number + * algorithm** to decide whether the point falls inside the filled area. + * Unlike a bounding-box check, the winding-number algorithm correctly + * classifies points in the concave "notches" of non-convex polygons as + * outside, and all points inside the true polygon boundary as inside. + * This is what makes drag-to-move work from anywhere in the visible + * polygon body regardless of the shape's convexity. + * + * Rotation is handled transparently: `getPointsFromBoundWithRotation` + * returns the polygon's absolute vertex positions already transformed by + * the element's `rotate` angle, so both `x`/`y` and `points` are expressed + * in the same (model) coordinate space. + * + * Note: unlike `rect` / `ellipse` which skip the interior check for + * transparent/unfilled shapes, polygon always tests the interior — a + * freeform polygon is always interactable regardless of fill settings. + */ includesPoint( this: ShapeElementModel, x: number, @@ -91,8 +115,12 @@ export const polygon = { ) { const point: IVec = [x, y]; const pointsFn = getPolygonVertices(this); + // Apply rotation transformation so vertices are in the same model-space + // coordinate system as the incoming (x, y) hit-test point. const points = getPointsFromBoundWithRotation(this, pointsFn); + // Pass 1: stroke hit-test (scaled by zoom so the threshold is constant + // in screen pixels regardless of the current viewport zoom level). let hit = pointOnPolygonStoke( point, points, @@ -100,32 +128,11 @@ export const polygon = { ); if (!hit) { - if (!options.ignoreTransparent || this.filled) { - hit = pointInPolygon([x, y], points); - } else { - // If shape is not filled or transparent - const text = this.text; - if (!text || !text.length) { - // Check the center area of the shape - const centralBounds = getCenterAreaBounds( - this, - DEFAULT_CENTRAL_AREA_RATIO - ); - const centralPoints = getPointsFromBoundWithRotation( - centralBounds, - pointsFn - ); - hit = pointInPolygon([x, y], centralPoints); - } else if (this.textBound) { - hit = pointInPolygon( - point, - getPointsFromBoundWithRotation( - this, - () => Bound.from(this.textBound!).points - ) - ); - } - } + // Pass 2: winding-number interior test. This replaces any naïve + // axis-aligned bounding-box containment check and correctly handles + // concave polygons — a point inside the bounding box but inside a + // concave notch is correctly reported as NOT a hit. + hit = pointInPolygon([x, y], points); } return hit; diff --git a/packages/framework/global/src/gfx/math.ts b/packages/framework/global/src/gfx/math.ts index b63e4bd56216..5640e8c5e04e 100644 --- a/packages/framework/global/src/gfx/math.ts +++ b/packages/framework/global/src/gfx/math.ts @@ -340,16 +340,38 @@ export function pointInEllipse( return (tdx * tdx) / (rx * rx) + (tdy * tdy) / (ry * ry) <= 1; } +/** + * Tests whether a point `p` is inside a polygon defined by `points` using the + * **winding-number algorithm**. + * + * The winding number counts how many times the polygon boundary winds around + * the test point. A non-zero winding number means the point is inside. + * + * Key advantages over a simple bounding-box (AABB) check or ray-casting: + * - **Concave polygons**: correctly reports points inside a concave "notch" as + * outside, and all points in the true interior as inside. + * - **No degenerate ray issues**: the winding-number approach handles + * horizontal edges and vertices that lie on the test ray without special + * casing. + * - Used by `polygon.includesPoint` to enable accurate drag-to-move + * hit-testing that works anywhere inside the visible polygon body. + * + * @param p - The point to test [x, y]. + * @param points - Ordered array of polygon vertex positions [x, y][]. + * @returns `true` if `p` is strictly inside the polygon, `false` otherwise. + */ export function pointInPolygon(p: IVec, points: IVec[]): boolean { let wn = 0; // winding number points.forEach((a, i) => { const b = points[(i + 1) % points.length]; if (a[1] <= p[1]) { + // Upward crossing: edge goes from below/on to above the ray if (b[1] > p[1] && Vec.cross(a, b, p) > 0) { wn += 1; } } else if (b[1] <= p[1] && Vec.cross(a, b, p) < 0) { + // Downward crossing: edge goes from above to below/on the ray wn -= 1; } }); diff --git a/packages/framework/store/src/__tests__/polygon-vertex-ops.unit.spec.ts b/packages/framework/store/src/__tests__/polygon-vertex-ops.unit.spec.ts new file mode 100644 index 000000000000..8a1165b31d43 --- /dev/null +++ b/packages/framework/store/src/__tests__/polygon-vertex-ops.unit.spec.ts @@ -0,0 +1,785 @@ +/** + * Unit tests for polygon vertex operation logic. + * + * Tests the core algorithms used by PolygonVertexEditingOverlay: + * - insertVertexAtMidpoint + * - deleteVertex (with bounding box recomputation) + * - toggleVertexSmooth (null → array, toggle) + * - moveVertex (with bounding box recomputation) + * - proportional resize (vertices remain normalized) + * - hitTestVertex / hitTestMidpoint (proximity detection) + * + * These algorithms are extracted as pure functions so they can be tested + * without a live GfxController or BlockSuite editor instance. + */ + +import { describe, expect, test } from 'vitest'; + +// ─── Pure algorithm implementations ────────────────────────────────────────── +// These mirror the implementations in PolygonVertexEditingOverlay exactly. +// They are copied here as pure functions to allow deterministic unit testing +// without a DOM or GfxController dependency. + +type Vertex = [number, number]; + +/** + * Convert a normalized [0-1] vertex to absolute model coordinates. + */ +function toAbsolute( + nv: number[], + bound: { x: number; y: number; w: number; h: number } +): Vertex { + return [bound.x + nv[0] * bound.w, bound.y + nv[1] * bound.h]; +} + +/** + * Recompute the bounding box from a list of absolute vertices and + * return normalized vertices plus the new bound. + */ +function recomputeBound(absVertices: Vertex[]): { + normalized: number[][]; + bound: { x: number; y: number; w: number; h: number }; +} { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const [vx, vy] of absVertices) { + if (vx < minX) minX = vx; + if (vy < minY) minY = vy; + if (vx > maxX) maxX = vx; + if (vy > maxY) maxY = vy; + } + const w = Math.max(maxX - minX, 1); + const h = Math.max(maxY - minY, 1); + const normalized = absVertices.map(([vx, vy]) => [ + (vx - minX) / w, + (vy - minY) / h, + ]); + return { normalized, bound: { x: minX, y: minY, w, h } }; +} + +/** + * Insert a new vertex at the midpoint of the edge between edgeIndex and + * edgeIndex+1. Returns [newVertices, newSmoothFlags, insertedIndex]. + */ +function insertVertexAtMidpoint( + vertices: number[][], + smoothFlags: boolean[] | null, + edgeIndex: number +): { vertices: number[][]; smoothFlags: boolean[] | null; insertedIndex: number } { + const verts = [...vertices]; + const nextIdx = (edgeIndex + 1) % verts.length; + const midNorm = [ + (verts[edgeIndex][0] + verts[nextIdx][0]) / 2, + (verts[edgeIndex][1] + verts[nextIdx][1]) / 2, + ]; + const insertIdx = edgeIndex + 1; + verts.splice(insertIdx, 0, midNorm); + + let newFlags: boolean[] | null = smoothFlags; + if (smoothFlags) { + const flags = [...smoothFlags]; + flags.splice(insertIdx, 0, false); + newFlags = flags; + } + + return { vertices: verts, smoothFlags: newFlags, insertedIndex: insertIdx }; +} + +/** + * Delete the vertex at `vertexIndex`. + * Returns null if there are <= 3 vertices (minimum polygon). + * On success returns { vertices, smoothFlags, bound }. + */ +function deleteVertex( + vertices: number[][], + smoothFlags: boolean[] | null, + bound: { x: number; y: number; w: number; h: number }, + vertexIndex: number +): { + vertices: number[][]; + smoothFlags: boolean[] | null; + bound: { x: number; y: number; w: number; h: number }; +} | null { + if (vertices.length <= 3) return null; // minimum triangle + + const verts = [...vertices]; + verts.splice(vertexIndex, 1); + + let newFlags: boolean[] | null = smoothFlags; + if (smoothFlags) { + const flags = [...smoothFlags]; + flags.splice(vertexIndex, 1); + newFlags = flags; + } + + // Re-compute bounding box from remaining vertices + const absVertices = verts.map(v => toAbsolute(v, bound)); + const { normalized, bound: newBound } = recomputeBound(absVertices); + + return { vertices: normalized, smoothFlags: newFlags, bound: newBound }; +} + +/** + * Toggle the smooth flag for `vertexIndex`. + */ +function toggleVertexSmooth( + vertices: number[][], + smoothFlags: boolean[] | null, + vertexIndex: number +): boolean[] { + const count = vertices.length; + let flags = smoothFlags ? [...smoothFlags] : new Array(count).fill(false); + while (flags.length < count) flags.push(false); + if (flags.length > count) flags = flags.slice(0, count); + flags[vertexIndex] = !flags[vertexIndex]; + return flags; +} + +/** + * Move a vertex to a new absolute position and recompute the bounding box. + */ +function moveVertex( + vertices: number[][], + bound: { x: number; y: number; w: number; h: number }, + vertexIndex: number, + newAbsX: number, + newAbsY: number +): { + vertices: number[][]; + bound: { x: number; y: number; w: number; h: number }; +} { + const newAbsVertices: Vertex[] = vertices.map( + (v, i) => + i === vertexIndex + ? ([newAbsX, newAbsY] as Vertex) + : (toAbsolute(v, bound) as Vertex) + ); + const { normalized, bound: newBound } = recomputeBound(newAbsVertices); + return { vertices: normalized, bound: newBound }; +} + +/** + * Check if a model-coordinate point is within `hitDist` of any vertex. + * Returns the matching vertex index or -1. + */ +function hitTestVertex( + vertices: number[][], + bound: { x: number; y: number; w: number; h: number }, + modelX: number, + modelY: number, + hitDist: number +): number { + for (let i = 0; i < vertices.length; i++) { + const [ax, ay] = toAbsolute(vertices[i], bound); + const dx = modelX - ax; + const dy = modelY - ay; + if (Math.sqrt(dx * dx + dy * dy) < hitDist) { + return i; + } + } + return -1; +} + +/** + * Check if a model-coordinate point is within `hitDist` of any edge midpoint. + * Returns the edge index (between vertex i and vertex i+1) or -1. + */ +function hitTestMidpoint( + vertices: number[][], + bound: { x: number; y: number; w: number; h: number }, + modelX: number, + modelY: number, + hitDist: number +): number { + for (let i = 0; i < vertices.length; i++) { + const [ax, ay] = toAbsolute(vertices[i], bound); + const nextIdx = (i + 1) % vertices.length; + const [bx, by] = toAbsolute(vertices[nextIdx], bound); + const mx = (ax + bx) / 2; + const my = (ay + by) / 2; + const dx = modelX - mx; + const dy = modelY - my; + if (Math.sqrt(dx * dx + dy * dy) < hitDist) { + return i; + } + } + return -1; +} + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +/** Pentagon (5 vertices) as normalized coords for a 100×100 bounding box. */ +const PENTAGON = [ + [0.5, 0], + [1, 0.38], + [0.81, 1], + [0.19, 1], + [0, 0.38], +]; + +/** Triangle (3 vertices, minimum polygon). */ +const TRIANGLE = [ + [0.5, 0], + [1, 1], + [0, 1], +]; + +/** Square (4 vertices, simple polygon). */ +const SQUARE = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], +]; + +const UNIT_BOUND = { x: 0, y: 0, w: 100, h: 100 }; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('polygon vertex add (insertVertexAtMidpoint)', () => { + test('inserts a vertex at the midpoint of an edge', () => { + const { vertices, insertedIndex } = insertVertexAtMidpoint( + SQUARE, + null, + 0 // edge between vertex 0 [0,0] and vertex 1 [1,0] + ); + // New vertex should be at the midpoint of [0,0] and [1,0] → [0.5, 0] + expect(insertedIndex).toBe(1); + expect(vertices[insertedIndex]).toEqual([0.5, 0]); + expect(vertices.length).toBe(5); + // Other vertices should remain at their original normalized positions + expect(vertices[0]).toEqual([0, 0]); + expect(vertices[2]).toEqual([1, 0]); + expect(vertices[3]).toEqual([1, 1]); + expect(vertices[4]).toEqual([0, 1]); + }); + + test('handles insertion on the last edge (wraps around)', () => { + const lastEdgeIdx = SQUARE.length - 1; // edge between vertex 3 [0,1] and vertex 0 [0,0] + const { vertices, insertedIndex } = insertVertexAtMidpoint( + SQUARE, + null, + lastEdgeIdx + ); + // New vertex between [0,1] and [0,0] → midpoint [0, 0.5] + expect(insertedIndex).toBe(lastEdgeIdx + 1); + expect(vertices[insertedIndex]).toEqual([0, 0.5]); + expect(vertices.length).toBe(5); + }); + + test('inserts false into smoothFlags at the correct index', () => { + const smoothFlags = [true, false, true, false]; // SQUARE has 4 vertices + const { vertices, smoothFlags: newFlags, insertedIndex } = + insertVertexAtMidpoint(SQUARE, smoothFlags, 1); + expect(newFlags).not.toBeNull(); + expect(newFlags!.length).toBe(5); + expect(newFlags![insertedIndex]).toBe(false); // new vertex is always sharp + expect(newFlags![0]).toBe(true); // vertex 0 unchanged + expect(newFlags![1]).toBe(false); // vertex 1 unchanged (was at index 1, now at index 1) + }); + + test('leaves smoothFlags as null when no smoothFlags were set', () => { + const { smoothFlags: newFlags } = insertVertexAtMidpoint(SQUARE, null, 0); + expect(newFlags).toBeNull(); + }); + + test('midpoint is correct for a diagonal edge', () => { + // Triangle: [0.5,0], [1,1], [0,1] + // Edge 0-1: midpoint = ([0.5+1]/2, [0+1]/2) = [0.75, 0.5] + const { vertices, insertedIndex } = insertVertexAtMidpoint(TRIANGLE, null, 0); + expect(insertedIndex).toBe(1); + expect(vertices[insertedIndex][0]).toBeCloseTo(0.75, 10); + expect(vertices[insertedIndex][1]).toBeCloseTo(0.5, 10); + expect(vertices.length).toBe(4); + }); + + test('preserves all other vertices after insertion', () => { + // Insert at edge 2 (between vertex 2 [0.81,1] and vertex 3 [0.19,1]) + // insertIdx = 3 + // New vertex at midpoint: [0.5, 1] + // Result: [0.5,0], [1,0.38], [0.81,1], [0.5,1], [0.19,1], [0,0.38] + const { vertices, insertedIndex } = insertVertexAtMidpoint(PENTAGON, null, 2); + expect(vertices.length).toBe(6); + expect(insertedIndex).toBe(3); + + // Vertices before the insertion point should be unchanged + for (let i = 0; i < insertedIndex; i++) { + expect(vertices[i]).toEqual(PENTAGON[i]); + } + // The inserted vertex is a midpoint — already tested separately + // Vertices after the insertion point (shifted by 1) + for (let i = insertedIndex + 1; i < vertices.length; i++) { + expect(vertices[i]).toEqual(PENTAGON[i - 1]); + } + }); +}); + +describe('polygon vertex delete (deleteVertex)', () => { + test('refuses to delete when only 3 vertices remain (minimum triangle)', () => { + const result = deleteVertex(TRIANGLE, null, UNIT_BOUND, 0); + expect(result).toBeNull(); + }); + + test('deletes a vertex from a 4-vertex polygon successfully', () => { + const result = deleteVertex(SQUARE, null, UNIT_BOUND, 0); + expect(result).not.toBeNull(); + expect(result!.vertices.length).toBe(3); + }); + + test('deletes a vertex from a 5-vertex polygon successfully', () => { + const result = deleteVertex(PENTAGON, null, UNIT_BOUND, 1); + expect(result).not.toBeNull(); + expect(result!.vertices.length).toBe(4); + }); + + test('removes the correct vertex by index', () => { + // Square with vertices: [0,0], [1,0], [1,1], [0,1] + // Delete vertex 1 ([1,0]) → remaining: [0,0], [1,1], [0,1] + const result = deleteVertex(SQUARE, null, UNIT_BOUND, 1)!; + // All absolute positions should be: [0,0], [100,100], [0,100] + // New bounding box: x=0, y=0, w=100, h=100 + // Normalized: [0,0], [1,1], [0,1] → same as SQUARE minus vertex 1 + expect(result.vertices).toEqual([ + [0, 0], + [1, 1], + [0, 1], + ]); + }); + + test('recomputes bounding box after deletion', () => { + // Polygon at [0,0,100,100], vertices: [0,0], [1,0], [1,1], [0,1] + // Delete vertex at [0,0] (vertex 0) + // Remaining abs: [100,0], [100,100], [0,100] + // New bound: x=0, y=0, w=100, h=100 (unchanged in this case since 0 is at extreme) + const result = deleteVertex(SQUARE, null, UNIT_BOUND, 0)!; + expect(result.bound.x).toBe(0); + expect(result.bound.y).toBe(0); + expect(result.bound.w).toBe(100); + expect(result.bound.h).toBe(100); + }); + + test('recomputes bounding box correctly when deleted vertex was at the edge', () => { + // Polygon at [0,0,200,200]: [0,0], [1,0], [0.5,1] (triangle) + // Let's use a 4-vertex polygon where vertex 0 is at the extremes + // Polygon: [[0,0],[0.5,0],[1,0.5],[0.5,1]] at bound x=0,y=0,w=200,h=200 + // Abs positions: [0,0],[100,0],[200,100],[100,200] + // Delete vertex 2 ([200,100]): remaining abs = [0,0],[100,0],[100,200] + // New bound: x=0,y=0,w=100,h=200 + // Normalized: [0,0],[1,0],[1,1] + const verts = [ + [0, 0], + [0.5, 0], + [1, 0.5], + [0.5, 1], + ]; + const b = { x: 0, y: 0, w: 200, h: 200 }; + const result = deleteVertex(verts, null, b, 2)!; + expect(result.bound.w).toBeCloseTo(100, 5); + expect(result.bound.h).toBeCloseTo(200, 5); + expect(result.vertices.length).toBe(3); + }); + + test('removes corresponding smoothFlag when deleting a vertex', () => { + const flags = [true, false, true, false]; // square + const result = deleteVertex(SQUARE, flags, UNIT_BOUND, 1)!; // delete vertex 1 + expect(result.smoothFlags!.length).toBe(3); + // After deleting index 1 (false), remaining: [true, true, false] + expect(result.smoothFlags).toEqual([true, true, false]); + }); + + test('leaves smoothFlags as null when no smoothFlags were set', () => { + const result = deleteVertex(SQUARE, null, UNIT_BOUND, 0)!; + expect(result.smoothFlags).toBeNull(); + }); + + test('vertices remain normalized [0-1] after deletion', () => { + const result = deleteVertex(PENTAGON, null, UNIT_BOUND, 2)!; + for (const [vx, vy] of result.vertices) { + expect(vx).toBeGreaterThanOrEqual(0); + expect(vx).toBeLessThanOrEqual(1); + expect(vy).toBeGreaterThanOrEqual(0); + expect(vy).toBeLessThanOrEqual(1); + } + // At least one vertex at x=0, one at x=1 (by normalized definition) + const xs = result.vertices.map(v => v[0]); + const ys = result.vertices.map(v => v[1]); + expect(Math.min(...xs)).toBeCloseTo(0, 10); + expect(Math.max(...xs)).toBeCloseTo(1, 10); + expect(Math.min(...ys)).toBeCloseTo(0, 10); + expect(Math.max(...ys)).toBeCloseTo(1, 10); + }); +}); + +describe('polygon Bezier toggle (toggleVertexSmooth)', () => { + test('creates smoothFlags array from null when toggling a vertex', () => { + const flags = toggleVertexSmooth(SQUARE, null, 0); + expect(flags.length).toBe(4); + expect(flags[0]).toBe(true); // toggled from false + expect(flags[1]).toBe(false); + expect(flags[2]).toBe(false); + expect(flags[3]).toBe(false); + }); + + test('toggles an existing smooth flag from false to true', () => { + const initial = [false, false, false, false]; + const flags = toggleVertexSmooth(SQUARE, initial, 2); + expect(flags[2]).toBe(true); + expect(flags[0]).toBe(false); + expect(flags[1]).toBe(false); + expect(flags[3]).toBe(false); + }); + + test('toggles an existing smooth flag from true to false', () => { + const initial = [true, false, true, false]; + const flags = toggleVertexSmooth(SQUARE, initial, 0); + expect(flags[0]).toBe(false); + expect(flags[2]).toBe(true); // unchanged + }); + + test('preserves all other flags when toggling one', () => { + const initial = [true, true, true, true]; + const flags = toggleVertexSmooth(SQUARE, initial, 3); + expect(flags[0]).toBe(true); + expect(flags[1]).toBe(true); + expect(flags[2]).toBe(true); + expect(flags[3]).toBe(false); // toggled + }); + + test('handles short smoothFlags array by padding with false', () => { + // If smoothFlags is shorter than vertex count, pad with false + const short = [true]; // only 1 flag for a 4-vertex polygon + const flags = toggleVertexSmooth(SQUARE, short, 3); + expect(flags.length).toBe(4); + expect(flags[0]).toBe(true); + expect(flags[1]).toBe(false); // padded + expect(flags[2]).toBe(false); // padded + expect(flags[3]).toBe(true); // toggled from padded false + }); + + test('toggling all vertices individually covers full roundtrip', () => { + let flags: boolean[] | null = null; + // Toggle each vertex to true + for (let i = 0; i < PENTAGON.length; i++) { + flags = toggleVertexSmooth(PENTAGON, flags, i); + } + expect(flags!.every(f => f)).toBe(true); // all true + + // Toggle each vertex to false + for (let i = 0; i < PENTAGON.length; i++) { + flags = toggleVertexSmooth(PENTAGON, flags, i); + } + expect(flags!.every(f => !f)).toBe(true); // all false + }); +}); + +describe('polygon vertex move (moveVertex)', () => { + test('moves a vertex to a new absolute position', () => { + // Square at [0,0,100,100], move vertex 0 from (0,0) to (50,50) + const { vertices, bound } = moveVertex(SQUARE, UNIT_BOUND, 0, 50, 50); + // Old vertex 0 was at abs (0,0); new abs = (50,50) + // Remaining abs: (100,0),(100,100),(0,100) + // All abs: (50,50),(100,0),(100,100),(0,100) + // minX=0,maxX=100,minY=0,maxY=100 → bound unchanged + expect(bound.w).toBe(100); + expect(bound.h).toBe(100); + expect(vertices.length).toBe(4); + // Vertex 0 normalized: (50-0)/100=0.5, (50-0)/100=0.5 + expect(vertices[0][0]).toBeCloseTo(0.5, 10); + expect(vertices[0][1]).toBeCloseTo(0.5, 10); + }); + + test('recomputes bounding box when moved vertex extends the polygon', () => { + // Square at [0,0,100,100], move vertex 0 from (0,0) to (-50, -50) + // New abs: (-50,-50),(100,0),(100,100),(0,100) + // New bound: x=-50,y=-50,w=150,h=150 + const { vertices, bound } = moveVertex(SQUARE, UNIT_BOUND, 0, -50, -50); + expect(bound.x).toBeCloseTo(-50, 10); + expect(bound.y).toBeCloseTo(-50, 10); + expect(bound.w).toBeCloseTo(150, 10); + expect(bound.h).toBeCloseTo(150, 10); + // Vertex 0 normalized: (-50-(-50))/150=0, (-50-(-50))/150=0 + expect(vertices[0][0]).toBeCloseTo(0, 10); + expect(vertices[0][1]).toBeCloseTo(0, 10); + }); + + test('vertices remain normalized [0-1] after move', () => { + const { vertices } = moveVertex(PENTAGON, UNIT_BOUND, 0, 25, 25); + for (const [vx, vy] of vertices) { + expect(vx).toBeGreaterThanOrEqual(0); + expect(vx).toBeLessThanOrEqual(1); + expect(vy).toBeGreaterThanOrEqual(0); + expect(vy).toBeLessThanOrEqual(1); + } + const xs = vertices.map(v => v[0]); + const ys = vertices.map(v => v[1]); + expect(Math.min(...xs)).toBeCloseTo(0, 10); + expect(Math.max(...xs)).toBeCloseTo(1, 10); + expect(Math.min(...ys)).toBeCloseTo(0, 10); + expect(Math.max(...ys)).toBeCloseTo(1, 10); + }); + + test('moves all vertices correctly for a drag sequence', () => { + // Simulate dragging vertex 0 of a triangle through several positions + let verts = [...TRIANGLE]; + let b = { ...UNIT_BOUND }; + + const positions: [number, number][] = [ + [30, 10], + [20, 5], + [25, 0], + ]; + for (const [absX, absY] of positions) { + const result = moveVertex(verts, b, 0, absX, absY); + verts = result.vertices; + b = result.bound; + } + // Final state: verify vertices are still normalized + for (const [vx, vy] of verts) { + expect(vx).toBeGreaterThanOrEqual(0); + expect(vx).toBeLessThanOrEqual(1); + expect(vy).toBeGreaterThanOrEqual(0); + expect(vy).toBeLessThanOrEqual(1); + } + }); +}); + +describe('proportional resize (vertices remain normalized)', () => { + /** + * Proportional resize works because vertices are stored as normalized [0-1] + * coordinates relative to the bounding box. When the bounding box (xywh) + * changes, the absolute positions scale automatically via: + * abs = (norm[0] * newW + newX, norm[1] * newH + newY) + * + * This means polygon resize is non-destructive: the vertex array never + * changes during resize. This suite verifies that property. + */ + + test('normalizing absolute vertices into a bound and back is lossless', () => { + // Create a polygon with known absolute positions + const absPositions: Vertex[] = [ + [100, 50], + [200, 50], + [250, 150], + [200, 250], + [100, 250], + [50, 150], + ]; + const b = { x: 50, y: 50, w: 200, h: 200 }; + const normalized = absPositions.map(([vx, vy]) => [ + (vx - b.x) / b.w, + (vy - b.y) / b.h, + ]); + + // Denormalize back into the original bound + const restored = normalized.map(([nx, ny]) => [ + b.x + nx * b.w, + b.y + ny * b.h, + ]); + + for (let i = 0; i < absPositions.length; i++) { + expect(restored[i][0]).toBeCloseTo(absPositions[i][0], 10); + expect(restored[i][1]).toBeCloseTo(absPositions[i][1], 10); + } + }); + + test('polygon shape scales proportionally when bound changes', () => { + // Start with a square polygon at 100×100 + const normalizedSquare = SQUARE; + const bound1 = UNIT_BOUND; + + // Resize to 200×200 + const bound2 = { x: 0, y: 0, w: 200, h: 200 }; + const abs1 = normalizedSquare.map(v => toAbsolute(v, bound1)); + const abs2 = normalizedSquare.map(v => toAbsolute(v, bound2)); + + // Each absolute coordinate should be exactly doubled + for (let i = 0; i < abs1.length; i++) { + expect(abs2[i][0]).toBeCloseTo(abs1[i][0] * 2, 10); + expect(abs2[i][1]).toBeCloseTo(abs1[i][1] * 2, 10); + } + }); + + test('normalized vertices are unchanged after a resize operation', () => { + // Simulate what happens during resize: only xywh changes, vertices stay + const originalVertices = [...PENTAGON]; + + // "Resize" by updating the bound only + const _newBound = { x: 50, y: 50, w: 300, h: 150 }; + + // The vertex array should be identical (resize doesn't touch vertices) + expect(PENTAGON).toEqual(originalVertices); + }); + + test('non-uniform resize changes aspect ratio correctly', () => { + // Pentagon at 100×100 → resized to 200×50 (wider, shorter) + const bound1 = UNIT_BOUND; + const bound2 = { x: 0, y: 0, w: 200, h: 50 }; + + const abs1 = PENTAGON.map(v => toAbsolute(v, bound1)); + const abs2 = PENTAGON.map(v => toAbsolute(v, bound2)); + + for (let i = 0; i < PENTAGON.length; i++) { + // X coords scale by factor 2 (w: 100 → 200) + expect(abs2[i][0]).toBeCloseTo(abs1[i][0] * 2, 10); + // Y coords scale by factor 0.5 (h: 100 → 50) + expect(abs2[i][1]).toBeCloseTo(abs1[i][1] * 0.5, 10); + } + }); +}); + +describe('hitTestVertex', () => { + test('returns vertex index when cursor is within hit distance', () => { + // Square at [0,0,100,100], vertex 0 at abs (0,0) + const idx = hitTestVertex(SQUARE, UNIT_BOUND, 2, 2, 10); + expect(idx).toBe(0); + }); + + test('returns -1 when cursor is outside hit distance of all vertices', () => { + const idx = hitTestVertex(SQUARE, UNIT_BOUND, 50, 50, 10); + expect(idx).toBe(-1); + }); + + test('returns the closest vertex when multiple are nearby', () => { + // Two vertices at (0,0) and (10,0), cursor at (3,0), hitDist=5 + const verts = [ + [0, 0], + [0.1, 0], + [1, 1], + ]; + const idx = hitTestVertex(verts, UNIT_BOUND, 3, 0, 5); + expect(idx).toBe(0); // vertex 0 at (0,0) is 3 units away, within hitDist=5 + }); + + test('hit test is exact at boundary distance', () => { + // Vertex 0 is at abs (0,0), cursor at (9.99, 0), hitDist=10 + const idx = hitTestVertex(SQUARE, UNIT_BOUND, 9.99, 0, 10); + expect(idx).toBe(0); + + // Cursor at (10.01, 0) — just outside hit distance + const miss = hitTestVertex(SQUARE, UNIT_BOUND, 10.01, 0, 10); + expect(miss).toBe(-1); + }); +}); + +describe('hitTestMidpoint', () => { + test('returns edge index when cursor is near a midpoint', () => { + // Square: edge 0 is between [0,0] and [100,0], midpoint at (50,0) + const idx = hitTestMidpoint(SQUARE, UNIT_BOUND, 50, 2, 10); + expect(idx).toBe(0); // edge between vertex 0 and vertex 1 + }); + + test('returns -1 when cursor is not near any midpoint', () => { + const idx = hitTestMidpoint(SQUARE, UNIT_BOUND, 50, 50, 10); + expect(idx).toBe(-1); + }); + + test('handles last edge (wraps around to first vertex)', () => { + // Square: edge 3 is between [0,100] and [0,0], midpoint at (0,50) + const idx = hitTestMidpoint(SQUARE, UNIT_BOUND, 2, 50, 10); + expect(idx).toBe(3); // last edge + }); + + test('returns first matching edge in iteration order', () => { + // Pentagon has 5 edges; cursor near edge 2 midpoint + // Edge 2: between vertex 2 [81,100] and vertex 3 [19,100], midpoint [50,100] + const idx = hitTestMidpoint(PENTAGON, UNIT_BOUND, 50, 100, 10); + expect(idx).toBe(2); + }); +}); + +describe('edge cases and invariants', () => { + test('inserting a vertex preserves the polygon closure (all in [0,1])', () => { + for (let edge = 0; edge < PENTAGON.length; edge++) { + const { vertices } = insertVertexAtMidpoint(PENTAGON, null, edge); + for (const [vx, vy] of vertices) { + expect(vx).toBeGreaterThanOrEqual(0 - 1e-10); + expect(vx).toBeLessThanOrEqual(1 + 1e-10); + expect(vy).toBeGreaterThanOrEqual(0 - 1e-10); + expect(vy).toBeLessThanOrEqual(1 + 1e-10); + } + } + }); + + test('delete followed by insert gives same vertex count', () => { + // Delete from 5-vertex polygon → 4 vertices + const afterDelete = deleteVertex(PENTAGON, null, UNIT_BOUND, 0)!; + expect(afterDelete.vertices.length).toBe(4); + + // Insert at edge 0 → 5 vertices + const { vertices: afterInsert } = insertVertexAtMidpoint( + afterDelete.vertices, + null, + 0 + ); + expect(afterInsert.length).toBe(5); + }); + + test('toggle smooth twice returns to original state (roundtrip)', () => { + const flags1 = toggleVertexSmooth(SQUARE, null, 1); + expect(flags1[1]).toBe(true); + + const flags2 = toggleVertexSmooth(SQUARE, flags1, 1); + expect(flags2[1]).toBe(false); + }); + + test('minimum bounding box size is 1 (guard against degenerate polygon)', () => { + // Move all vertices to the same point → bound collapses + // The moveVertex function has w = Math.max(maxX - minX, 1) + // Test recomputeBound with all vertices at the same point + const allSame: Vertex[] = [[50, 50], [50, 50], [50, 50]]; + const { bound, normalized } = recomputeBound(allSame); + expect(bound.w).toBeGreaterThanOrEqual(1); + expect(bound.h).toBeGreaterThanOrEqual(1); + expect(normalized.length).toBe(3); + }); + + test('smoothFlags length always matches vertex count after operations', () => { + let verts = [...PENTAGON]; + let flags: boolean[] | null = null; + const b = { ...UNIT_BOUND }; + + // Toggle a few vertices + flags = toggleVertexSmooth(verts, flags, 0); + flags = toggleVertexSmooth(verts, flags, 2); + expect(flags.length).toBe(verts.length); + + // Insert a vertex + const { vertices: v2, smoothFlags: f2 } = insertVertexAtMidpoint(verts, flags, 1); + verts = v2; + flags = f2; + expect(flags!.length).toBe(verts.length); + + // Delete a vertex + const result = deleteVertex(verts, flags, b, 0); + expect(result).not.toBeNull(); + expect(result!.smoothFlags!.length).toBe(result!.vertices.length); + }); + + test('consecutive deletes reduce vertex count correctly', () => { + // Start from pentagon (5 vertices), delete down to triangle (3) + let verts = [...PENTAGON]; + let flags: boolean[] | null = null; + let b = { ...UNIT_BOUND }; + + // Delete vertex 0: 5 → 4 + let result = deleteVertex(verts, flags, b, 0)!; + verts = result.vertices; + flags = result.smoothFlags; + b = result.bound; + expect(verts.length).toBe(4); + + // Delete vertex 0 again: 4 → 3 + result = deleteVertex(verts, flags, b, 0)!; + verts = result.vertices; + flags = result.smoothFlags; + b = result.bound; + expect(verts.length).toBe(3); + + // Attempting to delete when only 3 remain should fail + const blocked = deleteVertex(verts, flags, b, 0); + expect(blocked).toBeNull(); + expect(verts.length).toBe(3); // unchanged + }); +}); From 9f386324847b4f3e7996048a2e9258dd6dca91fd Mon Sep 17 00:00:00 2001 From: Mathieu Jolly Date: Fri, 20 Mar 2026 12:05:58 +0100 Subject: [PATCH 03/10] fix(edgeless): curve connector arrow position fix on polygone --- .claude/settings.local.json | 7 ++- .gitignore | 1 + .../gfx/connector/src/connector-manager.ts | 46 ++++++------------- .../model/src/elements/shape/api/polygon.ts | 22 +++++---- packages/framework/global/src/gfx/math.ts | 28 +++++++++++ 5 files changed, 62 insertions(+), 42 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d8cc4683f7e1..78a20be2e302 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,5 +1,10 @@ { "permissions": { - "allow": ["Bash(npx tsc:*)", "Bash(npx vitest:*)", "Bash(python:*)"] + "allow": [ + "Bash(npx tsc:*)", + "Bash(npx vitest:*)", + "Bash(python:*)", + "Bash(yarn dev:*)" + ] } } diff --git a/.gitignore b/.gitignore index 9363c194bc2d..770a42720315 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.claude .idea .vscode/* .zed diff --git a/packages/affine/gfx/connector/src/connector-manager.ts b/packages/affine/gfx/connector/src/connector-manager.ts index 8b84b0385ee0..bae04eaa7d9b 100644 --- a/packages/affine/gfx/connector/src/connector-manager.ts +++ b/packages/affine/gfx/connector/src/connector-manager.ts @@ -151,20 +151,21 @@ export function getAnchors(ele: GfxModel) { [0, 0.38], ]; - // Compute the polygon centroid in normalized space for outward normal calc - let cx = 0, - cy = 0; - for (const v of verts) { - cx += v[0]; - cy += v[1]; - } - cx /= verts.length; - cy /= verts.length; - for (let i = 0; i < verts.length; i++) { const curr = verts[i]; const next = verts[(i + 1) % verts.length]; + // CW edge direction in absolute space, with element rotation applied. + // This matches the convention used by linePolygonIntersects / polygonGetPointTangent: + // Vec.rot(CW_edge_direction, -π/2) yields the outward normal. + const edx = (next[0] - curr[0]) * bound.w; + const edy = (next[1] - curr[1]) * bound.h; + const edLen = Math.sqrt(edx * edx + edy * edy) || 1; + const edgeTangent: IVec = Vec.rot( + [edx / edLen, edy / edLen], + toRadian(rotate) + ); + // --- Vertex anchor --- const vertCoord: IVec = [curr[0], curr[1]]; const vertAbs: IVec = [ @@ -175,13 +176,8 @@ export function getAnchors(ele: GfxModel) { { ...bound, rotate }, vertAbs ); - // Tangent at vertex: outward direction from centroid through vertex - const vOutX = curr[0] - cx; - const vOutY = curr[1] - cy; - const vOutLen = Math.sqrt(vOutX * vOutX + vOutY * vOutY) || 1; - const vertTangent: IVec = [vOutX / vOutLen, vOutY / vOutLen]; anchors.push({ - point: new PointLocation(vertRotated, vertTangent), + point: new PointLocation(vertRotated, edgeTangent), coord: vertCoord, }); @@ -198,24 +194,8 @@ export function getAnchors(ele: GfxModel) { { ...bound, rotate }, midAbs ); - // Tangent at edge midpoint: outward normal of the edge - // Edge direction (curr → next), then rotate 90° to get normal - const edx = (next[0] - curr[0]) * bound.w; - const edy = (next[1] - curr[1]) * bound.h; - // Perpendicular candidates: (edy, -edx) and (-edy, edx) - // Pick the one pointing away from centroid - let nx = edy, - ny = -edx; - const toMidX = midCoord[0] - cx; - const toMidY = midCoord[1] - cy; - if (nx * toMidX + ny * toMidY < 0) { - nx = -nx; - ny = -ny; - } - const nLen = Math.sqrt(nx * nx + ny * ny) || 1; - const midTangent: IVec = [nx / nLen, ny / nLen]; anchors.push({ - point: new PointLocation(midRotated, midTangent), + point: new PointLocation(midRotated, edgeTangent), coord: midCoord, }); } diff --git a/packages/affine/model/src/elements/shape/api/polygon.ts b/packages/affine/model/src/elements/shape/api/polygon.ts index 1eaca40a222e..81526ae6c4e7 100644 --- a/packages/affine/model/src/elements/shape/api/polygon.ts +++ b/packages/affine/model/src/elements/shape/api/polygon.ts @@ -6,9 +6,10 @@ import { pointInPolygon, PointLocation, pointOnPolygonStoke, - polygonGetPointTangent, polygonNearestPoint, + polygonNearestPointAndTangent, rotatePoints, + Vec, } from '@blocksuite/global/gfx'; import type { PointTestOptions } from '@blocksuite/std/gfx'; import type { ShapeElementModel } from '../shape.js'; @@ -158,17 +159,22 @@ export const polygon = { getRelativePointLocation(position: IVec, element: ShapeElementModel) { const bound = Bound.deserialize(element.xywh); - const point = bound.getRelativePoint(position); const verts = element.vertices ?? DEFAULT_POLYGON_VERTICES; - let points: IVec[] = verts.map(v => [ + const points: IVec[] = verts.map(v => [ bound.x + v[0] * bound.w, bound.y + v[1] * bound.h, ]); - points.push(point); - points = rotatePoints(points, bound.center, element.rotate); - const rotatePoint = points.pop() as IVec; - const tangent = polygonGetPointTangent(points, rotatePoint); - return new PointLocation(rotatePoint, tangent); + const boxPoint = bound.getRelativePoint(position); + const { point: nearest, tangent } = polygonNearestPointAndTangent(points, boxPoint); + + const rotated = rotatePoints([nearest, ...points], bound.center, element.rotate); + const rotatePoint = rotated[0]; + // Rotate the tangent by the element's rotation angle + const rotatedTangent = element.rotate + ? Vec.rot(tangent, (element.rotate * Math.PI) / 180) + : tangent; + + return new PointLocation(rotatePoint, rotatedTangent); }, }; diff --git a/packages/framework/global/src/gfx/math.ts b/packages/framework/global/src/gfx/math.ts index 5640e8c5e04e..69065c586cac 100644 --- a/packages/framework/global/src/gfx/math.ts +++ b/packages/framework/global/src/gfx/math.ts @@ -93,6 +93,34 @@ export function polygonNearestPoint(points: IVec[], point: IVec) { return rst; } +export function polygonNearestPointAndTangent( + points: IVec[], + point: IVec +): { point: IVec; tangent: IVec } { + const len = points.length; + if (len < 2) throw new Error('Polygon must have at least 2 points'); + + let rst: IVec = points[0]; + let dis = Vec.dist(points[0], point); + let edgeIdx = 0; + + for (let i = 0; i < len; i++) { + const p = points[i]; + const p2 = points[(i + 1) % len]; + const temp = Vec.nearestPointOnLineSegment(p, p2, point, true); + const curDis = Vec.dist(temp, point); + if (curDis < dis) { + dis = curDis; + rst = temp; + edgeIdx = i; + } + } + + const p = points[edgeIdx]; + const p2 = points[(edgeIdx + 1) % len]; + return { point: rst, tangent: Vec.normalize(Vec.sub(p2, p)) }; +} + export function polygonPointDistance(points: IVec[], point: IVec) { const nearest = polygonNearestPoint(points, point); return Vec.dist(nearest, point); From b867ee32540310d34bb8372707993851b23d403e Mon Sep 17 00:00:00 2001 From: Mathieu Jolly Date: Fri, 20 Mar 2026 12:30:06 +0100 Subject: [PATCH 04/10] docs(edgeless): polygone freeshape mention in the documentation --- .claude/settings.local.json | 3 ++- docs/components/blocks/surface-block.md | 2 +- docs/components/editors/edgeless-data-structure.md | 14 ++++++++++++++ docs/components/editors/edgeless-editor.md | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 78a20be2e302..33aa0bffa7e3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(npx tsc:*)", "Bash(npx vitest:*)", "Bash(python:*)", - "Bash(yarn dev:*)" + "Bash(yarn dev:*)", + "Bash(git log:*)" ] } } diff --git a/docs/components/blocks/surface-block.md b/docs/components/blocks/surface-block.md index 8d7a5950e838..41732df5370f 100644 --- a/docs/components/blocks/surface-block.md +++ b/docs/components/blocks/surface-block.md @@ -3,7 +3,7 @@ This is a container used to render graphical content. - In documents opened with the [edgeless editor](../editors/edgeless-editor), this block is required. -- Its `elements` field can contain a large number of `CanvasElement`s. These elements use HTML5 canvas for rendering and can be interleaved with note blocks, with automatically flattening to use the fewest canvas contexts. +- Its `elements` field can contain a large number of `CanvasElement`s. These elements include geometric shapes (rectangles, ellipses, diamonds, triangles, and custom freeform polygons), freehand brush strokes, connectors, and text. They use HTML5 canvas for rendering and can be interleaved with note blocks, with automatically flattening to use the fewest canvas contexts. ![context-interleaving](../../images/context-interleaving.png) diff --git a/docs/components/editors/edgeless-data-structure.md b/docs/components/editors/edgeless-data-structure.md index 9c30b98bab5b..a5b3f3f1fcce 100644 --- a/docs/components/editors/edgeless-data-structure.md +++ b/docs/components/editors/edgeless-data-structure.md @@ -44,6 +44,20 @@ The surface block can store two types of content: - The `block.children` field can contain edgeless-specific card blocks, such as embed-style links to YouTube, Figma, or other BlockSuite documents. - Graphical content like brushstrokes and polygons are modeled as `SurfaceElement`s and stored in the `block.elements` field. Common element types include `BrushElement`, `ShapeElement`, and `ConnectorElement`. +### Polygon Shapes + +The `ShapeElement` type supports freeform polygons in addition to built-in geometric shapes (rectangle, ellipse, diamond, triangle). Polygon vertices are stored as **normalized `[0, 1]` coordinates** relative to the element's bounding box — `[0, 0]` maps to the top-left corner and `[1, 1]` to the bottom-right. This normalized representation ensures polygons scale correctly when the bounding box is resized. + +A polygon element carries three polygon-specific fields: + +| Field | Type | Description | +| ------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `vertices` | `number[][] \| null` | Array of `[x, y]` normalized vertex positions. `null` uses a default regular pentagon. | +| `isClosed` | `boolean` | Whether the last vertex connects back to the first (`true` by default). | +| `smoothFlags` | `boolean[] \| null` | Per-vertex flags — `true` slightly rounds the corner at that vertex using cubic Bézier curves (control points at 1/3 of adjacent edge lengths). `null` means all corners are sharp. | + +Users create polygons by clicking to place vertices one by one. Double-clicking or clicking near the first vertex finishes the shape. The tool normalizes the placed vertices into `[0, 1]` space before storing them. All vertices start sharp by default; to round a corner, enter vertex editing mode (double-click the polygon) and press **B** while hovering over a vertex. + A typical edgeless document structure with a surface block might look like this: ``` diff --git a/docs/components/editors/edgeless-editor.md b/docs/components/editors/edgeless-editor.md index 3f7e83650bb2..635ac0230d71 100644 --- a/docs/components/editors/edgeless-editor.md +++ b/docs/components/editors/edgeless-editor.md @@ -7,7 +7,7 @@ This editor component offers a canvas with infinite logical dimensions, suitable ## Features - All the rich text editing capabilities in the [page editor](./page-editor). -- `CanvasElement` rendered to HTML5 canvas, including shapes, brushes, connectors, and text. +- `CanvasElement` rendered to HTML5 canvas, including shapes (rectangles, ellipses, diamonds, triangles, and custom polygons), brushes, connectors, and text. - Use of [frames](../blocks/frame-block) to denote canvas areas of any size. - Presentation mode achieved by switching between multiple frames in sequence. - Nestable group elements. From cb40251a5a6a2783b07cc2b3db973643d8a98f36 Mon Sep 17 00:00:00 2001 From: Mathieu Jolly Date: Fri, 20 Mar 2026 14:09:37 +0100 Subject: [PATCH 05/10] fix(edgeless): isolate polygone lifecycle edition mode from resizing and movement --- .../src/element-renderer/shape/polygon.ts | 18 +- .../shape/src/element-renderer/shape/utils.ts | 26 +- packages/affine/gfx/shape/src/element-view.ts | 378 ++++++++++-------- .../overlay/polygon-vertex-editing-overlay.ts | 131 +++++- .../affine/model/src/elements/shape/shape.ts | 20 + 5 files changed, 372 insertions(+), 201 deletions(-) diff --git a/packages/affine/gfx/shape/src/element-renderer/shape/polygon.ts b/packages/affine/gfx/shape/src/element-renderer/shape/polygon.ts index c1b322acf15a..f1161926ffac 100644 --- a/packages/affine/gfx/shape/src/element-renderer/shape/polygon.ts +++ b/packages/affine/gfx/shape/src/element-renderer/shape/polygon.ts @@ -68,12 +68,14 @@ export function polygon( ? model.smoothFlags : null; + const controlPoints: ((number[] | null)[] | null) = + 'controlPoints' in model && (model as unknown as Record).controlPoints + ? (model as unknown as { controlPoints: (number[] | null)[] }).controlPoints + : null; + const hasBezier = smoothFlags && smoothFlags.some(f => f); if (hasBezier) { - // For Bezier polygons with rough.js, build the path manually - // since rough.js polygon() only supports straight lines. - // We use rough.path() with an SVG path string. const count = absPoints.length; let pathD = `M ${absPoints[0][0]} ${absPoints[0][1]} `; for (let i = 0; i < count; i++) { @@ -88,17 +90,19 @@ export function polygon( pathD += `L ${nx} ${ny} `; } else { let cp1x: number, cp1y: number; + const customCurr = controlPoints?.[i]; if (currSmooth) { - cp1x = cx + (nx - cx) / 3; - cp1y = cy + (ny - cy) / 3; + cp1x = customCurr ? customCurr[2] * renderWidth : cx + (nx - cx) / 3; + cp1y = customCurr ? customCurr[3] * renderHeight : cy + (ny - cy) / 3; } else { cp1x = cx; cp1y = cy; } let cp2x: number, cp2y: number; + const customNext = controlPoints?.[next]; if (nextSmooth) { - cp2x = nx + (cx - nx) / 3; - cp2y = ny + (cy - ny) / 3; + cp2x = customNext ? customNext[0] * renderWidth : nx + (cx - nx) / 3; + cp2y = customNext ? customNext[1] * renderHeight : ny + (cy - ny) / 3; } else { cp2x = nx; cp2y = ny; diff --git a/packages/affine/gfx/shape/src/element-renderer/shape/utils.ts b/packages/affine/gfx/shape/src/element-renderer/shape/utils.ts index 25ef745eb817..eb669152002f 100644 --- a/packages/affine/gfx/shape/src/element-renderer/shape/utils.ts +++ b/packages/affine/gfx/shape/src/element-renderer/shape/utils.ts @@ -196,6 +196,11 @@ function drawPolygon( ? shapeModel.smoothFlags : null; + const controlPoints: ((number[] | null)[] | null) = + 'controlPoints' in shapeModel && (shapeModel as unknown as Record).controlPoints + ? (shapeModel as unknown as { controlPoints: (number[] | null)[] }).controlPoints + : null; + const count = vertices.length; if (count === 0) return; @@ -208,13 +213,6 @@ function drawPolygon( ctx.lineTo(vertices[i][0] * width, vertices[i][1] * height); } } else { - // Some vertices have Bezier smoothing - // For each edge (from vertex i to vertex i+1): - // - If vertex i is smooth, the outgoing control point is at 1/3 toward next - // - If vertex i+1 is smooth, the incoming control point is at 1/3 toward prev - // - If both endpoints are sharp, draw a straight line - // - If either endpoint is smooth, draw a cubic Bezier - const abs = (v: number[]): [number, number] => [v[0] * width, v[1] * height]; ctx.moveTo(vertices[0][0] * width, vertices[0][1] * height); @@ -228,25 +226,23 @@ function drawPolygon( const [nx, ny] = abs(vertices[next]); if (!currSmooth && !nextSmooth) { - // Both sharp: straight line ctx.lineTo(nx, ny); } else { - // At least one endpoint is smooth: draw cubic Bezier - // Control point near current vertex let cp1x: number, cp1y: number; + const customCurr = controlPoints?.[i]; if (currSmooth) { - cp1x = cx + (nx - cx) / 3; - cp1y = cy + (ny - cy) / 3; + cp1x = customCurr ? customCurr[2] * width : cx + (nx - cx) / 3; + cp1y = customCurr ? customCurr[3] * height : cy + (ny - cy) / 3; } else { cp1x = cx; cp1y = cy; } - // Control point near next vertex let cp2x: number, cp2y: number; + const customNext = controlPoints?.[next]; if (nextSmooth) { - cp2x = nx + (cx - nx) / 3; - cp2y = ny + (cy - ny) / 3; + cp2x = customNext ? customNext[0] * width : nx + (cx - nx) / 3; + cp2y = customNext ? customNext[1] * height : ny + (cy - ny) / 3; } else { cp2x = nx; cp2y = ny; diff --git a/packages/affine/gfx/shape/src/element-view.ts b/packages/affine/gfx/shape/src/element-view.ts index 7ca5421f469b..b238733e6b37 100644 --- a/packages/affine/gfx/shape/src/element-view.ts +++ b/packages/affine/gfx/shape/src/element-view.ts @@ -14,11 +14,7 @@ import { GfxElementModelView, GfxViewInteractionExtension, } from '@blocksuite/std/gfx'; -import type { - DragEndContext, - DragMoveContext, - DragStartContext, -} from '@blocksuite/std/gfx'; +import type { PointerEventState } from '@blocksuite/std'; import { normalizeShapeBound } from './element-renderer'; import { PolygonVertexEditingOverlay } from './overlay/polygon-vertex-editing-overlay'; @@ -44,141 +40,22 @@ export class ShapeElementView extends GfxElementModelView { override onDestroyed(): void { this._escapeKeyDisposer?.(); this._escapeKeyDisposer = null; + for (const disposer of this._dragHandlerDisposers) { + disposer(); + } + this._dragHandlerDisposers = []; this._removeVertexEditingOverlay(); super.onDestroyed(); } - /** - * Override framework drag handlers to wire both polygon-body drag-to-move - * and vertex drag through the same pipeline. - * - * Design rationale - * ──────────────── - * `GfxViewEventManager.dispatch('dragstart')` returns `true` whenever the - * view has *any* 'dragstart' listener registered – regardless of whether - * the listener actually does anything. When it returns `true`, DefaultTool - * sees `handledByView = true` and skips the default drag-to-move path. - * - * The old approach registered a 'dragstart' handler in _enterVertexEditingMode - * and removed it in _exitVertexEditingMode. That worked for the non-editing - * case but had a bug in editing mode: clicking on the polygon BODY (not a - * vertex) still had handledByView=true so the polygon could not be moved - * while vertex editing was active. - * - * The new approach: - * 1. A 'pointerdown' handler (always registered for polygons) records - * which vertex was pressed → `_pendingVertexIndex`. - * 2. NO 'dragstart' listener is ever registered on this view. - * ⇒ dispatch('dragstart') always returns false - * ⇒ handledByView is always false - * ⇒ DefaultTool always proceeds to call handleElementMove() - * ⇒ InteractivityManager eventually calls view.onDragStart/Move/End - * 3. Here in onDragStart we inspect _pendingVertexIndex and branch: - * • ≥ 0 AND overlay.isEditing → vertex drag (suppress super) - * • otherwise → element move (call super) - * - * This guarantees that: - * • Clicking anywhere inside the polygon body (PIP-hit via includesPoint) - * always initiates a normal element move – both in normal mode and in - * vertex-editing mode. - * • Clicking a vertex handle in editing mode initiates a vertex drag. - */ - override onDragStart(ctx: DragStartContext): void { - // If in vertex editing mode and a vertex was pressed, start vertex drag. - if ( - this._vertexEditingOverlay?.isEditing && - this._pendingVertexIndex >= 0 - ) { - const verts = this.model.vertices; - if (verts && this._pendingVertexIndex < verts.length) { - this._isDraggingVertex = true; - this._vertexEditingOverlay.activeVertexIndex = this._pendingVertexIndex; - - // Stash so intermediate vertex moves do not flood the CRDT. - this.model.stash('xywh'); - this.model.stash('vertices'); - - // Record the vertex's absolute model position so that onDragMove can - // convert the framework's cumulative dx/dy back to an absolute - // target coordinate for moveVertex(). - const bound = Bound.deserialize(this.model.xywh); - const v = verts[this._pendingVertexIndex]; - this._vertexDragStartModelCoord = [ - bound.x + v[0] * bound.w, - bound.y + v[1] * bound.h, - ]; - - this._surfaceComponent?.refresh(); - return; // suppress default element-move stash - } - } - - // Default: let the framework move the whole element. - // This runs for: - // • Non-editing mode – click anywhere in polygon body (PIP tested) - // • Editing mode – click on body but not on a vertex handle - super.onDragStart(ctx); - } - - override onDragMove(ctx: DragMoveContext): void { - if (this._isDraggingVertex) { - if (this._vertexEditingOverlay && this._vertexDragStartModelCoord) { - // Convert the cumulative delta provided by the framework into the - // absolute model-space target position for the vertex. - const targetX = this._vertexDragStartModelCoord[0] + ctx.dx; - const targetY = this._vertexDragStartModelCoord[1] + ctx.dy; - this._vertexEditingOverlay.moveVertex( - this._vertexEditingOverlay.activeVertexIndex, - targetX, - targetY - ); - - // Sub-AC 5b: Recalculate connector paths in real-time as polygon - // vertices are dragged. - // - // Two complementary mechanisms keep connectors in sync: - // - // 1. Implicit path (already in place): - // moveVertex() updates stashed `xywh` and `vertices` → the stash - // setter fires `surface.elementUpdated` synchronously → the - // connector-watcher reacts and schedules `queueMicrotask` → - // microtask runs before the next RAF → canvas renders with - // up-to-date connector paths. - // - // 2. Explicit path (added here for determinism): - // Directly call ConnectorPathGenerator.updatePath() for each - // connector attached to this polygon. This is synchronous, so - // connector paths are guaranteed to be current before refresh() - // even if the microtask scheduling is delayed for any reason. - // The connector-watcher's subsequent microtask is a harmless - // no-op because it will compute the same path. - this._syncConnectorPaths(); - - this._surfaceComponent?.refresh(); - } - return; - } - - super.onDragMove(ctx); - } - /** * Synchronously recalculate and apply the path for every connector * attached to this polygon shape. * - * Called from `onDragMove` during polygon vertex drag so that connector - * routing stays visually correct on every drag frame (Sub-AC 5b). - * - * Uses `ConnectorPathGenerator.updatePath()` — the same function used by - * the connector-watcher middleware — so routing logic is consistent. - * - * The cast to `SurfaceBlockModel` (affine) is required because - * `GfxPrimitiveElementModel.surface` is typed as the framework base class - * which does not declare `getConnectors()`. At runtime this is always the - * affine SurfaceBlockModel that does have the method. + * Called during polygon vertex drag so that connector routing stays + * visually correct on every drag frame. */ private _syncConnectorPaths(): void { - // Cast to the affine SurfaceBlockModel which provides getConnectors(). const surface = this.model.surface as unknown as SurfaceBlockModel; const connectors = surface.getConnectors( this.model.id @@ -195,27 +72,6 @@ export class ShapeElementView extends GfxElementModelView { } } - override onDragEnd(ctx: DragEndContext): void { - if (this._isDraggingVertex) { - this._isDraggingVertex = false; - this._vertexDragStartModelCoord = null; - this._pendingVertexIndex = -1; - - if (this._vertexEditingOverlay) { - this._vertexEditingOverlay.activeVertexIndex = -1; - this._vertexEditingOverlay.clearSnapGuides(); - } - - // Commit the final vertex / bounding-box state to the CRDT. - this.model.pop('xywh'); - this.model.pop('vertices'); - this._surfaceComponent?.refresh(); - return; - } - - super.onDragEnd(ctx); - } - private _initDblClickToEdit(): void { this.on('dblclick', () => { const edgeless = this.std.view.getBlock(this.std.store.root!.id); @@ -225,12 +81,6 @@ export class ShapeElementView extends GfxElementModelView { !this.model.isLocked() && this.model instanceof ShapeElementModel ) { - // All shapes (including polygon) open the text editor on double-click. - // Vertex editing for polygons is accessed via the toolbar "Edit vertices" - // button instead. - // - // Text-editing mode and vertex-editing mode are mutually exclusive: - // if vertex editing is active, exit it before opening the text editor. if (this._vertexEditingOverlay?.isEditing) { this._exitVertexEditingMode(); } @@ -245,6 +95,11 @@ export class ShapeElementView extends GfxElementModelView { */ private _isDraggingVertex = false; + /** Bezier handle being dragged (null = none). */ + private _pendingBezierHandle: { vertexIndex: number; handleIndex: number } | null = null; + private _isDraggingBezierHandle = false; + private _bezierDragStartModelCoord: [number, number] | null = null; + /** * The vertex index pressed on the last pointerdown (-1 = none). * Examined in onDragStart to determine whether to enter vertex-drag mode. @@ -252,16 +107,24 @@ export class ShapeElementView extends GfxElementModelView { */ private _pendingVertexIndex = -1; + /** + * Pointer model-space position at drag start. + * Used to compute model-space deltas from PointerEventState screen coords. + */ + private _pointerStartModelCoord: [number, number] | null = null; + /** * Absolute model-space position of the dragged vertex at drag start. - * Lets onDragMove convert the framework's cumulative (dx, dy) deltas into - * absolute target coordinates for moveVertex(). + * Combined with pointer delta to compute absolute target for moveVertex(). */ private _vertexDragStartModelCoord: [number, number] | null = null; /** Disposer for the Escape/Delete/B key listener used in vertex editing mode. */ private _escapeKeyDisposer: (() => void) | null = null; + /** Disposers for drag event handlers registered during vertex editing mode. */ + private _dragHandlerDisposers: (() => void)[] = []; + /** * Enter polygon vertex editing mode: set overlay to editing, update * selection state to editing, and listen for Escape/Delete keys. @@ -307,7 +170,9 @@ export class ShapeElementView extends GfxElementModelView { if (this.model.smoothFlags) { this.model.stash('smoothFlags'); } + this.model.stash('controlPoints'); const deleted = this._vertexEditingOverlay.deleteVertex(idx); + this.model.pop('controlPoints'); this.model.pop('xywh'); this.model.pop('vertices'); if (this.model.smoothFlags) { @@ -337,7 +202,9 @@ export class ShapeElementView extends GfxElementModelView { // so that elementUpdated is emitted with props['smoothFlags'] in all // cases (null → array, array → modified array). this.model.stash('smoothFlags'); + this.model.stash('controlPoints'); this._vertexEditingOverlay.toggleVertexSmooth(idx); + this.model.pop('controlPoints'); this.model.pop('smoothFlags'); this._surfaceComponent?.refresh(); } @@ -351,12 +218,135 @@ export class ShapeElementView extends GfxElementModelView { }); }; - // Vertex drag is now handled entirely through onDragStart/Move/End via - // the _pendingVertexIndex set in the 'pointerdown' handler registered by - // _initPolygonVertexEditing(). No 'dragstart'/'dragmove'/'dragend' - // handlers are registered here so that dispatch('dragstart') always - // returns false, keeping handledByView=false and letting DefaultTool - // invoke handleElementMove → view.onDragStart/Move/End for all drags. + // ── Drag handlers for vertex/bezier editing ─────────────────────────── + // Registered handlers receive PointerEventState and are dispatched by + // GfxViewEventManager BEFORE DefaultTool checks selection.editing. + // This means they work even when editing=true (which causes DefaultTool + // to skip handleElementMove and the lifecycle onDragStart/Move/End). + // + // When editing mode exits, these are unregistered so dispatch('dragstart') + // returns false → DefaultTool proceeds normally → element move works. + + const dragstartDisposer = this.on('dragstart', (e: PointerEventState) => { + // Vertex drag + if (this._pendingVertexIndex >= 0) { + const verts = this.model.vertices; + if (verts && this._pendingVertexIndex < verts.length) { + this._isDraggingVertex = true; + this._vertexEditingOverlay!.activeVertexIndex = this._pendingVertexIndex; + + this.model.stash('xywh'); + this.model.stash('vertices'); + this.model.stash('controlPoints'); + + // Record pointer start in model space for delta computation + const [startMX, startMY] = this.gfx.viewport.toModelCoord(e.x, e.y); + this._pointerStartModelCoord = [startMX, startMY]; + + // Record vertex absolute model position + const bound = Bound.deserialize(this.model.xywh); + const v = verts[this._pendingVertexIndex]; + this._vertexDragStartModelCoord = [ + bound.x + v[0] * bound.w, + bound.y + v[1] * bound.h, + ]; + + this._surfaceComponent?.refresh(); + return; + } + } + + // Bezier handle drag + if (this._pendingBezierHandle) { + this._isDraggingBezierHandle = true; + this._vertexEditingOverlay!.activeBezierHandleIndex = this._pendingBezierHandle.vertexIndex; + this._vertexEditingOverlay!.activeBezierHandleType = this._pendingBezierHandle.handleIndex; + + this.model.stash('xywh'); + this.model.stash('controlPoints'); + + const [startMX, startMY] = this.gfx.viewport.toModelCoord(e.x, e.y); + this._pointerStartModelCoord = [startMX, startMY]; + + const cp = this._vertexEditingOverlay!.getBezierControlPoints(this._pendingBezierHandle.vertexIndex); + if (cp) { + const pt = this._pendingBezierHandle.handleIndex === 0 ? cp.cp1 : cp.cp2; + this._bezierDragStartModelCoord = pt; + } + + this._surfaceComponent?.refresh(); + } + }); + + const dragmoveDisposer = this.on('dragmove', (e: PointerEventState) => { + if (!this._pointerStartModelCoord) return; + + const [curMX, curMY] = this.gfx.viewport.toModelCoord(e.x, e.y); + const dx = curMX - this._pointerStartModelCoord[0]; + const dy = curMY - this._pointerStartModelCoord[1]; + + // Bezier handle drag + if (this._isDraggingBezierHandle && this._pendingBezierHandle && this._bezierDragStartModelCoord) { + this._vertexEditingOverlay!.moveBezierHandle( + this._pendingBezierHandle.vertexIndex, + this._pendingBezierHandle.handleIndex, + this._bezierDragStartModelCoord[0] + dx, + this._bezierDragStartModelCoord[1] + dy + ); + this._syncConnectorPaths(); + this._surfaceComponent?.refresh(); + return; + } + + // Vertex drag + if (this._isDraggingVertex && this._vertexDragStartModelCoord) { + this._vertexEditingOverlay!.moveVertex( + this._vertexEditingOverlay!.activeVertexIndex, + this._vertexDragStartModelCoord[0] + dx, + this._vertexDragStartModelCoord[1] + dy + ); + this._syncConnectorPaths(); + this._surfaceComponent?.refresh(); + } + }); + + const dragendDisposer = this.on('dragend', (_e: PointerEventState) => { + // Bezier handle drag end + if (this._isDraggingBezierHandle) { + this._isDraggingBezierHandle = false; + this._bezierDragStartModelCoord = null; + this._pointerStartModelCoord = null; + this._pendingBezierHandle = null; + if (this._vertexEditingOverlay) { + this._vertexEditingOverlay.activeBezierHandleIndex = -1; + this._vertexEditingOverlay.activeBezierHandleType = -1; + } + this.model.pop('controlPoints'); + this.model.pop('xywh'); + this._surfaceComponent?.refresh(); + return; + } + + // Vertex drag end + if (this._isDraggingVertex) { + this._isDraggingVertex = false; + this._vertexDragStartModelCoord = null; + this._pointerStartModelCoord = null; + this._pendingVertexIndex = -1; + + if (this._vertexEditingOverlay) { + this._vertexEditingOverlay.activeVertexIndex = -1; + this._vertexEditingOverlay.clearSnapGuides(); + } + + this.model.pop('xywh'); + this.model.pop('vertices'); + this.model.pop('controlPoints'); + this._surfaceComponent?.refresh(); + } + }); + + this._dragHandlerDisposers = [dragstartDisposer, dragmoveDisposer, dragendDisposer]; } /** @@ -382,10 +372,23 @@ export class ShapeElementView extends GfxElementModelView { this._escapeKeyDisposer?.(); this._escapeKeyDisposer = null; + // Unregister drag handlers so dispatch('dragstart') returns false + // and DefaultTool proceeds with normal element move. + for (const disposer of this._dragHandlerDisposers) { + disposer(); + } + this._dragHandlerDisposers = []; + // Reset any pending or active vertex drag state. this._pendingVertexIndex = -1; this._isDraggingVertex = false; this._vertexDragStartModelCoord = null; + this._pointerStartModelCoord = null; + + // Reset bezier handle drag state. + this._pendingBezierHandle = null; + this._isDraggingBezierHandle = false; + this._bezierDragStartModelCoord = null; } /** @@ -415,15 +418,9 @@ export class ShapeElementView extends GfxElementModelView { ); // ── Vertex press tracking ───────────────────────────────────────────── - // Record which vertex (if any) the pointer pressed on. onDragStart reads + // Record which vertex (if any) the pointer pressed on. The registered + // dragstart handler (added in _enterVertexEditingMode) reads // _pendingVertexIndex to decide whether to enter vertex-drag mode. - // - // We do NOT use a 'dragstart' handler for this because registering one - // would cause GfxViewEventManager.dispatch('dragstart') to return true, - // making DefaultTool see handledByView=true and skip handleElementMove - // entirely – which would break body drag-to-move. Using 'pointerdown' - // avoids that: pointerdown dispatch does not affect the handledByView - // flag checked by DefaultTool.dragStart(). this.on('pointerdown', (e) => { if (!this._vertexEditingOverlay?.isEditing) { // Outside editing mode no vertex drag is possible. @@ -434,6 +431,14 @@ export class ShapeElementView extends GfxElementModelView { const [mx, my] = this.gfx.viewport.toModelCoord(e.x, e.y); this._pendingVertexIndex = this._vertexEditingOverlay.hitTestVertex(mx, my); + + // If no vertex hit, check bezier handle hit + if (this._pendingVertexIndex < 0) { + this._pendingBezierHandle = + this._vertexEditingOverlay.hitTestBezierHandle(mx, my); + } else { + this._pendingBezierHandle = null; + } }); // Listen for pointer move to update hover state on the overlay @@ -489,7 +494,9 @@ export class ShapeElementView extends GfxElementModelView { this.model.stash('xywh'); this.model.stash('vertices'); this.model.stash('smoothFlags'); + this.model.stash('controlPoints'); const newIdx = this._vertexEditingOverlay.insertVertexAtMidpoint(midIdx); + this.model.pop('controlPoints'); this.model.pop('xywh'); this.model.pop('vertices'); this.model.pop('smoothFlags'); @@ -529,11 +536,16 @@ export class ShapeElementView extends GfxElementModelView { this._surfaceComponent?.renderer.removeOverlay(this._vertexEditingOverlay); this._vertexEditingOverlay = null; - // Reset vertex drag state in case the overlay is removed during an - // active drag (e.g. element deselected externally while dragging). + // Clean up drag handlers and reset drag state in case the overlay is + // removed during an active drag (e.g. element deselected externally). + for (const disposer of this._dragHandlerDisposers) { + disposer(); + } + this._dragHandlerDisposers = []; this._pendingVertexIndex = -1; this._isDraggingVertex = false; this._vertexDragStartModelCoord = null; + this._pointerStartModelCoord = null; this._surfaceComponent?.renderer.refresh(); } @@ -556,6 +568,22 @@ export class ShapeElementView extends GfxElementModelView { export const ShapeViewInteraction = GfxViewInteractionExtension(ShapeElementView.type, { + handleSelection: ({ gfx, view }) => { + return { + onSelect(context) { + // When polygon vertex editing is active, preserve the editing flag + // so that the bounding-box / resize handles stay hidden. + if (view.vertexEditingOverlay?.isEditing) { + gfx.selection.set({ + elements: [context.model.id], + editing: true, + }); + return; + } + context.default(context); + }, + }; + }, handleResize: () => { return { onResizeMove({ newBound, model }) { diff --git a/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts b/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts index 643e01dde094..6e84e79e30ea 100644 --- a/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts +++ b/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts @@ -100,6 +100,9 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { /** Index of the vertex whose Bezier control handle is being dragged (-1 = none). */ activeBezierHandleIndex = -1; + /** Which handle of the active bezier vertex is being dragged (0=cp1, 1=cp2, -1=none). */ + activeBezierHandleType: number = -1; + // ── Snap guide state ──────────────────────────────────────────────── /** Horizontal snap guide Y coordinate (null = hidden). */ @@ -281,6 +284,97 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { return -1; } + /** + * Test if a model-coordinate point hits a bezier control handle. + * Returns { vertexIndex, handleIndex: 0=cp1, 1=cp2 } or null. + */ + hitTestBezierHandle( + modelX: number, + modelY: number + ): { vertexIndex: number; handleIndex: number } | null { + const model = this._getPolygonModel(); + if (!model || !model.vertices || !model.smoothFlags) return null; + + const zoom = this.gfx.viewport.zoom; + const hitDist = BEZIER_HANDLE_RADIUS * 2 / zoom; + + for (let i = 0; i < model.vertices.length; i++) { + if (!model.smoothFlags[i]) continue; + const cp = this.getBezierControlPoints(i); + if (!cp) continue; + + // Check cp1 + const dx1 = modelX - cp.cp1[0]; + const dy1 = modelY - cp.cp1[1]; + if (Math.sqrt(dx1 * dx1 + dy1 * dy1) < hitDist) { + return { vertexIndex: i, handleIndex: 0 }; + } + + // Check cp2 + const dx2 = modelX - cp.cp2[0]; + const dy2 = modelY - cp.cp2[1]; + if (Math.sqrt(dx2 * dx2 + dy2 * dy2) < hitDist) { + return { vertexIndex: i, handleIndex: 1 }; + } + } + return null; + } + + /** + * Move a bezier control handle to an absolute model position. + * Stores the result as normalized coordinates in model.controlPoints. + */ + moveBezierHandle( + vertexIndex: number, + handleIndex: number, + modelX: number, + modelY: number + ): void { + const model = this._getPolygonModel(); + if (!model || !model.vertices) return; + + const bound = Bound.deserialize(model.xywh); + const count = model.vertices.length; + + // Initialize controlPoints array if null + let controlPoints: (number[] | null)[] = model.controlPoints + ? [...model.controlPoints] + : new Array(count).fill(null); + + // Ensure array is the right length + while (controlPoints.length < count) controlPoints.push(null); + + // Auto-compute current control points for this vertex if not set + if (!controlPoints[vertexIndex]) { + const cp = this.getBezierControlPoints(vertexIndex); + if (!cp) return; + // Store as normalized [cp1x, cp1y, cp2x, cp2y] + controlPoints[vertexIndex] = [ + (cp.cp1[0] - bound.x) / bound.w, + (cp.cp1[1] - bound.y) / bound.h, + (cp.cp2[0] - bound.x) / bound.w, + (cp.cp2[1] - bound.y) / bound.h, + ]; + } + + // Convert absolute position to normalized coordinates + const normX = (modelX - bound.x) / bound.w; + const normY = (modelY - bound.y) / bound.h; + + // Update the specific handle + const entry = [...controlPoints[vertexIndex]!]; + if (handleIndex === 0) { + entry[0] = normX; + entry[1] = normY; + } else { + entry[2] = normX; + entry[3] = normY; + } + controlPoints[vertexIndex] = entry; + + model.controlPoints = controlPoints; + } + /** * Insert a new vertex at the midpoint of edge `edgeIndex` (between vertex * edgeIndex and edgeIndex+1). Returns the index of the newly inserted vertex. @@ -309,6 +403,13 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { model.smoothFlags = flags; } + // Update controlPoints if present + if (model.controlPoints) { + const cps = [...model.controlPoints]; + cps.splice(insertIdx, 0, null); + model.controlPoints = cps; + } + model.vertices = vertices; return insertIdx; } @@ -332,6 +433,13 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { model.smoothFlags = flags; } + // Update controlPoints if present + if (model.controlPoints) { + const cps = [...model.controlPoints]; + cps.splice(vertexIndex, 1); + model.controlPoints = cps; + } + // Re-compute bounding box from remaining vertices const bound = Bound.deserialize(model.xywh); const absVertices = vertices.map(v => this._toAbsolute(v, bound)); @@ -377,8 +485,10 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { while (flags.length < count) flags.push(false); if (flags.length > count) flags = flags.slice(0, count); - flags[vertexIndex] = !flags[vertexIndex]; + const wasSmooth = flags[vertexIndex]; + flags[vertexIndex] = !wasSmooth; model.smoothFlags = flags; + } /** @@ -404,6 +514,22 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { if (!model.smoothFlags || !model.smoothFlags[vertexIndex]) return null; const bound = Bound.deserialize(model.xywh); + + // Check for custom control points first + const custom = model.controlPoints?.[vertexIndex]; + if (custom) { + const cp1: [number, number] = [ + custom[0] * bound.w + bound.x, + custom[1] * bound.h + bound.y, + ]; + const cp2: [number, number] = [ + custom[2] * bound.w + bound.x, + custom[3] * bound.h + bound.y, + ]; + return { cp1, cp2 }; + } + + // Fall back to auto-computation const vertices = model.vertices; const count = vertices.length; @@ -411,8 +537,6 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { const curr = this._toAbsolute(vertices[vertexIndex], bound); const next = this._toAbsolute(vertices[(vertexIndex + 1) % count], bound); - // Compute control points as 1/3 of the distance along the tangent - // that bisects the angle formed by prev-curr-next const dx1 = prev[0] - curr[0]; const dy1 = prev[1] - curr[1]; const dx2 = next[0] - curr[0]; @@ -421,7 +545,6 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1) || 1; const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2) || 1; - // Control points at 1/3 of the edge length toward the neighbours const cpDist1 = len1 / 3; const cpDist2 = len2 / 3; diff --git a/packages/affine/model/src/elements/shape/shape.ts b/packages/affine/model/src/elements/shape/shape.ts index 2f525d937d1d..dc5e9013deeb 100644 --- a/packages/affine/model/src/elements/shape/shape.ts +++ b/packages/affine/model/src/elements/shape/shape.ts @@ -64,6 +64,15 @@ export type ShapeProps = BaseElementProps & { */ smoothFlags?: boolean[] | null; + /** + * Optional custom Bezier control points per vertex (normalized [0-1] coords). + * Each entry is [cp1x, cp1y, cp2x, cp2y] or null (auto-compute). + * cp1 = incoming control point (toward previous vertex direction) + * cp2 = outgoing control point (toward next vertex direction) + * null array = all auto-computed. + */ + controlPoints?: (number[] | null)[] | null; + text?: Y.Text; textHorizontalAlign?: TextAlign; textVerticalAlign?: TextVerticalAlign; @@ -228,6 +237,14 @@ export class ShapeElementModel extends GfxPrimitiveElementModel { @field() accessor smoothFlags: boolean[] | null = null; + /** + * Optional custom Bezier control points per vertex (normalized [0-1] coords). + * Each entry is [cp1x, cp1y, cp2x, cp2y] or null (auto-compute). + * null array = all auto-computed. + */ + @field() + accessor controlPoints: (number[] | null)[] | null = null; + @field() accessor xywh: SerializedXYWH = '[0,0,100,100]'; } @@ -313,4 +330,7 @@ export class LocalShapeElementModel extends GfxLocalElementModel { @prop() accessor smoothFlags: boolean[] | null = null; + + @prop() + accessor controlPoints: (number[] | null)[] | null = null; } From 1f70675438448113783d7bf0f8d7d580f63d9b11 Mon Sep 17 00:00:00 2001 From: Mathieu Jolly Date: Sat, 21 Mar 2026 13:13:56 +0100 Subject: [PATCH 06/10] fix(edgeless): anticlockwise bug on connector anchor connection on polygone shape resolve --- .../connector/src/element-renderer/utils.ts | 23 +++++++++++--- packages/affine/gfx/shape/src/polygon-tool.ts | 30 ++++++++++++++++++- .../model/src/elements/shape/api/polygon.ts | 27 ++++++++++------- 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/packages/affine/gfx/connector/src/element-renderer/utils.ts b/packages/affine/gfx/connector/src/element-renderer/utils.ts index 04d6beec5770..b7a1c91c9e09 100644 --- a/packages/affine/gfx/connector/src/element-renderer/utils.ts +++ b/packages/affine/gfx/connector/src/element-renderer/utils.ts @@ -85,10 +85,25 @@ export function getPointWithTangent( ? Vec.tangent(anchorPoint, pointToAnchor) : Vec.tangent(pointToAnchor, anchorPoint); } else { - tangent = - endPoint === 'Rear' - ? getBezierTangent(bezierParameters, 1) - : getBezierTangent(bezierParameters, 0); + // Preserve shape-provided edge tangent for arrow direction rather than + // deriving it from the Bezier curve. For shapes whose vertices follow + // clockwise winding (rect, diamond, triangle, ellipse, polygon), the + // edge tangent's perpendicular gives the correct inward / outward normal + // that matches the connector's approach or departure direction. + const shapeTangent = anchorPoint.tangent; + if (shapeTangent[0] !== 0 || shapeTangent[1] !== 0) { + // CW winding: CCW 90° rotation → inward normal (Rear / approach), + // CW 90° rotation → outward normal (Front / departure). + tangent = + endPoint === 'Rear' + ? ([-shapeTangent[1], shapeTangent[0]] as IVec) + : ([shapeTangent[1], -shapeTangent[0]] as IVec); + } else { + tangent = + endPoint === 'Rear' + ? getBezierTangent(bezierParameters, 1) + : getBezierTangent(bezierParameters, 0); + } } clone.tangent = tangent ?? [0, 0]; diff --git a/packages/affine/gfx/shape/src/polygon-tool.ts b/packages/affine/gfx/shape/src/polygon-tool.ts index ec9cfe3987de..c16f693f7f94 100644 --- a/packages/affine/gfx/shape/src/polygon-tool.ts +++ b/packages/affine/gfx/shape/src/polygon-tool.ts @@ -251,6 +251,33 @@ export class PolygonTool extends BaseTool { (vy - minY) / h, ]); + // Ensure clockwise winding order using the shoelace formula. + // Positive signed area = counter-clockwise, so reverse to make clockwise. + // This is done once at creation time so downstream tangent computation + // always produces outward-pointing normals. + let signedArea2 = 0; + for (let i = 0; i < normalizedVertices.length; i++) { + const [x1, y1] = normalizedVertices[i]; + const [x2, y2] = + normalizedVertices[(i + 1) % normalizedVertices.length]; + signedArea2 += (x2 - x1) * (y2 + y1); + } + // In screen coordinates (Y-down), positive signedArea2 means + // counter-clockwise winding, so reverse to get clockwise. + // smoothFlags and controlPoints arrays are indexed parallel to vertices, + // so they must be reversed in sync to maintain correct per-vertex mapping. + let smoothFlagsForModel: boolean[] | null = null; + let controlPointsForModel: (number[] | null)[] | null = null; + if (signedArea2 > 0) { + normalizedVertices.reverse(); + if (smoothFlagsForModel) { + smoothFlagsForModel = [...smoothFlagsForModel].reverse(); + } + if (controlPointsForModel) { + controlPointsForModel = [...controlPointsForModel].reverse(); + } + } + const bound = new Bound(minX, minY, w, h); // Get last-used shape attributes for styling @@ -269,7 +296,8 @@ export class PolygonTool extends BaseTool { radius: 0, vertices: normalizedVertices, isClosed: true, - smoothFlags: null, + smoothFlags: smoothFlagsForModel, + ...(controlPointsForModel ? { controlPoints: controlPointsForModel } : {}), }); this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { diff --git a/packages/affine/model/src/elements/shape/api/polygon.ts b/packages/affine/model/src/elements/shape/api/polygon.ts index 81526ae6c4e7..465f6832bee8 100644 --- a/packages/affine/model/src/elements/shape/api/polygon.ts +++ b/packages/affine/model/src/elements/shape/api/polygon.ts @@ -6,10 +6,10 @@ import { pointInPolygon, PointLocation, pointOnPolygonStoke, + polygonGetPointTangent, polygonNearestPoint, polygonNearestPointAndTangent, rotatePoints, - Vec, } from '@blocksuite/global/gfx'; import type { PointTestOptions } from '@blocksuite/std/gfx'; import type { ShapeElementModel } from '../shape.js'; @@ -159,22 +159,27 @@ export const polygon = { getRelativePointLocation(position: IVec, element: ShapeElementModel) { const bound = Bound.deserialize(element.xywh); + const point = bound.getRelativePoint(position); const verts = element.vertices ?? DEFAULT_POLYGON_VERTICES; - const points: IVec[] = verts.map(v => [ + let points: IVec[] = verts.map(v => [ bound.x + v[0] * bound.w, bound.y + v[1] * bound.h, ]); + points.push(point); - const boxPoint = bound.getRelativePoint(position); - const { point: nearest, tangent } = polygonNearestPointAndTangent(points, boxPoint); + // Rotate everything together (same pattern as diamond/triangle) + points = rotatePoints(points, bound.center, element.rotate); + const rotatePoint = points.pop() as IVec; - const rotated = rotatePoints([nearest, ...points], bound.center, element.rotate); - const rotatePoint = rotated[0]; - // Rotate the tangent by the element's rotation angle - const rotatedTangent = element.rotate - ? Vec.rot(tangent, (element.rotate * Math.PI) / 180) - : tangent; + // Try exact edge tangent first (works when BB point lands on a polygon edge) + let tangent = polygonGetPointTangent(points, rotatePoint); - return new PointLocation(rotatePoint, rotatedTangent); + // For freeform polygons, the BB point rarely sits on an edge. + // Fall back to nearest-edge tangent for proper curve shaping. + if (tangent[0] === 0 && tangent[1] === 0) { + tangent = polygonNearestPointAndTangent(points, rotatePoint).tangent; + } + + return new PointLocation(rotatePoint, tangent); }, }; From d6e4f8ff22c8afb92a2f590119ebfc0f2bf244ce Mon Sep 17 00:00:00 2001 From: Mathieu Jolly Date: Sat, 21 Mar 2026 13:44:26 +0100 Subject: [PATCH 07/10] fix(edgeless): polygon menu button activated display and color selection with polygone fixed --- packages/affine/gfx/shape/src/draggable/shape-menu.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/affine/gfx/shape/src/draggable/shape-menu.ts b/packages/affine/gfx/shape/src/draggable/shape-menu.ts index 3bbf1cdfc5f3..c86af0668b2d 100644 --- a/packages/affine/gfx/shape/src/draggable/shape-menu.ts +++ b/packages/affine/gfx/shape/src/draggable/shape-menu.ts @@ -24,6 +24,7 @@ import { css, html, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; import { when } from 'lit/directives/when.js'; +import { PolygonTool } from '../polygon-tool'; import { ShapeTool } from '../shape-tool'; import { ShapeComponentConfig } from '../toolbar'; @@ -125,6 +126,8 @@ export class EdgelessShapeMenu extends SignalWatcher( if (shapeName) { this._shapeName$.value = shapeName; } + } else if (value && value.toolType === PolygonTool) { + this._shapeName$.value = ShapeType.Polygon; } }) ); From 6ee21390ff9f43477d0e3a409a2b0a8a80750553 Mon Sep 17 00:00:00 2001 From: Mathieu Jolly Date: Sat, 21 Mar 2026 19:12:21 +0100 Subject: [PATCH 08/10] fix(edgeless): polygone select zone is now takin smoothing handles and curve into account --- packages/affine/gfx/shape/src/element-view.ts | 6 + .../overlay/polygon-vertex-editing-overlay.ts | 418 +++++++++++++++++- .../model/src/elements/shape/api/polygon.ts | 243 +++++++++- .../affine/model/src/elements/shape/shape.ts | 29 +- 4 files changed, 689 insertions(+), 7 deletions(-) diff --git a/packages/affine/gfx/shape/src/element-view.ts b/packages/affine/gfx/shape/src/element-view.ts index b238733e6b37..214084105540 100644 --- a/packages/affine/gfx/shape/src/element-view.ts +++ b/packages/affine/gfx/shape/src/element-view.ts @@ -201,11 +201,15 @@ export class ShapeElementView extends GfxElementModelView { // a boolean array; stash/pop must bracket the mutation symmetrically // so that elementUpdated is emitted with props['smoothFlags'] in all // cases (null → array, array → modified array). + this.model.stash('xywh'); + this.model.stash('vertices'); this.model.stash('smoothFlags'); this.model.stash('controlPoints'); this._vertexEditingOverlay.toggleVertexSmooth(idx); this.model.pop('controlPoints'); this.model.pop('smoothFlags'); + this.model.pop('vertices'); + this.model.pop('xywh'); this._surfaceComponent?.refresh(); } return; @@ -263,6 +267,7 @@ export class ShapeElementView extends GfxElementModelView { this._vertexEditingOverlay!.activeBezierHandleType = this._pendingBezierHandle.handleIndex; this.model.stash('xywh'); + this.model.stash('vertices'); this.model.stash('controlPoints'); const [startMX, startMY] = this.gfx.viewport.toModelCoord(e.x, e.y); @@ -322,6 +327,7 @@ export class ShapeElementView extends GfxElementModelView { this._vertexEditingOverlay.activeBezierHandleType = -1; } this.model.pop('controlPoints'); + this.model.pop('vertices'); this.model.pop('xywh'); this._surfaceComponent?.refresh(); return; diff --git a/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts b/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts index 6e84e79e30ea..5b3206d79b41 100644 --- a/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts +++ b/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts @@ -3,7 +3,11 @@ import { ToolOverlay, } from '@blocksuite/affine-block-surface'; import { ShapeElementModel, ShapeType } from '@blocksuite/affine-model'; -import { Bound } from '@blocksuite/global/gfx'; +import { + Bound, + type BezierCurveParameters, + getBezierCurveBoundingBox, +} from '@blocksuite/global/gfx'; import type { GfxController } from '@blocksuite/std/gfx'; // ─── Visual constants ─────────────────────────────────────────────────────── @@ -230,6 +234,75 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { if (vy > maxY) maxY = vy; } + // Expand bounds to encompass Bezier geometry (control handles + arc curves) + const smoothFlags = model.smoothFlags; + const controlPoints = model.controlPoints; + const count = model.vertices.length; + + if (smoothFlags) { + for (let i = 0; i < count; i++) { + const next = (i + 1) % count; + const currSmooth = smoothFlags[i] ?? false; + const nextSmooth = smoothFlags[next] ?? false; + + if (!currSmooth && !nextSmooth) continue; + + const [cx, cy] = newAbsVertices[i]; + const [nx, ny] = newAbsVertices[next]; + + // Outgoing control point of current vertex + let cp1x: number, cp1y: number; + const customCurr = controlPoints?.[i]; + if (currSmooth && customCurr) { + cp1x = customCurr[2] * bound.w + bound.x; + cp1y = customCurr[3] * bound.h + bound.y; + } else if (currSmooth) { + cp1x = cx + (nx - cx) / 3; + cp1y = cy + (ny - cy) / 3; + } else { + cp1x = cx; + cp1y = cy; + } + + // Incoming control point of next vertex + let cp2x: number, cp2y: number; + const customNext = controlPoints?.[next]; + if (nextSmooth && customNext) { + cp2x = customNext[0] * bound.w + bound.x; + cp2y = customNext[1] * bound.h + bound.y; + } else if (nextSmooth) { + cp2x = nx + (cx - nx) / 3; + cp2y = ny + (cy - ny) / 3; + } else { + cp2x = nx; + cp2y = ny; + } + + // Include control handle positions + if (cp1x < minX) minX = cp1x; + if (cp1y < minY) minY = cp1y; + if (cp1x > maxX) maxX = cp1x; + if (cp1y > maxY) maxY = cp1y; + if (cp2x < minX) minX = cp2x; + if (cp2y < minY) minY = cp2y; + if (cp2x > maxX) maxX = cp2x; + if (cp2y > maxY) maxY = cp2y; + + // Use getBezierCurveBoundingBox for tight arc bounds + const bezierParams: BezierCurveParameters = [ + [cx, cy], + [cp1x, cp1y], + [cp2x, cp2y], + [nx, ny], + ]; + const arcBound = getBezierCurveBoundingBox(bezierParams); + if (arcBound.x < minX) minX = arcBound.x; + if (arcBound.y < minY) minY = arcBound.y; + if (arcBound.x + arcBound.w > maxX) maxX = arcBound.x + arcBound.w; + if (arcBound.y + arcBound.h > maxY) maxY = arcBound.y + arcBound.h; + } + } + const w = Math.max(maxX - minX, 1); const h = Math.max(maxY - minY, 1); @@ -244,6 +317,24 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { // Update model model.xywh = newBound.serialize(); model.vertices = normalized; + + // Re-normalize control points to new bounding box + if (controlPoints) { + const normalizedCPs = controlPoints.map(cp => { + if (!cp) return null; + const absCp1x = cp[0] * bound.w + bound.x; + const absCp1y = cp[1] * bound.h + bound.y; + const absCp2x = cp[2] * bound.w + bound.x; + const absCp2y = cp[3] * bound.h + bound.y; + return [ + (absCp1x - minX) / w, + (absCp1y - minY) / h, + (absCp2x - minX) / w, + (absCp2y - minY) / h, + ]; + }); + model.controlPoints = normalizedCPs; + } } /** Clear snap guides (call on drag end). */ @@ -357,7 +448,7 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { ]; } - // Convert absolute position to normalized coordinates + // Convert absolute position to normalized coordinates (in current bound) const normX = (modelX - bound.x) / bound.w; const normY = (modelY - bound.y) / bound.h; @@ -372,7 +463,118 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { } controlPoints[vertexIndex] = entry; - model.controlPoints = controlPoints; + // ── Re-normalize: expand bounding box to encompass all Bezier geometry ── + // Collect all absolute vertex positions + const absVertices: [number, number][] = model.vertices.map( + v => [bound.x + v[0] * bound.w, bound.y + v[1] * bound.h] as [number, number] + ); + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + + for (const [vx, vy] of absVertices) { + if (vx < minX) minX = vx; + if (vy < minY) minY = vy; + if (vx > maxX) maxX = vx; + if (vy > maxY) maxY = vy; + } + + // Expand bounds using getBezierCurveBoundingBox for each Bezier edge + const smoothFlags = model.smoothFlags; + if (smoothFlags) { + for (let i = 0; i < count; i++) { + const next = (i + 1) % count; + const currSmooth = smoothFlags[i] ?? false; + const nextSmooth = smoothFlags[next] ?? false; + + if (!currSmooth && !nextSmooth) continue; + + const [cx, cy] = absVertices[i]; + const [nx, ny] = absVertices[next]; + + // Outgoing control point of current vertex + let cp1x: number, cp1y: number; + const customCurr = controlPoints[i]; + if (currSmooth && customCurr) { + cp1x = customCurr[2] * bound.w + bound.x; + cp1y = customCurr[3] * bound.h + bound.y; + } else if (currSmooth) { + cp1x = cx + (nx - cx) / 3; + cp1y = cy + (ny - cy) / 3; + } else { + cp1x = cx; + cp1y = cy; + } + + // Incoming control point of next vertex + let cp2x: number, cp2y: number; + const customNext = controlPoints[next]; + if (nextSmooth && customNext) { + cp2x = customNext[0] * bound.w + bound.x; + cp2y = customNext[1] * bound.h + bound.y; + } else if (nextSmooth) { + cp2x = nx + (cx - nx) / 3; + cp2y = ny + (cy - ny) / 3; + } else { + cp2x = nx; + cp2y = ny; + } + + // Include control handle positions + if (cp1x < minX) minX = cp1x; + if (cp1y < minY) minY = cp1y; + if (cp1x > maxX) maxX = cp1x; + if (cp1y > maxY) maxY = cp1y; + if (cp2x < minX) minX = cp2x; + if (cp2y < minY) minY = cp2y; + if (cp2x > maxX) maxX = cp2x; + if (cp2y > maxY) maxY = cp2y; + + // Use getBezierCurveBoundingBox for tight arc bounds + const bezierParams: BezierCurveParameters = [ + [cx, cy], + [cp1x, cp1y], + [cp2x, cp2y], + [nx, ny], + ]; + const arcBound = getBezierCurveBoundingBox(bezierParams); + if (arcBound.x < minX) minX = arcBound.x; + if (arcBound.y < minY) minY = arcBound.y; + if (arcBound.x + arcBound.w > maxX) maxX = arcBound.x + arcBound.w; + if (arcBound.y + arcBound.h > maxY) maxY = arcBound.y + arcBound.h; + } + } + + const newW = Math.max(maxX - minX, 1); + const newH = Math.max(maxY - minY, 1); + + // Re-normalize vertices to new bounding box + const normalizedVerts = absVertices.map(([vx, vy]) => [ + (vx - minX) / newW, + (vy - minY) / newH, + ]); + + // Re-normalize control points to new bounding box + const normalizedCPs = controlPoints.map(cp => { + if (!cp) return null; + const absCp1x = cp[0] * bound.w + bound.x; + const absCp1y = cp[1] * bound.h + bound.y; + const absCp2x = cp[2] * bound.w + bound.x; + const absCp2y = cp[3] * bound.h + bound.y; + return [ + (absCp1x - minX) / newW, + (absCp1y - minY) / newH, + (absCp2x - minX) / newW, + (absCp2y - minY) / newH, + ]; + }); + + const newBound = new Bound(minX, minY, newW, newH); + model.xywh = newBound.serialize(); + model.vertices = normalizedVerts; + model.controlPoints = normalizedCPs; } /** @@ -442,7 +644,9 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { // Re-compute bounding box from remaining vertices const bound = Bound.deserialize(model.xywh); - const absVertices = vertices.map(v => this._toAbsolute(v, bound)); + const absVertices: [number, number][] = vertices.map( + v => this._toAbsolute(v, bound) as [number, number] + ); let minX = Infinity, minY = Infinity, @@ -455,6 +659,70 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { if (vy > maxY) maxY = vy; } + // Expand bounds to encompass Bezier geometry after deletion + const updatedFlags = model.smoothFlags; + const updatedCPs = model.controlPoints; + if (updatedFlags) { + const cnt = vertices.length; + for (let i = 0; i < cnt; i++) { + const next = (i + 1) % cnt; + const currSmooth = updatedFlags[i] ?? false; + const nextSmooth = updatedFlags[next] ?? false; + + if (!currSmooth && !nextSmooth) continue; + + const [cx, cy] = absVertices[i]; + const [nx, ny] = absVertices[next]; + + let cp1x: number, cp1y: number; + const customCurr = updatedCPs?.[i]; + if (currSmooth && customCurr) { + cp1x = customCurr[2] * bound.w + bound.x; + cp1y = customCurr[3] * bound.h + bound.y; + } else if (currSmooth) { + cp1x = cx + (nx - cx) / 3; + cp1y = cy + (ny - cy) / 3; + } else { + cp1x = cx; + cp1y = cy; + } + + let cp2x: number, cp2y: number; + const customNext = updatedCPs?.[next]; + if (nextSmooth && customNext) { + cp2x = customNext[0] * bound.w + bound.x; + cp2y = customNext[1] * bound.h + bound.y; + } else if (nextSmooth) { + cp2x = nx + (cx - nx) / 3; + cp2y = ny + (cy - ny) / 3; + } else { + cp2x = nx; + cp2y = ny; + } + + if (cp1x < minX) minX = cp1x; + if (cp1y < minY) minY = cp1y; + if (cp1x > maxX) maxX = cp1x; + if (cp1y > maxY) maxY = cp1y; + if (cp2x < minX) minX = cp2x; + if (cp2y < minY) minY = cp2y; + if (cp2x > maxX) maxX = cp2x; + if (cp2y > maxY) maxY = cp2y; + + const bezierParams: BezierCurveParameters = [ + [cx, cy], + [cp1x, cp1y], + [cp2x, cp2y], + [nx, ny], + ]; + const arcBound = getBezierCurveBoundingBox(bezierParams); + if (arcBound.x < minX) minX = arcBound.x; + if (arcBound.y < minY) minY = arcBound.y; + if (arcBound.x + arcBound.w > maxX) maxX = arcBound.x + arcBound.w; + if (arcBound.y + arcBound.h > maxY) maxY = arcBound.y + arcBound.h; + } + } + const w = Math.max(maxX - minX, 1); const h = Math.max(maxY - minY, 1); @@ -466,6 +734,25 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { const newBound = new Bound(minX, minY, w, h); model.xywh = newBound.serialize(); model.vertices = normalized; + + // Re-normalize control points to new bounding box + if (updatedCPs) { + const normalizedCPs = updatedCPs.map(cp => { + if (!cp) return null; + const absCp1x = cp[0] * bound.w + bound.x; + const absCp1y = cp[1] * bound.h + bound.y; + const absCp2x = cp[2] * bound.w + bound.x; + const absCp2y = cp[3] * bound.h + bound.y; + return [ + (absCp1x - minX) / w, + (absCp1y - minY) / h, + (absCp2x - minX) / w, + (absCp2y - minY) / h, + ]; + }); + model.controlPoints = normalizedCPs; + } + return true; } @@ -478,6 +765,7 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { const model = this._getPolygonModel(); if (!model || !model.vertices) return; + const bound = Bound.deserialize(model.xywh); const count = model.vertices.length; let flags = model.smoothFlags ? [...model.smoothFlags] : new Array(count).fill(false); @@ -489,6 +777,128 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { flags[vertexIndex] = !wasSmooth; model.smoothFlags = flags; + // If toggling off, clear custom control points for this vertex + let controlPoints: (number[] | null)[] = model.controlPoints + ? [...model.controlPoints] + : new Array(count).fill(null); + while (controlPoints.length < count) controlPoints.push(null); + + if (wasSmooth) { + controlPoints[vertexIndex] = null; + } + + // ── Re-normalize: expand bounding box to encompass all Bezier geometry ── + // We need to temporarily apply the new flags so getBezierControlPoints uses them + // Instead, compute everything inline using the new flags + + // Collect all absolute vertex positions + const absVertices: [number, number][] = model.vertices.map( + v => [bound.x + v[0] * bound.w, bound.y + v[1] * bound.h] as [number, number] + ); + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + + for (const [vx, vy] of absVertices) { + if (vx < minX) minX = vx; + if (vy < minY) minY = vy; + if (vx > maxX) maxX = vx; + if (vy > maxY) maxY = vy; + } + + // Expand bounds using getBezierCurveBoundingBox for each Bezier edge + for (let i = 0; i < count; i++) { + const next = (i + 1) % count; + const currSmooth = flags[i] ?? false; + const nextSmooth = flags[next] ?? false; + + if (!currSmooth && !nextSmooth) continue; + + const [cx, cy] = absVertices[i]; + const [nx, ny] = absVertices[next]; + + // Outgoing control point of current vertex (cp2 of its control points) + let cp1x: number, cp1y: number; + const customCurr = controlPoints[i]; + if (currSmooth && customCurr) { + cp1x = customCurr[2] * bound.w + bound.x; + cp1y = customCurr[3] * bound.h + bound.y; + } else if (currSmooth) { + cp1x = cx + (nx - cx) / 3; + cp1y = cy + (ny - cy) / 3; + } else { + cp1x = cx; + cp1y = cy; + } + + // Incoming control point of next vertex (cp1 of its control points) + let cp2x: number, cp2y: number; + const customNext = controlPoints[next]; + if (nextSmooth && customNext) { + cp2x = customNext[0] * bound.w + bound.x; + cp2y = customNext[1] * bound.h + bound.y; + } else if (nextSmooth) { + cp2x = nx + (cx - nx) / 3; + cp2y = ny + (cy - ny) / 3; + } else { + cp2x = nx; + cp2y = ny; + } + + // Include control handle positions + if (cp1x < minX) minX = cp1x; + if (cp1y < minY) minY = cp1y; + if (cp1x > maxX) maxX = cp1x; + if (cp1y > maxY) maxY = cp1y; + if (cp2x < minX) minX = cp2x; + if (cp2y < minY) minY = cp2y; + if (cp2x > maxX) maxX = cp2x; + if (cp2y > maxY) maxY = cp2y; + + // Use getBezierCurveBoundingBox for tight arc bounds + const bezierParams: BezierCurveParameters = [ + [cx, cy], + [cp1x, cp1y], + [cp2x, cp2y], + [nx, ny], + ]; + const arcBound = getBezierCurveBoundingBox(bezierParams); + if (arcBound.x < minX) minX = arcBound.x; + if (arcBound.y < minY) minY = arcBound.y; + if (arcBound.x + arcBound.w > maxX) maxX = arcBound.x + arcBound.w; + if (arcBound.y + arcBound.h > maxY) maxY = arcBound.y + arcBound.h; + } + + const newW = Math.max(maxX - minX, 1); + const newH = Math.max(maxY - minY, 1); + + // Re-normalize vertices to new bounding box + const normalizedVerts = absVertices.map(([vx, vy]) => [ + (vx - minX) / newW, + (vy - minY) / newH, + ]); + + // Re-normalize control points to new bounding box + const normalizedCPs = controlPoints.map(cp => { + if (!cp) return null; + const absCp1x = cp[0] * bound.w + bound.x; + const absCp1y = cp[1] * bound.h + bound.y; + const absCp2x = cp[2] * bound.w + bound.x; + const absCp2y = cp[3] * bound.h + bound.y; + return [ + (absCp1x - minX) / newW, + (absCp1y - minY) / newH, + (absCp2x - minX) / newW, + (absCp2y - minY) / newH, + ]; + }); + + const newBound = new Bound(minX, minY, newW, newH); + model.xywh = newBound.serialize(); + model.vertices = normalizedVerts; + model.controlPoints = normalizedCPs; } /** diff --git a/packages/affine/model/src/elements/shape/api/polygon.ts b/packages/affine/model/src/elements/shape/api/polygon.ts index 465f6832bee8..cc68f868213b 100644 --- a/packages/affine/model/src/elements/shape/api/polygon.ts +++ b/packages/affine/model/src/elements/shape/api/polygon.ts @@ -1,6 +1,8 @@ import type { IBound, IVec } from '@blocksuite/global/gfx'; import { Bound, + type BezierCurveParameters, + getBezierCurveBoundingBox, getPointsFromBoundWithRotation, linePolygonIntersects, pointInPolygon, @@ -46,6 +48,220 @@ function getPolygonVertices( }; } +/** + * Compute a tight bounding box that encompasses all polygon geometry + * including Bezier curve arcs and control handles. Uses + * getBezierCurveBoundingBox from curve.ts for arc bound computation. + * + * Returns null if the element has no Bezier curves (plain polygon). + */ +function computeBezierAwareBound( + element: ShapeElementModel +): Bound | null { + const vertices = element.vertices; + if (!vertices || vertices.length < 2) return null; + + const smoothFlags = element.smoothFlags; + if (!smoothFlags || !smoothFlags.some(f => f)) return null; + + const bound = Bound.deserialize(element.xywh); + const count = vertices.length; + + // Start with vertex positions + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + + const absVerts: [number, number][] = vertices.map(v => { + const ax = bound.x + v[0] * bound.w; + const ay = bound.y + v[1] * bound.h; + if (ax < minX) minX = ax; + if (ay < minY) minY = ay; + if (ax > maxX) maxX = ax; + if (ay > maxY) maxY = ay; + return [ax, ay]; + }); + + const controlPoints = element.controlPoints; + + // For each edge, compute the Bezier curve bounding box + for (let i = 0; i < count; i++) { + const next = (i + 1) % count; + const currSmooth = smoothFlags[i] ?? false; + const nextSmooth = smoothFlags[next] ?? false; + + if (!currSmooth && !nextSmooth) continue; + + const [cx, cy] = absVerts[i]; + const [nx, ny] = absVerts[next]; + + // Outgoing control point of current vertex + let cp1x: number, cp1y: number; + const customCurr = controlPoints?.[i]; + if (currSmooth) { + cp1x = customCurr + ? customCurr[2] * bound.w + bound.x + : cx + (nx - cx) / 3; + cp1y = customCurr + ? customCurr[3] * bound.h + bound.y + : cy + (ny - cy) / 3; + } else { + cp1x = cx; + cp1y = cy; + } + + // Incoming control point of next vertex + let cp2x: number, cp2y: number; + const customNext = controlPoints?.[next]; + if (nextSmooth) { + cp2x = customNext + ? customNext[0] * bound.w + bound.x + : nx + (cx - nx) / 3; + cp2y = customNext + ? customNext[1] * bound.h + bound.y + : ny + (cy - ny) / 3; + } else { + cp2x = nx; + cp2y = ny; + } + + // Include control handle positions + if (cp1x < minX) minX = cp1x; + if (cp1y < minY) minY = cp1y; + if (cp1x > maxX) maxX = cp1x; + if (cp1y > maxY) maxY = cp1y; + if (cp2x < minX) minX = cp2x; + if (cp2y < minY) minY = cp2y; + if (cp2x > maxX) maxX = cp2x; + if (cp2y > maxY) maxY = cp2y; + + // Use getBezierCurveBoundingBox to compute tight arc bounds + const bezierParams: BezierCurveParameters = [ + [cx, cy], + [cp1x, cp1y], + [cp2x, cp2y], + [nx, ny], + ]; + const arcBound = getBezierCurveBoundingBox(bezierParams); + if (arcBound.x < minX) minX = arcBound.x; + if (arcBound.y < minY) minY = arcBound.y; + if (arcBound.x + arcBound.w > maxX) maxX = arcBound.x + arcBound.w; + if (arcBound.y + arcBound.h > maxY) maxY = arcBound.y + arcBound.h; + } + + return new Bound(minX, minY, maxX - minX, maxY - minY); +} + +/** + * Check whether a point is near any Bezier control handle or on a Bezier + * curve arc for a polygon with smooth vertices. Used by includesPoint to + * prevent deselection when clicking on control handles or curve arcs that + * extend beyond the polygon body. + * + * Returns true if the point is within hitThreshold of a control handle or + * within hitThreshold of a point on any Bezier curve arc. + */ +function pointOnBezierGeometry( + element: ShapeElementModel, + px: number, + py: number, + hitThreshold: number +): boolean { + const vertices = element.vertices; + if (!vertices || vertices.length < 2) return false; + + const smoothFlags = element.smoothFlags; + if (!smoothFlags || !smoothFlags.some(f => f)) return false; + + const bound = Bound.deserialize(element.xywh); + const controlPoints = element.controlPoints; + const count = vertices.length; + const hitDistSq = hitThreshold * hitThreshold; + + const absVerts: [number, number][] = vertices.map(v => [ + bound.x + v[0] * bound.w, + bound.y + v[1] * bound.h, + ]); + + for (let i = 0; i < count; i++) { + const next = (i + 1) % count; + const currSmooth = smoothFlags[i] ?? false; + const nextSmooth = smoothFlags[next] ?? false; + + if (!currSmooth && !nextSmooth) continue; + + const [cx, cy] = absVerts[i]; + const [nx, ny] = absVerts[next]; + + // Outgoing control point of current vertex + let cp1x: number, cp1y: number; + const customCurr = controlPoints?.[i]; + if (currSmooth) { + cp1x = customCurr + ? customCurr[2] * bound.w + bound.x + : cx + (nx - cx) / 3; + cp1y = customCurr + ? customCurr[3] * bound.h + bound.y + : cy + (ny - cy) / 3; + } else { + cp1x = cx; + cp1y = cy; + } + + // Incoming control point of next vertex + let cp2x: number, cp2y: number; + const customNext = controlPoints?.[next]; + if (nextSmooth) { + cp2x = customNext + ? customNext[0] * bound.w + bound.x + : nx + (cx - nx) / 3; + cp2y = customNext + ? customNext[1] * bound.h + bound.y + : ny + (cy - ny) / 3; + } else { + cp2x = nx; + cp2y = ny; + } + + // Check proximity to control handle positions + const dxCp1 = px - cp1x; + const dyCp1 = py - cp1y; + if (dxCp1 * dxCp1 + dyCp1 * dyCp1 <= hitDistSq) return true; + + const dxCp2 = px - cp2x; + const dyCp2 = py - cp2y; + if (dxCp2 * dxCp2 + dyCp2 * dyCp2 <= hitDistSq) return true; + + // Check proximity to the Bezier curve arc by sampling points along the + // cubic Bezier and testing distance to the closest sample. + // Use ~20 samples per segment for reasonable precision. + const steps = 20; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const mt = 1 - t; + const mt2 = mt * mt; + const t2 = t * t; + // Cubic Bezier: B(t) = (1-t)^3*P0 + 3(1-t)^2*t*P1 + 3(1-t)*t^2*P2 + t^3*P3 + const bx = + mt2 * mt * cx + + 3 * mt2 * t * cp1x + + 3 * mt * t2 * cp2x + + t2 * t * nx; + const by = + mt2 * mt * cy + + 3 * mt2 * t * cp1y + + 3 * mt * t2 * cp2y + + t2 * t * ny; + const dbx = px - bx; + const dby = py - by; + if (dbx * dbx + dby * dby <= hitDistSq) return true; + } + } + + return false; +} + export const polygon = { points(bound: IBound, element?: ShapeElementModel): IVec[] { const verts = @@ -85,7 +301,7 @@ export const polygon = { * Determines whether the given model-space point `(x, y)` is considered * a "hit" on this polygon shape. * - * Hit-testing is performed in two passes: + * Hit-testing is performed in three passes: * * 1. **Stroke pass** – `pointOnPolygonStoke` checks whether the point lies * within `hitThreshold / zoom` model units of any polygon edge. This @@ -99,6 +315,11 @@ export const polygon = { * This is what makes drag-to-move work from anywhere in the visible * polygon body regardless of the shape's convexity. * + * 3. **Bezier geometry pass** – when the polygon has smooth vertices with + * Bezier curves, checks whether the point is near a control handle or + * on a Bezier curve arc. This prevents deselection when clicking on + * control handles that extend beyond the polygon body. + * * Rotation is handled transparently: `getPointsFromBoundWithRotation` * returns the polygon's absolute vertex positions already transformed by * the element's `rotate` angle, so both `x`/`y` and `points` are expressed @@ -120,12 +341,14 @@ export const polygon = { // coordinate system as the incoming (x, y) hit-test point. const points = getPointsFromBoundWithRotation(this, pointsFn); + const hitThreshold = (options?.hitThreshold ?? 1) / (options?.zoom ?? 1); + // Pass 1: stroke hit-test (scaled by zoom so the threshold is constant // in screen pixels regardless of the current viewport zoom level). let hit = pointOnPolygonStoke( point, points, - (options?.hitThreshold ?? 1) / (options?.zoom ?? 1) + hitThreshold ); if (!hit) { @@ -136,6 +359,13 @@ export const polygon = { hit = pointInPolygon([x, y], points); } + if (!hit) { + // Pass 3: Bezier geometry hit-test — check control handles and curve + // arcs so the polygon doesn't deselect when clicking on Bezier + // geometry that extends beyond the polygon body. + hit = pointOnBezierGeometry(this, x, y, hitThreshold); + } + return hit; }, @@ -182,4 +412,13 @@ export const polygon = { return new PointLocation(rotatePoint, tangent); }, + + /** + * Compute the element bound for a polygon, expanded to include Bezier + * curve arcs and control handles when present. Returns the standard + * xywh bound for plain polygons without Bezier curves. + */ + elementBound(element: ShapeElementModel): Bound { + return computeBezierAwareBound(element) ?? Bound.deserialize(element.xywh); + }, }; diff --git a/packages/affine/model/src/elements/shape/shape.ts b/packages/affine/model/src/elements/shape/shape.ts index dc5e9013deeb..abd352ad12f6 100644 --- a/packages/affine/model/src/elements/shape/shape.ts +++ b/packages/affine/model/src/elements/shape/shape.ts @@ -1,10 +1,10 @@ import type { - Bound, IBound, IVec, PointLocation, SerializedXYWH, } from '@blocksuite/global/gfx'; +import { Bound, getBoundWithRotation } from '@blocksuite/global/gfx'; import type { BaseElementProps, PointTestOptions } from '@blocksuite/std/gfx'; import { field, @@ -101,6 +101,33 @@ export class ShapeElementModel extends GfxPrimitiveElementModel { return props; } + /** + * Override elementBound for polygon shapes to encompass Bezier curve + * arcs and control handles. For non-polygon shapes, delegates to the + * default implementation. + */ + override get elementBound() { + const polygonApi = shapeMethods[ShapeType.Polygon] as typeof shapeMethods[ShapeType] & { + elementBound?: (element: ShapeElementModel) => Bound; + }; + if (this.shapeType === ShapeType.Polygon && polygonApi.elementBound) { + const bezierBound = polygonApi.elementBound(this); + if (this.rotate) { + return Bound.from( + getBoundWithRotation({ + x: bezierBound.x, + y: bezierBound.y, + w: bezierBound.w, + h: bezierBound.h, + rotate: this.rotate, + }) + ); + } + return bezierBound; + } + return super.elementBound; + } + override containsBound(bounds: Bound) { return shapeMethods[this.shapeType].containsBound(bounds, this); } From ba280762727b5eaae9817d47731099712d1a0863 Mon Sep 17 00:00:00 2001 From: Mathieu Jolly Date: Sun, 22 Mar 2026 10:14:10 +0100 Subject: [PATCH 09/10] fix(edgeless): rotate the vertices points in polygone edition mode to copy vertices positions --- packages/affine/gfx/shape/src/element-view.ts | 16 ++- .../overlay/polygon-vertex-editing-overlay.ts | 122 +++++++++++++++--- 2 files changed, 119 insertions(+), 19 deletions(-) diff --git a/packages/affine/gfx/shape/src/element-view.ts b/packages/affine/gfx/shape/src/element-view.ts index 214084105540..9e7798beb14f 100644 --- a/packages/affine/gfx/shape/src/element-view.ts +++ b/packages/affine/gfx/shape/src/element-view.ts @@ -8,7 +8,7 @@ import { ShapeElementModel, ShapeType, } from '@blocksuite/affine-model'; -import { Bound } from '@blocksuite/global/gfx'; +import { Bound, rotatePoint } from '@blocksuite/global/gfx'; import type { GfxModel } from '@blocksuite/std/gfx'; import { GfxElementModelView, @@ -250,10 +250,15 @@ export class ShapeElementView extends GfxElementModelView { // Record vertex absolute model position const bound = Bound.deserialize(this.model.xywh); const v = verts[this._pendingVertexIndex]; - this._vertexDragStartModelCoord = [ + const localCoord: [number, number] = [ bound.x + v[0] * bound.w, bound.y + v[1] * bound.h, ]; + this._vertexDragStartModelCoord = rotatePoint( + localCoord, + bound.center as [number, number], + this.model.rotate ?? 0 + ) as [number, number]; this._surfaceComponent?.refresh(); return; @@ -276,7 +281,12 @@ export class ShapeElementView extends GfxElementModelView { const cp = this._vertexEditingOverlay!.getBezierControlPoints(this._pendingBezierHandle.vertexIndex); if (cp) { const pt = this._pendingBezierHandle.handleIndex === 0 ? cp.cp1 : cp.cp2; - this._bezierDragStartModelCoord = pt; + const bound = Bound.deserialize(this.model.xywh); + this._bezierDragStartModelCoord = rotatePoint( + pt, + bound.center as [number, number], + this.model.rotate ?? 0 + ) as [number, number]; } this._surfaceComponent?.refresh(); diff --git a/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts b/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts index 5b3206d79b41..a7e171fdfab0 100644 --- a/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts +++ b/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts @@ -162,10 +162,26 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { const zoom = this.gfx.viewport.zoom; const hitDist = VERTEX_HIT_DISTANCE / zoom; + // Inverse-rotate incoming world coordinates into polygon's local space + let localX = modelX; + let localY = modelY; + const rotate = model.rotate ?? 0; + if (rotate) { + const cx = bound.x + bound.w / 2; + const cy = bound.y + bound.h / 2; + const rad = -(rotate * Math.PI) / 180; // negative for inverse rotation + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = modelX - cx; + const dy = modelY - cy; + localX = cx + dx * cos - dy * sin; + localY = cy + dx * sin + dy * cos; + } + for (let i = 0; i < model.vertices.length; i++) { const [ax, ay] = this._toAbsolute(model.vertices[i], bound); - const dx = modelX - ax; - const dy = modelY - ay; + const dx = localX - ax; + const dy = localY - ay; if (Math.sqrt(dx * dx + dy * dy) < hitDist) { return i; } @@ -190,25 +206,39 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { const zoom = this.gfx.viewport.zoom; const snapDist = SNAP_GUIDE_DISTANCE / zoom; - // Collect absolute positions of all other vertices for snapping + // Inverse-rotate mouse position from world space to local (unrotated) space + const rotate = model.rotate ?? 0; + let localX = modelX; + let localY = modelY; + if (rotate) { + const cx = bound.x + bound.w / 2; + const cy = bound.y + bound.h / 2; + const rad = (-rotate * Math.PI) / 180; // negative for inverse + const dx = modelX - cx; + const dy = modelY - cy; + localX = cx + dx * Math.cos(rad) - dy * Math.sin(rad); + localY = cy + dx * Math.sin(rad) + dy * Math.cos(rad); + } + + // Collect local-space absolute positions of all other vertices for snapping const otherAbsolute: [number, number][] = []; for (let i = 0; i < model.vertices.length; i++) { if (i === vertexIndex) continue; otherAbsolute.push(this._toAbsolute(model.vertices[i], bound)); } - // Attempt coordinate snapping - let snappedX = modelX; - let snappedY = modelY; + // Attempt coordinate snapping in local (unrotated) space + let snappedX = localX; + let snappedY = localY; this.snapGuideX = null; this.snapGuideY = null; for (const [ox, oy] of otherAbsolute) { - if (Math.abs(modelX - ox) < snapDist) { + if (Math.abs(localX - ox) < snapDist) { snappedX = ox; this.snapGuideX = ox; } - if (Math.abs(modelY - oy) < snapDist) { + if (Math.abs(localY - oy) < snapDist) { snappedY = oy; this.snapGuideY = oy; } @@ -359,6 +389,22 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { const zoom = this.gfx.viewport.zoom; const hitDist = MIDPOINT_HIT_DISTANCE / zoom; + // Inverse-rotate incoming world coordinates into polygon's local space + let localX = modelX; + let localY = modelY; + const rotate = model.rotate ?? 0; + if (rotate) { + const cx = bound.x + bound.w / 2; + const cy = bound.y + bound.h / 2; + const rad = -(rotate * Math.PI) / 180; // negative for inverse rotation + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = modelX - cx; + const dy = modelY - cy; + localX = cx + dx * cos - dy * sin; + localY = cy + dx * sin + dy * cos; + } + for (let i = 0; i < model.vertices.length; i++) { const [ax, ay] = this._toAbsolute(model.vertices[i], bound); const nextIdx = (i + 1) % model.vertices.length; @@ -366,8 +412,8 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { const mx = (ax + bx) / 2; const my = (ay + by) / 2; - const dx = modelX - mx; - const dy = modelY - my; + const dx = localX - mx; + const dy = localY - my; if (Math.sqrt(dx * dx + dy * dy) < hitDist) { return i; } @@ -386,24 +432,41 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { const model = this._getPolygonModel(); if (!model || !model.vertices || !model.smoothFlags) return null; + const bound = Bound.deserialize(model.xywh); const zoom = this.gfx.viewport.zoom; const hitDist = BEZIER_HANDLE_RADIUS * 2 / zoom; + // Inverse-rotate incoming world coordinates into polygon's local space + let localX = modelX; + let localY = modelY; + const rotate = model.rotate ?? 0; + if (rotate) { + const cx = bound.x + bound.w / 2; + const cy = bound.y + bound.h / 2; + const rad = (-rotate * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = modelX - cx; + const dy = modelY - cy; + localX = cx + dx * cos - dy * sin; + localY = cy + dx * sin + dy * cos; + } + for (let i = 0; i < model.vertices.length; i++) { if (!model.smoothFlags[i]) continue; const cp = this.getBezierControlPoints(i); if (!cp) continue; // Check cp1 - const dx1 = modelX - cp.cp1[0]; - const dy1 = modelY - cp.cp1[1]; + const dx1 = localX - cp.cp1[0]; + const dy1 = localY - cp.cp1[1]; if (Math.sqrt(dx1 * dx1 + dy1 * dy1) < hitDist) { return { vertexIndex: i, handleIndex: 0 }; } // Check cp2 - const dx2 = modelX - cp.cp2[0]; - const dy2 = modelY - cp.cp2[1]; + const dx2 = localX - cp.cp2[0]; + const dy2 = localY - cp.cp2[1]; if (Math.sqrt(dx2 * dx2 + dy2 * dy2) < hitDist) { return { vertexIndex: i, handleIndex: 1 }; } @@ -427,6 +490,22 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { const bound = Bound.deserialize(model.xywh); const count = model.vertices.length; + // Inverse-rotate mouse position from world space to local (unrotated) space + const rotate = model.rotate ?? 0; + let localX = modelX; + let localY = modelY; + if (rotate) { + const cx = bound.x + bound.w / 2; + const cy = bound.y + bound.h / 2; + const rad = -(rotate * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = modelX - cx; + const dy = modelY - cy; + localX = cx + dx * cos - dy * sin; + localY = cy + dx * sin + dy * cos; + } + // Initialize controlPoints array if null let controlPoints: (number[] | null)[] = model.controlPoints ? [...model.controlPoints] @@ -449,8 +528,8 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { } // Convert absolute position to normalized coordinates (in current bound) - const normX = (modelX - bound.x) / bound.w; - const normY = (modelY - bound.y) / bound.h; + const normX = (localX - bound.x) / bound.w; + const normY = (localY - bound.y) / bound.h; // Update the specific handle const entry = [...controlPoints[vertexIndex]!]; @@ -984,6 +1063,17 @@ export class PolygonVertexEditingOverlay extends ToolOverlay { ctx.save(); ctx.globalAlpha = this.globalAlpha; + // ── Apply rotation around shape center ───────────────────────── + const rotate = model.rotate ?? 0; + if (rotate) { + const cx = bound.x + bound.w / 2; + const cy = bound.y + bound.h / 2; + const rad = (rotate * Math.PI) / 180; + ctx.translate(cx, cy); + ctx.rotate(rad); + ctx.translate(-cx, -cy); + } + // ── Draw snap guides ──────────────────────────────────────────── if (this.activeVertexIndex >= 0) { ctx.setLineDash([4 / zoom, 3 / zoom]); From bd05ab589609fd615a9ada00354a44bed30e50f3 Mon Sep 17 00:00:00 2001 From: Mathieu Jolly Date: Sun, 22 Mar 2026 10:58:23 +0100 Subject: [PATCH 10/10] test(edgeless): unit test modification --- .../store/src/__tests__/polygon-vertex-ops.unit.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/framework/store/src/__tests__/polygon-vertex-ops.unit.spec.ts b/packages/framework/store/src/__tests__/polygon-vertex-ops.unit.spec.ts index 8a1165b31d43..0c535b64992e 100644 --- a/packages/framework/store/src/__tests__/polygon-vertex-ops.unit.spec.ts +++ b/packages/framework/store/src/__tests__/polygon-vertex-ops.unit.spec.ts @@ -271,7 +271,7 @@ describe('polygon vertex add (insertVertexAtMidpoint)', () => { test('inserts false into smoothFlags at the correct index', () => { const smoothFlags = [true, false, true, false]; // SQUARE has 4 vertices - const { vertices, smoothFlags: newFlags, insertedIndex } = + const { smoothFlags: newFlags, insertedIndex } = insertVertexAtMidpoint(SQUARE, smoothFlags, 1); expect(newFlags).not.toBeNull(); expect(newFlags!.length).toBe(5); @@ -605,9 +605,7 @@ describe('proportional resize (vertices remain normalized)', () => { // Simulate what happens during resize: only xywh changes, vertices stay const originalVertices = [...PENTAGON]; - // "Resize" by updating the bound only - const _newBound = { x: 50, y: 50, w: 300, h: 150 }; - + // "Resize" by updating the bound only (vertices stay in normalized [0-1] space) // The vertex array should be identical (resize doesn't touch vertices) expect(PENTAGON).toEqual(originalVertices); });