Reintroduce multi-point arrows and add migration for it (#635)

* Revert "Revert "Feature: Multi Point Arrows (#338)" (#634)"

This reverts commit 3d2e59bfed.

* Convert old arrow spec to new one

* Remove unnecessary failchecks and fix context transform issue in retina displays

* Remove old points failcheck from getArrowAbsoluteBounds

* Remove all failchecks for old arrow

* remove the rest of unnecessary checks

* Set default values for the arrow during import

* Add translations

* fix restore using unmigrated elements for state computation

* don't use width/height when migrating from new arrow spec

Co-authored-by: David Luzar <luzar.david@gmail.com>
Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
This commit is contained in:
Gasim Gasimzada 2020-02-01 15:49:18 +04:00 committed by GitHub
parent 4ff88ae03d
commit 1e4ce77612
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1241 additions and 112 deletions

View file

@ -44,10 +44,11 @@ import { ExcalidrawElement } from "./element/types";
import {
isInputLike,
isToolIcon,
debounce,
capitalizeString,
distance,
distance2d,
isToolIcon,
} from "./utils";
import { KEYS, isArrowKey } from "./keys";
@ -82,6 +83,7 @@ import {
actionSaveScene,
actionCopyStyles,
actionPasteStyles,
actionFinalize,
} from "./actions";
import { Action, ActionResult } from "./actions/types";
import { getDefaultAppState } from "./appState";
@ -92,6 +94,7 @@ import { ToolButton } from "./components/ToolButton";
import { LockIcon } from "./components/LockIcon";
import { ExportDialog } from "./components/ExportDialog";
import { LanguageList } from "./components/LanguageList";
import { Point } from "roughjs/bin/geometry";
import { t, languages, setLanguage, getLanguage } from "./i18n";
import { StoredScenesList } from "./components/StoredScenesList";
@ -114,6 +117,7 @@ function setCursorForShape(shape: string) {
}
}
const DRAGGING_THRESHOLD = 10; // 10px
const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
const ELEMENT_TRANSLATE_AMOUNT = 1;
const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
@ -173,6 +177,7 @@ export class App extends React.Component<any, AppState> {
canvasOnlyActions: Array<Action>;
constructor(props: any) {
super(props);
this.actionManager.registerAction(actionFinalize);
this.actionManager.registerAction(actionDeleteSelected);
this.actionManager.registerAction(actionSendToBack);
this.actionManager.registerAction(actionBringToFront);
@ -333,16 +338,7 @@ export class App extends React.Component<any, AppState> {
};
private onKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE && !this.state.draggingElement) {
elements = clearSelection(elements);
this.setState({ elementType: "selection" });
if (window.document.activeElement instanceof HTMLElement) {
window.document.activeElement.blur();
}
event.preventDefault();
return;
}
if (isInputLike(event.target)) return;
if (isInputLike(event.target) && event.key !== KEYS.ESCAPE) return;
const actionResult = this.actionManager.handleKeyDown(
event,
@ -390,19 +386,27 @@ export class App extends React.Component<any, AppState> {
} else if (event[KEYS.META] && event.code === "KeyZ") {
event.preventDefault();
if (
this.state.resizingElement ||
this.state.multiElement ||
this.state.editingElement
) {
return;
}
if (event.shiftKey) {
// Redo action
const data = history.redoOnce();
if (data !== null) {
elements = data.elements;
this.setState(data.appState);
this.setState({ ...data.appState });
}
} else {
// undo action
const data = history.undoOnce();
if (data !== null) {
elements = data.elements;
this.setState(data.appState);
this.setState({ ...data.appState });
}
}
} else if (event.key === KEYS.SPACE && !isHoldingMouseButton) {
@ -561,7 +565,7 @@ export class App extends React.Component<any, AppState> {
aria-label={capitalizeString(label)}
aria-keyshortcuts={`${label[0]} ${index + 1}`}
onChange={() => {
this.setState({ elementType: value });
this.setState({ elementType: value, multiElement: null });
elements = clearSelection(elements);
document.documentElement.style.cursor =
value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
@ -1018,11 +1022,28 @@ export class App extends React.Component<any, AppState> {
editingElement: element,
});
return;
} else if (this.state.elementType === "arrow") {
if (this.state.multiElement) {
const { multiElement } = this.state;
const { x: rx, y: ry } = multiElement;
multiElement.isSelected = true;
multiElement.points.push([x - rx, y - ry]);
multiElement.shape = null;
this.setState({ draggingElement: multiElement });
} else {
element.isSelected = false;
element.points.push([0, 0]);
element.shape = null;
elements = [...elements, element];
this.setState({
draggingElement: element,
});
}
} else {
elements = [...elements, element];
this.setState({ multiElement: null, draggingElement: element });
}
elements = [...elements, element];
this.setState({ draggingElement: element });
let lastX = x;
let lastY = y;
@ -1031,6 +1052,75 @@ export class App extends React.Component<any, AppState> {
lastY = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
}
let resizeArrowFn:
| ((
element: ExcalidrawElement,
p1: Point,
deltaX: number,
deltaY: number,
mouseX: number,
mouseY: number,
perfect: boolean,
) => void)
| null = null;
const arrowResizeOrigin = (
element: ExcalidrawElement,
p1: Point,
deltaX: number,
deltaY: number,
mouseX: number,
mouseY: number,
perfect: boolean,
) => {
// TODO: Implement perfect sizing for origin
if (perfect) {
const absPx = p1[0] + element.x;
const absPy = p1[1] + element.y;
let { width, height } = getPerfectElementSize(
"arrow",
mouseX - element.x - p1[0],
mouseY - element.y - p1[1],
);
const dx = element.x + width + p1[0];
const dy = element.y + height + p1[1];
element.x = dx;
element.y = dy;
p1[0] = absPx - element.x;
p1[1] = absPy - element.y;
} else {
element.x += deltaX;
element.y += deltaY;
p1[0] -= deltaX;
p1[1] -= deltaY;
}
};
const arrowResizeEnd = (
element: ExcalidrawElement,
p1: Point,
deltaX: number,
deltaY: number,
mouseX: number,
mouseY: number,
perfect: boolean,
) => {
if (perfect) {
const { width, height } = getPerfectElementSize(
"arrow",
mouseX - element.x,
mouseY - element.y,
);
p1[0] = width;
p1[1] = height;
} else {
p1[0] += deltaX;
p1[1] += deltaY;
}
};
const onMouseMove = (e: MouseEvent) => {
const target = e.target;
if (!(target instanceof HTMLElement)) {
@ -1057,6 +1147,16 @@ export class App extends React.Component<any, AppState> {
return;
}
// for arrows, don't start dragging until a given threshold
// 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 mousemove)
if (!draggingOccurred && this.state.elementType === "arrow") {
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD)
return;
}
if (isResizingElements && this.state.resizingElement) {
const el = this.state.resizingElement;
const selectedElements = elements.filter(el => el.isSelected);
@ -1069,73 +1169,217 @@ export class App extends React.Component<any, AppState> {
element.type === "line" || element.type === "arrow";
switch (resizeHandle) {
case "nw":
element.width -= deltaX;
element.x += deltaX;
if (
element.type === "arrow" &&
element.points.length === 2
) {
const [, p1] = element.points;
if (e.shiftKey) {
if (isLinear) {
resizePerfectLineForNWHandler(element, x, y);
} else {
element.y += element.height - element.width;
element.height = element.width;
if (!resizeArrowFn) {
if (p1[0] < 0 || p1[1] < 0) {
resizeArrowFn = arrowResizeEnd;
} else {
resizeArrowFn = arrowResizeOrigin;
}
}
resizeArrowFn(
element,
p1,
deltaX,
deltaY,
x,
y,
e.shiftKey,
);
} else {
element.height -= deltaY;
element.y += deltaY;
element.width -= deltaX;
element.x += deltaX;
if (e.shiftKey) {
if (isLinear) {
resizePerfectLineForNWHandler(element, x, y);
} else {
element.y += element.height - element.width;
element.height = element.width;
}
} else {
element.height -= deltaY;
element.y += deltaY;
}
}
break;
case "ne":
element.width += deltaX;
if (e.shiftKey) {
element.y += element.height - element.width;
element.height = element.width;
if (
element.type === "arrow" &&
element.points.length === 2
) {
const [, p1] = element.points;
if (!resizeArrowFn) {
if (p1[0] >= 0) {
resizeArrowFn = arrowResizeEnd;
} else {
resizeArrowFn = arrowResizeOrigin;
}
}
resizeArrowFn(
element,
p1,
deltaX,
deltaY,
x,
y,
e.shiftKey,
);
} else {
element.height -= deltaY;
element.y += deltaY;
element.width += deltaX;
if (e.shiftKey) {
element.y += element.height - element.width;
element.height = element.width;
} else {
element.height -= deltaY;
element.y += deltaY;
}
}
break;
case "sw":
element.width -= deltaX;
element.x += deltaX;
if (e.shiftKey) {
element.height = element.width;
if (
element.type === "arrow" &&
element.points.length === 2
) {
const [, p1] = element.points;
if (!resizeArrowFn) {
if (p1[0] <= 0) {
resizeArrowFn = arrowResizeEnd;
} else {
resizeArrowFn = arrowResizeOrigin;
}
}
resizeArrowFn(
element,
p1,
deltaX,
deltaY,
x,
y,
e.shiftKey,
);
} else {
element.height += deltaY;
element.width -= deltaX;
element.x += deltaX;
if (e.shiftKey) {
element.height = element.width;
} else {
element.height += deltaY;
}
}
break;
case "se":
if (e.shiftKey) {
if (isLinear) {
const { width, height } = getPerfectElementSize(
element.type,
x - element.x,
y - element.y,
);
element.width = width;
element.height = height;
if (
element.type === "arrow" &&
element.points.length === 2
) {
const [, p1] = element.points;
if (!resizeArrowFn) {
if (p1[0] > 0 || p1[1] > 0) {
resizeArrowFn = arrowResizeEnd;
} else {
resizeArrowFn = arrowResizeOrigin;
}
}
resizeArrowFn(
element,
p1,
deltaX,
deltaY,
x,
y,
e.shiftKey,
);
} else {
if (e.shiftKey) {
if (isLinear) {
const { width, height } = getPerfectElementSize(
element.type,
x - element.x,
y - element.y,
);
element.width = width;
element.height = height;
} else {
element.width += deltaX;
element.height = element.width;
}
} else {
element.width += deltaX;
element.height = element.width;
element.height += deltaY;
}
} else {
element.width += deltaX;
element.height += deltaY;
}
break;
case "n":
case "n": {
element.height -= deltaY;
element.y += deltaY;
if (element.points.length > 0) {
const len = element.points.length;
const points = [...element.points].sort(
(a, b) => a[1] - b[1],
);
for (let i = 1; i < points.length; ++i) {
const pnt = points[i];
pnt[1] -= deltaY / (len - i);
}
}
break;
case "w":
}
case "w": {
element.width -= deltaX;
element.x += deltaX;
if (element.points.length > 0) {
const len = element.points.length;
const points = [...element.points].sort(
(a, b) => a[0] - b[0],
);
for (let i = 0; i < points.length; ++i) {
const pnt = points[i];
pnt[0] -= deltaX / (len - i);
}
}
break;
case "s":
}
case "s": {
element.height += deltaY;
if (element.points.length > 0) {
const len = element.points.length;
const points = [...element.points].sort(
(a, b) => a[1] - b[1],
);
for (let i = 1; i < points.length; ++i) {
const pnt = points[i];
pnt[1] += deltaY / (len - i);
}
}
break;
case "e":
}
case "e": {
element.width += deltaX;
if (element.points.length > 0) {
const len = element.points.length;
const points = [...element.points].sort(
(a, b) => a[0] - b[0],
);
for (let i = 1; i < points.length; ++i) {
const pnt = points[i];
pnt[0] += deltaX / (len - i);
}
}
break;
}
}
if (resizeHandle) {
@ -1217,6 +1461,30 @@ export class App extends React.Component<any, AppState> {
draggingElement.width = width;
draggingElement.height = height;
if (this.state.elementType === "arrow") {
draggingOccurred = true;
const points = draggingElement.points;
let dx = x - draggingElement.x;
let dy = y - draggingElement.y;
if (e.shiftKey && points.length === 2) {
({ width: dx, height: dy } = getPerfectElementSize(
this.state.elementType,
dx,
dy,
));
}
if (points.length === 1) {
points.push([dx, dy]);
} else if (points.length > 1) {
const pnt = points[points.length - 1];
pnt[0] = dx;
pnt[1] = dy;
}
}
draggingElement.shape = null;
if (this.state.elementType === "selection") {
@ -1240,15 +1508,33 @@ export class App extends React.Component<any, AppState> {
const {
draggingElement,
resizingElement,
multiElement,
elementType,
elementLocked,
} = this.state;
resizeArrowFn = null;
lastMouseUp = null;
isHoldingMouseButton = false;
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
if (elementType === "arrow") {
if (draggingElement!.points.length > 1) {
history.resumeRecording();
}
if (!draggingOccurred && !multiElement) {
this.setState({ multiElement: this.state.draggingElement });
} else if (draggingOccurred && !multiElement) {
this.state.draggingElement!.isSelected = true;
this.setState({
draggingElement: null,
elementType: "selection",
});
}
return;
}
if (
elementType !== "selection" &&
draggingElement &&
@ -1328,9 +1614,15 @@ export class App extends React.Component<any, AppState> {
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
// We don't want to save history on mouseDown, only on mouseUp when it's fully configured
history.skipRecording();
this.setState({});
if (
!this.state.multiElement ||
(this.state.multiElement &&
this.state.multiElement.points.length < 2)
) {
// We don't want to save history on mouseDown, only on mouseUp when it's fully configured
history.skipRecording();
this.setState({});
}
}}
onDoubleClick={e => {
const { x, y } = viewportCoordsToSceneCoords(e, this.state);