mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
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:
co-authored by
Claude Fable 5
dwelle
Mark Tolmacs
parent
e6ae6bf057
commit
f179f7ffd2
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -102,3 +102,4 @@ export * from "./utils";
|
||||
export * from "./zindex";
|
||||
export * from "./arrows/helpers";
|
||||
export * from "./arrowheads";
|
||||
export * from "./convertToShape";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ export type ActionName =
|
||||
| "changeBackgroundColor"
|
||||
| "changeFillStyle"
|
||||
| "changeStrokeWidth"
|
||||
| "changeStrokeShape"
|
||||
| "changeSloppiness"
|
||||
| "changeFreedrawMode"
|
||||
| "changeStrokeStyle"
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -217,6 +217,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
||||
embeddable: true,
|
||||
hand: true,
|
||||
laser: false,
|
||||
autoshape: false,
|
||||
magicframe: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -11,6 +11,7 @@ export type SizeMappingDetails = {
|
||||
};
|
||||
|
||||
export type LaserPointerOptions = {
|
||||
/** @default 2 */
|
||||
size: number;
|
||||
|
||||
streamline: number;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user