Compare commits

..

4 Commits

Author SHA1 Message Date
Mark Tolmacs
a9f57f0fc1 feat: Cross-stitching fine-tune 2025-12-03 14:08:53 +00:00
Mark Tolmacs
4a67c3e9b7 feat: Cross-stitch algo 2025-12-03 09:20:02 +00:00
Mark Tolmacs
fdb8aaf44e fix: Orientation 2025-12-02 21:33:08 +00:00
Mark Tolmacs
6f4081e371 feat: Basic outlining polygon 2025-12-02 18:18:00 +00:00
14 changed files with 345 additions and 101 deletions

View File

@@ -1,5 +1,3 @@
import mobile from "is-mobile";
export type StylesPanelMode = "compact" | "full" | "mobile";
export type EditorInterface = Readonly<{
@@ -141,13 +139,12 @@ export const getFormFactor = (
editorWidth: number,
editorHeight: number,
): EditorInterface["formFactor"] => {
if (mobile()) {
if (isMobileBreakpoint(editorWidth, editorHeight)) {
return "phone";
} else if (mobile({ tablet: true })) {
}
if (isTabletBreakpoint(editorWidth, editorHeight)) {
return "tablet";
} else if (isMobileBreakpoint(editorWidth, editorHeight)) {
// NOTE: Very small editor sizes should be treated as phone
return "phone";
}
return "desktop";

View File

@@ -0,0 +1,255 @@
import {
type GlobalPoint,
type LineSegment,
lineSegment,
lineSegmentIntersectionPoints,
type LocalPoint,
pointDistanceSq,
pointFrom,
pointFromVector,
vectorAntiNormal,
vectorFromPoint,
vectorNormal,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import { debugDrawLine } from "@excalidraw/common";
import { type ExcalidrawFreeDrawElement } from "./types";
const offset = (
x: number,
y: number,
pressure: number,
direction: "left" | "right",
origin: LocalPoint,
) => {
const p = pointFrom<LocalPoint>(x, y);
const v = vectorNormalize(vectorFromPoint(p, origin));
const normal = direction === "left" ? vectorNormal(v) : vectorAntiNormal(v);
const scaled = vectorScale(normal, pressure / 2);
return pointFromVector(scaled, origin);
};
function generateSegments(
input:
| readonly [x: number, y: number, pressure: number][]
| readonly [x: number, y: number][],
element: ExcalidrawFreeDrawElement,
pressureMultiplier: number = 1,
minimumPressure: number = 1,
): LineSegment<LocalPoint>[] {
if (input.length < 3) {
return [];
}
let idx = 0;
const segments = Array(input.length * 4 - 4);
segments[idx++] = lineSegment(
offset(
input[1][0],
input[1][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"left",
pointFrom<LocalPoint>(input[0][0], input[0][1]),
),
offset(
input[0][0],
input[0][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"right",
pointFrom<LocalPoint>(input[1][0], input[1][1]),
),
);
for (let i = 2; i < input.length; i++) {
const a = segments[idx - 1][1];
const b = offset(
input[i][0],
input[i][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"left",
pointFrom<LocalPoint>(input[i - 1][0], input[i - 1][1]),
);
const c = offset(
input[i - 1][0],
input[i - 1][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"right",
pointFrom<LocalPoint>(input[i][0], input[i][1]),
);
segments[idx++] = lineSegment(a, b); // Bridge segment
segments[idx++] = lineSegment(b, c); // Main segment
}
// Turnaround segments
const prev = segments[idx - 1][1];
segments[idx++] = lineSegment(
prev,
pointFrom<LocalPoint>(
input[input.length - 1][0],
input[input.length - 1][1],
),
);
segments[idx++] = lineSegment(
pointFrom<LocalPoint>(
input[input.length - 1][0],
input[input.length - 1][1],
),
offset(
input[input.length - 2][0],
input[input.length - 2][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"left",
pointFrom<LocalPoint>(
input[input.length - 1][0],
input[input.length - 1][1],
),
),
);
for (let i = input.length - 2; i > 0; i--) {
const a = segments[idx - 1][1];
const b = offset(
input[i + 1][0],
input[i + 1][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"right",
pointFrom<LocalPoint>(input[i][0], input[i][1]),
);
const c = offset(
input[i - 1][0],
input[i - 1][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"left",
pointFrom<LocalPoint>(input[i][0], input[i][1]),
);
segments[idx++] = lineSegment(a, b); // Main segment
segments[idx++] = lineSegment(b, c); // Bridge segment
}
const last = segments[idx - 1][1];
segments[idx++] = lineSegment(
last,
offset(
input[1][0],
input[1][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"right",
pointFrom<LocalPoint>(input[0][0], input[0][1]),
),
);
// Closing cap
segments[idx++] = lineSegment(
segments[idx - 2][1],
pointFrom<LocalPoint>(input[0][0], input[0][1]),
);
segments[idx++] = lineSegment(
pointFrom<LocalPoint>(input[0][0], input[0][1]),
segments[0][0],
);
return segments;
}
export function getStroke(
input:
| readonly [x: number, y: number, pressure: number][]
| readonly [x: number, y: number][],
options: any,
element: ExcalidrawFreeDrawElement,
): LocalPoint[] {
const segments: (LineSegment<LocalPoint> | undefined)[] = generateSegments(
input,
element,
);
const MIN_DIST_SQ = 0.2 ** 2;
for (let j = 0; j < segments.length; j++) {
for (let i = j + 1; i < segments.length; i++) {
const a = segments[j];
const b = segments[i];
if (!a || !b) {
continue;
}
const intersection = lineSegmentIntersectionPoints(a, b);
if (
intersection &&
pointDistanceSq(a[0], intersection) > MIN_DIST_SQ &&
pointDistanceSq(a[1], intersection) > MIN_DIST_SQ &&
i === j + 2
) {
a[1] = intersection;
segments[j + 1] = undefined;
b[0] = intersection;
}
}
}
debugSegments(
segments.filter((s): s is LineSegment<LocalPoint> => !!s),
input,
element,
);
return [
...(segments[0] ? [segments[0][0]] : []),
...segments
.filter((s): s is LineSegment<LocalPoint> => !!s)
.map((s) => s[1]),
];
}
function debugSegments(
segments: LineSegment<LocalPoint>[],
input: readonly [number, number, number][] | readonly [number, number][],
element: ExcalidrawFreeDrawElement,
): void {
const colors = [
"#FF0000",
"#00FF00",
"#0000FF",
// "#FFFF00",
// "#00FFFF",
// "#FF00FF",
// "#C0C0C0",
// "#800000",
// "#808000",
// "#008000",
// "#800080",
// "#008080",
// "#000080",
];
segments.forEach((s, i) => {
debugDrawLine(
lineSegment(
pointFrom<GlobalPoint>(element.x + s[0][0], element.y + s[0][1]),
pointFrom<GlobalPoint>(element.x + s[1][0], element.y + s[1][1]),
),
{ color: colors[i % colors.length], permanent: true },
);
});
input.forEach((p, i) => {
if (i === 0) {
return;
}
debugDrawLine(
lineSegment(
pointFrom<GlobalPoint>(
element.x + input[i - 1][0],
element.y + input[i - 1][1],
),
pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]),
),
{ color: "#000000", permanent: true },
);
});
}

View File

@@ -1,5 +1,5 @@
import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand";
//import { getStroke } from "perfect-freehand";
import {
type GlobalPoint,
@@ -63,8 +63,8 @@ import {
} from "./typeChecks";
import { getContainingFrame } from "./frame";
import { getCornerRadius } from "./utils";
import { ShapeCache } from "./shape";
import { getStroke } from "./freedraw";
import type {
ExcalidrawElement,
@@ -1102,10 +1102,10 @@ export function getFreedrawOutlineAsSegments(
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
? (element.points as readonly [number, number][])
: ((element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]]) as [number, number, number][]);
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
@@ -1118,7 +1118,16 @@ export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
last: true,
};
return getStroke(inputPoints as number[][], options) as [number, number][];
// return getStroke(
// [
// [0, 0],
// [30, -30],
// [60, -30],
// ],
// options,
// element,
// );
return getStroke(inputPoints, options, element) as [number, number][];
}
function med(A: number[], B: number[]) {

View File

@@ -493,7 +493,6 @@ describe("binding for simple arrows", () => {
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
mouse.downAt(-100, -100);
mouse.moveTo(0, 0);
mouse.moveTo(650, 750);
mouse.up(0, 0);

View File

@@ -11463,10 +11463,13 @@ class App extends React.Component<AppProps, AppState> {
): void => {
const selectionElement = this.state.selectionElement;
const pointerCoords = pointerDownState.lastCoords;
const selectedElements = this.scene.getSelectedElements(this.state);
const onlyBindingElementSelected =
selectedElements?.length === 1 && isBindingElement(selectedElements[0]);
if (
selectionElement &&
pointerDownState.boxSelection.hasOccurred &&
this.state.activeTool.type !== "eraser"
this.state.activeTool.type !== "eraser" &&
!onlyBindingElementSelected
) {
dragNewElement({
newElement: selectionElement,

View File

@@ -95,7 +95,6 @@
"fractional-indexing": "3.2.0",
"fuzzy": "0.1.3",
"image-blob-reduce": "3.0.1",
"is-mobile": "5.0.0",
"jotai": "2.11.0",
"jotai-scope": "0.7.2",
"lodash.debounce": "4.0.8",

View File

@@ -1223,7 +1223,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 2019559783,
"width": 10,
"x": -20,
"y": -10,
@@ -1431,14 +1431,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1014066025,
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 1604849351,
"versionNonce": 1505387817,
"width": 20,
"x": 20,
"y": 30,
@@ -1463,14 +1463,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 493213705,
"versionNonce": 915032327,
"width": 20,
"x": -10,
"y": 0,
@@ -1765,14 +1765,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1014066025,
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 1604849351,
"versionNonce": 1505387817,
"width": 20,
"x": 20,
"y": 30,
@@ -1797,14 +1797,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 493213705,
"versionNonce": 915032327,
"width": 20,
"x": -10,
"y": 0,
@@ -2108,7 +2108,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 2019559783,
"width": 10,
"x": -20,
"y": -10,
@@ -2321,7 +2321,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1116226695,
"versionNonce": 1014066025,
"width": 10,
"x": -20,
"y": -10,
@@ -2567,7 +2567,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 2019559783,
"width": 10,
"x": -20,
"y": -10,
@@ -2592,14 +2592,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1014066025,
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 5,
"versionNonce": 400692809,
"versionNonce": 1604849351,
"width": 10,
"x": -10,
"y": 0,
@@ -2868,14 +2868,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 915032327,
"versionNonce": 81784553,
"width": 20,
"x": -10,
"y": 0,
@@ -2902,14 +2902,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1014066025,
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 81784553,
"versionNonce": 747212839,
"width": 20,
"x": 20,
"y": 30,
@@ -3238,14 +3238,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 60,
"roughness": 2,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1402203177,
"versionNonce": 1359939303,
"width": 20,
"x": -10,
"y": 0,
@@ -3270,14 +3270,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 60,
"roughness": 2,
"roundness": null,
"seed": 1898319239,
"seed": 640725609,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 9,
"versionNonce": 941653321,
"versionNonce": 908564423,
"width": 20,
"x": 20,
"y": 30,
@@ -3732,14 +3732,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 238820263,
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 915032327,
"versionNonce": 81784553,
"width": 20,
"x": 20,
"y": 30,
@@ -3764,14 +3764,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 2019559783,
"versionNonce": 1150084233,
"width": 20,
"x": -10,
"y": 0,
@@ -4058,14 +4058,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1014066025,
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 493213705,
"versionNonce": 915032327,
"width": 20,
"x": 20,
"y": 30,
@@ -4090,14 +4090,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 2019559783,
"versionNonce": 1150084233,
"width": 20,
"x": -10,
"y": 0,
@@ -4387,14 +4387,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 5,
"versionNonce": 760410951,
"versionNonce": 1006504105,
"width": 20,
"x": -10,
"y": 0,
@@ -4419,14 +4419,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 238820263,
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 5,
"versionNonce": 1006504105,
"versionNonce": 289600103,
"width": 20,
"x": 20,
"y": 30,
@@ -5675,14 +5675,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 453191,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 1150084233,
"versionNonce": 1014066025,
"width": 10,
"x": -10,
"y": 0,
@@ -5707,14 +5707,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 400692809,
"seed": 1505387817,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 23633383,
"versionNonce": 915032327,
"width": 10,
"x": 12,
"y": 0,
@@ -6899,14 +6899,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 747212839,
"versionNonce": 1723083209,
"width": 10,
"x": -10,
"y": 0,
@@ -6933,14 +6933,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 238820263,
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1723083209,
"versionNonce": 760410951,
"width": 10,
"x": 12,
"y": 0,
@@ -9837,7 +9837,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 2019559783,
"width": 10,
"x": -20,
"y": -10,

View File

@@ -13935,7 +13935,7 @@ exports[`regression tests > switches from group of selected elements to another
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1006504105,
"seed": 289600103,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,

View File

@@ -41,8 +41,8 @@ exports[`select single element on the scene > arrow 1`] = `
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 5,
"versionNonce": 1116226695,
"version": 4,
"versionNonce": 2019559783,
"width": 30,
"x": 10,
"y": 10,
@@ -88,8 +88,8 @@ exports[`select single element on the scene > arrow escape 1`] = `
"strokeWidth": 2,
"type": "line",
"updated": 1,
"version": 5,
"versionNonce": 1116226695,
"version": 4,
"versionNonce": 2019559783,
"width": 30,
"x": 10,
"y": 10,
@@ -120,8 +120,8 @@ exports[`select single element on the scene > diamond 1`] = `
"strokeWidth": 2,
"type": "diamond",
"updated": 1,
"version": 4,
"versionNonce": 2019559783,
"version": 3,
"versionNonce": 401146281,
"width": 30,
"x": 10,
"y": 10,
@@ -152,8 +152,8 @@ exports[`select single element on the scene > ellipse 1`] = `
"strokeWidth": 2,
"type": "ellipse",
"updated": 1,
"version": 4,
"versionNonce": 2019559783,
"version": 3,
"versionNonce": 401146281,
"width": 30,
"x": 10,
"y": 10,
@@ -184,8 +184,8 @@ exports[`select single element on the scene > rectangle 1`] = `
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 2019559783,
"version": 3,
"versionNonce": 401146281,
"width": 30,
"x": 10,
"y": 10,

View File

@@ -315,7 +315,6 @@ describe("history", () => {
]);
mouse.downAt(0, 0);
mouse.moveTo(25, 25);
mouse.moveTo(50, 50);
mouse.upAt(50, 50);
expect(API.getUndoStack().length).toBe(3);

View File

@@ -467,7 +467,6 @@ describe("regression tests", () => {
mouse.reset();
mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(...end);
mouse.up();
@@ -518,7 +517,6 @@ describe("regression tests", () => {
mouse.reset();
mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(...end);
mouse.up();
@@ -536,7 +534,6 @@ describe("regression tests", () => {
mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further
mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(...end);
mouse.up();

View File

@@ -65,7 +65,6 @@ describe("box-selection", () => {
API.setElements([rect1, rect2]);
mouse.downAt(175, -20);
mouse.move(-1000, -1000);
mouse.moveTo(85, 70);
mouse.up();
@@ -73,7 +72,6 @@ describe("box-selection", () => {
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.downAt(75, -20);
mouse.move(-1000, -1000);
mouse.moveTo(-15, 70);
mouse.up();
});
@@ -95,7 +93,6 @@ describe("box-selection", () => {
API.setElements([rect1]);
mouse.downAt(75, -20);
mouse.move(-1000, -1000);
mouse.moveTo(-15, 70);
assertSelectedElements([rect1.id]);
@@ -141,7 +138,6 @@ describe("inner box-selection", () => {
API.setElements([rect1, rect2, rect3]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(40, 40);
mouse.move(-1000, -1000);
mouse.moveTo(290, 290);
mouse.up();
@@ -179,7 +175,6 @@ describe("inner box-selection", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(40, 40);
mouse.move(-1000, -1000);
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
mouse.up();
@@ -217,7 +212,6 @@ describe("inner box-selection", () => {
API.setElements([rect1, rect2, rect3]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(rect2.x - 20, rect2.y - 20);
mouse.move(-1000, -1000);
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
assertSelectedElements([rect2.id, rect3.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
@@ -259,10 +253,9 @@ describe("selection element", () => {
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderInteractiveScene).toHaveBeenCalledTimes(4);
expect(renderStaticScene).toHaveBeenCalledTimes(3);
const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull();
@@ -282,11 +275,10 @@ describe("selection element", () => {
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(3);
expect(h.state.selectionElement).toBeNull();
});
@@ -311,7 +303,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("rectangle");
fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, {
@@ -344,7 +335,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("diamond");
fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, {
@@ -377,7 +367,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("ellipse");
fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, {
@@ -410,7 +399,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("arrow");
fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, {
@@ -437,8 +425,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -455,7 +443,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("line");
fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, {
@@ -482,8 +469,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -538,7 +525,6 @@ describe("selectedElementIds stability", () => {
expect(h.state.selectedElementIds).toBe(selectedElementIds_1);
mouse.downAt(-50, -50);
mouse.move(-1000, -1000);
mouse.moveTo(50, 50);
const selectedElementIds_2 = h.state.selectedElementIds;

View File

@@ -158,3 +158,8 @@ export const vectorNormalize = (v: Vector): Vector => {
* Calculate the right-hand normal of the vector.
*/
export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]);
/**
* Calculate the left-hand normal of the vector.
*/
export const vectorAntiNormal = (v: Vector): Vector => vector(-v[1], v[0]);

View File

@@ -6398,11 +6398,6 @@ is-map@^2.0.3:
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e"
integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==
is-mobile@5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/is-mobile/-/is-mobile-5.0.0.tgz#1e08a0ef2c38a67bff84a52af68d67bcef445333"
integrity sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==
is-module@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"