diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000000..33aa0bffa7e3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(npx tsc:*)", + "Bash(npx vitest:*)", + "Bash(python:*)", + "Bash(yarn dev:*)", + "Bash(git log:*)" + ] + } +} 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/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. 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-manager.ts b/packages/affine/gfx/connector/src/connector-manager.ts index 55ed2bc233b6..bae04eaa7d9b 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,70 @@ 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], + ]; + + 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 = [ + bound.x + vertCoord[0] * bound.w, + bound.y + vertCoord[1] * bound.h, + ]; + const vertRotated = getPointFromBoundsWithRotation( + { ...bound, rotate }, + vertAbs + ); + anchors.push({ + point: new PointLocation(vertRotated, edgeTangent), + 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 + ); + anchors.push({ + point: new PointLocation(midRotated, edgeTangent), + 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/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/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/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/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; } }) ); 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..f1161926ffac --- /dev/null +++ b/packages/affine/gfx/shape/src/element-renderer/shape/polygon.ts @@ -0,0 +1,134 @@ +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 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) { + 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; + const customCurr = controlPoints?.[i]; + if (currSmooth) { + 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 = customNext ? customNext[0] * renderWidth : nx + (cx - nx) / 3; + cp2y = customNext ? customNext[1] * renderHeight : 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..eb669152002f 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,89 @@ 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 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; + + 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 { + 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) { + ctx.lineTo(nx, ny); + } else { + let cp1x: number, cp1y: number; + const customCurr = controlPoints?.[i]; + if (currSmooth) { + cp1x = customCurr ? customCurr[2] * width : cx + (nx - cx) / 3; + cp1y = customCurr ? customCurr[3] * height : cy + (ny - cy) / 3; + } else { + cp1x = cx; + cp1y = cy; + } + + let cp2x: number, cp2y: number; + const customNext = controlPoints?.[next]; + if (nextSmooth) { + cp2x = customNext ? customNext[0] * width : nx + (cx - nx) / 3; + cp2y = customNext ? customNext[1] * height : 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..9e7798beb14f 100644 --- a/packages/affine/gfx/shape/src/element-view.ts +++ b/packages/affine/gfx/shape/src/element-view.ts @@ -1,19 +1,75 @@ -import { ShapeElementModel } from '@blocksuite/affine-model'; +import { + type SurfaceBlockComponent, + type SurfaceBlockModel, +} from '@blocksuite/affine-block-surface'; +import { ConnectorPathGenerator } from '@blocksuite/affine-gfx-connector'; +import { + type ConnectorElementModel, + ShapeElementModel, + ShapeType, +} from '@blocksuite/affine-model'; +import { Bound, rotatePoint } from '@blocksuite/global/gfx'; +import type { GfxModel } from '@blocksuite/std/gfx'; import { GfxElementModelView, GfxViewInteractionExtension, } from '@blocksuite/std/gfx'; +import type { PointerEventState } from '@blocksuite/std'; 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; + for (const disposer of this._dragHandlerDisposers) { + disposer(); + } + this._dragHandlerDisposers = []; + this._removeVertexEditingOverlay(); + super.onDestroyed(); + } + + /** + * Synchronously recalculate and apply the path for every connector + * attached to this polygon shape. + * + * Called during polygon vertex drag so that connector routing stays + * visually correct on every drag frame. + */ + private _syncConnectorPaths(): void { + 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); + } } private _initDblClickToEdit(): void { @@ -25,14 +81,525 @@ export class ShapeElementView extends GfxElementModelView { !this.model.isLocked() && this.model instanceof ShapeElementModel ) { + if (this._vertexEditingOverlay?.isEditing) { + this._exitVertexEditingMode(); + } mountShapeTextEditor(this.model, edgeless); } }); } + + /** + * Whether a vertex is currently being dragged. + * Set true in onDragStart when _pendingVertexIndex ≥ 0; reset in onDragEnd. + */ + 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. + * Reset on each pointerdown and when drag ends. + */ + 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. + * 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. + */ + 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(); + // 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; + } + + // 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'); + } + 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) { + 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(); + // 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('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; + } + }; + document.addEventListener('keydown', keydownHandler, { capture: true }); + this._escapeKeyDisposer = () => { + document.removeEventListener('keydown', keydownHandler, { + capture: true, + }); + }; + + // ── 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]; + 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; + } + } + + // 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('vertices'); + 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; + 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(); + } + }); + + 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('vertices'); + 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]; + } + + /** + * 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; + + // 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; + } + + /** + * 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(); + } + }) + ); + + // ── Vertex press tracking ───────────────────────────────────────────── + // 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. + 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); + + // 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 + 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(); + // 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'); + 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'); + 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(); + }); + } + + 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; + + // 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(); + } + + /** Get the vertex editing overlay (used by interaction handlers). */ + 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 = 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/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..a7e171fdfab0 --- /dev/null +++ b/packages/affine/gfx/shape/src/overlay/polygon-vertex-editing-overlay.ts @@ -0,0 +1,1213 @@ +import { + type RoughCanvas, + ToolOverlay, +} from '@blocksuite/affine-block-surface'; +import { ShapeElementModel, ShapeType } from '@blocksuite/affine-model'; +import { + Bound, + type BezierCurveParameters, + getBezierCurveBoundingBox, +} 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; + + /** 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). */ + 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; + + // 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 = localX - ax; + const dy = localY - 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; + + // 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 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(localX - ox) < snapDist) { + snappedX = ox; + this.snapGuideX = ox; + } + if (Math.abs(localY - 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; + } + + // 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); + + // 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; + + // 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). */ + 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; + + // 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; + const [bx, by] = this._toAbsolute(model.vertices[nextIdx], bound); + const mx = (ax + bx) / 2; + const my = (ay + by) / 2; + + const dx = localX - mx; + const dy = localY - my; + if (Math.sqrt(dx * dx + dy * dy) < hitDist) { + return i; + } + } + 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 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 = 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 = localX - cp.cp2[0]; + const dy2 = localY - 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; + + // 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] + : 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 (in current bound) + const normX = (localX - bound.x) / bound.w; + const normY = (localY - 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; + + // ── 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; + } + + /** + * 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; + } + + // Update controlPoints if present + if (model.controlPoints) { + const cps = [...model.controlPoints]; + cps.splice(insertIdx, 0, null); + model.controlPoints = cps; + } + + 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; + } + + // 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: [number, number][] = vertices.map( + v => this._toAbsolute(v, bound) 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 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); + + 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; + + // 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; + } + + /** + * 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 bound = Bound.deserialize(model.xywh); + 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); + + const wasSmooth = flags[vertexIndex]; + 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; + } + + /** + * 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); + + // 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; + + 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); + + 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; + + 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; + + // ── 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]); + 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..c16f693f7f94 --- /dev/null +++ b/packages/affine/gfx/shape/src/polygon-tool.ts @@ -0,0 +1,430 @@ +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, + ]); + + // 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 + 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: smoothFlagsForModel, + ...(controlPointsForModel ? { controlPoints: controlPointsForModel } : {}), + }); + + 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/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/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..cc68f868213b --- /dev/null +++ b/packages/affine/model/src/elements/shape/api/polygon.ts @@ -0,0 +1,424 @@ +import type { IBound, IVec } from '@blocksuite/global/gfx'; +import { + Bound, + type BezierCurveParameters, + getBezierCurveBoundingBox, + getPointsFromBoundWithRotation, + linePolygonIntersects, + pointInPolygon, + PointLocation, + pointOnPolygonStoke, + polygonGetPointTangent, + polygonNearestPoint, + polygonNearestPointAndTangent, + rotatePoints, +} from '@blocksuite/global/gfx'; +import type { PointTestOptions } from '@blocksuite/std/gfx'; +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]); + }; +} + +/** + * 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 = + 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(); + }, + + /** + * Determines whether the given model-space point `(x, y)` is considered + * a "hit" on this polygon shape. + * + * 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 + * 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. + * + * 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 + * 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, + y: number, + options: PointTestOptions + ) { + 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); + + 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, + hitThreshold + ); + + if (!hit) { + // 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); + } + + 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; + }, + + 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); + + // Rotate everything together (same pattern as diamond/triangle) + points = rotatePoints(points, bound.center, element.rotate); + const rotatePoint = points.pop() as IVec; + + // Try exact edge tangent first (works when BB point lands on a polygon edge) + let tangent = polygonGetPointTangent(points, rotatePoint); + + // 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); + }, + + /** + * 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 81446124a50b..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, @@ -44,6 +44,35 @@ 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; + + /** + * 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; @@ -72,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); } @@ -181,6 +237,41 @@ 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; + + /** + * 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]'; } @@ -257,4 +348,16 @@ 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; + + @prop() + accessor controlPoints: (number[] | null)[] | 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/global/src/gfx/math.ts b/packages/framework/global/src/gfx/math.ts index b63e4bd56216..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); @@ -340,16 +368,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-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/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..0c535b64992e --- /dev/null +++ b/packages/framework/store/src/__tests__/polygon-vertex-ops.unit.spec.ts @@ -0,0 +1,783 @@ +/** + * 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 { 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 (vertices stay in normalized [0-1] space) + // 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 + }); +}); 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); + }); +});