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:
parent
731093f631
commit
5a8dbe8030
@ -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
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,
|
||||
|
Loading…
Reference in New Issue
Block a user