mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
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:
parent
5f195694ee
commit
26f67d27ec
38 changed files with 3879 additions and 830 deletions
674
src/element/binding.ts
Normal file
674
src/element/binding.ts
Normal 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;
|
||||
};
|
|
@ -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];
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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")
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue