Compare commits

..

3 Commits

Author SHA1 Message Date
dwelle a22927d4d1 DEBUG 2025-01-07 18:28:01 +01:00
dwelle ca9b7a505e flake 2025-01-07 18:04:43 +01:00
dwelle 36b387f973 feat: add timeout on doublick pointerup 2025-01-07 18:00:22 +01:00
11 changed files with 165 additions and 221 deletions
+17 -7
View File
@@ -91,6 +91,7 @@ import {
DEFAULT_REDUCED_GLOBAL_ALPHA,
isSafari,
type EXPORT_IMAGE_TYPES,
DOUBLE_CLICK_POINTERUP_TIMEOUT,
} from "../constants";
import type { ExportedElements } from "../data";
import { exportCanvas, loadFromBlob } from "../data";
@@ -233,7 +234,7 @@ import {
findShapeByKey,
getBoundTextShape,
getCornerRadius,
getElementShapes,
getElementShape,
isPathALoop,
} from "../shapes";
import { getSelectionBoxShape } from "../../utils/geometry/shape";
@@ -5009,7 +5010,7 @@ class App extends React.Component<AppProps, AppState> {
x,
y,
element: elementWithHighestZIndex,
shapes: getElementShapes(
shape: getElementShape(
elementWithHighestZIndex,
this.scene.getNonDeletedElementsMap(),
),
@@ -5121,7 +5122,7 @@ class App extends React.Component<AppProps, AppState> {
x,
y,
element,
shapes: getElementShapes(element, this.scene.getNonDeletedElementsMap()),
shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(element)
? this.frameNameBoundsCache.get(element)
@@ -5153,7 +5154,7 @@ class App extends React.Component<AppProps, AppState> {
x,
y,
element: elements[index],
shapes: getElementShapes(
shape: getElementShape(
elements[index],
this.scene.getNonDeletedElementsMap(),
),
@@ -5349,6 +5350,14 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasDoubleClick = (
event: React.MouseEvent<HTMLCanvasElement>,
) => {
if (
this.lastPointerDownEvent &&
event.timeStamp - this.lastPointerDownEvent.timeStamp >
DOUBLE_CLICK_POINTERUP_TIMEOUT
) {
return;
}
// case: double-clicking with arrow/line tool selected would both create
// text and enter multiElement mode
if (this.state.multiElement) {
@@ -5437,7 +5446,7 @@ class App extends React.Component<AppProps, AppState> {
x: sceneX,
y: sceneY,
element: container,
shapes: getElementShapes(
shape: getElementShape(
container,
this.scene.getNonDeletedElementsMap(),
),
@@ -6211,7 +6220,7 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX,
y: scenePointerY,
element,
shapes: getElementShapes(
shape: getElementShape(
element,
this.scene.getNonDeletedElementsMap(),
),
@@ -6279,6 +6288,7 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLElement>,
) => {
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser();
if (this.state.searchMatches) {
@@ -9344,7 +9354,7 @@ class App extends React.Component<AppProps, AppState> {
x: pointerDownState.origin.x,
y: pointerDownState.origin.y,
element: hitElement,
shapes: getElementShapes(
shape: getElementShape(
hitElement,
this.scene.getNonDeletedElementsMap(),
),
+8
View File
@@ -255,6 +255,14 @@ export const EXPORT_SOURCE =
// time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300;
/**
* The time the user has from 2nd pointerdown to following pointerup
* before it's not considered a double click.
*
* Helps prevent cases where you double-click by mistake but then drag/keep
* the pointer down for to cancel the double click or do another action.
*/
export const DOUBLE_CLICK_POINTERUP_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
export const VERSION_TIMEOUT = 30000;
+3 -3
View File
@@ -52,7 +52,7 @@ import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { aabbForElement, getElementShapes, pointInsideBounds } from "../shapes";
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
import {
compareHeading,
HEADING_DOWN,
@@ -1406,9 +1406,9 @@ export const bindingBorderTest = (
): boolean => {
const threshold = maxBindingGap(element, element.width, element.height, zoom);
const shapes = getElementShapes(element, elementsMap);
const shape = getElementShape(element, elementsMap);
return (
shapes.some((shape) => isPointOnShape(pointFrom(x, y), shape, threshold)) ||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
(fullShape === true &&
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
);
+39 -39
View File
@@ -8,6 +8,7 @@ import type {
ElementsMap,
} from "./types";
import rough from "roughjs/bin/rough";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { Drawable, Op } from "roughjs/bin/core";
import type { AppState } from "../types";
import { generateRoughOptions } from "../scene/Shape";
@@ -23,7 +24,13 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { ShapeCache } from "../scene/ShapeCache";
import { arrayToMap, invariant } from "../utils";
import type { Degrees, GlobalPoint, LineSegment, Radians } from "../../math";
import type {
Degrees,
GlobalPoint,
LineSegment,
LocalPoint,
Radians,
} from "../../math";
import {
degreesToRadians,
lineSegment,
@@ -32,6 +39,7 @@ import {
pointFromArray,
pointRotateRads,
} from "../../math";
import type { Mutable } from "../utility-types";
export type RectangleBox = {
x: number;
@@ -724,12 +732,36 @@ export const getArrowheadPoints = (
return [x2, y2, x3, y3, x4, y4];
};
const generateLinearElementShape = (
element: ExcalidrawLinearElement,
): Drawable => {
const generator = rough.generator();
const options = generateRoughOptions(element);
const method = (() => {
if (element.roundness) {
return "curve";
}
if (options.fill) {
return "polygon";
}
return "linearPath";
})();
return generator[method](
element.points as Mutable<LocalPoint>[] as RoughPoint[],
options,
);
};
const getLinearElementRotatedBounds = (
element: ExcalidrawLinearElement,
cx: number,
cy: number,
elementsMap: ElementsMap,
): Bounds => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (element.points.length < 2) {
const [pointX, pointY] = element.points[0];
const [x, y] = pointRotateRads(
@@ -739,7 +771,6 @@ const getLinearElementRotatedBounds = (
);
let coords: Bounds = [x, y, x, y];
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element,
@@ -757,48 +788,18 @@ const getLinearElementRotatedBounds = (
return coords;
}
const cachedShape =
ShapeCache.get(element) ?? ShapeCache.generateElementShape(element, null);
const [arrowCurve, ...arrowhead] = cachedShape;
// first element is always the curve
const cachedShape = ShapeCache.get(element)?.[0];
const shape = cachedShape ?? generateLinearElementShape(element);
const ops = getCurvePathOps(shape);
const transformXY = ([x, y]: GlobalPoint) =>
pointRotateRads<GlobalPoint>(
pointFrom(element.x + x, element.y + y),
pointFrom(cx, cy),
element.angle,
);
let coords = getMinMaxXYFromCurvePathOps(
getCurvePathOps(arrowCurve),
transformXY,
);
for (const shape of arrowhead) {
let [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(
getCurvePathOps(shape),
);
[minX, minY] = pointRotateRads<GlobalPoint>(
pointFrom(minX + element.x, minY + element.y),
pointFrom(cx, cy),
element.angle,
);
[maxX, maxY] = pointRotateRads<GlobalPoint>(
pointFrom(maxX + element.x, maxY + element.y),
pointFrom(cx, cy),
element.angle,
);
coords = [
Math.min(minX, coords[0]),
Math.min(minY, coords[1]),
Math.max(maxX, coords[2]),
Math.max(maxY, coords[3]),
];
}
const boundTextElement = getBoundTextElement(element, elementsMap);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: Bounds = [res[0], res[1], res[2], res[3]];
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element,
@@ -813,7 +814,6 @@ const getLinearElementRotatedBounds = (
coordsWithBoundText[3],
];
}
return coords;
};
+8 -10
View File
@@ -45,7 +45,7 @@ export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
x: number;
y: number;
element: ExcalidrawElement;
shapes: GeometricShape<Point>[];
shape: GeometricShape<Point>;
threshold?: number;
frameNameBound?: FrameNameBounds | null;
};
@@ -54,18 +54,16 @@ export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
x,
y,
element,
shapes,
shape,
threshold = 10,
frameNameBound = null,
}: HitTestArgs<Point>) => {
const testInside = shouldTestInside(element);
let hit = shapes.some((shape) =>
testInside || shape.isClosed
? isPointInShape(pointFrom(x, y), shape) ||
isPointOnShape(pointFrom(x, y), shape, threshold)
: isPointOnShape(pointFrom(x, y), shape, threshold),
);
let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInShape(pointFrom(x, y), shape) ||
isPointOnShape(pointFrom(x, y), shape, threshold)
: isPointOnShape(pointFrom(x, y), shape, threshold);
// hit test against a frame's name
if (!hit && frameNameBound) {
@@ -1720,10 +1720,10 @@ export class LinearElementEditor {
includeBoundText: boolean = false,
): [number, number, number, number, number, number] => {
let coords: [number, number, number, number, number, number];
let x1 = Infinity;
let y1 = Infinity;
let x2 = -Infinity;
let y2 = -Infinity;
let x1;
let y1;
let x2;
let y2;
if (element.points.length < 2 || !ShapeCache.get(element)) {
// XXX this is just a poor estimate and not very useful
const { minX, minY, maxX, maxY } = element.points.reduce(
@@ -1745,15 +1745,14 @@ export class LinearElementEditor {
} else {
const shape = ShapeCache.generateElementShape(element, null);
for (const s of shape) {
const ops = getCurvePathOps(s);
// first element is always the curve
const ops = getCurvePathOps(shape[0]);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
x1 = Math.min(minX + element.x, x1);
y1 = Math.min(minY + element.y, y1);
x2 = Math.max(maxX + element.x, x2);
y2 = Math.max(maxY + element.y, y2);
}
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
x1 = minX + element.x;
y1 = minY + element.y;
x2 = maxX + element.x;
y2 = maxY + element.y;
}
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
+36 -103
View File
@@ -1,4 +1,3 @@
import { pointsOnBezierCurves } from "points-on-curve";
import {
isPoint,
pointFrom,
@@ -8,7 +7,6 @@ import {
pointsEqual,
type GlobalPoint,
type LocalPoint,
polygonFromPoints,
} from "../math";
import {
getClosedCurveShape,
@@ -16,7 +14,6 @@ import {
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPointsOnRoughCurve,
getPolygonShape,
type GeometricShape,
} from "../utils/geometry/shape";
@@ -144,10 +141,10 @@ export const findShapeByKey = (key: string) => {
* get the pure geometric shape of an excalidraw element
* which is then used for hit detection
*/
export const getElementShapes = <Point extends GlobalPoint | LocalPoint>(
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape<Point>[] => {
): GeometricShape<Point> => {
switch (element.type) {
case "rectangle":
case "diamond":
@@ -158,102 +155,40 @@ export const getElementShapes = <Point extends GlobalPoint | LocalPoint>(
case "iframe":
case "text":
case "selection":
return [getPolygonShape(element)];
return getPolygonShape(element);
case "arrow":
case "line": {
const [curve, ...arrowheads] =
ShapeCache.get(element) ??
ShapeCache.generateElementShape(element, null);
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
const center = pointFrom<Point>(cx, cy);
const startingPoint = pointFrom<Point>(element.x, element.y);
if (shouldTestInside(element)) {
return [
getClosedCurveShape<Point>(
return shouldTestInside(element)
? getClosedCurveShape<Point>(
element,
curve,
startingPoint,
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
center,
),
];
}
// otherwise return the curve shape (and also the shape of its arrowheads)
const arrowheadShapes: GeometricShape<Point>[] = [];
const transform = (p: Point): Point =>
pointRotateRads(
pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]),
center,
element.angle,
);
for (const arrowhead of arrowheads) {
if (arrowhead.shape === "polygon") {
const ops = arrowhead.sets[0].ops;
const otherPoints = ops.slice(1);
const arrowheadShape: GeometricShape<Point> = {
type: "polygon",
data: polygonFromPoints(
otherPoints.map((otherPoint) =>
transform(
pointFrom<Point>(otherPoint.data[0], otherPoint.data[1]),
),
),
),
isClosed: true,
};
arrowheadShapes.push(arrowheadShape);
}
if (arrowhead.shape === "circle") {
const polygonPoints = pointsOnBezierCurves(
getPointsOnRoughCurve(arrowhead),
15,
2,
).map((p) => transform(p as Point)) as Point[];
arrowheadShapes.push({
type: "polygon",
data: polygonFromPoints(polygonPoints),
isClosed: true,
});
}
if (arrowhead.shape === "line") {
arrowheadShapes.push(
getCurveShape<Point>(
arrowhead,
element.angle,
center,
startingPoint,
),
pointFrom(cx, cy),
)
: getCurveShape<Point>(
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
);
}
}
return [
getCurveShape<Point>(
curve,
element.angle,
pointFrom(cx, cy),
startingPoint,
),
...arrowheadShapes,
];
}
case "ellipse":
return [getEllipseShape(element)];
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return [
getFreedrawShape(element, pointFrom(cx, cy), shouldTestInside(element)),
];
return getFreedrawShape(
element,
pointFrom(cx, cy),
shouldTestInside(element),
);
}
}
};
@@ -266,23 +201,21 @@ export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
if (boundTextElement) {
if (element.type === "arrow") {
return (
getElementShapes<Point>(
{
...boundTextElement,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
),
},
elementsMap,
)[0] ?? null
return getElementShape(
{
...boundTextElement,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
),
},
elementsMap,
);
}
return getElementShapes<Point>(boundTextElement, elementsMap)[0] ?? null;
return getElementShape(boundTextElement, elementsMap);
}
return null;
+12 -5
View File
@@ -418,12 +418,19 @@ describe("element binding", () => {
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
// Drag arrow off of bound rectangle range
const handles = getTransformHandles(
arrow,
h.state.zoom,
arrayToMap(h.elements),
"mouse",
).se!;
Keyboard.keyDown(KEYS.CTRL_OR_CMD);
mouse.downAt(
arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1],
);
mouse.moveTo(300, 300);
const elX = handles[0] + handles[2] / 2;
const elY = handles[1] + handles[3] / 2;
mouse.downAt(elX, elY);
mouse.moveTo(300, 400);
mouse.up();
expect(arrow.startBinding).not.toBe(null);
+2 -2
View File
@@ -198,8 +198,8 @@ const checkElementsBoundingBox = async (
await waitFor(() => {
// Check if width and height did not change
expect(Math.abs(x2 - x1 - (x22 - x12))).toBeLessThanOrEqual(toleranceInPx);
expect(Math.abs(y2 - y1 - (y22 - y12))).toBeLessThanOrEqual(toleranceInPx);
expect(x2 - x1).toBeCloseTo(x22 - x12, -1);
expect(y2 - y1).toBeCloseTo(y22 - y12, -1);
});
};
+1 -1
View File
@@ -535,7 +535,7 @@ describe("arrow element", () => {
UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.168, 2);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
});
});
+28 -39
View File
@@ -73,7 +73,7 @@ export type Ellipse<Point extends GlobalPoint | LocalPoint> = {
halfHeight: number;
};
export type GeometricShape<Point extends GlobalPoint | LocalPoint> = (
export type GeometricShape<Point extends GlobalPoint | LocalPoint> =
| {
type: "line";
data: LineSegment<Point>;
@@ -97,10 +97,7 @@ export type GeometricShape<Point extends GlobalPoint | LocalPoint> = (
| {
type: "polycurve";
data: Polycurve<Point>;
}
) & {
isClosed?: boolean;
};
};
type RectangularElement =
| ExcalidrawRectangleElement
@@ -206,9 +203,9 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
// linear
export const getCurveShape = <Point extends GlobalPoint | LocalPoint>(
roughShape: Drawable,
startingPoint: Point = pointFrom(0, 0),
angleInRadian: Radians,
center: Point,
startingPoint: Point = pointFrom(0, 0),
): GeometricShape<Point> => {
const transform = (p: Point): Point =>
pointRotateRads(
@@ -288,35 +285,6 @@ export const getFreedrawShape = <Point extends GlobalPoint | LocalPoint>(
) as GeometricShape<Point>;
};
export const getPointsOnRoughCurve = <Point extends GlobalPoint | LocalPoint>(
roughCurve: Drawable,
) => {
const ops = getCurvePathOps(roughCurve);
const points: Point[] = [];
let odd = false;
for (const operation of ops) {
if (operation.op === "move") {
odd = !odd;
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
}
} else if (operation.op === "bcurveTo") {
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
points.push(pointFrom(operation.data[2], operation.data[3]));
points.push(pointFrom(operation.data[4], operation.data[5]));
}
} else if (operation.op === "lineTo") {
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
}
}
}
return points;
};
export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawLinearElement,
roughShape: Drawable,
@@ -340,10 +308,31 @@ export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
};
}
const polygonPoints = pointsOnBezierCurves(
getPointsOnRoughCurve(roughShape),
10,
5,
const ops = getCurvePathOps(roughShape);
const points: Point[] = [];
let odd = false;
for (const operation of ops) {
if (operation.op === "move") {
odd = !odd;
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
}
} else if (operation.op === "bcurveTo") {
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
points.push(pointFrom(operation.data[2], operation.data[3]));
points.push(pointFrom(operation.data[4], operation.data[5]));
}
} else if (operation.op === "lineTo") {
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
}
}
}
const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) =>
transform(p as Point),
) as Point[];
return {