mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
Compare commits
3 commits
c2b78346c1
...
a5a74be45d
Author | SHA1 | Date | |
---|---|---|---|
|
a5a74be45d | ||
|
3f9c6299a0 | ||
|
3068787ac4 |
9 changed files with 537 additions and 444 deletions
|
@ -1,14 +1,7 @@
|
|||
import {
|
||||
average,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
pointTranslate,
|
||||
vector,
|
||||
} from "@excalidraw/math";
|
||||
import { average } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
} from "@excalidraw/element/types";
|
||||
|
@ -1208,6 +1201,3 @@ export const escapeDoubleQuotes = (str: string) => {
|
|||
|
||||
export const castArray = <T>(value: T | T[]): T[] =>
|
||||
Array.isArray(value) ? value : [value];
|
||||
|
||||
export const toLocalPoint = (p: GlobalPoint, element: ExcalidrawElement) =>
|
||||
pointTranslate<GlobalPoint, LocalPoint>(p, vector(-element.x, -element.y));
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
invariant,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
toLocalPoint,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
|
@ -527,14 +526,18 @@ export const bindLinearElement = (
|
|||
const points = Array.from(linearElement.points);
|
||||
|
||||
if (isArrowElement(linearElement)) {
|
||||
points[edgePointIndex] = toLocalPoint(
|
||||
bindPointToSnapToElementOutline(
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
),
|
||||
const [x, y] = bindPointToSnapToElementOutline(
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
);
|
||||
points[edgePointIndex] = LinearElementEditor.createPointAt(
|
||||
linearElement,
|
||||
elementsMap,
|
||||
x,
|
||||
y,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -469,6 +469,7 @@ export class LinearElementEditor {
|
|||
editingLinearElement: LinearElementEditor,
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
shouldBind?: boolean,
|
||||
): LinearElementEditor {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elements = scene.getNonDeletedElements();
|
||||
|
@ -531,6 +532,19 @@ export class LinearElementEditor {
|
|||
}
|
||||
}
|
||||
|
||||
if (shouldBind) {
|
||||
const element = scene.getElement(editingLinearElement.elementId);
|
||||
if (isBindingElement(element) && isBindingEnabled(appState)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
bindings.startBindingElement || "keep",
|
||||
bindings.endBindingElement || "keep",
|
||||
elementsMap,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...editingLinearElement,
|
||||
...bindings,
|
||||
|
|
|
@ -100,7 +100,6 @@ import {
|
|||
arrayToMap,
|
||||
type EXPORT_IMAGE_TYPES,
|
||||
randomInteger,
|
||||
toLocalPoint,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
|
@ -114,7 +113,6 @@ import {
|
|||
fixBindingsAfterDeletion,
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
isLinearElementSimpleAndAlreadyBound,
|
||||
maybeBindLinearElement,
|
||||
shouldEnableBindingForPointerEvent,
|
||||
updateBoundElements,
|
||||
|
@ -172,7 +170,6 @@ import {
|
|||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import {
|
||||
getLockedLinearCursorAlignSize,
|
||||
getNormalizedDimensions,
|
||||
isElementCompletelyInViewport,
|
||||
isElementInViewport,
|
||||
|
@ -307,7 +304,6 @@ import { isNonDeletedElement } from "@excalidraw/element";
|
|||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawGenericElement,
|
||||
|
@ -466,6 +462,14 @@ import { isMaybeMermaidDefinition } from "../mermaid";
|
|||
|
||||
import { LassoTrail } from "../lasso";
|
||||
|
||||
import {
|
||||
handleCanvasPointerMoveForLinearElement,
|
||||
handleDoubleClickForLinearElement,
|
||||
maybeSuggestBindingsForLinearElementAtCoords,
|
||||
onPointerMoveFromPointerDownOnLinearElement,
|
||||
onPointerUpFromPointerDownOnLinearElementHandler,
|
||||
} from "../linear";
|
||||
|
||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
|
||||
|
@ -5447,75 +5451,16 @@ class App extends React.Component<AppProps, AppState> {
|
|||
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
if (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
(!this.state.editingLinearElement ||
|
||||
this.state.editingLinearElement.elementId !==
|
||||
selectedElements[0].id) &&
|
||||
!isElbowArrow(selectedElements[0])
|
||||
handleDoubleClickForLinearElement(
|
||||
this,
|
||||
this.store,
|
||||
selectedElements[0],
|
||||
event,
|
||||
sceneX,
|
||||
sceneY,
|
||||
)
|
||||
) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(selectedElements[0]),
|
||||
});
|
||||
return;
|
||||
} else if (
|
||||
this.state.selectedLinearElement &&
|
||||
isElbowArrow(selectedElements[0])
|
||||
) {
|
||||
const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords(
|
||||
this.state.selectedLinearElement,
|
||||
{ x: sceneX, y: sceneY },
|
||||
this.state,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
const midPoint = hitCoords
|
||||
? LinearElementEditor.getSegmentMidPointIndex(
|
||||
this.state.selectedLinearElement,
|
||||
this.state,
|
||||
hitCoords,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
: -1;
|
||||
|
||||
if (midPoint && midPoint > -1) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
|
||||
|
||||
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
|
||||
{
|
||||
...this.state.selectedLinearElement,
|
||||
segmentMidPointHoveredCoords: null,
|
||||
},
|
||||
{ x: sceneX, y: sceneY },
|
||||
this.state,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
const nextIndex = nextCoords
|
||||
? LinearElementEditor.getSegmentMidPointIndex(
|
||||
this.state.selectedLinearElement,
|
||||
this.state,
|
||||
nextCoords,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
: null;
|
||||
|
||||
this.setState({
|
||||
selectedLinearElement: {
|
||||
...this.state.selectedLinearElement,
|
||||
pointerDownState: {
|
||||
...this.state.selectedLinearElement.pointerDownState,
|
||||
segmentMidpoint: {
|
||||
index: nextIndex,
|
||||
value: hitCoords,
|
||||
added: false,
|
||||
},
|
||||
},
|
||||
segmentMidPointHoveredCoords: nextCoords,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -5896,9 +5841,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||
// and point
|
||||
const { newElement } = this.state;
|
||||
if (isBindingElement(newElement, false)) {
|
||||
this.maybeSuggestBindingsForLinearElementAtCoords(
|
||||
maybeSuggestBindingsForLinearElementAtCoords(
|
||||
newElement,
|
||||
[scenePointer],
|
||||
this,
|
||||
this.state.startBoundElement,
|
||||
);
|
||||
} else {
|
||||
|
@ -5908,121 +5854,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||
|
||||
if (this.state.multiElement) {
|
||||
const { multiElement } = this.state;
|
||||
const { x: rx, y: ry } = multiElement;
|
||||
|
||||
const { points, lastCommittedPoint } = multiElement;
|
||||
const lastPoint = points[points.length - 1];
|
||||
|
||||
setCursorForShape(this.interactiveCanvas, this.state);
|
||||
|
||||
if (lastPoint === lastCommittedPoint) {
|
||||
// if we haven't yet created a temp point and we're beyond commit-zone
|
||||
// threshold, add a point
|
||||
if (
|
||||
pointDistance(
|
||||
pointFrom(scenePointerX - rx, scenePointerY - ry),
|
||||
lastPoint,
|
||||
) >= LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
mutateElement(
|
||||
multiElement,
|
||||
{
|
||||
points: [
|
||||
...points,
|
||||
pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
|
||||
],
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
// in this branch, we're inside the commit zone, and no uncommitted
|
||||
// point exists. Thus do nothing (don't add/remove points).
|
||||
}
|
||||
} else if (
|
||||
points.length > 2 &&
|
||||
lastCommittedPoint &&
|
||||
pointDistance(
|
||||
pointFrom(scenePointerX - rx, scenePointerY - ry),
|
||||
lastCommittedPoint,
|
||||
) < LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
mutateElement(
|
||||
multiElement,
|
||||
{
|
||||
points: points.slice(0, -1),
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement)
|
||||
? null
|
||||
: this.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
const [lastCommittedX, lastCommittedY] =
|
||||
multiElement?.lastCommittedPoint ?? [0, 0];
|
||||
|
||||
let dxFromLastCommitted = gridX - rx - lastCommittedX;
|
||||
let dyFromLastCommitted = gridY - ry - lastCommittedY;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event)) {
|
||||
({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
|
||||
getLockedLinearCursorAlignSize(
|
||||
// actual coordinate of the last committed point
|
||||
lastCommittedX + rx,
|
||||
lastCommittedY + ry,
|
||||
// cursor-grid coordinate
|
||||
gridX,
|
||||
gridY,
|
||||
));
|
||||
}
|
||||
|
||||
if (isPathALoop(points, this.state.zoom.value)) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
}
|
||||
// update last uncommitted point
|
||||
mutateElement(
|
||||
multiElement,
|
||||
{
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
isArrowElement(multiElement)
|
||||
? toLocalPoint(
|
||||
getOutlineAvoidingPoint(
|
||||
multiElement,
|
||||
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
|
||||
multiElement.points.length - 1,
|
||||
this.scene,
|
||||
this.state.zoom,
|
||||
pointFrom<GlobalPoint>(
|
||||
multiElement.x + lastCommittedX + dxFromLastCommitted,
|
||||
multiElement.y + lastCommittedY + dyFromLastCommitted,
|
||||
),
|
||||
),
|
||||
multiElement,
|
||||
)
|
||||
: pointFrom<LocalPoint>(
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
),
|
||||
],
|
||||
},
|
||||
false,
|
||||
{
|
||||
isDragging: true,
|
||||
},
|
||||
);
|
||||
|
||||
// in this path, we're mutating multiElement to reflect
|
||||
// how it will be after adding pointer position as the next point
|
||||
// trigger update here so that new element canvas renders again to reflect this
|
||||
this.triggerRender(false);
|
||||
}
|
||||
handleCanvasPointerMoveForLinearElement(
|
||||
multiElement,
|
||||
this,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
event,
|
||||
this.triggerRender,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -8301,9 +8140,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
(element, pointsSceneCoords) => {
|
||||
this.maybeSuggestBindingsForLinearElementAtCoords(
|
||||
maybeSuggestBindingsForLinearElementAtCoords(
|
||||
element,
|
||||
pointsSceneCoords,
|
||||
this,
|
||||
);
|
||||
},
|
||||
linearElementEditor,
|
||||
|
@ -8691,120 +8531,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||
});
|
||||
}
|
||||
} else if (isLinearElement(newElement)) {
|
||||
pointerDownState.drag.hasOccurred = true;
|
||||
const points = newElement.points;
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
|
||||
);
|
||||
let dx = gridX - newElement.x;
|
||||
let dy = gridY - newElement.y;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
|
||||
({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
|
||||
newElement.x,
|
||||
newElement.y,
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
));
|
||||
}
|
||||
|
||||
if (points.length === 1) {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [
|
||||
...points,
|
||||
isArrowElement(newElement)
|
||||
? toLocalPoint(
|
||||
getOutlineAvoidingPoint(
|
||||
newElement,
|
||||
pointFrom<GlobalPoint>(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
),
|
||||
newElement.points.length - 1,
|
||||
this.scene,
|
||||
this.state.zoom,
|
||||
pointFrom<GlobalPoint>(
|
||||
newElement.x + dx,
|
||||
newElement.y + dy,
|
||||
),
|
||||
),
|
||||
newElement,
|
||||
)
|
||||
: pointFrom<LocalPoint>(dx, dy),
|
||||
],
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else if (
|
||||
points.length === 2 ||
|
||||
(points.length > 1 && isElbowArrow(newElement))
|
||||
) {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
isArrowElement(newElement)
|
||||
? toLocalPoint(
|
||||
getOutlineAvoidingPoint(
|
||||
newElement,
|
||||
pointFrom<GlobalPoint>(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
),
|
||||
newElement.points.length - 1,
|
||||
this.scene,
|
||||
this.state.zoom,
|
||||
pointFrom<GlobalPoint>(
|
||||
newElement.x + dx,
|
||||
newElement.y + dy,
|
||||
),
|
||||
),
|
||||
newElement,
|
||||
)
|
||||
: pointFrom<LocalPoint>(dx, dy),
|
||||
],
|
||||
},
|
||||
false,
|
||||
{ isDragging: true },
|
||||
);
|
||||
LinearElementEditor.movePoints(newElement, [
|
||||
{
|
||||
index: 0,
|
||||
isDragging: false,
|
||||
point: toLocalPoint(
|
||||
getOutlineAvoidingPoint(
|
||||
newElement,
|
||||
pointFrom<GlobalPoint>(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
),
|
||||
0,
|
||||
this.scene,
|
||||
this.state.zoom,
|
||||
),
|
||||
newElement,
|
||||
),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
onPointerMoveFromPointerDownOnLinearElement(
|
||||
newElement,
|
||||
});
|
||||
|
||||
if (isBindingElement(newElement, false)) {
|
||||
// When creating a linear element by dragging
|
||||
this.maybeSuggestBindingsForLinearElementAtCoords(
|
||||
newElement,
|
||||
[pointerCoords],
|
||||
this.state.startBoundElement,
|
||||
);
|
||||
}
|
||||
this,
|
||||
pointerDownState,
|
||||
pointerCoords,
|
||||
event,
|
||||
elementsMap,
|
||||
);
|
||||
} else {
|
||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||
|
@ -9055,21 +8789,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.state.selectedLinearElement,
|
||||
this.state,
|
||||
this.scene,
|
||||
true,
|
||||
);
|
||||
|
||||
const { startBindingElement, endBindingElement } =
|
||||
linearElementEditor;
|
||||
const element = this.scene.getElement(linearElementEditor.elementId);
|
||||
if (isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
elementsMap,
|
||||
this.scene,
|
||||
);
|
||||
}
|
||||
|
||||
if (linearElementEditor !== this.state.selectedLinearElement) {
|
||||
this.setState({
|
||||
selectedLinearElement: {
|
||||
|
@ -9170,65 +8892,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
|
||||
if (isLinearElement(newElement)) {
|
||||
if (newElement!.points.length > 1) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
const pointerCoords = viewportCoordsToSceneCoords(
|
||||
onPointerUpFromPointerDownOnLinearElementHandler(
|
||||
newElement,
|
||||
multiElement,
|
||||
this,
|
||||
this.store,
|
||||
pointerDownState,
|
||||
childEvent,
|
||||
this.state,
|
||||
activeTool,
|
||||
);
|
||||
|
||||
if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) {
|
||||
mutateElement(newElement, {
|
||||
points: [
|
||||
...newElement.points,
|
||||
pointFrom<LocalPoint>(
|
||||
pointerCoords.x - newElement.x,
|
||||
pointerCoords.y - newElement.y,
|
||||
),
|
||||
],
|
||||
});
|
||||
this.setState({
|
||||
multiElement: newElement,
|
||||
newElement,
|
||||
});
|
||||
} else if (pointerDownState.drag.hasOccurred && !multiElement) {
|
||||
if (
|
||||
isBindingEnabled(this.state) &&
|
||||
isBindingElement(newElement, false)
|
||||
) {
|
||||
maybeBindLinearElement(
|
||||
newElement,
|
||||
this.state,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.scene.getNonDeletedElements(),
|
||||
);
|
||||
}
|
||||
this.setState({ suggestedBindings: [], startBoundElement: null });
|
||||
if (!activeTool.locked) {
|
||||
resetCursor(this.interactiveCanvas);
|
||||
this.setState((prevState) => ({
|
||||
newElement: null,
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: "selection",
|
||||
}),
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
{
|
||||
...prevState.selectedElementIds,
|
||||
[newElement.id]: true,
|
||||
},
|
||||
prevState,
|
||||
),
|
||||
selectedLinearElement: new LinearElementEditor(newElement),
|
||||
}));
|
||||
} else {
|
||||
this.setState((prevState) => ({
|
||||
newElement: null,
|
||||
}));
|
||||
}
|
||||
// so that the scene gets rendered again to display the newly drawn linear as well
|
||||
this.scene.triggerUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -10294,49 +9966,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||
});
|
||||
};
|
||||
|
||||
private maybeSuggestBindingsForLinearElementAtCoords = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
/** scene coords */
|
||||
pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[],
|
||||
// During line creation the start binding hasn't been written yet
|
||||
// into `linearElement`
|
||||
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
|
||||
): void => {
|
||||
if (!pointerCoords.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestedBindings = pointerCoords.reduce(
|
||||
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
|
||||
const hoveredBindableElement = getHoveredElementForBinding(
|
||||
coords,
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state.zoom,
|
||||
isElbowArrow(linearElement),
|
||||
isElbowArrow(linearElement),
|
||||
);
|
||||
if (
|
||||
hoveredBindableElement != null &&
|
||||
!isLinearElementSimpleAndAlreadyBound(
|
||||
linearElement,
|
||||
oppositeBindingBoundElement?.id,
|
||||
hoveredBindableElement,
|
||||
)
|
||||
) {
|
||||
acc.push(hoveredBindableElement);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
this.setState({ suggestedBindings });
|
||||
};
|
||||
|
||||
private clearSelection(hitElement: ExcalidrawElement | null): void {
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: makeNextSelectedElementIds({}, prevState),
|
||||
|
|
457
packages/excalidraw/linear.ts
Normal file
457
packages/excalidraw/linear.ts
Normal file
|
@ -0,0 +1,457 @@
|
|||
import {
|
||||
CURSOR_TYPE,
|
||||
getGridPoint,
|
||||
KEYS,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
updateActiveTool,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getLockedLinearCursorAlignSize } from "@excalidraw/element/sizeHelpers";
|
||||
|
||||
import {
|
||||
isArrowElement,
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import {
|
||||
getHoveredElementForBinding,
|
||||
getOutlineAvoidingPoint,
|
||||
isBindingEnabled,
|
||||
isLinearElementSimpleAndAlreadyBound,
|
||||
maybeBindLinearElement,
|
||||
} from "@excalidraw/element/binding";
|
||||
|
||||
import { pointDistance, pointFrom } from "@excalidraw/math";
|
||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
|
||||
import { isPathALoop } from "@excalidraw/element/shapes";
|
||||
|
||||
import { makeNextSelectedElementIds } from "@excalidraw/element/selection";
|
||||
|
||||
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeleted,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { resetCursor, setCursor, setCursorForShape } from "./cursor";
|
||||
|
||||
import type App from "./components/App";
|
||||
|
||||
import type { ActiveTool, PointerDownState } from "./types";
|
||||
|
||||
/**
|
||||
* This function is called when the user drags the pointer to create a new linear element.
|
||||
*/
|
||||
export function onPointerMoveFromPointerDownOnLinearElement(
|
||||
newElement: ExcalidrawLinearElement,
|
||||
app: App,
|
||||
pointerDownState: PointerDownState,
|
||||
pointerCoords: { x: number; y: number },
|
||||
event: PointerEvent,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
) {
|
||||
pointerDownState.drag.hasOccurred = true;
|
||||
const points = newElement.points;
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
let dx = gridX - newElement.x;
|
||||
let dy = gridY - newElement.y;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
|
||||
({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
|
||||
newElement.x,
|
||||
newElement.y,
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
));
|
||||
}
|
||||
|
||||
if (points.length === 1) {
|
||||
let x = newElement.x + dx;
|
||||
let y = newElement.y + dy;
|
||||
if (isArrowElement(newElement)) {
|
||||
[x, y] = getOutlineAvoidingPoint(
|
||||
newElement,
|
||||
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
|
||||
newElement.points.length - 1,
|
||||
app.scene,
|
||||
app.state.zoom,
|
||||
pointFrom<GlobalPoint>(newElement.x + dx, newElement.y + dy),
|
||||
);
|
||||
}
|
||||
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [
|
||||
...points,
|
||||
pointFrom<LocalPoint>(x - newElement.x, y - newElement.y),
|
||||
],
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else if (
|
||||
points.length === 2 ||
|
||||
(points.length > 1 && isElbowArrow(newElement))
|
||||
) {
|
||||
const targets = [];
|
||||
|
||||
if (isArrowElement(newElement)) {
|
||||
const [endX, endY] = getOutlineAvoidingPoint(
|
||||
newElement,
|
||||
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
|
||||
points.length - 1,
|
||||
app.scene,
|
||||
app.state.zoom,
|
||||
pointFrom<GlobalPoint>(newElement.x + dx, newElement.y + dy),
|
||||
);
|
||||
|
||||
targets.push({
|
||||
index: points.length - 1,
|
||||
isDragging: true,
|
||||
point: pointFrom<LocalPoint>(endX - newElement.x, endY - newElement.y),
|
||||
});
|
||||
} else {
|
||||
targets.push({
|
||||
index: points.length - 1,
|
||||
isDragging: true,
|
||||
point: pointFrom<LocalPoint>(dx, dy),
|
||||
});
|
||||
}
|
||||
|
||||
LinearElementEditor.movePoints(newElement, targets);
|
||||
}
|
||||
|
||||
app.setState({
|
||||
newElement,
|
||||
});
|
||||
|
||||
if (isBindingElement(newElement, false)) {
|
||||
// When creating a linear element by dragging
|
||||
maybeSuggestBindingsForLinearElementAtCoords(
|
||||
newElement,
|
||||
[pointerCoords],
|
||||
app,
|
||||
app.state.startBoundElement,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function handleCanvasPointerMoveForLinearElement(
|
||||
multiElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
app: App,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
triggerRender: (forceUpdate?: boolean) => void,
|
||||
) {
|
||||
const { x: rx, y: ry } = multiElement;
|
||||
|
||||
const { points, lastCommittedPoint } = multiElement;
|
||||
const lastPoint = points[points.length - 1];
|
||||
|
||||
setCursorForShape(app.interactiveCanvas, app.state);
|
||||
|
||||
if (lastPoint === lastCommittedPoint) {
|
||||
// if we haven't yet created a temp point and we're beyond commit-zone
|
||||
// threshold, add a point
|
||||
if (
|
||||
pointDistance(
|
||||
pointFrom(scenePointerX - rx, scenePointerY - ry),
|
||||
lastPoint,
|
||||
) >= LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
mutateElement(
|
||||
multiElement,
|
||||
{
|
||||
points: [
|
||||
...points,
|
||||
pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
|
||||
],
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
setCursor(app.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
// in this branch, we're inside the commit zone, and no uncommitted
|
||||
// point exists. Thus do nothing (don't add/remove points).
|
||||
}
|
||||
} else if (
|
||||
points.length > 2 &&
|
||||
lastCommittedPoint &&
|
||||
pointDistance(
|
||||
pointFrom(scenePointerX - rx, scenePointerY - ry),
|
||||
lastCommittedPoint,
|
||||
) < LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
setCursor(app.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
mutateElement(
|
||||
multiElement,
|
||||
{
|
||||
points: points.slice(0, -1),
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
const [lastCommittedX, lastCommittedY] =
|
||||
multiElement?.lastCommittedPoint ?? [0, 0];
|
||||
|
||||
let dxFromLastCommitted = gridX - rx - lastCommittedX;
|
||||
let dyFromLastCommitted = gridY - ry - lastCommittedY;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event)) {
|
||||
({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
|
||||
getLockedLinearCursorAlignSize(
|
||||
// actual coordinate of the last committed point
|
||||
lastCommittedX + rx,
|
||||
lastCommittedY + ry,
|
||||
// cursor-grid coordinate
|
||||
gridX,
|
||||
gridY,
|
||||
));
|
||||
}
|
||||
|
||||
if (isPathALoop(points, app.state.zoom.value)) {
|
||||
setCursor(app.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
}
|
||||
|
||||
let x = multiElement.x + lastCommittedX + dxFromLastCommitted;
|
||||
let y = multiElement.y + lastCommittedY + dyFromLastCommitted;
|
||||
|
||||
if (isArrowElement(multiElement)) {
|
||||
[x, y] = getOutlineAvoidingPoint(
|
||||
multiElement,
|
||||
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
|
||||
multiElement.points.length - 1,
|
||||
app.scene,
|
||||
app.state.zoom,
|
||||
pointFrom<GlobalPoint>(x, y),
|
||||
);
|
||||
}
|
||||
|
||||
// update last uncommitted point
|
||||
LinearElementEditor.movePoints(multiElement, [
|
||||
{
|
||||
index: points.length - 1,
|
||||
point: pointFrom<LocalPoint>(x - multiElement.x, y - multiElement.y),
|
||||
isDragging: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// in this path, we're mutating multiElement to reflect
|
||||
// how it will be after adding pointer position as the next point
|
||||
// trigger update here so that new element canvas renders again to reflect this
|
||||
triggerRender(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function onPointerUpFromPointerDownOnLinearElementHandler(
|
||||
newElement: ExcalidrawLinearElement,
|
||||
multiElement: NonDeleted<ExcalidrawLinearElement> | null,
|
||||
app: App,
|
||||
store: App["store"],
|
||||
pointerDownState: PointerDownState,
|
||||
childEvent: PointerEvent,
|
||||
activeTool: {
|
||||
lastActiveTool: ActiveTool | null;
|
||||
locked: boolean;
|
||||
fromSelection: boolean;
|
||||
} & ActiveTool,
|
||||
) {
|
||||
if (newElement!.points.length > 1) {
|
||||
store.shouldCaptureIncrement();
|
||||
}
|
||||
const pointerCoords = viewportCoordsToSceneCoords(childEvent, app.state);
|
||||
|
||||
if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) {
|
||||
mutateElement(newElement, {
|
||||
points: [
|
||||
...newElement.points,
|
||||
pointFrom<LocalPoint>(
|
||||
pointerCoords.x - newElement.x,
|
||||
pointerCoords.y - newElement.y,
|
||||
),
|
||||
],
|
||||
});
|
||||
app.setState({
|
||||
multiElement: newElement,
|
||||
newElement,
|
||||
});
|
||||
} else if (pointerDownState.drag.hasOccurred && !multiElement) {
|
||||
if (isBindingEnabled(app.state) && isBindingElement(newElement, false)) {
|
||||
maybeBindLinearElement(
|
||||
newElement,
|
||||
app.state,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
app.scene.getNonDeletedElements(),
|
||||
);
|
||||
}
|
||||
app.setState({ suggestedBindings: [], startBoundElement: null });
|
||||
if (!activeTool.locked) {
|
||||
resetCursor(app.interactiveCanvas);
|
||||
app.setState((prevState) => ({
|
||||
newElement: null,
|
||||
activeTool: updateActiveTool(app.state, {
|
||||
type: "selection",
|
||||
}),
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
{
|
||||
...prevState.selectedElementIds,
|
||||
[newElement.id]: true,
|
||||
},
|
||||
prevState,
|
||||
),
|
||||
selectedLinearElement: new LinearElementEditor(newElement),
|
||||
}));
|
||||
} else {
|
||||
app.setState((prevState) => ({
|
||||
newElement: null,
|
||||
}));
|
||||
}
|
||||
// so that the scene gets rendered again to display the newly drawn linear as well
|
||||
app.scene.triggerUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles double click on a linear element to edit it or delete a segment
|
||||
*/
|
||||
export function handleDoubleClickForLinearElement(
|
||||
app: App,
|
||||
store: App["store"],
|
||||
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
event: React.MouseEvent<HTMLCanvasElement>,
|
||||
sceneX: number,
|
||||
sceneY: number,
|
||||
) {
|
||||
if (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
(!app.state.editingLinearElement ||
|
||||
app.state.editingLinearElement.elementId !== selectedElement.id) &&
|
||||
!isElbowArrow(selectedElement)
|
||||
) {
|
||||
store.shouldCaptureIncrement();
|
||||
app.setState({
|
||||
editingLinearElement: new LinearElementEditor(selectedElement),
|
||||
});
|
||||
} else if (app.state.selectedLinearElement && isElbowArrow(selectedElement)) {
|
||||
const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords(
|
||||
app.state.selectedLinearElement,
|
||||
{ x: sceneX, y: sceneY },
|
||||
app.state,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
const midPoint = hitCoords
|
||||
? LinearElementEditor.getSegmentMidPointIndex(
|
||||
app.state.selectedLinearElement,
|
||||
app.state,
|
||||
hitCoords,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
: -1;
|
||||
|
||||
if (midPoint && midPoint > -1) {
|
||||
store.shouldCaptureIncrement();
|
||||
LinearElementEditor.deleteFixedSegment(selectedElement, midPoint);
|
||||
|
||||
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
|
||||
{
|
||||
...app.state.selectedLinearElement,
|
||||
segmentMidPointHoveredCoords: null,
|
||||
},
|
||||
{ x: sceneX, y: sceneY },
|
||||
app.state,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
const nextIndex = nextCoords
|
||||
? LinearElementEditor.getSegmentMidPointIndex(
|
||||
app.state.selectedLinearElement,
|
||||
app.state,
|
||||
nextCoords,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
: null;
|
||||
|
||||
app.setState({
|
||||
selectedLinearElement: {
|
||||
...app.state.selectedLinearElement,
|
||||
pointerDownState: {
|
||||
...app.state.selectedLinearElement.pointerDownState,
|
||||
segmentMidpoint: {
|
||||
index: nextIndex,
|
||||
value: hitCoords,
|
||||
added: false,
|
||||
},
|
||||
},
|
||||
segmentMidPointHoveredCoords: nextCoords,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function maybeSuggestBindingsForLinearElementAtCoords(
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
/** scene coords */
|
||||
pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[],
|
||||
app: App,
|
||||
// During line creation the start binding hasn't been written yet
|
||||
// into `linearElement`
|
||||
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
|
||||
) {
|
||||
if (!pointerCoords.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestedBindings = pointerCoords.reduce(
|
||||
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
|
||||
const hoveredBindableElement = getHoveredElementForBinding(
|
||||
coords,
|
||||
app.scene.getNonDeletedElements(),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
app.state.zoom,
|
||||
isElbowArrow(linearElement),
|
||||
isElbowArrow(linearElement),
|
||||
);
|
||||
if (
|
||||
hoveredBindableElement != null &&
|
||||
!isLinearElementSimpleAndAlreadyBound(
|
||||
linearElement,
|
||||
oppositeBindingBoundElement?.id,
|
||||
hoveredBindableElement,
|
||||
)
|
||||
) {
|
||||
acc.push(hoveredBindableElement);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
app.setState({ suggestedBindings });
|
||||
}
|
|
@ -7500,7 +7500,7 @@ History {
|
|||
|
||||
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of elements 1`] = `1`;
|
||||
|
||||
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `10`;
|
||||
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `11`;
|
||||
|
||||
exports[`history > multiplayer undo/redo > should iterate through the history when when element change relates to remotely deleted element > [end of test] appState 1`] = `
|
||||
{
|
||||
|
@ -10571,7 +10571,7 @@ History {
|
|||
|
||||
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of elements 1`] = `1`;
|
||||
|
||||
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `15`;
|
||||
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `16`;
|
||||
|
||||
exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] appState 1`] = `
|
||||
{
|
||||
|
@ -20198,4 +20198,4 @@ History {
|
|||
|
||||
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of elements 1`] = `1`;
|
||||
|
||||
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `21`;
|
||||
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `22`;
|
||||
|
|
|
@ -50,7 +50,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
|
|||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 8,
|
||||
"versionNonce": 1604849351,
|
||||
"versionNonce": 1505387817,
|
||||
"width": 70,
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
|
@ -106,7 +106,7 @@ exports[`multi point mode in linear elements > line 3`] = `
|
|||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 8,
|
||||
"versionNonce": 1604849351,
|
||||
"versionNonce": 1505387817,
|
||||
"width": 70,
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
|
|
|
@ -6835,7 +6835,7 @@ History {
|
|||
|
||||
exports[`regression tests > draw every type of shape > [end of test] number of elements 1`] = `0`;
|
||||
|
||||
exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `33`;
|
||||
exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `35`;
|
||||
|
||||
exports[`regression tests > given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up > [end of test] appState 1`] = `
|
||||
{
|
||||
|
@ -14566,7 +14566,7 @@ History {
|
|||
|
||||
exports[`regression tests > undo/redo drawing an element > [end of test] number of elements 1`] = `0`;
|
||||
|
||||
exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `20`;
|
||||
exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `21`;
|
||||
|
||||
exports[`regression tests > updates fontSize & fontFamily appState > [end of test] appState 1`] = `
|
||||
{
|
||||
|
|
|
@ -118,8 +118,8 @@ describe("multi point mode in linear elements", () => {
|
|||
key: KEYS.ENTER,
|
||||
});
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
@ -161,8 +161,8 @@ describe("multi point mode in linear elements", () => {
|
|||
fireEvent.keyDown(document, {
|
||||
key: KEYS.ENTER,
|
||||
});
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
|
Loading…
Add table
Reference in a new issue