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:
Aakansha Doshi 2022-09-14 19:55:54 +05:30 committed by GitHub
parent c5869979c8
commit 0d1058a596
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 666 additions and 113 deletions

View File

@ -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, LinearElementEditor.getSegmentMidpointHitCoords(
{ x: scenePointerX, y: scenePointerY }, linearElementEditor,
this.state, { x: scenePointerX, y: scenePointerY },
); 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,
}, },
}); });
} }

View File

@ -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) { element,
mutateElement(element, { segmentMidPoint[0],
points: [ segmentMidPoint[1],
element.points[0], appState.gridSize,
LinearElementEditor.createPointAt( );
element, const points = [
midPoint[0], ...element.points.slice(0, index),
midPoint[1], newMidPoint,
appState.gridSize, ...element.points.slice(index),
), ];
...element.points.slice(1), 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;

View File

@ -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];
};

View File

@ -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 {

View 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,
],
]
`;

View File

@ -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",
}, },

View 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();
});
});
});