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 { newElementWith } from "../element/mutateElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
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({
|
||||
name: "deleteSelectedElements",
|
||||
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 {
|
||||
elements: nextElements,
|
||||
appState: nextAppState,
|
||||
} = deleteSelectedElements(elements, appState);
|
||||
|
||||
if (appState.editingGroupId) {
|
||||
const siblingElements = getElementsInGroup(
|
||||
getNonDeletedElements(nextElements),
|
||||
appState.editingGroupId!,
|
||||
);
|
||||
if (siblingElements.length) {
|
||||
nextAppState = {
|
||||
...nextAppState,
|
||||
selectedElementIds: { [siblingElements[0].id]: true },
|
||||
};
|
||||
}
|
||||
}
|
||||
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
|
@ -8,10 +8,30 @@ import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { isPathALoop } from "../math";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
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;
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
window.document.activeElement.blur();
|
||||
@ -94,8 +114,8 @@ export const actionFinalize = register({
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
(event.key === KEYS.ESCAPE &&
|
||||
!appState.draggingElement &&
|
||||
appState.multiElement === null) ||
|
||||
(appState.editingLinearElement !== null ||
|
||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
|
@ -30,23 +30,26 @@ const writeData = (
|
||||
const prevElementMap = getElementMap(prevElements);
|
||||
const nextElements = data.elements;
|
||||
const nextElementMap = getElementMap(nextElements);
|
||||
return {
|
||||
elements: nextElements
|
||||
.map((nextElement) =>
|
||||
newElementWith(
|
||||
prevElementMap[nextElement.id] || nextElement,
|
||||
nextElement,
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
prevElements
|
||||
.filter(
|
||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||
)
|
||||
.map((prevElement) =>
|
||||
newElementWith(prevElement, { isDeleted: true }),
|
||||
),
|
||||
|
||||
const elements = nextElements
|
||||
.map((nextElement) =>
|
||||
newElementWith(
|
||||
prevElementMap[nextElement.id] || nextElement,
|
||||
nextElement,
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
prevElements
|
||||
.filter(
|
||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||
)
|
||||
.map((prevElement) =>
|
||||
newElementWith(prevElement, { isDeleted: true }),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: { ...appState, ...data.appState },
|
||||
commitToHistory,
|
||||
syncHistory: true,
|
||||
|
@ -16,6 +16,7 @@ export const getDefaultAppState = (): AppState => {
|
||||
resizingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
editingLinearElement: null,
|
||||
elementType: "selection",
|
||||
elementLocked: false,
|
||||
exportBackground: true,
|
||||
@ -70,6 +71,7 @@ export const clearAppStateForLocalStorage = (appState: AppState) => {
|
||||
isLoading,
|
||||
errorMessage,
|
||||
showShortcutsDialog,
|
||||
editingLinearElement,
|
||||
...exportedState
|
||||
} = appState;
|
||||
return exportedState;
|
||||
|
@ -132,6 +132,7 @@ import {
|
||||
} from "../data/localStorage";
|
||||
|
||||
import throttle from "lodash.throttle";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
selectGroupsForSelectedElements,
|
||||
@ -502,6 +503,16 @@ class App extends React.Component<any, AppState> {
|
||||
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: {
|
||||
[id: string]: string | undefined;
|
||||
} = {};
|
||||
@ -1182,6 +1193,19 @@ class App extends React.Component<any, AppState> {
|
||||
);
|
||||
|
||||
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 &&
|
||||
!isLinearElement(selectedElements[0])
|
||||
) {
|
||||
@ -1482,6 +1506,26 @@ class App extends React.Component<any, AppState> {
|
||||
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(
|
||||
event,
|
||||
this.state,
|
||||
@ -1581,12 +1625,28 @@ class App extends React.Component<any, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
const { x, y } = viewportCoordsToSceneCoords(
|
||||
const { x: scenePointerX, y: scenePointerY } = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.state,
|
||||
this.canvas,
|
||||
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) {
|
||||
const { multiElement } = this.state;
|
||||
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
|
||||
// threshold, add a point
|
||||
if (
|
||||
distance2d(x - rx, y - ry, lastPoint[0], lastPoint[1]) >=
|
||||
LINE_CONFIRM_THRESHOLD
|
||||
distance2d(
|
||||
scenePointerX - rx,
|
||||
scenePointerY - ry,
|
||||
lastPoint[0],
|
||||
lastPoint[1],
|
||||
) >= LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
mutateElement(multiElement, {
|
||||
points: [...points, [x - rx, y - ry]],
|
||||
points: [...points, [scenePointerX - rx, scenePointerY - ry]],
|
||||
});
|
||||
} else {
|
||||
document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
|
||||
@ -1618,8 +1682,8 @@ class App extends React.Component<any, AppState> {
|
||||
points.length > 2 &&
|
||||
lastCommittedPoint &&
|
||||
distance2d(
|
||||
x - rx,
|
||||
y - ry,
|
||||
scenePointerX - rx,
|
||||
scenePointerY - ry,
|
||||
lastCommittedPoint[0],
|
||||
lastCommittedPoint[1],
|
||||
) < LINE_CONFIRM_THRESHOLD
|
||||
@ -1634,7 +1698,10 @@ class App extends React.Component<any, AppState> {
|
||||
}
|
||||
// update last uncommitted point
|
||||
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 selectedElements = getSelectedElements(elements, this.state);
|
||||
if (selectedElements.length === 1 && !isOverScrollBar) {
|
||||
if (
|
||||
selectedElements.length === 1 &&
|
||||
!isOverScrollBar &&
|
||||
!this.state.editingLinearElement
|
||||
) {
|
||||
const elementWithResizeHandler = getElementWithResizeHandler(
|
||||
elements,
|
||||
this.state,
|
||||
{ x, y },
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
this.state.zoom,
|
||||
event.pointerType,
|
||||
);
|
||||
@ -1671,7 +1743,8 @@ class App extends React.Component<any, AppState> {
|
||||
if (canResizeMutlipleElements(selectedElements)) {
|
||||
const resizeHandle = getResizeHandlerFromCoords(
|
||||
getCommonBounds(selectedElements),
|
||||
{ x, y },
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
this.state.zoom,
|
||||
event.pointerType,
|
||||
);
|
||||
@ -1686,8 +1759,8 @@ class App extends React.Component<any, AppState> {
|
||||
const hitElement = getElementAtPosition(
|
||||
elements,
|
||||
this.state,
|
||||
x,
|
||||
y,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
this.state.zoom,
|
||||
);
|
||||
if (this.state.elementType === "text") {
|
||||
@ -1928,11 +2001,12 @@ class App extends React.Component<any, AppState> {
|
||||
if (this.state.elementType === "selection") {
|
||||
const elements = globalSceneState.getElements();
|
||||
const selectedElements = getSelectedElements(elements, this.state);
|
||||
if (selectedElements.length === 1) {
|
||||
if (selectedElements.length === 1 && !this.state.editingLinearElement) {
|
||||
const elementWithResizeHandler = getElementWithResizeHandler(
|
||||
elements,
|
||||
this.state,
|
||||
{ x, y },
|
||||
x,
|
||||
y,
|
||||
this.state.zoom,
|
||||
event.pointerType,
|
||||
);
|
||||
@ -1952,7 +2026,8 @@ class App extends React.Component<any, AppState> {
|
||||
if (canResizeMutlipleElements(selectedElements)) {
|
||||
resizeHandle = getResizeHandlerFromCoords(
|
||||
getCommonBounds(selectedElements),
|
||||
{ x, y },
|
||||
x,
|
||||
y,
|
||||
this.state.zoom,
|
||||
event.pointerType,
|
||||
);
|
||||
@ -1985,13 +2060,28 @@ class App extends React.Component<any, AppState> {
|
||||
}
|
||||
}
|
||||
if (!isResizingElements) {
|
||||
hitElement = getElementAtPosition(
|
||||
elements,
|
||||
this.state,
|
||||
x,
|
||||
y,
|
||||
this.state.zoom,
|
||||
);
|
||||
if (this.state.editingLinearElement) {
|
||||
const ret = LinearElementEditor.handlePointerDown(
|
||||
event,
|
||||
this.state,
|
||||
(appState) => this.setState(appState),
|
||||
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
|
||||
if (
|
||||
!(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]) {
|
||||
// Marking that click was used for dragging to check
|
||||
// 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");
|
||||
|
||||
// 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;
|
||||
|
||||
window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
||||
|
@ -6,6 +6,7 @@ import { getSelectedElements } from "../scene";
|
||||
import "./HintViewer.scss";
|
||||
import { AppState } from "../types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { getShortcutKey } from "../utils";
|
||||
|
||||
interface Hint {
|
||||
appState: AppState;
|
||||
@ -43,11 +44,20 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
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;
|
||||
};
|
||||
|
||||
export const HintViewer = ({ appState, elements }: Hint) => {
|
||||
const hint = getHints({
|
||||
let hint = getHints({
|
||||
appState,
|
||||
elements,
|
||||
});
|
||||
@ -55,6 +65,8 @@ export const HintViewer = ({ appState, elements }: Hint) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
hint = getShortcutKey(hint);
|
||||
|
||||
return (
|
||||
<div className="HintViewer">
|
||||
<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 = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
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 { getSizeFromPoints } from "../points";
|
||||
import { randomInteger } from "../random";
|
||||
import { Point } from "../types";
|
||||
|
||||
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
@ -24,7 +25,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
const { points } = updates as any;
|
||||
|
||||
if (typeof points !== "undefined") {
|
||||
didChange = true;
|
||||
updates = { ...getSizeFromPoints(points), ...updates };
|
||||
}
|
||||
|
||||
@ -38,6 +38,30 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
) {
|
||||
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;
|
||||
didChange = true;
|
||||
}
|
||||
|
@ -63,21 +63,31 @@ export const resizeTest = (
|
||||
export const getElementWithResizeHandler = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
{ x, y }: { x: number; y: number },
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
zoom: number,
|
||||
pointerType: PointerType,
|
||||
) =>
|
||||
elements.reduce((result, element) => {
|
||||
) => {
|
||||
return elements.reduce((result, element) => {
|
||||
if (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;
|
||||
}, null as { element: NonDeletedExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
|
||||
};
|
||||
|
||||
export const getResizeHandlerFromCoords = (
|
||||
[x1, y1, x2, y2]: readonly [number, number, number, number],
|
||||
{ x, y }: { x: number; y: number },
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
zoom: number,
|
||||
pointerType: PointerType,
|
||||
) => {
|
||||
@ -91,7 +101,7 @@ export const getResizeHandlerFromCoords = (
|
||||
|
||||
const found = Object.keys(handlers).find((key) => {
|
||||
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;
|
||||
};
|
||||
|
@ -65,7 +65,7 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "arrow" | "line" | "draw";
|
||||
points: Point[];
|
||||
points: readonly Point[];
|
||||
lastCommittedPoint?: Point | null;
|
||||
}>;
|
||||
|
||||
|
@ -22,6 +22,7 @@ const clearAppStatePropertiesForHistory = (appState: AppState) => {
|
||||
return {
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
editingLinearElement: appState.editingLinearElement,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
name: appState.name,
|
||||
};
|
||||
@ -160,6 +161,14 @@ export class SceneHistory {
|
||||
// note: this is safe because entry's appState is guaranteed no excess props
|
||||
let key: keyof typeof nextEntry.appState;
|
||||
for (key in nextEntry.appState) {
|
||||
if (key === "editingLinearElement") {
|
||||
if (
|
||||
nextEntry.appState[key]?.elementId ===
|
||||
lastEntry.appState[key]?.elementId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (key === "selectedElementIds") {
|
||||
continue;
|
||||
}
|
||||
|
@ -121,7 +121,10 @@
|
||||
"freeDraw": "Click and drag, release when you're finished",
|
||||
"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",
|
||||
"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": {
|
||||
"headingMain_pre": "Encountered an error. Try ",
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Point } from "./types";
|
||||
import { LINE_CONFIRM_THRESHOLD } from "./constants";
|
||||
import { ExcalidrawLinearElement } from "./element/types";
|
||||
|
||||
// https://stackoverflow.com/a/6853926/232122
|
||||
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
|
||||
// to be considered a loop
|
||||
export const isPathALoop = (points: Point[]): boolean => {
|
||||
export const isPathALoop = (
|
||||
points: ExcalidrawLinearElement["points"],
|
||||
): boolean => {
|
||||
if (points.length >= 3) {
|
||||
const [firstPoint, lastPoint] = [points[0], points[points.length - 1]];
|
||||
return (
|
||||
|
@ -6,6 +6,8 @@ import { FlooredNumber, AppState } from "../types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeleted,
|
||||
GroupId,
|
||||
} from "../element/types";
|
||||
import {
|
||||
@ -28,6 +30,8 @@ import { getSelectedElements } from "../scene/selection";
|
||||
|
||||
import { renderElement, renderElementToSvg } from "./renderElement";
|
||||
import colors from "../colors";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import {
|
||||
isSelectedViaGroup,
|
||||
getSelectedGroupIds,
|
||||
@ -83,6 +87,41 @@ const strokeCircle = (
|
||||
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 = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
@ -153,9 +192,16 @@ export const renderScene = (
|
||||
|
||||
visibleElements.forEach((element) => {
|
||||
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) {
|
||||
renderElement(
|
||||
selectionElement,
|
||||
@ -167,7 +213,11 @@ export const renderScene = (
|
||||
}
|
||||
|
||||
// Paint selected elements
|
||||
if (renderSelection) {
|
||||
if (
|
||||
renderSelection &&
|
||||
!appState.multiElement &&
|
||||
!appState.editingLinearElement
|
||||
) {
|
||||
context.translate(sceneState.scrollX, sceneState.scrollY);
|
||||
|
||||
const selections = elements.reduce((acc, element) => {
|
||||
|
@ -1,8 +1,13 @@
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
} from "../element/types";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
isNonDeletedElement,
|
||||
getElementMap,
|
||||
} from "../element";
|
||||
|
||||
export interface SceneStateCallback {
|
||||
(): void;
|
||||
@ -13,22 +18,40 @@ export interface SceneStateCallbackRemover {
|
||||
}
|
||||
|
||||
class GlobalScene {
|
||||
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
|
||||
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() {
|
||||
return this._elements;
|
||||
return this.elements;
|
||||
}
|
||||
|
||||
getElements(): readonly NonDeletedExcalidrawElement[] {
|
||||
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[]) {
|
||||
this._elements = nextElements;
|
||||
this.nonDeletedElements = getNonDeletedElements(this._elements);
|
||||
this.elements = nextElements;
|
||||
this.elementsMap = getElementMap(nextElements);
|
||||
this.nonDeletedElements = getNonDeletedElements(this.elements);
|
||||
this.informMutation();
|
||||
}
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@ import {
|
||||
import { SHAPES } from "./shapes";
|
||||
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
import { SocketUpdateDataSource } from "./data";
|
||||
import { LinearElementEditor } from "./element/linearElementEditor";
|
||||
|
||||
export type FlooredNumber = number & { _brand: "FlooredNumber" };
|
||||
export type Point = Readonly<RoughPoint>;
|
||||
@ -25,6 +26,7 @@ export type AppState = {
|
||||
// element being edited, but not necessarily added to elements array yet
|
||||
// (e.g. text element when typing into the input)
|
||||
editingElement: NonDeletedExcalidrawElement | null;
|
||||
editingLinearElement: LinearElementEditor | null;
|
||||
elementType: typeof SHAPES[number]["value"];
|
||||
elementLocked: 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);
|
||||
if (isMac) {
|
||||
return `${shortcut
|
||||
.replace(/CtrlOrCmd/i, "Cmd")
|
||||
.replace(/Alt/i, "Option")
|
||||
.replace(/Del/i, "Delete")
|
||||
.replace(/Enter|Return/i, "Enter")}`;
|
||||
.replace(/\bCtrlOrCmd\b/i, "Cmd")
|
||||
.replace(/\bAlt\b/i, "Option")
|
||||
.replace(/\bDel\b/i, "Delete")
|
||||
.replace(/\b(Enter|Return)\b/i, "Enter")}`;
|
||||
}
|
||||
return `${shortcut.replace(/CtrlOrCmd/i, "Ctrl")}`;
|
||||
return `${shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl")}`;
|
||||
};
|
||||
export const viewportCoordsToSceneCoords = (
|
||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||
|
Loading…
x
Reference in New Issue
Block a user