feat: support segment midpoints in line editor (#5641)
* feat: support segment midpoints in line editor * fix tests * midpoints working in bezier curve * midpoint working with non zero roughness * calculate beizer curve control points for points >2 * unnecessary rerender * don't show phantom points inside editor for short segments * don't show phantom points for small curves * improve the algo for plotting midpoints on bezier curve by taking arc lengths and doing binary search * fix tests finally * fix naming * cache editor midpoints * clear midpoint cache when undo * fix caching * calculate index properly when not all segments have midpoints * make sure correct element version is fetched from cache * chore * fix * direct comparison for equal points * create arePointsEqual util * upate name * don't update cache except inside getter * don't compute midpoints outside editor unless 2pointer lines * update cache to object and burst when Zoom updated as well * early return if midpoints not present outside editor * don't early return * cleanup * Add specs * fix
This commit is contained in:
parent
c5869979c8
commit
0d1058a596
@ -2718,18 +2718,23 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
event,
|
event,
|
||||||
scenePointerX,
|
scenePointerX,
|
||||||
scenePointerY,
|
scenePointerY,
|
||||||
this.state.editingLinearElement,
|
this.state,
|
||||||
this.state.gridSize,
|
|
||||||
);
|
);
|
||||||
if (editingLinearElement !== this.state.editingLinearElement) {
|
|
||||||
|
if (
|
||||||
|
editingLinearElement &&
|
||||||
|
editingLinearElement !== this.state.editingLinearElement
|
||||||
|
) {
|
||||||
// Since we are reading from previous state which is not possible with
|
// Since we are reading from previous state which is not possible with
|
||||||
// automatic batching in React 18 hence using flush sync to synchronously
|
// automatic batching in React 18 hence using flush sync to synchronously
|
||||||
// update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
|
// update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
this.setState({ editingLinearElement });
|
this.setState({
|
||||||
|
editingLinearElement,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (editingLinearElement.lastUncommittedPoint != null) {
|
if (editingLinearElement?.lastUncommittedPoint != null) {
|
||||||
this.maybeSuggestBindingAtCursor(scenePointer);
|
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||||
} else {
|
} else {
|
||||||
this.setState({ suggestedBindings: [] });
|
this.setState({ suggestedBindings: [] });
|
||||||
@ -3058,7 +3063,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
if (this.state.selectedLinearElement) {
|
if (this.state.selectedLinearElement) {
|
||||||
let hoverPointIndex = -1;
|
let hoverPointIndex = -1;
|
||||||
let midPointHovered = false;
|
let segmentMidPointHoveredCoords = null;
|
||||||
if (
|
if (
|
||||||
isHittingElementNotConsideringBoundingBox(element, this.state, [
|
isHittingElementNotConsideringBoundingBox(element, this.state, [
|
||||||
scenePointerX,
|
scenePointerX,
|
||||||
@ -3071,13 +3076,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
scenePointerX,
|
scenePointerX,
|
||||||
scenePointerY,
|
scenePointerY,
|
||||||
);
|
);
|
||||||
midPointHovered = LinearElementEditor.isHittingMidPoint(
|
segmentMidPointHoveredCoords =
|
||||||
|
LinearElementEditor.getSegmentMidpointHitCoords(
|
||||||
linearElementEditor,
|
linearElementEditor,
|
||||||
{ x: scenePointerX, y: scenePointerY },
|
{ x: scenePointerX, y: scenePointerY },
|
||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hoverPointIndex >= 0 || midPointHovered) {
|
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
|
||||||
setCursor(this.canvas, CURSOR_TYPE.POINTER);
|
setCursor(this.canvas, CURSOR_TYPE.POINTER);
|
||||||
} else {
|
} else {
|
||||||
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||||
@ -3106,12 +3112,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.state.selectedLinearElement.midPointHovered !== midPointHovered
|
!LinearElementEditor.arePointsEqual(
|
||||||
|
this.state.selectedLinearElement.segmentMidPointHoveredCoords,
|
||||||
|
segmentMidPointHoveredCoords,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
this.setState({
|
this.setState({
|
||||||
selectedLinearElement: {
|
selectedLinearElement: {
|
||||||
...this.state.selectedLinearElement,
|
...this.state.selectedLinearElement,
|
||||||
midPointHovered,
|
segmentMidPointHoveredCoords,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,11 @@ import {
|
|||||||
getGridPoint,
|
getGridPoint,
|
||||||
rotatePoint,
|
rotatePoint,
|
||||||
centerPoint,
|
centerPoint,
|
||||||
|
getControlPointsForBezierCurve,
|
||||||
|
getBezierXY,
|
||||||
|
getBezierCurveLength,
|
||||||
|
mapIntervalToBezierT,
|
||||||
|
arePointsEqual,
|
||||||
} from "../math";
|
} from "../math";
|
||||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||||
import { getElementPointsCoords } from "./bounds";
|
import { getElementPointsCoords } from "./bounds";
|
||||||
@ -29,6 +34,12 @@ import { tupleToCoors } from "../utils";
|
|||||||
import { isBindingElement } from "./typeChecks";
|
import { isBindingElement } from "./typeChecks";
|
||||||
import { shouldRotateWithDiscreteAngle } from "../keys";
|
import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||||
|
|
||||||
|
const editorMidPointsCache: {
|
||||||
|
version: number | null;
|
||||||
|
points: (Point | null)[];
|
||||||
|
zoom: number | null;
|
||||||
|
} = { version: null, points: [], zoom: null };
|
||||||
|
|
||||||
export class LinearElementEditor {
|
export class LinearElementEditor {
|
||||||
public readonly elementId: ExcalidrawElement["id"] & {
|
public readonly elementId: ExcalidrawElement["id"] & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
@ -52,7 +63,7 @@ export class LinearElementEditor {
|
|||||||
| "keep";
|
| "keep";
|
||||||
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||||
public readonly hoverPointIndex: number;
|
public readonly hoverPointIndex: number;
|
||||||
public readonly midPointHovered: boolean;
|
public readonly segmentMidPointHoveredCoords: Point | null;
|
||||||
|
|
||||||
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
|
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
|
||||||
this.elementId = element.id as string & {
|
this.elementId = element.id as string & {
|
||||||
@ -72,7 +83,7 @@ export class LinearElementEditor {
|
|||||||
lastClickedPoint: -1,
|
lastClickedPoint: -1,
|
||||||
};
|
};
|
||||||
this.hoverPointIndex = -1;
|
this.hoverPointIndex = -1;
|
||||||
this.midPointHovered = false;
|
this.segmentMidPointHoveredCoords = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -80,7 +91,6 @@ export class LinearElementEditor {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static POINT_HANDLE_SIZE = 10;
|
static POINT_HANDLE_SIZE = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param id the `elementId` from the instance of this class (so that we can
|
* @param id the `elementId` from the instance of this class (so that we can
|
||||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||||
@ -359,7 +369,60 @@ export class LinearElementEditor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static isHittingMidPoint = (
|
static getEditorMidPoints = (
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
appState: AppState,
|
||||||
|
): typeof editorMidPointsCache["points"] => {
|
||||||
|
// Since its not needed outside editor unless 2 pointer lines
|
||||||
|
if (!appState.editingLinearElement && element.points.length > 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
editorMidPointsCache.version === element.version &&
|
||||||
|
editorMidPointsCache.zoom === appState.zoom.value
|
||||||
|
) {
|
||||||
|
return editorMidPointsCache.points;
|
||||||
|
}
|
||||||
|
LinearElementEditor.updateEditorMidPointsCache(element, appState);
|
||||||
|
return editorMidPointsCache.points!;
|
||||||
|
};
|
||||||
|
|
||||||
|
static updateEditorMidPointsCache = (
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
const midpoints: (Point | null)[] = [];
|
||||||
|
while (index < points.length - 1) {
|
||||||
|
if (
|
||||||
|
LinearElementEditor.isSegmentTooShort(
|
||||||
|
element,
|
||||||
|
element.points[index],
|
||||||
|
element.points[index + 1],
|
||||||
|
appState.zoom,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
midpoints.push(null);
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
||||||
|
element,
|
||||||
|
points[index],
|
||||||
|
points[index + 1],
|
||||||
|
index + 1,
|
||||||
|
);
|
||||||
|
midpoints.push(segmentMidPoint);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
editorMidPointsCache.points = midpoints;
|
||||||
|
editorMidPointsCache.version = element.version;
|
||||||
|
editorMidPointsCache.zoom = appState.zoom.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
static getSegmentMidpointHitCoords = (
|
||||||
linearElementEditor: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
scenePointer: { x: number; y: number },
|
scenePointer: { x: number; y: number },
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@ -367,7 +430,7 @@ export class LinearElementEditor {
|
|||||||
const { elementId } = linearElementEditor;
|
const { elementId } = linearElementEditor;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||||
element,
|
element,
|
||||||
@ -376,37 +439,125 @@ export class LinearElementEditor {
|
|||||||
scenePointer.y,
|
scenePointer.y,
|
||||||
);
|
);
|
||||||
if (clickedPointIndex >= 0) {
|
if (clickedPointIndex >= 0) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
|
||||||
if (points.length >= 3) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const midPoint = LinearElementEditor.getMidPoint(linearElementEditor);
|
|
||||||
if (midPoint) {
|
|
||||||
const threshold =
|
|
||||||
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
|
|
||||||
const distance = distance2d(
|
|
||||||
midPoint[0],
|
|
||||||
midPoint[1],
|
|
||||||
scenePointer.x,
|
|
||||||
scenePointer.y,
|
|
||||||
);
|
|
||||||
return distance <= threshold;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
static getMidPoint(linearElementEditor: LinearElementEditor) {
|
|
||||||
const { elementId } = linearElementEditor;
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
|
||||||
if (!element) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||||
|
if (points.length >= 3 && !appState.editingLinearElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return centerPoint(points[0], points.at(-1)!);
|
const threshold =
|
||||||
|
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
|
||||||
|
|
||||||
|
const existingSegmentMidpointHitCoords =
|
||||||
|
linearElementEditor.segmentMidPointHoveredCoords;
|
||||||
|
if (existingSegmentMidpointHitCoords) {
|
||||||
|
const distance = distance2d(
|
||||||
|
existingSegmentMidpointHitCoords[0],
|
||||||
|
existingSegmentMidpointHitCoords[1],
|
||||||
|
scenePointer.x,
|
||||||
|
scenePointer.y,
|
||||||
|
);
|
||||||
|
if (distance <= threshold) {
|
||||||
|
return existingSegmentMidpointHitCoords;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let index = 0;
|
||||||
|
const midPoints: typeof editorMidPointsCache["points"] =
|
||||||
|
LinearElementEditor.getEditorMidPoints(element, appState);
|
||||||
|
while (index < midPoints.length) {
|
||||||
|
if (midPoints[index] !== null) {
|
||||||
|
const distance = distance2d(
|
||||||
|
midPoints[index]![0],
|
||||||
|
midPoints[index]![1],
|
||||||
|
scenePointer.x,
|
||||||
|
scenePointer.y,
|
||||||
|
);
|
||||||
|
if (distance <= threshold) {
|
||||||
|
return midPoints[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
static isSegmentTooShort(
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
startPoint: Point,
|
||||||
|
endPoint: Point,
|
||||||
|
zoom: AppState["zoom"],
|
||||||
|
) {
|
||||||
|
let distance = distance2d(
|
||||||
|
startPoint[0],
|
||||||
|
startPoint[1],
|
||||||
|
endPoint[0],
|
||||||
|
endPoint[1],
|
||||||
|
);
|
||||||
|
if (element.points.length > 2 && element.strokeSharpness === "round") {
|
||||||
|
distance = getBezierCurveLength(element, endPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSegmentMidPoint(
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
startPoint: Point,
|
||||||
|
endPoint: Point,
|
||||||
|
endPointIndex: number,
|
||||||
|
) {
|
||||||
|
let segmentMidPoint = centerPoint(startPoint, endPoint);
|
||||||
|
if (element.points.length > 2 && element.strokeSharpness === "round") {
|
||||||
|
const controlPoints = getControlPointsForBezierCurve(
|
||||||
|
element,
|
||||||
|
element.points[endPointIndex],
|
||||||
|
);
|
||||||
|
if (controlPoints) {
|
||||||
|
const t = mapIntervalToBezierT(
|
||||||
|
element,
|
||||||
|
element.points[endPointIndex],
|
||||||
|
0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [tx, ty] = getBezierXY(
|
||||||
|
controlPoints[0],
|
||||||
|
controlPoints[1],
|
||||||
|
controlPoints[2],
|
||||||
|
controlPoints[3],
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||||
|
element,
|
||||||
|
[tx, ty],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segmentMidPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSegmentMidPointIndex(
|
||||||
|
linearElementEditor: LinearElementEditor,
|
||||||
|
appState: AppState,
|
||||||
|
midPoint: Point,
|
||||||
|
) {
|
||||||
|
const element = LinearElementEditor.getElement(
|
||||||
|
linearElementEditor.elementId,
|
||||||
|
);
|
||||||
|
if (!element) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const midPoints = LinearElementEditor.getEditorMidPoints(element, appState);
|
||||||
|
let index = 0;
|
||||||
|
while (index < midPoints.length - 1) {
|
||||||
|
if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) {
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
static handlePointerDown(
|
static handlePointerDown(
|
||||||
@ -438,33 +589,32 @@ export class LinearElementEditor {
|
|||||||
if (!element) {
|
if (!element) {
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
const hittingMidPoint = LinearElementEditor.isHittingMidPoint(
|
const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords(
|
||||||
linearElementEditor,
|
linearElementEditor,
|
||||||
scenePointer,
|
scenePointer,
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
if (
|
if (segmentMidPoint) {
|
||||||
LinearElementEditor.isHittingMidPoint(
|
const index = LinearElementEditor.getSegmentMidPointIndex(
|
||||||
linearElementEditor,
|
linearElementEditor,
|
||||||
scenePointer,
|
|
||||||
appState,
|
appState,
|
||||||
)
|
segmentMidPoint,
|
||||||
) {
|
);
|
||||||
const midPoint = LinearElementEditor.getMidPoint(linearElementEditor);
|
const newMidPoint = LinearElementEditor.createPointAt(
|
||||||
if (midPoint) {
|
|
||||||
mutateElement(element, {
|
|
||||||
points: [
|
|
||||||
element.points[0],
|
|
||||||
LinearElementEditor.createPointAt(
|
|
||||||
element,
|
element,
|
||||||
midPoint[0],
|
segmentMidPoint[0],
|
||||||
midPoint[1],
|
segmentMidPoint[1],
|
||||||
appState.gridSize,
|
appState.gridSize,
|
||||||
),
|
);
|
||||||
...element.points.slice(1),
|
const points = [
|
||||||
],
|
...element.points.slice(0, index),
|
||||||
|
newMidPoint,
|
||||||
|
...element.points.slice(index),
|
||||||
|
];
|
||||||
|
mutateElement(element, {
|
||||||
|
points,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
ret.didAddPoint = true;
|
ret.didAddPoint = true;
|
||||||
ret.isMidPoint = true;
|
ret.isMidPoint = true;
|
||||||
ret.linearElementEditor = {
|
ret.linearElementEditor = {
|
||||||
@ -520,7 +670,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
// if we clicked on a point, set the element as hitElement otherwise
|
// if we clicked on a point, set the element as hitElement otherwise
|
||||||
// it would get deselected if the point is outside the hitbox area
|
// it would get deselected if the point is outside the hitbox area
|
||||||
if (clickedPointIndex >= 0 || hittingMidPoint) {
|
if (clickedPointIndex >= 0 || segmentMidPoint) {
|
||||||
ret.hitElement = element;
|
ret.hitElement = element;
|
||||||
} else {
|
} else {
|
||||||
// You might be wandering why we are storing the binding elements on
|
// You might be wandering why we are storing the binding elements on
|
||||||
@ -579,17 +729,29 @@ export class LinearElementEditor {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static arePointsEqual(point1: Point | null, point2: Point | null) {
|
||||||
|
if (!point1 && !point2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!point1 || !point2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return arePointsEqual(point1, point2);
|
||||||
|
}
|
||||||
|
|
||||||
static handlePointerMove(
|
static handlePointerMove(
|
||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLCanvasElement>,
|
||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
linearElementEditor: LinearElementEditor,
|
appState: AppState,
|
||||||
gridSize: number | null,
|
): LinearElementEditor | null {
|
||||||
): LinearElementEditor {
|
if (!appState.editingLinearElement) {
|
||||||
const { elementId, lastUncommittedPoint } = linearElementEditor;
|
return null;
|
||||||
|
}
|
||||||
|
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return linearElementEditor;
|
return appState.editingLinearElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
@ -599,7 +761,10 @@ export class LinearElementEditor {
|
|||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||||
}
|
}
|
||||||
return { ...linearElementEditor, lastUncommittedPoint: null };
|
return {
|
||||||
|
...appState.editingLinearElement,
|
||||||
|
lastUncommittedPoint: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let newPoint: Point;
|
let newPoint: Point;
|
||||||
@ -611,7 +776,7 @@ export class LinearElementEditor {
|
|||||||
element,
|
element,
|
||||||
lastCommittedPoint,
|
lastCommittedPoint,
|
||||||
[scenePointerX, scenePointerY],
|
[scenePointerX, scenePointerY],
|
||||||
gridSize,
|
appState.gridSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
newPoint = [
|
newPoint = [
|
||||||
@ -621,9 +786,9 @@ export class LinearElementEditor {
|
|||||||
} else {
|
} else {
|
||||||
newPoint = LinearElementEditor.createPointAt(
|
newPoint = LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
scenePointerX - appState.editingLinearElement.pointerOffset.x,
|
||||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
scenePointerY - appState.editingLinearElement.pointerOffset.y,
|
||||||
gridSize,
|
appState.gridSize,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -635,11 +800,10 @@ export class LinearElementEditor {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...linearElementEditor,
|
...appState.editingLinearElement,
|
||||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -884,6 +1048,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static addPoints(
|
static addPoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
appState: AppState,
|
||||||
targetPoints: { point: Point }[],
|
targetPoints: { point: Point }[],
|
||||||
) {
|
) {
|
||||||
const offsetX = 0;
|
const offsetX = 0;
|
||||||
|
166
src/math.ts
166
src/math.ts
@ -1,6 +1,8 @@
|
|||||||
import { NormalizedZoomValue, Point, Zoom } from "./types";
|
import { NormalizedZoomValue, Point, Zoom } from "./types";
|
||||||
import { LINE_CONFIRM_THRESHOLD } from "./constants";
|
import { LINE_CONFIRM_THRESHOLD } from "./constants";
|
||||||
import { ExcalidrawLinearElement } from "./element/types";
|
import { ExcalidrawLinearElement, NonDeleted } from "./element/types";
|
||||||
|
import { getShapeForElement } from "./renderer/renderElement";
|
||||||
|
import { getCurvePathOps } from "./element/bounds";
|
||||||
|
|
||||||
export const rotate = (
|
export const rotate = (
|
||||||
x1: number,
|
x1: number,
|
||||||
@ -263,3 +265,165 @@ export const getGridPoint = (
|
|||||||
}
|
}
|
||||||
return [x, y];
|
return [x, y];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getControlPointsForBezierCurve = (
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
endPoint: Point,
|
||||||
|
) => {
|
||||||
|
const shape = getShapeForElement(element as ExcalidrawLinearElement);
|
||||||
|
if (!shape) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ops = getCurvePathOps(shape[0]);
|
||||||
|
let currentP: Mutable<Point> = [0, 0];
|
||||||
|
let index = 0;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
let controlPoints: Mutable<Point>[] | null = null;
|
||||||
|
|
||||||
|
while (index < ops.length) {
|
||||||
|
const { op, data } = ops[index];
|
||||||
|
if (op === "move") {
|
||||||
|
currentP = data as unknown as Mutable<Point>;
|
||||||
|
}
|
||||||
|
if (op === "bcurveTo") {
|
||||||
|
const p0 = currentP;
|
||||||
|
const p1 = [data[0], data[1]] as Mutable<Point>;
|
||||||
|
const p2 = [data[2], data[3]] as Mutable<Point>;
|
||||||
|
const p3 = [data[4], data[5]] as Mutable<Point>;
|
||||||
|
const distance = distance2d(p3[0], p3[1], endPoint[0], endPoint[1]);
|
||||||
|
if (distance < minDistance) {
|
||||||
|
minDistance = distance;
|
||||||
|
controlPoints = [p0, p1, p2, p3];
|
||||||
|
}
|
||||||
|
currentP = p3;
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return controlPoints;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBezierXY = (
|
||||||
|
p0: Point,
|
||||||
|
p1: Point,
|
||||||
|
p2: Point,
|
||||||
|
p3: Point,
|
||||||
|
t: number,
|
||||||
|
) => {
|
||||||
|
const equation = (t: number, idx: number) =>
|
||||||
|
Math.pow(1 - t, 3) * p3[idx] +
|
||||||
|
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
||||||
|
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
||||||
|
p0[idx] * Math.pow(t, 3);
|
||||||
|
const tx = equation(t, 0);
|
||||||
|
const ty = equation(t, 1);
|
||||||
|
return [tx, ty];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPointsInBezierCurve = (
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
endPoint: Point,
|
||||||
|
) => {
|
||||||
|
const controlPoints: Mutable<Point>[] = getControlPointsForBezierCurve(
|
||||||
|
element,
|
||||||
|
endPoint,
|
||||||
|
)!;
|
||||||
|
if (!controlPoints) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const pointsOnCurve: Mutable<Point>[] = [];
|
||||||
|
let t = 1;
|
||||||
|
// Take 20 points on curve for better accuracy
|
||||||
|
while (t > 0) {
|
||||||
|
const point = getBezierXY(
|
||||||
|
controlPoints[0],
|
||||||
|
controlPoints[1],
|
||||||
|
controlPoints[2],
|
||||||
|
controlPoints[3],
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
pointsOnCurve.push([point[0], point[1]]);
|
||||||
|
t -= 0.05;
|
||||||
|
}
|
||||||
|
if (pointsOnCurve.length) {
|
||||||
|
if (arePointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
|
||||||
|
pointsOnCurve.push([endPoint[0], endPoint[1]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pointsOnCurve;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBezierCurveArcLengths = (
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
endPoint: Point,
|
||||||
|
) => {
|
||||||
|
const arcLengths: number[] = [];
|
||||||
|
arcLengths[0] = 0;
|
||||||
|
const points = getPointsInBezierCurve(element, endPoint);
|
||||||
|
let index = 0;
|
||||||
|
let distance = 0;
|
||||||
|
while (index < points.length - 1) {
|
||||||
|
const segmentDistance = distance2d(
|
||||||
|
points[index][0],
|
||||||
|
points[index][1],
|
||||||
|
points[index + 1][0],
|
||||||
|
points[index + 1][1],
|
||||||
|
);
|
||||||
|
distance += segmentDistance;
|
||||||
|
arcLengths.push(distance);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return arcLengths;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBezierCurveLength = (
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
endPoint: Point,
|
||||||
|
) => {
|
||||||
|
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
||||||
|
return arcLengths.at(-1) as number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
|
||||||
|
export const mapIntervalToBezierT = (
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
endPoint: Point,
|
||||||
|
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
|
||||||
|
) => {
|
||||||
|
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
||||||
|
const pointsCount = arcLengths.length - 1;
|
||||||
|
const curveLength = arcLengths.at(-1) as number;
|
||||||
|
const targetLength = interval * curveLength;
|
||||||
|
let low = 0;
|
||||||
|
let high = pointsCount;
|
||||||
|
let index = 0;
|
||||||
|
// Doing a binary search to find the largest length that is less than the target length
|
||||||
|
while (low < high) {
|
||||||
|
index = Math.floor(low + (high - low) / 2);
|
||||||
|
if (arcLengths[index] < targetLength) {
|
||||||
|
low = index + 1;
|
||||||
|
} else {
|
||||||
|
high = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (arcLengths[index] > targetLength) {
|
||||||
|
index--;
|
||||||
|
}
|
||||||
|
if (arcLengths[index] === targetLength) {
|
||||||
|
return index / pointsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
1 -
|
||||||
|
(index +
|
||||||
|
(targetLength - arcLengths[index]) /
|
||||||
|
(arcLengths[index + 1] - arcLengths[index])) /
|
||||||
|
pointsCount
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const arePointsEqual = (p1: Point, p2: Point) => {
|
||||||
|
return p1[0] === p2[0] && p1[1] === p2[1];
|
||||||
|
};
|
||||||
|
@ -197,12 +197,7 @@ const renderLinearPointHandles = (
|
|||||||
context.translate(renderConfig.scrollX, renderConfig.scrollY);
|
context.translate(renderConfig.scrollX, renderConfig.scrollY);
|
||||||
context.lineWidth = 1 / renderConfig.zoom.value;
|
context.lineWidth = 1 / renderConfig.zoom.value;
|
||||||
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||||
const centerPoint = LinearElementEditor.getMidPoint(
|
|
||||||
appState.selectedLinearElement,
|
|
||||||
);
|
|
||||||
if (!centerPoint) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { POINT_HANDLE_SIZE } = LinearElementEditor;
|
const { POINT_HANDLE_SIZE } = LinearElementEditor;
|
||||||
const radius = appState.editingLinearElement
|
const radius = appState.editingLinearElement
|
||||||
? POINT_HANDLE_SIZE
|
? POINT_HANDLE_SIZE
|
||||||
@ -221,11 +216,20 @@ const renderLinearPointHandles = (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (points.length < 3) {
|
//Rendering segment mid points
|
||||||
if (appState.selectedLinearElement.midPointHovered) {
|
const midPoints = LinearElementEditor.getEditorMidPoints(
|
||||||
const centerPoint = LinearElementEditor.getMidPoint(
|
element,
|
||||||
appState.selectedLinearElement,
|
appState,
|
||||||
)!;
|
).filter((midPoint) => midPoint !== null) as Point[];
|
||||||
|
|
||||||
|
midPoints.forEach((segmentMidPoint) => {
|
||||||
|
if (
|
||||||
|
appState?.selectedLinearElement?.segmentMidPointHoveredCoords &&
|
||||||
|
LinearElementEditor.arePointsEqual(
|
||||||
|
segmentMidPoint,
|
||||||
|
appState.selectedLinearElement.segmentMidPointHoveredCoords,
|
||||||
|
)
|
||||||
|
) {
|
||||||
// The order of renderingSingleLinearPoint and highLight points is different
|
// The order of renderingSingleLinearPoint and highLight points is different
|
||||||
// inside vs outside editor as hover states are different,
|
// inside vs outside editor as hover states are different,
|
||||||
// in editor when hovered the original point is not visible as hover state fully covers it whereas outside the
|
// in editor when hovered the original point is not visible as hover state fully covers it whereas outside the
|
||||||
@ -235,34 +239,34 @@ const renderLinearPointHandles = (
|
|||||||
context,
|
context,
|
||||||
appState,
|
appState,
|
||||||
renderConfig,
|
renderConfig,
|
||||||
centerPoint,
|
segmentMidPoint,
|
||||||
radius,
|
radius,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
highlightPoint(centerPoint, context, renderConfig);
|
highlightPoint(segmentMidPoint, context, renderConfig);
|
||||||
} else {
|
} else {
|
||||||
highlightPoint(centerPoint, context, renderConfig);
|
highlightPoint(segmentMidPoint, context, renderConfig);
|
||||||
renderSingleLinearPoint(
|
renderSingleLinearPoint(
|
||||||
context,
|
context,
|
||||||
appState,
|
appState,
|
||||||
renderConfig,
|
renderConfig,
|
||||||
centerPoint,
|
segmentMidPoint,
|
||||||
radius,
|
radius,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (appState.editingLinearElement || points.length === 2) {
|
||||||
renderSingleLinearPoint(
|
renderSingleLinearPoint(
|
||||||
context,
|
context,
|
||||||
appState,
|
appState,
|
||||||
renderConfig,
|
renderConfig,
|
||||||
centerPoint,
|
segmentMidPoint,
|
||||||
POINT_HANDLE_SIZE / 2,
|
POINT_HANDLE_SIZE / 2,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
@ -403,6 +407,20 @@ export const _renderScene = ({
|
|||||||
visibleElements.forEach((element) => {
|
visibleElements.forEach((element) => {
|
||||||
try {
|
try {
|
||||||
renderElement(element, rc, context, renderConfig);
|
renderElement(element, rc, context, renderConfig);
|
||||||
|
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
||||||
|
// ShapeCache returns empty hence making sure that we get the
|
||||||
|
// correct element from visible elements
|
||||||
|
if (appState.editingLinearElement?.elementId === element.id) {
|
||||||
|
if (element) {
|
||||||
|
renderLinearPointHandles(
|
||||||
|
context,
|
||||||
|
appState,
|
||||||
|
renderConfig,
|
||||||
|
element as NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!isExporting) {
|
if (!isExporting) {
|
||||||
renderLinkIcon(element, context, appState);
|
renderLinkIcon(element, context, appState);
|
||||||
}
|
}
|
||||||
@ -411,15 +429,6 @@ export const _renderScene = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (appState.editingLinearElement) {
|
|
||||||
const element = LinearElementEditor.getElement(
|
|
||||||
appState.editingLinearElement.elementId,
|
|
||||||
);
|
|
||||||
if (element) {
|
|
||||||
renderLinearPointHandles(context, appState, renderConfig, element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paint selection element
|
// Paint selection element
|
||||||
if (appState.selectionElement) {
|
if (appState.selectionElement) {
|
||||||
try {
|
try {
|
||||||
|
60
src/tests/__snapshots__/linearElementEditor.test.tsx.snap
Normal file
60
src/tests/__snapshots__/linearElementEditor.test.tsx.snap
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[` Test Linear Elements Inside editor should allow dragging line from midpoint in 2 pointer lines 1`] = `
|
||||||
|
Array [
|
||||||
|
Array [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
Array [
|
||||||
|
70,
|
||||||
|
50,
|
||||||
|
],
|
||||||
|
Array [
|
||||||
|
40,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[` Test Linear Elements Inside editor should allow dragging lines from midpoints in between segments 1`] = `
|
||||||
|
Array [
|
||||||
|
Array [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
Array [
|
||||||
|
85,
|
||||||
|
75,
|
||||||
|
],
|
||||||
|
Array [
|
||||||
|
70,
|
||||||
|
50,
|
||||||
|
],
|
||||||
|
Array [
|
||||||
|
105,
|
||||||
|
75,
|
||||||
|
],
|
||||||
|
Array [
|
||||||
|
40,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[` Test Linear Elements should allow dragging line from midpoint in 2 pointer lines outside editor 1`] = `
|
||||||
|
Array [
|
||||||
|
Array [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
Array [
|
||||||
|
70,
|
||||||
|
50,
|
||||||
|
],
|
||||||
|
Array [
|
||||||
|
40,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
`;
|
@ -10982,7 +10982,6 @@ Object {
|
|||||||
"hoverPointIndex": -1,
|
"hoverPointIndex": -1,
|
||||||
"isDragging": false,
|
"isDragging": false,
|
||||||
"lastUncommittedPoint": null,
|
"lastUncommittedPoint": null,
|
||||||
"midPointHovered": false,
|
|
||||||
"pointerDownState": Object {
|
"pointerDownState": Object {
|
||||||
"lastClickedPoint": -1,
|
"lastClickedPoint": -1,
|
||||||
"prevSelectedPointsIndices": null,
|
"prevSelectedPointsIndices": null,
|
||||||
@ -10991,6 +10990,7 @@ Object {
|
|||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
|
"segmentMidPointHoveredCoords": null,
|
||||||
"selectedPointsIndices": null,
|
"selectedPointsIndices": null,
|
||||||
"startBindingElement": "keep",
|
"startBindingElement": "keep",
|
||||||
},
|
},
|
||||||
@ -11208,7 +11208,6 @@ Object {
|
|||||||
"hoverPointIndex": -1,
|
"hoverPointIndex": -1,
|
||||||
"isDragging": false,
|
"isDragging": false,
|
||||||
"lastUncommittedPoint": null,
|
"lastUncommittedPoint": null,
|
||||||
"midPointHovered": false,
|
|
||||||
"pointerDownState": Object {
|
"pointerDownState": Object {
|
||||||
"lastClickedPoint": -1,
|
"lastClickedPoint": -1,
|
||||||
"prevSelectedPointsIndices": null,
|
"prevSelectedPointsIndices": null,
|
||||||
@ -11217,6 +11216,7 @@ Object {
|
|||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
|
"segmentMidPointHoveredCoords": null,
|
||||||
"selectedPointsIndices": null,
|
"selectedPointsIndices": null,
|
||||||
"startBindingElement": "keep",
|
"startBindingElement": "keep",
|
||||||
},
|
},
|
||||||
@ -11661,7 +11661,6 @@ Object {
|
|||||||
"hoverPointIndex": -1,
|
"hoverPointIndex": -1,
|
||||||
"isDragging": false,
|
"isDragging": false,
|
||||||
"lastUncommittedPoint": null,
|
"lastUncommittedPoint": null,
|
||||||
"midPointHovered": false,
|
|
||||||
"pointerDownState": Object {
|
"pointerDownState": Object {
|
||||||
"lastClickedPoint": -1,
|
"lastClickedPoint": -1,
|
||||||
"prevSelectedPointsIndices": null,
|
"prevSelectedPointsIndices": null,
|
||||||
@ -11670,6 +11669,7 @@ Object {
|
|||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
|
"segmentMidPointHoveredCoords": null,
|
||||||
"selectedPointsIndices": null,
|
"selectedPointsIndices": null,
|
||||||
"startBindingElement": "keep",
|
"startBindingElement": "keep",
|
||||||
},
|
},
|
||||||
@ -12066,7 +12066,6 @@ Object {
|
|||||||
"hoverPointIndex": -1,
|
"hoverPointIndex": -1,
|
||||||
"isDragging": false,
|
"isDragging": false,
|
||||||
"lastUncommittedPoint": null,
|
"lastUncommittedPoint": null,
|
||||||
"midPointHovered": false,
|
|
||||||
"pointerDownState": Object {
|
"pointerDownState": Object {
|
||||||
"lastClickedPoint": -1,
|
"lastClickedPoint": -1,
|
||||||
"prevSelectedPointsIndices": null,
|
"prevSelectedPointsIndices": null,
|
||||||
@ -12075,6 +12074,7 @@ Object {
|
|||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
|
"segmentMidPointHoveredCoords": null,
|
||||||
"selectedPointsIndices": null,
|
"selectedPointsIndices": null,
|
||||||
"startBindingElement": "keep",
|
"startBindingElement": "keep",
|
||||||
},
|
},
|
||||||
|
146
src/tests/linearElementEditor.test.tsx
Normal file
146
src/tests/linearElementEditor.test.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import { ExcalidrawLinearElement } from "../element/types";
|
||||||
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
|
import { centerPoint } from "../math";
|
||||||
|
import { reseed } from "../random";
|
||||||
|
import * as Renderer from "../renderer/renderScene";
|
||||||
|
import { Keyboard } from "./helpers/ui";
|
||||||
|
import { screen } from "./test-utils";
|
||||||
|
|
||||||
|
import { render, fireEvent } from "./test-utils";
|
||||||
|
import { Point } from "../types";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
|
||||||
|
const renderScene = jest.spyOn(Renderer, "renderScene");
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
describe(" Test Linear Elements", () => {
|
||||||
|
let getByToolName: (...args: string[]) => HTMLElement;
|
||||||
|
let container: HTMLElement;
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Unmount ReactDOM from root
|
||||||
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
|
localStorage.clear();
|
||||||
|
renderScene.mockClear();
|
||||||
|
reseed(7);
|
||||||
|
const comp = await render(<ExcalidrawApp />);
|
||||||
|
getByToolName = comp.getByToolName;
|
||||||
|
container = comp.container;
|
||||||
|
canvas = container.querySelector("canvas")!;
|
||||||
|
});
|
||||||
|
|
||||||
|
const p1: Point = [20, 20];
|
||||||
|
const p2: Point = [60, 20];
|
||||||
|
const midpoint = centerPoint(p1, p2);
|
||||||
|
|
||||||
|
const createTwoPointerLinearElement = (
|
||||||
|
type: ExcalidrawLinearElement["type"],
|
||||||
|
edge: "Sharp" | "Round" = "Sharp",
|
||||||
|
roughness: "Architect" | "Cartoonist" | "Artist" = "Architect",
|
||||||
|
) => {
|
||||||
|
const tool = getByToolName(type);
|
||||||
|
fireEvent.click(tool);
|
||||||
|
fireEvent.click(screen.getByTitle(edge));
|
||||||
|
fireEvent.click(screen.getByTitle(roughness));
|
||||||
|
fireEvent.pointerDown(canvas, { clientX: p1[0], clientY: p1[1] });
|
||||||
|
fireEvent.pointerMove(canvas, { clientX: p2[0], clientY: p2[1] });
|
||||||
|
fireEvent.pointerUp(canvas, { clientX: p2[0], clientY: p2[1] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createThreePointerLinearElement = (
|
||||||
|
type: ExcalidrawLinearElement["type"],
|
||||||
|
edge: "Sharp" | "Round" = "Sharp",
|
||||||
|
) => {
|
||||||
|
createTwoPointerLinearElement("line");
|
||||||
|
// Extending line via midpoint
|
||||||
|
fireEvent.pointerDown(canvas, {
|
||||||
|
clientX: midpoint[0],
|
||||||
|
clientY: midpoint[1],
|
||||||
|
});
|
||||||
|
fireEvent.pointerMove(canvas, {
|
||||||
|
clientX: midpoint[0] + 50,
|
||||||
|
clientY: midpoint[1] + 50,
|
||||||
|
});
|
||||||
|
fireEvent.pointerUp(canvas, {
|
||||||
|
clientX: midpoint[0] + 50,
|
||||||
|
clientY: midpoint[1] + 50,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragLinearElementFromPoint = (point: Point) => {
|
||||||
|
fireEvent.pointerDown(canvas, {
|
||||||
|
clientX: point[0],
|
||||||
|
clientY: point[1],
|
||||||
|
});
|
||||||
|
fireEvent.pointerMove(canvas, {
|
||||||
|
clientX: point[0] + 50,
|
||||||
|
clientY: point[1] + 50,
|
||||||
|
});
|
||||||
|
fireEvent.pointerUp(canvas, {
|
||||||
|
clientX: point[0] + 50,
|
||||||
|
clientY: point[1] + 50,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should allow dragging line from midpoint in 2 pointer lines outside editor", async () => {
|
||||||
|
createTwoPointerLinearElement("line");
|
||||||
|
const line = h.elements[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
expect(renderScene).toHaveBeenCalledTimes(10);
|
||||||
|
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2);
|
||||||
|
|
||||||
|
// drag line from midpoint
|
||||||
|
dragLinearElementFromPoint(midpoint);
|
||||||
|
expect(renderScene).toHaveBeenCalledTimes(13);
|
||||||
|
expect(line.points.length).toEqual(3);
|
||||||
|
expect(line.points).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Inside editor", () => {
|
||||||
|
it("should allow dragging line from midpoint in 2 pointer lines", async () => {
|
||||||
|
createTwoPointerLinearElement("line");
|
||||||
|
const line = h.elements[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
fireEvent.click(canvas, { clientX: p1[0], clientY: p1[1] });
|
||||||
|
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
|
||||||
|
// drag line from midpoint
|
||||||
|
dragLinearElementFromPoint(midpoint);
|
||||||
|
expect(line.points.length).toEqual(3);
|
||||||
|
expect(line.points).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow dragging lines from midpoints in between segments", async () => {
|
||||||
|
createThreePointerLinearElement("line");
|
||||||
|
|
||||||
|
const line = h.elements[0] as ExcalidrawLinearElement;
|
||||||
|
expect(line.points.length).toEqual(3);
|
||||||
|
fireEvent.click(canvas, { clientX: p1[0], clientY: p1[1] });
|
||||||
|
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
|
||||||
|
let points = LinearElementEditor.getPointsGlobalCoordinates(line);
|
||||||
|
const firstSegmentMidpoint = centerPoint(points[0], points[1]);
|
||||||
|
// drag line via first segment midpoint
|
||||||
|
dragLinearElementFromPoint(firstSegmentMidpoint);
|
||||||
|
expect(line.points.length).toEqual(4);
|
||||||
|
|
||||||
|
// drag line from last segment midpoint
|
||||||
|
points = LinearElementEditor.getPointsGlobalCoordinates(line);
|
||||||
|
const lastSegmentMidpoint = centerPoint(points.at(-2)!, points.at(-1)!);
|
||||||
|
dragLinearElementFromPoint(lastSegmentMidpoint);
|
||||||
|
expect(line.points.length).toEqual(5);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(h.elements[0] as ExcalidrawLinearElement).points,
|
||||||
|
).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user