Allow binding linear elements to other elements (#1899)

* Refactor: simplify linear element type

* Refactor: dedupe scrollbar handling

* First step towards binding - establish relationship and basic test for dragged lines

* Refactor: use zoom from appstate

* Refactor: generalize getElementAtPosition

* Only consider bindable elements in hit test

* Refactor: pull out pieces of hit test for reuse later

* Refactor: pull out diamond from hit test for reuse later

* Refactor: pull out text from hit test for reuse later

* Suggest binding when hovering

* Give shapes in regression test real size

* Give shapes in undo/redo test real size

* Keep bound element highlighted

* Show binding suggestion for multi-point elements

* Move binding to its on module with functions so that I can use it from actions, add support for binding end of multi-point elements

* Use Id instead of ID

* Improve boundary offset for non-squarish elements

* Fix localStorage for binding on linear elements

* Simplify dragging code and fix elements bound twice to the same shape

* Fix binding for rectangles

* Bind both ends at the end of the linear element creation, needed for focus points

* wip

* Refactor: Renames and reshapes for next commit

* Calculate and store focus points and gaps, but dont use them yet

* Focus points for rectangles

* Dont blow up when canceling linear element

* Stop suggesting binding when a non-compatible tool is selected

* Clean up collision code

* Using Geometric Algebra for hit tests

* Correct binding for all shapes

* Constant gap around polygon corners

* Fix rotation handling

* Generalize update and fix hit test for rotated elements

* Handle rotation realtime

* Handle scaling

* Remove vibration when moving bound and binding element together

* Handle simultenous scaling

* Allow binding and unbinding when editing linear elements

* Dont delete binding when the end point wasnt touched

* Bind on enter/escape when editing

* Support multiple suggested bindable elements in preparation for supporting linear elements dragging

* Update binding when moving linear elements

* Update binding when resizing linear elements

* Dont re-render UI on binding hints

* Update both ends when one is moved

* Use distance instead of focus point for binding

* Complicated approach for posterity, ignore this commit

* Revert the complicated approach

* Better focus point strategy, working for all shapes

* Update snapshots

* Dont break binding gap when mirroring shape

* Dont break binding gap when grid mode pushes it inside

* Dont bind draw elements

* Support alt duplication

* Fix alt duplication to

* Support cmd+D duplication

* All copy mechanisms are supported

* Allow binding shapes to arrows, having arrows created first

* Prevent arrows from disappearing for ellipses

* Better binding suggestion highlight for shapes

* Dont suggest second binding for simple elements when editing or moving them

* Dont steal already bound linear elements when moving shapes

* Fix highlighting diamonds and more precisely highlight other shapes

* Highlight linear element edges for binding

* Highlight text binding too

* Handle deletion

* Dont suggest second binding for simple linear elements when creating them

* Dont highlight bound element during creation

* Fix binding for rotated linear elements

* Fix collision check for ellipses

* Dont show suggested bindings for selected pairs

* Bind multi-point linear elements when the tool is switched - important for mobile

* Handle unbinding one of two bound edges correctly

* Rename boundElement in state to startBoundElement

* Dont double account for zoom when rendering binding highlight

* Fix rendering of edited linear element point handles

* Suggest binding when adding new point to a linear element

* Bind when adding a new point to a linear element and dont unbind when moving middle elements

* Handle deleting points

* Add cmd modifier key to disable binding

* Use state for enabling binding, fix not binding for linear elements during creation

* Drop support for binding lines, only arrows are bindable

* Reset binding mode on blur

* Fix not binding lines
This commit is contained in:
Michal Srb 2020-08-08 21:04:15 -07:00 committed by GitHub
parent 5f195694ee
commit 26f67d27ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 3879 additions and 830 deletions

674
src/element/binding.ts Normal file
View file

@ -0,0 +1,674 @@
import {
ExcalidrawLinearElement,
ExcalidrawBindableElement,
NonDeleted,
NonDeletedExcalidrawElement,
PointBinding,
ExcalidrawElement,
} from "./types";
import { getElementAtPosition } from "../scene";
import { AppState } from "../types";
import { isBindableElement, isBindingElement } from "./typeChecks";
import {
bindingBorderTest,
distanceToBindableElement,
maxBindingGap,
determineFocusDistance,
intersectElementWithLine,
determineFocusPoint,
} from "./collision";
import { mutateElement } from "./mutateElement";
import Scene from "../scene/Scene";
import { LinearElementEditor } from "./linearElementEditor";
import { tupleToCoors } from "../utils";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
| SuggestedPointBinding;
export type SuggestedPointBinding = [
NonDeleted<ExcalidrawLinearElement>,
"start" | "end" | "both",
NonDeleted<ExcalidrawBindableElement>,
];
export const isBindingEnabled = (appState: AppState): boolean => {
return appState.isBindingEnabled;
};
export const bindOrUnbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startBindingElement: ExcalidrawBindableElement | null | "keep",
endBindingElement: ExcalidrawBindableElement | null | "keep",
): void => {
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
bindOrUnbindLinearElementEdge(
linearElement,
startBindingElement,
"start",
boundToElementIds,
unboundFromElementIds,
);
bindOrUnbindLinearElementEdge(
linearElement,
endBindingElement,
"end",
boundToElementIds,
unboundFromElementIds,
);
const onlyUnbound = Array.from(unboundFromElementIds).filter(
(id) => !boundToElementIds.has(id),
);
Scene.getScene(linearElement)!
.getNonDeletedElements(onlyUnbound)
.forEach((element) => {
mutateElement(element, {
boundElementIds: element.boundElementIds?.filter(
(id) => id !== linearElement.id,
),
});
});
};
const bindOrUnbindLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
bindableElement: ExcalidrawBindableElement | null | "keep",
startOrEnd: "start" | "end",
// Is mutated
boundToElementIds: Set<ExcalidrawBindableElement["id"]>,
// Is mutated
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
): void => {
if (bindableElement !== "keep") {
if (bindableElement != null) {
bindLinearElement(linearElement, bindableElement, startOrEnd);
boundToElementIds.add(bindableElement.id);
} else {
const unbound = unbindLinearElement(linearElement, startOrEnd);
if (unbound != null) {
unboundFromElementIds.add(unbound);
}
}
}
};
export const bindOrUnbindSelectedElements = (
elements: NonDeleted<ExcalidrawElement>[],
): void => {
elements.forEach((element) => {
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
getElligibleElementForBindingElement(element, "start"),
getElligibleElementForBindingElement(element, "end"),
);
} else if (isBindableElement(element)) {
maybeBindBindableElement(element);
}
});
};
export const maybeBindBindableElement = (
bindableElement: NonDeleted<ExcalidrawBindableElement>,
): void => {
getElligibleElementsForBindableElementAndWhere(
bindableElement,
).forEach(([linearElement, where]) =>
bindOrUnbindLinearElement(
linearElement,
where === "end" ? "keep" : bindableElement,
where === "start" ? "keep" : bindableElement,
),
);
};
export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
scene: Scene,
pointerCoords: { x: number; y: number },
): void => {
if (appState.startBoundElement != null) {
bindLinearElement(linearElement, appState.startBoundElement, "start");
}
const hoveredElement = getHoveredElementForBinding(pointerCoords, scene);
if (hoveredElement != null) {
bindLinearElement(linearElement, hoveredElement, "end");
}
};
const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
): void => {
if (
isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
linearElement,
hoveredElement,
startOrEnd,
)
) {
return;
}
mutateElement(linearElement, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: {
elementId: hoveredElement.id,
...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd),
} as PointBinding,
});
mutateElement(hoveredElement, {
boundElementIds: [
...new Set([...(hoveredElement.boundElementIds ?? []), linearElement.id]),
],
});
};
// Don't bind both ends of a simple segment
const isLinearElementSimpleAndAlreadyBoundOnOppositeEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
): boolean => {
const otherBinding =
linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"];
return isLinearElementSimpleAndAlreadyBound(
linearElement,
otherBinding?.elementId,
bindableElement,
);
};
export const isLinearElementSimpleAndAlreadyBound = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined,
bindableElement: ExcalidrawBindableElement,
): boolean => {
return (
alreadyBoundToId === bindableElement.id && linearElement.points.length < 3
);
};
export const unbindLinearElements = (
elements: NonDeleted<ExcalidrawElement>[],
): void => {
elements.forEach((element) => {
if (isBindingElement(element)) {
bindOrUnbindLinearElement(element, null, null);
}
});
};
const unbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
): ExcalidrawBindableElement["id"] | null => {
const field = startOrEnd === "start" ? "startBinding" : "endBinding";
const binding = linearElement[field];
if (binding == null) {
return null;
}
mutateElement(linearElement, { [field]: null });
return binding.elementId;
};
export const getHoveredElementForBinding = (
pointerCoords: {
x: number;
y: number;
},
scene: Scene,
): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition(
scene.getElements(),
(element) =>
isBindableElement(element) && bindingBorderTest(element, pointerCoords),
);
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
};
const calculateFocusAndGap = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
): { focus: number; gap: number } => {
const direction = startOrEnd === "start" ? -1 : 1;
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
const adjacentPointIndex = edgePointIndex - direction;
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
edgePointIndex,
);
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
adjacentPointIndex,
);
return {
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
};
};
// Supports translating, rotating and scaling `changedElement` with bound
// linear elements.
// Because scaling involves moving the focus points as well, it is
// done before the `changedElement` is updated, and the `newSize` is passed
// in explicitly.
export const updateBoundElements = (
changedElement: NonDeletedExcalidrawElement,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
},
) => {
const boundElementIds = changedElement.boundElementIds ?? [];
if (boundElementIds.length === 0) {
return;
}
const { newSize, simultaneouslyUpdated } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
(Scene.getScene(changedElement)!.getNonDeletedElements(
boundElementIds,
) as NonDeleted<ExcalidrawLinearElement>[]).forEach((linearElement) => {
const bindableElement = changedElement as ExcalidrawBindableElement;
// In case the boundElementIds are stale
if (!doesNeedUpdate(linearElement, bindableElement)) {
return;
}
const startBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
linearElement.startBinding,
newSize,
);
const endBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
linearElement.endBinding,
newSize,
);
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(linearElement.id)) {
mutateElement(linearElement, { startBinding, endBinding });
return;
}
updateBoundPoint(
linearElement,
"start",
startBinding,
changedElement as ExcalidrawBindableElement,
);
updateBoundPoint(
linearElement,
"end",
endBinding,
changedElement as ExcalidrawBindableElement,
);
});
};
const doesNeedUpdate = (
boundElement: NonDeleted<ExcalidrawLinearElement>,
changedElement: ExcalidrawBindableElement,
) => {
return (
boundElement.startBinding?.elementId === changedElement.id ||
boundElement.endBinding?.elementId === changedElement.id
);
};
const getSimultaneouslyUpdatedElementIds = (
simultaneouslyUpdated: readonly ExcalidrawElement[] | undefined,
): Set<ExcalidrawElement["id"]> => {
return new Set((simultaneouslyUpdated || []).map((element) => element.id));
};
const updateBoundPoint = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
binding: PointBinding | null | undefined,
changedElement: ExcalidrawBindableElement,
): void => {
if (
binding == null ||
// We only need to update the other end if this is a 2 point line element
(binding.elementId !== changedElement.id && linearElement.points.length > 2)
) {
return;
}
const bindingElement = Scene.getScene(linearElement)!.getElement(
binding.elementId,
) as ExcalidrawBindableElement | null;
if (bindingElement == null) {
// We're not cleaning up after deleted elements atm., so handle this case
return;
}
const direction = startOrEnd === "start" ? -1 : 1;
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
const adjacentPointIndex = edgePointIndex - direction;
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
adjacentPointIndex,
);
const focusPointAbsolute = determineFocusPoint(
bindingElement,
binding.focus,
adjacentPoint,
);
let newEdgePoint;
// The linear element was not originally pointing inside the bound shape,
// we can point directly at the focus point
if (binding.gap === 0) {
newEdgePoint = focusPointAbsolute;
} else {
const intersections = intersectElementWithLine(
bindingElement,
adjacentPoint,
focusPointAbsolute,
binding.gap,
);
if (intersections.length === 0) {
// This should never happen, since focusPoint should always be
// inside the element, but just in case, bail out
newEdgePoint = focusPointAbsolute;
} else {
// Guaranteed to intersect because focusPoint is always inside the shape
newEdgePoint = intersections[0];
}
}
LinearElementEditor.movePoint(
linearElement,
edgePointIndex,
LinearElementEditor.pointFromAbsoluteCoords(linearElement, newEdgePoint),
{ [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding },
);
};
const maybeCalculateNewGapWhenScaling = (
changedElement: ExcalidrawBindableElement,
currentBinding: PointBinding | null | undefined,
newSize: { width: number; height: number } | undefined,
): PointBinding | null | undefined => {
if (currentBinding == null || newSize == null) {
return currentBinding;
}
const { gap, focus, elementId } = currentBinding;
const { width: newWidth, height: newHeight } = newSize;
const { width, height } = changedElement;
const newGap = Math.max(
1,
Math.min(
maxBindingGap(changedElement, newWidth, newHeight),
gap * (newWidth < newHeight ? newWidth / width : newHeight / height),
),
);
return { elementId, gap: newGap, focus };
};
export const getEligibleElementsForBinding = (
elements: NonDeleted<ExcalidrawElement>[],
): SuggestedBinding[] => {
const includedElementIds = new Set(elements.map(({ id }) => id));
return elements.flatMap((element) =>
isBindingElement(element)
? (getElligibleElementsForBindingElement(
element as NonDeleted<ExcalidrawLinearElement>,
).filter(
(element) => !includedElementIds.has(element.id),
) as SuggestedBinding[])
: isBindableElement(element)
? getElligibleElementsForBindableElementAndWhere(element).filter(
(binding) => !includedElementIds.has(binding[0].id),
)
: [],
);
};
const getElligibleElementsForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
): NonDeleted<ExcalidrawBindableElement>[] => {
return [
getElligibleElementForBindingElement(linearElement, "start"),
getElligibleElementForBindingElement(linearElement, "end"),
].filter(
(element): element is NonDeleted<ExcalidrawBindableElement> =>
element != null,
);
};
const getElligibleElementForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
): NonDeleted<ExcalidrawBindableElement> | null => {
return getElligibleElementForBindingElementAtCoors(
linearElement,
startOrEnd,
getLinearElementEdgeCoors(linearElement, startOrEnd),
);
};
export const getElligibleElementForBindingElementAtCoors = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
pointerCoords: {
x: number;
y: number;
},
): NonDeleted<ExcalidrawBindableElement> | null => {
const bindableElement = getHoveredElementForBinding(
pointerCoords,
Scene.getScene(linearElement)!,
);
if (bindableElement == null) {
return null;
}
// Note: We could push this check inside a version of
// `getHoveredElementForBinding`, but it's unlikely this is needed.
if (
isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
linearElement,
bindableElement,
startOrEnd,
)
) {
return null;
}
return bindableElement;
};
const getLinearElementEdgeCoors = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
): { x: number; y: number } => {
const index = startOrEnd === "start" ? 0 : -1;
return tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(linearElement, index),
);
};
const getElligibleElementsForBindableElementAndWhere = (
bindableElement: NonDeleted<ExcalidrawBindableElement>,
): SuggestedPointBinding[] => {
return Scene.getScene(bindableElement)!
.getElements()
.map((element) => {
if (!isBindingElement(element)) {
return null;
}
const canBindStart = isLinearElementEligibleForNewBindingByBindable(
element,
"start",
bindableElement,
);
const canBindEnd = isLinearElementEligibleForNewBindingByBindable(
element,
"end",
bindableElement,
);
if (!canBindStart && !canBindEnd) {
return null;
}
return [
element,
canBindStart && canBindEnd ? "both" : canBindStart ? "start" : "end",
bindableElement,
];
})
.filter((maybeElement) => maybeElement != null) as SuggestedPointBinding[];
};
const isLinearElementEligibleForNewBindingByBindable = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
bindableElement: NonDeleted<ExcalidrawBindableElement>,
): boolean => {
const existingBinding =
linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"];
return (
existingBinding == null &&
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
linearElement,
bindableElement,
startOrEnd,
) &&
bindingBorderTest(
bindableElement,
getLinearElementEdgeCoors(linearElement, startOrEnd),
)
);
};
// We need to:
// 1: Update elements not selected to point to duplicated elements
// 2: Update duplicated elements to point to other duplicated elements
export const fixBindingsAfterDuplication = (
sceneElements: readonly ExcalidrawElement[],
oldElements: readonly ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
// There are three copying mechanisms: Copy-paste, duplication and alt-drag.
// Only when alt-dragging the new "duplicates" act as the "old", while
// the "old" elements act as the "new copy" - essentially working reverse
// to the other two.
duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined,
): void => {
// First collect all the binding/bindable elements, so we only update
// each once, regardless of whether they were duplicated or not.
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
oldElements.forEach((oldElement) => {
const { boundElementIds } = oldElement;
if (boundElementIds != null && boundElementIds.length > 0) {
boundElementIds.forEach((boundElementId) => {
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElementId)) {
allBoundElementIds.add(boundElementId);
}
});
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
}
if (isBindingElement(oldElement)) {
if (oldElement.startBinding != null) {
const { elementId } = oldElement.startBinding;
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
allBindableElementIds.add(elementId);
}
}
if (oldElement.endBinding != null) {
const { elementId } = oldElement.endBinding;
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
allBindableElementIds.add(elementId);
}
}
if (oldElement.startBinding != null || oldElement.endBinding != null) {
allBoundElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
}
}
});
// Update the linear elements
(sceneElements.filter(({ id }) =>
allBoundElementIds.has(id),
) as ExcalidrawLinearElement[]).forEach((element) => {
const { startBinding, endBinding } = element;
mutateElement(element, {
startBinding: newBindingAfterDuplication(
startBinding,
oldIdToDuplicatedId,
),
endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId),
});
});
// Update the bindable shapes
sceneElements
.filter(({ id }) => allBindableElementIds.has(id))
.forEach((bindableElement) => {
const { boundElementIds } = bindableElement;
if (boundElementIds != null && boundElementIds.length > 0) {
mutateElement(bindableElement, {
boundElementIds: boundElementIds.map(
(boundElementId) =>
oldIdToDuplicatedId.get(boundElementId) ?? boundElementId,
),
});
}
});
};
const newBindingAfterDuplication = (
binding: PointBinding | null,
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
): PointBinding | null => {
if (binding == null) {
return null;
}
const { elementId, focus, gap } = binding;
return {
focus,
gap,
elementId: oldIdToDuplicatedId.get(elementId) ?? elementId,
};
};
export const fixBindingsAfterDeletion = (
sceneElements: readonly ExcalidrawElement[],
deletedElements: readonly ExcalidrawElement[],
): void => {
const deletedElementIds = new Set(
deletedElements.map((element) => element.id),
);
// Non deleted and need an update
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
deletedElements.forEach((deletedElement) => {
if (isBindableElement(deletedElement)) {
deletedElement.boundElementIds?.forEach((id) => {
if (!deletedElementIds.has(id)) {
boundElementIds.add(id);
}
});
}
});
(sceneElements.filter(({ id }) =>
boundElementIds.has(id),
) as ExcalidrawLinearElement[]).forEach(
(element: ExcalidrawLinearElement) => {
const { startBinding, endBinding } = element;
mutateElement(element, {
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
});
},
);
};
const newBindingAfterDeletion = (
binding: PointBinding | null,
deletedElementIds: Set<ExcalidrawElement["id"]>,
): PointBinding | null => {
if (binding == null || deletedElementIds.has(binding.elementId)) {
return null;
}
return binding;
};

View file

@ -10,11 +10,14 @@ import {
import { isLinearElement } from "./typeChecks";
import { rescalePoints } from "../points";
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number];
// If the element is created from right to left, the width is going to be negative
// This set of functions retrieves the absolute position of the 4 points.
export const getElementAbsoluteCoords = (
element: ExcalidrawElement,
): [number, number, number, number] => {
): Bounds => {
if (isLinearElement(element)) {
return getLinearElementAbsoluteCoords(element);
}
@ -26,6 +29,13 @@ export const getElementAbsoluteCoords = (
];
};
export const pointRelativeTo = (
element: ExcalidrawElement,
absoluteCoords: Point,
): Point => {
return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
};
export const getDiamondPoints = (element: ExcalidrawElement) => {
// Here we add +1 to avoid these numbers to be 0
// otherwise rough.js will throw an error complaining about it
@ -35,7 +45,7 @@ export const getDiamondPoints = (element: ExcalidrawElement) => {
const rightY = Math.floor(element.height / 2) + 1;
const bottomX = topX;
const bottomY = element.height;
const leftX = topY;
const leftX = 0;
const leftY = rightY;
return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];

View file

@ -1,23 +1,28 @@
import {
distanceBetweenPointAndSegment,
isPathALoop,
rotate,
isPointInPolygon,
} from "../math";
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 { isPathALoop, isPointInPolygon, rotate } from "../math";
import { pointsOnBezierCurves } from "points-on-curve";
import { NonDeletedExcalidrawElement } from "./types";
import {
getDiamondPoints,
getElementAbsoluteCoords,
getCurvePathOps,
} from "./bounds";
NonDeletedExcalidrawElement,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawRectangleElement,
ExcalidrawDiamondElement,
ExcalidrawTextElement,
ExcalidrawEllipseElement,
NonDeleted,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
import { Point } from "../types";
import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
import { isLinearElement } from "./typeChecks";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
@ -40,179 +45,575 @@ export const hitTest = (
appState: AppState,
x: number,
y: number,
zoom: number,
): boolean => {
// For shapes that are composed of lines, we only enable point-selection when the distance
// of the click is less than x pixels of any of the lines that the shape is composed of
const lineThreshold = 10 / zoom;
// How many pixels off the shape boundary we still consider a hit
const threshold = 10 / appState.zoom;
const check = isElementDraggableFromInside(element, appState)
? isInsideCheck
: isNearCheck;
const point: Point = [x, y];
return hitTestPointAgainstElement({ element, point, threshold, check });
};
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
// reverse rotate the pointer
[x, y] = rotate(x, y, cx, cy, -element.angle);
export const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
{ x, y }: { x: number; y: number },
): boolean => {
const threshold = maxBindingGap(element, element.width, element.height);
const check = isOutsideCheck;
const point: Point = [x, y];
return hitTestPointAgainstElement({ element, point, threshold, check });
};
if (element.type === "ellipse") {
// https://stackoverflow.com/a/46007540/232122
const px = Math.abs(x - element.x - element.width / 2);
const py = Math.abs(y - element.y - element.height / 2);
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(15, Math.min(0.25 * smallerDimension, 80));
};
let tx = 0.707;
let ty = 0.707;
type HitTestArgs = {
element: NonDeletedExcalidrawElement;
point: Point;
threshold: number;
check: (distance: number, threshold: number) => boolean;
};
const a = Math.abs(element.width) / 2;
const b = Math.abs(element.height) / 2;
[0, 1, 2, 3].forEach((x) => {
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;
});
if (isElementDraggableFromInside(element, appState)) {
return (
a * tx - (px - lineThreshold) >= 0 && b * ty - (py - lineThreshold) >= 0
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
switch (args.element.type) {
case "rectangle":
case "text":
case "diamond":
case "ellipse":
const distance = distanceToBindableElement(args.element, args.point);
return args.check(distance, args.threshold);
case "arrow":
case "line":
case "draw":
return hitTestLinear(args);
case "selection":
console.warn(
"This should not happen, we need to investigate why it does.",
);
}
return Math.hypot(a * tx - px, b * ty - py) < lineThreshold;
} else if (element.type === "rectangle") {
if (isElementDraggableFromInside(element, appState)) {
return (
x > x1 - lineThreshold &&
x < x2 + lineThreshold &&
y > y1 - lineThreshold &&
y < y2 + lineThreshold
);
}
// (x1, y1) --A-- (x2, y1)
// |D |B
// (x1, y2) --C-- (x2, y2)
return (
distanceBetweenPointAndSegment(x, y, x1, y1, x2, y1) < lineThreshold || // A
distanceBetweenPointAndSegment(x, y, x2, y1, x2, y2) < lineThreshold || // B
distanceBetweenPointAndSegment(x, y, x2, y2, x1, y2) < lineThreshold || // C
distanceBetweenPointAndSegment(x, y, x1, y2, x1, y1) < lineThreshold // D
);
} else if (element.type === "diamond") {
x -= element.x;
y -= element.y;
let [
topX,
topY,
rightX,
rightY,
bottomX,
bottomY,
leftX,
leftY,
] = getDiamondPoints(element);
if (isElementDraggableFromInside(element, appState)) {
// TODO: remove this when we normalize coordinates globally
if (topY > bottomY) {
[bottomY, topY] = [topY, bottomY];
}
if (rightX < leftX) {
[leftX, rightX] = [rightX, leftX];
}
topY -= lineThreshold;
bottomY += lineThreshold;
leftX -= lineThreshold;
rightX += lineThreshold;
// all deltas should be < 0. Delta > 0 indicates it's on the outside side
// of the line.
//
// (topX, topY)
// D / \ A
// / \
// (leftX, leftY) (rightX, rightY)
// C \ / B
// \ /
// (bottomX, bottomY)
//
// https://stackoverflow.com/a/2752753/927631
return (
// delta from line D
(leftX - topX) * (y - leftY) - (leftX - x) * (topY - leftY) <= 0 &&
// delta from line A
(topX - rightX) * (y - rightY) - (x - rightX) * (topY - rightY) <= 0 &&
// delta from line B
(rightX - bottomX) * (y - bottomY) -
(x - bottomX) * (rightY - bottomY) <=
0 &&
// delta from line C
(bottomX - leftX) * (y - leftY) - (x - leftX) * (bottomY - leftY) <= 0
);
}
return (
distanceBetweenPointAndSegment(x, y, topX, topY, rightX, rightY) <
lineThreshold ||
distanceBetweenPointAndSegment(x, y, rightX, rightY, bottomX, bottomY) <
lineThreshold ||
distanceBetweenPointAndSegment(x, y, bottomX, bottomY, leftX, leftY) <
lineThreshold ||
distanceBetweenPointAndSegment(x, y, leftX, leftY, topX, topY) <
lineThreshold
);
} else if (isLinearElement(element)) {
if (!getShapeForElement(element)) {
return false;
}
const shape = getShapeForElement(element) as Drawable[];
}
};
if (
x < x1 - lineThreshold ||
y < y1 - lineThreshold ||
x > x2 + lineThreshold ||
y > y2 + lineThreshold
) {
return false;
}
export const distanceToBindableElement = (
element: ExcalidrawBindableElement,
point: Point,
): number => {
switch (element.type) {
case "rectangle":
case "text":
return distanceToRectangle(element, point);
case "diamond":
return distanceToDiamond(element, point);
case "ellipse":
return distanceToEllipse(element, point);
}
};
const relX = x - element.x;
const relY = y - element.y;
const isInsideCheck = (distance: number, threshold: number): boolean => {
return distance < threshold;
};
if (isElementDraggableFromInside(element, appState)) {
const hit = shape.some((subshape) =>
hitTestCurveInside(subshape, relX, relY, lineThreshold),
);
if (hit) {
return true;
}
}
const isNearCheck = (distance: number, threshold: number): boolean => {
return Math.abs(distance) < threshold;
};
// hit thest all "subshapes" of the linear element
return shape.some((subshape) =>
hitTestRoughShape(subshape, relX, relY, lineThreshold),
);
} else if (element.type === "text") {
return x >= x1 && x <= x2 && y >= y1 && y <= y2;
} else if (element.type === "selection") {
console.warn("This should not happen, we need to investigate why it does.");
const isOutsideCheck = (distance: number, threshold: number): boolean => {
return 0 <= distance && distance < threshold;
};
const distanceToRectangle = (
element: ExcalidrawRectangleElement | ExcalidrawTextElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
const nearSide =
GAPoint.distanceToLine(pointRel, GALine.vector(hwidth, hheight)) > 0
? GALine.equation(0, 1, -hheight)
: GALine.equation(1, 0, -hwidth);
return GAPoint.distanceToLine(pointRel, nearSide);
};
const distanceToDiamond = (
element: ExcalidrawDiamondElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
const side = GALine.equation(hheight, hwidth, -hheight * hwidth);
return GAPoint.distanceToLine(pointRel, side);
};
const distanceToEllipse = (
element: ExcalidrawEllipseElement,
point: Point,
): number => {
const [pointRel, tangent] = ellipseParamsForTest(element, point);
return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent);
};
const ellipseParamsForTest = (
element: ExcalidrawEllipseElement,
point: Point,
): [GA.Point, GA.Line] => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
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];
};
const hitTestLinear = (args: HitTestArgs): boolean => {
const { element, threshold } = args;
if (!getShapeForElement(element)) {
return false;
}
throw new Error(`Unimplemented type ${element.type}`);
const [point, pointAbs, hwidth, hheight] = pointRelativeToElement(
args.element,
args.point,
);
const side1 = GALine.equation(0, 1, -hheight);
const side2 = GALine.equation(1, 0, -hwidth);
if (
!isInsideCheck(GAPoint.distanceToLine(pointAbs, side1), threshold) ||
!isInsideCheck(GAPoint.distanceToLine(pointAbs, side2), threshold)
) {
return false;
}
const [relX, relY] = GAPoint.toTuple(point);
const shape = getShapeForElement(element) as Drawable[];
if (args.check === isInsideCheck) {
const hit = shape.some((subshape) =>
hitTestCurveInside(subshape, relX, relY, threshold),
);
if (hit) {
return true;
}
}
// hit test all "subshapes" of the linear element
return shape.some((subshape) =>
hitTestRoughShape(subshape, relX, relY, threshold),
);
};
// 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,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter(elementCoords);
// 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 [ax, ay, bx, by] = elementCoords;
const halfWidth = (bx - ax) / 2;
const halfHeight = (by - ay) / 2;
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
};
// Returns point in absolute coordinates
export const pointInAbsoluteCoords = (
element: ExcalidrawElement,
// Point relative to the element position
point: Point,
): Point => {
const [x, y] = point;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x2 - x1) / 2;
const cy = (y2 - y1) / 2;
const [rotatedX, rotatedY] = rotate(x, y, cx, cy, element.angle);
return [element.x + rotatedX, element.y + rotatedY];
};
const relativizationToElementCenter = (
element: ExcalidrawElement,
): GA.Transform => {
const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter(elementCoords);
// 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 = ([ax, ay, bx, by]: Bounds): GA.Point => {
return GA.point((ax + bx) / 2, (ay + by) / 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,
): number => {
const relateToCenter = relativizationToElementCenter(element);
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);
switch (element.type) {
case "rectangle":
case "text":
return c / (hwidth * (nabs + q * mabs));
case "diamond":
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
case "ellipse":
return c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
}
};
export const determineFocusPoint = (
element: ExcalidrawBindableElement,
// The oriented, relative distance from the center of `element` of the
// returned focusPoint
focus: number,
adjecentPoint: Point,
): Point => {
if (focus === 0) {
const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter(elementCoords);
return GAPoint.toTuple(center);
}
const relateToCenter = relativizationToElementCenter(element);
const adjecentPointRel = GATransform.apply(
relateToCenter,
GAPoint.from(adjecentPoint),
);
const reverseRelateToCenter = GA.reverse(relateToCenter);
let point;
switch (element.type) {
case "rectangle":
case "text":
case "diamond":
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,
): Point[] => {
const relateToCenter = relativizationToElementCenter(element);
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 "text":
case "diamond":
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
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
scale: number = 1,
): GA.Point[] => {
const hx = (scale * element.width) / 2;
const hy = (scale * element.height) / 2;
switch (element.type) {
case "rectangle":
case "text":
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 conincides 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;
const n = (-m * px - 1) / py;
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
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
// 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!;
};
const pointInBezierEquation = (

View file

@ -1,20 +1,25 @@
import { NonDeletedExcalidrawElement } from "./types";
import { SHAPES } from "../shapes";
import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { SHAPES } from "../shapes";
import { getPerfectElementSize } from "./sizeHelpers";
import Scene from "../scene/Scene";
import { NonDeletedExcalidrawElement } from "./types";
export const dragSelectedElements = (
selectedElements: NonDeletedExcalidrawElement[],
pointerX: number,
pointerY: number,
scene: Scene,
) => {
const [x1, y1] = getCommonBounds(selectedElements);
const offset = { x: pointerX - x1, y: pointerY - y1 };
selectedElements.forEach((element) => {
mutateElement(element, {
x: pointerX + element.x - x1,
y: pointerY + element.y - y1,
x: element.x + offset.x,
y: element.y + offset.y,
});
updateBoundElements(element, { simultaneouslyUpdated: selectedElements });
});
};

View file

@ -1,10 +1,14 @@
import { ExcalidrawElement, PointerType } from "./types";
import { getElementAbsoluteCoords } from "./bounds";
import { getElementAbsoluteCoords, Bounds } from "./bounds";
import { rotate } from "../math";
type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se" | "rotation";
export type Handlers = Partial<
{ [T in Sides]: [number, number, number, number] }
>;
const handleSizes: { [k in PointerType]: number } = {
mouse: 8,
pen: 16,
@ -61,12 +65,12 @@ const generateHandler = (
};
export const handlerRectanglesFromCoords = (
[x1, y1, x2, y2]: [number, number, number, number],
[x1, y1, x2, y2]: Bounds,
angle: number,
zoom: number,
pointerType: PointerType = "mouse",
omitSides: { [T in Sides]?: boolean } = {},
): Partial<{ [T in Sides]: [number, number, number, number] }> => {
): Handlers => {
const size = handleSizes[pointerType];
const handlerWidth = size / zoom;
const handlerHeight = size / zoom;

View file

@ -2,6 +2,8 @@ import {
NonDeleted,
ExcalidrawLinearElement,
ExcalidrawElement,
PointBinding,
ExcalidrawBindableElement,
} from "./types";
import { distance2d, rotate, isPathALoop, getGridPoint } from "../math";
import { getElementAbsoluteCoords } from ".";
@ -11,6 +13,13 @@ import { mutateElement } from "./mutateElement";
import { SceneHistory } from "../history";
import Scene from "../scene/Scene";
import {
bindOrUnbindLinearElement,
getHoveredElementForBinding,
isBindingEnabled,
} from "./binding";
import { tupleToCoors } from "../utils";
import { isBindingElement } from "./typeChecks";
export class LinearElementEditor {
public elementId: ExcalidrawElement["id"] & {
@ -21,6 +30,8 @@ export class LinearElementEditor {
public isDragging: boolean;
public lastUncommittedPoint: Point | null;
public pointerOffset: { x: number; y: number };
public startBindingElement: ExcalidrawBindableElement | null | "keep";
public endBindingElement: ExcalidrawBindableElement | null | "keep";
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
this.elementId = element.id as string & {
@ -33,6 +44,8 @@ export class LinearElementEditor {
this.lastUncommittedPoint = null;
this.isDragging = false;
this.pointerOffset = { x: 0, y: 0 };
this.startBindingElement = "keep";
this.endBindingElement = "keep";
}
// ---------------------------------------------------------------------------
@ -59,6 +72,10 @@ export class LinearElementEditor {
setState: React.Component<any, AppState>["setState"],
scenePointerX: number,
scenePointerY: number,
maybeSuggestBinding: (
element: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
) => void,
): boolean {
if (!appState.editingLinearElement) {
return false;
@ -88,13 +105,18 @@ export class LinearElementEditor {
appState.gridSize,
);
LinearElementEditor.movePoint(element, activePointIndex, newPoint);
if (isBindingElement(element)) {
maybeSuggestBinding(element, activePointIndex === 0 ? "start" : "end");
}
return true;
}
return false;
}
static handlePointerUp(
event: PointerEvent,
editingLinearElement: LinearElementEditor,
appState: AppState,
): LinearElementEditor {
const { elementId, activePointIndex, isDragging } = editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
@ -102,22 +124,40 @@ export class LinearElementEditor {
return editingLinearElement;
}
let binding = {};
if (
isDragging &&
(activePointIndex === 0 ||
activePointIndex === element.points.length - 1) &&
isPathALoop(element.points)
(activePointIndex === 0 || activePointIndex === element.points.length - 1)
) {
LinearElementEditor.movePoint(
element,
activePointIndex,
activePointIndex === 0
? element.points[element.points.length - 1]
: element.points[0],
);
if (isPathALoop(element.points)) {
LinearElementEditor.movePoint(
element,
activePointIndex,
activePointIndex === 0
? element.points[element.points.length - 1]
: element.points[0],
);
}
const bindingElement = isBindingEnabled(appState)
? getHoveredElementForBinding(
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
activePointIndex!,
),
),
Scene.getScene(element)!,
)
: null;
binding = {
[activePointIndex === 0
? "startBindingElement"
: "endBindingElement"]: bindingElement,
};
}
return {
...editingLinearElement,
...binding,
isDragging: false,
pointerOffset: { x: 0, y: 0 },
};
@ -128,8 +168,7 @@ export class LinearElementEditor {
appState: AppState,
setState: React.Component<any, AppState>["setState"],
history: SceneHistory,
scenePointerX: number,
scenePointerY: number,
scenePointer: { x: number; y: number },
): {
didAddPoint: boolean;
hitElement: ExcalidrawElement | null;
@ -151,14 +190,14 @@ export class LinearElementEditor {
}
if (event.altKey) {
if (!appState.editingLinearElement.lastUncommittedPoint) {
if (appState.editingLinearElement.lastUncommittedPoint == null) {
mutateElement(element, {
points: [
...element.points,
LinearElementEditor.createPointAt(
element,
scenePointerX,
scenePointerY,
scenePointer.x,
scenePointer.y,
appState.gridSize,
),
],
@ -170,6 +209,10 @@ export class LinearElementEditor {
...appState.editingLinearElement,
activePointIndex: element.points.length - 1,
lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(
scenePointer,
Scene.getScene(element)!,
),
},
});
ret.didAddPoint = true;
@ -179,14 +222,31 @@ export class LinearElementEditor {
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
element,
appState.zoom,
scenePointerX,
scenePointerY,
scenePointer.x,
scenePointer.y,
);
// if we clicked on a point, set the element as hitElement otherwise
// it would get deselected if the point is outside the hitbox area
if (clickedPointIndex > -1) {
ret.hitElement = element;
} else {
// You might be wandering why we are storing the binding elements on
// LinearElementEditor and passing them in, insted of calculating them
// from the end points of the `linearElement` - this is to allow disabling
// binding (which needs to happen at the point the user finishes moving
// the point).
const {
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
if (isBindingEnabled(appState) && isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
);
}
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
@ -208,8 +268,8 @@ export class LinearElementEditor {
activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null,
pointerOffset: targetPoint
? {
x: scenePointerX - targetPoint[0],
y: scenePointerY - targetPoint[1],
x: scenePointer.x - targetPoint[0],
y: scenePointer.y - targetPoint[1],
}
: { x: 0, y: 0 },
},
@ -237,7 +297,7 @@ export class LinearElementEditor {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoint(element, points.length - 1, "delete");
}
return editingLinearElement;
return { ...editingLinearElement, lastUncommittedPoint: null };
}
const newPoint = LinearElementEditor.createPointAt(
@ -276,6 +336,40 @@ export class LinearElementEditor {
});
}
static getPointAtIndexGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
indexMaybeFromEnd: number, // -1 for last element
): Point {
const index =
indexMaybeFromEnd < 0
? element.points.length + indexMaybeFromEnd
: indexMaybeFromEnd;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const point = element.points[index];
const { x, y } = element;
return rotate(x + point[0], y + point[1], cx, cy, element.angle);
}
static pointFromAbsoluteCoords(
element: NonDeleted<ExcalidrawLinearElement>,
absoluteCoords: Point,
): Point {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const [x, y] = rotate(
absoluteCoords[0],
absoluteCoords[1],
cx,
cy,
-element.angle,
);
return [x - element.x, y - element.y];
}
static getPointIndexUnderCursor(
element: NonDeleted<ExcalidrawLinearElement>,
zoom: AppState["zoom"],
@ -343,10 +437,23 @@ export class LinearElementEditor {
});
}
static movePointByOffset(
element: NonDeleted<ExcalidrawLinearElement>,
pointIndex: number,
offset: { x: number; y: number },
) {
const [x, y] = element.points[pointIndex];
LinearElementEditor.movePoint(element, pointIndex, [
x + offset.x,
y + offset.y,
]);
}
static movePoint(
element: NonDeleted<ExcalidrawLinearElement>,
pointIndex: number | "new",
targetPosition: Point | "delete",
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
) {
const { points } = element;
@ -412,6 +519,7 @@ export class LinearElementEditor {
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
mutateElement(element, {
...otherUpdates,
points: nextPoints,
x: element.x + rotated[0],
y: element.y + rotated[1],

View file

@ -24,6 +24,7 @@ type ElementConstructorOpts = MarkOptional<
| "height"
| "angle"
| "groupIds"
| "boundElementIds"
| "seed"
| "version"
| "versionNonce"
@ -45,6 +46,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
height = 0,
angle = 0,
groupIds = [],
boundElementIds = null,
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => ({
@ -67,6 +69,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
version: rest.version || 1,
versionNonce: rest.versionNonce ?? 0,
isDeleted: false as false,
boundElementIds,
});
export const newElement = (
@ -215,6 +218,8 @@ export const newLinearElement = (
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
};
};

View file

@ -22,6 +22,7 @@ import {
normalizeResizeHandle,
} from "./resizeTest";
import { measureText, getFontString } from "../utils";
import { updateBoundElements } from "./binding";
const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) {
@ -32,6 +33,7 @@ const normalizeAngle = (angle: number): number => {
type ResizeTestType = ReturnType<typeof resizeTest>;
// Returns true when a resize (scaling/rotation) happened
export const resizeElements = (
resizeHandle: ResizeTestType,
setResizeHandle: (nextResizeHandle: ResizeTestType) => void,
@ -55,6 +57,7 @@ export const resizeElements = (
pointerY,
isRotateWithDiscreteAngle,
);
updateBoundElements(element);
} else if (
isLinearElement(element) &&
element.points.length === 2 &&
@ -404,6 +407,9 @@ const resizeSingleElement = (
const deltaX2 = (x2 - nextX2) / 2;
const deltaY2 = (y2 - nextY2) / 2;
const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight);
updateBoundElements(element, {
newSize: { width: nextWidth, height: nextHeight },
});
const [finalX1, finalY1, finalX2, finalY2] = getResizedElementAbsoluteCoords(
{
...element,
@ -530,6 +536,10 @@ const resizeMultipleElements = (
}
const origCoords = getElementAbsoluteCoords(element);
const rescaledPoints = rescalePointsInElement(element, width, height);
updateBoundElements(element, {
newSize: { width, height },
simultaneouslyUpdated: elements,
});
const finalCoords = getResizedElementAbsoluteCoords(
{
...element,

View file

@ -2,6 +2,7 @@ import {
ExcalidrawElement,
ExcalidrawTextElement,
ExcalidrawLinearElement,
ExcalidrawBindableElement,
} from "./types";
export const isTextElement = (
@ -13,11 +14,38 @@ export const isTextElement = (
export const isLinearElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLinearElement => {
return element != null && isLinearElementType(element.type);
};
export const isLinearElementType = (
elementType: ExcalidrawElement["type"],
): boolean => {
return (
elementType === "arrow" || elementType === "line" || elementType === "draw"
);
};
export const isBindingElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLinearElement => {
return element != null && isBindingElementType(element.type);
};
export const isBindingElementType = (
elementType: ExcalidrawElement["type"],
): boolean => {
return elementType === "arrow";
};
export const isBindableElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawBindableElement => {
return (
element != null &&
(element.type === "arrow" ||
element.type === "line" ||
element.type === "draw")
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "text")
);
};

View file

@ -22,19 +22,33 @@ type _ExcalidrawElementBase = Readonly<{
versionNonce: number;
isDeleted: boolean;
groupIds: readonly GroupId[];
boundElementIds: readonly ExcalidrawLinearElement["id"][] | null;
}>;
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
type: "selection";
};
export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
type: "rectangle";
};
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
type: "diamond";
};
export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
type: "ellipse";
};
/**
* These are elements that don't have any additional properties.
*/
export type ExcalidrawGenericElement =
| ExcalidrawSelectionElement
| (_ExcalidrawElementBase & {
type: "rectangle" | "diamond" | "ellipse";
});
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement;
/**
* ExcalidrawElement should be JSON serializable and (eventually) contain
@ -63,11 +77,25 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
verticalAlign: VerticalAlign;
}>;
export type ExcalidrawBindableElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawTextElement;
export type PointBinding = {
elementId: ExcalidrawBindableElement["id"];
focus: number;
gap: number;
};
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
type: "arrow" | "line" | "draw";
points: readonly Point[];
lastCommittedPoint: Point | null;
startBinding: PointBinding | null;
endBinding: PointBinding | null;
}>;
export type PointerType = "mouse" | "pen" | "touch";