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
|
@ -31,6 +31,7 @@ import {
|
|||
dragSelectedElements,
|
||||
getDragOffsetXY,
|
||||
dragNewElement,
|
||||
hitTest,
|
||||
} from "../element";
|
||||
import {
|
||||
getElementsWithinSelection,
|
||||
|
@ -60,6 +61,8 @@ import {
|
|||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawBindableElement,
|
||||
} from "../element/types";
|
||||
|
||||
import { distance2d, isPathALoop, getGridPoint } from "../math";
|
||||
|
@ -136,7 +139,13 @@ import { generateCollaborationLink, getCollaborationLinkData } from "../data";
|
|||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||
import { unstable_batchedUpdates } from "react-dom";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import {
|
||||
isLinearElement,
|
||||
isLinearElementType,
|
||||
isBindingElement,
|
||||
isBindingElementType,
|
||||
isBindableElement,
|
||||
} from "../element/typeChecks";
|
||||
import { actionFinalize, actionDeleteSelected } from "../actions";
|
||||
import {
|
||||
restoreUsernameFromLocalStorage,
|
||||
|
@ -154,6 +163,19 @@ import {
|
|||
} from "../groups";
|
||||
import { Library } from "../data/library";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
getHoveredElementForBinding,
|
||||
maybeBindLinearElement,
|
||||
getEligibleElementsForBinding,
|
||||
bindOrUnbindSelectedElements,
|
||||
unbindLinearElements,
|
||||
fixBindingsAfterDuplication,
|
||||
maybeBindBindableElement,
|
||||
getElligibleElementForBindingElementAtCoors,
|
||||
fixBindingsAfterDeletion,
|
||||
isLinearElementSimpleAndAlreadyBound,
|
||||
isBindingEnabled,
|
||||
} from "../element/binding";
|
||||
|
||||
/**
|
||||
* @param func handler taking at most single parameter (event).
|
||||
|
@ -407,6 +429,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
|
||||
private onBlur = withBatchedUpdates(() => {
|
||||
isHoldingSpace = false;
|
||||
this.setState({ isBindingEnabled: true });
|
||||
this.saveDebounced();
|
||||
this.saveDebounced.flush();
|
||||
});
|
||||
|
@ -690,7 +713,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
this.broadcastScene(SCENE.UPDATE, /* syncAll */ true);
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
componentDidUpdate(prevProps: ExcalidrawProps) {
|
||||
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
|
||||
const { width: prevWidth, height: prevHeight } = prevProps;
|
||||
const { width: currentWidth, height: currentHeight } = this.props;
|
||||
if (prevWidth !== currentWidth || prevHeight !== currentHeight) {
|
||||
|
@ -714,6 +737,25 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
this.actionManager.executeAction(actionFinalize);
|
||||
});
|
||||
}
|
||||
const { multiElement } = prevState;
|
||||
if (
|
||||
prevState.elementType !== this.state.elementType &&
|
||||
multiElement != null &&
|
||||
isBindingEnabled(this.state) &&
|
||||
isBindingElement(multiElement)
|
||||
) {
|
||||
maybeBindLinearElement(
|
||||
multiElement,
|
||||
this.state,
|
||||
this.scene,
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiElement,
|
||||
-1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const cursorButton: {
|
||||
[id: string]: string | undefined;
|
||||
|
@ -950,16 +992,31 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
const dy = y - elementsCenterY;
|
||||
const groupIdMap = new Map();
|
||||
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const newElements = clipboardElements.map((element) => {
|
||||
return duplicateElement(this.state.editingGroupId, groupIdMap, element, {
|
||||
x: element.x + dx - minX,
|
||||
y: element.y + dy - minY,
|
||||
});
|
||||
const newElement = duplicateElement(
|
||||
this.state.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
{
|
||||
x: element.x + dx - minX,
|
||||
y: element.y + dy - minY,
|
||||
},
|
||||
);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
return newElement;
|
||||
});
|
||||
this.scene.replaceAllElements([
|
||||
const nextElements = [
|
||||
...this.scene.getElementsIncludingDeleted(),
|
||||
...newElements,
|
||||
]);
|
||||
];
|
||||
fixBindingsAfterDuplication(
|
||||
nextElements,
|
||||
clipboardElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
history.resumeRecording();
|
||||
this.setState(
|
||||
selectGroupsForSelectedElements(
|
||||
|
@ -1403,6 +1460,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
if (event[KEYS.CTRL_OR_CMD] && event.keyCode === KEYS.GRID_KEY_CODE) {
|
||||
this.toggleGridMode();
|
||||
}
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
this.setState({ isBindingEnabled: false });
|
||||
}
|
||||
|
||||
if (event.code === "KeyC" && event.altKey && event.shiftKey) {
|
||||
this.copyToClipboardAsPng();
|
||||
|
@ -1511,6 +1571,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
}
|
||||
isHoldingSpace = false;
|
||||
}
|
||||
if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
|
||||
this.setState({ isBindingEnabled: true });
|
||||
}
|
||||
});
|
||||
|
||||
private selectShapeTool(elementType: AppState["elementType"]) {
|
||||
|
@ -1520,6 +1583,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
if (isToolIcon(document.activeElement)) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
if (!isLinearElementType(elementType)) {
|
||||
this.setState({ suggestedBindings: [] });
|
||||
}
|
||||
if (elementType !== "selection") {
|
||||
this.setState({
|
||||
elementType,
|
||||
|
@ -1558,10 +1624,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
gesture.initialScale = null;
|
||||
});
|
||||
|
||||
private setElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
this.scene.replaceAllElements(elements);
|
||||
};
|
||||
|
||||
private handleTextWysiwyg(
|
||||
element: ExcalidrawTextElement,
|
||||
{
|
||||
|
@ -1612,6 +1674,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
[element.id]: true,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
fixBindingsAfterDeletion(this.scene.getElements(), [element]);
|
||||
}
|
||||
if (!isDeleted || isExistingElement) {
|
||||
history.resumeRecording();
|
||||
|
@ -1643,13 +1707,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
x: number,
|
||||
y: number,
|
||||
): NonDeleted<ExcalidrawTextElement> | null {
|
||||
const element = getElementAtPosition(
|
||||
this.scene.getElements(),
|
||||
this.state,
|
||||
x,
|
||||
y,
|
||||
this.state.zoom,
|
||||
);
|
||||
const element = this.getElementAtPosition(x, y);
|
||||
|
||||
if (element && isTextElement(element) && !element.isDeleted) {
|
||||
return element;
|
||||
|
@ -1657,6 +1715,15 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
return null;
|
||||
}
|
||||
|
||||
private getElementAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
): NonDeleted<ExcalidrawElement> | null {
|
||||
return getElementAtPosition(this.scene.getElements(), (element) =>
|
||||
hitTest(element, this.state, x, y),
|
||||
);
|
||||
}
|
||||
|
||||
private startTextEditing = ({
|
||||
sceneX,
|
||||
sceneY,
|
||||
|
@ -1786,14 +1853,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
const selectedGroupIds = getSelectedGroupIds(this.state);
|
||||
|
||||
if (selectedGroupIds.length > 0) {
|
||||
const elements = this.scene.getElements();
|
||||
const hitElement = getElementAtPosition(
|
||||
elements,
|
||||
this.state,
|
||||
sceneX,
|
||||
sceneY,
|
||||
this.state.zoom,
|
||||
);
|
||||
const hitElement = this.getElementAtPosition(sceneX, sceneY);
|
||||
|
||||
const selectedGroupId =
|
||||
hitElement &&
|
||||
|
@ -1873,12 +1933,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
}
|
||||
}
|
||||
|
||||
const { x: scenePointerX, y: scenePointerY } = viewportCoordsToSceneCoords(
|
||||
const scenePointer = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.state,
|
||||
this.canvas,
|
||||
window.devicePixelRatio,
|
||||
);
|
||||
const { x: scenePointerX, y: scenePointerY } = scenePointer;
|
||||
|
||||
if (
|
||||
this.state.editingLinearElement &&
|
||||
|
@ -1894,6 +1955,27 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
if (editingLinearElement !== this.state.editingLinearElement) {
|
||||
this.setState({ editingLinearElement });
|
||||
}
|
||||
if (editingLinearElement.lastUncommittedPoint != null) {
|
||||
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||
} else {
|
||||
this.setState({ suggestedBindings: [] });
|
||||
}
|
||||
}
|
||||
|
||||
if (isBindingElementType(this.state.elementType)) {
|
||||
// Hovering with a selected tool or creating new linear element via click
|
||||
// and point
|
||||
const { draggingElement } = this.state;
|
||||
if (isBindingElement(draggingElement)) {
|
||||
this.maybeSuggestBindingForLinearElementAtCursor(
|
||||
draggingElement,
|
||||
"end",
|
||||
scenePointer,
|
||||
this.state.startBoundElement,
|
||||
);
|
||||
} else {
|
||||
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.multiElement) {
|
||||
|
@ -1954,6 +2036,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -2003,13 +2086,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
return;
|
||||
}
|
||||
}
|
||||
const hitElement = getElementAtPosition(
|
||||
elements,
|
||||
this.state,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
this.state.zoom,
|
||||
);
|
||||
const hitElement = this.getElementAtPosition(scenePointerX, scenePointerY);
|
||||
if (this.state.elementType === "text") {
|
||||
document.documentElement.style.cursor = isTextElement(hitElement)
|
||||
? CURSOR_TYPE.TEXT
|
||||
|
@ -2328,24 +2405,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (pointerDownState.scrollbars.isOverHorizontal) {
|
||||
const x = event.clientX;
|
||||
const dx = x - pointerDownState.lastCoords.x;
|
||||
this.setState({
|
||||
scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
|
||||
});
|
||||
pointerDownState.lastCoords.x = x;
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointerDownState.scrollbars.isOverVertical) {
|
||||
const y = event.clientY;
|
||||
const dy = y - pointerDownState.lastCoords.y;
|
||||
this.setState({
|
||||
scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
|
||||
});
|
||||
pointerDownState.lastCoords.y = y;
|
||||
}
|
||||
this.handlePointerMoveOverScrollbars(event, pointerDownState);
|
||||
});
|
||||
|
||||
const onPointerUp = withBatchedUpdates(() => {
|
||||
|
@ -2440,8 +2500,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
this.state,
|
||||
(appState) => this.setState(appState),
|
||||
history,
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
pointerDownState.origin,
|
||||
);
|
||||
if (ret.hitElement) {
|
||||
pointerDownState.hit.element = ret.hitElement;
|
||||
|
@ -2454,12 +2513,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
// hitElement may already be set above, so check first
|
||||
pointerDownState.hit.element =
|
||||
pointerDownState.hit.element ??
|
||||
getElementAtPosition(
|
||||
elements,
|
||||
this.state,
|
||||
this.getElementAtPosition(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
this.state.zoom,
|
||||
);
|
||||
|
||||
this.maybeClearSelectionWhenHittingElement(
|
||||
|
@ -2544,7 +2600,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
|
||||
private handleLinearElementOnPointerDown = (
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
elementType: "draw" | "line" | "arrow",
|
||||
elementType: ExcalidrawLinearElement["type"],
|
||||
pointerDownState: PointerDownState,
|
||||
): void => {
|
||||
if (this.state.multiElement) {
|
||||
|
@ -2616,6 +2672,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
mutateElement(element, {
|
||||
points: [...element.points, [0, 0]],
|
||||
});
|
||||
const boundElement = getHoveredElementForBinding(
|
||||
pointerDownState.origin,
|
||||
this.scene,
|
||||
);
|
||||
this.scene.replaceAllElements([
|
||||
...this.scene.getElementsIncludingDeleted(),
|
||||
element,
|
||||
|
@ -2623,6 +2683,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
this.setState({
|
||||
draggingElement: element,
|
||||
editingElement: element,
|
||||
startBoundElement: boundElement,
|
||||
suggestedBindings: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -2690,33 +2752,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (pointerDownState.scrollbars.isOverHorizontal) {
|
||||
const x = event.clientX;
|
||||
const dx = x - pointerDownState.lastCoords.x;
|
||||
this.setState({
|
||||
scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
|
||||
});
|
||||
pointerDownState.lastCoords.x = x;
|
||||
if (this.handlePointerMoveOverScrollbars(event, pointerDownState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointerDownState.scrollbars.isOverVertical) {
|
||||
const y = event.clientY;
|
||||
const dy = y - pointerDownState.lastCoords.y;
|
||||
this.setState({
|
||||
scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
|
||||
});
|
||||
pointerDownState.lastCoords.y = y;
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y } = viewportCoordsToSceneCoords(
|
||||
const pointerCoords = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.state,
|
||||
this.canvas,
|
||||
window.devicePixelRatio,
|
||||
);
|
||||
const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize);
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
this.state.gridSize,
|
||||
);
|
||||
|
||||
// for arrows/lines, don't start dragging until a given threshold
|
||||
// to ensure we don't create a 2-point arrow by mistake when
|
||||
|
@ -2729,8 +2779,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
) {
|
||||
if (
|
||||
distance2d(
|
||||
x,
|
||||
y,
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
) < DRAGGING_THRESHOLD
|
||||
|
@ -2753,8 +2803,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
isRotating: resizeHandle === "rotation",
|
||||
});
|
||||
const [resizeX, resizeY] = getGridPoint(
|
||||
x - pointerDownState.resize.offset.x,
|
||||
y - pointerDownState.resize.offset.y,
|
||||
pointerCoords.x - pointerDownState.resize.offset.x,
|
||||
pointerCoords.y - pointerDownState.resize.offset.y,
|
||||
this.state.gridSize,
|
||||
);
|
||||
if (
|
||||
|
@ -2775,6 +2825,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
pointerDownState.resize.originalElements,
|
||||
)
|
||||
) {
|
||||
this.maybeSuggestBindingForAll(selectedElements);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -2783,13 +2834,20 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
const didDrag = LinearElementEditor.handlePointDragging(
|
||||
this.state,
|
||||
(appState) => this.setState(appState),
|
||||
x,
|
||||
y,
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
(element, startOrEnd) => {
|
||||
this.maybeSuggestBindingForLinearElementAtCursor(
|
||||
element,
|
||||
startOrEnd,
|
||||
pointerCoords,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (didDrag) {
|
||||
pointerDownState.lastCoords.x = x;
|
||||
pointerDownState.lastCoords.y = y;
|
||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -2805,11 +2863,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
);
|
||||
if (selectedElements.length > 0) {
|
||||
const [dragX, dragY] = getGridPoint(
|
||||
x - pointerDownState.drag.offset.x,
|
||||
y - pointerDownState.drag.offset.y,
|
||||
pointerCoords.x - pointerDownState.drag.offset.x,
|
||||
pointerCoords.y - pointerDownState.drag.offset.y,
|
||||
this.state.gridSize,
|
||||
);
|
||||
dragSelectedElements(selectedElements, dragX, dragY);
|
||||
dragSelectedElements(selectedElements, dragX, dragY, this.scene);
|
||||
this.maybeSuggestBindingForAll(selectedElements);
|
||||
|
||||
// We duplicate the selected element if alt is pressed on pointer move
|
||||
if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
|
||||
|
@ -2822,6 +2881,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
const nextElements = [];
|
||||
const elementsToAppend = [];
|
||||
const groupIdMap = new Map();
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
for (const element of this.scene.getElementsIncludingDeleted()) {
|
||||
if (
|
||||
this.state.selectedElementIds[element.id] ||
|
||||
|
@ -2846,14 +2906,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
});
|
||||
nextElements.push(duplicatedElement);
|
||||
elementsToAppend.push(element);
|
||||
oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
|
||||
} else {
|
||||
nextElements.push(element);
|
||||
}
|
||||
}
|
||||
this.scene.replaceAllElements([
|
||||
...nextElements,
|
||||
...elementsToAppend,
|
||||
]);
|
||||
const nextSceneElements = [...nextElements, ...elementsToAppend];
|
||||
fixBindingsAfterDuplication(
|
||||
nextSceneElements,
|
||||
elementsToAppend,
|
||||
oldIdToDuplicatedId,
|
||||
"duplicatesServeAsOld",
|
||||
);
|
||||
this.scene.replaceAllElements(nextSceneElements);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
@ -2872,8 +2937,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
let dx: number;
|
||||
let dy: number;
|
||||
if (draggingElement.type === "draw") {
|
||||
dx = x - draggingElement.x;
|
||||
dy = y - draggingElement.y;
|
||||
dx = pointerCoords.x - draggingElement.x;
|
||||
dy = pointerCoords.y - draggingElement.y;
|
||||
} else {
|
||||
dx = gridX - draggingElement.x;
|
||||
dy = gridY - draggingElement.y;
|
||||
|
@ -2903,16 +2968,25 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
});
|
||||
}
|
||||
}
|
||||
if (isBindingElement(draggingElement)) {
|
||||
// When creating a linear element by dragging
|
||||
this.maybeSuggestBindingForLinearElementAtCursor(
|
||||
draggingElement,
|
||||
"end",
|
||||
pointerCoords,
|
||||
this.state.startBoundElement,
|
||||
);
|
||||
}
|
||||
} else if (draggingElement.type === "selection") {
|
||||
dragNewElement(
|
||||
draggingElement,
|
||||
this.state.elementType,
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
x,
|
||||
y,
|
||||
distance(pointerDownState.origin.x, x),
|
||||
distance(pointerDownState.origin.y, y),
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
distance(pointerDownState.origin.x, pointerCoords.x),
|
||||
distance(pointerDownState.origin.y, pointerCoords.y),
|
||||
getResizeWithSidesSameLengthKey(event),
|
||||
getResizeCenterPointKey(event),
|
||||
);
|
||||
|
@ -2929,6 +3003,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
getResizeWithSidesSameLengthKey(event),
|
||||
getResizeCenterPointKey(event),
|
||||
);
|
||||
this.maybeSuggestBindingForAll([draggingElement]);
|
||||
}
|
||||
|
||||
if (this.state.elementType === "selection") {
|
||||
|
@ -2963,6 +3038,33 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
});
|
||||
}
|
||||
|
||||
// Returns whether the pointer move happened over either scrollbar
|
||||
private handlePointerMoveOverScrollbars(
|
||||
event: PointerEvent,
|
||||
pointerDownState: PointerDownState,
|
||||
): boolean {
|
||||
if (pointerDownState.scrollbars.isOverHorizontal) {
|
||||
const x = event.clientX;
|
||||
const dx = x - pointerDownState.lastCoords.x;
|
||||
this.setState({
|
||||
scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
|
||||
});
|
||||
pointerDownState.lastCoords.x = x;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pointerDownState.scrollbars.isOverVertical) {
|
||||
const y = event.clientY;
|
||||
const dy = y - pointerDownState.lastCoords.y;
|
||||
this.setState({
|
||||
scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
|
||||
});
|
||||
pointerDownState.lastCoords.y = y;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private onPointerUpFromPointerDownHandler(
|
||||
pointerDownState: PointerDownState,
|
||||
): (event: PointerEvent) => void {
|
||||
|
@ -2973,6 +3075,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
multiElement,
|
||||
elementType,
|
||||
elementLocked,
|
||||
isResizing,
|
||||
} = this.state;
|
||||
|
||||
this.setState({
|
||||
|
@ -2991,14 +3094,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
|
||||
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
|
||||
|
||||
// if moving start/end point towards start/end point within threshold,
|
||||
// close the loop
|
||||
// Handle end of dragging a point of a linear element, might close a loop
|
||||
// and sets binding element
|
||||
if (this.state.editingLinearElement) {
|
||||
const editingLinearElement = LinearElementEditor.handlePointerUp(
|
||||
childEvent,
|
||||
this.state.editingLinearElement,
|
||||
this.state,
|
||||
);
|
||||
if (editingLinearElement !== this.state.editingLinearElement) {
|
||||
this.setState({ editingLinearElement });
|
||||
this.setState({
|
||||
editingLinearElement,
|
||||
suggestedBindings: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3021,21 +3129,24 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
if (draggingElement!.points.length > 1) {
|
||||
history.resumeRecording();
|
||||
}
|
||||
const pointerCoords = viewportCoordsToSceneCoords(
|
||||
childEvent,
|
||||
this.state,
|
||||
this.canvas,
|
||||
window.devicePixelRatio,
|
||||
);
|
||||
if (
|
||||
!pointerDownState.drag.hasOccurred &&
|
||||
draggingElement &&
|
||||
!multiElement
|
||||
) {
|
||||
const { x, y } = viewportCoordsToSceneCoords(
|
||||
childEvent,
|
||||
this.state,
|
||||
this.canvas,
|
||||
window.devicePixelRatio,
|
||||
);
|
||||
mutateElement(draggingElement, {
|
||||
points: [
|
||||
...draggingElement.points,
|
||||
[x - draggingElement.x, y - draggingElement.y],
|
||||
[
|
||||
pointerCoords.x - draggingElement.x,
|
||||
pointerCoords.y - draggingElement.y,
|
||||
],
|
||||
],
|
||||
});
|
||||
this.setState({
|
||||
|
@ -3043,6 +3154,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
editingElement: this.state.draggingElement,
|
||||
});
|
||||
} else if (pointerDownState.drag.hasOccurred && !multiElement) {
|
||||
if (
|
||||
isBindingEnabled(this.state) &&
|
||||
isBindingElement(draggingElement)
|
||||
) {
|
||||
maybeBindLinearElement(
|
||||
draggingElement,
|
||||
this.state,
|
||||
this.scene,
|
||||
pointerCoords,
|
||||
);
|
||||
}
|
||||
this.setState({ suggestedBindings: [], startBoundElement: null });
|
||||
if (!elementLocked) {
|
||||
resetCursor();
|
||||
this.setState((prevState) => ({
|
||||
|
@ -3086,6 +3209,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
draggingElement,
|
||||
getNormalizedDimensions(draggingElement),
|
||||
);
|
||||
|
||||
if (
|
||||
isBindingEnabled(this.state) &&
|
||||
isBindableElement(draggingElement)
|
||||
) {
|
||||
maybeBindBindableElement(draggingElement);
|
||||
}
|
||||
}
|
||||
|
||||
if (resizingElement) {
|
||||
|
@ -3155,20 +3285,80 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
history.resumeRecording();
|
||||
}
|
||||
|
||||
if (pointerDownState.drag.hasOccurred || isResizing) {
|
||||
(isBindingEnabled(this.state)
|
||||
? bindOrUnbindSelectedElements
|
||||
: unbindLinearElements)(
|
||||
getSelectedElements(this.scene.getElements(), this.state),
|
||||
);
|
||||
}
|
||||
|
||||
if (!elementLocked) {
|
||||
resetCursor();
|
||||
this.setState({
|
||||
draggingElement: null,
|
||||
suggestedBindings: [],
|
||||
elementType: "selection",
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
draggingElement: null,
|
||||
suggestedBindings: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private maybeSuggestBindingAtCursor = (pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): void => {
|
||||
const hoveredBindableElement = getHoveredElementForBinding(
|
||||
pointerCoords,
|
||||
this.scene,
|
||||
);
|
||||
this.setState({
|
||||
suggestedBindings:
|
||||
hoveredBindableElement != null ? [hoveredBindableElement] : [],
|
||||
});
|
||||
};
|
||||
|
||||
private maybeSuggestBindingForLinearElementAtCursor = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
// During line creation the start binding hasn't been written yet
|
||||
// into `linearElement`
|
||||
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
|
||||
): void => {
|
||||
const hoveredBindableElement = getElligibleElementForBindingElementAtCoors(
|
||||
linearElement,
|
||||
startOrEnd,
|
||||
pointerCoords,
|
||||
);
|
||||
this.setState({
|
||||
suggestedBindings:
|
||||
hoveredBindableElement != null &&
|
||||
!isLinearElementSimpleAndAlreadyBound(
|
||||
linearElement,
|
||||
oppositeBindingBoundElement?.id,
|
||||
hoveredBindableElement,
|
||||
)
|
||||
? [hoveredBindableElement]
|
||||
: [],
|
||||
});
|
||||
};
|
||||
|
||||
private maybeSuggestBindingForAll(
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
): void {
|
||||
const suggestedBindings = getEligibleElementsForBinding(selectedElements);
|
||||
this.setState({ suggestedBindings });
|
||||
}
|
||||
|
||||
private maybeClearSelectionWhenHittingElement(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
hitElement: ExcalidrawElement | null,
|
||||
|
@ -3291,13 +3481,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
);
|
||||
|
||||
const elements = this.scene.getElements();
|
||||
const element = getElementAtPosition(
|
||||
elements,
|
||||
this.state,
|
||||
x,
|
||||
y,
|
||||
this.state.zoom,
|
||||
);
|
||||
const element = this.getElementAtPosition(x, y);
|
||||
if (!element) {
|
||||
ContextMenu.push({
|
||||
options: [
|
||||
|
|
|
@ -590,7 +590,13 @@ const LayerUI = ({
|
|||
|
||||
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
||||
const { cursorX, cursorY, ...ret } = appState;
|
||||
const {
|
||||
cursorX,
|
||||
cursorY,
|
||||
suggestedBindings,
|
||||
startBoundElement: boundElement,
|
||||
...ret
|
||||
} = appState;
|
||||
return ret;
|
||||
};
|
||||
const prevAppState = getNecessaryObj(prev.appState);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue