Merge remote-tracking branch 'origin/master' into zsviczian-stickynote
This commit is contained in:
+1218
-1231
File diff suppressed because it is too large
Load Diff
@@ -1276,6 +1276,13 @@ export const elementCenterPoint = (
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
if (isLinearElement(element)) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
|
||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||
}
|
||||
|
||||
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||
|
||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isTransparent } from "@excalidraw/common";
|
||||
import { invariant, isTransparent } from "@excalidraw/common";
|
||||
import {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
@@ -34,10 +34,14 @@ import {
|
||||
elementCenterPoint,
|
||||
getCenterForBounds,
|
||||
getCubicBezierCurveBound,
|
||||
getDiamondPoints,
|
||||
getElementBounds,
|
||||
pointInsideBounds,
|
||||
} from "./bounds";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isBindableElement,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isIframeLikeElement,
|
||||
isImageElement,
|
||||
@@ -58,12 +62,17 @@ import { distanceToElement } from "./distance";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
Ordered,
|
||||
} from "./types";
|
||||
|
||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
@@ -94,6 +103,7 @@ export type HitTestArgs = {
|
||||
threshold: number;
|
||||
elementsMap: ElementsMap;
|
||||
frameNameBound?: FrameNameBounds | null;
|
||||
overrideShouldTestInside?: boolean;
|
||||
};
|
||||
|
||||
export const hitElementItself = ({
|
||||
@@ -102,6 +112,7 @@ export const hitElementItself = ({
|
||||
threshold,
|
||||
elementsMap,
|
||||
frameNameBound = null,
|
||||
overrideShouldTestInside = false,
|
||||
}: HitTestArgs) => {
|
||||
// Hit test against a frame's name
|
||||
const hitFrameName = frameNameBound
|
||||
@@ -134,7 +145,9 @@ export const hitElementItself = ({
|
||||
}
|
||||
|
||||
// Do the precise (and relatively costly) hit test
|
||||
const hitElement = shouldTestInside(element)
|
||||
const hitElement = (
|
||||
overrideShouldTestInside ? true : shouldTestInside(element)
|
||||
)
|
||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||
// we would need `onShape` as well to include the "borders"
|
||||
isPointInElement(point, element, elementsMap) ||
|
||||
@@ -193,6 +206,116 @@ export const hitElementBoundText = (
|
||||
return isPointInElement(point, boundTextElement, elementsMap);
|
||||
};
|
||||
|
||||
const bindingBorderTest = (
|
||||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
[x, y]: Readonly<GlobalPoint>,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance: number = 0,
|
||||
): boolean => {
|
||||
const p = pointFrom<GlobalPoint>(x, y);
|
||||
const shouldTestInside =
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
!isFrameLikeElement(element);
|
||||
|
||||
// PERF: Run a cheap test to see if the binding element
|
||||
// is even close to the element
|
||||
const t = Math.max(1, tolerance);
|
||||
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the element is inside a frame, we should clip the element
|
||||
if (element.frameId) {
|
||||
const enclosingFrame = elementsMap.get(element.frameId);
|
||||
if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
|
||||
const enclosingFrameBounds = getElementBounds(
|
||||
enclosingFrame,
|
||||
elementsMap,
|
||||
);
|
||||
if (!pointInsideBounds(p, enclosingFrameBounds)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do the intersection test against the element since it's close enough
|
||||
const intersections = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
lineSegment(elementCenterPoint(element, elementsMap), p),
|
||||
);
|
||||
const distance = distanceToElement(element, elementsMap, p);
|
||||
|
||||
return shouldTestInside
|
||||
? intersections.length === 0 || distance <= tolerance
|
||||
: intersections.length > 0 && distance <= t;
|
||||
};
|
||||
|
||||
export const getAllHoveredElementAtPoint = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
toleranceFn?: (element: ExcalidrawBindableElement) => number,
|
||||
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
const element = elements[index];
|
||||
|
||||
invariant(
|
||||
!element.isDeleted,
|
||||
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||
);
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, toleranceFn?.(element))
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
|
||||
if (!isTransparent(element.backgroundColor)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidateElements;
|
||||
};
|
||||
|
||||
export const getHoveredElementForBinding = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
toleranceFn?: (element: ExcalidrawBindableElement) => number,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const candidateElements = getAllHoveredElementAtPoint(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
toleranceFn,
|
||||
);
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
}
|
||||
|
||||
// Prefer smaller shapes
|
||||
return candidateElements
|
||||
.sort(
|
||||
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
||||
)
|
||||
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Intersect a line with an element for binding test
|
||||
*
|
||||
@@ -554,3 +677,61 @@ export const isPointInElement = (
|
||||
|
||||
return intersections.length % 2 === 1;
|
||||
};
|
||||
|
||||
export const isBindableElementInsideOtherBindable = (
|
||||
innerElement: ExcalidrawBindableElement,
|
||||
outerElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean => {
|
||||
// Get corner points of the inner element based on its type
|
||||
const getCornerPoints = (
|
||||
element: ExcalidrawElement,
|
||||
offset: number,
|
||||
): GlobalPoint[] => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
// Diamond has 4 corner points at the middle of each side
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(x + topX, y + topY - offset), // top
|
||||
pointFrom(x + rightX + offset, y + rightY), // right
|
||||
pointFrom(x + bottomX, y + bottomY + offset), // bottom
|
||||
pointFrom(x + leftX - offset, y + leftY), // left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
}
|
||||
if (element.type === "ellipse") {
|
||||
// For ellipse, test points at the extremes (top, right, bottom, left)
|
||||
const cx = x + width / 2;
|
||||
const cy = y + height / 2;
|
||||
const rx = width / 2;
|
||||
const ry = height / 2;
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(cx, cy - ry - offset), // top
|
||||
pointFrom(cx + rx + offset, cy), // right
|
||||
pointFrom(cx, cy + ry + offset), // bottom
|
||||
pointFrom(cx - rx - offset, cy), // left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
}
|
||||
// Rectangle and other rectangular shapes (image, text, etc.)
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(x - offset, y - offset), // top-left
|
||||
pointFrom(x + width + offset, y - offset), // top-right
|
||||
pointFrom(x + width + offset, y + height + offset), // bottom-right
|
||||
pointFrom(x - offset, y + height + offset), // bottom-left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
};
|
||||
|
||||
const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset
|
||||
const innerCorners = getCornerPoints(innerElement, offset);
|
||||
|
||||
// Check if all corner points of the inner element are inside the outer element
|
||||
return innerCorners.every((corner) =>
|
||||
isPointInElement(corner, outerElement, elementsMap),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
TEXT_AUTOWRAP_THRESHOLD,
|
||||
getGridPoint,
|
||||
getFontString,
|
||||
DRAGGING_THRESHOLD,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
@@ -13,7 +14,7 @@ import type {
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { unbindBindingElement, updateBoundElements } from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
@@ -102,9 +103,26 @@ export const dragSelectedElements = (
|
||||
gridSize,
|
||||
);
|
||||
|
||||
const elementsToUpdateIds = new Set(
|
||||
Array.from(elementsToUpdate, (el) => el.id),
|
||||
);
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
const isArrow = !isArrowElement(element);
|
||||
const isStartBoundElementSelected =
|
||||
isArrow ||
|
||||
(element.startBinding
|
||||
? elementsToUpdateIds.has(element.startBinding.elementId)
|
||||
: false);
|
||||
const isEndBoundElementSelected =
|
||||
isArrow ||
|
||||
(element.endBinding
|
||||
? elementsToUpdateIds.has(element.endBinding.elementId)
|
||||
: false);
|
||||
|
||||
if (!isArrowElement(element)) {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
|
||||
// skip arrow labels since we calculate its position during render
|
||||
const textElement = getBoundTextElement(
|
||||
element,
|
||||
@@ -121,6 +139,33 @@ export const dragSelectedElements = (
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
} else if (
|
||||
// NOTE: Add a little initial drag to the arrow dragging when the arrow
|
||||
// is the single element being dragged to avoid accidentally unbinding
|
||||
// the arrow when the user just wants to select it.
|
||||
|
||||
elementsToUpdate.size > 1 ||
|
||||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
|
||||
DRAGGING_THRESHOLD ||
|
||||
(!element.startBinding && !element.endBinding)
|
||||
) {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
|
||||
const shouldUnbindStart =
|
||||
element.startBinding && !isStartBoundElementSelected;
|
||||
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
|
||||
if (shouldUnbindStart || shouldUnbindEnd) {
|
||||
// NOTE: Moving the bound arrow should unbind it, otherwise we would
|
||||
// have weird situations, like 0 lenght arrow when the user moves
|
||||
// the arrow outside a filled shape suddenly forcing the arrow start
|
||||
// and end point to jump "outside" the shape.
|
||||
if (shouldUnbindStart) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
}
|
||||
if (shouldUnbindEnd) {
|
||||
unbindBindingElement(element, "end", scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
BinaryHeap,
|
||||
invariant,
|
||||
isAnyTrue,
|
||||
tupleToCoors,
|
||||
getSizeFromPoints,
|
||||
isDevEnv,
|
||||
arrayToMap,
|
||||
@@ -27,10 +26,11 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import {
|
||||
bindPointToSnapToElementOutline,
|
||||
FIXED_BINDING_DISTANCE,
|
||||
getHeadingForElbowArrowSnap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
getHoveredElementForBinding,
|
||||
getBindingGap,
|
||||
maxBindingDistance_simple,
|
||||
BASE_BINDING_GAP_ELBOW,
|
||||
} from "./binding";
|
||||
import { distanceToElement } from "./distance";
|
||||
import {
|
||||
@@ -51,8 +51,8 @@ import {
|
||||
type ExcalidrawElbowArrowElement,
|
||||
type NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import { aabbForElement, pointInsideBounds } from "./bounds";
|
||||
import { getHoveredElementForBinding } from "./collision";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { Heading } from "./heading";
|
||||
@@ -63,6 +63,7 @@ import type {
|
||||
FixedPointBinding,
|
||||
FixedSegment,
|
||||
NonDeletedExcalidrawElement,
|
||||
Ordered,
|
||||
} from "./types";
|
||||
|
||||
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
||||
@@ -1243,6 +1244,7 @@ const getElbowArrowData = (
|
||||
const startGlobalPoint = getGlobalPoint(
|
||||
{
|
||||
...arrow,
|
||||
angle: 0,
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
points: nextPoints,
|
||||
@@ -1257,6 +1259,7 @@ const getElbowArrowData = (
|
||||
const endGlobalPoint = getGlobalPoint(
|
||||
{
|
||||
...arrow,
|
||||
angle: 0,
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
points: nextPoints,
|
||||
@@ -1274,6 +1277,7 @@ const getElbowArrowData = (
|
||||
hoveredStartElement,
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
options?.zoom,
|
||||
);
|
||||
const endHeading = getBindPointHeading(
|
||||
endGlobalPoint,
|
||||
@@ -1281,6 +1285,7 @@ const getElbowArrowData = (
|
||||
hoveredEndElement,
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
options?.zoom,
|
||||
);
|
||||
const startPointBounds = [
|
||||
startGlobalPoint[0] - 2,
|
||||
@@ -1301,8 +1306,8 @@ const getElbowArrowData = (
|
||||
offsetFromHeading(
|
||||
startHeading,
|
||||
arrow.startArrowhead
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2,
|
||||
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
|
||||
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -1314,8 +1319,8 @@ const getElbowArrowData = (
|
||||
offsetFromHeading(
|
||||
endHeading,
|
||||
arrow.endArrowhead
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2,
|
||||
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
|
||||
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -1362,8 +1367,8 @@ const getElbowArrowData = (
|
||||
? 0
|
||||
: BASE_PADDING -
|
||||
(arrow.startArrowhead
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2),
|
||||
? BASE_BINDING_GAP_ELBOW * 6
|
||||
: BASE_BINDING_GAP_ELBOW * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap
|
||||
@@ -1378,8 +1383,8 @@ const getElbowArrowData = (
|
||||
? 0
|
||||
: BASE_PADDING -
|
||||
(arrow.endArrowhead
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2),
|
||||
? BASE_BINDING_GAP_ELBOW * 6
|
||||
: BASE_BINDING_GAP_ELBOW * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap,
|
||||
@@ -2239,6 +2244,7 @@ const getBindPointHeading = (
|
||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
): Heading =>
|
||||
getHeadingForElbowArrowSnap(
|
||||
p,
|
||||
@@ -2257,21 +2263,20 @@ const getBindPointHeading = (
|
||||
),
|
||||
origPoint,
|
||||
elementsMap,
|
||||
zoom,
|
||||
);
|
||||
|
||||
const getHoveredElement = (
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
zoom?: AppState["zoom"],
|
||||
) => {
|
||||
return getHoveredElementForBinding(
|
||||
tupleToCoors(origPoint),
|
||||
origPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
zoom,
|
||||
true,
|
||||
true,
|
||||
(element) => maxBindingDistance_simple(zoom),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
PendingExcalidrawElements,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { bindLinearElement } from "./binding";
|
||||
import { bindBindingElement } from "./binding";
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
import {
|
||||
HEADING_DOWN,
|
||||
@@ -448,8 +448,14 @@ const createBindingArrow = (
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
|
||||
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
|
||||
bindBindingElement(
|
||||
bindingArrow,
|
||||
startBindingElement,
|
||||
"orbit",
|
||||
"start",
|
||||
scene,
|
||||
);
|
||||
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
|
||||
|
||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
changedElements.set(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { toIterable } from "@excalidraw/common";
|
||||
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -55,27 +54,6 @@ export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
): element is NonDeleted<T> => !element.isDeleted;
|
||||
|
||||
const _clearElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): ExcalidrawElement[] =>
|
||||
getNonDeletedElements(elements).map((element) =>
|
||||
isLinearElementType(element.type)
|
||||
? { ...element, lastCommittedPoint: null }
|
||||
: element,
|
||||
);
|
||||
|
||||
export const clearElementsForDatabase = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export const clearElementsForExport = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export const clearElementsForLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export * from "./align";
|
||||
export * from "./binding";
|
||||
export * from "./bounds";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,16 +46,13 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
|
||||
// casting to any because can't use `in` operator
|
||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||
const { points, fixedSegments, startBinding, endBinding, fileId } =
|
||||
updates as any;
|
||||
const { points, fixedSegments, fileId } = updates as any;
|
||||
|
||||
if (
|
||||
isElbowArrow(element) &&
|
||||
(Object.keys(updates).length === 0 || // normalization case
|
||||
typeof points !== "undefined" || // repositioning
|
||||
typeof fixedSegments !== "undefined" || // segment fixing
|
||||
typeof startBinding !== "undefined" ||
|
||||
typeof endBinding !== "undefined") // manual binding to element
|
||||
typeof fixedSegments !== "undefined") // segment fixing
|
||||
) {
|
||||
updates = {
|
||||
...updates,
|
||||
|
||||
@@ -562,7 +562,6 @@ export const newFreeDrawElement = (
|
||||
points: opts.points || [],
|
||||
pressures: opts.pressures || [],
|
||||
simulatePressure: opts.simulatePressure,
|
||||
lastCommittedPoint: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -576,7 +575,7 @@ export const newLinearElement = (
|
||||
const element = {
|
||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: null,
|
||||
@@ -611,7 +610,6 @@ export const newArrowElement = <T extends boolean>(
|
||||
return {
|
||||
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
@@ -626,7 +624,6 @@ export const newArrowElement = <T extends boolean>(
|
||||
return {
|
||||
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
|
||||
@@ -98,7 +98,7 @@ const isPendingImageElement = (
|
||||
const shouldResetImageFilter = (
|
||||
element: ExcalidrawElement,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
) => {
|
||||
return (
|
||||
appState.theme === THEME.DARK &&
|
||||
@@ -225,7 +225,7 @@ const generateElementCanvas = (
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom: Zoom,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
): ExcalidrawElementWithCanvas | null => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d")!;
|
||||
@@ -277,7 +277,7 @@ const generateElementCanvas = (
|
||||
context.filter = IMAGE_INVERT_FILTER;
|
||||
}
|
||||
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
|
||||
context.restore();
|
||||
|
||||
@@ -420,7 +420,6 @@ const drawElementOnCanvas = (
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
@@ -566,7 +565,7 @@ const generateElementWithCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
) => {
|
||||
const zoom: Zoom = renderConfig
|
||||
? appState.zoom
|
||||
@@ -623,7 +622,7 @@ const drawElementFromCanvas = (
|
||||
elementWithCanvas: ExcalidrawElementWithCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
allElementsMap: NonDeletedSceneElementsMap,
|
||||
) => {
|
||||
const element = elementWithCanvas.element;
|
||||
@@ -741,7 +740,7 @@ export const renderElement = (
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
) => {
|
||||
const reduceAlphaForSelection =
|
||||
appState.openDialog?.name === "elementLinkSelector" &&
|
||||
@@ -811,7 +810,7 @@ export const renderElement = (
|
||||
context.translate(cx, cy);
|
||||
context.rotate(element.angle);
|
||||
context.translate(-shiftX, -shiftY);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
context.restore();
|
||||
} else {
|
||||
const elementWithCanvas = generateElementWithCanvas(
|
||||
@@ -904,13 +903,7 @@ export const renderElement = (
|
||||
|
||||
tempCanvasContext.translate(-shiftX, -shiftY);
|
||||
|
||||
drawElementOnCanvas(
|
||||
element,
|
||||
tempRc,
|
||||
tempCanvasContext,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
|
||||
|
||||
tempCanvasContext.translate(shiftX, shiftY);
|
||||
|
||||
@@ -949,7 +942,7 @@ export const renderElement = (
|
||||
}
|
||||
|
||||
context.translate(-shiftX, -shiftY);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
@@ -1130,7 +1123,7 @@ export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
||||
last: true,
|
||||
};
|
||||
|
||||
return getStroke(inputPoints as number[][], options) as [number, number][];
|
||||
|
||||
@@ -21,7 +21,11 @@ import type { PointerDownState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
|
||||
import {
|
||||
getArrowLocalFixedPoints,
|
||||
unbindBindingElement,
|
||||
updateBoundElements,
|
||||
} from "./binding";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getCommonBounds,
|
||||
@@ -47,6 +51,7 @@ import {
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBindingElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
@@ -75,7 +80,9 @@ import type {
|
||||
ExcalidrawImageElement,
|
||||
ElementsMap,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawArrowElement,
|
||||
} from "./types";
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
|
||||
// Returns true when transform (resizing/rotation) happened
|
||||
export const transformElements = (
|
||||
@@ -221,7 +228,25 @@ const rotateSingleElement = (
|
||||
}
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
scene.mutateElement(element, { angle });
|
||||
let update: ElementUpdate<NonDeletedExcalidrawElement> = {
|
||||
angle,
|
||||
};
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
update = {
|
||||
...update,
|
||||
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||
|
||||
if (element.startBinding) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
}
|
||||
if (element.endBinding) {
|
||||
unbindBindingElement(element, "end", scene);
|
||||
}
|
||||
}
|
||||
|
||||
scene.mutateElement(element, update);
|
||||
|
||||
if (boundTextElementId) {
|
||||
const textElement =
|
||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||
@@ -395,6 +420,11 @@ const rotateMultipleElements = (
|
||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
|
||||
const rotatedElementsMap = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
NonDeletedExcalidrawElement
|
||||
>(elements.map((element) => [element.id, element]));
|
||||
|
||||
for (const element of elements) {
|
||||
if (!isFrameLikeElement(element)) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
@@ -425,6 +455,19 @@ const rotateMultipleElements = (
|
||||
simultaneouslyUpdated: elements,
|
||||
});
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
if (element.startBinding) {
|
||||
if (!rotatedElementsMap.has(element.startBinding.elementId)) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
}
|
||||
}
|
||||
if (element.endBinding) {
|
||||
if (!rotatedElementsMap.has(element.endBinding.elementId)) {
|
||||
unbindBindingElement(element, "end", scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const boundText = getBoundTextElement(element, elementsMap);
|
||||
if (boundText && !isArrowElement(element)) {
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
@@ -838,13 +881,32 @@ export const resizeSingleElement = (
|
||||
Number.isFinite(newOrigin.x) &&
|
||||
Number.isFinite(newOrigin.y)
|
||||
) {
|
||||
const updates = {
|
||||
let updates: ElementUpdate<ExcalidrawElement> = {
|
||||
...newOrigin,
|
||||
width: Math.abs(nextWidth),
|
||||
height: Math.abs(nextHeight),
|
||||
...rescaledPoints,
|
||||
};
|
||||
|
||||
if (isBindingElement(latestElement)) {
|
||||
if (latestElement.startBinding) {
|
||||
updates = {
|
||||
...updates,
|
||||
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||
|
||||
if (latestElement.startBinding) {
|
||||
unbindBindingElement(latestElement, "start", scene);
|
||||
}
|
||||
}
|
||||
|
||||
if (latestElement.endBinding) {
|
||||
updates = {
|
||||
...updates,
|
||||
endBinding: null,
|
||||
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||
}
|
||||
}
|
||||
|
||||
scene.mutateElement(latestElement, updates, {
|
||||
informMutation: shouldInformMutation,
|
||||
isDragging: false,
|
||||
@@ -862,10 +924,7 @@ export const resizeSingleElement = (
|
||||
shouldMaintainAspectRatio,
|
||||
);
|
||||
|
||||
updateBoundElements(latestElement, scene, {
|
||||
// TODO: confirm with MARK if this actually makes sense
|
||||
newSize: { width: nextWidth, height: nextHeight },
|
||||
});
|
||||
updateBoundElements(latestElement, scene);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1399,20 +1458,36 @@ export const resizeMultipleElements = (
|
||||
}
|
||||
|
||||
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
||||
const resizedElementsMap = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
NonDeletedExcalidrawElement
|
||||
>(elementsAndUpdates.map(({ element }) => [element.id, element]));
|
||||
|
||||
for (const {
|
||||
element,
|
||||
update: { boundTextFontSize, ...update },
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
const { angle } = update;
|
||||
|
||||
scene.mutateElement(element, update);
|
||||
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
newSize: { width, height },
|
||||
});
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
if (element.startBinding) {
|
||||
if (!resizedElementsMap.has(element.startBinding.elementId)) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
}
|
||||
}
|
||||
if (element.endBinding) {
|
||||
if (!resizedElementsMap.has(element.endBinding.elementId)) {
|
||||
unbindBindingElement(element, "end", scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && boundTextFontSize) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
|
||||
@@ -5,17 +5,20 @@ import {
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { SIDE_RESIZING_THRESHOLD } from "@excalidraw/common";
|
||||
import {
|
||||
SIDE_RESIZING_THRESHOLD,
|
||||
type EditorInterface,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { AppState, Device, Zoom } from "@excalidraw/excalidraw/types";
|
||||
import type { AppState, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getTransformHandlesFromCoords,
|
||||
getTransformHandles,
|
||||
getOmitSidesForDevice,
|
||||
getOmitSidesForEditorInterface,
|
||||
canResizeFromSides,
|
||||
} from "./transformHandles";
|
||||
import { isImageElement, isLinearElement } from "./typeChecks";
|
||||
@@ -51,7 +54,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
y: number,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
device: Device,
|
||||
editorInterface: EditorInterface,
|
||||
): MaybeTransformHandleType => {
|
||||
if (!appState.selectedElementIds[element.id]) {
|
||||
return false;
|
||||
@@ -63,7 +66,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
zoom,
|
||||
elementsMap,
|
||||
pointerType,
|
||||
getOmitSidesForDevice(device),
|
||||
getOmitSidesForEditorInterface(editorInterface),
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -86,7 +89,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
return filter[0] as TransformHandleType;
|
||||
}
|
||||
|
||||
if (canResizeFromSides(device)) {
|
||||
if (canResizeFromSides(editorInterface)) {
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
@@ -132,7 +135,7 @@ export const getElementWithTransformHandleType = (
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
elementsMap: ElementsMap,
|
||||
device: Device,
|
||||
editorInterface: EditorInterface,
|
||||
) => {
|
||||
return elements.reduce((result, element) => {
|
||||
if (result) {
|
||||
@@ -146,7 +149,7 @@ export const getElementWithTransformHandleType = (
|
||||
scenePointerY,
|
||||
zoom,
|
||||
pointerType,
|
||||
device,
|
||||
editorInterface,
|
||||
);
|
||||
return transformHandleType ? { element, transformHandleType } : null;
|
||||
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
||||
@@ -160,14 +163,14 @@ export const getTransformHandleTypeFromCoords = <
|
||||
scenePointerY: number,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
device: Device,
|
||||
editorInterface: EditorInterface,
|
||||
): MaybeTransformHandleType => {
|
||||
const transformHandles = getTransformHandlesFromCoords(
|
||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||
0 as Radians,
|
||||
zoom,
|
||||
pointerType,
|
||||
getOmitSidesForDevice(device),
|
||||
getOmitSidesForEditorInterface(editorInterface),
|
||||
);
|
||||
|
||||
const found = Object.keys(transformHandles).find((key) => {
|
||||
@@ -183,7 +186,7 @@ export const getTransformHandleTypeFromCoords = <
|
||||
return found as MaybeTransformHandleType;
|
||||
}
|
||||
|
||||
if (canResizeFromSides(device)) {
|
||||
if (canResizeFromSides(editorInterface)) {
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
isAndroid,
|
||||
isIOS,
|
||||
isMobileOrTablet,
|
||||
type EditorInterface,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
@@ -10,7 +8,6 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
Device,
|
||||
InteractiveCanvasAppState,
|
||||
Zoom,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
@@ -112,20 +109,21 @@ const generateTransformHandle = (
|
||||
return [xx - width / 2, yy - height / 2, width, height];
|
||||
};
|
||||
|
||||
export const canResizeFromSides = (device: Device) => {
|
||||
if (device.viewport.isMobile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (device.isTouchScreen && (isAndroid || isIOS)) {
|
||||
export const canResizeFromSides = (editorInterface: EditorInterface) => {
|
||||
if (
|
||||
editorInterface.formFactor === "phone" &&
|
||||
editorInterface.userAgent.isMobileDevice
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getOmitSidesForDevice = (device: Device) => {
|
||||
if (canResizeFromSides(device)) {
|
||||
export const getOmitSidesForEditorInterface = (
|
||||
editorInterface: EditorInterface,
|
||||
) => {
|
||||
if (canResizeFromSides(editorInterface)) {
|
||||
return DEFAULT_OMIT_SIDES;
|
||||
}
|
||||
|
||||
@@ -330,8 +328,12 @@ export const getTransformHandles = (
|
||||
export const hasBoundingBox = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: InteractiveCanvasAppState,
|
||||
editorInterface: EditorInterface,
|
||||
) => {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
if (
|
||||
appState.selectedLinearElement?.isEditing ||
|
||||
appState.selectedLinearElement?.isDragging
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (elements.length > 1) {
|
||||
@@ -348,5 +350,5 @@ export const hasBoundingBox = (
|
||||
|
||||
// on mobile/tablet we currently don't show bbox because of resize issues
|
||||
// (also prob best for simplicity's sake)
|
||||
return element.points.length > 2 && !isMobileOrTablet();
|
||||
return element.points.length > 2 && !editorInterface.userAgent.isMobileDevice;
|
||||
};
|
||||
|
||||
@@ -28,8 +28,6 @@ import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
PointBinding,
|
||||
FixedPointBinding,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
ExcalidrawLinearElementSubType,
|
||||
} from "./types";
|
||||
@@ -163,7 +161,7 @@ export const isLinearElementType = (
|
||||
export const isBindingElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
): element is ExcalidrawArrowElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
@@ -355,15 +353,6 @@ export const getDefaultRoundnessTypeForElement = (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isFixedPointBinding = (
|
||||
binding: PointBinding | FixedPointBinding,
|
||||
): binding is FixedPointBinding => {
|
||||
return (
|
||||
Object.hasOwn(binding, "fixedPoint") &&
|
||||
(binding as FixedPointBinding).fixedPoint != null
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Move this to @excalidraw/math
|
||||
export const isBounds = (box: unknown): box is Bounds =>
|
||||
Array.isArray(box) &&
|
||||
|
||||
@@ -288,23 +288,22 @@ export type ExcalidrawTextElementWithContainer = {
|
||||
|
||||
export type FixedPoint = [number, number];
|
||||
|
||||
export type PointBinding = {
|
||||
elementId: ExcalidrawBindableElement["id"];
|
||||
focus: number;
|
||||
gap: number;
|
||||
};
|
||||
export type BindMode = "inside" | "orbit" | "skip";
|
||||
|
||||
export type FixedPointBinding = Merge<
|
||||
PointBinding,
|
||||
{
|
||||
// Represents the fixed point binding information in form of a vertical and
|
||||
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
||||
// gives the user selected fixed point by multiplying the bound element width
|
||||
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
||||
// bound element-local point coordinate.
|
||||
fixedPoint: FixedPoint;
|
||||
}
|
||||
>;
|
||||
export type FixedPointBinding = {
|
||||
elementId: ExcalidrawBindableElement["id"];
|
||||
|
||||
// Represents the fixed point binding information in form of a vertical and
|
||||
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
||||
// gives the user selected fixed point by multiplying the bound element width
|
||||
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
||||
// bound element-local point coordinate.
|
||||
fixedPoint: FixedPoint;
|
||||
|
||||
// Determines whether the arrow remains outside the shape or is allowed to
|
||||
// go all the way inside the shape up to the exact fixed point.
|
||||
mode: BindMode;
|
||||
};
|
||||
|
||||
type Index = number;
|
||||
|
||||
@@ -331,9 +330,8 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "line" | "arrow";
|
||||
points: readonly LocalPoint[];
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
startBinding: PointBinding | null;
|
||||
endBinding: PointBinding | null;
|
||||
startBinding: FixedPointBinding | null;
|
||||
endBinding: FixedPointBinding | null;
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
@@ -360,9 +358,9 @@ export type ExcalidrawElbowArrowElement = Merge<
|
||||
ExcalidrawArrowElement,
|
||||
{
|
||||
elbowed: true;
|
||||
fixedSegments: readonly FixedSegment[] | null;
|
||||
startBinding: FixedPointBinding | null;
|
||||
endBinding: FixedPointBinding | null;
|
||||
fixedSegments: readonly FixedSegment[] | null;
|
||||
/**
|
||||
* Marks that the 3rd point should be used as the 2nd point of the arrow in
|
||||
* order to temporarily hide the first segment of the arrow without losing
|
||||
@@ -388,7 +386,6 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||
points: readonly LocalPoint[];
|
||||
pressures: readonly number[];
|
||||
simulatePressure: boolean;
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
}>;
|
||||
|
||||
export type FileId = string & { _brand: "FileId" };
|
||||
|
||||
+152
-15
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
invariant,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
} from "@excalidraw/common";
|
||||
@@ -10,10 +11,17 @@ import {
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromArray,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointTranslate,
|
||||
rectangle,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
@@ -21,11 +29,17 @@ import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getDiamondPoints } from "./bounds";
|
||||
import { elementCenterPoint, getDiamondPoints } from "./bounds";
|
||||
|
||||
import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import { isPointInElement } from "./collision";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isRectangularElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
@@ -400,20 +414,10 @@ export function deconstructDiamondElement(
|
||||
), // TOP
|
||||
];
|
||||
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
const corners = baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(curveOffsetPoints(corner, offset))!,
|
||||
);
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(
|
||||
@@ -481,3 +485,136 @@ export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getDiagonalsForBindableElement = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
// for rectangles, shrink the diagonals a bit because there's something
|
||||
// going on with the focus points around the corners. Ask Mark for details.
|
||||
const OFFSET_PX = element.type === "rectangle" ? 15 : 0;
|
||||
const shrinkSegment = (seg: LineSegment<GlobalPoint>) => {
|
||||
const v = vectorNormalize(vectorFromPoint(seg[1], seg[0]));
|
||||
const offset = vectorScale(v, OFFSET_PX);
|
||||
return lineSegment<GlobalPoint>(
|
||||
pointTranslate(seg[0], offset),
|
||||
pointTranslate(seg[1], vectorScale(offset, -1)),
|
||||
);
|
||||
};
|
||||
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const diagonalOne = shrinkSegment(
|
||||
isRectangularElement(element)
|
||||
? lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
)
|
||||
: lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
);
|
||||
const diagonalTwo = shrinkSegment(
|
||||
isRectangularElement(element)
|
||||
? lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
)
|
||||
: lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return [diagonalOne, diagonalTwo];
|
||||
};
|
||||
|
||||
export const projectFixedPointOntoDiagonal = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint | null => {
|
||||
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
|
||||
if (arrow.width < 3 && arrow.height < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "start" ? 1 : arrow.points.length - 2,
|
||||
elementsMap,
|
||||
);
|
||||
const b = pointFromVector<GlobalPoint>(
|
||||
vectorScale(
|
||||
vectorFromPoint(point, a),
|
||||
2 * pointDistance(a, point) +
|
||||
Math.max(
|
||||
pointDistance(diagonalOne[0], diagonalOne[1]),
|
||||
pointDistance(diagonalTwo[0], diagonalTwo[1]),
|
||||
),
|
||||
),
|
||||
a,
|
||||
);
|
||||
const intersector = lineSegment<GlobalPoint>(point, b);
|
||||
const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector);
|
||||
const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector);
|
||||
const d1 = p1 && pointDistance(a, p1);
|
||||
const d2 = p2 && pointDistance(a, p2);
|
||||
|
||||
let p = null;
|
||||
if (d1 != null && d2 != null) {
|
||||
p = d1 < d2 ? p1 : p2;
|
||||
} else {
|
||||
p = p1 || p2 || null;
|
||||
}
|
||||
|
||||
return p && isPointInElement(p, element, elementsMap) ? p : null;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import { isFrameLikeElement } from "./typeChecks";
|
||||
|
||||
import { isFrameLikeElement, isTextElement } from "./typeChecks";
|
||||
import { getElementsInGroup } from "./groups";
|
||||
|
||||
import { syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { getSelectedElements } from "./selection";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { getHoveredElementForBinding } from "./collision";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
Ordered,
|
||||
OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
|
||||
return element.frameId === frameId || element.id === frameId;
|
||||
@@ -139,6 +146,51 @@ const getContiguousFrameRangeElements = (
|
||||
return allElements.slice(rangeStart, rangeEnd + 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves the arrow element above any bindable elements it intersects with or
|
||||
* hovers over.
|
||||
*/
|
||||
export const moveArrowAboveBindable = (
|
||||
point: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
): readonly OrderedExcalidrawElement[] => {
|
||||
const hoveredElement = getHoveredElementForBinding(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (!hoveredElement) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(hoveredElement, elementsMap);
|
||||
const containerElement = isTextElement(hoveredElement)
|
||||
? getContainerElement(hoveredElement, elementsMap)
|
||||
: null;
|
||||
|
||||
const bindableIds = [
|
||||
hoveredElement.id,
|
||||
boundTextElement?.id,
|
||||
containerElement?.id,
|
||||
].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id);
|
||||
const bindableIdx = elements.findIndex((el) => bindableIds.includes(el.id));
|
||||
const arrowIdx = elements.findIndex((el) => el.id === arrow.id);
|
||||
|
||||
if (arrowIdx !== -1 && bindableIdx !== -1 && arrowIdx < bindableIdx) {
|
||||
const updatedElements = Array.from(elements);
|
||||
const arrow = updatedElements.splice(arrowIdx, 1)[0];
|
||||
updatedElements.splice(bindableIdx, 0, arrow);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns next candidate index that's available to be moved to. Currently that
|
||||
* is a non-deleted element, and not inside a group (unless we're editing it).
|
||||
|
||||
Reference in New Issue
Block a user