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,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
this.state.editingLinearElement,
|
||||
this.state.gridSize,
|
||||
this.state,
|
||||
);
|
||||
if (editingLinearElement !== this.state.editingLinearElement) {
|
||||
|
||||
if (
|
||||
editingLinearElement &&
|
||||
editingLinearElement !== this.state.editingLinearElement
|
||||
) {
|
||||
// Since we are reading from previous state which is not possible with
|
||||
// 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.
|
||||
flushSync(() => {
|
||||
this.setState({ editingLinearElement });
|
||||
this.setState({
|
||||
editingLinearElement,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (editingLinearElement.lastUncommittedPoint != null) {
|
||||
if (editingLinearElement?.lastUncommittedPoint != null) {
|
||||
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||
} else {
|
||||
this.setState({ suggestedBindings: [] });
|
||||
@ -3058,7 +3063,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
if (this.state.selectedLinearElement) {
|
||||
let hoverPointIndex = -1;
|
||||
let midPointHovered = false;
|
||||
let segmentMidPointHoveredCoords = null;
|
||||
if (
|
||||
isHittingElementNotConsideringBoundingBox(element, this.state, [
|
||||
scenePointerX,
|
||||
@ -3071,13 +3076,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
);
|
||||
midPointHovered = LinearElementEditor.isHittingMidPoint(
|
||||
segmentMidPointHoveredCoords =
|
||||
LinearElementEditor.getSegmentMidpointHitCoords(
|
||||
linearElementEditor,
|
||||
{ x: scenePointerX, y: scenePointerY },
|
||||
this.state,
|
||||
);
|
||||
|
||||
if (hoverPointIndex >= 0 || midPointHovered) {
|
||||
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.POINTER);
|
||||
} else {
|
||||
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||
@ -3106,12 +3112,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.selectedLinearElement.midPointHovered !== midPointHovered
|
||||
!LinearElementEditor.arePointsEqual(
|
||||
this.state.selectedLinearElement.segmentMidPointHoveredCoords,
|
||||
segmentMidPointHoveredCoords,
|
||||
)
|
||||
) {
|
||||
this.setState({
|
||||
selectedLinearElement: {
|
||||
...this.state.selectedLinearElement,
|
||||
midPointHovered,
|
||||
segmentMidPointHoveredCoords,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -12,6 +12,11 @@ import {
|
||||
getGridPoint,
|
||||
rotatePoint,
|
||||
centerPoint,
|
||||
getControlPointsForBezierCurve,
|
||||
getBezierXY,
|
||||
getBezierCurveLength,
|
||||
mapIntervalToBezierT,
|
||||
arePointsEqual,
|
||||
} from "../math";
|
||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||
import { getElementPointsCoords } from "./bounds";
|
||||
@ -29,6 +34,12 @@ import { tupleToCoors } from "../utils";
|
||||
import { isBindingElement } from "./typeChecks";
|
||||
import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
version: number | null;
|
||||
points: (Point | null)[];
|
||||
zoom: number | null;
|
||||
} = { version: null, points: [], zoom: null };
|
||||
|
||||
export class LinearElementEditor {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@ -52,7 +63,7 @@ export class LinearElementEditor {
|
||||
| "keep";
|
||||
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public readonly hoverPointIndex: number;
|
||||
public readonly midPointHovered: boolean;
|
||||
public readonly segmentMidPointHoveredCoords: Point | null;
|
||||
|
||||
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
|
||||
this.elementId = element.id as string & {
|
||||
@ -72,7 +83,7 @@ export class LinearElementEditor {
|
||||
lastClickedPoint: -1,
|
||||
};
|
||||
this.hoverPointIndex = -1;
|
||||
this.midPointHovered = false;
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -80,7 +91,6 @@ export class LinearElementEditor {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static POINT_HANDLE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* @param id the `elementId` from the instance of this class (so that we can
|
||||
* 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,
|
||||
scenePointer: { x: number; y: number },
|
||||
appState: AppState,
|
||||
@ -367,7 +430,7 @@ export class LinearElementEditor {
|
||||
const { elementId } = linearElementEditor;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||
element,
|
||||
@ -376,37 +439,125 @@ export class LinearElementEditor {
|
||||
scenePointer.y,
|
||||
);
|
||||
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;
|
||||
}
|
||||
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(
|
||||
@ -438,33 +589,32 @@ export class LinearElementEditor {
|
||||
if (!element) {
|
||||
return ret;
|
||||
}
|
||||
const hittingMidPoint = LinearElementEditor.isHittingMidPoint(
|
||||
const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords(
|
||||
linearElementEditor,
|
||||
scenePointer,
|
||||
appState,
|
||||
);
|
||||
if (
|
||||
LinearElementEditor.isHittingMidPoint(
|
||||
if (segmentMidPoint) {
|
||||
const index = LinearElementEditor.getSegmentMidPointIndex(
|
||||
linearElementEditor,
|
||||
scenePointer,
|
||||
appState,
|
||||
)
|
||||
) {
|
||||
const midPoint = LinearElementEditor.getMidPoint(linearElementEditor);
|
||||
if (midPoint) {
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
element.points[0],
|
||||
LinearElementEditor.createPointAt(
|
||||
segmentMidPoint,
|
||||
);
|
||||
const newMidPoint = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
midPoint[0],
|
||||
midPoint[1],
|
||||
segmentMidPoint[0],
|
||||
segmentMidPoint[1],
|
||||
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.isMidPoint = true;
|
||||
ret.linearElementEditor = {
|
||||
@ -520,7 +670,7 @@ export class LinearElementEditor {
|
||||
|
||||
// if we clicked on a point, set the element as hitElement otherwise
|
||||
// it would get deselected if the point is outside the hitbox area
|
||||
if (clickedPointIndex >= 0 || hittingMidPoint) {
|
||||
if (clickedPointIndex >= 0 || segmentMidPoint) {
|
||||
ret.hitElement = element;
|
||||
} else {
|
||||
// You might be wandering why we are storing the binding elements on
|
||||
@ -579,17 +729,29 @@ export class LinearElementEditor {
|
||||
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(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
gridSize: number | null,
|
||||
): LinearElementEditor {
|
||||
const { elementId, lastUncommittedPoint } = linearElementEditor;
|
||||
appState: AppState,
|
||||
): LinearElementEditor | null {
|
||||
if (!appState.editingLinearElement) {
|
||||
return null;
|
||||
}
|
||||
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return linearElementEditor;
|
||||
return appState.editingLinearElement;
|
||||
}
|
||||
|
||||
const { points } = element;
|
||||
@ -599,7 +761,10 @@ export class LinearElementEditor {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||
}
|
||||
return { ...linearElementEditor, lastUncommittedPoint: null };
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
};
|
||||
}
|
||||
|
||||
let newPoint: Point;
|
||||
@ -611,7 +776,7 @@ export class LinearElementEditor {
|
||||
element,
|
||||
lastCommittedPoint,
|
||||
[scenePointerX, scenePointerY],
|
||||
gridSize,
|
||||
appState.gridSize,
|
||||
);
|
||||
|
||||
newPoint = [
|
||||
@ -621,9 +786,9 @@ export class LinearElementEditor {
|
||||
} else {
|
||||
newPoint = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
gridSize,
|
||||
scenePointerX - appState.editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - appState.editingLinearElement.pointerOffset.y,
|
||||
appState.gridSize,
|
||||
);
|
||||
}
|
||||
|
||||
@ -635,11 +800,10 @@ export class LinearElementEditor {
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
||||
LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]);
|
||||
}
|
||||
|
||||
return {
|
||||
...linearElementEditor,
|
||||
...appState.editingLinearElement,
|
||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||
};
|
||||
}
|
||||
@ -884,6 +1048,7 @@ export class LinearElementEditor {
|
||||
|
||||
static addPoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
targetPoints: { point: Point }[],
|
||||
) {
|
||||
const offsetX = 0;
|
||||
|
166
src/math.ts
166
src/math.ts
@ -1,6 +1,8 @@
|
||||
import { NormalizedZoomValue, Point, Zoom } from "./types";
|
||||
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 = (
|
||||
x1: number,
|
||||
@ -263,3 +265,165 @@ export const getGridPoint = (
|
||||
}
|
||||
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.lineWidth = 1 / renderConfig.zoom.value;
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||
const centerPoint = LinearElementEditor.getMidPoint(
|
||||
appState.selectedLinearElement,
|
||||
);
|
||||
if (!centerPoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { POINT_HANDLE_SIZE } = LinearElementEditor;
|
||||
const radius = appState.editingLinearElement
|
||||
? POINT_HANDLE_SIZE
|
||||
@ -221,11 +216,20 @@ const renderLinearPointHandles = (
|
||||
);
|
||||
});
|
||||
|
||||
if (points.length < 3) {
|
||||
if (appState.selectedLinearElement.midPointHovered) {
|
||||
const centerPoint = LinearElementEditor.getMidPoint(
|
||||
appState.selectedLinearElement,
|
||||
)!;
|
||||
//Rendering segment mid points
|
||||
const midPoints = LinearElementEditor.getEditorMidPoints(
|
||||
element,
|
||||
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
|
||||
// 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
|
||||
@ -235,34 +239,34 @@ const renderLinearPointHandles = (
|
||||
context,
|
||||
appState,
|
||||
renderConfig,
|
||||
centerPoint,
|
||||
segmentMidPoint,
|
||||
radius,
|
||||
false,
|
||||
);
|
||||
highlightPoint(centerPoint, context, renderConfig);
|
||||
highlightPoint(segmentMidPoint, context, renderConfig);
|
||||
} else {
|
||||
highlightPoint(centerPoint, context, renderConfig);
|
||||
highlightPoint(segmentMidPoint, context, renderConfig);
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
renderConfig,
|
||||
centerPoint,
|
||||
segmentMidPoint,
|
||||
radius,
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
} else if (appState.editingLinearElement || points.length === 2) {
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
renderConfig,
|
||||
centerPoint,
|
||||
segmentMidPoint,
|
||||
POINT_HANDLE_SIZE / 2,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
context.restore();
|
||||
};
|
||||
@ -403,6 +407,20 @@ export const _renderScene = ({
|
||||
visibleElements.forEach((element) => {
|
||||
try {
|
||||
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) {
|
||||
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
|
||||
if (appState.selectionElement) {
|
||||
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,
|
||||
"isDragging": false,
|
||||
"lastUncommittedPoint": null,
|
||||
"midPointHovered": false,
|
||||
"pointerDownState": Object {
|
||||
"lastClickedPoint": -1,
|
||||
"prevSelectedPointsIndices": null,
|
||||
@ -10991,6 +10990,7 @@ Object {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"segmentMidPointHoveredCoords": null,
|
||||
"selectedPointsIndices": null,
|
||||
"startBindingElement": "keep",
|
||||
},
|
||||
@ -11208,7 +11208,6 @@ Object {
|
||||
"hoverPointIndex": -1,
|
||||
"isDragging": false,
|
||||
"lastUncommittedPoint": null,
|
||||
"midPointHovered": false,
|
||||
"pointerDownState": Object {
|
||||
"lastClickedPoint": -1,
|
||||
"prevSelectedPointsIndices": null,
|
||||
@ -11217,6 +11216,7 @@ Object {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"segmentMidPointHoveredCoords": null,
|
||||
"selectedPointsIndices": null,
|
||||
"startBindingElement": "keep",
|
||||
},
|
||||
@ -11661,7 +11661,6 @@ Object {
|
||||
"hoverPointIndex": -1,
|
||||
"isDragging": false,
|
||||
"lastUncommittedPoint": null,
|
||||
"midPointHovered": false,
|
||||
"pointerDownState": Object {
|
||||
"lastClickedPoint": -1,
|
||||
"prevSelectedPointsIndices": null,
|
||||
@ -11670,6 +11669,7 @@ Object {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"segmentMidPointHoveredCoords": null,
|
||||
"selectedPointsIndices": null,
|
||||
"startBindingElement": "keep",
|
||||
},
|
||||
@ -12066,7 +12066,6 @@ Object {
|
||||
"hoverPointIndex": -1,
|
||||
"isDragging": false,
|
||||
"lastUncommittedPoint": null,
|
||||
"midPointHovered": false,
|
||||
"pointerDownState": Object {
|
||||
"lastClickedPoint": -1,
|
||||
"prevSelectedPointsIndices": null,
|
||||
@ -12075,6 +12074,7 @@ Object {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"segmentMidPointHoveredCoords": null,
|
||||
"selectedPointsIndices": null,
|
||||
"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