1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-02 03:25:53 +01:00

feat: show a mid point for linear elements (#5534)

* feat: Add a mid point for linear elements

* fix tests

* show mid point only on hover

* hacky fix :(

* don't add mid points if present and only add outside editor

* improve styling and always show phantom point instead of just on hover

* fix tests

* fix

* only add polyfill for test

* add hover state for phantom point

* fix tests

* fix

* Add Array.at polyfill

* reuse `centerPoint()` helper

* reuse `distance2d`

* use `Point` type

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi 2022-08-11 20:16:25 +05:30 committed by GitHub
parent 731093f631
commit 5a8dbe8030
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 266 additions and 43 deletions

@ -1,5 +1,6 @@
import React, { useContext } from "react";
import { flushSync } from "react-dom";
import { RoughCanvas } from "roughjs/bin/canvas";
import rough from "roughjs/bin/rough";
import clsx from "clsx";
@ -3030,6 +3031,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (this.state.selectedLinearElement) {
let hoverPointIndex = -1;
let midPointHovered = false;
if (
isHittingElementNotConsideringBoundingBox(element, this.state, [
scenePointerX,
@ -3042,7 +3044,13 @@ class App extends React.Component<AppProps, AppState> {
scenePointerX,
scenePointerY,
);
if (hoverPointIndex >= 0) {
midPointHovered = LinearElementEditor.isHittingMidPoint(
linearElementEditor,
{ x: scenePointerX, y: scenePointerY },
this.state,
);
if (hoverPointIndex >= 0 || midPointHovered) {
setCursor(this.canvas, CURSOR_TYPE.POINTER);
} else {
setCursor(this.canvas, CURSOR_TYPE.MOVE);
@ -3069,6 +3077,17 @@ class App extends React.Component<AppProps, AppState> {
},
});
}
if (
this.state.selectedLinearElement.midPointHovered !== midPointHovered
) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
midPointHovered,
},
});
}
} else {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
}
@ -3623,7 +3642,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ editingLinearElement: ret.linearElementEditor });
}
}
if (ret.didAddPoint) {
if (ret.didAddPoint && !ret.isMidPoint) {
return true;
}
}
@ -4112,6 +4131,7 @@ class App extends React.Component<AppProps, AppState> {
// to ensure we don't create a 2-point arrow by mistake when
// user clicks mouse in a way that it moves a tiny bit (thus
// triggering pointermove)
if (
!pointerDownState.drag.hasOccurred &&
(this.state.activeTool.type === "arrow" ||
@ -4128,7 +4148,6 @@ class App extends React.Component<AppProps, AppState> {
return;
}
}
if (pointerDownState.resize.isResizing) {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
@ -4339,8 +4358,10 @@ class App extends React.Component<AppProps, AppState> {
}
if (points.length === 1) {
mutateElement(draggingElement, { points: [...points, [dx, dy]] });
} else if (points.length > 1) {
mutateElement(draggingElement, {
points: [...points, [dx, dy]],
});
} else if (points.length === 2) {
mutateElement(draggingElement, {
points: [...points.slice(0, -1), [dx, dy]],
});
@ -6202,4 +6223,5 @@ if (
},
});
}
export default App;

@ -11,6 +11,7 @@ import {
isPathALoop,
getGridPoint,
rotatePoint,
centerPoint,
} from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import { getElementPointsCoords } from "./bounds";
@ -51,6 +52,7 @@ export class LinearElementEditor {
| "keep";
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number;
public readonly midPointHovered: boolean;
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
this.elementId = element.id as string & {
@ -70,6 +72,7 @@ export class LinearElementEditor {
lastClickedPoint: -1,
};
this.hoverPointIndex = -1;
this.midPointHovered = false;
}
// ---------------------------------------------------------------------------
@ -157,7 +160,6 @@ export class LinearElementEditor {
if (!linearElementEditor) {
return false;
}
const { selectedPointsIndices, elementId } = linearElementEditor;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
@ -357,6 +359,59 @@ export class LinearElementEditor {
};
}
static isHittingMidPoint = (
linearElementEditor: LinearElementEditor,
scenePointer: { x: number; y: number },
appState: AppState,
) => {
if (appState.editingLinearElement) {
return false;
}
const { elementId } = linearElementEditor;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return false;
}
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
element,
appState.zoom,
scenePointer.x,
scenePointer.y,
);
if (clickedPointIndex >= 0) {
return false;
}
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
if (points.length >= 3) {
return false;
}
const midPoint = this.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);
return centerPoint(points[0], points.at(-1)!);
}
static handlePointerDown(
event: React.PointerEvent<HTMLCanvasElement>,
appState: AppState,
@ -367,11 +422,13 @@ export class LinearElementEditor {
didAddPoint: boolean;
hitElement: NonDeleted<ExcalidrawElement> | null;
linearElementEditor: LinearElementEditor | null;
isMidPoint: boolean;
} {
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
didAddPoint: false,
hitElement: null,
linearElementEditor: null,
isMidPoint: false,
};
if (!linearElementEditor) {
@ -384,6 +441,45 @@ export class LinearElementEditor {
if (!element) {
return ret;
}
const hittingMidPoint = LinearElementEditor.isHittingMidPoint(
linearElementEditor,
scenePointer,
appState,
);
if (
LinearElementEditor.isHittingMidPoint(
linearElementEditor,
scenePointer,
appState,
)
) {
const midPoint = this.getMidPoint(linearElementEditor);
if (midPoint) {
mutateElement(element, {
points: [
element.points[0],
LinearElementEditor.createPointAt(
element,
midPoint[0],
midPoint[1],
appState.gridSize,
),
...element.points.slice(1),
],
});
}
ret.didAddPoint = true;
ret.isMidPoint = true;
ret.linearElementEditor = {
...linearElementEditor,
selectedPointsIndices: element.points[1],
pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: -1,
},
lastUncommittedPoint: null,
};
}
if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) {
mutateElement(element, {
@ -397,6 +493,7 @@ export class LinearElementEditor {
),
],
});
ret.didAddPoint = true;
}
history.resumeRecording();
ret.linearElementEditor = {
@ -426,7 +523,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 > -1) {
if (clickedPointIndex >= 0 || hittingMidPoint) {
ret.hitElement = element;
} else {
// You might be wandering why we are storing the binding elements on
@ -567,14 +664,14 @@ export class LinearElementEditor {
/** scene coords */
static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
) {
): Point[] {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
return element.points.map((point) => {
let { x, y } = element;
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
return [x, y];
return [x, y] as const;
});
}

@ -1,3 +1,4 @@
import polyfill from "../polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics";
@ -83,6 +84,7 @@ import { jotaiStore, useAtomWithInitialValue } from "../jotai";
import { reconcileElements } from "./collab/reconciliation";
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
polyfill();
window.EXCALIDRAW_THROTTLE_RENDER = true;
const isExcalidrawPlusSignedUser = document.cookie.includes(

@ -1,5 +1,7 @@
import "./publicPath";
import polyfill from "../../polyfill";
import "../../../public/fonts.css";
polyfill();
export * from "./index";

@ -1,5 +1,4 @@
import React, { useEffect, forwardRef } from "react";
import { InitializeApp } from "../../components/InitializeApp";
import App from "../../components/App";

26
src/polyfill.ts Normal file

@ -0,0 +1,26 @@
const polyfill = () => {
if (!Array.prototype.at) {
// Taken from https://github.com/tc39/proposal-relative-indexing-method#polyfill so that it works in tests
/* eslint-disable */
Object.defineProperty(Array.prototype, "at", {
value: function (n: number) {
// ToInteger() abstract op
n = Math.trunc(n) || 0;
// Allow negative indexing from the end
if (n < 0) {
n += this.length;
}
// OOB access is guaranteed to return undefined
if (n < 0 || n >= this.length) {
return undefined;
}
// Otherwise, this is just normal property access
return this[n];
},
writable: true,
enumerable: false,
configurable: true,
});
}
};
export default polyfill;

@ -2,7 +2,7 @@ import { RoughCanvas } from "roughjs/bin/canvas";
import { RoughSVG } from "roughjs/bin/svg";
import oc from "open-color";
import { AppState, BinaryFiles, Zoom } from "../types";
import { AppState, BinaryFiles, Point, Zoom } from "../types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
@ -157,34 +157,105 @@ const strokeGrid = (
context.restore();
};
const renderSingleLinearPoint = (
context: CanvasRenderingContext2D,
appState: AppState,
renderConfig: RenderConfig,
point: Point,
isSelected: boolean,
isPhantomPoint = false,
) => {
context.strokeStyle = "#5e5ad8";
context.setLineDash([]);
context.fillStyle = "rgba(255, 255, 255, 0.9)";
if (isSelected) {
context.fillStyle = "rgba(134, 131, 226, 0.9)";
} else if (isPhantomPoint) {
context.fillStyle = "rgba(177, 151, 252, 0.7)";
}
const { POINT_HANDLE_SIZE } = LinearElementEditor;
const radius = appState.editingLinearElement
? POINT_HANDLE_SIZE
: POINT_HANDLE_SIZE / 2;
fillCircle(
context,
point[0],
point[1],
radius / renderConfig.zoom.value,
!isPhantomPoint,
);
};
const renderLinearPointHandles = (
context: CanvasRenderingContext2D,
appState: AppState,
renderConfig: RenderConfig,
element: NonDeleted<ExcalidrawLinearElement>,
) => {
if (!appState.selectedLinearElement) {
return;
}
context.save();
context.translate(renderConfig.scrollX, renderConfig.scrollY);
context.lineWidth = 1 / renderConfig.zoom.value;
LinearElementEditor.getPointsGlobalCoordinates(element).forEach(
(point, idx) => {
context.strokeStyle = "#5e5ad8";
context.setLineDash([]);
context.fillStyle =
appState.editingLinearElement?.selectedPointsIndices?.includes(idx)
? "rgba(134, 131, 226, 0.9)"
: "rgba(255, 255, 255, 0.9)";
const { POINT_HANDLE_SIZE } = LinearElementEditor;
const radius = appState.editingLinearElement
? POINT_HANDLE_SIZE
: POINT_HANDLE_SIZE / 2;
fillCircle(context, point[0], point[1], radius / renderConfig.zoom.value);
},
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
const centerPoint = LinearElementEditor.getMidPoint(
appState.selectedLinearElement,
);
if (!centerPoint) {
return;
}
points.forEach((point, idx) => {
const isSelected =
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
renderSingleLinearPoint(context, appState, renderConfig, point, isSelected);
});
if (!appState.editingLinearElement && points.length < 3) {
if (appState.selectedLinearElement.midPointHovered) {
const centerPoint = LinearElementEditor.getMidPoint(
appState.selectedLinearElement,
)!;
highlightPoint(centerPoint, context, appState, renderConfig);
renderSingleLinearPoint(
context,
appState,
renderConfig,
centerPoint,
false,
);
} else {
renderSingleLinearPoint(
context,
appState,
renderConfig,
centerPoint,
false,
true,
);
}
}
context.restore();
};
const highlightPoint = (
point: Point,
context: CanvasRenderingContext2D,
appState: AppState,
renderConfig: RenderConfig,
) => {
context.fillStyle = "rgba(105, 101, 219, 0.4)";
fillCircle(
context,
point[0],
point[1],
LinearElementEditor.POINT_HANDLE_SIZE / renderConfig.zoom.value,
false,
);
};
const renderLinearElementPointHighlight = (
context: CanvasRenderingContext2D,
appState: AppState,
@ -202,23 +273,14 @@ const renderLinearElementPointHighlight = (
if (!element) {
return;
}
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
hoverPointIndex,
);
context.save();
context.translate(renderConfig.scrollX, renderConfig.scrollY);
context.fillStyle = "rgba(105, 101, 219, 0.4)";
fillCircle(
context,
x,
y,
LinearElementEditor.POINT_HANDLE_SIZE / renderConfig.zoom.value,
false,
);
highlightPoint(point, context, appState, renderConfig);
context.restore();
};
@ -345,7 +407,7 @@ export const _renderScene = (
if (
appState.selectedLinearElement &&
appState.selectedLinearElement.hoverPointIndex !== -1
appState.selectedLinearElement.hoverPointIndex >= 0
) {
renderLinearElementPointHighlight(context, appState, renderConfig);
}

@ -1,8 +1,9 @@
import "@testing-library/jest-dom";
import "jest-canvas-mock";
import dotenv from "dotenv";
import polyfill from "./polyfill";
polyfill();
// jest doesn't know of .env.development so we need to init it ourselves
dotenv.config({
path: require("path").resolve(__dirname, "../.env.development"),

@ -10982,6 +10982,7 @@ Object {
"hoverPointIndex": -1,
"isDragging": false,
"lastUncommittedPoint": null,
"midPointHovered": false,
"pointerDownState": Object {
"lastClickedPoint": -1,
"prevSelectedPointsIndices": null,
@ -11207,6 +11208,7 @@ Object {
"hoverPointIndex": -1,
"isDragging": false,
"lastUncommittedPoint": null,
"midPointHovered": false,
"pointerDownState": Object {
"lastClickedPoint": -1,
"prevSelectedPointsIndices": null,
@ -11659,6 +11661,7 @@ Object {
"hoverPointIndex": -1,
"isDragging": false,
"lastUncommittedPoint": null,
"midPointHovered": false,
"pointerDownState": Object {
"lastClickedPoint": -1,
"prevSelectedPointsIndices": null,
@ -12063,6 +12066,7 @@ Object {
"hoverPointIndex": -1,
"isDragging": false,
"lastUncommittedPoint": null,
"midPointHovered": false,
"pointerDownState": Object {
"lastClickedPoint": -1,
"prevSelectedPointsIndices": null,

@ -21,6 +21,10 @@ Object {
0,
0,
],
Array [
15,
25,
],
Array [
30,
50,
@ -36,8 +40,8 @@ Object {
"strokeWidth": 1,
"type": "arrow",
"updated": 1,
"version": 3,
"versionNonce": 449462985,
"version": 4,
"versionNonce": 453191,
"width": 30,
"x": 10,
"y": 10,
@ -65,6 +69,10 @@ Object {
0,
0,
],
Array [
15,
25,
],
Array [
30,
50,
@ -80,8 +88,8 @@ Object {
"strokeWidth": 1,
"type": "line",
"updated": 1,
"version": 3,
"versionNonce": 449462985,
"version": 4,
"versionNonce": 453191,
"width": 30,
"x": 10,
"y": 10,