mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
refactor: update collision from ga to vector geometry (#7636)
* new collision api * isPointOnShape * removed redundant code * new collision methods in app * curve shape takes starting point * clean up geometry * curve rotation * freedraw * inside curve * improve ellipse inside check * ellipse distance func * curve inside * include frame name bounds * replace previous private methods for getting elements at x,y * arrow bound text hit detection * keep iframes on top * remove dependence on old collision methods from app * remove old collision functions * move some hit functions outside of app * code refactor * type * text collision from inside * fix context menu test * highest z-index collision * fix 1px away binding test * strictly less * remove unused imports * lint * 'ignore' resize flipping test * more lint fix * skip 'flips while resizing' test * more test * fix merge errors * fix selection in resize test * added a bit more comment --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
3e334a67ed
commit
bbdcd30a73
20 changed files with 2721 additions and 1627 deletions
|
@ -8,7 +8,6 @@ import { register } from "./register";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { isPathALoop } from "../math";
|
import { isPathALoop } from "../math";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import Scene from "../scene/Scene";
|
|
||||||
import {
|
import {
|
||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
|
@ -21,12 +20,9 @@ export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
label: "",
|
label: "",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (
|
perform: (elements, appState, _, app) => {
|
||||||
elements,
|
const { interactiveCanvas, focusContainer, scene } = app;
|
||||||
appState,
|
|
||||||
_,
|
|
||||||
{ interactiveCanvas, focusContainer, scene },
|
|
||||||
) => {
|
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
|
@ -131,13 +127,7 @@ export const actionFinalize = register({
|
||||||
-1,
|
-1,
|
||||||
arrayToMap(elements),
|
arrayToMap(elements),
|
||||||
);
|
);
|
||||||
maybeBindLinearElement(
|
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
|
||||||
multiPointElement,
|
|
||||||
appState,
|
|
||||||
Scene.getScene(multiPointElement)!,
|
|
||||||
{ x, y },
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { resizeMultipleElements } from "../element/resizeElements";
|
import { resizeMultipleElements } from "../element/resizeElements";
|
||||||
import { AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getCommonBoundingBox } from "../element/bounds";
|
import { getCommonBoundingBox } from "../element/bounds";
|
||||||
|
@ -32,6 +32,7 @@ export const actionFlipHorizontal = register({
|
||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
appState,
|
appState,
|
||||||
"horizontal",
|
"horizontal",
|
||||||
|
app,
|
||||||
),
|
),
|
||||||
appState,
|
appState,
|
||||||
app,
|
app,
|
||||||
|
@ -56,6 +57,7 @@ export const actionFlipVertical = register({
|
||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
appState,
|
appState,
|
||||||
"vertical",
|
"vertical",
|
||||||
|
app,
|
||||||
),
|
),
|
||||||
appState,
|
appState,
|
||||||
app,
|
app,
|
||||||
|
@ -73,6 +75,7 @@ const flipSelectedElements = (
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
|
@ -89,6 +92,7 @@ const flipSelectedElements = (
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState,
|
appState,
|
||||||
flipDirection,
|
flipDirection,
|
||||||
|
app,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
@ -104,6 +108,7 @@ const flipElements = (
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
|
app: AppClassProperties,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
||||||
|
|
||||||
|
@ -118,7 +123,7 @@ const flipElements = (
|
||||||
);
|
);
|
||||||
|
|
||||||
isBindingEnabled(appState)
|
isBindingEnabled(appState)
|
||||||
? bindOrUnbindSelectedElements(selectedElements, elements, elementsMap)
|
? bindOrUnbindSelectedElements(selectedElements, app)
|
||||||
: unbindLinearElements(selectedElements, elementsMap);
|
: unbindLinearElements(selectedElements, elementsMap);
|
||||||
|
|
||||||
return selectedElements;
|
return selectedElements;
|
||||||
|
|
|
@ -107,8 +107,6 @@ import {
|
||||||
getResizeOffsetXY,
|
getResizeOffsetXY,
|
||||||
getLockedLinearCursorAlignSize,
|
getLockedLinearCursorAlignSize,
|
||||||
getTransformHandleTypeFromCoords,
|
getTransformHandleTypeFromCoords,
|
||||||
hitTest,
|
|
||||||
isHittingElementBoundingBoxWithoutHittingElement,
|
|
||||||
isInvisiblySmallElement,
|
isInvisiblySmallElement,
|
||||||
isNonDeletedElement,
|
isNonDeletedElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
|
@ -119,6 +117,7 @@ import {
|
||||||
transformElements,
|
transformElements,
|
||||||
updateTextElement,
|
updateTextElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
|
getElementAbsoluteCoords,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
|
@ -162,6 +161,7 @@ import {
|
||||||
isIframeElement,
|
isIframeElement,
|
||||||
isIframeLikeElement,
|
isIframeLikeElement,
|
||||||
isMagicFrameElement,
|
isMagicFrameElement,
|
||||||
|
isTextBindableContainer,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
|
@ -212,7 +212,6 @@ import {
|
||||||
} from "../math";
|
} from "../math";
|
||||||
import {
|
import {
|
||||||
calculateScrollCenter,
|
calculateScrollCenter,
|
||||||
getElementsAtPosition,
|
|
||||||
getElementsWithinSelection,
|
getElementsWithinSelection,
|
||||||
getNormalizedZoom,
|
getNormalizedZoom,
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
|
@ -223,6 +222,15 @@ import Scene from "../scene/Scene";
|
||||||
import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types";
|
import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import { findShapeByKey } from "../shapes";
|
import { findShapeByKey } from "../shapes";
|
||||||
|
import {
|
||||||
|
GeometricShape,
|
||||||
|
getClosedCurveShape,
|
||||||
|
getCurveShape,
|
||||||
|
getEllipseShape,
|
||||||
|
getFreedrawShape,
|
||||||
|
getPolygonShape,
|
||||||
|
} from "../../utils/geometry/shape";
|
||||||
|
import { isPointInShape } from "../../utils/collision";
|
||||||
import {
|
import {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
AppProps,
|
AppProps,
|
||||||
|
@ -318,11 +326,9 @@ import {
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
getDefaultLineHeight,
|
getDefaultLineHeight,
|
||||||
getLineHeightInPx,
|
getLineHeightInPx,
|
||||||
getTextBindableContainerAtPosition,
|
|
||||||
isMeasureTextSupported,
|
isMeasureTextSupported,
|
||||||
isValidTextContainer,
|
isValidTextContainer,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
|
|
||||||
import {
|
import {
|
||||||
showHyperlinkTooltip,
|
showHyperlinkTooltip,
|
||||||
hideHyperlinkToolip,
|
hideHyperlinkToolip,
|
||||||
|
@ -407,6 +413,13 @@ import { AnimatedTrail } from "../animated-trail";
|
||||||
import { LaserTrails } from "../laser-trails";
|
import { LaserTrails } from "../laser-trails";
|
||||||
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
||||||
import { getRenderOpacity } from "../renderer/renderElement";
|
import { getRenderOpacity } from "../renderer/renderElement";
|
||||||
|
import {
|
||||||
|
hitElementBoundText,
|
||||||
|
hitElementBoundingBox,
|
||||||
|
hitElementBoundingBoxOnly,
|
||||||
|
hitElementItself,
|
||||||
|
shouldTestInside,
|
||||||
|
} from "../element/collision";
|
||||||
import { textWysiwyg } from "../element/textWysiwyg";
|
import { textWysiwyg } from "../element/textWysiwyg";
|
||||||
import { isOverScrollBars } from "../scene/scrollbars";
|
import { isOverScrollBars } from "../scene/scrollbars";
|
||||||
import {
|
import {
|
||||||
|
@ -2757,7 +2770,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
maybeBindLinearElement(
|
maybeBindLinearElement(
|
||||||
multiElement,
|
multiElement,
|
||||||
this.state,
|
this.state,
|
||||||
this.scene,
|
|
||||||
tupleToCoors(
|
tupleToCoors(
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
multiElement,
|
multiElement,
|
||||||
|
@ -2765,7 +2777,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
elementsMap,
|
elementsMap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
elementsMap,
|
this,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.history.record(this.state, elements);
|
this.history.record(this.state, elements);
|
||||||
|
@ -4048,11 +4060,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||||
isBindingEnabled(this.state)
|
isBindingEnabled(this.state)
|
||||||
? bindOrUnbindSelectedElements(
|
? bindOrUnbindSelectedElements(selectedElements, this)
|
||||||
selectedElements,
|
|
||||||
this.scene.getNonDeletedElements(),
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
: unbindLinearElements(selectedElements, elementsMap);
|
: unbindLinearElements(selectedElements, elementsMap);
|
||||||
this.setState({ suggestedBindings: [] });
|
this.setState({ suggestedBindings: [] });
|
||||||
}
|
}
|
||||||
|
@ -4355,12 +4363,87 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the pure geometric shape of an excalidraw element
|
||||||
|
* which is then used for hit detection
|
||||||
|
*/
|
||||||
|
public getElementShape(element: ExcalidrawElement): GeometricShape {
|
||||||
|
switch (element.type) {
|
||||||
|
case "rectangle":
|
||||||
|
case "diamond":
|
||||||
|
case "frame":
|
||||||
|
case "magicframe":
|
||||||
|
case "embeddable":
|
||||||
|
case "image":
|
||||||
|
case "iframe":
|
||||||
|
case "text":
|
||||||
|
case "selection":
|
||||||
|
return getPolygonShape(element);
|
||||||
|
case "arrow":
|
||||||
|
case "line": {
|
||||||
|
const roughShape =
|
||||||
|
ShapeCache.get(element)?.[0] ??
|
||||||
|
ShapeCache.generateElementShape(element, null)[0];
|
||||||
|
const [, , , , cx, cy] = getElementAbsoluteCoords(
|
||||||
|
element,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return shouldTestInside(element)
|
||||||
|
? getClosedCurveShape(
|
||||||
|
roughShape,
|
||||||
|
[element.x, element.y],
|
||||||
|
element.angle,
|
||||||
|
[cx, cy],
|
||||||
|
)
|
||||||
|
: getCurveShape(roughShape, [element.x, element.y], element.angle, [
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ellipse":
|
||||||
|
return getEllipseShape(element);
|
||||||
|
|
||||||
|
case "freedraw": {
|
||||||
|
const [, , , , cx, cy] = getElementAbsoluteCoords(
|
||||||
|
element,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null {
|
||||||
|
const boundTextElement = getBoundTextElement(
|
||||||
|
element,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (boundTextElement) {
|
||||||
|
if (element.type === "arrow") {
|
||||||
|
return this.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,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.getElementShape(boundTextElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private getElementAtPosition(
|
private getElementAtPosition(
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
opts?: {
|
opts?: {
|
||||||
/** if true, returns the first selected element (with highest z-index)
|
|
||||||
of all hit elements */
|
|
||||||
preferSelected?: boolean;
|
preferSelected?: boolean;
|
||||||
includeBoundTextElement?: boolean;
|
includeBoundTextElement?: boolean;
|
||||||
includeLockedElements?: boolean;
|
includeLockedElements?: boolean;
|
||||||
|
@ -4372,6 +4455,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
opts?.includeBoundTextElement,
|
opts?.includeBoundTextElement,
|
||||||
opts?.includeLockedElements,
|
opts?.includeLockedElements,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allHitElements.length > 1) {
|
if (allHitElements.length > 1) {
|
||||||
if (opts?.preferSelected) {
|
if (opts?.preferSelected) {
|
||||||
for (let index = allHitElements.length - 1; index > -1; index--) {
|
for (let index = allHitElements.length - 1; index > -1; index--) {
|
||||||
|
@ -4382,22 +4466,20 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
const elementWithHighestZIndex =
|
const elementWithHighestZIndex =
|
||||||
allHitElements[allHitElements.length - 1];
|
allHitElements[allHitElements.length - 1];
|
||||||
|
|
||||||
// If we're hitting element with highest z-index only on its bounding box
|
// If we're hitting element with highest z-index only on its bounding box
|
||||||
// while also hitting other element figure, the latter should be considered.
|
// while also hitting other element figure, the latter should be considered.
|
||||||
return isHittingElementBoundingBoxWithoutHittingElement(
|
return isPointInShape(
|
||||||
elementWithHighestZIndex,
|
[x, y],
|
||||||
this.state,
|
this.getElementShape(elementWithHighestZIndex),
|
||||||
this.frameNameBoundsCache,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
this.scene.getNonDeletedElementsMap(),
|
|
||||||
)
|
)
|
||||||
? allHitElements[allHitElements.length - 2]
|
? elementWithHighestZIndex
|
||||||
: elementWithHighestZIndex;
|
: allHitElements[allHitElements.length - 2];
|
||||||
}
|
}
|
||||||
if (allHitElements.length === 1) {
|
if (allHitElements.length === 1) {
|
||||||
return allHitElements[0];
|
return allHitElements[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4407,7 +4489,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
includeBoundTextElement: boolean = false,
|
includeBoundTextElement: boolean = false,
|
||||||
includeLockedElements: boolean = false,
|
includeLockedElements: boolean = false,
|
||||||
): NonDeleted<ExcalidrawElement>[] {
|
): NonDeleted<ExcalidrawElement>[] {
|
||||||
const elements =
|
const iframeLikes: ExcalidrawIframeElement[] = [];
|
||||||
|
|
||||||
|
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
const elements = (
|
||||||
includeBoundTextElement && includeLockedElements
|
includeBoundTextElement && includeLockedElements
|
||||||
? this.scene.getNonDeletedElements()
|
? this.scene.getNonDeletedElements()
|
||||||
: this.scene
|
: this.scene
|
||||||
|
@ -4417,19 +4503,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
(includeLockedElements || !element.locked) &&
|
(includeLockedElements || !element.locked) &&
|
||||||
(includeBoundTextElement ||
|
(includeBoundTextElement ||
|
||||||
!(isTextElement(element) && element.containerId)),
|
!(isTextElement(element) && element.containerId)),
|
||||||
);
|
)
|
||||||
|
)
|
||||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
.filter((el) => this.hitElement(x, y, el))
|
||||||
return getElementsAtPosition(elements, (element) =>
|
.filter((element) => {
|
||||||
hitTest(
|
|
||||||
element,
|
|
||||||
this.state,
|
|
||||||
this.frameNameBoundsCache,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
).filter((element) => {
|
|
||||||
// hitting a frame's element from outside the frame is not considered a hit
|
// hitting a frame's element from outside the frame is not considered a hit
|
||||||
const containingFrame = getContainingFrame(element, elementsMap);
|
const containingFrame = getContainingFrame(element, elementsMap);
|
||||||
return containingFrame &&
|
return containingFrame &&
|
||||||
|
@ -4437,9 +4514,109 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.state.frameRendering.clip
|
this.state.frameRendering.clip
|
||||||
? isCursorInFrame({ x, y }, containingFrame, elementsMap)
|
? isCursorInFrame({ x, y }, containingFrame, elementsMap)
|
||||||
: true;
|
: true;
|
||||||
|
})
|
||||||
|
.filter((el) => {
|
||||||
|
// The parameter elements comes ordered from lower z-index to higher.
|
||||||
|
// We want to preserve that order on the returned array.
|
||||||
|
// Exception being embeddables which should be on top of everything else in
|
||||||
|
// terms of hit testing.
|
||||||
|
if (isIframeElement(el)) {
|
||||||
|
iframeLikes.push(el);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.concat(iframeLikes) as NonDeleted<ExcalidrawElement>[];
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHitThreshold() {
|
||||||
|
return 10 / this.state.zoom.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hitElement(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
considerBoundingBox = true,
|
||||||
|
) {
|
||||||
|
// if the element is selected, then hit test is done against its bounding box
|
||||||
|
if (
|
||||||
|
considerBoundingBox &&
|
||||||
|
this.state.selectedElementIds[element.id] &&
|
||||||
|
shouldShowBoundingBox([element], this.state)
|
||||||
|
) {
|
||||||
|
return hitElementBoundingBox(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
element,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
this.getHitThreshold(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// take bound text element into consideration for hit collision as well
|
||||||
|
const hitBoundTextOfElement = hitElementBoundText(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
this.getBoundTextShape(element),
|
||||||
|
);
|
||||||
|
if (hitBoundTextOfElement) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hitElementItself({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
element,
|
||||||
|
shape: this.getElementShape(element),
|
||||||
|
threshold: this.getHitThreshold(),
|
||||||
|
frameNameBound: isFrameLikeElement(element)
|
||||||
|
? this.frameNameBoundsCache.get(element)
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getTextBindableContainerAtPosition(x: number, y: number) {
|
||||||
|
const elements = this.scene.getNonDeletedElements();
|
||||||
|
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||||
|
if (selectedElements.length === 1) {
|
||||||
|
return isTextBindableContainer(selectedElements[0], false)
|
||||||
|
? selectedElements[0]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
let hitElement = null;
|
||||||
|
// We need to do hit testing from front (end of the array) to back (beginning of the array)
|
||||||
|
for (let index = elements.length - 1; index >= 0; --index) {
|
||||||
|
if (elements[index].isDeleted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(
|
||||||
|
elements[index],
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
isArrowElement(elements[index]) &&
|
||||||
|
hitElementItself({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
element: elements[index],
|
||||||
|
shape: this.getElementShape(elements[index]),
|
||||||
|
threshold: this.getHitThreshold(),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
hitElement = elements[index];
|
||||||
|
break;
|
||||||
|
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
|
||||||
|
hitElement = elements[index];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isTextBindableContainer(hitElement, false) ? hitElement : null;
|
||||||
|
}
|
||||||
|
|
||||||
private startTextEditing = ({
|
private startTextEditing = ({
|
||||||
sceneX,
|
sceneX,
|
||||||
sceneY,
|
sceneY,
|
||||||
|
@ -4667,25 +4844,19 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = getTextBindableContainerAtPosition(
|
const container = this.getTextBindableContainerAtPosition(sceneX, sceneY);
|
||||||
this.scene.getNonDeletedElements(),
|
|
||||||
this.state,
|
|
||||||
sceneX,
|
|
||||||
sceneY,
|
|
||||||
this.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
if (
|
if (
|
||||||
hasBoundTextElement(container) ||
|
hasBoundTextElement(container) ||
|
||||||
!isTransparent(container.backgroundColor) ||
|
!isTransparent(container.backgroundColor) ||
|
||||||
isHittingElementNotConsideringBoundingBox(
|
hitElementItself({
|
||||||
container,
|
x: sceneX,
|
||||||
this.state,
|
y: sceneY,
|
||||||
this.frameNameBoundsCache,
|
element: container,
|
||||||
[sceneX, sceneY],
|
shape: this.getElementShape(container),
|
||||||
this.scene.getNonDeletedElementsMap(),
|
threshold: this.getHitThreshold(),
|
||||||
)
|
})
|
||||||
) {
|
) {
|
||||||
const midPoint = getContainerCenter(
|
const midPoint = getContainerCenter(
|
||||||
container,
|
container,
|
||||||
|
@ -5281,7 +5452,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
scenePointer.x,
|
scenePointer.x,
|
||||||
scenePointer.y,
|
scenePointer.y,
|
||||||
);
|
);
|
||||||
const threshold = 10 / this.state.zoom.value;
|
const threshold = this.getHitThreshold();
|
||||||
const point = { ...pointerDownState.lastCoords };
|
const point = { ...pointerDownState.lastCoords };
|
||||||
let samplingInterval = 0;
|
let samplingInterval = 0;
|
||||||
while (samplingInterval <= distance) {
|
while (samplingInterval <= distance) {
|
||||||
|
@ -5346,7 +5517,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
linearElementEditor.elementId,
|
linearElementEditor.elementId,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
|
||||||
|
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
|
@ -5355,13 +5525,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
let hoverPointIndex = -1;
|
let hoverPointIndex = -1;
|
||||||
let segmentMidPointHoveredCoords = null;
|
let segmentMidPointHoveredCoords = null;
|
||||||
if (
|
if (
|
||||||
isHittingElementNotConsideringBoundingBox(
|
hitElementItself({
|
||||||
|
x: scenePointerX,
|
||||||
|
y: scenePointerY,
|
||||||
element,
|
element,
|
||||||
this.state,
|
shape: this.getElementShape(element),
|
||||||
this.frameNameBoundsCache,
|
})
|
||||||
[scenePointerX, scenePointerY],
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||||
element,
|
element,
|
||||||
|
@ -5383,29 +5552,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
} else {
|
} else {
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||||
shouldShowBoundingBox([element], this.state) &&
|
|
||||||
isHittingElementBoundingBoxWithoutHittingElement(
|
|
||||||
element,
|
|
||||||
this.state,
|
|
||||||
this.frameNameBoundsCache,
|
|
||||||
scenePointerX,
|
|
||||||
scenePointerY,
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
|
||||||
} else if (
|
|
||||||
boundTextElement &&
|
|
||||||
hitTest(
|
|
||||||
boundTextElement,
|
|
||||||
this.state,
|
|
||||||
this.frameNameBoundsCache,
|
|
||||||
scenePointerX,
|
|
||||||
scenePointerY,
|
|
||||||
this.scene.getNonDeletedElementsMap(),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6159,8 +6306,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.history,
|
this.history,
|
||||||
pointerDownState.origin,
|
pointerDownState.origin,
|
||||||
linearElementEditor,
|
linearElementEditor,
|
||||||
this.scene.getNonDeletedElements(),
|
this,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
if (ret.hitElement) {
|
if (ret.hitElement) {
|
||||||
pointerDownState.hit.element = ret.hitElement;
|
pointerDownState.hit.element = ret.hitElement;
|
||||||
|
@ -6383,7 +6529,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// How many pixels off the shape boundary we still consider a hit
|
// How many pixels off the shape boundary we still consider a hit
|
||||||
const threshold = 10 / this.state.zoom.value;
|
const threshold = this.getHitThreshold();
|
||||||
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
|
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
|
||||||
return (
|
return (
|
||||||
point.x > x1 - threshold &&
|
point.x > x1 - threshold &&
|
||||||
|
@ -6411,13 +6557,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
});
|
});
|
||||||
|
|
||||||
// FIXME
|
// FIXME
|
||||||
let container = getTextBindableContainerAtPosition(
|
let container = this.getTextBindableContainerAtPosition(sceneX, sceneY);
|
||||||
this.scene.getNonDeletedElements(),
|
|
||||||
this.state,
|
|
||||||
sceneX,
|
|
||||||
sceneY,
|
|
||||||
this.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasBoundTextElement(element)) {
|
if (hasBoundTextElement(element)) {
|
||||||
container = element as ExcalidrawTextContainer;
|
container = element as ExcalidrawTextContainer;
|
||||||
|
@ -6497,8 +6637,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
|
|
||||||
const boundElement = getHoveredElementForBinding(
|
const boundElement = getHoveredElementForBinding(
|
||||||
pointerDownState.origin,
|
pointerDownState.origin,
|
||||||
this.scene.getNonDeletedElements(),
|
this,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
);
|
||||||
this.scene.addNewElement(element);
|
this.scene.addNewElement(element);
|
||||||
this.setState({
|
this.setState({
|
||||||
|
@ -6766,8 +6905,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
});
|
});
|
||||||
const boundElement = getHoveredElementForBinding(
|
const boundElement = getHoveredElementForBinding(
|
||||||
pointerDownState.origin,
|
pointerDownState.origin,
|
||||||
this.scene.getNonDeletedElements(),
|
this,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.scene.addNewElement(element);
|
this.scene.addNewElement(element);
|
||||||
|
@ -7551,7 +7689,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
? this.state.editingElement
|
? this.state.editingElement
|
||||||
: null,
|
: null,
|
||||||
snapLines: updateStable(prevState.snapLines, []),
|
snapLines: updateStable(prevState.snapLines, []),
|
||||||
|
|
||||||
originSnapOffset: null,
|
originSnapOffset: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -7578,8 +7715,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
childEvent,
|
childEvent,
|
||||||
this.state.editingLinearElement,
|
this.state.editingLinearElement,
|
||||||
this.state,
|
this.state,
|
||||||
this.scene.getNonDeletedElements(),
|
this,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
if (editingLinearElement !== this.state.editingLinearElement) {
|
if (editingLinearElement !== this.state.editingLinearElement) {
|
||||||
this.setState({
|
this.setState({
|
||||||
|
@ -7603,8 +7739,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
childEvent,
|
childEvent,
|
||||||
this.state.selectedLinearElement,
|
this.state.selectedLinearElement,
|
||||||
this.state,
|
this.state,
|
||||||
this.scene.getNonDeletedElements(),
|
this,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { startBindingElement, endBindingElement } =
|
const { startBindingElement, endBindingElement } =
|
||||||
|
@ -7753,9 +7888,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
maybeBindLinearElement(
|
maybeBindLinearElement(
|
||||||
draggingElement,
|
draggingElement,
|
||||||
this.state,
|
this.state,
|
||||||
this.scene,
|
|
||||||
pointerCoords,
|
pointerCoords,
|
||||||
elementsMap,
|
this,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.setState({ suggestedBindings: [], startBoundElement: null });
|
this.setState({ suggestedBindings: [], startBoundElement: null });
|
||||||
|
@ -8207,16 +8341,24 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
// not dragged
|
||||||
!pointerDownState.drag.hasOccurred &&
|
!pointerDownState.drag.hasOccurred &&
|
||||||
|
// not resized
|
||||||
!this.state.isResizing &&
|
!this.state.isResizing &&
|
||||||
|
// only hitting the bounding box of the previous hit element
|
||||||
((hitElement &&
|
((hitElement &&
|
||||||
isHittingElementBoundingBoxWithoutHittingElement(
|
hitElementBoundingBoxOnly(
|
||||||
hitElement,
|
{
|
||||||
this.state,
|
x: pointerDownState.origin.x,
|
||||||
this.frameNameBoundsCache,
|
y: pointerDownState.origin.y,
|
||||||
pointerDownState.origin.x,
|
element: hitElement,
|
||||||
pointerDownState.origin.y,
|
shape: this.getElementShape(hitElement),
|
||||||
this.scene.getNonDeletedElementsMap(),
|
threshold: this.getHitThreshold(),
|
||||||
|
frameNameBound: isFrameLikeElement(hitElement)
|
||||||
|
? this.frameNameBoundsCache.get(hitElement)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
elementsMap,
|
||||||
)) ||
|
)) ||
|
||||||
(!hitElement &&
|
(!hitElement &&
|
||||||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements))
|
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements))
|
||||||
|
@ -8232,6 +8374,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// reset cursor
|
||||||
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8267,11 +8411,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
isBindingEnabled(this.state)
|
isBindingEnabled(this.state)
|
||||||
? bindOrUnbindSelectedElements(
|
? bindOrUnbindSelectedElements(
|
||||||
this.scene.getSelectedElements(this.state),
|
this.scene.getSelectedElements(this.state),
|
||||||
this.scene.getNonDeletedElements(),
|
this,
|
||||||
elementsMap,
|
|
||||||
)
|
)
|
||||||
: unbindLinearElements(
|
: unbindLinearElements(
|
||||||
this.scene.getSelectedElements(this.state),
|
this.scene.getNonDeletedElements(),
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -8758,8 +8901,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}): void => {
|
}): void => {
|
||||||
const hoveredBindableElement = getHoveredElementForBinding(
|
const hoveredBindableElement = getHoveredElementForBinding(
|
||||||
pointerCoords,
|
pointerCoords,
|
||||||
this.scene.getNonDeletedElements(),
|
this,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
);
|
||||||
this.setState({
|
this.setState({
|
||||||
suggestedBindings:
|
suggestedBindings:
|
||||||
|
@ -8786,8 +8928,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
|
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
|
||||||
const hoveredBindableElement = getHoveredElementForBinding(
|
const hoveredBindableElement = getHoveredElementForBinding(
|
||||||
coords,
|
coords,
|
||||||
this.scene.getNonDeletedElements(),
|
this,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
hoveredBindableElement != null &&
|
hoveredBindableElement != null &&
|
||||||
|
@ -8815,8 +8956,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
const suggestedBindings = getEligibleElementsForBinding(
|
const suggestedBindings = getEligibleElementsForBinding(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
this.scene.getNonDeletedElements(),
|
this,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
);
|
||||||
this.setState({ suggestedBindings });
|
this.setState({ suggestedBindings });
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,9 +26,9 @@ import clsx from "clsx";
|
||||||
import { KEYS } from "../../keys";
|
import { KEYS } from "../../keys";
|
||||||
import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants";
|
import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants";
|
||||||
import { getElementAbsoluteCoords } from "../../element/bounds";
|
import { getElementAbsoluteCoords } from "../../element/bounds";
|
||||||
import { getTooltipDiv, updateTooltipPosition } from "../Tooltip";
|
import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip";
|
||||||
import { getSelectedElements } from "../../scene";
|
import { getSelectedElements } from "../../scene";
|
||||||
import { isPointHittingElementBoundingBox } from "../../element/collision";
|
import { hitElementBoundingBox } from "../../element/collision";
|
||||||
import { isLocalLink, normalizeLink } from "../../data/url";
|
import { isLocalLink, normalizeLink } from "../../data/url";
|
||||||
|
|
||||||
import "./Hyperlink.scss";
|
import "./Hyperlink.scss";
|
||||||
|
@ -425,15 +425,7 @@ const shouldHideLinkPopup = (
|
||||||
|
|
||||||
const threshold = 15 / appState.zoom.value;
|
const threshold = 15 / appState.zoom.value;
|
||||||
// hitbox to prevent hiding when hovered in element bounding box
|
// hitbox to prevent hiding when hovered in element bounding box
|
||||||
if (
|
if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) {
|
||||||
isPointHittingElementBoundingBox(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
[sceneX, sceneY],
|
|
||||||
threshold,
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { MIME_TYPES } from "../../constants";
|
import { MIME_TYPES } from "../../constants";
|
||||||
import { Bounds, getElementAbsoluteCoords } from "../../element/bounds";
|
import { Bounds, getElementAbsoluteCoords } from "../../element/bounds";
|
||||||
import { isPointHittingElementBoundingBox } from "../../element/collision";
|
import { hitElementBoundingBox } from "../../element/collision";
|
||||||
import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types";
|
import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
import { rotate } from "../../math";
|
import { rotate } from "../../math";
|
||||||
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
|
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
|
||||||
|
@ -75,17 +75,10 @@ export const isPointHittingLink = (
|
||||||
if (!element.link || appState.selectedElementIds[element.id]) {
|
if (!element.link || appState.selectedElementIds[element.id]) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const threshold = 4 / appState.zoom.value;
|
|
||||||
if (
|
if (
|
||||||
!isMobile &&
|
!isMobile &&
|
||||||
appState.viewModeEnabled &&
|
appState.viewModeEnabled &&
|
||||||
isPointHittingElementBoundingBox(
|
hitElementBoundingBox(x, y, element, elementsMap)
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
[x, y],
|
|
||||||
threshold,
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,28 +1,37 @@
|
||||||
|
import * as GA from "../ga";
|
||||||
|
import * as GAPoint from "../gapoints";
|
||||||
|
import * as GADirection from "../gadirections";
|
||||||
|
import * as GALine from "../galines";
|
||||||
|
import * as GATransform from "../gatransforms";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ExcalidrawLinearElement,
|
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
NonDeleted,
|
|
||||||
NonDeletedExcalidrawElement,
|
|
||||||
PointBinding,
|
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
ExcalidrawRectangleElement,
|
||||||
|
ExcalidrawDiamondElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
ExcalidrawEllipseElement,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawImageElement,
|
||||||
|
ExcalidrawFrameLikeElement,
|
||||||
|
ExcalidrawIframeLikeElement,
|
||||||
|
NonDeleted,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
PointBinding,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
import { getElementAbsoluteCoords } from "./bounds";
|
||||||
|
import { AppClassProperties, AppState, Point } from "../types";
|
||||||
|
import { isPointOnShape } from "../../utils/collision";
|
||||||
import { getElementAtPosition } from "../scene";
|
import { getElementAtPosition } from "../scene";
|
||||||
import { AppState } from "../types";
|
|
||||||
import {
|
import {
|
||||||
isBindableElement,
|
isBindableElement,
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import {
|
|
||||||
bindingBorderTest,
|
|
||||||
distanceToBindableElement,
|
|
||||||
maxBindingGap,
|
|
||||||
determineFocusDistance,
|
|
||||||
intersectElementWithLine,
|
|
||||||
determineFocusPoint,
|
|
||||||
} from "./collision";
|
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
@ -152,29 +161,22 @@ const bindOrUnbindLinearElementEdge = (
|
||||||
|
|
||||||
export const bindOrUnbindSelectedElements = (
|
export const bindOrUnbindSelectedElements = (
|
||||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||||
elements: readonly ExcalidrawElement[],
|
app: AppClassProperties,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
): void => {
|
): void => {
|
||||||
selectedElements.forEach((selectedElement) => {
|
selectedElements.forEach((selectedElement) => {
|
||||||
if (isBindingElement(selectedElement)) {
|
if (isBindingElement(selectedElement)) {
|
||||||
bindOrUnbindLinearElement(
|
bindOrUnbindLinearElement(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
getElligibleElementForBindingElement(
|
getElligibleElementForBindingElement(selectedElement, "start", app),
|
||||||
selectedElement,
|
getElligibleElementForBindingElement(selectedElement, "end", app),
|
||||||
"start",
|
app.scene.getNonDeletedElementsMap(),
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
getElligibleElementForBindingElement(
|
|
||||||
selectedElement,
|
|
||||||
"end",
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
} else if (isBindableElement(selectedElement)) {
|
} else if (isBindableElement(selectedElement)) {
|
||||||
maybeBindBindableElement(selectedElement, elementsMap);
|
maybeBindBindableElement(
|
||||||
|
selectedElement,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
app,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -182,11 +184,10 @@ export const bindOrUnbindSelectedElements = (
|
||||||
const maybeBindBindableElement = (
|
const maybeBindBindableElement = (
|
||||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
app: AppClassProperties,
|
||||||
): void => {
|
): void => {
|
||||||
getElligibleElementsForBindableElementAndWhere(
|
getElligibleElementsForBindableElementAndWhere(bindableElement, app).forEach(
|
||||||
bindableElement,
|
([linearElement, where]) =>
|
||||||
elementsMap,
|
|
||||||
).forEach(([linearElement, where]) =>
|
|
||||||
bindOrUnbindLinearElement(
|
bindOrUnbindLinearElement(
|
||||||
linearElement,
|
linearElement,
|
||||||
where === "end" ? "keep" : bindableElement,
|
where === "end" ? "keep" : bindableElement,
|
||||||
|
@ -199,23 +200,18 @@ const maybeBindBindableElement = (
|
||||||
export const maybeBindLinearElement = (
|
export const maybeBindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
scene: Scene,
|
|
||||||
pointerCoords: { x: number; y: number },
|
pointerCoords: { x: number; y: number },
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
app: AppClassProperties,
|
||||||
): void => {
|
): void => {
|
||||||
if (appState.startBoundElement != null) {
|
if (appState.startBoundElement != null) {
|
||||||
bindLinearElement(
|
bindLinearElement(
|
||||||
linearElement,
|
linearElement,
|
||||||
appState.startBoundElement,
|
appState.startBoundElement,
|
||||||
"start",
|
"start",
|
||||||
elementsMap,
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const hoveredElement = getHoveredElementForBinding(
|
const hoveredElement = getHoveredElementForBinding(pointerCoords, app);
|
||||||
pointerCoords,
|
|
||||||
scene.getNonDeletedElements(),
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
hoveredElement != null &&
|
hoveredElement != null &&
|
||||||
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
||||||
|
@ -224,7 +220,12 @@ export const maybeBindLinearElement = (
|
||||||
"end",
|
"end",
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
|
bindLinearElement(
|
||||||
|
linearElement,
|
||||||
|
hoveredElement,
|
||||||
|
"end",
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -283,7 +284,7 @@ export const isLinearElementSimpleAndAlreadyBound = (
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unbindLinearElements = (
|
export const unbindLinearElements = (
|
||||||
elements: NonDeleted<ExcalidrawElement>[],
|
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
): void => {
|
): void => {
|
||||||
elements.forEach((element) => {
|
elements.forEach((element) => {
|
||||||
|
@ -311,14 +312,13 @@ export const getHoveredElementForBinding = (
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
},
|
},
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
app: AppClassProperties,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
const hoveredElement = getElementAtPosition(
|
const hoveredElement = getElementAtPosition(
|
||||||
elements,
|
app.scene.getNonDeletedElements(),
|
||||||
(element) =>
|
(element) =>
|
||||||
isBindableElement(element, false) &&
|
isBindableElement(element, false) &&
|
||||||
bindingBorderTest(element, pointerCoords, elementsMap),
|
bindingBorderTest(element, pointerCoords, app),
|
||||||
);
|
);
|
||||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||||
};
|
};
|
||||||
|
@ -547,23 +547,21 @@ const maybeCalculateNewGapWhenScaling = (
|
||||||
// TODO: this is a bottleneck, optimise
|
// TODO: this is a bottleneck, optimise
|
||||||
export const getEligibleElementsForBinding = (
|
export const getEligibleElementsForBinding = (
|
||||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||||
elements: readonly ExcalidrawElement[],
|
app: AppClassProperties,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
): SuggestedBinding[] => {
|
): SuggestedBinding[] => {
|
||||||
const includedElementIds = new Set(selectedElements.map(({ id }) => id));
|
const includedElementIds = new Set(selectedElements.map(({ id }) => id));
|
||||||
return selectedElements.flatMap((selectedElement) =>
|
return selectedElements.flatMap((selectedElement) =>
|
||||||
isBindingElement(selectedElement, false)
|
isBindingElement(selectedElement, false)
|
||||||
? (getElligibleElementsForBindingElement(
|
? (getElligibleElementsForBindingElement(
|
||||||
selectedElement as NonDeleted<ExcalidrawLinearElement>,
|
selectedElement as NonDeleted<ExcalidrawLinearElement>,
|
||||||
elements,
|
app,
|
||||||
elementsMap,
|
|
||||||
).filter(
|
).filter(
|
||||||
(element) => !includedElementIds.has(element.id),
|
(element) => !includedElementIds.has(element.id),
|
||||||
) as SuggestedBinding[])
|
) as SuggestedBinding[])
|
||||||
: isBindableElement(selectedElement, false)
|
: isBindableElement(selectedElement, false)
|
||||||
? getElligibleElementsForBindableElementAndWhere(
|
? getElligibleElementsForBindableElementAndWhere(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
elementsMap,
|
app,
|
||||||
).filter((binding) => !includedElementIds.has(binding[0].id))
|
).filter((binding) => !includedElementIds.has(binding[0].id))
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
@ -571,22 +569,11 @@ export const getEligibleElementsForBinding = (
|
||||||
|
|
||||||
const getElligibleElementsForBindingElement = (
|
const getElligibleElementsForBindingElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
elements: readonly ExcalidrawElement[],
|
app: AppClassProperties,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
): NonDeleted<ExcalidrawBindableElement>[] => {
|
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||||
return [
|
return [
|
||||||
getElligibleElementForBindingElement(
|
getElligibleElementForBindingElement(linearElement, "start", app),
|
||||||
linearElement,
|
getElligibleElementForBindingElement(linearElement, "end", app),
|
||||||
"start",
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
getElligibleElementForBindingElement(
|
|
||||||
linearElement,
|
|
||||||
"end",
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
].filter(
|
].filter(
|
||||||
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||||
element != null,
|
element != null,
|
||||||
|
@ -596,13 +583,15 @@ const getElligibleElementsForBindingElement = (
|
||||||
const getElligibleElementForBindingElement = (
|
const getElligibleElementForBindingElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
elements: readonly ExcalidrawElement[],
|
app: AppClassProperties,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
return getHoveredElementForBinding(
|
return getHoveredElementForBinding(
|
||||||
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
|
getLinearElementEdgeCoors(
|
||||||
elements,
|
linearElement,
|
||||||
elementsMap,
|
startOrEnd,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
),
|
||||||
|
app,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -623,7 +612,7 @@ const getLinearElementEdgeCoors = (
|
||||||
|
|
||||||
const getElligibleElementsForBindableElementAndWhere = (
|
const getElligibleElementsForBindableElementAndWhere = (
|
||||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
app: AppClassProperties,
|
||||||
): SuggestedPointBinding[] => {
|
): SuggestedPointBinding[] => {
|
||||||
const scene = Scene.getScene(bindableElement)!;
|
const scene = Scene.getScene(bindableElement)!;
|
||||||
return scene
|
return scene
|
||||||
|
@ -636,13 +625,15 @@ const getElligibleElementsForBindableElementAndWhere = (
|
||||||
element,
|
element,
|
||||||
"start",
|
"start",
|
||||||
bindableElement,
|
bindableElement,
|
||||||
elementsMap,
|
scene.getNonDeletedElementsMap(),
|
||||||
|
app,
|
||||||
);
|
);
|
||||||
const canBindEnd = isLinearElementEligibleForNewBindingByBindable(
|
const canBindEnd = isLinearElementEligibleForNewBindingByBindable(
|
||||||
element,
|
element,
|
||||||
"end",
|
"end",
|
||||||
bindableElement,
|
bindableElement,
|
||||||
elementsMap,
|
scene.getNonDeletedElementsMap(),
|
||||||
|
app,
|
||||||
);
|
);
|
||||||
if (!canBindStart && !canBindEnd) {
|
if (!canBindStart && !canBindEnd) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -661,6 +652,7 @@ const isLinearElementEligibleForNewBindingByBindable = (
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
app: AppClassProperties,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const existingBinding =
|
const existingBinding =
|
||||||
linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"];
|
linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"];
|
||||||
|
@ -674,7 +666,7 @@ const isLinearElementEligibleForNewBindingByBindable = (
|
||||||
bindingBorderTest(
|
bindingBorderTest(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
|
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
|
||||||
elementsMap,
|
app,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -846,3 +838,547 @@ const newBoundElementsAfterDeletion = (
|
||||||
}
|
}
|
||||||
return boundElements.filter((ele) => !deletedElementIds.has(ele.id));
|
return boundElements.filter((ele) => !deletedElementIds.has(ele.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const bindingBorderTest = (
|
||||||
|
element: NonDeleted<ExcalidrawBindableElement>,
|
||||||
|
{ x, y }: { x: number; y: number },
|
||||||
|
app: AppClassProperties,
|
||||||
|
): boolean => {
|
||||||
|
const threshold = maxBindingGap(element, element.width, element.height);
|
||||||
|
const shape = app.getElementShape(element);
|
||||||
|
return isPointOnShape([x, y], shape, threshold);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const maxBindingGap = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementWidth: number,
|
||||||
|
elementHeight: number,
|
||||||
|
): number => {
|
||||||
|
// Aligns diamonds with rectangles
|
||||||
|
const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
|
||||||
|
const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
|
||||||
|
// We make the bindable boundary bigger for bigger elements
|
||||||
|
return Math.max(16, Math.min(0.25 * smallerDimension, 32));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const distanceToBindableElement = (
|
||||||
|
element: ExcalidrawBindableElement,
|
||||||
|
point: Point,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): number => {
|
||||||
|
switch (element.type) {
|
||||||
|
case "rectangle":
|
||||||
|
case "image":
|
||||||
|
case "text":
|
||||||
|
case "iframe":
|
||||||
|
case "embeddable":
|
||||||
|
case "frame":
|
||||||
|
case "magicframe":
|
||||||
|
return distanceToRectangle(element, point, elementsMap);
|
||||||
|
case "diamond":
|
||||||
|
return distanceToDiamond(element, point, elementsMap);
|
||||||
|
case "ellipse":
|
||||||
|
return distanceToEllipse(element, point, elementsMap);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const distanceToRectangle = (
|
||||||
|
element:
|
||||||
|
| ExcalidrawRectangleElement
|
||||||
|
| ExcalidrawTextElement
|
||||||
|
| ExcalidrawFreeDrawElement
|
||||||
|
| ExcalidrawImageElement
|
||||||
|
| ExcalidrawIframeLikeElement
|
||||||
|
| ExcalidrawFrameLikeElement,
|
||||||
|
point: Point,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): number => {
|
||||||
|
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
||||||
|
element,
|
||||||
|
point,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
return Math.max(
|
||||||
|
GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
|
||||||
|
GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const distanceToDiamond = (
|
||||||
|
element: ExcalidrawDiamondElement,
|
||||||
|
point: Point,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): number => {
|
||||||
|
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
||||||
|
element,
|
||||||
|
point,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const side = GALine.equation(hheight, hwidth, -hheight * hwidth);
|
||||||
|
return GAPoint.distanceToLine(pointRel, side);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const distanceToEllipse = (
|
||||||
|
element: ExcalidrawEllipseElement,
|
||||||
|
point: Point,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): number => {
|
||||||
|
const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
|
||||||
|
return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ellipseParamsForTest = (
|
||||||
|
element: ExcalidrawEllipseElement,
|
||||||
|
point: Point,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): [GA.Point, GA.Line] => {
|
||||||
|
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
||||||
|
element,
|
||||||
|
point,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const [px, py] = GAPoint.toTuple(pointRel);
|
||||||
|
|
||||||
|
// We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)`
|
||||||
|
let tx = 0.707;
|
||||||
|
let ty = 0.707;
|
||||||
|
|
||||||
|
const a = hwidth;
|
||||||
|
const b = hheight;
|
||||||
|
|
||||||
|
// This is a numerical method to find the params tx, ty at which
|
||||||
|
// the ellipse has the closest point to the given point
|
||||||
|
[0, 1, 2, 3].forEach((_) => {
|
||||||
|
const xx = a * tx;
|
||||||
|
const yy = b * ty;
|
||||||
|
|
||||||
|
const ex = ((a * a - b * b) * tx ** 3) / a;
|
||||||
|
const ey = ((b * b - a * a) * ty ** 3) / b;
|
||||||
|
|
||||||
|
const rx = xx - ex;
|
||||||
|
const ry = yy - ey;
|
||||||
|
|
||||||
|
const qx = px - ex;
|
||||||
|
const qy = py - ey;
|
||||||
|
|
||||||
|
const r = Math.hypot(ry, rx);
|
||||||
|
const q = Math.hypot(qy, qx);
|
||||||
|
|
||||||
|
tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
|
||||||
|
ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
|
||||||
|
const t = Math.hypot(ty, tx);
|
||||||
|
tx /= t;
|
||||||
|
ty /= t;
|
||||||
|
});
|
||||||
|
|
||||||
|
const closestPoint = GA.point(a * tx, b * ty);
|
||||||
|
|
||||||
|
const tangent = GALine.orthogonalThrough(pointRel, closestPoint);
|
||||||
|
return [pointRel, tangent];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns:
|
||||||
|
// 1. the point relative to the elements (x, y) position
|
||||||
|
// 2. the point relative to the element's center with positive (x, y)
|
||||||
|
// 3. half element width
|
||||||
|
// 4. half element height
|
||||||
|
//
|
||||||
|
// Note that for linear elements the (x, y) position is not at the
|
||||||
|
// top right corner of their boundary.
|
||||||
|
//
|
||||||
|
// Rectangles, diamonds and ellipses are symmetrical over axes,
|
||||||
|
// and other elements have a rectangular boundary,
|
||||||
|
// so we only need to perform hit tests for the positive quadrant.
|
||||||
|
const pointRelativeToElement = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
pointTuple: Point,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): [GA.Point, GA.Point, number, number] => {
|
||||||
|
const point = GAPoint.from(pointTuple);
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
const center = coordsCenter(x1, y1, x2, y2);
|
||||||
|
// GA has angle orientation opposite to `rotate`
|
||||||
|
const rotate = GATransform.rotation(center, element.angle);
|
||||||
|
const pointRotated = GATransform.apply(rotate, point);
|
||||||
|
const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
|
||||||
|
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
||||||
|
const elementPos = GA.offset(element.x, element.y);
|
||||||
|
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
||||||
|
const halfWidth = (x2 - x1) / 2;
|
||||||
|
const halfHeight = (y2 - y1) / 2;
|
||||||
|
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
|
||||||
|
};
|
||||||
|
|
||||||
|
const relativizationToElementCenter = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): GA.Transform => {
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
const center = coordsCenter(x1, y1, x2, y2);
|
||||||
|
// GA has angle orientation opposite to `rotate`
|
||||||
|
const rotate = GATransform.rotation(center, element.angle);
|
||||||
|
const translate = GA.reverse(
|
||||||
|
GATransform.translation(GADirection.from(center)),
|
||||||
|
);
|
||||||
|
return GATransform.compose(rotate, translate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const coordsCenter = (
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
): GA.Point => {
|
||||||
|
return GA.point((x1 + x2) / 2, (y1 + y2) / 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The focus distance is the oriented ratio between the size of
|
||||||
|
// the `element` and the "focus image" of the element on which
|
||||||
|
// all focus points lie, so it's a number between -1 and 1.
|
||||||
|
// The line going through `a` and `b` is a tangent to the "focus image"
|
||||||
|
// of the element.
|
||||||
|
export const determineFocusDistance = (
|
||||||
|
element: ExcalidrawBindableElement,
|
||||||
|
// Point on the line, in absolute coordinates
|
||||||
|
a: Point,
|
||||||
|
// Another point on the line, in absolute coordinates (closer to element)
|
||||||
|
b: Point,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): number => {
|
||||||
|
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
||||||
|
const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
|
||||||
|
const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
|
||||||
|
const line = GALine.through(aRel, bRel);
|
||||||
|
const q = element.height / element.width;
|
||||||
|
const hwidth = element.width / 2;
|
||||||
|
const hheight = element.height / 2;
|
||||||
|
const n = line[2];
|
||||||
|
const m = line[3];
|
||||||
|
const c = line[1];
|
||||||
|
const mabs = Math.abs(m);
|
||||||
|
const nabs = Math.abs(n);
|
||||||
|
let ret;
|
||||||
|
switch (element.type) {
|
||||||
|
case "rectangle":
|
||||||
|
case "image":
|
||||||
|
case "text":
|
||||||
|
case "iframe":
|
||||||
|
case "embeddable":
|
||||||
|
case "frame":
|
||||||
|
case "magicframe":
|
||||||
|
ret = c / (hwidth * (nabs + q * mabs));
|
||||||
|
break;
|
||||||
|
case "diamond":
|
||||||
|
ret = mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||||
|
break;
|
||||||
|
case "ellipse":
|
||||||
|
ret = c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return ret || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const determineFocusPoint = (
|
||||||
|
element: ExcalidrawBindableElement,
|
||||||
|
// The oriented, relative distance from the center of `element` of the
|
||||||
|
// returned focusPoint
|
||||||
|
focus: number,
|
||||||
|
adjecentPoint: Point,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): Point => {
|
||||||
|
if (focus === 0) {
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
const center = coordsCenter(x1, y1, x2, y2);
|
||||||
|
return GAPoint.toTuple(center);
|
||||||
|
}
|
||||||
|
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
||||||
|
const adjecentPointRel = GATransform.apply(
|
||||||
|
relateToCenter,
|
||||||
|
GAPoint.from(adjecentPoint),
|
||||||
|
);
|
||||||
|
const reverseRelateToCenter = GA.reverse(relateToCenter);
|
||||||
|
let point;
|
||||||
|
switch (element.type) {
|
||||||
|
case "rectangle":
|
||||||
|
case "image":
|
||||||
|
case "text":
|
||||||
|
case "diamond":
|
||||||
|
case "iframe":
|
||||||
|
case "embeddable":
|
||||||
|
case "frame":
|
||||||
|
case "magicframe":
|
||||||
|
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||||
|
break;
|
||||||
|
case "ellipse":
|
||||||
|
point = findFocusPointForEllipse(element, focus, adjecentPointRel);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns 2 or 0 intersection points between line going through `a` and `b`
|
||||||
|
// and the `element`, in ascending order of distance from `a`.
|
||||||
|
export const intersectElementWithLine = (
|
||||||
|
element: ExcalidrawBindableElement,
|
||||||
|
// Point on the line, in absolute coordinates
|
||||||
|
a: Point,
|
||||||
|
// Another point on the line, in absolute coordinates
|
||||||
|
b: Point,
|
||||||
|
// If given, the element is inflated by this value
|
||||||
|
gap: number = 0,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): Point[] => {
|
||||||
|
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
||||||
|
const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
|
||||||
|
const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
|
||||||
|
const line = GALine.through(aRel, bRel);
|
||||||
|
const reverseRelateToCenter = GA.reverse(relateToCenter);
|
||||||
|
const intersections = getSortedElementLineIntersections(
|
||||||
|
element,
|
||||||
|
line,
|
||||||
|
aRel,
|
||||||
|
gap,
|
||||||
|
);
|
||||||
|
return intersections.map((point) =>
|
||||||
|
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortedElementLineIntersections = (
|
||||||
|
element: ExcalidrawBindableElement,
|
||||||
|
// Relative to element center
|
||||||
|
line: GA.Line,
|
||||||
|
// Relative to element center
|
||||||
|
nearPoint: GA.Point,
|
||||||
|
gap: number = 0,
|
||||||
|
): GA.Point[] => {
|
||||||
|
let intersections: GA.Point[];
|
||||||
|
switch (element.type) {
|
||||||
|
case "rectangle":
|
||||||
|
case "image":
|
||||||
|
case "text":
|
||||||
|
case "diamond":
|
||||||
|
case "iframe":
|
||||||
|
case "embeddable":
|
||||||
|
case "frame":
|
||||||
|
case "magicframe":
|
||||||
|
const corners = getCorners(element);
|
||||||
|
intersections = corners
|
||||||
|
.flatMap((point, i) => {
|
||||||
|
const edge: [GA.Point, GA.Point] = [point, corners[(i + 1) % 4]];
|
||||||
|
return intersectSegment(line, offsetSegment(edge, gap));
|
||||||
|
})
|
||||||
|
.concat(
|
||||||
|
corners.flatMap((point) => getCircleIntersections(point, gap, line)),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ellipse":
|
||||||
|
intersections = getEllipseIntersections(element, gap, line);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (intersections.length < 2) {
|
||||||
|
// Ignore the "edge" case of only intersecting with a single corner
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const sortedIntersections = intersections.sort(
|
||||||
|
(i1, i2) =>
|
||||||
|
GAPoint.distance(i1, nearPoint) - GAPoint.distance(i2, nearPoint),
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
sortedIntersections[0],
|
||||||
|
sortedIntersections[sortedIntersections.length - 1],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCorners = (
|
||||||
|
element:
|
||||||
|
| ExcalidrawRectangleElement
|
||||||
|
| ExcalidrawImageElement
|
||||||
|
| ExcalidrawDiamondElement
|
||||||
|
| ExcalidrawTextElement
|
||||||
|
| ExcalidrawIframeLikeElement
|
||||||
|
| ExcalidrawFrameLikeElement,
|
||||||
|
scale: number = 1,
|
||||||
|
): GA.Point[] => {
|
||||||
|
const hx = (scale * element.width) / 2;
|
||||||
|
const hy = (scale * element.height) / 2;
|
||||||
|
switch (element.type) {
|
||||||
|
case "rectangle":
|
||||||
|
case "image":
|
||||||
|
case "text":
|
||||||
|
case "iframe":
|
||||||
|
case "embeddable":
|
||||||
|
case "frame":
|
||||||
|
case "magicframe":
|
||||||
|
return [
|
||||||
|
GA.point(hx, hy),
|
||||||
|
GA.point(hx, -hy),
|
||||||
|
GA.point(-hx, -hy),
|
||||||
|
GA.point(-hx, hy),
|
||||||
|
];
|
||||||
|
case "diamond":
|
||||||
|
return [
|
||||||
|
GA.point(0, hy),
|
||||||
|
GA.point(hx, 0),
|
||||||
|
GA.point(0, -hy),
|
||||||
|
GA.point(-hx, 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns intersection of `line` with `segment`, with `segment` moved by
|
||||||
|
// `gap` in its polar direction.
|
||||||
|
// If intersection coincides with second segment point returns empty array.
|
||||||
|
const intersectSegment = (
|
||||||
|
line: GA.Line,
|
||||||
|
segment: [GA.Point, GA.Point],
|
||||||
|
): GA.Point[] => {
|
||||||
|
const [a, b] = segment;
|
||||||
|
const aDist = GAPoint.distanceToLine(a, line);
|
||||||
|
const bDist = GAPoint.distanceToLine(b, line);
|
||||||
|
if (aDist * bDist >= 0) {
|
||||||
|
// The intersection is outside segment `(a, b)`
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [GAPoint.intersect(line, GALine.through(a, b))];
|
||||||
|
};
|
||||||
|
|
||||||
|
const offsetSegment = (
|
||||||
|
segment: [GA.Point, GA.Point],
|
||||||
|
distance: number,
|
||||||
|
): [GA.Point, GA.Point] => {
|
||||||
|
const [a, b] = segment;
|
||||||
|
const offset = GATransform.translationOrthogonal(
|
||||||
|
GADirection.fromTo(a, b),
|
||||||
|
distance,
|
||||||
|
);
|
||||||
|
return [GATransform.apply(offset, a), GATransform.apply(offset, b)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEllipseIntersections = (
|
||||||
|
element: ExcalidrawEllipseElement,
|
||||||
|
gap: number,
|
||||||
|
line: GA.Line,
|
||||||
|
): GA.Point[] => {
|
||||||
|
const a = element.width / 2 + gap;
|
||||||
|
const b = element.height / 2 + gap;
|
||||||
|
const m = line[2];
|
||||||
|
const n = line[3];
|
||||||
|
const c = line[1];
|
||||||
|
const squares = a * a * m * m + b * b * n * n;
|
||||||
|
const discr = squares - c * c;
|
||||||
|
if (squares === 0 || discr <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const discrRoot = Math.sqrt(discr);
|
||||||
|
const xn = -a * a * m * c;
|
||||||
|
const yn = -b * b * n * c;
|
||||||
|
return [
|
||||||
|
GA.point(
|
||||||
|
(xn + a * b * n * discrRoot) / squares,
|
||||||
|
(yn - a * b * m * discrRoot) / squares,
|
||||||
|
),
|
||||||
|
GA.point(
|
||||||
|
(xn - a * b * n * discrRoot) / squares,
|
||||||
|
(yn + a * b * m * discrRoot) / squares,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCircleIntersections = (
|
||||||
|
center: GA.Point,
|
||||||
|
radius: number,
|
||||||
|
line: GA.Line,
|
||||||
|
): GA.Point[] => {
|
||||||
|
if (radius === 0) {
|
||||||
|
return GAPoint.distanceToLine(line, center) === 0 ? [center] : [];
|
||||||
|
}
|
||||||
|
const m = line[2];
|
||||||
|
const n = line[3];
|
||||||
|
const c = line[1];
|
||||||
|
const [a, b] = GAPoint.toTuple(center);
|
||||||
|
const r = radius;
|
||||||
|
const squares = m * m + n * n;
|
||||||
|
const discr = r * r * squares - (m * a + n * b + c) ** 2;
|
||||||
|
if (squares === 0 || discr <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const discrRoot = Math.sqrt(discr);
|
||||||
|
const xn = a * n * n - b * m * n - m * c;
|
||||||
|
const yn = b * m * m - a * m * n - n * c;
|
||||||
|
|
||||||
|
return [
|
||||||
|
GA.point((xn + n * discrRoot) / squares, (yn - m * discrRoot) / squares),
|
||||||
|
GA.point((xn - n * discrRoot) / squares, (yn + m * discrRoot) / squares),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// The focus point is the tangent point of the "focus image" of the
|
||||||
|
// `element`, where the tangent goes through `point`.
|
||||||
|
export const findFocusPointForEllipse = (
|
||||||
|
ellipse: ExcalidrawEllipseElement,
|
||||||
|
// Between -1 and 1 (not 0) the relative size of the "focus image" of
|
||||||
|
// the element on which the focus point lies
|
||||||
|
relativeDistance: number,
|
||||||
|
// The point for which we're trying to find the focus point, relative
|
||||||
|
// to the ellipse center.
|
||||||
|
point: GA.Point,
|
||||||
|
): GA.Point => {
|
||||||
|
const relativeDistanceAbs = Math.abs(relativeDistance);
|
||||||
|
const a = (ellipse.width * relativeDistanceAbs) / 2;
|
||||||
|
const b = (ellipse.height * relativeDistanceAbs) / 2;
|
||||||
|
|
||||||
|
const orientation = Math.sign(relativeDistance);
|
||||||
|
const [px, pyo] = GAPoint.toTuple(point);
|
||||||
|
|
||||||
|
// The calculation below can't handle py = 0
|
||||||
|
const py = pyo === 0 ? 0.0001 : pyo;
|
||||||
|
|
||||||
|
const squares = px ** 2 * b ** 2 + py ** 2 * a ** 2;
|
||||||
|
// Tangent mx + ny + 1 = 0
|
||||||
|
const m =
|
||||||
|
(-px * b ** 2 +
|
||||||
|
orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
|
||||||
|
squares;
|
||||||
|
|
||||||
|
let n = (-m * px - 1) / py;
|
||||||
|
|
||||||
|
if (n === 0) {
|
||||||
|
// if zero {-0, 0}, fall back to a same-sign value in the similar range
|
||||||
|
n = (Object.is(n, -0) ? -1 : 1) * 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2);
|
||||||
|
return GA.point(x, (-m * x - 1) / n);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findFocusPointForRectangulars = (
|
||||||
|
element:
|
||||||
|
| ExcalidrawRectangleElement
|
||||||
|
| ExcalidrawImageElement
|
||||||
|
| ExcalidrawDiamondElement
|
||||||
|
| ExcalidrawTextElement
|
||||||
|
| ExcalidrawIframeLikeElement
|
||||||
|
| ExcalidrawFrameLikeElement,
|
||||||
|
// Between -1 and 1 for how far away should the focus point be relative
|
||||||
|
// to the size of the element. Sign determines orientation.
|
||||||
|
relativeDistance: number,
|
||||||
|
// The point for which we're trying to find the focus point, relative
|
||||||
|
// to the element center.
|
||||||
|
point: GA.Point,
|
||||||
|
): GA.Point => {
|
||||||
|
const relativeDistanceAbs = Math.abs(relativeDistance);
|
||||||
|
const orientation = Math.sign(relativeDistance);
|
||||||
|
const corners = getCorners(element, relativeDistanceAbs);
|
||||||
|
|
||||||
|
let maxDistance = 0;
|
||||||
|
let tangentPoint: null | GA.Point = null;
|
||||||
|
corners.forEach((corner) => {
|
||||||
|
const distance = orientation * GALine.through(point, corner)[1];
|
||||||
|
if (distance > maxDistance) {
|
||||||
|
maxDistance = distance;
|
||||||
|
tangentPoint = corner;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return tangentPoint!;
|
||||||
|
};
|
||||||
|
|
|
@ -299,13 +299,6 @@ export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pointRelativeTo = (
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
absoluteCoords: Point,
|
|
||||||
): Point => {
|
|
||||||
return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDiamondPoints = (element: ExcalidrawElement) => {
|
export const getDiamondPoints = (element: ExcalidrawElement) => {
|
||||||
// Here we add +1 to avoid these numbers to be 0
|
// Here we add +1 to avoid these numbers to be 0
|
||||||
// otherwise rough.js will throw an error complaining about it
|
// otherwise rough.js will throw an error complaining about it
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -29,10 +29,6 @@ export {
|
||||||
getTransformHandlesFromCoords,
|
getTransformHandlesFromCoords,
|
||||||
getTransformHandles,
|
getTransformHandles,
|
||||||
} from "./transformHandles";
|
} from "./transformHandles";
|
||||||
export {
|
|
||||||
hitTest,
|
|
||||||
isHittingElementBoundingBoxWithoutHittingElement,
|
|
||||||
} from "./collision";
|
|
||||||
export {
|
export {
|
||||||
resizeTest,
|
resizeTest,
|
||||||
getCursorForResizingElement,
|
getCursorForResizingElement,
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
NonDeletedExcalidrawElement,
|
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
|
@ -34,6 +33,7 @@ import {
|
||||||
AppState,
|
AppState,
|
||||||
PointerCoords,
|
PointerCoords,
|
||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
|
AppClassProperties,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import History from "../history";
|
import History from "../history";
|
||||||
|
@ -334,9 +334,10 @@ export class LinearElementEditor {
|
||||||
event: PointerEvent,
|
event: PointerEvent,
|
||||||
editingLinearElement: LinearElementEditor,
|
editingLinearElement: LinearElementEditor,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
app: AppClassProperties,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
): LinearElementEditor {
|
): LinearElementEditor {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
||||||
editingLinearElement;
|
editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
@ -380,8 +381,7 @@ export class LinearElementEditor {
|
||||||
elementsMap,
|
elementsMap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
elements,
|
app,
|
||||||
elementsMap,
|
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
@ -645,13 +645,14 @@ export class LinearElementEditor {
|
||||||
history: History,
|
history: History,
|
||||||
scenePointer: { x: number; y: number },
|
scenePointer: { x: number; y: number },
|
||||||
linearElementEditor: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
app: AppClassProperties,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
): {
|
): {
|
||||||
didAddPoint: boolean;
|
didAddPoint: boolean;
|
||||||
hitElement: NonDeleted<ExcalidrawElement> | null;
|
hitElement: NonDeleted<ExcalidrawElement> | null;
|
||||||
linearElementEditor: LinearElementEditor | null;
|
linearElementEditor: LinearElementEditor | null;
|
||||||
} {
|
} {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
||||||
didAddPoint: false,
|
didAddPoint: false,
|
||||||
hitElement: null,
|
hitElement: null,
|
||||||
|
@ -714,11 +715,7 @@ export class LinearElementEditor {
|
||||||
},
|
},
|
||||||
selectedPointsIndices: [element.points.length - 1],
|
selectedPointsIndices: [element.points.length - 1],
|
||||||
lastUncommittedPoint: null,
|
lastUncommittedPoint: null,
|
||||||
endBindingElement: getHoveredElementForBinding(
|
endBindingElement: getHoveredElementForBinding(scenePointer, app),
|
||||||
scenePointer,
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ret.didAddPoint = true;
|
ret.didAddPoint = true;
|
||||||
|
|
|
@ -26,16 +26,11 @@ import { isTextElement } from ".";
|
||||||
import { isBoundToContainer, isArrowElement } from "./typeChecks";
|
import { isBoundToContainer, isArrowElement } from "./typeChecks";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isTextBindableContainer } from "./typeChecks";
|
|
||||||
import { getElementAbsoluteCoords } from ".";
|
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import { isHittingElementNotConsideringBoundingBox } from "./collision";
|
|
||||||
|
|
||||||
import { ExtractSetType, MakeBrand } from "../utility-types";
|
|
||||||
import {
|
import {
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "./containerCache";
|
} from "./containerCache";
|
||||||
|
import { ExtractSetType, MakeBrand } from "../utility-types";
|
||||||
|
|
||||||
export const normalizeText = (text: string) => {
|
export const normalizeText = (text: string) => {
|
||||||
return (
|
return (
|
||||||
|
@ -771,50 +766,6 @@ export const suppportsHorizontalAlign = (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTextBindableContainerAtPosition = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): ExcalidrawTextContainer | null => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
|
||||||
if (selectedElements.length === 1) {
|
|
||||||
return isTextBindableContainer(selectedElements[0], false)
|
|
||||||
? selectedElements[0]
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
let hitElement = null;
|
|
||||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
|
||||||
for (let index = elements.length - 1; index >= 0; --index) {
|
|
||||||
if (elements[index].isDeleted) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(
|
|
||||||
elements[index],
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
isArrowElement(elements[index]) &&
|
|
||||||
isHittingElementNotConsideringBoundingBox(
|
|
||||||
elements[index],
|
|
||||||
appState,
|
|
||||||
null,
|
|
||||||
[x, y],
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
hitElement = elements[index];
|
|
||||||
break;
|
|
||||||
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
|
|
||||||
hitElement = elements[index];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return isTextBindableContainer(hitElement, false) ? hitElement : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const VALID_CONTAINER_TYPES = new Set([
|
const VALID_CONTAINER_TYPES = new Set([
|
||||||
"rectangle",
|
"rectangle",
|
||||||
"ellipse",
|
"ellipse",
|
||||||
|
|
|
@ -34,8 +34,11 @@ import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
|
||||||
|
|
||||||
import { renderSnaps } from "../renderer/renderSnaps";
|
import { renderSnaps } from "../renderer/renderSnaps";
|
||||||
|
|
||||||
import { maxBindingGap } from "../element/collision";
|
import {
|
||||||
import { SuggestedBinding, SuggestedPointBinding } from "../element/binding";
|
maxBindingGap,
|
||||||
|
SuggestedBinding,
|
||||||
|
SuggestedPointBinding,
|
||||||
|
} from "../element/binding";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
import {
|
||||||
bootstrapCanvas,
|
bootstrapCanvas,
|
||||||
|
|
|
@ -2294,14 +2294,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 1116226695,
|
"versionNonce": 2019559783,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -2354,14 +2354,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -2396,14 +2396,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 1116226695,
|
"versionNonce": 2019559783,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -2540,14 +2540,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -2573,14 +2573,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1116226695,
|
"seed": 2019559783,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
|
@ -2633,14 +2633,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -2677,14 +2677,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -2707,14 +2707,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1116226695,
|
"seed": 2019559783,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
|
@ -2858,14 +2858,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 1505387817,
|
"versionNonce": 400692809,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -2893,14 +2893,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1116226695,
|
"seed": 2019559783,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 23633383,
|
"versionNonce": 1604849351,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
|
@ -2953,14 +2953,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -2997,14 +2997,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -3027,14 +3027,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1116226695,
|
"seed": 2019559783,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 238820263,
|
"versionNonce": 1116226695,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
|
@ -3076,14 +3076,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 1505387817,
|
"versionNonce": 400692809,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -3108,14 +3108,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1116226695,
|
"seed": 2019559783,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 23633383,
|
"versionNonce": 1604849351,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
|
@ -3254,14 +3254,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"strokeStyle": "dotted",
|
"strokeStyle": "dotted",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 640725609,
|
"versionNonce": 1315507081,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -3287,14 +3287,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 760410951,
|
"seed": 747212839,
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"strokeStyle": "dotted",
|
"strokeStyle": "dotted",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 8,
|
"version": 8,
|
||||||
"versionNonce": 1315507081,
|
"versionNonce": 1006504105,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
|
@ -3347,14 +3347,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -3391,14 +3391,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -3421,13 +3421,87 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1116226695,
|
"seed": 2019559783,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
|
"versionNonce": 1116226695,
|
||||||
|
"width": 20,
|
||||||
|
"x": 20,
|
||||||
|
"y": 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appState": {
|
||||||
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
|
"name": "Untitled-201933152653",
|
||||||
|
"selectedElementIds": {
|
||||||
|
"id1": true,
|
||||||
|
},
|
||||||
|
"selectedGroupIds": {},
|
||||||
|
"viewBackgroundColor": "#ffffff",
|
||||||
|
},
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"angle": 0,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"frameId": null,
|
||||||
|
"groupIds": [],
|
||||||
|
"height": 20,
|
||||||
|
"id": "id0",
|
||||||
|
"isDeleted": false,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"opacity": 100,
|
||||||
|
"roughness": 1,
|
||||||
|
"roundness": {
|
||||||
|
"type": 3,
|
||||||
|
},
|
||||||
|
"seed": 1278240551,
|
||||||
|
"strokeColor": "#1e1e1e",
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"type": "rectangle",
|
||||||
|
"updated": 1,
|
||||||
|
"version": 2,
|
||||||
|
"versionNonce": 453191,
|
||||||
|
"width": 20,
|
||||||
|
"x": -10,
|
||||||
|
"y": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"angle": 0,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"frameId": null,
|
||||||
|
"groupIds": [],
|
||||||
|
"height": 20,
|
||||||
|
"id": "id1",
|
||||||
|
"isDeleted": false,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"opacity": 100,
|
||||||
|
"roughness": 1,
|
||||||
|
"roundness": {
|
||||||
|
"type": 3,
|
||||||
|
},
|
||||||
|
"seed": 2019559783,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"type": "rectangle",
|
||||||
|
"updated": 1,
|
||||||
|
"version": 3,
|
||||||
"versionNonce": 238820263,
|
"versionNonce": 238820263,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
|
@ -3465,21 +3539,21 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "#a5d8ff",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"customData": undefined,
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
|
@ -3495,13 +3569,13 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1116226695,
|
"seed": 2019559783,
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"versionNonce": 1604849351,
|
"versionNonce": 1604849351,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
|
@ -3539,14 +3613,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -3556,7 +3630,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"backgroundColor": "#a5d8ff",
|
"backgroundColor": "#a5d8ff",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"customData": undefined,
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "cross-hatch",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 20,
|
"height": 20,
|
||||||
|
@ -3569,13 +3643,13 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1116226695,
|
"seed": 2019559783,
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 4,
|
"version": 5,
|
||||||
"versionNonce": 23633383,
|
"versionNonce": 23633383,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
|
@ -3613,14 +3687,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -3643,13 +3717,13 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1116226695,
|
"seed": 2019559783,
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "dotted",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 5,
|
"version": 6,
|
||||||
"versionNonce": 915032327,
|
"versionNonce": 915032327,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
|
@ -3687,88 +3761,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
|
||||||
"x": -10,
|
|
||||||
"y": 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"angle": 0,
|
|
||||||
"backgroundColor": "#a5d8ff",
|
|
||||||
"boundElements": null,
|
|
||||||
"customData": undefined,
|
|
||||||
"fillStyle": "cross-hatch",
|
|
||||||
"frameId": null,
|
|
||||||
"groupIds": [],
|
|
||||||
"height": 20,
|
|
||||||
"id": "id1",
|
|
||||||
"isDeleted": false,
|
|
||||||
"link": null,
|
|
||||||
"locked": false,
|
|
||||||
"opacity": 100,
|
|
||||||
"roughness": 1,
|
|
||||||
"roundness": {
|
|
||||||
"type": 3,
|
|
||||||
},
|
|
||||||
"seed": 1116226695,
|
|
||||||
"strokeColor": "#e03131",
|
|
||||||
"strokeStyle": "dotted",
|
|
||||||
"strokeWidth": 2,
|
|
||||||
"type": "rectangle",
|
|
||||||
"updated": 1,
|
|
||||||
"version": 6,
|
|
||||||
"versionNonce": 747212839,
|
|
||||||
"width": 20,
|
|
||||||
"x": 20,
|
|
||||||
"y": 30,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appState": {
|
|
||||||
"editingGroupId": null,
|
|
||||||
"editingLinearElement": null,
|
|
||||||
"name": "Untitled-201933152653",
|
|
||||||
"selectedElementIds": {
|
|
||||||
"id1": true,
|
|
||||||
},
|
|
||||||
"selectedGroupIds": {},
|
|
||||||
"viewBackgroundColor": "#ffffff",
|
|
||||||
},
|
|
||||||
"elements": [
|
|
||||||
{
|
|
||||||
"angle": 0,
|
|
||||||
"backgroundColor": "transparent",
|
|
||||||
"boundElements": null,
|
|
||||||
"customData": undefined,
|
|
||||||
"fillStyle": "solid",
|
|
||||||
"frameId": null,
|
|
||||||
"groupIds": [],
|
|
||||||
"height": 20,
|
|
||||||
"id": "id0",
|
|
||||||
"isDeleted": false,
|
|
||||||
"link": null,
|
|
||||||
"locked": false,
|
|
||||||
"opacity": 100,
|
|
||||||
"roughness": 1,
|
|
||||||
"roundness": {
|
|
||||||
"type": 3,
|
|
||||||
},
|
|
||||||
"seed": 449462985,
|
|
||||||
"strokeColor": "#1e1e1e",
|
|
||||||
"strokeStyle": "solid",
|
|
||||||
"strokeWidth": 2,
|
|
||||||
"type": "rectangle",
|
|
||||||
"updated": 1,
|
|
||||||
"version": 2,
|
|
||||||
"versionNonce": 2019559783,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -3791,14 +3791,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 760410951,
|
"seed": 747212839,
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"strokeStyle": "dotted",
|
"strokeStyle": "dotted",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 7,
|
"version": 7,
|
||||||
"versionNonce": 1006504105,
|
"versionNonce": 1723083209,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
|
@ -3835,14 +3835,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -3865,14 +3865,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 760410951,
|
"seed": 747212839,
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"strokeStyle": "dotted",
|
"strokeStyle": "dotted",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 8,
|
"version": 8,
|
||||||
"versionNonce": 1315507081,
|
"versionNonce": 1006504105,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
|
@ -3909,14 +3909,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"strokeStyle": "dotted",
|
"strokeStyle": "dotted",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 640725609,
|
"versionNonce": 1315507081,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -3939,14 +3939,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 760410951,
|
"seed": 747212839,
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"strokeStyle": "dotted",
|
"strokeStyle": "dotted",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 8,
|
"version": 8,
|
||||||
"versionNonce": 1315507081,
|
"versionNonce": 1006504105,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
|
@ -4468,14 +4468,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1116226695,
|
"seed": 2019559783,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 1604849351,
|
"versionNonce": 238820263,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
|
@ -4501,14 +4501,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -4561,14 +4561,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -4605,14 +4605,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -4635,14 +4635,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1116226695,
|
"seed": 2019559783,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 238820263,
|
"versionNonce": 1116226695,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
|
@ -4679,14 +4679,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1116226695,
|
"seed": 2019559783,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 1604849351,
|
"versionNonce": 238820263,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
|
@ -4709,14 +4709,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 2019559783,
|
"versionNonce": 453191,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -6115,14 +6115,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 453191,
|
"seed": 449462985,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 1116226695,
|
"versionNonce": 2019559783,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -6148,14 +6148,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 238820263,
|
"seed": 1116226695,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 1604849351,
|
"versionNonce": 238820263,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -6208,14 +6208,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 453191,
|
"seed": 449462985,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 1116226695,
|
"versionNonce": 2019559783,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -6252,14 +6252,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 453191,
|
"seed": 449462985,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 1116226695,
|
"versionNonce": 2019559783,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -6282,14 +6282,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 238820263,
|
"seed": 1116226695,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 1604849351,
|
"versionNonce": 238820263,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
|
|
@ -287,9 +287,16 @@ const transform = (
|
||||||
keyboardModifiers: KeyboardModifiers = {},
|
keyboardModifiers: KeyboardModifiers = {},
|
||||||
) => {
|
) => {
|
||||||
const elements = Array.isArray(element) ? element : [element];
|
const elements = Array.isArray(element) ? element : [element];
|
||||||
mouse.select(elements);
|
h.setState({
|
||||||
|
selectedElementIds: elements.reduce(
|
||||||
|
(acc, e) => ({
|
||||||
|
...acc,
|
||||||
|
[e.id]: true,
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
});
|
||||||
let handleCoords: TransformHandle | undefined;
|
let handleCoords: TransformHandle | undefined;
|
||||||
|
|
||||||
if (elements.length === 1) {
|
if (elements.length === 1) {
|
||||||
handleCoords = getTransformHandles(
|
handleCoords = getTransformHandles(
|
||||||
elements[0],
|
elements[0],
|
||||||
|
|
|
@ -321,9 +321,9 @@ describe("Test Linear Elements", () => {
|
||||||
fireEvent.click(screen.getByTitle("Round"));
|
fireEvent.click(screen.getByTitle("Round"));
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`10`,
|
`9`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
|
const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
|
||||||
h.elements[0] as ExcalidrawLinearElement,
|
h.elements[0] as ExcalidrawLinearElement,
|
||||||
|
@ -379,9 +379,9 @@ describe("Test Linear Elements", () => {
|
||||||
drag(startPoint, endPoint);
|
drag(startPoint, endPoint);
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`13`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||||
|
|
||||||
expect([line.x, line.y]).toEqual([
|
expect([line.x, line.y]).toEqual([
|
||||||
points[0][0] + deltaX,
|
points[0][0] + deltaX,
|
||||||
|
@ -441,9 +441,9 @@ describe("Test Linear Elements", () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`17`,
|
`16`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||||
|
|
||||||
expect(line.points.length).toEqual(5);
|
expect(line.points.length).toEqual(5);
|
||||||
|
|
||||||
|
@ -492,9 +492,9 @@ describe("Test Linear Elements", () => {
|
||||||
drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
|
drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`13`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
line,
|
line,
|
||||||
|
@ -533,9 +533,9 @@ describe("Test Linear Elements", () => {
|
||||||
drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
|
drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`13`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
line,
|
line,
|
||||||
|
@ -581,9 +581,9 @@ describe("Test Linear Elements", () => {
|
||||||
deletePoint(points[2]);
|
deletePoint(points[2]);
|
||||||
expect(line.points.length).toEqual(3);
|
expect(line.points.length).toEqual(3);
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`19`,
|
`18`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||||
|
|
||||||
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
||||||
line,
|
line,
|
||||||
|
@ -631,9 +631,9 @@ describe("Test Linear Elements", () => {
|
||||||
lastSegmentMidpoint[1] + delta,
|
lastSegmentMidpoint[1] + delta,
|
||||||
]);
|
]);
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`17`,
|
`16`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||||
expect(line.points.length).toEqual(5);
|
expect(line.points.length).toEqual(5);
|
||||||
|
|
||||||
expect((h.elements[0] as ExcalidrawLinearElement).points)
|
expect((h.elements[0] as ExcalidrawLinearElement).points)
|
||||||
|
@ -729,9 +729,9 @@ describe("Test Linear Elements", () => {
|
||||||
drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
|
drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`13`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
line,
|
line,
|
||||||
|
|
|
@ -586,6 +586,7 @@ export type AppClassProperties = {
|
||||||
setOpenDialog: App["setOpenDialog"];
|
setOpenDialog: App["setOpenDialog"];
|
||||||
insertEmbeddableElement: App["insertEmbeddableElement"];
|
insertEmbeddableElement: App["insertEmbeddableElement"];
|
||||||
onMagicframeToolSelect: App["onMagicframeToolSelect"];
|
onMagicframeToolSelect: App["onMagicframeToolSelect"];
|
||||||
|
getElementShape: App["getElementShape"];
|
||||||
getName: App["getName"];
|
getName: App["getName"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -722,7 +723,7 @@ export type Device = Readonly<{
|
||||||
isTouchScreen: boolean;
|
isTouchScreen: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type FrameNameBounds = {
|
export type FrameNameBounds = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
|
|
66
packages/utils/collision.ts
Normal file
66
packages/utils/collision.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import { Point, Polygon, GeometricShape } from "./geometry/shape";
|
||||||
|
import {
|
||||||
|
pointInEllipse,
|
||||||
|
pointInPolygon,
|
||||||
|
pointOnCurve,
|
||||||
|
pointOnEllipse,
|
||||||
|
pointOnLine,
|
||||||
|
pointOnPolycurve,
|
||||||
|
pointOnPolygon,
|
||||||
|
pointOnPolyline,
|
||||||
|
close,
|
||||||
|
} from "./geometry/geometry";
|
||||||
|
|
||||||
|
// check if the given point is considered on the given shape's border
|
||||||
|
export const isPointOnShape = (
|
||||||
|
point: Point,
|
||||||
|
shape: GeometricShape,
|
||||||
|
tolerance = 0,
|
||||||
|
) => {
|
||||||
|
// get the distance from the given point to the given element
|
||||||
|
// check if the distance is within the given epsilon range
|
||||||
|
switch (shape.type) {
|
||||||
|
case "polygon":
|
||||||
|
return pointOnPolygon(point, shape.data, tolerance);
|
||||||
|
case "ellipse":
|
||||||
|
return pointOnEllipse(point, shape.data, tolerance);
|
||||||
|
case "line":
|
||||||
|
return pointOnLine(point, shape.data, tolerance);
|
||||||
|
case "polyline":
|
||||||
|
return pointOnPolyline(point, shape.data, tolerance);
|
||||||
|
case "curve":
|
||||||
|
return pointOnCurve(point, shape.data, tolerance);
|
||||||
|
case "polycurve":
|
||||||
|
return pointOnPolycurve(point, shape.data, tolerance);
|
||||||
|
default:
|
||||||
|
throw Error(`shape ${shape} is not implemented`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// check if the given point is considered inside the element's border
|
||||||
|
export const isPointInShape = (point: Point, shape: GeometricShape) => {
|
||||||
|
switch (shape.type) {
|
||||||
|
case "polygon":
|
||||||
|
return pointInPolygon(point, shape.data);
|
||||||
|
case "line":
|
||||||
|
return false;
|
||||||
|
case "curve":
|
||||||
|
return false;
|
||||||
|
case "ellipse":
|
||||||
|
return pointInEllipse(point, shape.data);
|
||||||
|
case "polyline": {
|
||||||
|
const polygon = close(shape.data.flat()) as Polygon;
|
||||||
|
return pointInPolygon(point, polygon);
|
||||||
|
}
|
||||||
|
case "polycurve": {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw Error(`shape ${shape} is not implemented`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// check if the given element is in the given bounds
|
||||||
|
export const isPointInBounds = (point: Point, bounds: Polygon) => {
|
||||||
|
return pointInPolygon(point, bounds);
|
||||||
|
};
|
249
packages/utils/geometry/geometry.test.ts
Normal file
249
packages/utils/geometry/geometry.test.ts
Normal file
|
@ -0,0 +1,249 @@
|
||||||
|
import {
|
||||||
|
lineIntersectsLine,
|
||||||
|
lineRotate,
|
||||||
|
pointInEllipse,
|
||||||
|
pointInPolygon,
|
||||||
|
pointLeftofLine,
|
||||||
|
pointOnCurve,
|
||||||
|
pointOnEllipse,
|
||||||
|
pointOnLine,
|
||||||
|
pointOnPolygon,
|
||||||
|
pointOnPolyline,
|
||||||
|
pointRightofLine,
|
||||||
|
pointRotate,
|
||||||
|
} from "./geometry";
|
||||||
|
import { Curve, Ellipse, Line, Point, Polygon, Polyline } from "./shape";
|
||||||
|
|
||||||
|
describe("point and line", () => {
|
||||||
|
const line: Line = [
|
||||||
|
[1, 0],
|
||||||
|
[1, 2],
|
||||||
|
];
|
||||||
|
|
||||||
|
it("point on left or right of line", () => {
|
||||||
|
expect(pointLeftofLine([0, 1], line)).toBe(true);
|
||||||
|
expect(pointLeftofLine([1, 1], line)).toBe(false);
|
||||||
|
expect(pointLeftofLine([2, 1], line)).toBe(false);
|
||||||
|
|
||||||
|
expect(pointRightofLine([0, 1], line)).toBe(false);
|
||||||
|
expect(pointRightofLine([1, 1], line)).toBe(false);
|
||||||
|
expect(pointRightofLine([2, 1], line)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("point on the line", () => {
|
||||||
|
expect(pointOnLine([0, 1], line)).toBe(false);
|
||||||
|
expect(pointOnLine([1, 1], line, 0)).toBe(true);
|
||||||
|
expect(pointOnLine([2, 1], line)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("point and polylines", () => {
|
||||||
|
const polyline: Polyline = [
|
||||||
|
[
|
||||||
|
[1, 0],
|
||||||
|
[1, 2],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[1, 2],
|
||||||
|
[2, 2],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[2, 2],
|
||||||
|
[2, 1],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[2, 1],
|
||||||
|
[3, 1],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
it("point on the line", () => {
|
||||||
|
expect(pointOnPolyline([1, 0], polyline)).toBe(true);
|
||||||
|
expect(pointOnPolyline([1, 2], polyline)).toBe(true);
|
||||||
|
expect(pointOnPolyline([2, 2], polyline)).toBe(true);
|
||||||
|
expect(pointOnPolyline([2, 1], polyline)).toBe(true);
|
||||||
|
expect(pointOnPolyline([3, 1], polyline)).toBe(true);
|
||||||
|
|
||||||
|
expect(pointOnPolyline([1, 1], polyline)).toBe(true);
|
||||||
|
expect(pointOnPolyline([2, 1.5], polyline)).toBe(true);
|
||||||
|
expect(pointOnPolyline([2.5, 1], polyline)).toBe(true);
|
||||||
|
|
||||||
|
expect(pointOnPolyline([0, 1], polyline)).toBe(false);
|
||||||
|
expect(pointOnPolyline([2.1, 1.5], polyline)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("point on the line with rotation", () => {
|
||||||
|
const truePoints = [
|
||||||
|
[1, 0],
|
||||||
|
[1, 2],
|
||||||
|
[2, 2],
|
||||||
|
[2, 1],
|
||||||
|
[3, 1],
|
||||||
|
] as Point[];
|
||||||
|
|
||||||
|
truePoints.forEach((point) => {
|
||||||
|
const rotation = Math.random() * 360;
|
||||||
|
const rotatedPoint = pointRotate(point, rotation);
|
||||||
|
const rotatedPolyline: Polyline = polyline.map((line) =>
|
||||||
|
lineRotate(line, rotation, [0, 0]),
|
||||||
|
);
|
||||||
|
expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const falsePoints = [
|
||||||
|
[0, 1],
|
||||||
|
[2.1, 1.5],
|
||||||
|
] as Point[];
|
||||||
|
|
||||||
|
falsePoints.forEach((point) => {
|
||||||
|
const rotation = Math.random() * 360;
|
||||||
|
const rotatedPoint = pointRotate(point, rotation);
|
||||||
|
const rotatedPolyline: Polyline = polyline.map((line) =>
|
||||||
|
lineRotate(line, rotation, [0, 0]),
|
||||||
|
);
|
||||||
|
expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("point and polygon", () => {
|
||||||
|
const polygon: Polygon = [
|
||||||
|
[10, 10],
|
||||||
|
[50, 10],
|
||||||
|
[50, 50],
|
||||||
|
[10, 50],
|
||||||
|
];
|
||||||
|
|
||||||
|
it("point on polygon", () => {
|
||||||
|
expect(pointOnPolygon([30, 10], polygon)).toBe(true);
|
||||||
|
expect(pointOnPolygon([50, 30], polygon)).toBe(true);
|
||||||
|
expect(pointOnPolygon([30, 50], polygon)).toBe(true);
|
||||||
|
expect(pointOnPolygon([10, 30], polygon)).toBe(true);
|
||||||
|
expect(pointOnPolygon([30, 30], polygon)).toBe(false);
|
||||||
|
expect(pointOnPolygon([30, 70], polygon)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("point in polygon", () => {
|
||||||
|
const polygon: Polygon = [
|
||||||
|
[0, 0],
|
||||||
|
[2, 0],
|
||||||
|
[2, 2],
|
||||||
|
[0, 2],
|
||||||
|
];
|
||||||
|
expect(pointInPolygon([1, 1], polygon)).toBe(true);
|
||||||
|
expect(pointInPolygon([3, 3], polygon)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("point and curve", () => {
|
||||||
|
const curve: Curve = [
|
||||||
|
[1.4, 1.65],
|
||||||
|
[1.9, 7.9],
|
||||||
|
[5.9, 1.65],
|
||||||
|
[6.44, 4.84],
|
||||||
|
];
|
||||||
|
|
||||||
|
it("point on curve", () => {
|
||||||
|
expect(pointOnCurve(curve[0], curve)).toBe(true);
|
||||||
|
expect(pointOnCurve(curve[3], curve)).toBe(true);
|
||||||
|
|
||||||
|
expect(pointOnCurve([2, 4], curve, 0.1)).toBe(true);
|
||||||
|
expect(pointOnCurve([4, 4.4], curve, 0.1)).toBe(true);
|
||||||
|
expect(pointOnCurve([5.6, 3.85], curve, 0.1)).toBe(true);
|
||||||
|
|
||||||
|
expect(pointOnCurve([5.6, 4], curve, 0.1)).toBe(false);
|
||||||
|
expect(pointOnCurve(curve[1], curve, 0.1)).toBe(false);
|
||||||
|
expect(pointOnCurve(curve[2], curve, 0.1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("point and ellipse", () => {
|
||||||
|
const ellipse: Ellipse = {
|
||||||
|
center: [0, 0],
|
||||||
|
angle: 0,
|
||||||
|
halfWidth: 2,
|
||||||
|
halfHeight: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("point on ellipse", () => {
|
||||||
|
[
|
||||||
|
[0, 1],
|
||||||
|
[0, -1],
|
||||||
|
[2, 0],
|
||||||
|
[-2, 0],
|
||||||
|
].forEach((point) => {
|
||||||
|
expect(pointOnEllipse(point as Point, ellipse)).toBe(true);
|
||||||
|
});
|
||||||
|
expect(pointOnEllipse([-1.4, 0.7], ellipse, 0.1)).toBe(true);
|
||||||
|
expect(pointOnEllipse([-1.4, 0.71], ellipse, 0.01)).toBe(true);
|
||||||
|
|
||||||
|
expect(pointOnEllipse([1.4, 0.7], ellipse, 0.1)).toBe(true);
|
||||||
|
expect(pointOnEllipse([1.4, 0.71], ellipse, 0.01)).toBe(true);
|
||||||
|
|
||||||
|
expect(pointOnEllipse([1, -0.86], ellipse, 0.1)).toBe(true);
|
||||||
|
expect(pointOnEllipse([1, -0.86], ellipse, 0.01)).toBe(true);
|
||||||
|
|
||||||
|
expect(pointOnEllipse([-1, -0.86], ellipse, 0.1)).toBe(true);
|
||||||
|
expect(pointOnEllipse([-1, -0.86], ellipse, 0.01)).toBe(true);
|
||||||
|
|
||||||
|
expect(pointOnEllipse([-1, 0.8], ellipse)).toBe(false);
|
||||||
|
expect(pointOnEllipse([1, -0.8], ellipse)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("point in ellipse", () => {
|
||||||
|
[
|
||||||
|
[0, 1],
|
||||||
|
[0, -1],
|
||||||
|
[2, 0],
|
||||||
|
[-2, 0],
|
||||||
|
].forEach((point) => {
|
||||||
|
expect(pointInEllipse(point as Point, ellipse)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pointInEllipse([-1, 0.8], ellipse)).toBe(true);
|
||||||
|
expect(pointInEllipse([1, -0.8], ellipse)).toBe(true);
|
||||||
|
|
||||||
|
expect(pointInEllipse([-1, 1], ellipse)).toBe(false);
|
||||||
|
expect(pointInEllipse([-1.4, 0.8], ellipse)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("line and line", () => {
|
||||||
|
const lineA: Line = [
|
||||||
|
[1, 4],
|
||||||
|
[3, 4],
|
||||||
|
];
|
||||||
|
const lineB: Line = [
|
||||||
|
[2, 1],
|
||||||
|
[2, 7],
|
||||||
|
];
|
||||||
|
const lineC: Line = [
|
||||||
|
[1, 8],
|
||||||
|
[3, 8],
|
||||||
|
];
|
||||||
|
const lineD: Line = [
|
||||||
|
[1, 8],
|
||||||
|
[3, 8],
|
||||||
|
];
|
||||||
|
const lineE: Line = [
|
||||||
|
[1, 9],
|
||||||
|
[3, 9],
|
||||||
|
];
|
||||||
|
const lineF: Line = [
|
||||||
|
[1, 2],
|
||||||
|
[3, 4],
|
||||||
|
];
|
||||||
|
const lineG: Line = [
|
||||||
|
[0, 1],
|
||||||
|
[2, 3],
|
||||||
|
];
|
||||||
|
|
||||||
|
it("intersection", () => {
|
||||||
|
expect(lineIntersectsLine(lineA, lineB)).toBe(true);
|
||||||
|
expect(lineIntersectsLine(lineA, lineC)).toBe(false);
|
||||||
|
expect(lineIntersectsLine(lineB, lineC)).toBe(false);
|
||||||
|
expect(lineIntersectsLine(lineC, lineD)).toBe(true);
|
||||||
|
expect(lineIntersectsLine(lineE, lineD)).toBe(false);
|
||||||
|
expect(lineIntersectsLine(lineF, lineG)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
956
packages/utils/geometry/geometry.ts
Normal file
956
packages/utils/geometry/geometry.ts
Normal file
|
@ -0,0 +1,956 @@
|
||||||
|
import { distance2d } from "../../excalidraw/math";
|
||||||
|
import {
|
||||||
|
Point,
|
||||||
|
Line,
|
||||||
|
Polygon,
|
||||||
|
Curve,
|
||||||
|
Ellipse,
|
||||||
|
Polycurve,
|
||||||
|
Polyline,
|
||||||
|
} from "./shape";
|
||||||
|
|
||||||
|
const DEFAULT_THRESHOLD = 10e-5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* utils
|
||||||
|
*/
|
||||||
|
|
||||||
|
// the two vectors are ao and bo
|
||||||
|
export const cross = (a: Point, b: Point, o: Point) => {
|
||||||
|
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isClosed = (polygon: Polygon) => {
|
||||||
|
const first = polygon[0];
|
||||||
|
const last = polygon[polygon.length - 1];
|
||||||
|
return first[0] === last[0] && first[1] === last[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const close = (polygon: Polygon) => {
|
||||||
|
return isClosed(polygon) ? polygon : [...polygon, polygon[0]];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* angles
|
||||||
|
*/
|
||||||
|
|
||||||
|
// convert radians to degress
|
||||||
|
export const angleToDegrees = (angle: number) => {
|
||||||
|
return (angle * 180) / Math.PI;
|
||||||
|
};
|
||||||
|
|
||||||
|
// convert degrees to radians
|
||||||
|
export const angleToRadians = (angle: number) => {
|
||||||
|
return (angle / 180) * Math.PI;
|
||||||
|
};
|
||||||
|
|
||||||
|
// return the angle of reflection given an angle of incidence and a surface angle in degrees
|
||||||
|
export const angleReflect = (incidenceAngle: number, surfaceAngle: number) => {
|
||||||
|
const a = surfaceAngle * 2 - incidenceAngle;
|
||||||
|
return a >= 360 ? a - 360 : a < 0 ? a + 360 : a;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* points
|
||||||
|
*/
|
||||||
|
|
||||||
|
const rotate = (point: Point, angle: number): Point => {
|
||||||
|
return [
|
||||||
|
point[0] * Math.cos(angle) - point[1] * Math.sin(angle),
|
||||||
|
point[0] * Math.sin(angle) + point[1] * Math.cos(angle),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOrigin = (point: Point) => {
|
||||||
|
return point[0] === 0 && point[1] === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// rotate a given point about a given origin at the given angle
|
||||||
|
export const pointRotate = (
|
||||||
|
point: Point,
|
||||||
|
angle: number,
|
||||||
|
origin?: Point,
|
||||||
|
): Point => {
|
||||||
|
const r = angleToRadians(angle);
|
||||||
|
|
||||||
|
if (!origin || isOrigin(origin)) {
|
||||||
|
return rotate(point, r);
|
||||||
|
}
|
||||||
|
return rotate(point.map((c, i) => c - origin[i]) as Point, r).map(
|
||||||
|
(c, i) => c + origin[i],
|
||||||
|
) as Point;
|
||||||
|
};
|
||||||
|
|
||||||
|
// translate a point by an angle (in degrees) and distance
|
||||||
|
export const pointTranslate = (point: Point, angle = 0, distance = 0) => {
|
||||||
|
const r = angleToRadians(angle);
|
||||||
|
return [
|
||||||
|
point[0] + distance * Math.cos(r),
|
||||||
|
point[1] + distance * Math.sin(r),
|
||||||
|
] as Point;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointInverse = (point: Point) => {
|
||||||
|
return [-point[0], -point[1]] as Point;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointAdd = (pointA: Point, pointB: Point): Point => {
|
||||||
|
return [pointA[0] + pointB[0], pointA[1] + pointB[1]];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const distanceToPoint = (p1: Point, p2: Point) => {
|
||||||
|
return distance2d(...p1, ...p2);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lines
|
||||||
|
*/
|
||||||
|
|
||||||
|
// return the angle of a line, in degrees
|
||||||
|
export const lineAngle = (line: Line) => {
|
||||||
|
return angleToDegrees(
|
||||||
|
Math.atan2(line[1][1] - line[0][1], line[1][0] - line[0][0]),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// get the distance between the endpoints of a line segment
|
||||||
|
export const lineLength = (line: Line) => {
|
||||||
|
return Math.sqrt(
|
||||||
|
Math.pow(line[1][0] - line[0][0], 2) + Math.pow(line[1][1] - line[0][1], 2),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// get the midpoint of a line segment
|
||||||
|
export const lineMidpoint = (line: Line) => {
|
||||||
|
return [
|
||||||
|
(line[0][0] + line[1][0]) / 2,
|
||||||
|
(line[0][1] + line[1][1]) / 2,
|
||||||
|
] as Point;
|
||||||
|
};
|
||||||
|
|
||||||
|
// return the coordinates resulting from rotating the given line about an origin by an angle in degrees
|
||||||
|
// note that when the origin is not given, the midpoint of the given line is used as the origin
|
||||||
|
export const lineRotate = (line: Line, angle: number, origin?: Point): Line => {
|
||||||
|
return line.map((point) =>
|
||||||
|
pointRotate(point, angle, origin || lineMidpoint(line)),
|
||||||
|
) as Line;
|
||||||
|
};
|
||||||
|
|
||||||
|
// returns the coordinates resulting from translating a line by an angle in degrees and a distance.
|
||||||
|
export const lineTranslate = (line: Line, angle: number, distance: number) => {
|
||||||
|
return line.map((point) => pointTranslate(point, angle, distance));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lineInterpolate = (line: Line, clamp = false) => {
|
||||||
|
const [[x1, y1], [x2, y2]] = line;
|
||||||
|
return (t: number) => {
|
||||||
|
const t0 = clamp ? (t < 0 ? 0 : t > 1 ? 1 : t) : t;
|
||||||
|
return [(x2 - x1) * t0 + x1, (y2 - y1) * t0 + y1] as Point;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* curves
|
||||||
|
*/
|
||||||
|
function clone(p: Point): Point {
|
||||||
|
return [...p] as Point;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const curveToBezier = (
|
||||||
|
pointsIn: readonly Point[],
|
||||||
|
curveTightness = 0,
|
||||||
|
): Point[] => {
|
||||||
|
const len = pointsIn.length;
|
||||||
|
if (len < 3) {
|
||||||
|
throw new Error("A curve must have at least three points.");
|
||||||
|
}
|
||||||
|
const out: Point[] = [];
|
||||||
|
if (len === 3) {
|
||||||
|
out.push(
|
||||||
|
clone(pointsIn[0]),
|
||||||
|
clone(pointsIn[1]),
|
||||||
|
clone(pointsIn[2]),
|
||||||
|
clone(pointsIn[2]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const points: Point[] = [];
|
||||||
|
points.push(pointsIn[0], pointsIn[0]);
|
||||||
|
for (let i = 1; i < pointsIn.length; i++) {
|
||||||
|
points.push(pointsIn[i]);
|
||||||
|
if (i === pointsIn.length - 1) {
|
||||||
|
points.push(pointsIn[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const b: Point[] = [];
|
||||||
|
const s = 1 - curveTightness;
|
||||||
|
out.push(clone(points[0]));
|
||||||
|
for (let i = 1; i + 2 < points.length; i++) {
|
||||||
|
const cachedVertArray = points[i];
|
||||||
|
b[0] = [cachedVertArray[0], cachedVertArray[1]];
|
||||||
|
b[1] = [
|
||||||
|
cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6,
|
||||||
|
cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6,
|
||||||
|
];
|
||||||
|
b[2] = [
|
||||||
|
points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6,
|
||||||
|
points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6,
|
||||||
|
];
|
||||||
|
b[3] = [points[i + 1][0], points[i + 1][1]];
|
||||||
|
out.push(b[1], b[2], b[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const curveRotate = (curve: Curve, angle: number, origin: Point) => {
|
||||||
|
return curve.map((p) => pointRotate(p, angle, origin));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cubicBezierPoint = (t: number, controlPoints: Curve): Point => {
|
||||||
|
const [p0, p1, p2, p3] = controlPoints;
|
||||||
|
|
||||||
|
const x =
|
||||||
|
Math.pow(1 - t, 3) * p0[0] +
|
||||||
|
3 * Math.pow(1 - t, 2) * t * p1[0] +
|
||||||
|
3 * (1 - t) * Math.pow(t, 2) * p2[0] +
|
||||||
|
Math.pow(t, 3) * p3[0];
|
||||||
|
|
||||||
|
const y =
|
||||||
|
Math.pow(1 - t, 3) * p0[1] +
|
||||||
|
3 * Math.pow(1 - t, 2) * t * p1[1] +
|
||||||
|
3 * (1 - t) * Math.pow(t, 2) * p2[1] +
|
||||||
|
Math.pow(t, 3) * p3[1];
|
||||||
|
|
||||||
|
return [x, y];
|
||||||
|
};
|
||||||
|
|
||||||
|
const solveCubicEquation = (a: number, b: number, c: number, d: number) => {
|
||||||
|
// This function solves the cubic equation ax^3 + bx^2 + cx + d = 0
|
||||||
|
const roots: number[] = [];
|
||||||
|
|
||||||
|
const discriminant =
|
||||||
|
18 * a * b * c * d -
|
||||||
|
4 * Math.pow(b, 3) * d +
|
||||||
|
Math.pow(b, 2) * Math.pow(c, 2) -
|
||||||
|
4 * a * Math.pow(c, 3) -
|
||||||
|
27 * Math.pow(a, 2) * Math.pow(d, 2);
|
||||||
|
|
||||||
|
if (discriminant >= 0) {
|
||||||
|
const C = Math.cbrt((discriminant + Math.sqrt(discriminant)) / 2);
|
||||||
|
const D = Math.cbrt((discriminant - Math.sqrt(discriminant)) / 2);
|
||||||
|
|
||||||
|
const root1 = (-b - C - D) / (3 * a);
|
||||||
|
const root2 = (-b + (C + D) / 2) / (3 * a);
|
||||||
|
const root3 = (-b + (C + D) / 2) / (3 * a);
|
||||||
|
|
||||||
|
roots.push(root1, root2, root3);
|
||||||
|
} else {
|
||||||
|
const realPart = -b / (3 * a);
|
||||||
|
|
||||||
|
const root1 =
|
||||||
|
2 * Math.sqrt(-b / (3 * a)) * Math.cos(Math.acos(realPart) / 3);
|
||||||
|
const root2 =
|
||||||
|
2 *
|
||||||
|
Math.sqrt(-b / (3 * a)) *
|
||||||
|
Math.cos((Math.acos(realPart) + 2 * Math.PI) / 3);
|
||||||
|
const root3 =
|
||||||
|
2 *
|
||||||
|
Math.sqrt(-b / (3 * a)) *
|
||||||
|
Math.cos((Math.acos(realPart) + 4 * Math.PI) / 3);
|
||||||
|
|
||||||
|
roots.push(root1, root2, root3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findClosestParameter = (point: Point, controlPoints: Curve) => {
|
||||||
|
// This function finds the parameter t that minimizes the distance between the point
|
||||||
|
// and any point on the cubic Bezier curve.
|
||||||
|
|
||||||
|
const [p0, p1, p2, p3] = controlPoints;
|
||||||
|
|
||||||
|
// Use the direct formula to find the parameter t
|
||||||
|
const a = p3[0] - 3 * p2[0] + 3 * p1[0] - p0[0];
|
||||||
|
const b = 3 * p2[0] - 6 * p1[0] + 3 * p0[0];
|
||||||
|
const c = 3 * p1[0] - 3 * p0[0];
|
||||||
|
const d = p0[0] - point[0];
|
||||||
|
|
||||||
|
const rootsX = solveCubicEquation(a, b, c, d);
|
||||||
|
|
||||||
|
// Do the same for the y-coordinate
|
||||||
|
const e = p3[1] - 3 * p2[1] + 3 * p1[1] - p0[1];
|
||||||
|
const f = 3 * p2[1] - 6 * p1[1] + 3 * p0[1];
|
||||||
|
const g = 3 * p1[1] - 3 * p0[1];
|
||||||
|
const h = p0[1] - point[1];
|
||||||
|
|
||||||
|
const rootsY = solveCubicEquation(e, f, g, h);
|
||||||
|
|
||||||
|
// Select the real root that is between 0 and 1 (inclusive)
|
||||||
|
const validRootsX = rootsX.filter((root) => root >= 0 && root <= 1);
|
||||||
|
const validRootsY = rootsY.filter((root) => root >= 0 && root <= 1);
|
||||||
|
|
||||||
|
if (validRootsX.length === 0 || validRootsY.length === 0) {
|
||||||
|
// No valid roots found, use the midpoint as a fallback
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose the parameter t that minimizes the distance
|
||||||
|
let minDistance = Infinity;
|
||||||
|
let closestT = 0;
|
||||||
|
|
||||||
|
for (const rootX of validRootsX) {
|
||||||
|
for (const rootY of validRootsY) {
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
(rootX - point[0]) ** 2 + (rootY - point[1]) ** 2,
|
||||||
|
);
|
||||||
|
if (distance < minDistance) {
|
||||||
|
minDistance = distance;
|
||||||
|
closestT = (rootX + rootY) / 2; // Use the average for a smoother result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closestT;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cubicBezierDistance = (point: Point, controlPoints: Curve) => {
|
||||||
|
// Calculate the closest point on the Bezier curve to the given point
|
||||||
|
const t = findClosestParameter(point, controlPoints);
|
||||||
|
|
||||||
|
// Calculate the coordinates of the closest point on the curve
|
||||||
|
const [closestX, closestY] = cubicBezierPoint(t, controlPoints);
|
||||||
|
|
||||||
|
// Calculate the distance between the given point and the closest point on the curve
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
(point[0] - closestX) ** 2 + (point[1] - closestY) ** 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
return distance;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* polygons
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const polygonRotate = (
|
||||||
|
polygon: Polygon,
|
||||||
|
angle: number,
|
||||||
|
origin: Point,
|
||||||
|
) => {
|
||||||
|
return polygon.map((p) => pointRotate(p, angle, origin));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polygonBounds = (polygon: Polygon) => {
|
||||||
|
let xMin = Infinity;
|
||||||
|
let xMax = -Infinity;
|
||||||
|
let yMin = Infinity;
|
||||||
|
let yMax = -Infinity;
|
||||||
|
|
||||||
|
for (let i = 0, l = polygon.length; i < l; i++) {
|
||||||
|
const p = polygon[i];
|
||||||
|
const x = p[0];
|
||||||
|
const y = p[1];
|
||||||
|
|
||||||
|
if (x != null && isFinite(x) && y != null && isFinite(y)) {
|
||||||
|
if (x < xMin) {
|
||||||
|
xMin = x;
|
||||||
|
}
|
||||||
|
if (x > xMax) {
|
||||||
|
xMax = x;
|
||||||
|
}
|
||||||
|
if (y < yMin) {
|
||||||
|
yMin = y;
|
||||||
|
}
|
||||||
|
if (y > yMax) {
|
||||||
|
yMax = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
[xMin, yMin],
|
||||||
|
[xMax, yMax],
|
||||||
|
] as [Point, Point];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polygonCentroid = (vertices: Point[]) => {
|
||||||
|
let a = 0;
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
const l = vertices.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < l; i++) {
|
||||||
|
const s = i === l - 1 ? 0 : i + 1;
|
||||||
|
const v0 = vertices[i];
|
||||||
|
const v1 = vertices[s];
|
||||||
|
const f = v0[0] * v1[1] - v1[0] * v0[1];
|
||||||
|
|
||||||
|
a += f;
|
||||||
|
x += (v0[0] + v1[0]) * f;
|
||||||
|
y += (v0[1] + v1[1]) * f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = a * 3;
|
||||||
|
|
||||||
|
return [x / d, y / d] as Point;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polygonScale = (
|
||||||
|
polygon: Polygon,
|
||||||
|
scale: number,
|
||||||
|
origin?: Point,
|
||||||
|
) => {
|
||||||
|
if (!origin) {
|
||||||
|
origin = polygonCentroid(polygon);
|
||||||
|
}
|
||||||
|
|
||||||
|
const p: Polygon = [];
|
||||||
|
|
||||||
|
for (let i = 0, l = polygon.length; i < l; i++) {
|
||||||
|
const v = polygon[i];
|
||||||
|
const d = lineLength([origin, v]);
|
||||||
|
const a = lineAngle([origin, v]);
|
||||||
|
|
||||||
|
p[i] = pointTranslate(origin, a, d * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polygonScaleX = (
|
||||||
|
polygon: Polygon,
|
||||||
|
scale: number,
|
||||||
|
origin?: Point,
|
||||||
|
) => {
|
||||||
|
if (!origin) {
|
||||||
|
origin = polygonCentroid(polygon);
|
||||||
|
}
|
||||||
|
|
||||||
|
const p: Polygon = [];
|
||||||
|
|
||||||
|
for (let i = 0, l = polygon.length; i < l; i++) {
|
||||||
|
const v = polygon[i];
|
||||||
|
const d = lineLength([origin, v]);
|
||||||
|
const a = lineAngle([origin, v]);
|
||||||
|
const t = pointTranslate(origin, a, d * scale);
|
||||||
|
|
||||||
|
p[i] = [t[0], v[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polygonScaleY = (
|
||||||
|
polygon: Polygon,
|
||||||
|
scale: number,
|
||||||
|
origin?: Point,
|
||||||
|
) => {
|
||||||
|
if (!origin) {
|
||||||
|
origin = polygonCentroid(polygon);
|
||||||
|
}
|
||||||
|
|
||||||
|
const p: Polygon = [];
|
||||||
|
|
||||||
|
for (let i = 0, l = polygon.length; i < l; i++) {
|
||||||
|
const v = polygon[i];
|
||||||
|
const d = lineLength([origin, v]);
|
||||||
|
const a = lineAngle([origin, v]);
|
||||||
|
const t = pointTranslate(origin, a, d * scale);
|
||||||
|
|
||||||
|
p[i] = [v[0], t[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polygonReflectX = (polygon: Polygon, reflectFactor = 1) => {
|
||||||
|
const [[min], [max]] = polygonBounds(polygon);
|
||||||
|
const p: Point[] = [];
|
||||||
|
|
||||||
|
for (let i = 0, l = polygon.length; i < l; i++) {
|
||||||
|
const [x, y] = polygon[i];
|
||||||
|
const r: Point = [min + max - x, y];
|
||||||
|
|
||||||
|
if (reflectFactor === 0) {
|
||||||
|
p[i] = [x, y];
|
||||||
|
} else if (reflectFactor === 1) {
|
||||||
|
p[i] = r;
|
||||||
|
} else {
|
||||||
|
const t = lineInterpolate([[x, y], r]);
|
||||||
|
p[i] = t(Math.max(Math.min(reflectFactor, 1), 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polygonReflectY = (polygon: Polygon, reflectFactor = 1) => {
|
||||||
|
const [[, min], [, max]] = polygonBounds(polygon);
|
||||||
|
const p: Point[] = [];
|
||||||
|
|
||||||
|
for (let i = 0, l = polygon.length; i < l; i++) {
|
||||||
|
const [x, y] = polygon[i];
|
||||||
|
const r: Point = [x, min + max - y];
|
||||||
|
|
||||||
|
if (reflectFactor === 0) {
|
||||||
|
p[i] = [x, y];
|
||||||
|
} else if (reflectFactor === 1) {
|
||||||
|
p[i] = r;
|
||||||
|
} else {
|
||||||
|
const t = lineInterpolate([[x, y], r]);
|
||||||
|
p[i] = t(Math.max(Math.min(reflectFactor, 1), 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polygonTranslate = (
|
||||||
|
polygon: Polygon,
|
||||||
|
angle: number,
|
||||||
|
distance: number,
|
||||||
|
) => {
|
||||||
|
return polygon.map((p) => pointTranslate(p, angle, distance));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ellipses
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ellipseAxes = (ellipse: Ellipse) => {
|
||||||
|
const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight;
|
||||||
|
|
||||||
|
const majorAxis = widthGreaterThanHeight
|
||||||
|
? ellipse.halfWidth * 2
|
||||||
|
: ellipse.halfHeight * 2;
|
||||||
|
const minorAxis = widthGreaterThanHeight
|
||||||
|
? ellipse.halfHeight * 2
|
||||||
|
: ellipse.halfWidth * 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
majorAxis,
|
||||||
|
minorAxis,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ellipseFocusToCenter = (ellipse: Ellipse) => {
|
||||||
|
const { majorAxis, minorAxis } = ellipseAxes(ellipse);
|
||||||
|
|
||||||
|
return Math.sqrt(majorAxis ** 2 - minorAxis ** 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ellipseExtremes = (ellipse: Ellipse) => {
|
||||||
|
const { center, angle } = ellipse;
|
||||||
|
const { majorAxis, minorAxis } = ellipseAxes(ellipse);
|
||||||
|
|
||||||
|
const cos = Math.cos(angle);
|
||||||
|
const sin = Math.sin(angle);
|
||||||
|
|
||||||
|
const sqSum = majorAxis ** 2 + minorAxis ** 2;
|
||||||
|
const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle);
|
||||||
|
|
||||||
|
const yMax = Math.sqrt((sqSum - sqDiff) / 2);
|
||||||
|
const xAtYMax =
|
||||||
|
(yMax * sqSum * sin * cos) /
|
||||||
|
(majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2);
|
||||||
|
|
||||||
|
const xMax = Math.sqrt((sqSum + sqDiff) / 2);
|
||||||
|
const yAtXMax =
|
||||||
|
(xMax * sqSum * sin * cos) /
|
||||||
|
(majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2);
|
||||||
|
|
||||||
|
return [
|
||||||
|
pointAdd([xAtYMax, yMax], center),
|
||||||
|
pointAdd(pointInverse([xAtYMax, yMax]), center),
|
||||||
|
pointAdd([xMax, yAtXMax], center),
|
||||||
|
pointAdd([xMax, yAtXMax], center),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointRelativeToCenter = (
|
||||||
|
point: Point,
|
||||||
|
center: Point,
|
||||||
|
angle: number,
|
||||||
|
): Point => {
|
||||||
|
const translated = pointAdd(point, pointInverse(center));
|
||||||
|
const rotated = pointRotate(translated, -angleToDegrees(angle));
|
||||||
|
|
||||||
|
return rotated;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* relationships
|
||||||
|
*/
|
||||||
|
|
||||||
|
const topPointFirst = (line: Line) => {
|
||||||
|
return line[1][1] > line[0][1] ? line : [line[1], line[0]];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointLeftofLine = (point: Point, line: Line) => {
|
||||||
|
const t = topPointFirst(line);
|
||||||
|
return cross(point, t[1], t[0]) < 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointRightofLine = (point: Point, line: Line) => {
|
||||||
|
const t = topPointFirst(line);
|
||||||
|
return cross(point, t[1], t[0]) > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const distanceToSegment = (point: Point, line: Line) => {
|
||||||
|
const [x, y] = point;
|
||||||
|
const [[x1, y1], [x2, y2]] = line;
|
||||||
|
|
||||||
|
const A = x - x1;
|
||||||
|
const B = y - y1;
|
||||||
|
const C = x2 - x1;
|
||||||
|
const D = y2 - y1;
|
||||||
|
|
||||||
|
const dot = A * C + B * D;
|
||||||
|
const len_sq = C * C + D * D;
|
||||||
|
let param = -1;
|
||||||
|
if (len_sq !== 0) {
|
||||||
|
param = dot / len_sq;
|
||||||
|
}
|
||||||
|
|
||||||
|
let xx;
|
||||||
|
let yy;
|
||||||
|
|
||||||
|
if (param < 0) {
|
||||||
|
xx = x1;
|
||||||
|
yy = y1;
|
||||||
|
} else if (param > 1) {
|
||||||
|
xx = x2;
|
||||||
|
yy = y2;
|
||||||
|
} else {
|
||||||
|
xx = x1 + param * C;
|
||||||
|
yy = y1 + param * D;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = x - xx;
|
||||||
|
const dy = y - yy;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointOnLine = (
|
||||||
|
point: Point,
|
||||||
|
line: Line,
|
||||||
|
threshold = DEFAULT_THRESHOLD,
|
||||||
|
) => {
|
||||||
|
const distance = distanceToSegment(point, line);
|
||||||
|
|
||||||
|
if (distance === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return distance < threshold;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointOnPolyline = (
|
||||||
|
point: Point,
|
||||||
|
polyline: Polyline,
|
||||||
|
threshold = DEFAULT_THRESHOLD,
|
||||||
|
) => {
|
||||||
|
return polyline.some((line) => pointOnLine(point, line, threshold));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lineIntersectsLine = (lineA: Line, lineB: Line) => {
|
||||||
|
const [[a0x, a0y], [a1x, a1y]] = lineA;
|
||||||
|
const [[b0x, b0y], [b1x, b1y]] = lineB;
|
||||||
|
|
||||||
|
// shared points
|
||||||
|
if (a0x === b0x && a0y === b0y) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (a1x === b1x && a1y === b1y) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// point on line
|
||||||
|
if (pointOnLine(lineA[0], lineB) || pointOnLine(lineA[1], lineB)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (pointOnLine(lineB[0], lineA) || pointOnLine(lineB[1], lineA)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const denom = (b1y - b0y) * (a1x - a0x) - (b1x - b0x) * (a1y - a0y);
|
||||||
|
|
||||||
|
if (denom === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaY = a0y - b0y;
|
||||||
|
const deltaX = a0x - b0x;
|
||||||
|
const numer0 = (b1x - b0x) * deltaY - (b1y - b0y) * deltaX;
|
||||||
|
const numer1 = (a1x - a0x) * deltaY - (a1y - a0y) * deltaX;
|
||||||
|
const quotA = numer0 / denom;
|
||||||
|
const quotB = numer1 / denom;
|
||||||
|
|
||||||
|
return quotA > 0 && quotA < 1 && quotB > 0 && quotB < 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lineIntersectsPolygon = (line: Line, polygon: Polygon) => {
|
||||||
|
let intersects = false;
|
||||||
|
const closed = close(polygon);
|
||||||
|
|
||||||
|
for (let i = 0, l = closed.length - 1; i < l; i++) {
|
||||||
|
const v0 = closed[i];
|
||||||
|
const v1 = closed[i + 1];
|
||||||
|
|
||||||
|
if (
|
||||||
|
lineIntersectsLine(line, [v0, v1]) ||
|
||||||
|
(pointOnLine(v0, line) && pointOnLine(v1, line))
|
||||||
|
) {
|
||||||
|
intersects = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intersects;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointInBezierEquation = (
|
||||||
|
p0: Point,
|
||||||
|
p1: Point,
|
||||||
|
p2: Point,
|
||||||
|
p3: Point,
|
||||||
|
[mx, my]: Point,
|
||||||
|
lineThreshold: number,
|
||||||
|
) => {
|
||||||
|
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
|
||||||
|
const equation = (t: number, idx: number) =>
|
||||||
|
Math.pow(1 - t, 3) * p3[idx] +
|
||||||
|
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
||||||
|
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
||||||
|
p0[idx] * Math.pow(t, 3);
|
||||||
|
|
||||||
|
const lineSegmentPoints: Point[] = [];
|
||||||
|
let t = 0;
|
||||||
|
while (t <= 1.0) {
|
||||||
|
const tx = equation(t, 0);
|
||||||
|
const ty = equation(t, 1);
|
||||||
|
|
||||||
|
const diff = Math.sqrt(Math.pow(tx - mx, 2) + Math.pow(ty - my, 2));
|
||||||
|
|
||||||
|
if (diff < lineThreshold) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
lineSegmentPoints.push([tx, ty]);
|
||||||
|
|
||||||
|
t += 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check the distance from line segments to the given point
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cubicBezierEquation = (curve: Curve) => {
|
||||||
|
const [p0, p1, p2, p3] = curve;
|
||||||
|
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
|
||||||
|
return (t: number, idx: number) =>
|
||||||
|
Math.pow(1 - t, 3) * p3[idx] +
|
||||||
|
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
||||||
|
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
||||||
|
p0[idx] * Math.pow(t, 3);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polyLineFromCurve = (curve: Curve, segments = 10): Polyline => {
|
||||||
|
const equation = cubicBezierEquation(curve);
|
||||||
|
let startingPoint = [equation(0, 0), equation(0, 1)] as Point;
|
||||||
|
const lineSegments: Polyline = [];
|
||||||
|
let t = 0;
|
||||||
|
const increment = 1 / segments;
|
||||||
|
|
||||||
|
for (let i = 0; i < segments; i++) {
|
||||||
|
t += increment;
|
||||||
|
if (t <= 1) {
|
||||||
|
const nextPoint: Point = [equation(t, 0), equation(t, 1)];
|
||||||
|
lineSegments.push([startingPoint, nextPoint]);
|
||||||
|
startingPoint = nextPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lineSegments;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointOnCurve = (
|
||||||
|
point: Point,
|
||||||
|
curve: Curve,
|
||||||
|
threshold = DEFAULT_THRESHOLD,
|
||||||
|
) => {
|
||||||
|
return pointOnPolyline(point, polyLineFromCurve(curve), threshold);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointOnPolycurve = (
|
||||||
|
point: Point,
|
||||||
|
polycurve: Polycurve,
|
||||||
|
threshold = DEFAULT_THRESHOLD,
|
||||||
|
) => {
|
||||||
|
return polycurve.some((curve) => pointOnCurve(point, curve, threshold));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointInPolygon = (point: Point, polygon: Polygon) => {
|
||||||
|
const x = point[0];
|
||||||
|
const y = point[1];
|
||||||
|
let inside = false;
|
||||||
|
|
||||||
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||||
|
const xi = polygon[i][0];
|
||||||
|
const yi = polygon[i][1];
|
||||||
|
const xj = polygon[j][0];
|
||||||
|
const yj = polygon[j][1];
|
||||||
|
|
||||||
|
if (
|
||||||
|
((yi > y && yj <= y) || (yi <= y && yj > y)) &&
|
||||||
|
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
|
||||||
|
) {
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inside;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointOnPolygon = (
|
||||||
|
point: Point,
|
||||||
|
polygon: Polygon,
|
||||||
|
threshold = DEFAULT_THRESHOLD,
|
||||||
|
) => {
|
||||||
|
let on = false;
|
||||||
|
const closed = close(polygon);
|
||||||
|
|
||||||
|
for (let i = 0, l = closed.length - 1; i < l; i++) {
|
||||||
|
if (pointOnLine(point, [closed[i], closed[i + 1]], threshold)) {
|
||||||
|
on = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return on;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polygonInPolygon = (polygonA: Polygon, polygonB: Polygon) => {
|
||||||
|
let inside = true;
|
||||||
|
const closed = close(polygonA);
|
||||||
|
|
||||||
|
for (let i = 0, l = closed.length - 1; i < l; i++) {
|
||||||
|
const v0 = closed[i];
|
||||||
|
|
||||||
|
// Points test
|
||||||
|
if (!pointInPolygon(v0, polygonB)) {
|
||||||
|
inside = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lines test
|
||||||
|
if (lineIntersectsPolygon([v0, closed[i + 1]], polygonB)) {
|
||||||
|
inside = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inside;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polygonIntersectPolygon = (
|
||||||
|
polygonA: Polygon,
|
||||||
|
polygonB: Polygon,
|
||||||
|
) => {
|
||||||
|
let intersects = false;
|
||||||
|
let onCount = 0;
|
||||||
|
const closed = close(polygonA);
|
||||||
|
|
||||||
|
for (let i = 0, l = closed.length - 1; i < l; i++) {
|
||||||
|
const v0 = closed[i];
|
||||||
|
const v1 = closed[i + 1];
|
||||||
|
|
||||||
|
if (lineIntersectsPolygon([v0, v1], polygonB)) {
|
||||||
|
intersects = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointOnPolygon(v0, polygonB)) {
|
||||||
|
++onCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onCount === 2) {
|
||||||
|
intersects = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intersects;
|
||||||
|
};
|
||||||
|
|
||||||
|
const distanceToEllipse = (point: Point, ellipse: Ellipse) => {
|
||||||
|
const { angle, halfWidth, halfHeight, center } = ellipse;
|
||||||
|
const a = halfWidth;
|
||||||
|
const b = halfHeight;
|
||||||
|
const [rotatedPointX, rotatedPointY] = pointRelativeToCenter(
|
||||||
|
point,
|
||||||
|
center,
|
||||||
|
angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const px = Math.abs(rotatedPointX);
|
||||||
|
const py = Math.abs(rotatedPointY);
|
||||||
|
|
||||||
|
let tx = 0.707;
|
||||||
|
let ty = 0.707;
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const x = a * tx;
|
||||||
|
const y = b * ty;
|
||||||
|
|
||||||
|
const ex = ((a * a - b * b) * tx ** 3) / a;
|
||||||
|
const ey = ((b * b - a * a) * ty ** 3) / b;
|
||||||
|
|
||||||
|
const rx = x - ex;
|
||||||
|
const ry = y - ey;
|
||||||
|
|
||||||
|
const qx = px - ex;
|
||||||
|
const qy = py - ey;
|
||||||
|
|
||||||
|
const r = Math.hypot(ry, rx);
|
||||||
|
const q = Math.hypot(qy, qx);
|
||||||
|
|
||||||
|
tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
|
||||||
|
ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
|
||||||
|
const t = Math.hypot(ty, tx);
|
||||||
|
tx /= t;
|
||||||
|
ty /= t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [minX, minY] = [
|
||||||
|
a * tx * Math.sign(rotatedPointX),
|
||||||
|
b * ty * Math.sign(rotatedPointY),
|
||||||
|
];
|
||||||
|
|
||||||
|
return distanceToPoint([rotatedPointX, rotatedPointY], [minX, minY]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointOnEllipse = (
|
||||||
|
point: Point,
|
||||||
|
ellipse: Ellipse,
|
||||||
|
threshold = DEFAULT_THRESHOLD,
|
||||||
|
) => {
|
||||||
|
return distanceToEllipse(point, ellipse) <= threshold;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointInEllipse = (point: Point, ellipse: Ellipse) => {
|
||||||
|
const { center, angle, halfWidth, halfHeight } = ellipse;
|
||||||
|
const [rotatedPointX, rotatedPointY] = pointRelativeToCenter(
|
||||||
|
point,
|
||||||
|
center,
|
||||||
|
angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
(rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) +
|
||||||
|
(rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <=
|
||||||
|
1
|
||||||
|
);
|
||||||
|
};
|
278
packages/utils/geometry/shape.ts
Normal file
278
packages/utils/geometry/shape.ts
Normal file
|
@ -0,0 +1,278 @@
|
||||||
|
/**
|
||||||
|
* this file defines pure geometric shapes
|
||||||
|
*
|
||||||
|
* for instance, a cubic bezier curve is specified by its four control points and
|
||||||
|
* an ellipse is defined by its center, angle, semi major axis and semi minor axis
|
||||||
|
* (but in semi-width and semi-height so it's more relevant to Excalidraw)
|
||||||
|
*
|
||||||
|
* the idea with pure shapes is so that we can provide collision and other geoemtric methods not depending on
|
||||||
|
* the specifics of roughjs or elements in Excalidraw; instead, we can focus on the pure shapes themselves
|
||||||
|
*
|
||||||
|
* also included in this file are methods for converting an Excalidraw element or a Drawable from roughjs
|
||||||
|
* to pure shapes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ExcalidrawDiamondElement,
|
||||||
|
ExcalidrawEllipseElement,
|
||||||
|
ExcalidrawEmbeddableElement,
|
||||||
|
ExcalidrawFrameLikeElement,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawIframeElement,
|
||||||
|
ExcalidrawImageElement,
|
||||||
|
ExcalidrawRectangleElement,
|
||||||
|
ExcalidrawSelectionElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
} from "../../excalidraw/element/types";
|
||||||
|
import { angleToDegrees, close, pointAdd, pointRotate } from "./geometry";
|
||||||
|
import { pointsOnBezierCurves } from "points-on-curve";
|
||||||
|
import type { Drawable, Op } from "roughjs/bin/core";
|
||||||
|
|
||||||
|
// a point is specified by its coordinate (x, y)
|
||||||
|
export type Point = [number, number];
|
||||||
|
export type Vector = Point;
|
||||||
|
|
||||||
|
// a line (segment) is defined by two endpoints
|
||||||
|
export type Line = [Point, Point];
|
||||||
|
|
||||||
|
// a polyline (made up term here) is a line consisting of other line segments
|
||||||
|
// this corresponds to a straight line element in the editor but it could also
|
||||||
|
// be used to model other elements
|
||||||
|
export type Polyline = Line[];
|
||||||
|
|
||||||
|
// cubic bezier curve with four control points
|
||||||
|
export type Curve = [Point, Point, Point, Point];
|
||||||
|
|
||||||
|
// a polycurve is a curve consisting of ther curves, this corresponds to a complex
|
||||||
|
// curve on the canvas
|
||||||
|
export type Polycurve = Curve[];
|
||||||
|
|
||||||
|
// a polygon is a closed shape by connecting the given points
|
||||||
|
// rectangles and diamonds are modelled by polygons
|
||||||
|
export type Polygon = Point[];
|
||||||
|
|
||||||
|
// an ellipse is specified by its center, angle, and its major and minor axes
|
||||||
|
// but for the sake of simplicity, we've used halfWidth and halfHeight instead
|
||||||
|
// in replace of semi major and semi minor axes
|
||||||
|
export type Ellipse = {
|
||||||
|
center: Point;
|
||||||
|
angle: number;
|
||||||
|
halfWidth: number;
|
||||||
|
halfHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometricShape =
|
||||||
|
| {
|
||||||
|
type: "line";
|
||||||
|
data: Line;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "polygon";
|
||||||
|
data: Polygon;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "curve";
|
||||||
|
data: Curve;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "ellipse";
|
||||||
|
data: Ellipse;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "polyline";
|
||||||
|
data: Polyline;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "polycurve";
|
||||||
|
data: Polycurve;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RectangularElement =
|
||||||
|
| ExcalidrawRectangleElement
|
||||||
|
| ExcalidrawDiamondElement
|
||||||
|
| ExcalidrawFrameLikeElement
|
||||||
|
| ExcalidrawEmbeddableElement
|
||||||
|
| ExcalidrawImageElement
|
||||||
|
| ExcalidrawIframeElement
|
||||||
|
| ExcalidrawTextElement
|
||||||
|
| ExcalidrawSelectionElement;
|
||||||
|
|
||||||
|
// polygon
|
||||||
|
export const getPolygonShape = (
|
||||||
|
element: RectangularElement,
|
||||||
|
): GeometricShape => {
|
||||||
|
const { angle, width, height, x, y } = element;
|
||||||
|
const angleInDegrees = angleToDegrees(angle);
|
||||||
|
const cx = x + width / 2;
|
||||||
|
const cy = y + height / 2;
|
||||||
|
|
||||||
|
const center: Point = [cx, cy];
|
||||||
|
|
||||||
|
let data: Polygon = [];
|
||||||
|
|
||||||
|
if (element.type === "diamond") {
|
||||||
|
data = [
|
||||||
|
pointRotate([cx, y], angleInDegrees, center),
|
||||||
|
pointRotate([x + width, cy], angleInDegrees, center),
|
||||||
|
pointRotate([cx, y + height], angleInDegrees, center),
|
||||||
|
pointRotate([x, cy], angleInDegrees, center),
|
||||||
|
] as Polygon;
|
||||||
|
} else {
|
||||||
|
data = [
|
||||||
|
pointRotate([x, y], angleInDegrees, center),
|
||||||
|
pointRotate([x + width, y], angleInDegrees, center),
|
||||||
|
pointRotate([x + width, y + height], angleInDegrees, center),
|
||||||
|
pointRotate([x, y + height], angleInDegrees, center),
|
||||||
|
] as Polygon;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "polygon",
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ellipse
|
||||||
|
export const getEllipseShape = (
|
||||||
|
element: ExcalidrawEllipseElement,
|
||||||
|
): GeometricShape => {
|
||||||
|
const { width, height, angle, x, y } = element;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "ellipse",
|
||||||
|
data: {
|
||||||
|
center: [x + width / 2, y + height / 2],
|
||||||
|
angle,
|
||||||
|
halfWidth: width / 2,
|
||||||
|
halfHeight: height / 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurvePathOps = (shape: Drawable): Op[] => {
|
||||||
|
for (const set of shape.sets) {
|
||||||
|
if (set.type === "path") {
|
||||||
|
return set.ops;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shape.sets[0].ops;
|
||||||
|
};
|
||||||
|
|
||||||
|
// linear
|
||||||
|
export const getCurveShape = (
|
||||||
|
roughShape: Drawable,
|
||||||
|
startingPoint: Point = [0, 0],
|
||||||
|
angleInRadian: number,
|
||||||
|
center: Point,
|
||||||
|
): GeometricShape => {
|
||||||
|
const transform = (p: Point) =>
|
||||||
|
pointRotate(
|
||||||
|
[p[0] + startingPoint[0], p[1] + startingPoint[1]],
|
||||||
|
angleToDegrees(angleInRadian),
|
||||||
|
center,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ops = getCurvePathOps(roughShape);
|
||||||
|
const polycurve: Polycurve = [];
|
||||||
|
let p0: Point = [0, 0];
|
||||||
|
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.op === "move") {
|
||||||
|
p0 = transform(op.data as Point);
|
||||||
|
}
|
||||||
|
if (op.op === "bcurveTo") {
|
||||||
|
const p1: Point = transform([op.data[0], op.data[1]]);
|
||||||
|
const p2: Point = transform([op.data[2], op.data[3]]);
|
||||||
|
const p3: Point = transform([op.data[4], op.data[5]]);
|
||||||
|
polycurve.push([p0, p1, p2, p3]);
|
||||||
|
p0 = p3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "polycurve",
|
||||||
|
data: polycurve,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const polylineFromPoints = (points: Point[]) => {
|
||||||
|
let previousPoint = points[0];
|
||||||
|
const polyline: Polyline = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const nextPoint = points[i];
|
||||||
|
polyline.push([previousPoint, nextPoint]);
|
||||||
|
previousPoint = nextPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return polyline;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFreedrawShape = (
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
center: Point,
|
||||||
|
isClosed: boolean = false,
|
||||||
|
): GeometricShape => {
|
||||||
|
const angle = angleToDegrees(element.angle);
|
||||||
|
const transform = (p: Point) =>
|
||||||
|
pointRotate(pointAdd(p, [element.x, element.y] as Point), angle, center);
|
||||||
|
|
||||||
|
const polyline = polylineFromPoints(
|
||||||
|
element.points.map((p) => transform(p as Point)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return isClosed
|
||||||
|
? {
|
||||||
|
type: "polygon",
|
||||||
|
data: close(polyline.flat()) as Polygon,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "polyline",
|
||||||
|
data: polyline,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClosedCurveShape = (
|
||||||
|
roughShape: Drawable,
|
||||||
|
startingPoint: Point = [0, 0],
|
||||||
|
angleInRadian: number,
|
||||||
|
center: Point,
|
||||||
|
): GeometricShape => {
|
||||||
|
const ops = getCurvePathOps(roughShape);
|
||||||
|
const transform = (p: Point) =>
|
||||||
|
pointRotate(
|
||||||
|
[p[0] + startingPoint[0], p[1] + startingPoint[1]],
|
||||||
|
angleToDegrees(angleInRadian),
|
||||||
|
center,
|
||||||
|
);
|
||||||
|
|
||||||
|
const points: Point[] = [];
|
||||||
|
let odd = false;
|
||||||
|
for (const operation of ops) {
|
||||||
|
if (operation.op === "move") {
|
||||||
|
odd = !odd;
|
||||||
|
if (odd) {
|
||||||
|
points.push([operation.data[0], operation.data[1]]);
|
||||||
|
}
|
||||||
|
} else if (operation.op === "bcurveTo") {
|
||||||
|
if (odd) {
|
||||||
|
points.push([operation.data[0], operation.data[1]]);
|
||||||
|
points.push([operation.data[2], operation.data[3]]);
|
||||||
|
points.push([operation.data[4], operation.data[5]]);
|
||||||
|
}
|
||||||
|
} else if (operation.op === "lineTo") {
|
||||||
|
if (odd) {
|
||||||
|
points.push([operation.data[0], operation.data[1]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) =>
|
||||||
|
transform(p),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "polygon",
|
||||||
|
data: polygonPoints,
|
||||||
|
};
|
||||||
|
};
|
Loading…
Add table
Reference in a new issue