Implement line editing (#1616)

* implement line editing

* line editing with rotation

* ensure adding new points is disabled on point dragging

* fix hotkey replacement

* don't paint bounding box when creating new multipoint

* tweak points style, account for zoom and z-index

* don't persist editingLinearElement to localStorage

* don't mutate on noop points updates

* account for rotation when adding new point

* ensure clicking on points doesn't deselect element

* tweak history handling around editingline element

* update snapshots

* refactor pointerMove handling

* factor out point dragging

* factor out pointerDown

* improve positioning with rotation

* revert to use roughjs for calculating points bounds

* migrate from storing editingLinearElement.element to id

* make GlobalScene.getElement into O(1)

* use Alt for adding new points

* fix adding and deleting a point with rotation

* disable resize handlers & bounding box on line edit

Co-authored-by: daishi <daishi@axlight.com>
This commit is contained in:
David Luzar 2020-06-01 11:35:44 +02:00 committed by GitHub
parent db316f32e0
commit 14a66956d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1129 additions and 76 deletions

View File

@ -10,6 +10,7 @@ import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups"; import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -29,26 +30,80 @@ const deleteSelectedElements = (
}; };
}; };
function handleGroupEditingState(
appState: AppState,
elements: readonly ExcalidrawElement[],
): AppState {
if (appState.editingGroupId) {
const siblingElements = getElementsInGroup(
getNonDeletedElements(elements),
appState.editingGroupId!,
);
if (siblingElements.length) {
return {
...appState,
selectedElementIds: { [siblingElements[0].id]: true },
};
}
}
return appState;
}
export const actionDeleteSelected = register({ export const actionDeleteSelected = register({
name: "deleteSelectedElements", name: "deleteSelectedElements",
perform: (elements, appState) => { perform: (elements, appState) => {
if (
appState.editingLinearElement?.activePointIndex != null &&
appState.editingLinearElement?.activePointIndex > -1
) {
const { elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (element) {
// case: deleting last point
if (element.points.length < 2) {
const nextElements = elements.filter((el) => el.id !== element.id);
const nextAppState = handleGroupEditingState(appState, nextElements);
return {
elements: nextElements,
appState: {
...nextAppState,
editingLinearElement: null,
},
commitToHistory: false,
};
}
LinearElementEditor.movePoint(
element,
appState.editingLinearElement.activePointIndex,
"delete",
);
return {
elements: elements,
appState: {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
activePointIndex:
appState.editingLinearElement.activePointIndex > 0
? appState.editingLinearElement.activePointIndex - 1
: 0,
},
},
commitToHistory: true,
};
}
}
let { let {
elements: nextElements, elements: nextElements,
appState: nextAppState, appState: nextAppState,
} = deleteSelectedElements(elements, appState); } = deleteSelectedElements(elements, appState);
if (appState.editingGroupId) { nextAppState = handleGroupEditingState(nextAppState, nextElements);
const siblingElements = getElementsInGroup(
getNonDeletedElements(nextElements),
appState.editingGroupId!,
);
if (siblingElements.length) {
nextAppState = {
...nextAppState,
selectedElementIds: { [siblingElements[0].id]: true },
};
}
}
return { return {
elements: nextElements, elements: nextElements,
appState: { appState: {

View File

@ -8,10 +8,30 @@ import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { isPathALoop } from "../math"; import { isPathALoop } from "../math";
import { LinearElementEditor } from "../element/linearElementEditor";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
perform: (elements, appState) => { perform: (elements, appState) => {
if (appState.editingLinearElement) {
const { elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (element) {
return {
elements:
element.points.length < 2 || isInvisiblySmallElement(element)
? elements.filter((el) => el.id !== element.id)
: undefined,
appState: {
...appState,
editingLinearElement: null,
},
commitToHistory: true,
};
}
}
let newElements = elements; let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
window.document.activeElement.blur(); window.document.activeElement.blur();
@ -94,8 +114,8 @@ export const actionFinalize = register({
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && (event.key === KEYS.ESCAPE &&
!appState.draggingElement && (appState.editingLinearElement !== null ||
appState.multiElement === null) || (!appState.draggingElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (

View File

@ -30,23 +30,26 @@ const writeData = (
const prevElementMap = getElementMap(prevElements); const prevElementMap = getElementMap(prevElements);
const nextElements = data.elements; const nextElements = data.elements;
const nextElementMap = getElementMap(nextElements); const nextElementMap = getElementMap(nextElements);
return {
elements: nextElements const elements = nextElements
.map((nextElement) => .map((nextElement) =>
newElementWith( newElementWith(
prevElementMap[nextElement.id] || nextElement, prevElementMap[nextElement.id] || nextElement,
nextElement, nextElement,
),
)
.concat(
prevElements
.filter(
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
)
.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
), ),
)
.concat(
prevElements
.filter(
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
)
.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
);
return {
elements,
appState: { ...appState, ...data.appState }, appState: { ...appState, ...data.appState },
commitToHistory, commitToHistory,
syncHistory: true, syncHistory: true,

View File

@ -16,6 +16,7 @@ export const getDefaultAppState = (): AppState => {
resizingElement: null, resizingElement: null,
multiElement: null, multiElement: null,
editingElement: null, editingElement: null,
editingLinearElement: null,
elementType: "selection", elementType: "selection",
elementLocked: false, elementLocked: false,
exportBackground: true, exportBackground: true,
@ -70,6 +71,7 @@ export const clearAppStateForLocalStorage = (appState: AppState) => {
isLoading, isLoading,
errorMessage, errorMessage,
showShortcutsDialog, showShortcutsDialog,
editingLinearElement,
...exportedState ...exportedState
} = appState; } = appState;
return exportedState; return exportedState;

View File

@ -132,6 +132,7 @@ import {
} from "../data/localStorage"; } from "../data/localStorage";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { LinearElementEditor } from "../element/linearElementEditor";
import { import {
getSelectedGroupIds, getSelectedGroupIds,
selectGroupsForSelectedElements, selectGroupsForSelectedElements,
@ -502,6 +503,16 @@ class App extends React.Component<any, AppState> {
this.initializeSocketClient({ showLoadingState: true }); this.initializeSocketClient({ showLoadingState: true });
} }
if (
this.state.editingLinearElement &&
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
) {
// defer so that the commitToHistory flag isn't reset via current update
setTimeout(() => {
this.actionManager.executeAction(actionFinalize);
});
}
const cursorButton: { const cursorButton: {
[id: string]: string | undefined; [id: string]: string | undefined;
} = {}; } = {};
@ -1182,6 +1193,19 @@ class App extends React.Component<any, AppState> {
); );
if ( if (
selectedElements.length === 1 &&
isLinearElement(selectedElements[0])
) {
if (
!this.state.editingLinearElement ||
this.state.editingLinearElement.elementId !== selectedElements[0].id
) {
history.resumeRecording();
this.setState({
editingLinearElement: new LinearElementEditor(selectedElements[0]),
});
}
} else if (
selectedElements.length === 1 && selectedElements.length === 1 &&
!isLinearElement(selectedElements[0]) !isLinearElement(selectedElements[0])
) { ) {
@ -1482,6 +1506,26 @@ class App extends React.Component<any, AppState> {
return; return;
} }
const selectedElements = getSelectedElements(
globalSceneState.getElements(),
this.state,
);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (
!this.state.editingLinearElement ||
this.state.editingLinearElement.elementId !== selectedElements[0].id
) {
history.resumeRecording();
this.setState({
editingLinearElement: new LinearElementEditor(selectedElements[0]),
});
}
return;
}
resetCursor();
const { x, y } = viewportCoordsToSceneCoords( const { x, y } = viewportCoordsToSceneCoords(
event, event,
this.state, this.state,
@ -1581,12 +1625,28 @@ class App extends React.Component<any, AppState> {
} }
} }
const { x, y } = viewportCoordsToSceneCoords( const { x: scenePointerX, y: scenePointerY } = viewportCoordsToSceneCoords(
event, event,
this.state, this.state,
this.canvas, this.canvas,
window.devicePixelRatio, window.devicePixelRatio,
); );
if (
this.state.editingLinearElement &&
this.state.editingLinearElement.draggingElementPointIndex === null
) {
const editingLinearElement = LinearElementEditor.handlePointerMove(
event,
scenePointerX,
scenePointerY,
this.state.editingLinearElement,
);
if (editingLinearElement !== this.state.editingLinearElement) {
this.setState({ editingLinearElement });
}
}
if (this.state.multiElement) { if (this.state.multiElement) {
const { multiElement } = this.state; const { multiElement } = this.state;
const { x: rx, y: ry } = multiElement; const { x: rx, y: ry } = multiElement;
@ -1600,11 +1660,15 @@ class App extends React.Component<any, AppState> {
// if we haven't yet created a temp point and we're beyond commit-zone // if we haven't yet created a temp point and we're beyond commit-zone
// threshold, add a point // threshold, add a point
if ( if (
distance2d(x - rx, y - ry, lastPoint[0], lastPoint[1]) >= distance2d(
LINE_CONFIRM_THRESHOLD scenePointerX - rx,
scenePointerY - ry,
lastPoint[0],
lastPoint[1],
) >= LINE_CONFIRM_THRESHOLD
) { ) {
mutateElement(multiElement, { mutateElement(multiElement, {
points: [...points, [x - rx, y - ry]], points: [...points, [scenePointerX - rx, scenePointerY - ry]],
}); });
} else { } else {
document.documentElement.style.cursor = CURSOR_TYPE.POINTER; document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
@ -1618,8 +1682,8 @@ class App extends React.Component<any, AppState> {
points.length > 2 && points.length > 2 &&
lastCommittedPoint && lastCommittedPoint &&
distance2d( distance2d(
x - rx, scenePointerX - rx,
y - ry, scenePointerY - ry,
lastCommittedPoint[0], lastCommittedPoint[0],
lastCommittedPoint[1], lastCommittedPoint[1],
) < LINE_CONFIRM_THRESHOLD ) < LINE_CONFIRM_THRESHOLD
@ -1634,7 +1698,10 @@ class App extends React.Component<any, AppState> {
} }
// update last uncommitted point // update last uncommitted point
mutateElement(multiElement, { mutateElement(multiElement, {
points: [...points.slice(0, -1), [x - rx, y - ry]], points: [
...points.slice(0, -1),
[scenePointerX - rx, scenePointerY - ry],
],
}); });
} }
} }
@ -1653,11 +1720,16 @@ class App extends React.Component<any, AppState> {
const elements = globalSceneState.getElements(); const elements = globalSceneState.getElements();
const selectedElements = getSelectedElements(elements, this.state); const selectedElements = getSelectedElements(elements, this.state);
if (selectedElements.length === 1 && !isOverScrollBar) { if (
selectedElements.length === 1 &&
!isOverScrollBar &&
!this.state.editingLinearElement
) {
const elementWithResizeHandler = getElementWithResizeHandler( const elementWithResizeHandler = getElementWithResizeHandler(
elements, elements,
this.state, this.state,
{ x, y }, scenePointerX,
scenePointerY,
this.state.zoom, this.state.zoom,
event.pointerType, event.pointerType,
); );
@ -1671,7 +1743,8 @@ class App extends React.Component<any, AppState> {
if (canResizeMutlipleElements(selectedElements)) { if (canResizeMutlipleElements(selectedElements)) {
const resizeHandle = getResizeHandlerFromCoords( const resizeHandle = getResizeHandlerFromCoords(
getCommonBounds(selectedElements), getCommonBounds(selectedElements),
{ x, y }, scenePointerX,
scenePointerY,
this.state.zoom, this.state.zoom,
event.pointerType, event.pointerType,
); );
@ -1686,8 +1759,8 @@ class App extends React.Component<any, AppState> {
const hitElement = getElementAtPosition( const hitElement = getElementAtPosition(
elements, elements,
this.state, this.state,
x, scenePointerX,
y, scenePointerY,
this.state.zoom, this.state.zoom,
); );
if (this.state.elementType === "text") { if (this.state.elementType === "text") {
@ -1928,11 +2001,12 @@ class App extends React.Component<any, AppState> {
if (this.state.elementType === "selection") { if (this.state.elementType === "selection") {
const elements = globalSceneState.getElements(); const elements = globalSceneState.getElements();
const selectedElements = getSelectedElements(elements, this.state); const selectedElements = getSelectedElements(elements, this.state);
if (selectedElements.length === 1) { if (selectedElements.length === 1 && !this.state.editingLinearElement) {
const elementWithResizeHandler = getElementWithResizeHandler( const elementWithResizeHandler = getElementWithResizeHandler(
elements, elements,
this.state, this.state,
{ x, y }, x,
y,
this.state.zoom, this.state.zoom,
event.pointerType, event.pointerType,
); );
@ -1952,7 +2026,8 @@ class App extends React.Component<any, AppState> {
if (canResizeMutlipleElements(selectedElements)) { if (canResizeMutlipleElements(selectedElements)) {
resizeHandle = getResizeHandlerFromCoords( resizeHandle = getResizeHandlerFromCoords(
getCommonBounds(selectedElements), getCommonBounds(selectedElements),
{ x, y }, x,
y,
this.state.zoom, this.state.zoom,
event.pointerType, event.pointerType,
); );
@ -1985,13 +2060,28 @@ class App extends React.Component<any, AppState> {
} }
} }
if (!isResizingElements) { if (!isResizingElements) {
hitElement = getElementAtPosition( if (this.state.editingLinearElement) {
elements, const ret = LinearElementEditor.handlePointerDown(
this.state, event,
x, this.state,
y, (appState) => this.setState(appState),
this.state.zoom, history,
); x,
y,
);
if (ret.hitElement) {
hitElement = ret.hitElement;
}
if (ret.didAddPoint) {
return;
}
}
// hitElement may already be set above, so check first
hitElement =
hitElement ||
getElementAtPosition(elements, this.state, x, y, this.state.zoom);
// clear selection if shift is not clicked // clear selection if shift is not clicked
if ( if (
!(hitElement && this.state.selectedElementIds[hitElement.id]) && !(hitElement && this.state.selectedElementIds[hitElement.id]) &&
@ -2271,6 +2361,23 @@ class App extends React.Component<any, AppState> {
} }
} }
if (this.state.editingLinearElement) {
const didDrag = LinearElementEditor.handlePointDragging(
this.state,
(appState) => this.setState(appState),
x,
y,
lastX,
lastY,
);
if (didDrag) {
lastX = x;
lastY = y;
return;
}
}
if (hitElement && this.state.selectedElementIds[hitElement.id]) { if (hitElement && this.state.selectedElementIds[hitElement.id]) {
// Marking that click was used for dragging to check // Marking that click was used for dragging to check
// if elements should be deselected on pointerup // if elements should be deselected on pointerup
@ -2457,6 +2564,17 @@ class App extends React.Component<any, AppState> {
this.savePointer(childEvent.clientX, childEvent.clientY, "up"); this.savePointer(childEvent.clientX, childEvent.clientY, "up");
// if moving start/end point towards start/end point within threshold,
// close the loop
if (this.state.editingLinearElement) {
const editingLinearElement = LinearElementEditor.handlePointerUp(
this.state.editingLinearElement,
);
if (editingLinearElement !== this.state.editingLinearElement) {
this.setState({ editingLinearElement });
}
}
lastPointerUp = null; lastPointerUp = null;
window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove); window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);

View File

@ -6,6 +6,7 @@ import { getSelectedElements } from "../scene";
import "./HintViewer.scss"; import "./HintViewer.scss";
import { AppState } from "../types"; import { AppState } from "../types";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { getShortcutKey } from "../utils";
interface Hint { interface Hint {
appState: AppState; appState: AppState;
@ -43,11 +44,20 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.rotate"); return t("hints.rotate");
} }
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (appState.editingLinearElement) {
return appState.editingLinearElement.activePointIndex
? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected");
}
return t("hints.lineEditor_info");
}
return null; return null;
}; };
export const HintViewer = ({ appState, elements }: Hint) => { export const HintViewer = ({ appState, elements }: Hint) => {
const hint = getHints({ let hint = getHints({
appState, appState,
elements, elements,
}); });
@ -55,6 +65,8 @@ export const HintViewer = ({ appState, elements }: Hint) => {
return null; return null;
} }
hint = getShortcutKey(hint);
return ( return (
<div className="HintViewer"> <div className="HintViewer">
<span>{hint}</span> <span>{hint}</span>

View File

@ -343,6 +343,26 @@ export const getResizedElementAbsoluteCoords = (
]; ];
}; };
export const getElementPointsCoords = (
element: ExcalidrawLinearElement,
points: readonly (readonly [number, number])[],
): [number, number, number, number] => {
// This might be computationally heavey
const gen = rough.generator();
const curve = gen.curve(
points as [number, number][],
generateRoughOptions(element),
);
const ops = getCurvePathOps(curve);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
};
export const getClosestElementBounds = ( export const getClosestElementBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
from: { x: number; y: number }, from: { x: number; y: number },

View File

@ -0,0 +1,409 @@
import {
NonDeleted,
ExcalidrawLinearElement,
ExcalidrawElement,
} from "./types";
import { distance2d, rotate, isPathALoop } from "../math";
import { getElementAbsoluteCoords } from ".";
import { getElementPointsCoords } from "./bounds";
import { Point, AppState } from "../types";
import { mutateElement } from "./mutateElement";
import { SceneHistory } from "../history";
import { globalSceneState } from "../scene";
export class LinearElementEditor {
public elementId: ExcalidrawElement["id"];
public activePointIndex: number | null;
public draggingElementPointIndex: number | null;
public lastUncommittedPoint: Point | null;
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
LinearElementEditor.normalizePoints(element);
this.elementId = element.id;
this.activePointIndex = null;
this.lastUncommittedPoint = null;
this.draggingElementPointIndex = null;
}
// ---------------------------------------------------------------------------
// static methods
// ---------------------------------------------------------------------------
static POINT_HANDLE_SIZE = 20;
static getElement(id: ExcalidrawElement["id"]) {
const element = globalSceneState.getNonDeletedElement(id);
if (element) {
return element as NonDeleted<ExcalidrawLinearElement>;
}
return null;
}
/** @returns whether point was dragged */
static handlePointDragging(
appState: AppState,
setState: React.Component<any, AppState>["setState"],
scenePointerX: number,
scenePointerY: number,
lastX: number,
lastY: number,
): boolean {
if (!appState.editingLinearElement) {
return false;
}
const { editingLinearElement } = appState;
let { draggingElementPointIndex, elementId } = editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return false;
}
const clickedPointIndex =
draggingElementPointIndex ??
LinearElementEditor.getPointIndexUnderCursor(
element,
appState.zoom,
scenePointerX,
scenePointerY,
);
draggingElementPointIndex = draggingElementPointIndex ?? clickedPointIndex;
if (draggingElementPointIndex > -1) {
if (
editingLinearElement.draggingElementPointIndex !==
draggingElementPointIndex ||
editingLinearElement.activePointIndex !== clickedPointIndex
) {
setState({
editingLinearElement: {
...editingLinearElement,
draggingElementPointIndex,
activePointIndex: clickedPointIndex,
},
});
}
const [deltaX, deltaY] = rotate(
scenePointerX - lastX,
scenePointerY - lastY,
0,
0,
-element.angle,
);
const targetPoint = element.points[clickedPointIndex];
LinearElementEditor.movePoint(element, clickedPointIndex, [
targetPoint[0] + deltaX,
targetPoint[1] + deltaY,
]);
return true;
}
return false;
}
static handlePointerUp(
editingLinearElement: LinearElementEditor,
): LinearElementEditor {
const { elementId, draggingElementPointIndex } = editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return editingLinearElement;
}
if (
draggingElementPointIndex !== null &&
(draggingElementPointIndex === 0 ||
draggingElementPointIndex === element.points.length - 1) &&
isPathALoop(element.points)
) {
LinearElementEditor.movePoint(
element,
draggingElementPointIndex,
draggingElementPointIndex === 0
? element.points[element.points.length - 1]
: element.points[0],
);
}
if (draggingElementPointIndex !== null) {
return {
...editingLinearElement,
draggingElementPointIndex: null,
};
}
return editingLinearElement;
}
static handlePointerDown(
event: React.PointerEvent<HTMLCanvasElement>,
appState: AppState,
setState: React.Component<any, AppState>["setState"],
history: SceneHistory,
scenePointerX: number,
scenePointerY: number,
): {
didAddPoint: boolean;
hitElement: ExcalidrawElement | null;
} {
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
didAddPoint: false,
hitElement: null,
};
if (!appState.editingLinearElement) {
return ret;
}
const { elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return ret;
}
if (event.altKey) {
if (!appState.editingLinearElement.lastUncommittedPoint) {
mutateElement(element, {
points: [
...element.points,
LinearElementEditor.createPointAt(
element,
scenePointerX,
scenePointerY,
),
],
});
}
if (appState.editingLinearElement.lastUncommittedPoint !== null) {
history.resumeRecording();
}
setState({
editingLinearElement: {
...appState.editingLinearElement,
activePointIndex: element.points.length - 1,
lastUncommittedPoint: null,
},
});
ret.didAddPoint = true;
return ret;
}
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
element,
appState.zoom,
scenePointerX,
scenePointerY,
);
// 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) {
ret.hitElement = element;
}
setState({
editingLinearElement: {
...appState.editingLinearElement,
activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null,
},
});
return ret;
}
static handlePointerMove(
event: React.PointerEvent<HTMLCanvasElement>,
scenePointerX: number,
scenePointerY: number,
editingLinearElement: LinearElementEditor,
): LinearElementEditor {
const { elementId, lastUncommittedPoint } = editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return editingLinearElement;
}
const { points } = element;
const lastPoint = points[points.length - 1];
if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoint(element, points.length - 1, "delete");
}
return editingLinearElement;
}
const newPoint = LinearElementEditor.createPointAt(
element,
scenePointerX,
scenePointerY,
);
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoint(
element,
element.points.length - 1,
newPoint,
);
} else {
LinearElementEditor.movePoint(element, "new", newPoint);
}
return {
...editingLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1],
};
}
static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
) {
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];
});
}
static getPointIndexUnderCursor(
element: NonDeleted<ExcalidrawLinearElement>,
zoom: AppState["zoom"],
x: number,
y: number,
) {
const pointHandles = this.getPointsGlobalCoordinates(element);
let idx = pointHandles.length;
// loop from right to left because points on the right are rendered over
// points on the left, thus should take precedence when clicking, if they
// overlap
while (--idx > -1) {
const point = pointHandles[idx];
if (
distance2d(x, y, point[0], point[1]) * zoom <
// +1px to account for outline stroke
this.POINT_HANDLE_SIZE / 2 + 1
) {
return idx;
}
}
return -1;
}
static createPointAt(
element: NonDeleted<ExcalidrawLinearElement>,
scenePointerX: number,
scenePointerY: number,
): Point {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const [rotatedX, rotatedY] = rotate(
scenePointerX,
scenePointerY,
cx,
cy,
-element.angle,
);
return [rotatedX - element.x, rotatedY - element.y];
}
// element-mutating methods
// ---------------------------------------------------------------------------
/**
* Normalizes line points so that the start point is at [0,0]. This is
* expected in various parts of the codebase.
*/
static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
const { points } = element;
const offsetX = points[0][0];
const offsetY = points[0][1];
mutateElement(element, {
points: points.map((point, _idx) => {
return [point[0] - offsetX, point[1] - offsetY] as const;
}),
x: element.x + offsetX,
y: element.y + offsetY,
});
}
static movePoint(
element: NonDeleted<ExcalidrawLinearElement>,
pointIndex: number | "new",
targetPosition: Point | "delete",
) {
const { points } = element;
// in case we're moving start point, instead of modifying its position
// which would break the invariant of it being at [0,0], we move
// all the other points in the opposite direction by delta to
// offset it. We do the same with actual element.x/y position, so
// this hacks are completely transparent to the user.
let offsetX = 0;
let offsetY = 0;
let nextPoints: (readonly [number, number])[];
if (targetPosition === "delete") {
// remove point
if (pointIndex === "new") {
throw new Error("invalid args in movePoint");
}
nextPoints = points.slice();
nextPoints.splice(pointIndex, 1);
if (pointIndex === 0) {
// if deleting first point, make the next to be [0,0] and recalculate
// positions of the rest with respect to it
offsetX = nextPoints[0][0];
offsetY = nextPoints[0][1];
nextPoints = nextPoints.map((point, idx) => {
if (idx === 0) {
return [0, 0];
}
return [point[0] - offsetX, point[1] - offsetY];
});
}
} else if (pointIndex === "new") {
nextPoints = [...points, targetPosition];
} else {
const deltaX = targetPosition[0] - points[pointIndex][0];
const deltaY = targetPosition[1] - points[pointIndex][1];
nextPoints = points.map((point, idx) => {
if (idx === pointIndex) {
if (idx === 0) {
offsetX = deltaX;
offsetY = deltaY;
return point;
}
offsetX = 0;
offsetY = 0;
return [point[0] + deltaX, point[1] + deltaY] as const;
}
return offsetX || offsetY
? ([point[0] - offsetX, point[1] - offsetY] as const)
: point;
});
}
const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, points);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY;
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
mutateElement(element, {
points: nextPoints,
x: element.x + rotated[0],
y: element.y + rotated[1],
});
}
}

View File

@ -3,6 +3,7 @@ import { invalidateShapeForElement } from "../renderer/renderElement";
import { globalSceneState } from "../scene"; import { globalSceneState } from "../scene";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { Point } from "../types";
type ElementUpdate<TElement extends ExcalidrawElement> = Omit< type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
@ -24,7 +25,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
const { points } = updates as any; const { points } = updates as any;
if (typeof points !== "undefined") { if (typeof points !== "undefined") {
didChange = true;
updates = { ...getSizeFromPoints(points), ...updates }; updates = { ...getSizeFromPoints(points), ...updates };
} }
@ -38,6 +38,30 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
) { ) {
continue; continue;
} }
if (key === "points") {
const prevPoints = (element as any)[key];
const nextPoints = value;
if (prevPoints.length === nextPoints.length) {
let didChangePoints = false;
let i = prevPoints.length;
while (--i) {
const prevPoint: Point = prevPoints[i];
const nextPoint: Point = nextPoints[i];
if (
prevPoint[0] !== nextPoint[0] ||
prevPoint[1] !== nextPoint[1]
) {
didChangePoints = true;
break;
}
}
if (!didChangePoints) {
continue;
}
}
}
(element as any)[key] = value; (element as any)[key] = value;
didChange = true; didChange = true;
} }

View File

@ -63,21 +63,31 @@ export const resizeTest = (
export const getElementWithResizeHandler = ( export const getElementWithResizeHandler = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
{ x, y }: { x: number; y: number }, scenePointerX: number,
scenePointerY: number,
zoom: number, zoom: number,
pointerType: PointerType, pointerType: PointerType,
) => ) => {
elements.reduce((result, element) => { return elements.reduce((result, element) => {
if (result) { if (result) {
return result; return result;
} }
const resizeHandle = resizeTest(element, appState, x, y, zoom, pointerType); const resizeHandle = resizeTest(
element,
appState,
scenePointerX,
scenePointerY,
zoom,
pointerType,
);
return resizeHandle ? { element, resizeHandle } : null; return resizeHandle ? { element, resizeHandle } : null;
}, null as { element: NonDeletedExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null); }, null as { element: NonDeletedExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
};
export const getResizeHandlerFromCoords = ( export const getResizeHandlerFromCoords = (
[x1, y1, x2, y2]: readonly [number, number, number, number], [x1, y1, x2, y2]: readonly [number, number, number, number],
{ x, y }: { x: number; y: number }, scenePointerX: number,
scenePointerY: number,
zoom: number, zoom: number,
pointerType: PointerType, pointerType: PointerType,
) => { ) => {
@ -91,7 +101,7 @@ export const getResizeHandlerFromCoords = (
const found = Object.keys(handlers).find((key) => { const found = Object.keys(handlers).find((key) => {
const handler = handlers[key as Exclude<HandlerRectanglesRet, "rotation">]!; const handler = handlers[key as Exclude<HandlerRectanglesRet, "rotation">]!;
return handler && isInHandlerRect(handler, x, y); return handler && isInHandlerRect(handler, scenePointerX, scenePointerY);
}); });
return (found || false) as HandlerRectanglesRet; return (found || false) as HandlerRectanglesRet;
}; };

View File

@ -65,7 +65,7 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "arrow" | "line" | "draw"; type: "arrow" | "line" | "draw";
points: Point[]; points: readonly Point[];
lastCommittedPoint?: Point | null; lastCommittedPoint?: Point | null;
}>; }>;

View File

@ -22,6 +22,7 @@ const clearAppStatePropertiesForHistory = (appState: AppState) => {
return { return {
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,
editingLinearElement: appState.editingLinearElement,
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
name: appState.name, name: appState.name,
}; };
@ -160,6 +161,14 @@ export class SceneHistory {
// note: this is safe because entry's appState is guaranteed no excess props // note: this is safe because entry's appState is guaranteed no excess props
let key: keyof typeof nextEntry.appState; let key: keyof typeof nextEntry.appState;
for (key in nextEntry.appState) { for (key in nextEntry.appState) {
if (key === "editingLinearElement") {
if (
nextEntry.appState[key]?.elementId ===
lastEntry.appState[key]?.elementId
) {
continue;
}
}
if (key === "selectedElementIds") { if (key === "selectedElementIds") {
continue; continue;
} }

View File

@ -121,7 +121,10 @@
"freeDraw": "Click and drag, release when you're finished", "freeDraw": "Click and drag, release when you're finished",
"linearElementMulti": "Click on last point or press Escape or Enter to finish", "linearElementMulti": "Click on last point or press Escape or Enter to finish",
"resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center", "resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center",
"rotate": "You can constrain angles by holding SHIFT while rotating" "rotate": "You can constrain angles by holding SHIFT while rotating",
"lineEditor_info": "Double-click or press Enter to edit points",
"lineEditor_pointSelected": "Press Delete to remove point or drag to move",
"lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points"
}, },
"errorSplash": { "errorSplash": {
"headingMain_pre": "Encountered an error. Try ", "headingMain_pre": "Encountered an error. Try ",

View File

@ -1,5 +1,6 @@
import { Point } from "./types"; import { Point } from "./types";
import { LINE_CONFIRM_THRESHOLD } from "./constants"; import { LINE_CONFIRM_THRESHOLD } from "./constants";
import { ExcalidrawLinearElement } from "./element/types";
// https://stackoverflow.com/a/6853926/232122 // https://stackoverflow.com/a/6853926/232122
export const distanceBetweenPointAndSegment = ( export const distanceBetweenPointAndSegment = (
@ -240,7 +241,9 @@ export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
// Checks if the first and last point are close enough // Checks if the first and last point are close enough
// to be considered a loop // to be considered a loop
export const isPathALoop = (points: Point[]): boolean => { export const isPathALoop = (
points: ExcalidrawLinearElement["points"],
): boolean => {
if (points.length >= 3) { if (points.length >= 3) {
const [firstPoint, lastPoint] = [points[0], points[points.length - 1]]; const [firstPoint, lastPoint] = [points[0], points[points.length - 1]];
return ( return (

View File

@ -6,6 +6,8 @@ import { FlooredNumber, AppState } from "../types";
import { import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
GroupId, GroupId,
} from "../element/types"; } from "../element/types";
import { import {
@ -28,6 +30,8 @@ import { getSelectedElements } from "../scene/selection";
import { renderElement, renderElementToSvg } from "./renderElement"; import { renderElement, renderElementToSvg } from "./renderElement";
import colors from "../colors"; import colors from "../colors";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { import {
isSelectedViaGroup, isSelectedViaGroup,
getSelectedGroupIds, getSelectedGroupIds,
@ -83,6 +87,41 @@ const strokeCircle = (
context.stroke(); context.stroke();
}; };
const renderLinearPointHandles = (
context: CanvasRenderingContext2D,
appState: AppState,
sceneState: SceneState,
element: NonDeleted<ExcalidrawLinearElement>,
) => {
context.translate(sceneState.scrollX, sceneState.scrollY);
const origStrokeStyle = context.strokeStyle;
const lineWidth = context.lineWidth;
context.lineWidth = 1 / sceneState.zoom;
LinearElementEditor.getPointsGlobalCoordinates(element).forEach(
(point, idx) => {
context.strokeStyle = "red";
context.setLineDash([]);
context.fillStyle =
appState.editingLinearElement?.activePointIndex === idx
? "rgba(255, 127, 127, 0.9)"
: "rgba(255, 255, 255, 0.9)";
const { POINT_HANDLE_SIZE } = LinearElementEditor;
strokeCircle(
context,
point[0] - POINT_HANDLE_SIZE / 2 / sceneState.zoom,
point[1] - POINT_HANDLE_SIZE / 2 / sceneState.zoom,
POINT_HANDLE_SIZE / sceneState.zoom,
POINT_HANDLE_SIZE / sceneState.zoom,
);
},
);
context.setLineDash([]);
context.lineWidth = lineWidth;
context.translate(-sceneState.scrollX, -sceneState.scrollY);
context.strokeStyle = origStrokeStyle;
};
export const renderScene = ( export const renderScene = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
@ -153,9 +192,16 @@ export const renderScene = (
visibleElements.forEach((element) => { visibleElements.forEach((element) => {
renderElement(element, rc, context, renderOptimizations, sceneState); renderElement(element, rc, context, renderOptimizations, sceneState);
if (
isLinearElement(element) &&
appState.editingLinearElement &&
appState.editingLinearElement.elementId === element.id
) {
renderLinearPointHandles(context, appState, sceneState, element);
}
}); });
// Pain selection element // Paint selection element
if (selectionElement) { if (selectionElement) {
renderElement( renderElement(
selectionElement, selectionElement,
@ -167,7 +213,11 @@ export const renderScene = (
} }
// Paint selected elements // Paint selected elements
if (renderSelection) { if (
renderSelection &&
!appState.multiElement &&
!appState.editingLinearElement
) {
context.translate(sceneState.scrollX, sceneState.scrollY); context.translate(sceneState.scrollX, sceneState.scrollY);
const selections = elements.reduce((acc, element) => { const selections = elements.reduce((acc, element) => {

View File

@ -1,8 +1,13 @@
import { import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted,
} from "../element/types"; } from "../element/types";
import { getNonDeletedElements } from "../element"; import {
getNonDeletedElements,
isNonDeletedElement,
getElementMap,
} from "../element";
export interface SceneStateCallback { export interface SceneStateCallback {
(): void; (): void;
@ -13,22 +18,40 @@ export interface SceneStateCallbackRemover {
} }
class GlobalScene { class GlobalScene {
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
private callbacks: Set<SceneStateCallback> = new Set(); private callbacks: Set<SceneStateCallback> = new Set();
constructor(private _elements: readonly ExcalidrawElement[] = []) {} private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
private elements: readonly ExcalidrawElement[] = [];
private elementsMap: {
[id: string]: ExcalidrawElement;
} = {};
getElementsIncludingDeleted() { getElementsIncludingDeleted() {
return this._elements; return this.elements;
} }
getElements(): readonly NonDeletedExcalidrawElement[] { getElements(): readonly NonDeletedExcalidrawElement[] {
return this.nonDeletedElements; return this.nonDeletedElements;
} }
getElement(id: ExcalidrawElement["id"]): ExcalidrawElement | null {
return this.elementsMap[id] || null;
}
getNonDeletedElement(
id: ExcalidrawElement["id"],
): NonDeleted<ExcalidrawElement> | null {
const element = this.getElement(id);
if (element && isNonDeletedElement(element)) {
return element;
}
return null;
}
replaceAllElements(nextElements: readonly ExcalidrawElement[]) { replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
this._elements = nextElements; this.elements = nextElements;
this.nonDeletedElements = getNonDeletedElements(this._elements); this.elementsMap = getElementMap(nextElements);
this.nonDeletedElements = getNonDeletedElements(this.elements);
this.informMutation(); this.informMutation();
} }

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@ import {
import { SHAPES } from "./shapes"; import { SHAPES } from "./shapes";
import { Point as RoughPoint } from "roughjs/bin/geometry"; import { Point as RoughPoint } from "roughjs/bin/geometry";
import { SocketUpdateDataSource } from "./data"; import { SocketUpdateDataSource } from "./data";
import { LinearElementEditor } from "./element/linearElementEditor";
export type FlooredNumber = number & { _brand: "FlooredNumber" }; export type FlooredNumber = number & { _brand: "FlooredNumber" };
export type Point = Readonly<RoughPoint>; export type Point = Readonly<RoughPoint>;
@ -25,6 +26,7 @@ export type AppState = {
// element being edited, but not necessarily added to elements array yet // element being edited, but not necessarily added to elements array yet
// (e.g. text element when typing into the input) // (e.g. text element when typing into the input)
editingElement: NonDeletedExcalidrawElement | null; editingElement: NonDeletedExcalidrawElement | null;
editingLinearElement: LinearElementEditor | null;
elementType: typeof SHAPES[number]["value"]; elementType: typeof SHAPES[number]["value"];
elementLocked: boolean; elementLocked: boolean;
exportBackground: boolean; exportBackground: boolean;

View File

@ -168,12 +168,12 @@ export const getShortcutKey = (shortcut: string): string => {
const isMac = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform); const isMac = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
if (isMac) { if (isMac) {
return `${shortcut return `${shortcut
.replace(/CtrlOrCmd/i, "Cmd") .replace(/\bCtrlOrCmd\b/i, "Cmd")
.replace(/Alt/i, "Option") .replace(/\bAlt\b/i, "Option")
.replace(/Del/i, "Delete") .replace(/\bDel\b/i, "Delete")
.replace(/Enter|Return/i, "Enter")}`; .replace(/\b(Enter|Return)\b/i, "Enter")}`;
} }
return `${shortcut.replace(/CtrlOrCmd/i, "Ctrl")}`; return `${shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl")}`;
}; };
export const viewportCoordsToSceneCoords = ( export const viewportCoordsToSceneCoords = (
{ clientX, clientY }: { clientX: number; clientY: number }, { clientX, clientY }: { clientX: number; clientY: number },