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:
parent
db316f32e0
commit
14a66956d7
@ -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: {
|
||||||
|
@ -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 }) => (
|
||||||
|
@ -30,8 +30,8 @@ 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,
|
||||||
@ -46,7 +46,10 @@ const writeData = (
|
|||||||
.map((prevElement) =>
|
.map((prevElement) =>
|
||||||
newElementWith(prevElement, { isDeleted: true }),
|
newElementWith(prevElement, { isDeleted: true }),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
appState: { ...appState, ...data.appState },
|
appState: { ...appState, ...data.appState },
|
||||||
commitToHistory,
|
commitToHistory,
|
||||||
syncHistory: true,
|
syncHistory: true,
|
||||||
|
@ -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;
|
||||||
|
@ -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(
|
||||||
|
event,
|
||||||
this.state,
|
this.state,
|
||||||
|
(appState) => this.setState(appState),
|
||||||
|
history,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
this.state.zoom,
|
|
||||||
);
|
);
|
||||||
|
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);
|
||||||
|
@ -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>
|
||||||
|
@ -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 },
|
||||||
|
409
src/element/linearElementEditor.ts
Normal file
409
src/element/linearElementEditor.ts
Normal 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],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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 ",
|
||||||
|
@ -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 (
|
||||||
|
@ -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) => {
|
||||||
|
@ -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
@ -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;
|
||||||
|
10
src/utils.ts
10
src/utils.ts
@ -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 },
|
||||||
|
Loading…
x
Reference in New Issue
Block a user