Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(npx tsc:*)",
"Bash(npx vitest:*)",
"Bash(python:*)",
"Bash(yarn dev:*)",
"Bash(git log:*)"
]
}
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.DS_Store
.claude
.idea
.vscode/*
.zed
Expand Down
2 changes: 1 addition & 1 deletion docs/components/blocks/surface-block.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions docs/components/editors/edgeless-data-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
Expand Down
2 changes: 1 addition & 1 deletion docs/components/editors/edgeless-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
264 changes: 264 additions & 0 deletions packages/affine/blocks/surface/src/__tests__/math-utils.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
linePolygonIntersects,
linePolylineIntersects,
pointAlmostEqual,
pointInPolygon,
pointOnPolygonStoke,
polygonGetPointTangent,
rotatePoints,
toDegree,
Expand Down Expand Up @@ -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);
});
});
27 changes: 26 additions & 1 deletion packages/affine/blocks/surface/src/tool/default-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading