feat(editor): draw to shape (auto-detection) (#9313)

* Initial commit

* Segments detection

* Remove aspect ratio

* Refactor

* Fix tests

* Toggle shapeSnap

* Fix linting

* debug: live preview

* Rebase fixes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Bind to elements (no hover background)

* Shape closed

* Fix rad calc + rename segments

* Comments

* Fix binding highlighting + lints

* Rename to convert to shape

* fix: Binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Refactor spline simplification

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove unused doBoundsIntersect

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Restore type signature

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Propose new button shapes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove unused types

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Icon shapes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Element cloning

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Element cloning

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove unused

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Shape settings in recognition mode

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove debug

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Actions

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrowheads for freedraw

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Draw shape tool instead of freedraw

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Keyboard shortcut visibility

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Unique cursor for draw shape

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove selection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Shape selection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Improve shape recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Further refine shape recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Preview

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Add hint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Refined shape recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Less ellipse more rectanguloid

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Refined arrow recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Refinerecognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Lower linear threshold

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Rectangle head arrow

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: More arrowhead sizes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow remains an arrow

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Draw line between elements converts to arrow

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* remove pendingDrawShapeElement

* fix: Arrow start point binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Different arrow endpoint heuristic

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Point type

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Various small fixes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Avoid recompute

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Type issues

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* test(editor): cover the drawShape (snap-to-shape) tool

The drawShape tool had no dedicated tests, which let master's toolbar /
active-tool rewrite silently break it during the merge: actionFinalize
reverted to the selection tool after every sketch, because the drawShape
flow finalizes without a `newElement` and so failed master's new
`&& element` guard.

Covers the behavior the merge had to preserve:
- sketch recognition (rectangle, ellipse) and the too-small no-op
- the tool stays active after finalizing, so shapes can be sketched
  consecutively (regression guard for the above)
- activation via the S shortcut and the extra-tools dropdown
- the tool's own cursor

Points are fed into the trail directly rather than through pointermove,
which is rAF-throttled and would drop all but one point per frame — the
same approach lasso.test.tsx uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Add test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Breakout from App.tsx

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Shortcut is now Shift+X

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: No selecting arrows for shape recog

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Double history

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: PCA + feature extraction recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* reduce and normalize min-size gates

* never select newElement when shape recog tool active

* simplify lifecycle & remove double finalize

* make arrows bindings orbit

* funnel though actionFinalize & fix two unrelated cleanup cases

* use crosshair cursor

* show strokeWidth

* switch icons for drawShape & extraTools

* dedupe getBoundsFromPoints

* remove unused helpers

* jsdoc cleanup

* dynamic trail size

* improve rectangle recognition

* disallow linears to deviate too much from a straight line

* put under freedraw tool on tablet/mobile

* tweak label

* rename `drawShape` -> `autoshape`

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mathias Krafft
2026-07-23 18:42:42 +02:00
committed by GitHub
co-authored by Claude Fable 5 dwelle Mark Tolmacs
parent e6ae6bf057
commit f179f7ffd2
36 changed files with 3115 additions and 138 deletions
+2
View File
@@ -371,6 +371,7 @@ export const TEXT_ALIGN = {
};
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
export const ELEMENT_PENDING_DRAW_SHAPE_OPACITY = 70;
// Radius represented as 25% of element's largest side (width/height).
// Used for LEGACY and PROPORTIONAL_RADIUS algorithms, or when the element is
@@ -498,6 +499,7 @@ export const TOOL_TYPE = {
magicframe: "magicframe",
embeddable: "embeddable",
laser: "laser",
autoshape: "autoshape",
} as const;
export const EDITOR_LS_KEYS = {
+8 -4
View File
@@ -7,7 +7,8 @@ export const hasBackground = (type: ElementOrToolType) =>
type === "ellipse" ||
type === "diamond" ||
type === "line" ||
type === "freedraw";
type === "freedraw" ||
type === "autoshape";
export const hasStrokeColor = (type: ElementOrToolType) =>
type === "rectangle" ||
@@ -17,7 +18,8 @@ export const hasStrokeColor = (type: ElementOrToolType) =>
type === "arrow" ||
type === "line" ||
type === "text" ||
type === "embeddable";
type === "embeddable" ||
type === "autoshape";
export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" ||
@@ -27,7 +29,8 @@ export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "diamond" ||
type === "freedraw" ||
type === "arrow" ||
type === "line";
type === "line" ||
type === "autoshape";
export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "rectangle" ||
@@ -36,7 +39,8 @@ export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "ellipse" ||
type === "diamond" ||
type === "arrow" ||
type === "line";
type === "line" ||
type === "autoshape";
export const hasFreedrawMode = (type: ElementOrToolType) => type === "freedraw";
+818
View File
@@ -0,0 +1,818 @@
import {
getBoundsFromPoints,
getElementAbsoluteCoords,
} from "@excalidraw/element/bounds";
import {
newArrowElement,
newElement,
newLinearElement,
} from "@excalidraw/element/newElement";
import {
ELEMENT_PENDING_DRAW_SHAPE_OPACITY,
getStrokeWidthByKey,
ROUNDNESS,
} from "@excalidraw/common";
import {
convexHull,
distanceToLineSegment,
elongation,
kurtosis,
lineSegment,
orientPrincipalAxes,
pointFrom,
polygonArea,
principalAxes,
principalCoords,
skewness,
} from "@excalidraw/math";
import type { App, AppState } from "@excalidraw/excalidraw/types";
import type { LocalPoint, GlobalPoint, Radians } from "@excalidraw/math";
import type { Bounds } from "@excalidraw/common";
import type {
ExcalidrawArrowElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawFrameLikeElement,
ExcalidrawRectangleElement,
ElementsMap,
ExcalidrawNonSelectionElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawLineElement,
} from "@excalidraw/element/types";
import { getFrameLikeElements } from "./frame";
import { isLinearElement, isUsingAdaptiveRadius } from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor";
declare global {
interface Window {
LAST_POINTS?: readonly GlobalPoint[];
}
}
// A moment-based shape recognizer.
//
// NOTE: Rectangle vs. diamond is deliberately *not* rotation invariant. A 45
// rectangle = diamond.
type NonDeletedRecognizedShapeElement = NonDeleted<
| ExcalidrawRectangleElement
| ExcalidrawEllipseElement
| ExcalidrawDiamondElement
| ExcalidrawArrowElement
| ExcalidrawLineElement
| ExcalidrawFreeDrawElement
>;
type Shape = NonDeletedRecognizedShapeElement["type"];
interface ShapeRecognitionResult<P extends LocalPoint | GlobalPoint> {
// The type of the recognized shape, or "freedraw" if no good match was found
type: Shape;
// The original input points (not simplified)
points: readonly P[];
// Bounding box of the original input points, used for positioning the
// converted shape
boundingBox: Bounds;
}
// Number of points every stroke is resampled to before feature extraction.
const RESAMPLE_N = 64;
// Minimum apparent (on-screen) size of a stroke's larger bounding-box
// dimension, in pixels, for recognition to run — below this the stroke reads
// as an accidental scribble. Compared against `sceneSize * zoom` so the same
// physical gesture behaves the same at every zoom level.
//
// Maximum not required. Runtime is O(point count), points come in at roughly
// one per frame.
const RECOGNITION_MIN_SCREEN_SIZE = 25;
// A stroke whose endpoints are farther apart than this fraction of its own
// path length is treated as open (a line or an arrow) rather than closed (a
// rectangle, diamond or ellipse).
const CLOSED_GAP_MAX_RATIO = 0.15;
// Maximum elongation for an open stroke to count as straight.
const LINEAR_MAX_ELONGATION = 0.25;
// Fraction of the start→tip distance around the tip inside which an arrowhead
// may live: points there are exempt from the shaft straightness check.
const ARROWHEAD_ZONE_RATIO = 0.5;
// Maximum distance any point outside the arrowhead zone may stray from the
// start→tip chord, as a fraction of the chord length. Loose enough for a lazy
// bow or a small squiggle, tight enough to reject elbows, arcs and sawtooths
// that only *statistically* look straight (their point-cloud elongation
// passes, their geometry doesn't).
const LINEAR_MAX_SHAFT_DEVIATION = 0.15;
// Minimum |skew| along the major axis for an open, straight stroke to be an
// arrow rather than a plain line. Arrowhead add "mass" -> skews that way.
const ARROW_MIN_SKEW = 0.3;
// Maximum feature-space distance to a shape prototype, in units of the
// per-feature tolerances in CLOSED_SHAPE_PROTOTYPES. Beyond this the stroke is
// not enough like any known shape and stays freedraw.
const CLOSED_SHAPE_MAX_DISTANCE = 1.5;
// Half-width, in resampled points, of the chord window used to measure local
// turning along the stroke. Wide enough to smooth pen wobble, narrow enough
// that adjacent corners of a small quadrilateral stay separate peaks.
const TURN_WINDOW = 3;
// =============================================================================
// Feature extraction
// =============================================================================
// Resample `pts` to exactly `n` evenly-spaced points along the stroke path.
function resample<P extends LocalPoint | GlobalPoint>(
pts: readonly P[],
n: number,
): P[] {
let totalLen = 0;
for (let i = 1; i < pts.length; i++) {
totalLen += Math.hypot(
pts[i][0] - pts[i - 1][0],
pts[i][1] - pts[i - 1][1],
);
}
const interval = totalLen / (n - 1);
let accumulated = 0;
const result: P[] = [pts[0]];
let prev = pts[0];
for (let i = 1; i < pts.length; i++) {
const curr = pts[i];
const segLen = Math.hypot(curr[0] - prev[0], curr[1] - prev[1]);
if (accumulated + segLen >= interval) {
// Insert interpolated points within this segment
let remaining = interval - accumulated;
while (remaining <= segLen + 1e-10) {
const t = remaining / segLen;
const newPt: P = [
prev[0] + t * (curr[0] - prev[0]),
prev[1] + t * (curr[1] - prev[1]),
] as P;
result.push(newPt);
if (result.length === n) {
return result;
}
prev = newPt;
accumulated = 0;
remaining += interval;
}
accumulated = segLen - (remaining - interval);
} else {
accumulated += segLen;
}
prev = curr;
}
// Fill any remaining slots with the last point (rounding errors)
while (result.length < n) {
result.push(pts[pts.length - 1]);
}
return result;
}
// Statistical description of a stroke, in terms that don't depend on where,
// how big, or (except where noted) how rotated it was drawn.
interface StrokeFeatures {
// Straight-line distance between the endpoints over the stroke's own path
// length. ~0 when the stroke returns to its start.
gapRatio: number;
// Point cloud elongation along the major axis.
elongation: number;
// Skew of the points projected onto the major axis. Signed so that it is
// <= 0 by construction.
majorSkew: number;
// Convex hull area over axis-aligned bounding box area, in the screen
// frame: ~1 for a rectangle, ~PI/4 for an ellipse, ~1/2 for a diamond.
hullFillRatio: number;
// Share of the stroke's total turning concentrated in its 4 strongest
// corner loci: ~1 for any quadrilateral — however tapered or slanted, all
// turning happens at the corners — vs. ~0.5 for an ellipse, whose turning
// is spread along the whole outline. This is what tells a sloppy,
// trapezoid-ish rectangle (hull fill ≈ PI/4, same as an ellipse's) from an
// actual ellipse: sharp corners.
cornerTurnShare: number;
// kurtosis along x times kurtosis along y, in the screen frame. The two
// factors each vary with the aspect ratio, but their product barely does:
// ~1.83 for any rectangle, 2.25 for any ellipse, 3.24 for any diamond.
kurtosisProduct: number;
// How far the stroke strays from the straight chord between its start and
// its farthest point ("tip"), as a fraction of that chord's length — points
// within ARROWHEAD_ZONE_RATIO of the tip are exempt, so an arrowhead
// doesn't count as straying. ~0 for a line or arrow however wobbly, large
// for elbows, arcs and sawtooths, whose point-cloud statistics alone can
// still look line-like.
shaftDeviationRatio: number;
}
// See StrokeFeatures.shaftDeviationRatio.
function shaftDeviationRatio<P extends LocalPoint | GlobalPoint>(
pts: readonly P[],
): number {
const start = pts[0];
let tip = start;
let tipDistance = 0;
for (const point of pts) {
const distance = Math.hypot(point[0] - start[0], point[1] - start[1]);
if (distance > tipDistance) {
tipDistance = distance;
tip = point;
}
}
if (tipDistance === 0) {
return 0;
}
const chord = lineSegment(start, tip);
let maxDeviation = 0;
for (const point of pts) {
const tipDist = Math.hypot(point[0] - tip[0], point[1] - tip[1]);
if (tipDist <= ARROWHEAD_ZONE_RATIO * tipDistance) {
continue;
}
maxDeviation = Math.max(maxDeviation, distanceToLineSegment(point, chord));
}
return maxDeviation / tipDistance;
}
// The turn angle at every resampled point, measured between the incoming and
// outgoing chords `TURN_WINDOW` points away. The stroke is deliberately NOT
// treated as a closed loop: wrapping around would let the closure artifacts of
// a hand-drawn outline (overshooting or undershooting the starting point)
// fabricate a sharp corner that was never drawn.
function windowedTurns<P extends LocalPoint | GlobalPoint>(
pts: readonly P[],
): number[] {
const turns: number[] = [];
for (let i = TURN_WINDOW; i < pts.length - TURN_WINDOW; i++) {
const [ax, ay] = pts[i - TURN_WINDOW];
const [bx, by] = pts[i];
const [cx, cy] = pts[i + TURN_WINDOW];
const v1x = bx - ax;
const v1y = by - ay;
const v2x = cx - bx;
const v2y = cy - by;
turns.push(
Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y)),
);
}
return turns;
}
// See StrokeFeatures.cornerTurnShare. Greedily picks the 4 strongest turn
// peaks, each absorbing its ±TURN_WINDOW neighborhood so one physical corner
// (whose turn smears across the window) counts once.
function cornerTurnShare<P extends LocalPoint | GlobalPoint>(
pts: readonly P[],
): number {
const turns = windowedTurns(pts);
const total = turns.reduce((sum, turn) => sum + turn, 0);
if (total === 0) {
return 0;
}
const taken = new Array<boolean>(turns.length).fill(false);
let top4 = 0;
for (let corner = 0; corner < 4; corner++) {
let peak = -1;
let peakTurn = 0;
for (let i = 0; i < turns.length; i++) {
if (!taken[i] && turns[i] > peakTurn) {
peakTurn = turns[i];
peak = i;
}
}
if (peak < 0) {
break;
}
for (let i = peak - TURN_WINDOW; i <= peak + TURN_WINDOW; i++) {
if (i >= 0 && i < turns.length && !taken[i]) {
top4 += turns[i];
taken[i] = true;
}
}
}
return top4 / total;
}
function extractFeatures<P extends LocalPoint | GlobalPoint>(
points: readonly P[],
): StrokeFeatures {
// Resample first: features are moments of the point set, so they would
// otherwise be biased by pointer speed, which crowds samples wherever the
// user drew slowly.
const pts = resample(points, RESAMPLE_N);
let pathLength = 0;
for (let i = 1; i < pts.length; i++) {
pathLength += Math.hypot(
pts[i][0] - pts[i - 1][0],
pts[i][1] - pts[i - 1][1],
);
}
const gap = Math.hypot(
pts[pts.length - 1][0] - pts[0][0],
pts[pts.length - 1][1] - pts[0][1],
);
const axes = orientPrincipalAxes(pts, principalAxes(pts));
const majorProjection = principalCoords(pts, axes).map(([u]) => u);
const hull = convexHull(pts);
const [minX, minY, maxX, maxY] = getBoundsFromPoints(pts);
const boxArea = (maxX - minX) * (maxY - minY);
return {
gapRatio: pathLength > 0 ? gap / pathLength : 0,
elongation: elongation(axes),
majorSkew: skewness(majorProjection),
hullFillRatio: boxArea > 0 ? polygonArea(hull) / boxArea : 0,
cornerTurnShare: cornerTurnShare(pts),
kurtosisProduct:
kurtosis(pts.map(([x]) => x)) * kurtosis(pts.map(([, y]) => y)),
shaftDeviationRatio: shaftDeviationRatio(pts),
};
}
// =============================================================================
// Classification
// =============================================================================
const CLOSED_SHAPE_PROTOTYPES: readonly {
type: Shape;
hullFillRatio: number;
cornerTurnShare: number;
kurtosisProduct: number;
}[] = [
{
type: "rectangle",
hullFillRatio: 1,
cornerTurnShare: 0.95,
kurtosisProduct: 1.83,
},
{
type: "diamond",
hullFillRatio: 0.5,
cornerTurnShare: 0.95,
kurtosisProduct: 3.24,
},
{
type: "ellipse",
hullFillRatio: Math.PI / 4,
cornerTurnShare: 0.55,
kurtosisProduct: 2.25,
},
];
// Hull fill no longer has to separate rectangles from ellipses on its own
// (cornerTurnShare carries that), so it can afford the slack that lets a
// tapered, hand-drawn quadrilateral (fill ≈ 0.8) still read as a rectangle.
const HULL_FILL_RATIO_TOLERANCE = 0.2;
const CORNER_TURN_SHARE_TOLERANCE = 0.2;
const KURTOSIS_PRODUCT_TOLERANCE = 0.7;
// Pick the closed shape whose prototype the stroke's features sit closest to,
// or freedraw when even the nearest one is too far away.
function classifyClosedStroke(features: StrokeFeatures): Shape {
let best: Shape = "freedraw";
let bestDistance = CLOSED_SHAPE_MAX_DISTANCE;
for (const prototype of CLOSED_SHAPE_PROTOTYPES) {
const distance = Math.hypot(
(features.hullFillRatio - prototype.hullFillRatio) /
HULL_FILL_RATIO_TOLERANCE,
(features.cornerTurnShare - prototype.cornerTurnShare) /
CORNER_TURN_SHARE_TOLERANCE,
(features.kurtosisProduct - prototype.kurtosisProduct) /
KURTOSIS_PRODUCT_TOLERANCE,
);
if (distance < bestDistance) {
bestDistance = distance;
best = prototype.type;
}
}
return best;
}
// An open stroke is a line or an arrow, provided it is straight enough; the
// arrowhead is what makes the point distribution lopsided.
function classifyOpenStroke(features: StrokeFeatures): Shape {
if (
features.elongation > LINEAR_MAX_ELONGATION ||
features.shaftDeviationRatio > LINEAR_MAX_SHAFT_DEVIATION
) {
return "freedraw";
}
return Math.abs(features.majorSkew) >= ARROW_MIN_SKEW ? "arrow" : "line";
}
function classify(features: StrokeFeatures): Shape {
return features.gapRatio > CLOSED_GAP_MAX_RATIO
? classifyOpenStroke(features)
: classifyClosedStroke(features);
}
// Arrow endpoint selection
// When an arrow is recognized, the original input's last point may not
// correspond to the actual arrow tip. The tip must lie on the bounding-box
// perimeter and is the perimeter point farthest from the start (first input
// point). We find the closest original input point to that ideal tip.
function getArrowEndpoint<P extends LocalPoint | GlobalPoint>(
points: readonly P[],
boundingBox: Bounds,
startPoint: P,
): P {
const [minX, minY, maxX, maxY] = boundingBox;
const w = maxX - minX;
const h = maxY - minY;
// Degenerate bounding box – fall back to last point
if (w === 0 && h === 0) {
return points[points.length - 1];
}
// Perimeter corners / edge points to check (4 corners + 4 edge midpoints
// give good coverage of the perimeter).
const perimeterPoints: LocalPoint[] = [
pointFrom(minX, minY),
pointFrom((minX + maxX) / 2, minY),
pointFrom(maxX, minY),
pointFrom(maxX, (minY + maxY) / 2),
pointFrom(maxX, maxY),
pointFrom((minX + maxX) / 2, maxY),
pointFrom(minX, maxY),
pointFrom(minX, (minY + maxY) / 2),
];
// Ideal endpoint = perimeter point farthest from start.
let idealDist = -1;
let idealEndpoint: LocalPoint = pointFrom(maxX, maxY);
for (const pp of perimeterPoints) {
const d = Math.hypot(pp[0] - startPoint[0], pp[1] - startPoint[1]);
if (d > idealDist) {
idealDist = d;
idealEndpoint = pp;
}
}
// Find the original input point closest to the ideal endpoint.
let bestDist = Infinity;
let bestPoint: P = points[points.length - 1];
for (const pt of points) {
const d = Math.hypot(pt[0] - idealEndpoint[0], pt[1] - idealEndpoint[1]);
if (d < bestDist) {
bestDist = d;
bestPoint = pt;
}
}
return bestPoint;
}
// =============================================================================
// Public API
// =============================================================================
// Recognizes common shapes from free-draw input points
export const recognizeShape = <P extends LocalPoint | GlobalPoint>(
points: P[],
previousElement: ExcalidrawElement | null,
zoom: number = 1,
): ShapeRecognitionResult<P> => {
// DEBUG (will be removed lated)
if (typeof window !== "undefined") {
window.LAST_POINTS = points.map(([x, y]) => pointFrom<GlobalPoint>(x, y));
}
const boundingBox = getBoundsFromPoints(points);
const [minX, minY, maxX, maxY] = boundingBox;
const maxDim = Math.max(maxX - minX, maxY - minY);
if (points.length < 3 || maxDim * zoom < RECOGNITION_MIN_SCREEN_SIZE) {
return { type: "freedraw", points, boundingBox };
}
const recognized = classify(extractFeatures(points));
const type =
previousElement?.type === "arrow" && recognized !== "arrow"
? "freedraw"
: recognized;
return { type, points, boundingBox };
};
// Converts a freedraw element to the detected shape.
//
// `frames` lets callers pass the scene's cached frame-like elements (e.g.
// scene.getNonDeletedFramesLikes()) to avoid spreading and re-filtering the
// whole element map on every invocation; when omitted it is derived from
// `elementsMap`.
export const convertToShape = (
points: GlobalPoint[],
appState: AppState,
elementsMap: ElementsMap,
previousElement: ExcalidrawElement | null,
frames?: readonly ExcalidrawFrameLikeElement[],
): NonDeletedRecognizedShapeElement | undefined => {
const recognizedShape = recognizeShape(
points,
previousElement,
appState.zoom.value,
);
const boundingBox = recognizedShape.boundingBox;
const [minX, minY, maxX, maxY] = boundingBox;
const frameLikeElements =
frames ?? getFrameLikeElements([...elementsMap.values()]);
const frameId =
frameLikeElements.find((frame) => {
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
return fx1 <= minX && fy1 <= minY && fx2 >= maxX && fy2 >= maxY;
})?.id ?? null;
const roundness =
appState.currentItemRoundness === "round"
? {
type: isUsingAdaptiveRadius(recognizedShape.type)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null;
switch (recognizedShape.type) {
case "rectangle":
case "diamond":
case "ellipse": {
return newElement({
type: recognizedShape.type,
x: recognizedShape.boundingBox[0],
y: recognizedShape.boundingBox[1],
width: recognizedShape.boundingBox[2] - recognizedShape.boundingBox[0],
height: recognizedShape.boundingBox[3] - recognizedShape.boundingBox[1],
groupIds: [],
angle: 0 as Radians,
frameId,
roundness,
roughness: appState.currentItemRoughness,
backgroundColor: appState.currentItemBackgroundColor,
strokeColor: appState.currentItemStrokeColor,
fillStyle: appState.currentItemFillStyle,
opacity: appState.currentItemOpacity,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: getStrokeWidthByKey(
recognizedShape.type,
appState.currentItemStrokeWidthKey,
),
}) as NonDeletedRecognizedShapeElement;
}
case "arrow": {
const [arrowX, arrowY] = recognizedShape.points[0];
let globalEndX: number;
let globalEndY: number;
if (previousElement && previousElement.type === "line") {
const prevEndLocal = previousElement.points[1];
// No need for rotation handling
globalEndX = prevEndLocal[0] + previousElement.x;
globalEndY = prevEndLocal[1] + previousElement.y;
} else {
[globalEndX, globalEndY] = getArrowEndpoint(
recognizedShape.points,
recognizedShape.boundingBox,
recognizedShape.points[0],
);
}
const endPoint: LocalPoint = pointFrom<LocalPoint>(
globalEndX - arrowX,
globalEndY - arrowY,
);
const arrowLen = Math.hypot(
globalEndX - recognizedShape.points[0][0],
globalEndY - recognizedShape.points[0][1],
);
if (arrowLen < 60) {
const tempElement = newLinearElement({
type: "line",
x: arrowX,
y: arrowY,
points: [
pointFrom<LocalPoint>(
recognizedShape.points[0][0] - arrowX,
recognizedShape.points[0][1] - arrowY,
),
endPoint,
],
groupIds: [],
frameId,
locked: false,
angle: 0 as Radians,
roundness,
roughness: appState.currentItemRoughness,
backgroundColor: appState.currentItemBackgroundColor,
strokeColor: appState.currentItemStrokeColor,
fillStyle: appState.currentItemFillStyle,
opacity: appState.currentItemOpacity,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: getStrokeWidthByKey(
recognizedShape.type,
appState.currentItemStrokeWidthKey,
),
});
const normalized =
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
return newLinearElement({
...tempElement,
...normalized,
}) as NonDeletedRecognizedShapeElement;
}
const tempElement = newArrowElement({
type: "arrow",
x: arrowX,
y: arrowY,
startArrowhead: appState.currentItemStartArrowhead,
endArrowhead: appState.currentItemEndArrowhead,
points: [
pointFrom<LocalPoint>(
recognizedShape.points[0][0] - arrowX,
recognizedShape.points[0][1] - arrowY,
),
endPoint,
],
groupIds: [],
frameId,
locked: false,
angle: 0 as Radians,
roundness,
roughness: appState.currentItemRoughness,
backgroundColor: appState.currentItemBackgroundColor,
strokeColor: appState.currentItemStrokeColor,
fillStyle: appState.currentItemFillStyle,
opacity: appState.currentItemOpacity,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: getStrokeWidthByKey(
recognizedShape.type,
appState.currentItemStrokeWidthKey,
),
});
const normalized =
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
return newArrowElement({
...tempElement,
...normalized,
});
}
case "line": {
const [lineX, lineY] = recognizedShape.points[0];
const endPoint =
recognizedShape.points[recognizedShape.points.length - 1];
const tempElement = newLinearElement({
type: recognizedShape.type,
x: lineX,
y: lineY,
points: [
pointFrom<LocalPoint>(
recognizedShape.points[0][0] - lineX,
recognizedShape.points[0][1] - lineY,
),
pointFrom<LocalPoint>(endPoint[0] - lineX, endPoint[1] - lineY),
],
groupIds: [],
frameId,
locked: false,
angle: 0 as Radians,
roundness,
roughness: appState.currentItemRoughness,
backgroundColor: appState.currentItemBackgroundColor,
strokeColor: appState.currentItemStrokeColor,
fillStyle: appState.currentItemFillStyle,
opacity: appState.currentItemOpacity,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: getStrokeWidthByKey(
recognizedShape.type,
appState.currentItemStrokeWidthKey,
),
});
const normalized =
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
return newLinearElement({
...tempElement,
...normalized,
}) as NonDeletedRecognizedShapeElement;
}
}
return undefined;
};
// Whether two drawShape preview elements are visually identical, so a redundant
// preview recompute on pointermove can skip setState (avoiding a no-op React
// re-render and roughjs shape regeneration).
const isDrawShapePreviewEqual = (
a: NonDeletedExcalidrawElement | null,
b: NonDeletedExcalidrawElement,
): boolean => {
if (
!a ||
a.type !== b.type ||
a.x !== b.x ||
a.y !== b.y ||
a.width !== b.width ||
a.height !== b.height ||
a.angle !== b.angle
) {
return false;
}
if (isLinearElement(a) && isLinearElement(b)) {
if (a.points.length !== b.points.length) {
return false;
}
for (let i = 0; i < a.points.length; i++) {
if (
a.points[i][0] !== b.points[i][0] ||
a.points[i][1] !== b.points[i][1]
) {
return false;
}
}
}
return true;
};
// Append to the trail and recompute the shape preview. This runs inside
// withBatchedUpdatesThrottled (throttleRAF), so it fires at most once per
// animation frame regardless of raw pointermove rate.
export const convertToShapeHandlePointerMoveFromPointerDown = (
app: App,
pointerCoords: { x: number; y: number },
) => {
if (app.state.activeTool.type === "autoshape") {
app.drawShape.trail.addPointToPath(pointerCoords.x, pointerCoords.y);
const drawShapeTrailPoints = app.drawShape.trail.getCurrentPoints();
// note: size gate inside recognizeShape
if (drawShapeTrailPoints.length >= 3) {
const shapePreview = convertToShape(
drawShapeTrailPoints,
app.state,
app.scene.getNonDeletedElementsMap(),
app.state.newElement,
app.scene.getNonDeletedFramesLikes(),
) as ExcalidrawNonSelectionElement | undefined;
if (shapePreview) {
const prevPreview = app.state.newElement;
const nextPreview = {
...shapePreview,
// Keep a stable id across the gesture while the recognized type
// is unchanged, so the preview element's identity doesn't churn
// frame to frame.
id:
prevPreview?.type === shapePreview.type
? prevPreview.id
: shapePreview.id,
seed: 1,
opacity: ELEMENT_PENDING_DRAW_SHAPE_OPACITY,
isDeleted: false as const,
} as NonDeletedRecognizedShapeElement;
if (!isDrawShapePreviewEqual(prevPreview, nextPreview)) {
app.setState({ newElement: nextPreview });
}
} else if (app.state.newElement !== null) {
app.setState({ newElement: null });
}
}
return true;
}
return false;
};
+1
View File
@@ -102,3 +102,4 @@ export * from "./utils";
export * from "./zindex";
export * from "./arrows/helpers";
export * from "./arrowheads";
export * from "./convertToShape";
+5 -2
View File
@@ -216,12 +216,15 @@ export const getTargetElements = (
elements: ElementsMapOrArray,
appState: Pick<
AppState,
"selectedElementIds" | "editingTextElement" | "newElement"
"selectedElementIds" | "editingTextElement" | "newElement" | "activeTool"
>,
) =>
appState.editingTextElement
? [appState.editingTextElement]
: appState.newElement
: // the drawShape recognition preview in `newElement` is not a real
// in-progress element — targeting it would make the styles panel track
// whatever shape is currently recognized instead of the tool defaults
appState.newElement && appState.activeTool.type !== "autoshape"
? [appState.newElement]
: getSelectedElements(elements, appState, {
includeBoundTextElement: true,
@@ -0,0 +1,327 @@
import type { GlobalPoint } from "@excalidraw/math";
import { recognizeShape } from "../src/convertToShape";
// Deterministic jitter, so a run that passes keeps passing.
let seed = 1;
const random = () => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed / 2147483648 - 0.5;
};
// Draw the shape somewhere else, at another size, turned by `angle`, with a
// wobbly hand.
const sketch = (
points: readonly [number, number][],
{ angle = 0, scale = 1, noise = 0 } = {},
): GlobalPoint[] =>
points.map(([x, y]) => {
const nx = x * scale + random() * noise;
const ny = y * scale + random() * noise;
return [
nx * Math.cos(angle) - ny * Math.sin(angle) + 600,
nx * Math.sin(angle) + ny * Math.cos(angle) + 600,
] as GlobalPoint;
});
const edge = (
from: readonly [number, number],
to: readonly [number, number],
steps: number,
): [number, number][] =>
Array.from({ length: steps }, (_, i) => [
from[0] + ((to[0] - from[0]) * i) / steps,
from[1] + ((to[1] - from[1]) * i) / steps,
]);
const outline = (corners: readonly [number, number][]): [number, number][] => [
...corners.flatMap((corner, i) => edge(corner, corners[(i + 1) % 4], 18)),
corners[0],
];
const rectangle = (w: number, h: number) =>
outline([
[-w / 2, -h / 2],
[w / 2, -h / 2],
[w / 2, h / 2],
[-w / 2, h / 2],
]);
const diamond = (w: number, h: number) =>
outline([
[0, -h / 2],
[w / 2, 0],
[0, h / 2],
[-w / 2, 0],
]);
const ellipse = (rx: number, ry: number): [number, number][] => {
const points: [number, number][] = [];
for (let a = 0; a <= Math.PI * 2 + 0.05; a += 0.1) {
points.push([Math.cos(a) * rx, Math.sin(a) * ry]);
}
return points;
};
// Moving-average smoothing: rounds every corner the way a relaxed pen does.
const smooth = (
pts: readonly [number, number][],
window: number,
): [number, number][] =>
pts.map((_, i) => {
let sx = 0;
let sy = 0;
for (let k = -window; k <= window; k++) {
const [x, y] = pts[(i + k + pts.length) % pts.length];
sx += x;
sy += y;
}
return [sx / (2 * window + 1), sy / (2 * window + 1)];
});
// A sloppy hand-drawn "rectangle" that tapers, bucket-like: wider at the top
// than the bottom. Its hull fill ratio, (topW + bottomW) / (2 * topW), lands
// right at an ellipse's PI/4 — only its sharp corners tell the two apart.
const taperedRectangle = (
topW: number,
bottomW: number,
h: number,
): [number, number][] =>
outline([
[-topW / 2, -h / 2],
[topW / 2, -h / 2],
[bottomW / 2, h / 2],
[-bottomW / 2, h / 2],
]);
// A rectangle drawn with broad, pen-like rounded corners. Its outline is a
// superellipse midway between an ellipse (exponent 2) and a sharp rectangle.
const roundedRectangle = (rx: number, ry: number): [number, number][] => {
const points: [number, number][] = [];
for (let a = 0; a <= Math.PI * 2 + 0.05; a += 0.1) {
const cos = Math.cos(a);
const sin = Math.sin(a);
points.push([
Math.sign(cos) * Math.sqrt(Math.abs(cos)) * rx,
Math.sign(sin) * Math.sqrt(Math.abs(sin)) * ry,
]);
}
return points;
};
const line = (length: number) => edge([-length / 2, 0], [length / 2, 0], 40);
/** A shaft, then a V head traced back from the tip and over itself. */
const arrow = (length: number, headRatio: number): [number, number][] => {
const tip: [number, number] = [length / 2, 0];
const headLength = length * headRatio;
const arm = (side: number): [number, number] => [
tip[0] - headLength * Math.cos(Math.PI / 6),
tip[1] - side * headLength * Math.sin(Math.PI / 6),
];
return [
...edge([-length / 2, 0], tip, 24),
...edge(tip, arm(1), 8),
...edge(arm(1), tip, 8),
...edge(tip, arm(-1), 8),
];
};
const SCALES = [0.5, 1, 2, 4];
describe("recognizeShape", () => {
describe("is invariant to where, how big and how rotated a shape is drawn", () => {
// A rectangle turned 45° *is* a diamond, so these two are only swept over
// the right angles that map them onto themselves. Every other class is
// swept over arbitrary angles.
const RIGHT_ANGLES = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2];
// 4.7 rad is within a degree of vertical — the size gate compares the
// larger bounding-box dimension, so thin (near-)axis-aligned strokes are
// recognized like any other.
const ANY_ANGLES = [0, 0.3, Math.PI / 4, 1.2, Math.PI / 2, 2.5, 4.7];
const cases: [string, [number, number][], number[], string][] = [
["a square", rectangle(200, 200), RIGHT_ANGLES, "rectangle"],
["a wide rectangle", rectangle(300, 150), RIGHT_ANGLES, "rectangle"],
["a thin rectangle", rectangle(500, 100), RIGHT_ANGLES, "rectangle"],
[
"a tapered rectangle",
taperedRectangle(170, 100, 200),
RIGHT_ANGLES,
"rectangle",
],
["a diamond", diamond(200, 200), RIGHT_ANGLES, "diamond"],
["a wide diamond", diamond(300, 150), RIGHT_ANGLES, "diamond"],
["a circle", ellipse(100, 100), ANY_ANGLES, "ellipse"],
["an ellipse", ellipse(150, 75), ANY_ANGLES, "ellipse"],
["a line", line(300), ANY_ANGLES, "line"],
["an arrow with a small head", arrow(300, 0.15), ANY_ANGLES, "arrow"],
["an arrow with a large head", arrow(300, 0.45), ANY_ANGLES, "arrow"],
];
it.each(cases)("recognizes %s", (_name, points, angles, expected) => {
for (const angle of angles) {
for (const scale of SCALES) {
for (const noise of [0, 3]) {
expect({
angle,
scale,
noise,
type: recognizeShape(
sketch(points, { angle, scale, noise }),
null,
).type,
}).toEqual({ angle, scale, noise, type: expected });
}
}
}
});
});
it("tells an arrow from a line by the points its head piles up", () => {
const options = { angle: 0.3 };
expect(recognizeShape(sketch(line(300), options), null).type).toBe("line");
expect(recognizeShape(sketch(arrow(300, 0.3), options), null).type).toBe(
"arrow",
);
});
it("recognizes an outline the user didn't quite close", () => {
const gapped = rectangle(200, 200).slice(0, -6);
expect(recognizeShape(sketch(gapped), null).type).toBe("rectangle");
});
it("recognizes a pen-drawn outline with rounded corners as a rectangle", () => {
expect(recognizeShape(sketch(roundedRectangle(100, 70)), null).type).toBe(
"rectangle",
);
// ...while a true ellipse of the same proportions remains an ellipse
expect(recognizeShape(sketch(ellipse(100, 70)), null).type).toBe("ellipse");
});
it("tells a tapered, bucket-like rectangle from an ellipse by its corners", () => {
// proportions traced from real sketches that used to recognize as
// ellipses: hull fill ≈ 0.8 ≈ PI/4, so corner sharpness must decide
const buckets: [number, number, number][] = [
[110, 65, 220],
[110, 65, 130],
[75, 50, 115],
[120, 60, 250],
];
for (const [topW, bottomW, h] of buckets) {
// lightly rounded corners + wobble, like the real strokes
const points = sketch(smooth(taperedRectangle(topW, bottomW, h), 2), {
noise: 2,
});
expect({
bucket: [topW, bottomW, h],
type: recognizeShape(points, null).type,
}).toEqual({ bucket: [topW, bottomW, h], type: "rectangle" });
}
});
it("does not read sharply bent open strokes as lines or arrows", () => {
const semicircle: [number, number][] = Array.from(
{ length: 40 },
(_, i) => {
const a = (Math.PI * i) / 39;
return [Math.cos(a) * 150, Math.sin(a) * 150];
},
);
const sawtooth: [number, number][] = Array.from({ length: 31 }, (_, i) => [
i * 10,
(i % 2) * 60,
]);
const bent: [string, [number, number][]][] = [
[
"a right-angle elbow",
[...edge([0, 0], [110, 110], 20), ...edge([110, 110], [220, 0], 20)],
],
[
"an L shape",
[...edge([0, 0], [0, 150], 20), ...edge([0, 150], [150, 150], 20)],
],
[
"a checkmark",
[...edge([0, 60], [60, 120], 12), ...edge([60, 120], [200, 0], 24)],
],
["a semicircular arc", semicircle],
["a sawtooth", sawtooth],
];
for (const [name, points] of bent) {
expect({
name,
type: recognizeShape(sketch(points, { noise: 2 }), null).type,
}).toEqual({ name, type: "freedraw" });
}
});
it("still reads a lazily bowed or squiggly stroke as a line", () => {
const bowed: [number, number][] = Array.from({ length: 40 }, (_, i) => {
const t = i / 39;
return [t * 300, Math.sin(t * Math.PI) * 24];
});
const squiggly: [number, number][] = Array.from({ length: 40 }, (_, i) => {
const t = i / 39;
return [t * 300, Math.sin(t * Math.PI * 4) * 12];
});
expect(recognizeShape(sketch(bowed, { noise: 2 }), null).type).toBe("line");
expect(recognizeShape(sketch(squiggly, { noise: 2 }), null).type).toBe(
"line",
);
});
it("leaves a stroke that resembles no known shape as freedraw", () => {
const squiggle: [number, number][] = Array.from({ length: 60 }, (_, i) => [
i * 4 - 120,
Math.sin(i / 2) * 70 + random() * 20,
]);
expect(recognizeShape(sketch(squiggle), null).type).toBe("freedraw");
});
it("only extends an arrow with another arrow", () => {
const previous = { type: "arrow" } as Parameters<typeof recognizeShape>[1];
expect(
recognizeShape(sketch(arrow(300, 0.3), { angle: 0.3 }), previous).type,
).toBe("arrow");
expect(recognizeShape(sketch(rectangle(200, 200)), previous).type).toBe(
"freedraw",
);
});
it("ignores a stroke too small to be a shape", () => {
const tiny: [number, number][] = [
[0, 0],
[1, 1],
[2, 0],
[1, -1],
];
expect(recognizeShape(sketch(tiny), null).type).toBe("freedraw");
});
it("gates on apparent (screen) size, so zoom decides what is too small", () => {
const smallCircle = ellipse(8, 8); // 16 scene units across
expect(recognizeShape(sketch(smallCircle), null).type).toBe("freedraw");
expect(recognizeShape(sketch(smallCircle), null, 10).type).toBe("ellipse");
// 300 apparent px at 0.1 zoom
expect(
recognizeShape(sketch(line(3000), { angle: 0.3 }), null, 0.1).type,
).toBe("line");
// 10 apparent px at 0.1 zoom
expect(
recognizeShape(sketch(line(100), { angle: 0.3 }), null, 0.1).type,
).toBe("freedraw");
});
});
+52 -29
View File
@@ -58,6 +58,7 @@ export const actionFinalize = register<FormData>({
let newElements = elements;
const { focusContainer, scene } = app;
const elementsMap = scene.getNonDeletedElementsMap();
const isDrawShapeTool = appState.activeTool.type === "autoshape";
if (data && appState.selectedLinearElement) {
const { event, sceneCoords } = data;
@@ -174,18 +175,22 @@ export const actionFinalize = register<FormData>({
appState: {
...appState,
cursorButton: "up",
selectedLinearElement: activeToolLocked
? null
: {
...linearElementEditor,
selectedPointsIndices: null,
isEditing: false,
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
selectedElementIds: isDrawShapeTool
? {}
: appState.selectedElementIds,
selectedLinearElement:
activeToolLocked || isDrawShapeTool
? null
: {
...linearElementEditor,
selectedPointsIndices: null,
isEditing: false,
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
pointerOffset: { x: 0, y: 0 },
},
pointerOffset: { x: 0, y: 0 },
},
selectionElement: null,
suggestedBinding: null,
newElement: null,
@@ -200,12 +205,29 @@ export const actionFinalize = register<FormData>({
focusContainer();
}
// clean up pending gesture even if active tool is already not drawShape
const hadPendingSketch = app.drawShape.hasPendingGesture();
if (hadPendingSketch || isDrawShapeTool) {
app.drawShape.finalize();
if (hadPendingSketch) {
// finalize() inserts the recognized element via app.insertNewElement
// — re-read so the returned array includes it (replaceAllElements
// would otherwise drop it)
newElements = app.scene.getElementsIncludingDeleted();
}
}
let element: NonDeleted<ExcalidrawElement> | null = null;
if (appState.multiElement) {
element = appState.multiElement;
} else if (
appState.newElement?.type === "freedraw" ||
isBindingElement(appState.newElement)
// the drawShape preview in `newElement` is not a scene element and the
// sketch was finalized by app.drawShape.finalize() above — never treat
// the preview as an in-progress element here
!isDrawShapeTool &&
!hadPendingSketch &&
(appState.newElement?.type === "freedraw" ||
isBindingElement(appState.newElement))
) {
element = appState.newElement;
} else if (Object.keys(appState.selectedElementIds).length === 1) {
@@ -333,10 +355,13 @@ export const actionFinalize = register<FormData>({
// locked via the tool lock or host-forced (`props.activeTool`)
const isToolLocked = app.isToolLocked();
if (
(!isToolLocked && appState.activeTool.type !== "freedraw") ||
!element
) {
// the drawShape flow finalizes without a `newElement` (the sketch is
// converted separately), so it stays active regardless of `element`
const keepActiveTool =
appState.activeTool.type === "autoshape" ||
((isToolLocked || appState.activeTool.type === "freedraw") && !!element);
if (!keepActiveTool) {
// the active tool reverts (see the returned `appState.activeTool`) —
// apply the reverted tool's cursor
app.cursor.applyForTool(activeTool);
@@ -366,10 +391,7 @@ export const actionFinalize = register<FormData>({
appState: {
...appState,
cursorButton: "up",
activeTool:
(isToolLocked || appState.activeTool.type === "freedraw") && element
? appState.activeTool
: activeTool,
activeTool: keepActiveTool ? appState.activeTool : activeTool,
activeEmbeddable: null,
newElement: null,
selectionElement: null,
@@ -377,15 +399,16 @@ export const actionFinalize = register<FormData>({
editingTextElement: null,
suggestedBinding: null,
frameToHighlight: null,
selectedElementIds:
element && !isToolLocked && appState.activeTool.type !== "freedraw"
? {
...appState.selectedElementIds,
[element.id]: true,
}
: appState.selectedElementIds,
selectedElementIds: isDrawShapeTool
? {}
: element && !isToolLocked && appState.activeTool.type !== "freedraw"
? {
...appState.selectedElementIds,
[element.id]: true,
}
: appState.selectedElementIds,
selectedLinearElement,
selectedLinearElement: isDrawShapeTool ? null : selectedLinearElement,
},
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: shouldCommit
@@ -35,7 +35,11 @@ const executeHistoryAction = (
!appState.newElement &&
!appState.selectedElementsAreBeingDragged &&
!appState.selectionElement &&
!app.flowchart.isCreatingChart
!app.flowchart.isCreatingChart &&
// a drawShape sketch in progress isn't visible via `newElement` while
// the stroke is still unrecognized — block history mid-gesture the same
// way as for the other in-progress interactions above
!app.drawShape.hasPendingGesture()
) {
const result = updater();
-1
View File
@@ -66,7 +66,6 @@ export type ActionName =
| "changeBackgroundColor"
| "changeFillStyle"
| "changeStrokeWidth"
| "changeStrokeShape"
| "changeSloppiness"
| "changeFreedrawMode"
| "changeStrokeStyle"
+13 -16
View File
@@ -162,14 +162,11 @@ export const SelectedShapeActions = ({
{predicates.strokeWidth && renderAction("changeStrokeWidth")}
{predicates.strokeStyle && <>{renderAction("changeStrokeStyle")}</>}
{predicates.freedrawMode && renderAction("changeFreedrawMode")}
{predicates.strokeStyle && (
<>
{renderAction("changeStrokeStyle")}
{renderAction("changeSloppiness")}
</>
)}
{predicates.sloppiness && <>{renderAction("changeSloppiness")}</>}
{predicates.roundness && <>{renderAction("changeRoundness")}</>}
@@ -186,9 +183,9 @@ export const SelectedShapeActions = ({
{predicates.verticalAlign && renderAction("changeVerticalAlign")}
{predicates.arrowheads && <>{renderAction("changeArrowhead")}</>}
{renderAction("changeOpacity")}
{predicates.opacity && renderAction("changeOpacity")}
<LayersFieldset renderAction={renderAction} />
{predicates.layers && <LayersFieldset renderAction={renderAction} />}
{predicates.align && (
<AlignFieldset
@@ -289,13 +286,11 @@ const CombinedShapeProperties = ({
predicates.freedrawMode && renderAction("changeFreedrawMode")
}
{predicates.strokeStyle && (
<>
{renderAction("changeStrokeStyle")}
{renderAction("changeSloppiness")}
</>
<>{renderAction("changeStrokeStyle")}</>
)}
{predicates.sloppiness && <>{renderAction("changeSloppiness")}</>}
{predicates.roundness && renderAction("changeRoundness")}
{renderAction("changeOpacity")}
{predicates.opacity && renderAction("changeOpacity")}
</div>
</PropertiesPopover>
)}
@@ -321,12 +316,12 @@ const CombinedArrowProperties = ({
container: HTMLDivElement | null;
app: AppClassProperties;
}) => {
const isOpen = appState.openPopup === "compactArrowProperties";
if (!predicates.arrowType) {
return null;
}
const isOpen = appState.openPopup === "compactArrowProperties";
return (
<div className="compact-action-item">
<Popover.Root
@@ -549,7 +544,9 @@ const CombinedExtraActions = ({
onClose={() => {}}
>
<div className="selected-shape-actions">
<LayersFieldset renderAction={renderAction} />
{predicates.layers && (
<LayersFieldset renderAction={renderAction} />
)}
{predicates.align && (
<AlignFieldset
@@ -34,6 +34,11 @@ export class AppCursor {
| null = null;
private eraserPreviewDataURL: DataURL | null = null;
private drawShapeCanvasCache:
| (HTMLCanvasElement & { theme?: AppState["theme"] })
| null = null;
private drawShapePreviewDataURL: DataURL | null = null;
constructor(private app: App) {}
private get canvas() {
@@ -79,6 +84,8 @@ export class AppCursor {
this.set(CURSOR_TYPE.GRAB);
} else if (isEraserActive({ activeTool })) {
this.applyEraser();
} else if (activeTool.type === "autoshape") {
this.set(CURSOR_TYPE.CROSSHAIR);
} else if (activeTool.type === "laser") {
const url =
this.app.state.theme === THEME.LIGHT
@@ -0,0 +1,265 @@
import { randomInteger } from "@excalidraw/common";
import {
bindBindingElement,
convertToShape,
convertToShapeHandlePointerMoveFromPointerDown,
getHoveredElementForBinding,
isBindingElement,
isLineElement,
LinearElementEditor,
maxBindingDistance_simple,
newArrowElement,
updateBoundPoint,
} from "@excalidraw/element";
import type {
ExcalidrawArrowElement,
ExcalidrawLineElement,
NonDeleted,
NonDeletedExcalidrawElement,
PointsPositionUpdates,
} from "@excalidraw/element/types";
import { actionFinalize } from "../actions";
import { DrawShapeTrail } from "../drawShapeTrail";
import type App from "./App";
import type { PointerDownState } from "../types";
/**
* Captures the App state management for the drawShape tool (free-form
* sketch converted into a recognized excalidraw element). Pointer events
* are piped here from App.
*
* The gesture is committed by `finalize()` — recognize → (maybe upgrade
* line to arrow) → insert → bind endpoints — which is invoked from within
* `actionFinalize`, the editor's single finalization funnel. Whatever
* triggers a finalize (the gesture's own pointerup, a mid-gesture paste
* switching tools, …), the pending sketch resolves there exactly once.
*/
export class AppDrawShape {
public trail: DrawShapeTrail;
/** a pointerdown started a sketch that `finalize()` hasn't resolved yet */
private gestureInProgress = false;
constructor(private app: App) {
this.trail = new DrawShapeTrail(app);
}
stop = () => {
this.trail.stop();
};
hasPendingGesture = () => {
return this.gestureInProgress;
};
handlePointerDown = (pointerDownState: PointerDownState) => {
if (this.gestureInProgress) {
// a stale gesture (e.g. after a missed pointerup) — resolve it before
// starting a new one
this.app.actionManager.executeAction(actionFinalize);
}
this.gestureInProgress = true;
this.trail.startPath(
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
);
};
/** returns true when the pointermove was consumed by the shape preview */
handlePointerMove = (pointerCoords: { x: number; y: number }): boolean => {
return convertToShapeHandlePointerMoveFromPointerDown(
this.app,
pointerCoords,
);
};
/**
* Binds both endpoints of a freshly inserted drawShape arrow to the
* bindable elements they hover, if any.
*
* Endpoints bind in "orbit" mode — the arrow starts/ends at the target's
* outline — even when the stroke was started or released inside the shape
* (a sketched connector aims at the shape, not at a point inside it). The
* exception is a sketch contained in a single shape: both ends then bind
* "inside" and stay where they were drawn, like the interactive
* inside→inside flow.
*/
private bindRecognizedArrow = (arrow: NonDeleted<ExcalidrawArrowElement>) => {
const { app } = this;
const elementsMap = app.scene.getNonDeletedElementsMap();
const elements = app.scene.getNonDeletedElements();
const bindingDistance = maxBindingDistance_simple(app.state.zoom);
const endpoints = (["start", "end"] as const).map((startOrEnd) => {
const globalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
return {
startOrEnd,
globalPoint,
target: getHoveredElementForBinding(
globalPoint,
elements,
elementsMap,
bindingDistance,
),
};
});
const sameTarget =
!!endpoints[0].target && endpoints[0].target === endpoints[1].target;
const isMidpointSnappingEnabled =
app.state.isMidpointSnappingEnabled && !app.state.gridModeEnabled;
for (const { startOrEnd, globalPoint, target } of endpoints) {
if (target) {
bindBindingElement(
arrow,
target,
sameTarget ? "inside" : "orbit",
startOrEnd,
app.scene,
globalPoint,
app.state.isBindingEnabled,
isMidpointSnappingEnabled,
);
}
}
if (sameTarget) {
return;
}
// pull the orbit-bound endpoints onto the target outlines; both bindings
// exist by now, so each endpoint clips the segment between the two focus
// points (mirrors the focusPoint branch of bindOrUnbindBindingElement)
const updates: PointsPositionUpdates = new Map();
for (const { startOrEnd, target } of endpoints) {
if (!target) {
continue;
}
const point = updateBoundPoint(
arrow,
startOrEnd === "start" ? "startBinding" : "endBinding",
startOrEnd === "start" ? arrow.startBinding : arrow.endBinding,
target,
elementsMap,
);
if (point) {
updates.set(startOrEnd === "start" ? 0 : arrow.points.length - 1, {
point,
});
}
}
if (updates.size) {
LinearElementEditor.movePoints(arrow, app.scene, updates);
}
};
/**
* A sketched line that connects a bindable element with something else —
* another element or blank canvas — was meant as a connector: upgrade it
* to an arrow (called before insertion). A line touching no shape, or
* contained in a single shape (an annotation, not a connector), stays a
* line.
*/
private maybeUpgradeLineToArrow = (
line: NonDeleted<ExcalidrawLineElement>,
): NonDeleted<ExcalidrawArrowElement> | null => {
const { app } = this;
const elementsMap = app.scene.getNonDeletedElementsMap();
const elements = app.scene.getNonDeletedElements();
const bindingDistance = maxBindingDistance_simple(app.state.zoom);
const [startTarget, endTarget] = ([0, -1] as const).map((index) =>
getHoveredElementForBinding(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
line,
index,
elementsMap,
),
elements,
elementsMap,
bindingDistance,
),
);
if ((!startTarget && !endTarget) || startTarget === endTarget) {
return null;
}
return newArrowElement({
type: "arrow",
x: line.x,
y: line.y,
width: line.width,
height: line.height,
startArrowhead: null,
endArrowhead: app.state.currentItemEndArrowhead,
points: line.points,
groupIds: line.groupIds,
frameId: line.frameId,
locked: false,
angle: line.angle,
strokeColor: line.strokeColor,
backgroundColor: line.backgroundColor,
fillStyle: line.fillStyle,
roughness: line.roughness,
opacity: line.opacity,
strokeStyle: line.strokeStyle,
strokeWidth: line.strokeWidth,
});
};
/**
* Commits the pending sketch, if any, and clears the trail. Invoked from
* within `actionFinalize` — never call directly, execute `actionFinalize`
* instead so element insertion flows into the action's returned elements.
*/
finalize = () => {
const { app } = this;
// when no gesture is in progress this only clears residual trail state
const points = this.gestureInProgress ? this.trail.getCurrentPoints() : [];
this.gestureInProgress = false;
this.trail.endPath();
// note: size gate inside recognizeShape
if (points.length >= 3) {
const detectedElement =
app.state.newElement ||
convertToShape(
points,
app.state,
app.scene.getNonDeletedElementsMap(),
app.state.newElement,
app.scene.getNonDeletedFramesLikes(),
);
if (detectedElement) {
let element: NonDeletedExcalidrawElement = {
...detectedElement,
seed: randomInteger(),
opacity: app.state.currentItemOpacity,
};
if (app.state.isBindingEnabled && isLineElement(element)) {
element = this.maybeUpgradeLineToArrow(element) ?? element;
}
app.insertNewElement(element);
if (app.state.isBindingEnabled && isBindingElement(element)) {
this.bindRecognizedArrow(element);
}
}
}
this.trail.clearTrails();
};
}
+29 -6
View File
@@ -431,6 +431,7 @@ import ConvertElementTypePopup, {
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { AppCursor } from "./App.cursor";
import { AppDrawShape } from "./App.drawshape";
import { AppFlowchart } from "./App.flowchart";
import { AppViewport, RIGHT_SIDEBAR_WIDTH } from "./App.viewport";
import BraveMeasureTextError from "./BraveMeasureTextError";
@@ -708,6 +709,8 @@ class App extends React.Component<AppProps, AppState> {
private removedArrowheads = new Map<string, Arrowhead>();
/** previous frame pointer coords */
previousPointerMoveCoords: { x: number; y: number } | null = null;
drawShape = new AppDrawShape(this);
laserTrails = new LaserTrails(this);
eraserTrail = new EraserTrail(this);
lassoTrail = new LassoTrail(this);
@@ -2438,6 +2441,7 @@ class App extends React.Component<AppProps, AppState> {
this.laserTrails,
this.lassoTrail,
this.eraserTrail,
this.drawShape.trail,
]}
/>
{this.isDefaultUIEnabled() && <CursorHint />}
@@ -3785,6 +3789,7 @@ class App extends React.Component<AppProps, AppState> {
this.removeEventListeners();
this.library.destroy();
this.laserTrails.stop();
this.drawShape.stop();
this.eraserTrail.stop();
this.onChangeEmitter.clear();
this.store.onStoreIncrementEmitter.clear();
@@ -5499,14 +5504,11 @@ class App extends React.Component<AppProps, AppState> {
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
// so that an uppercase letter can only mean CapsLock (Shift+letter
// must not switch tools — findShapeByKey lowercases the key)
!event.shiftKey &&
!this.state.newElement &&
!this.state.selectionElement &&
!this.state.selectedElementsAreBeingDragged
) {
const shape = findShapeByKey(event.key, this);
const shape = findShapeByKey(event.key, this, event.shiftKey);
if (this.state.viewModeEnabled && !oneOf(shape, ["laser", "hand"])) {
return;
@@ -5950,6 +5952,13 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (this.drawShape.hasPendingGesture()) {
// switching tools mid-sketch (e.g. paste resets to the selection tool)
// must not strand the gesture — commit it through the finalize funnel
// while the drawShape tool is still active
this.actionManager.executeAction(actionFinalize);
}
const isToggleTool = TOGGLE_TOOLS.includes(tool.type);
const toggle = opts.toggle === true && isToggleTool;
@@ -7510,7 +7519,7 @@ class App extends React.Component<AppProps, AppState> {
}
};
private insertNewElement = (element: ExcalidrawElement) => {
public insertNewElement = (element: ExcalidrawElement) => {
this.insertNewElements([element]);
const frame = element.frameId
@@ -8677,6 +8686,8 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
);
} else if (this.state.activeTool.type === "autoshape") {
this.drawShape.handlePointerDown(pointerDownState);
} else if (
this.state.activeTool.type !== "eraser" &&
this.state.activeTool.type !== "hand" &&
@@ -10478,6 +10489,10 @@ class App extends React.Component<AppProps, AppState> {
this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y);
}
if (this.drawShape.handlePointerMove(pointerCoords)) {
return;
}
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
@@ -11572,7 +11587,10 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (isLinearElement(newElement)) {
if (
isLinearElement(newElement) &&
this.state.activeTool.type !== "autoshape"
) {
const pointerCoords = viewportCoordsToSceneCoords(
childEvent,
this.state,
@@ -12264,6 +12282,11 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (activeTool.type === "autoshape") {
this.actionManager.executeAction(actionFinalize);
return;
}
if (
!this.isToolLocked() &&
activeTool.type !== "freedraw" &&
@@ -104,6 +104,10 @@ const getHints = ({
return t("hints.embeddable");
}
if (activeTool.type === "autoshape") {
return t("hints.autoshape");
}
if (
isResizing &&
lastPointerDownWith === "mouse" &&
@@ -12,7 +12,8 @@ import { ToolPopover } from "./ToolPopover";
import {
EraserToolButton,
FrameToolButton,
FreedrawToolButton,
FreedrawToolPopover,
getToolShortcut,
HandToolButton,
ImageToolButton,
isToolButtonDisabled,
@@ -24,10 +25,11 @@ import {
import {
TextIcon,
ImageIcon,
extraToolsIcon,
DotsIcon,
frameToolIcon,
EmbedIcon,
laserPointerToolIcon,
drawShapeToolIcon,
mermaidLogoIcon,
MagicIcon,
} from "./icons";
@@ -71,6 +73,7 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
}, [activeTool.type]);
const frameToolSelected = activeTool.type === "frame";
const drawShapeToolSelected = activeTool.type === "autoshape";
const laserToolSelected = activeTool.type === "laser";
const embeddableToolSelected = activeTool.type === "embeddable";
@@ -131,8 +134,8 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
? laserPointerToolIcon
: activeTool.type === "magicframe"
? MagicIcon
: extraToolsIcon
: extraToolsIcon;
: DotsIcon
: DotsIcon;
const toolProps = { app, activeTool };
@@ -152,7 +155,7 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
<SelectionToolPopover {...toolProps} setAppState={setAppState} />
{/* Free Draw */}
<FreedrawToolButton {...toolProps} hideShortcut />
<FreedrawToolPopover {...toolProps} />
{/* Eraser */}
<EraserToolButton {...toolProps} hideShortcut />
@@ -285,6 +288,16 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "autoshape" })}
icon={drawShapeToolIcon}
shortcut={getToolShortcut("autoshape")}
data-testid="toolbar-autoshape"
selected={drawShapeToolSelected}
disabled={isToolButtonDisabled(app, "autoshape")}
>
{t("toolBar.autoshape")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "laser" })}
icon={laserPointerToolIcon}
@@ -45,7 +45,7 @@ export const ToolPopover = ({
const [isPopupOpen, setIsPopupOpen] = useState(false);
const currentType = activeTool.type;
const isActive = displayedOption.type === currentType;
const SIDE_OFFSET = 32 / 2 + 10;
const SIDE_OFFSET = 16;
const { container } = useExcalidrawContainer();
// if currentType is not in options, close popup
@@ -13,10 +13,6 @@
.ToolIcon__keybinding {
display: none;
}
.App-toolbar__divider {
margin: 0;
}
}
&__divider {
@@ -24,11 +20,6 @@
height: 1.5rem;
align-self: center;
background-color: var(--default-border-color);
margin: 0 0.25rem;
@include isMobile {
margin: 0;
}
}
}
+32 -5
View File
@@ -14,20 +14,23 @@ import { PenModeButton } from "./PenModeButton";
import Stack from "./Stack";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import {
drawShapeToolIcon,
EmbedIcon,
extraToolsIcon,
frameToolIcon,
LassoIcon,
laserPointerToolIcon,
MagicIcon,
mermaidLogoIcon,
DotsIcon,
} from "./icons";
import {
ArrowToolButton,
DiamondToolButton,
EllipseToolButton,
EraserToolButton,
FreedrawToolPopover,
FreedrawToolButton,
getToolShortcut,
HandToolButton,
ImageToolButton,
isToolButtonDisabled,
@@ -60,6 +63,7 @@ const ExtraToolsDropdown = ({
const { TTDDialogTriggerTunnel } = useTunnels();
const frameToolSelected = activeTool.type === "frame";
const drawShapeToolSelected = activeTool.type === "autoshape";
const laserToolSelected = activeTool.type === "laser";
const lassoToolSelected =
isFullStylesPanel &&
@@ -74,6 +78,7 @@ const ExtraToolsDropdown = ({
"App-toolbar__extra-tools-trigger--selected":
frameToolSelected ||
embeddableToolSelected ||
(isFullStylesPanel && drawShapeToolSelected) ||
lassoToolSelected ||
// in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button
@@ -90,11 +95,13 @@ const ExtraToolsDropdown = ({
? frameToolIcon
: embeddableToolSelected
? EmbedIcon
: isFullStylesPanel && drawShapeToolSelected
? drawShapeToolIcon
: laserToolSelected && !app.props.isCollaborating
? laserPointerToolIcon
: lassoToolSelected
? LassoIcon
: extraToolsIcon}
: DotsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
@@ -120,6 +127,16 @@ const ExtraToolsDropdown = ({
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "autoshape" })}
icon={drawShapeToolIcon}
shortcut={getToolShortcut("autoshape")}
data-testid="toolbar-autoshape"
selected={drawShapeToolSelected}
disabled={isToolButtonDisabled(app, "autoshape")}
>
{t("toolBar.autoshape")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "laser" })}
icon={laserPointerToolIcon}
@@ -229,7 +246,10 @@ export const Toolbar = ({
disabled={app.props.activeTool != null}
/>
<div className="App-toolbar__divider" />
<div
className="App-toolbar__divider"
style={{ marginRight: "0.25rem" }}
/>
</>
)}
@@ -246,12 +266,19 @@ export const Toolbar = ({
<EllipseToolButton {...toolProps} />
<ArrowToolButton {...toolProps} />
<LineToolButton {...toolProps} />
<FreedrawToolButton {...toolProps} />
{isCompactStylesPanel ? (
<FreedrawToolPopover {...toolProps} />
) : (
<FreedrawToolButton {...toolProps} />
)}
<TextToolButton {...toolProps} />
{UIOptions.tools?.image !== false && <ImageToolButton {...toolProps} />}
<EraserToolButton {...toolProps} />
<div className="App-toolbar__divider" />
<div
className="App-toolbar__divider"
style={{ marginLeft: "0.25rem" }}
/>
<ExtraToolsDropdown
app={app}
+89 -8
View File
@@ -1,4 +1,5 @@
import clsx from "clsx";
import { useEffect, useState } from "react";
import { KEYS, capitalizeString } from "@excalidraw/common";
@@ -6,6 +7,7 @@ import type { PointerType } from "@excalidraw/element/types";
import { trackEvent } from "../analytics";
import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { IconButton } from "./IconButton";
import { ToolPopover } from "./ToolPopover";
@@ -17,6 +19,7 @@ import {
ArrowIcon,
LineIcon,
FreedrawIcon,
drawShapeToolIcon,
TextIcon,
ImageIcon,
EraserIcon,
@@ -38,6 +41,8 @@ export type ToolConfig = {
icon: React.ReactNode;
/** letter shortcut(s) — the first one is shown in tooltips */
letterKey?: string | readonly string[];
/** whether `letterKey` requires Shift to be held (e.g. Shift+X) */
shiftKey?: boolean;
numericKey?: string;
/** whether the tool's shapes can be filled — fills the icon when active */
fillable?: boolean;
@@ -125,6 +130,12 @@ export const TOOLS = defineTools({
icon: frameToolIcon,
letterKey: KEYS.F,
},
autoshape: {
icon: drawShapeToolIcon,
letterKey: KEYS.X,
shiftKey: true,
fillable: false,
},
embeddable: {
icon: EmbedIcon,
},
@@ -149,11 +160,14 @@ export const TOGGLE_TOOLS: readonly (ToolType | "custom")[] = (
).filter((type) => TOOLS[type].toggle);
export const getToolLetter = (type: ToolbarToolType) => {
const { letterKey } = TOOLS[type];
return (
letterKey &&
capitalizeString(typeof letterKey === "string" ? letterKey : letterKey[0])
const { letterKey, shiftKey } = TOOLS[type];
if (!letterKey) {
return letterKey;
}
const letter = capitalizeString(
typeof letterKey === "string" ? letterKey : letterKey[0],
);
return shiftKey ? getShortcutKey(`Shift+${letter}`) : letter;
};
/** human-readable shortcut hint, e.g. "R or 2", used in tooltips & aria */
@@ -165,13 +179,22 @@ export const getToolShortcut = (type: ToolbarToolType) => {
: `${letter || numericKey}`;
};
export const findShapeByKey = (key: string, app: AppClassProperties) => {
// CapsLock-insensitive (the caller excludes modified keypresses, incl.
// shift, so a capital letter here means CapsLock)
export const findShapeByKey = (
key: string,
app: AppClassProperties,
shiftKey: boolean = false,
) => {
// CapsLock-insensitive: the caller excludes every modifier but shift, and
// shift is matched explicitly below, so a capital letter on its own means
// CapsLock
const lowerKey = key.toLowerCase();
for (const type of Object.keys(TOOLS) as ToolbarToolType[]) {
const { letterKey, numericKey } = TOOLS[type];
const { letterKey, numericKey, shiftKey: requiresShift } = TOOLS[type];
// shift-bound tools require shift; plain-bound ones require its absence
if (shiftKey !== Boolean(requiresShift)) {
continue;
}
if (
(numericKey != null && key === numericKey) ||
(letterKey &&
@@ -379,3 +402,61 @@ export const SelectionToolPopover = ({
/>
);
};
/**
* The freedraw ⇄ draw-shape popover used in compact (tablet) and mobile
* toolbars. The trigger remembers and displays the most recently used option.
*/
export const FreedrawToolPopover = ({
app,
activeTool,
}: {
app: AppClassProperties;
activeTool: UIAppState["activeTool"];
}) => {
const DRAWING_TOOLS = [
{
type: "freedraw",
icon: TOOLS.freedraw.icon,
fillable: TOOLS.freedraw.fillable,
title: capitalizeString(t("toolBar.freedraw")),
},
{
type: "autoshape",
icon: TOOLS.autoshape.icon,
fillable: TOOLS.autoshape.fillable,
title: capitalizeString(t("toolBar.autoshape")),
},
] as const;
const [lastDrawingTool, setLastDrawingTool] = useState<
typeof DRAWING_TOOLS[number]["type"]
>(activeTool.type === "autoshape" ? "autoshape" : "freedraw");
useEffect(() => {
if (activeTool.type === "freedraw" || activeTool.type === "autoshape") {
setLastDrawingTool(activeTool.type);
}
}, [activeTool.type]);
const displayedOption =
DRAWING_TOOLS.find((tool) => tool.type === lastDrawingTool) ||
DRAWING_TOOLS[0];
return (
<ToolPopover
app={app}
options={DRAWING_TOOLS}
activeTool={activeTool}
defaultOption={lastDrawingTool}
data-testid="toolbar-freedraw"
onToolChange={(type: string) => {
if (type === "freedraw" || type === "autoshape") {
setLastDrawingTool(type);
app.setActiveTool({ type });
}
}}
displayedOption={displayedOption}
/>
);
};
+9 -10
View File
@@ -1964,16 +1964,6 @@ export const eyeDropperIcon = createIcon(
tablerIconProps,
);
export const extraToolsIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 3l-4 7h8z"></path>
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"></path>
</g>,
tablerIconProps,
);
export const frameToolIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@@ -2022,6 +2012,15 @@ export const ArrowRightIcon = createIcon(
modifiedTablerIconProps,
);
export const drawShapeToolIcon = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 9.5a6.5 6.5 0 1 0 13 0a6.5 6.5 0 1 0 -13 0" />
<path d="M10 12a2 2 0 0 1 2 -2h7a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-7a2 2 0 0 1 -2 -2l0 -7" />
</g>,
tablerIconProps,
);
export const laserPointerToolIcon = createIcon(
<g
fill="none"
@@ -23,6 +23,7 @@ import { alignActionsPredicate } from "../actions/actionAlign";
import {
canChangeRoundness,
canHaveArrowheads,
getSelectedElements,
hasBackground,
hasFreedrawMode,
hasStrokeStyle,
@@ -124,6 +125,7 @@ export const getShapeActionPredicates = (
strokeWidth: forToolOrSelection(hasStrokeWidth),
freedrawMode: forToolOrSelection(hasFreedrawMode),
strokeStyle: forToolOrSelection(hasStrokeStyle),
sloppiness: forToolOrSelection(hasStrokeStyle),
roundness: forToolOrSelection(canChangeRoundness),
arrowType: forToolOrSelection(toolIsArrow),
arrowheads: forToolOrSelection(canHaveArrowheads),
@@ -135,7 +137,18 @@ export const getShapeActionPredicates = (
suppportsHorizontalAlign(targetElements, elementsMap),
verticalAlign: shouldAllowVerticalAlign(targetElements, elementsMap),
opacity: activeToolType !== "autoshape" || hasSelection,
// arrangement
// z-order controls are hidden while the freedraw or drawShape tool is
// active without an actual selection
layers:
(activeToolType !== "freedraw" &&
activeToolType !== "autoshape" &&
!targetElements.some((element) => element.type === "freedraw")) ||
getSelectedElements(elementsMap, appState).some(
(element) => element.type === "freedraw",
),
align:
!isSingleElementBoundContainer && alignActionsPredicate(appState, app),
distribute: targetElements.length > 2,
+1
View File
@@ -217,6 +217,7 @@ export const AllowedExcalidrawActiveTools: Record<
embeddable: true,
hand: true,
laser: false,
autoshape: false,
magicframe: false,
};
+80
View File
@@ -0,0 +1,80 @@
import { getStrokeWidthByKey, THEME } from "@excalidraw/common";
import { clamp, pointFrom } from "@excalidraw/math";
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import type { GlobalPoint } from "@excalidraw/math";
import { AnimatedTrail } from "./animatedTrail";
import type App from "./components/App";
import type { Trail } from "./animatedTrail";
export class DrawShapeTrail implements Trail {
private trail: AnimatedTrail;
constructor(private app: App) {
this.trail = new AnimatedTrail(this.app, {
...this.getTrailOptions(),
fill: () =>
app.state.theme === THEME.LIGHT
? "rgba(0, 0, 0, 0.2)"
: "rgba(255, 255, 255, 0.2)",
});
}
private getTrailOptions() {
return {
simplify: 0,
streamline: 0.4,
size: 1,
sizeMapping: () => {
const size = clamp(
getStrokeWidthByKey(
"line",
this.app.state.currentItemStrokeWidthKey,
) *
0.65 *
this.app.state.zoom.value,
1.25,
4,
);
return size;
},
} as Partial<LaserPointerOptions>;
}
startPath(x: number, y: number): void {
this.trail.startPath(x, y);
}
addPointToPath(x: number, y: number): void {
this.trail.addPointToPath(x, y);
}
endPath(): void {
this.trail.endPath();
}
clearTrails(): void {
this.trail.clearTrails();
}
getCurrentPoints(): GlobalPoint[] {
const currentTrail = this.trail.getCurrentTrail();
if (!currentTrail) {
return [];
}
return currentTrail.originalPoints.map(([x, y]) =>
pointFrom<GlobalPoint>(x, y),
);
}
start(container: SVGSVGElement) {
this.trail.start(container);
}
stop() {
this.trail.stop();
}
}
+2
View File
@@ -331,6 +331,7 @@
"magicframe": "Wireframe to code",
"embeddable": "Web Embed",
"laser": "Laser pointer",
"autoshape": "Draw to shape",
"hand": "Hand (panning tool)",
"extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw",
@@ -366,6 +367,7 @@
"freeDraw": "Click and drag, release when you're finished",
"text": "Tip: you can also add text by double-clicking anywhere with the selection tool",
"embeddable": "Click-drag to create a website embed",
"autoshape": "Draw rectangles, circles, diamonds, lines and arrows",
"text_selected": "Double-click or press {{shortcut}} to edit text",
"text_editing": "Press {{shortcut_1}} or {{shortcut_2}} to finish editing",
"linearElementMulti": "Click on last point or press {{shortcut_1}} or {{shortcut_2}} to finish",
@@ -67,8 +67,10 @@ const _renderNewElementScene = ({
}
}
const renderTargetElement = newElement;
renderElement(
newElement,
renderTargetElement,
elementsMap,
allElementsMap,
rc,
@@ -0,0 +1,658 @@
import { KEYS } from "@excalidraw/common";
import { getTargetElements, isArrowElement } from "@excalidraw/element";
import { Excalidraw } from "../index";
import { actionFinalize } from "../actions";
import { getShapeActionPredicates } from "../components/shapeActionPredicates";
import { API } from "./helpers/api";
import { Keyboard, Pointer } from "./helpers/ui";
import { GlobalTestState, act, fireEvent, render, waitFor } from "./test-utils";
const { h } = window;
const mouse = new Pointer("mouse");
const sketch = (points: [number, number][]) => {
const [startX, startY] = points[0];
mouse.downAt(startX, startY);
act(() => {
for (const [x, y] of points.slice(1)) {
h.app.drawShape.trail.addPointToPath(x, y);
}
});
const [endX, endY] = points[points.length - 1];
mouse.upAt(endX, endY);
};
/** a closed, roughly rectangular path */
const rectanglePath = (
x: number,
y: number,
width: number,
height: number,
): [number, number][] => {
const points: [number, number][] = [];
const step = 10;
for (let i = x; i <= x + width; i += step) {
points.push([i, y]);
}
for (let i = y; i <= y + height; i += step) {
points.push([x + width, i]);
}
for (let i = x + width; i >= x; i -= step) {
points.push([i, y + height]);
}
for (let i = y + height; i >= y; i -= step) {
points.push([x, i]);
}
points.push([x, y]);
return points;
};
const seg = (
from: [number, number],
to: [number, number],
steps: number,
): [number, number][] => {
const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
points.push([
from[0] + ((to[0] - from[0]) * i) / steps,
from[1] + ((to[1] - from[1]) * i) / steps,
]);
}
return points;
};
/** a V-headed arrow: the shaft, then both head arms drawn back from the tip */
const arrowPath = (
x1: number,
y1: number,
x2: number,
y2: number,
): [number, number][] => {
const tip: [number, number] = [x2, y2];
const length = Math.hypot(x2 - x1, y2 - y1);
const ux = (x2 - x1) / length;
const uy = (y2 - y1) / length;
const headLength = length * 0.3;
const headAngle = Math.PI / 6;
const arm = (sign: number): [number, number] => [
x2 -
headLength * (ux * Math.cos(headAngle) - sign * uy * Math.sin(headAngle)),
y2 -
headLength * (uy * Math.cos(headAngle) + sign * ux * Math.sin(headAngle)),
];
return [
...seg([x1, y1], tip, 20),
...seg(tip, arm(1), 8),
...seg(arm(1), tip, 8),
...seg(tip, arm(-1), 8),
];
};
/** a closed, roughly circular path */
const circlePath = (
cx: number,
cy: number,
radius: number,
): [number, number][] => {
const points: [number, number][] = [];
for (let a = 0; a <= Math.PI * 2 + 0.1; a += 0.15) {
points.push([cx + Math.cos(a) * radius, cy + Math.sin(a) * radius]);
}
return points;
};
describe("autoshape tool", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
act(() => {
h.app.setActiveTool({ type: "autoshape" });
});
});
it("converts a sketched rectangle into a rectangle element", () => {
sketch(rectanglePath(100, 100, 200, 120));
expect(h.elements).toHaveLength(1);
expect(h.elements[0].type).toBe("rectangle");
});
it("converts a sketched circle into an ellipse element", () => {
sketch(circlePath(300, 300, 80));
expect(h.elements).toHaveLength(1);
expect(h.elements[0].type).toBe("ellipse");
});
it("keeps the autoshape tool active after finalizing a shape", () => {
sketch(rectanglePath(100, 100, 200, 120));
expect(h.elements).toHaveLength(1);
expect(h.state.activeTool.type).toBe("autoshape");
});
it("does not select the recognized element, so styles stay tool defaults", () => {
sketch(rectanglePath(100, 100, 200, 120));
expect(h.elements).toHaveLength(1);
expect(h.state.selectedElementIds).toEqual({});
});
it("does not select a recognized arrow, yet still binds it", () => {
const start = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 100,
height: 100,
});
const end = API.createElement({
type: "rectangle",
x: 400,
y: 300,
width: 100,
height: 100,
});
API.setElements([start, end]);
sketch(arrowPath(160, 160, 390, 290));
const arrow = h.elements.find((element) => element.type === "arrow");
assert(isArrowElement(arrow));
expect(h.state.selectedLinearElement).toBeNull();
expect(h.state.selectedElementIds).toEqual({});
expect(arrow.startBinding?.elementId).toBe(start.id);
expect(arrow.endBinding?.elementId).toBe(end.id);
});
it("does not select a recognized arrow that binds nothing", () => {
sketch(arrowPath(100, 100, 400, 300));
expect(h.elements.map((element) => element.type)).toEqual(["arrow"]);
expect(h.state.selectedLinearElement).toBeNull();
expect(h.state.selectedElementIds).toEqual({});
});
it("upgrades a line drawn between two bindable shapes to a bound arrow", () => {
const start = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 100,
height: 100,
});
const end = API.createElement({
type: "rectangle",
x: 400,
y: 400,
width: 100,
height: 100,
});
API.setElements([start, end]);
sketch(seg([205, 205], [395, 395], 30));
const arrow = h.elements.find((element) => element.type === "arrow");
assert(isArrowElement(arrow));
expect(arrow.startBinding?.elementId).toBe(start.id);
expect(arrow.endBinding?.elementId).toBe(end.id);
expect(h.state.selectedElementIds).toEqual({});
expect(h.state.selectedLinearElement).toBeNull();
});
it("orbit-binds arrows sketched from inside one shape into another, endpoints on the outlines", () => {
const start = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 100,
height: 100,
});
const end = API.createElement({
type: "rectangle",
x: 400,
y: 400,
width: 100,
height: 100,
});
API.setElements([start, end]);
// starts and ends deep inside the shapes
sketch(seg([150, 150], [450, 450], 30));
const arrow = h.elements.find((element) => element.type === "arrow");
assert(isArrowElement(arrow));
expect(arrow.startBinding?.mode).toBe("orbit");
expect(arrow.endBinding?.mode).toBe("orbit");
// the endpoints were pulled out of the shape interiors onto the outlines
const [endDx, endDy] = arrow.points[arrow.points.length - 1];
expect(arrow.x).toBeGreaterThan(200);
expect(arrow.y).toBeGreaterThan(200);
expect(arrow.x + endDx).toBeLessThan(400);
expect(arrow.y + endDy).toBeLessThan(400);
});
it("keeps inside-bindings for an arrow sketched within a single shape", () => {
const container = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 500,
height: 500,
});
API.setElements([container]);
sketch(arrowPath(150, 300, 400, 300));
const arrow = h.elements.find((element) => element.type === "arrow");
assert(isArrowElement(arrow));
expect(arrow.startBinding?.mode).toBe("inside");
expect(arrow.endBinding?.mode).toBe("inside");
// the endpoints stay where they were drawn
expect(arrow.x).toBeCloseTo(150, 0);
expect(arrow.y).toBeCloseTo(300, 0);
});
it("keeps a line sketched within a single shape a line", () => {
const container = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 500,
height: 500,
});
API.setElements([container]);
sketch(seg([150, 150], [450, 450], 30));
expect(h.elements.map((element) => element.type)).toEqual([
"rectangle",
"line",
]);
});
it("upgrades a line from inside a shape to blank canvas into an arrow orbit-bound at the shape", () => {
const only = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 100,
height: 100,
});
API.setElements([only]);
sketch(seg([150, 150], [395, 395], 30));
const arrow = h.elements.find((element) => element.type === "arrow");
assert(isArrowElement(arrow));
expect(arrow.startBinding?.elementId).toBe(only.id);
expect(arrow.startBinding?.mode).toBe("orbit");
expect(arrow.endBinding).toBeNull();
// the start was pulled out of the shape interior onto the outline
expect(arrow.x).toBeGreaterThan(200);
expect(arrow.y).toBeGreaterThan(200);
});
it("upgrades a line from blank canvas into a shape into an arrow orbit-bound at the shape", () => {
const only = API.createElement({
type: "rectangle",
x: 400,
y: 400,
width: 100,
height: 100,
});
API.setElements([only]);
sketch(seg([100, 100], [450, 450], 30));
const arrow = h.elements.find((element) => element.type === "arrow");
assert(isArrowElement(arrow));
expect(arrow.startBinding).toBeNull();
expect(arrow.endBinding?.elementId).toBe(only.id);
expect(arrow.endBinding?.mode).toBe("orbit");
// the end was pulled back onto the outline
const [endDx, endDy] = arrow.points[arrow.points.length - 1];
expect(arrow.x + endDx).toBeLessThan(400);
expect(arrow.y + endDy).toBeLessThan(400);
});
it("keeps a line touching no shape a line", () => {
sketch(seg([100, 100], [400, 400], 30));
expect(h.elements.map((element) => element.type)).toEqual(["line"]);
});
it("allows sketching consecutive shapes without reselecting the tool", () => {
sketch(rectanglePath(100, 100, 200, 120));
sketch(circlePath(600, 300, 80));
expect(h.elements).toHaveLength(2);
expect(h.elements[0].type).toBe("rectangle");
expect(h.elements[1].type).toBe("ellipse");
expect(h.state.activeTool.type).toBe("autoshape");
});
it("ignores a sketch too small to be a shape", () => {
sketch([
[100, 100],
[101, 101],
[102, 100],
[101, 99],
]);
expect(h.elements).toHaveLength(0);
expect(h.state.activeTool.type).toBe("autoshape");
});
});
describe("autoshape styles panel & selection (preview path)", () => {
/**
* Unlike `sketch`, also drives the pointermove handler, so
* `state.newElement` holds the live recognition preview mid-gesture the way
* it does in the real app.
*/
const sketchWithPreview = (points: [number, number][]) => {
const [startX, startY] = points[0];
mouse.downAt(startX, startY);
act(() => {
for (const [x, y] of points.slice(1)) {
h.app.drawShape.trail.addPointToPath(x, y);
h.app.drawShape.handlePointerMove({ x, y });
}
});
const [endX, endY] = points[points.length - 1];
mouse.upAt(endX, endY);
};
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
act(() => {
h.app.setActiveTool({ type: "autoshape" });
});
});
it("does not select a recognized arrow drawn with a live preview", () => {
sketchWithPreview(arrowPath(100, 100, 400, 300));
expect(h.elements.map((element) => element.type)).toEqual(["arrow"]);
expect(h.state.selectedElementIds).toEqual({});
expect(h.state.selectedLinearElement).toBeNull();
expect(h.state.activeTool.type).toBe("autoshape");
});
it("does not select a recognized rectangle drawn with a live preview", () => {
sketchWithPreview(rectanglePath(100, 100, 200, 120));
expect(h.elements.map((element) => element.type)).toEqual(["rectangle"]);
expect(h.state.selectedElementIds).toEqual({});
});
it("upgrades a line between two bindable shapes identically with a live preview", () => {
const start = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 100,
height: 100,
});
const end = API.createElement({
type: "rectangle",
x: 400,
y: 400,
width: 100,
height: 100,
});
API.setElements([start, end]);
sketchWithPreview(seg([205, 205], [395, 395], 30));
const arrow = h.elements.find((element) => element.type === "arrow");
assert(isArrowElement(arrow));
expect(arrow.startBinding?.elementId).toBe(start.id);
expect(arrow.endBinding?.elementId).toBe(end.id);
expect(h.state.newElement).toBeNull();
});
it("keeps the styles panel on tool defaults while sketching", () => {
mouse.downAt(100, 100);
act(() => {
for (const [x, y] of rectanglePath(100, 100, 200, 120).slice(1)) {
h.app.drawShape.trail.addPointToPath(x, y);
h.app.drawShape.handlePointerMove({ x, y });
}
});
// the preview exists, but the styles panel must not target it — it keeps
// showing the tool defaults
expect(h.state.newElement?.type).toBe("rectangle");
const targetElements = getTargetElements(
h.app.scene.getNonDeletedElementsMap(),
h.state,
);
expect(targetElements).toEqual([]);
// the drawShape panel is limited to stroke color, background, fill style
// and stroke style
const predicates = getShapeActionPredicates(
h.state,
targetElements,
h.app.scene.getNonDeletedElementsMap(),
h.app,
);
expect(predicates.strokeColor).toBe(true);
expect(predicates.backgroundColor).toBe(true);
expect(predicates.strokeStyle).toBe(true);
// fill follows the standard rule: hidden while the background is
// transparent (the default), shown otherwise
expect(predicates.fill).toBe(false);
expect(predicates.strokeWidth).toBe(true);
expect(predicates.sloppiness).toBe(true);
expect(predicates.roundness).toBe(false);
expect(predicates.opacity).toBe(false);
expect(predicates.layers).toBe(false);
act(() => {
h.setState({ currentItemBackgroundColor: "#ffc9c9" });
});
expect(
getShapeActionPredicates(
h.state,
targetElements,
h.app.scene.getNonDeletedElementsMap(),
h.app,
).fill,
).toBe(true);
mouse.upAt(300, 220);
expect(h.state.selectedElementIds).toEqual({});
});
});
describe("autoshape finalize funnel", () => {
/** starts a gesture and leaves the pointer down */
const startSketch = (points: [number, number][]) => {
const [startX, startY] = points[0];
mouse.downAt(startX, startY);
act(() => {
for (const [x, y] of points.slice(1)) {
h.app.drawShape.trail.addPointToPath(x, y);
h.app.drawShape.handlePointerMove({ x, y });
}
});
};
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
act(() => {
h.app.setActiveTool({ type: "autoshape" });
});
});
it("commits the pending sketch when actionFinalize fires mid-gesture", () => {
startSketch(rectanglePath(100, 100, 200, 120));
act(() => {
h.app.actionManager.executeAction(actionFinalize);
});
expect(h.elements.map((element) => element.type)).toEqual(["rectangle"]);
expect(h.state.newElement).toBeNull();
expect(h.app.drawShape.hasPendingGesture()).toBe(false);
// releasing the pointer must not commit a second element
mouse.upAt(300, 220);
expect(h.elements).toHaveLength(1);
});
it("commits the sketch when the tool is switched away mid-gesture (paste resets the tool)", () => {
startSketch(rectanglePath(100, 100, 200, 120));
act(() => {
h.app.setActiveTool({ type: "selection" });
});
expect(h.elements.map((element) => element.type)).toEqual(["rectangle"]);
expect(h.app.drawShape.hasPendingGesture()).toBe(false);
expect(h.state.newElement).toBeNull();
expect(h.state.activeTool.type).toBe("selection");
mouse.upAt(300, 220);
expect(h.elements).toHaveLength(1);
});
it("blocks undo while a sketch is in progress", () => {
sketch(rectanglePath(100, 100, 200, 120));
expect(h.elements).toHaveLength(1);
// start a second gesture; a tiny stroke isn't recognized yet, so
// `newElement` alone would not block history
startSketch([
[600, 100],
[610, 105],
[620, 100],
]);
expect(h.state.newElement).toBeNull();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
// undo was blocked — the previously committed shape is still there
expect(h.elements).toHaveLength(1);
expect(h.elements[0].isDeleted).toBe(false);
mouse.upAt(620, 100);
// the tiny sketch is discarded, the committed shape survives
expect(h.elements).toHaveLength(1);
expect(h.app.drawShape.hasPendingGesture()).toBe(false);
});
});
describe("autoshape tool activation", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("activates via its keyboard shortcut", () => {
expect(h.state.activeTool.type).toBe("selection");
Keyboard.withModifierKeys({ shift: true }, () => {
Keyboard.keyPress(KEYS.X);
});
expect(h.state.activeTool.type).toBe("autoshape");
});
it("is not activated by an unmodified X (freedraw's shortcut)", () => {
Keyboard.keyPress(KEYS.X);
expect(h.state.activeTool.type).toBe("freedraw");
});
it("activates from the extra tools dropdown", () => {
fireEvent.click(
GlobalTestState.renderResult.container.querySelector(
".App-toolbar__extra-tools-trigger",
)!,
);
fireEvent.click(
document.querySelector<HTMLButtonElement>(
'[data-testid="toolbar-autoshape"]',
)!,
);
expect(h.state.activeTool.type).toBe("autoshape");
});
});
describe("autoshape compact toolbar placement", () => {
it.each(["tablet", "phone"] as const)(
"groups autoshape under freedraw on %s",
async (formFactor) => {
const { container } = await render(
<Excalidraw UIOptions={{ getFormFactor: () => formFactor }} />,
);
fireEvent.resize(window);
await waitFor(() =>
expect(h.app.editorInterface.formFactor).toBe(formFactor),
);
const freedrawTrigger = container.querySelector(
'[data-testid="toolbar-freedraw"]',
)!;
fireEvent.click(freedrawTrigger);
const drawShapeOption = await waitFor(() => {
const option = document.querySelector<HTMLButtonElement>(
'.tool-popover-content [data-testid="toolbar-autoshape"]',
);
expect(option).not.toBeNull();
return option!;
});
fireEvent.click(drawShapeOption);
expect(h.state.activeTool.type).toBe("autoshape");
expect(freedrawTrigger).toHaveAttribute("aria-pressed", "true");
const extraToolsTrigger = container.querySelector(
".App-toolbar__extra-tools-trigger",
)!;
expect(extraToolsTrigger).not.toHaveClass(
"App-toolbar__extra-tools-trigger--selected",
);
fireEvent.click(extraToolsTrigger);
const extraTools = await waitFor(() => {
const menu = document.querySelector(
".App-toolbar__extra-tools-dropdown",
);
expect(menu).not.toBeNull();
return menu!;
});
expect(
extraTools.querySelector('[data-testid="toolbar-autoshape"]'),
).not.toBeNull();
},
);
});
+2 -1
View File
@@ -1350,7 +1350,8 @@ describe("tool locking & selection", () => {
value !== "laser" &&
// no top-level toolbar button (rendered in the extra-tools dropdown)
value !== "frame" &&
value !== "embeddable"
value !== "embeddable" &&
value !== "autoshape"
) {
const element = UI.createElement(value);
expect(h.state.selectedElementIds[element.id]).not.toBe(true);
+16
View File
@@ -107,6 +107,22 @@ describe("findShapeByKey()", () => {
expect(findShapeByKey("R", app)).toBe("rectangle");
expect(findShapeByKey("X", app)).toBe("freedraw");
});
it("matches shift-bound tools only when shift is held", () => {
const app = appWithPreferredTool("selection");
expect(findShapeByKey("X", app, true)).toBe("autoshape");
expect(findShapeByKey("x", app, true)).toBe("autoshape");
// Pressing "X" while CapsLock is active (no shift) stays freedraw
expect(findShapeByKey("X", app, false)).toBe("freedraw");
});
it("does not match plain-bound tools when shift is held", () => {
const app = appWithPreferredTool("selection");
expect(findShapeByKey("R", app, true)).toBeNull();
expect(findShapeByKey("V", app, true)).toBeNull();
});
});
describe("props.activeTool (forced tool)", () => {
+3 -1
View File
@@ -159,7 +159,8 @@ export type ToolType =
| "frame"
| "magicframe"
| "embeddable"
| "laser";
| "laser"
| "autoshape";
export type ElementOrToolType = ExcalidrawElementType | ToolType | "custom";
@@ -1071,6 +1072,7 @@ export type AppClassProperties = {
getName: App["getName"];
dismissLinearEditor: App["dismissLinearEditor"];
flowchart: App["flowchart"];
drawShape: App["drawShape"];
cursor: App["cursor"];
isToolLocked: App["isToolLocked"];
getEffectiveGridSize: App["getEffectiveGridSize"];
+1
View File
@@ -11,6 +11,7 @@ export type SizeMappingDetails = {
};
export type LaserPointerOptions = {
/** @default 2 */
size: number;
streamline: number;
+1
View File
@@ -2,6 +2,7 @@ export * from "./angle";
export * from "./curve";
export * from "./ellipse";
export * from "./line";
export * from "./pca";
export * from "./point";
export * from "./polygon";
export * from "./range";
+202
View File
@@ -0,0 +1,202 @@
import { pointFrom } from "./point";
import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector";
import type { GlobalPoint, LocalPoint, Vector } from "./types";
/**
* The principal axes of a point set, i.e. the eigen decomposition of its
* 2x2 covariance matrix.
*
* The axes are orthonormal and ordered by variance, so `major` is the
* direction along which the points spread the most. Together with `centroid`
* they define a canonical frame: expressing the points in it removes
* translation and rotation, and dividing by `sqrt(majorVariance)` removes
* scale.
*/
export type PrincipalAxes<Point extends GlobalPoint | LocalPoint> = {
centroid: Point;
/** Unit vector along the direction of largest variance. */
major: Vector;
/** Unit vector along the direction of smallest variance, normal to major. */
minor: Vector;
/** Variance along `major` (the larger eigenvalue, λ₁). */
majorVariance: number;
/** Variance along `minor` (the smaller eigenvalue, λ₂). */
minorVariance: number;
};
/**
* A point expressed in a principal axes frame: `u` along the major axis,
* `v` along the minor axis, both relative to the centroid.
*/
export type PrincipalCoords = [u: number, v: number];
/**
* Compute the centroid of a point set.
*/
export function centroid<Point extends GlobalPoint | LocalPoint>(
points: readonly Point[],
): Point {
let cx = 0;
let cy = 0;
for (const [x, y] of points) {
cx += x;
cy += y;
}
return pointFrom<Point>(cx / points.length, cy / points.length);
}
/**
* Principal component analysis of a 2D point set.
*
* Computes the centroid and the eigen decomposition of the covariance matrix
* [[μ20, μ11], [μ11, μ02]].
*
* @param points At least two points; fewer leaves the axes degenerate.
* @returns The centroid, the orthonormal axes and their variances.
*/
export function principalAxes<Point extends GlobalPoint | LocalPoint>(
points: readonly Point[],
): PrincipalAxes<Point> {
if (points.length < 2) {
console.error("Degenerate point set: at least two points are required");
}
const c = centroid(points);
let m20 = 0;
let m02 = 0;
let m11 = 0;
for (const [x, y] of points) {
const dx = x - c[0];
const dy = y - c[1];
m20 += dx * dx;
m02 += dy * dy;
m11 += dx * dy;
}
m20 /= points.length;
m02 /= points.length;
m11 /= points.length;
// Eigenvalues of [[m20, m11], [m11, m02]].
const trace = m20 + m02;
const diff = Math.hypot(m20 - m02, 2 * m11);
const majorVariance = (trace + diff) / 2;
const minorVariance = (trace - diff) / 2;
// Eigenvector for the larger eigenvalue. When m11 is 0 the covariance is
// already diagonal and the axes are the coordinate axes.
let major: Vector;
if (Math.abs(m11) > Number.EPSILON) {
major = vectorNormalize(vector(majorVariance - m02, m11));
} else {
major = m20 >= m02 ? vector(1, 0) : vector(0, 1);
}
return {
centroid: c,
major,
minor: vectorNormal(major),
majorVariance,
minorVariance,
};
}
/**
* Express points in the frame of the given principal axes, making the point
* cloud rotation, scale and translation invariant.
*/
export function principalCoords<Point extends GlobalPoint | LocalPoint>(
points: readonly Point[],
axes: PrincipalAxes<Point>,
scale: number = 1,
): PrincipalCoords[] {
const { centroid: c, major, minor } = axes;
return points.map(([x, y]) => {
const dx = x - c[0];
const dy = y - c[1];
return [
(dx * major[0] + dy * major[1]) * scale,
(dx * minor[0] + dy * minor[1]) * scale,
] as PrincipalCoords;
});
}
/**
* Flip the major axis so that it points toward the denser end of the point
* cloud, resolving the 180° sign ambiguity of the eigenvector.
*
* @returns The axes with `major`/`minor` flipped if needed. Variances are
* unchanged, as they are invariant to axis sign.
*/
export function orientPrincipalAxes<Point extends GlobalPoint | LocalPoint>(
points: readonly Point[],
axes: PrincipalAxes<Point>,
): PrincipalAxes<Point> {
const u = principalCoords(points, axes).map(([uu]) => uu);
if (skewness(u) <= 0) {
return axes;
}
const major = vectorScale(axes.major, -1);
return { ...axes, major, minor: vectorNormal(major) };
}
/**
* Ratio of the minor to the major variance in [0, 1]
*
* @returns 0 is a perfectly straight stroke, 1 is a stroke with no preferred
* direction.
*/
export function elongation<Point extends GlobalPoint | LocalPoint>(
axes: PrincipalAxes<Point>,
): number {
return axes.majorVariance > 0 ? axes.minorVariance / axes.majorVariance : 1;
}
/**
* The `order`-th standardized moment of a sample in a rotation and scale
* invariant way (sandardizing by sigma).
*
* @returns 0 for a degenerate (zero variance) sample.
*/
export function standardizedMoment(
values: readonly number[],
order: number,
): number {
const n = values.length;
let mean = 0;
for (const v of values) {
mean += v;
}
mean /= n;
let variance = 0;
let moment = 0;
for (const v of values) {
const d = v - mean;
variance += d * d;
moment += d ** order;
}
variance /= n;
moment /= n;
const sigma = Math.sqrt(variance);
return sigma > Number.EPSILON ? moment / sigma ** order : 0;
}
/**
* Third standardized moment: how lopsided a sample is.
*
*/
export function skewness(values: readonly number[]): number {
return standardizedMoment(values, 3);
}
/**
* Fourth standardized moment: how the mass is split between the tails and the
* middle of a sample. Not excess kurtosis, a normal sample gives 3.
*
*/
export function kurtosis(values: readonly number[]): number {
return standardizedMoment(values, 4);
}
+128 -21
View File
@@ -1,5 +1,4 @@
import { pointsEqual } from "./point";
import { lineSegment, pointOnLineSegment } from "./segment";
import { PRECISION } from "./utils";
import type { GlobalPoint, LocalPoint, Polygon } from "./types";
@@ -69,23 +68,6 @@ export const polygonIncludesPointNonZero = <Point extends [number, number]>(
return windingNumber !== 0;
};
export const pointOnPolygon = <Point extends LocalPoint | GlobalPoint>(
p: Point,
poly: Polygon<Point>,
threshold = PRECISION,
) => {
let on = false;
for (let i = 0, l = poly.length - 1; i < l; i++) {
if (pointOnLineSegment(p, lineSegment(poly[i], poly[i + 1]), threshold)) {
on = true;
break;
}
}
return on;
};
function polygonClose<Point extends LocalPoint | GlobalPoint>(
polygon: Point[],
) {
@@ -94,8 +76,133 @@ function polygonClose<Point extends LocalPoint | GlobalPoint>(
: ([...polygon, polygon[0]] as Polygon<Point>);
}
function polygonIsClosed<Point extends LocalPoint | GlobalPoint>(
polygon: Point[],
export function polygonIsClosed<Point extends LocalPoint | GlobalPoint>(
polygon: readonly Point[],
tolerance: number = PRECISION,
) {
return pointsEqual(polygon[0], polygon[polygon.length - 1]);
return pointsEqual(polygon[0], polygon[polygon.length - 1], tolerance);
}
/**
* The signed area of a polygon via the shoelace formula. Positive when the
* vertices wind counter-clockwise in a y-down coordinate system.
*
* The polygon may be given open or closed; a closing vertex is ignored.
*/
export function polygonSignedArea<Point extends LocalPoint | GlobalPoint>(
polygon: readonly Point[],
): number {
const pts = polygonIsClosed(polygon) ? polygon.slice(0, -1) : polygon;
let sum = 0;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
sum += pts[j][0] * pts[i][1] - pts[i][0] * pts[j][1];
}
return sum / 2;
}
export function polygonArea<Point extends LocalPoint | GlobalPoint>(
polygon: readonly Point[],
): number {
return Math.abs(polygonSignedArea(polygon));
}
/**
* The convex hull of a point set via Andrew's monotone chain.
*
* @returns The hull vertices in counter-clockwise order (y-down), without a
* repeated closing vertex.
*/
export function convexHull<Point extends LocalPoint | GlobalPoint>(
points: readonly Point[],
): Point[] {
if (points.length < 3) {
return [...points];
}
const sorted = [...points].sort((a, b) =>
a[0] === b[0] ? a[1] - b[1] : a[0] - b[0],
);
// Cross product of OA x OB. Negative means the turn O->A->B is clockwise.
const cross = (o: Point, a: Point, b: Point) =>
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const half = (pts: Point[]) => {
const chain: Point[] = [];
for (const p of pts) {
while (
chain.length >= 2 &&
cross(chain[chain.length - 2], chain[chain.length - 1], p) <= 0
) {
chain.pop();
}
chain.push(p);
}
chain.pop(); // shared with the other half's first vertex
return chain;
};
const hull = [...half(sorted), ...half([...sorted].reverse())];
return hull.length >= 3 ? hull : [...points];
}
/**
* Drop the points of a convex polygon that only contribute a shallow turn,
* merging each run of near-collinear points into one.
*
* @param polygon A convex polygon, as returned by `convexHull`.
* @param angleThreshold Minimum accumulated turn (radians) for a vertex to be
* kept.
*/
export function simplifyConvexPolygon<Point extends LocalPoint | GlobalPoint>(
polygon: readonly Point[],
angleThreshold: number,
): Point[] {
if (polygon.length < 3) {
return [...polygon];
}
const turnAt = (i: number) => {
const prev = polygon[(i - 1 + polygon.length) % polygon.length];
const curr = polygon[i];
const next = polygon[(i + 1) % polygon.length];
const inAngle = Math.atan2(curr[1] - prev[1], curr[0] - prev[0]);
const outAngle = Math.atan2(next[1] - curr[1], next[0] - curr[0]);
return Math.abs(normalizeTurn(outAngle - inAngle));
};
// Walk the hull accumulating turn, emitting a vertex whenever enough of it
// has built up. Starting at the sharpest vertex keeps the result stable: a
// real corner always ends a run rather than being split across the seam.
let start = 0;
for (let i = 1; i < polygon.length; i++) {
if (turnAt(i) > turnAt(start)) {
start = i;
}
}
const simplified: Point[] = [];
let accumulated = 0;
for (let k = 0; k < polygon.length; k++) {
const i = (start + k) % polygon.length;
accumulated += turnAt(i);
if (accumulated >= angleThreshold) {
simplified.push(polygon[i]);
accumulated = 0;
}
}
return simplified.length >= 3 ? simplified : [...polygon];
}
function normalizeTurn(angle: number): number {
let a = angle;
while (a > Math.PI) {
a -= 2 * Math.PI;
}
while (a < -Math.PI) {
a += 2 * Math.PI;
}
return a;
}
+200
View File
@@ -0,0 +1,200 @@
import {
centroid,
elongation,
kurtosis,
orientPrincipalAxes,
principalAxes,
principalCoords,
skewness,
standardizedMoment,
} from "../src/pca";
import { pointFrom } from "../src/point";
import type { GlobalPoint } from "../src/types";
const rotate = (points: GlobalPoint[], angle: number, scale = 1) =>
points.map(([x, y]) =>
pointFrom<GlobalPoint>(
(x * Math.cos(angle) - y * Math.sin(angle)) * scale + 17,
(x * Math.sin(angle) + y * Math.cos(angle)) * scale - 4,
),
);
const horizontalSpread: GlobalPoint[] = Array.from({ length: 21 }, (_, i) =>
pointFrom<GlobalPoint>(i - 10, 0),
);
describe("centroid", () => {
it("averages the points", () => {
expect(
centroid([
pointFrom<GlobalPoint>(0, 0),
pointFrom<GlobalPoint>(4, 0),
pointFrom<GlobalPoint>(2, 6),
]),
).toEqual([2, 2]);
});
});
describe("principalAxes", () => {
it("finds the direction the points spread along", () => {
const axes = principalAxes(horizontalSpread);
expect(Math.abs(axes.major[0])).toBeCloseTo(1);
expect(axes.major[1]).toBeCloseTo(0);
expect(axes.majorVariance).toBeGreaterThan(axes.minorVariance);
});
it("tracks the points when they are rotated", () => {
const axes = principalAxes(rotate(horizontalSpread, Math.PI / 6));
// Up to the sign of the eigenvector, the major axis is the rotated x axis.
expect(Math.abs(axes.major[0])).toBeCloseTo(Math.cos(Math.PI / 6));
expect(Math.abs(axes.major[1])).toBeCloseTo(Math.sin(Math.PI / 6));
});
it("keeps the axes orthonormal", () => {
const { major, minor } = principalAxes([
...rotate(horizontalSpread, 1.1),
pointFrom<GlobalPoint>(3, 9),
]);
expect(Math.hypot(...major)).toBeCloseTo(1);
expect(Math.hypot(...minor)).toBeCloseTo(1);
expect(major[0] * minor[0] + major[1] * minor[1]).toBeCloseTo(0);
});
it("falls back to the coordinate axes for an already diagonal covariance", () => {
const axes = principalAxes([
pointFrom<GlobalPoint>(-1, 0),
pointFrom<GlobalPoint>(1, 0),
pointFrom<GlobalPoint>(0, -5),
pointFrom<GlobalPoint>(0, 5),
]);
expect(axes.major).toEqual([0, 1]);
});
});
describe("principalCoords", () => {
it("undoes translation, rotation and scale", () => {
const points = rotate(horizontalSpread, 0.7, 3);
const axes = principalAxes(points);
const coords = principalCoords(
points,
axes,
1 / Math.sqrt(axes.majorVariance),
);
// The canonical form of a horizontal spread is the same spread, whatever
// was done to it in between.
const us = coords.map(([u]) => u).sort((a, b) => a - b);
expect(coords.every(([, v]) => Math.abs(v) < 1e-9)).toBe(true);
expect(Math.abs(us[0])).toBeCloseTo(Math.abs(us[us.length - 1]));
expect(Math.abs(us[0])).toBeCloseTo(1.65, 1);
});
});
describe("orientPrincipalAxes", () => {
it("points the major axis at the dense end", () => {
// Sparse to the left of the origin, piled up to the right of it.
const lopsided: GlobalPoint[] = [
...Array.from({ length: 5 }, (_, i) =>
pointFrom<GlobalPoint>(-100 + i * 10, 0),
),
...Array.from({ length: 30 }, (_, i) => pointFrom<GlobalPoint>(i, 0)),
];
for (const angle of [0, 1, 2.5, 4]) {
const points = rotate(lopsided, angle);
const axes = orientPrincipalAxes(points, principalAxes(points));
const us = principalCoords(points, axes).map(([u]) => u);
// The dense end is at +u, i.e. the projection is skewed negative.
expect(skewness(us)).toBeLessThan(0);
}
});
it("leaves a symmetric point set alone", () => {
const axes = principalAxes(horizontalSpread);
expect(orientPrincipalAxes(horizontalSpread, axes)).toEqual(axes);
});
});
describe("elongation", () => {
it("is 0 for a straight spread and 1 for an isotropic one", () => {
expect(elongation(principalAxes(horizontalSpread))).toBeCloseTo(0);
expect(
elongation(
principalAxes(
Array.from({ length: 36 }, (_, i) =>
pointFrom<GlobalPoint>(
Math.cos((i * Math.PI) / 18),
Math.sin((i * Math.PI) / 18),
),
),
),
),
).toBeCloseTo(1);
});
it("is invariant to rotation and scale", () => {
const points = Array.from({ length: 40 }, (_, i) =>
pointFrom<GlobalPoint>(Math.cos(i / 6) * 4, Math.sin(i / 6)),
);
expect(elongation(principalAxes(rotate(points, 0.9, 7)))).toBeCloseTo(
elongation(principalAxes(points)),
);
});
});
describe("standardizedMoment", () => {
it("is 0 for a sample with no spread", () => {
expect(standardizedMoment([3, 3, 3], 3)).toBe(0);
});
it("is unchanged by shifting and scaling the sample", () => {
const sample = [1, 2, 2, 3, 9, 4];
expect(
standardizedMoment(
sample.map((v) => v * 5 + 100),
3,
),
).toBeCloseTo(standardizedMoment(sample, 3));
});
});
describe("skewness", () => {
it("is 0 for a symmetric sample", () => {
expect(skewness([-2, -1, 0, 1, 2])).toBeCloseTo(0);
});
it("is positive when the tail runs to the right of the mass", () => {
expect(skewness([1, 1, 1, 1, 1, 9])).toBeGreaterThan(0);
expect(skewness([-9, -1, -1, -1, -1, -1])).toBeLessThan(0);
});
});
describe("kurtosis", () => {
it("separates a uniform sample from a normal-ish one", () => {
const uniform = Array.from({ length: 101 }, (_, i) => i / 100);
// 1.8 for a uniform sample, 3 for a normal one.
expect(kurtosis(uniform)).toBeCloseTo(1.8, 1);
});
it("is invariant to rotation, via the principal frame", () => {
const points = Array.from({ length: 50 }, (_, i) =>
pointFrom<GlobalPoint>(i - 25, ((i % 5) - 2) * 0.3),
);
const project = (pts: GlobalPoint[]) => {
const axes = principalAxes(pts);
return kurtosis(principalCoords(pts, axes).map(([u]) => u));
};
expect(project(rotate(points, 1.3, 2))).toBeCloseTo(project(points));
});
});
+120
View File
@@ -0,0 +1,120 @@
import { pointFrom } from "../src/point";
import {
convexHull,
polygonArea,
polygonSignedArea,
simplifyConvexPolygon,
} from "../src/polygon";
import type { GlobalPoint } from "../src/types";
const square: GlobalPoint[] = [
pointFrom(0, 0),
pointFrom(10, 0),
pointFrom(10, 10),
pointFrom(0, 10),
];
describe("polygonArea", () => {
it("measures a polygon, whichever way it winds", () => {
expect(polygonArea(square)).toBe(100);
expect(polygonArea([...square].reverse())).toBe(100);
});
it("ignores a repeated closing vertex", () => {
expect(polygonArea([...square, square[0]])).toBe(100);
});
it("reports the winding direction in the sign", () => {
expect(polygonSignedArea(square)).toBe(100);
expect(polygonSignedArea([...square].reverse())).toBe(-100);
});
});
describe("convexHull", () => {
it("drops the points inside the hull", () => {
const hull = convexHull([...square, pointFrom<GlobalPoint>(5, 5)]);
expect(hull).toHaveLength(4);
expect(hull).toEqual(expect.arrayContaining(square));
});
it("drops collinear points", () => {
expect(convexHull([...square, pointFrom<GlobalPoint>(5, 0)])).toHaveLength(
4,
);
});
it("wraps a point cloud whatever order it arrives in", () => {
const cloud: GlobalPoint[] = Array.from({ length: 30 }, (_, i) =>
pointFrom(Math.cos(i * 2.4) * 10, Math.sin(i * 2.4) * 10),
);
const hull = convexHull(cloud);
expect(polygonArea(hull)).toBeCloseTo(
polygonArea(convexHull([...cloud].reverse())),
);
// Every point is inside or on the hull it produced.
expect(polygonArea(hull)).toBeGreaterThan(250);
expect(polygonArea(hull)).toBeLessThanOrEqual(Math.PI * 100);
});
it("returns degenerate input as-is", () => {
const two = square.slice(0, 2);
expect(convexHull(two)).toEqual(two);
});
});
describe("simplifyConvexPolygon", () => {
const circle = (n: number): GlobalPoint[] =>
Array.from({ length: n }, (_, i) =>
pointFrom(
Math.cos((i * 2 * Math.PI) / n) * 100,
Math.sin((i * 2 * Math.PI) / n) * 100,
),
);
it("keeps the corners of a polygon and drops the wobble along its sides", () => {
// A square whose sides bulge slightly outward, as a hand-drawn one would.
const wobbly = convexHull([
...square,
pointFrom<GlobalPoint>(5, -0.2),
pointFrom<GlobalPoint>(10.2, 5),
pointFrom<GlobalPoint>(5, 10.2),
pointFrom<GlobalPoint>(-0.2, 5),
]);
expect(simplifyConvexPolygon(wobbly, (25 * Math.PI) / 180)).toHaveLength(4);
});
it("spreads an ellipse's turn over many corners", () => {
const corners = simplifyConvexPolygon(
convexHull(circle(64)),
(25 * Math.PI) / 180,
);
// A full turn is 360°, emitted in ~25° steps.
expect(corners.length).toBeGreaterThan(10);
expect(corners.length).toBeLessThanOrEqual(15);
});
it("does not depend on where the hull happens to start", () => {
const hull = convexHull([
...square,
pointFrom<GlobalPoint>(5, -0.2),
pointFrom<GlobalPoint>(10.2, 5),
]);
const rotated = [...hull.slice(2), ...hull.slice(0, 2)];
expect(simplifyConvexPolygon(rotated, (25 * Math.PI) / 180)).toHaveLength(
simplifyConvexPolygon(hull, (25 * Math.PI) / 180).length,
);
});
it("returns degenerate input as-is", () => {
const two = square.slice(0, 2);
expect(simplifyConvexPolygon(two, 0.4)).toEqual(two);
});
});
-17
View File
@@ -3,7 +3,6 @@ import {
lineSegment,
polygon,
pointOnLineSegment,
pointOnPolygon,
polygonIncludesPoint,
segmentsIntersectAt,
} from "@excalidraw/math";
@@ -43,22 +42,6 @@ describe("point and line", () => {
});
describe("point and polygon", () => {
const poly: Polygon<GlobalPoint> = polygon(
pointFrom(10, 10),
pointFrom(50, 10),
pointFrom(50, 50),
pointFrom(10, 50),
);
it("point on polygon", () => {
expect(pointOnPolygon(pointFrom(30, 10), poly)).toBe(true);
expect(pointOnPolygon(pointFrom(50, 30), poly)).toBe(true);
expect(pointOnPolygon(pointFrom(30, 50), poly)).toBe(true);
expect(pointOnPolygon(pointFrom(10, 30), poly)).toBe(true);
expect(pointOnPolygon(pointFrom(30, 30), poly)).toBe(false);
expect(pointOnPolygon(pointFrom(30, 70), poly)).toBe(false);
});
it("point in polygon", () => {
const poly: Polygon<GlobalPoint> = polygon(
pointFrom(0, 0),