Allow binding linear elements to other elements (#1899)
* Refactor: simplify linear element type * Refactor: dedupe scrollbar handling * First step towards binding - establish relationship and basic test for dragged lines * Refactor: use zoom from appstate * Refactor: generalize getElementAtPosition * Only consider bindable elements in hit test * Refactor: pull out pieces of hit test for reuse later * Refactor: pull out diamond from hit test for reuse later * Refactor: pull out text from hit test for reuse later * Suggest binding when hovering * Give shapes in regression test real size * Give shapes in undo/redo test real size * Keep bound element highlighted * Show binding suggestion for multi-point elements * Move binding to its on module with functions so that I can use it from actions, add support for binding end of multi-point elements * Use Id instead of ID * Improve boundary offset for non-squarish elements * Fix localStorage for binding on linear elements * Simplify dragging code and fix elements bound twice to the same shape * Fix binding for rectangles * Bind both ends at the end of the linear element creation, needed for focus points * wip * Refactor: Renames and reshapes for next commit * Calculate and store focus points and gaps, but dont use them yet * Focus points for rectangles * Dont blow up when canceling linear element * Stop suggesting binding when a non-compatible tool is selected * Clean up collision code * Using Geometric Algebra for hit tests * Correct binding for all shapes * Constant gap around polygon corners * Fix rotation handling * Generalize update and fix hit test for rotated elements * Handle rotation realtime * Handle scaling * Remove vibration when moving bound and binding element together * Handle simultenous scaling * Allow binding and unbinding when editing linear elements * Dont delete binding when the end point wasnt touched * Bind on enter/escape when editing * Support multiple suggested bindable elements in preparation for supporting linear elements dragging * Update binding when moving linear elements * Update binding when resizing linear elements * Dont re-render UI on binding hints * Update both ends when one is moved * Use distance instead of focus point for binding * Complicated approach for posterity, ignore this commit * Revert the complicated approach * Better focus point strategy, working for all shapes * Update snapshots * Dont break binding gap when mirroring shape * Dont break binding gap when grid mode pushes it inside * Dont bind draw elements * Support alt duplication * Fix alt duplication to * Support cmd+D duplication * All copy mechanisms are supported * Allow binding shapes to arrows, having arrows created first * Prevent arrows from disappearing for ellipses * Better binding suggestion highlight for shapes * Dont suggest second binding for simple elements when editing or moving them * Dont steal already bound linear elements when moving shapes * Fix highlighting diamonds and more precisely highlight other shapes * Highlight linear element edges for binding * Highlight text binding too * Handle deletion * Dont suggest second binding for simple linear elements when creating them * Dont highlight bound element during creation * Fix binding for rotated linear elements * Fix collision check for ellipses * Dont show suggested bindings for selected pairs * Bind multi-point linear elements when the tool is switched - important for mobile * Handle unbinding one of two bound edges correctly * Rename boundElement in state to startBoundElement * Dont double account for zoom when rendering binding highlight * Fix rendering of edited linear element point handles * Suggest binding when adding new point to a linear element * Bind when adding a new point to a linear element and dont unbind when moving middle elements * Handle deleting points * Add cmd modifier key to disable binding * Use state for enabling binding, fix not binding for linear elements during creation * Drop support for binding lines, only arrows are bindable * Reset binding mode on blur * Fix not binding lines
This commit is contained in:
parent
5f195694ee
commit
26f67d27ec
@ -11,6 +11,7 @@ import { AppState } from "../types";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -53,7 +54,12 @@ export const actionDeleteSelected = register({
|
||||
name: "deleteSelectedElements",
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, activePointIndex } = appState.editingLinearElement;
|
||||
const {
|
||||
elementId,
|
||||
activePointIndex,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return false;
|
||||
@ -62,7 +68,7 @@ export const actionDeleteSelected = register({
|
||||
// case: no point selected → delete whole element
|
||||
activePointIndex == null ||
|
||||
activePointIndex === -1 ||
|
||||
// case: deleting last point
|
||||
// case: deleting last remaining point
|
||||
element.points.length < 2
|
||||
) {
|
||||
const nextElements = elements.filter((el) => el.id !== element.id);
|
||||
@ -78,6 +84,17 @@ export const actionDeleteSelected = register({
|
||||
};
|
||||
}
|
||||
|
||||
// We cannot do this inside `movePoint` because it is also called
|
||||
// when deleting the uncommitted point (which hasn't caused any binding)
|
||||
const binding = {
|
||||
startBindingElement:
|
||||
activePointIndex === 0 ? null : startBindingElement,
|
||||
endBindingElement:
|
||||
activePointIndex === element.points.length - 1
|
||||
? null
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
||||
|
||||
return {
|
||||
@ -86,6 +103,7 @@ export const actionDeleteSelected = register({
|
||||
...appState,
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
...binding,
|
||||
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
|
||||
},
|
||||
},
|
||||
@ -97,6 +115,10 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: nextAppState,
|
||||
} = deleteSelectedElements(elements, appState);
|
||||
fixBindingsAfterDeletion(
|
||||
nextElements,
|
||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||
);
|
||||
|
||||
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
||||
|
||||
|
@ -11,6 +11,9 @@ import { getShortcutKey } from "../utils";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||
import { ActionResult } from "./types";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
@ -50,40 +53,8 @@ export const actionDuplicateSelection = register({
|
||||
};
|
||||
}
|
||||
|
||||
const groupIdMap = new Map();
|
||||
const newElements: ExcalidrawElement[] = [];
|
||||
const finalElements = elements.reduce(
|
||||
(acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
const newElement = duplicateElement(
|
||||
appState.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
{
|
||||
x: element.x + 10,
|
||||
y: element.y + 10,
|
||||
},
|
||||
);
|
||||
newElements.push(newElement);
|
||||
return acc.concat([element, newElement]);
|
||||
}
|
||||
return acc.concat(element);
|
||||
},
|
||||
[],
|
||||
);
|
||||
return {
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{
|
||||
...appState,
|
||||
selectedGroupIds: {},
|
||||
selectedElementIds: newElements.reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as any),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
),
|
||||
elements: finalElements,
|
||||
...duplicateElements(elements, appState),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
@ -102,3 +73,49 @@ export const actionDuplicateSelection = register({
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
const duplicateElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
): Partial<ActionResult> => {
|
||||
const groupIdMap = new Map();
|
||||
const newElements: ExcalidrawElement[] = [];
|
||||
const oldElements: ExcalidrawElement[] = [];
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const finalElements = elements.reduce(
|
||||
(acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
const newElement = duplicateElement(
|
||||
appState.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
{
|
||||
x: element.x + 10,
|
||||
y: element.y + 10,
|
||||
},
|
||||
);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
oldElements.push(element);
|
||||
newElements.push(newElement);
|
||||
return acc.concat([element, newElement]);
|
||||
}
|
||||
return acc.concat(element);
|
||||
},
|
||||
[],
|
||||
);
|
||||
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
||||
return {
|
||||
elements: finalElements,
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{
|
||||
...appState,
|
||||
selectedGroupIds: {},
|
||||
selectedElementIds: newElements.reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as any),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
@ -9,15 +9,32 @@ import { register } from "./register";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { isPathALoop } from "../math";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
} from "../element/binding";
|
||||
import { isBindingElement } from "../element/typeChecks";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId } = appState.editingLinearElement;
|
||||
const {
|
||||
elementId,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
|
||||
if (element) {
|
||||
if (isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
);
|
||||
}
|
||||
return {
|
||||
elements:
|
||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||
@ -66,11 +83,12 @@ export const actionFinalize = register({
|
||||
// If the multi point line closes the loop,
|
||||
// set the last point to first point.
|
||||
// This ensures that loop remains closed at different scales.
|
||||
const isLoop = isPathALoop(multiPointElement.points);
|
||||
if (
|
||||
multiPointElement.type === "line" ||
|
||||
multiPointElement.type === "draw"
|
||||
) {
|
||||
if (isPathALoop(multiPointElement.points)) {
|
||||
if (isLoop) {
|
||||
const linePoints = multiPointElement.points;
|
||||
const firstPoint = linePoints[0];
|
||||
mutateElement(multiPointElement, {
|
||||
@ -83,6 +101,23 @@ export const actionFinalize = register({
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isBindingElement(multiPointElement) &&
|
||||
!isLoop &&
|
||||
multiPointElement.points.length > 1
|
||||
) {
|
||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiPointElement,
|
||||
-1,
|
||||
);
|
||||
maybeBindLinearElement(
|
||||
multiPointElement,
|
||||
appState,
|
||||
Scene.getScene(multiPointElement)!,
|
||||
{ x, y },
|
||||
);
|
||||
}
|
||||
|
||||
if (!appState.elementLocked) {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
@ -101,6 +136,8 @@ export const actionFinalize = register({
|
||||
draggingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
multiPointElement && !appState.elementLocked
|
||||
? {
|
||||
|
@ -9,6 +9,7 @@ import { AppState } from "../types";
|
||||
import { KEYS } from "../keys";
|
||||
import { getElementMap } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
|
||||
const writeData = (
|
||||
prevElements: readonly ExcalidrawElement[],
|
||||
@ -31,6 +32,9 @@ const writeData = (
|
||||
const nextElements = data.elements;
|
||||
const nextElementMap = getElementMap(nextElements);
|
||||
|
||||
const deletedElements = prevElements.filter(
|
||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||
);
|
||||
const elements = nextElements
|
||||
.map((nextElement) =>
|
||||
newElementWith(
|
||||
@ -39,14 +43,11 @@ const writeData = (
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
prevElements
|
||||
.filter(
|
||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||
)
|
||||
.map((prevElement) =>
|
||||
deletedElements.map((prevElement) =>
|
||||
newElementWith(prevElement, { isDeleted: true }),
|
||||
),
|
||||
);
|
||||
fixBindingsAfterDeletion(elements, deletedElements);
|
||||
|
||||
return {
|
||||
elements,
|
||||
|
@ -19,6 +19,7 @@ export const getDefaultAppState = (): Omit<
|
||||
resizingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
startBoundElement: null,
|
||||
editingLinearElement: null,
|
||||
elementType: "selection",
|
||||
elementLocked: false,
|
||||
@ -43,6 +44,7 @@ export const getDefaultAppState = (): Omit<
|
||||
scrolledOutside: false,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
username: "",
|
||||
isBindingEnabled: true,
|
||||
isCollaborating: false,
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
@ -55,6 +57,7 @@ export const getDefaultAppState = (): Omit<
|
||||
collaborators: new Map(),
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showShortcutsDialog: false,
|
||||
suggestedBindings: [],
|
||||
zenModeEnabled: false,
|
||||
gridSize: null,
|
||||
editingGroupId: null,
|
||||
@ -96,6 +99,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
cursorY: { browser: true, export: false },
|
||||
draggingElement: { browser: false, export: false },
|
||||
editingElement: { browser: false, export: false },
|
||||
startBoundElement: { browser: false, export: false },
|
||||
editingGroupId: { browser: true, export: false },
|
||||
editingLinearElement: { browser: false, export: false },
|
||||
elementLocked: { browser: true, export: false },
|
||||
@ -104,6 +108,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
exportBackground: { browser: true, export: false },
|
||||
gridSize: { browser: true, export: true },
|
||||
height: { browser: false, export: false },
|
||||
isBindingEnabled: { browser: false, export: false },
|
||||
isCollaborating: { browser: false, export: false },
|
||||
isLibraryOpen: { browser: false, export: false },
|
||||
isLoading: { browser: false, export: false },
|
||||
@ -124,6 +129,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
shouldAddWatermark: { browser: true, export: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||
showShortcutsDialog: { browser: false, export: false },
|
||||
suggestedBindings: { browser: false, export: false },
|
||||
username: { browser: true, export: false },
|
||||
viewBackgroundColor: { browser: true, export: true },
|
||||
width: { browser: false, export: false },
|
||||
|
@ -31,6 +31,7 @@ import {
|
||||
dragSelectedElements,
|
||||
getDragOffsetXY,
|
||||
dragNewElement,
|
||||
hitTest,
|
||||
} from "../element";
|
||||
import {
|
||||
getElementsWithinSelection,
|
||||
@ -60,6 +61,8 @@ import {
|
||||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawBindableElement,
|
||||
} from "../element/types";
|
||||
|
||||
import { distance2d, isPathALoop, getGridPoint } from "../math";
|
||||
@ -136,7 +139,13 @@ import { generateCollaborationLink, getCollaborationLinkData } from "../data";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||
import { unstable_batchedUpdates } from "react-dom";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import {
|
||||
isLinearElement,
|
||||
isLinearElementType,
|
||||
isBindingElement,
|
||||
isBindingElementType,
|
||||
isBindableElement,
|
||||
} from "../element/typeChecks";
|
||||
import { actionFinalize, actionDeleteSelected } from "../actions";
|
||||
import {
|
||||
restoreUsernameFromLocalStorage,
|
||||
@ -154,6 +163,19 @@ import {
|
||||
} from "../groups";
|
||||
import { Library } from "../data/library";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
getHoveredElementForBinding,
|
||||
maybeBindLinearElement,
|
||||
getEligibleElementsForBinding,
|
||||
bindOrUnbindSelectedElements,
|
||||
unbindLinearElements,
|
||||
fixBindingsAfterDuplication,
|
||||
maybeBindBindableElement,
|
||||
getElligibleElementForBindingElementAtCoors,
|
||||
fixBindingsAfterDeletion,
|
||||
isLinearElementSimpleAndAlreadyBound,
|
||||
isBindingEnabled,
|
||||
} from "../element/binding";
|
||||
|
||||
/**
|
||||
* @param func handler taking at most single parameter (event).
|
||||
@ -407,6 +429,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
private onBlur = withBatchedUpdates(() => {
|
||||
isHoldingSpace = false;
|
||||
this.setState({ isBindingEnabled: true });
|
||||
this.saveDebounced();
|
||||
this.saveDebounced.flush();
|
||||
});
|
||||
@ -690,7 +713,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.broadcastScene(SCENE.UPDATE, /* syncAll */ true);
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
componentDidUpdate(prevProps: ExcalidrawProps) {
|
||||
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
|
||||
const { width: prevWidth, height: prevHeight } = prevProps;
|
||||
const { width: currentWidth, height: currentHeight } = this.props;
|
||||
if (prevWidth !== currentWidth || prevHeight !== currentHeight) {
|
||||
@ -714,6 +737,25 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
});
|
||||
}
|
||||
const { multiElement } = prevState;
|
||||
if (
|
||||
prevState.elementType !== this.state.elementType &&
|
||||
multiElement != null &&
|
||||
isBindingEnabled(this.state) &&
|
||||
isBindingElement(multiElement)
|
||||
) {
|
||||
maybeBindLinearElement(
|
||||
multiElement,
|
||||
this.state,
|
||||
this.scene,
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiElement,
|
||||
-1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const cursorButton: {
|
||||
[id: string]: string | undefined;
|
||||
@ -950,16 +992,31 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
const dy = y - elementsCenterY;
|
||||
const groupIdMap = new Map();
|
||||
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const newElements = clipboardElements.map((element) => {
|
||||
return duplicateElement(this.state.editingGroupId, groupIdMap, element, {
|
||||
const newElement = duplicateElement(
|
||||
this.state.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
{
|
||||
x: element.x + dx - minX,
|
||||
y: element.y + dy - minY,
|
||||
},
|
||||
);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
return newElement;
|
||||
});
|
||||
});
|
||||
this.scene.replaceAllElements([
|
||||
const nextElements = [
|
||||
...this.scene.getElementsIncludingDeleted(),
|
||||
...newElements,
|
||||
]);
|
||||
];
|
||||
fixBindingsAfterDuplication(
|
||||
nextElements,
|
||||
clipboardElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
history.resumeRecording();
|
||||
this.setState(
|
||||
selectGroupsForSelectedElements(
|
||||
@ -1403,6 +1460,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
if (event[KEYS.CTRL_OR_CMD] && event.keyCode === KEYS.GRID_KEY_CODE) {
|
||||
this.toggleGridMode();
|
||||
}
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
this.setState({ isBindingEnabled: false });
|
||||
}
|
||||
|
||||
if (event.code === "KeyC" && event.altKey && event.shiftKey) {
|
||||
this.copyToClipboardAsPng();
|
||||
@ -1511,6 +1571,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
isHoldingSpace = false;
|
||||
}
|
||||
if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
|
||||
this.setState({ isBindingEnabled: true });
|
||||
}
|
||||
});
|
||||
|
||||
private selectShapeTool(elementType: AppState["elementType"]) {
|
||||
@ -1520,6 +1583,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
if (isToolIcon(document.activeElement)) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
if (!isLinearElementType(elementType)) {
|
||||
this.setState({ suggestedBindings: [] });
|
||||
}
|
||||
if (elementType !== "selection") {
|
||||
this.setState({
|
||||
elementType,
|
||||
@ -1558,10 +1624,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
gesture.initialScale = null;
|
||||
});
|
||||
|
||||
private setElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
this.scene.replaceAllElements(elements);
|
||||
};
|
||||
|
||||
private handleTextWysiwyg(
|
||||
element: ExcalidrawTextElement,
|
||||
{
|
||||
@ -1612,6 +1674,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
[element.id]: true,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
fixBindingsAfterDeletion(this.scene.getElements(), [element]);
|
||||
}
|
||||
if (!isDeleted || isExistingElement) {
|
||||
history.resumeRecording();
|
||||
@ -1643,13 +1707,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
x: number,
|
||||
y: number,
|
||||
): NonDeleted<ExcalidrawTextElement> | null {
|
||||
const element = getElementAtPosition(
|
||||
this.scene.getElements(),
|
||||
this.state,
|
||||
x,
|
||||
y,
|
||||
this.state.zoom,
|
||||
);
|
||||
const element = this.getElementAtPosition(x, y);
|
||||
|
||||
if (element && isTextElement(element) && !element.isDeleted) {
|
||||
return element;
|
||||
@ -1657,6 +1715,15 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
private getElementAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
): NonDeleted<ExcalidrawElement> | null {
|
||||
return getElementAtPosition(this.scene.getElements(), (element) =>
|
||||
hitTest(element, this.state, x, y),
|
||||
);
|
||||
}
|
||||
|
||||
private startTextEditing = ({
|
||||
sceneX,
|
||||
sceneY,
|
||||
@ -1786,14 +1853,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
const selectedGroupIds = getSelectedGroupIds(this.state);
|
||||
|
||||
if (selectedGroupIds.length > 0) {
|
||||
const elements = this.scene.getElements();
|
||||
const hitElement = getElementAtPosition(
|
||||
elements,
|
||||
this.state,
|
||||
sceneX,
|
||||
sceneY,
|
||||
this.state.zoom,
|
||||
);
|
||||
const hitElement = this.getElementAtPosition(sceneX, sceneY);
|
||||
|
||||
const selectedGroupId =
|
||||
hitElement &&
|
||||
@ -1873,12 +1933,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
const { x: scenePointerX, y: scenePointerY } = viewportCoordsToSceneCoords(
|
||||
const scenePointer = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.state,
|
||||
this.canvas,
|
||||
window.devicePixelRatio,
|
||||
);
|
||||
const { x: scenePointerX, y: scenePointerY } = scenePointer;
|
||||
|
||||
if (
|
||||
this.state.editingLinearElement &&
|
||||
@ -1894,6 +1955,27 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
if (editingLinearElement !== this.state.editingLinearElement) {
|
||||
this.setState({ editingLinearElement });
|
||||
}
|
||||
if (editingLinearElement.lastUncommittedPoint != null) {
|
||||
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||
} else {
|
||||
this.setState({ suggestedBindings: [] });
|
||||
}
|
||||
}
|
||||
|
||||
if (isBindingElementType(this.state.elementType)) {
|
||||
// Hovering with a selected tool or creating new linear element via click
|
||||
// and point
|
||||
const { draggingElement } = this.state;
|
||||
if (isBindingElement(draggingElement)) {
|
||||
this.maybeSuggestBindingForLinearElementAtCursor(
|
||||
draggingElement,
|
||||
"end",
|
||||
scenePointer,
|
||||
this.state.startBoundElement,
|
||||
);
|
||||
} else {
|
||||
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.multiElement) {
|
||||
@ -1954,6 +2036,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -2003,13 +2086,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const hitElement = getElementAtPosition(
|
||||
elements,
|
||||
this.state,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
this.state.zoom,
|
||||
);
|
||||
const hitElement = this.getElementAtPosition(scenePointerX, scenePointerY);
|
||||
if (this.state.elementType === "text") {
|
||||
document.documentElement.style.cursor = isTextElement(hitElement)
|
||||
? CURSOR_TYPE.TEXT
|
||||
@ -2328,24 +2405,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointerDownState.scrollbars.isOverHorizontal) {
|
||||
const x = event.clientX;
|
||||
const dx = x - pointerDownState.lastCoords.x;
|
||||
this.setState({
|
||||
scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
|
||||
});
|
||||
pointerDownState.lastCoords.x = x;
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointerDownState.scrollbars.isOverVertical) {
|
||||
const y = event.clientY;
|
||||
const dy = y - pointerDownState.lastCoords.y;
|
||||
this.setState({
|
||||
scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
|
||||
});
|
||||
pointerDownState.lastCoords.y = y;
|
||||
}
|
||||
this.handlePointerMoveOverScrollbars(event, pointerDownState);
|
||||
});
|
||||
|
||||
const onPointerUp = withBatchedUpdates(() => {
|
||||
@ -2440,8 +2500,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.state,
|
||||
(appState) => this.setState(appState),
|
||||
history,
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
pointerDownState.origin,
|
||||
);
|
||||
if (ret.hitElement) {
|
||||
pointerDownState.hit.element = ret.hitElement;
|
||||
@ -2454,12 +2513,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
// hitElement may already be set above, so check first
|
||||
pointerDownState.hit.element =
|
||||
pointerDownState.hit.element ??
|
||||
getElementAtPosition(
|
||||
elements,
|
||||
this.state,
|
||||
this.getElementAtPosition(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
this.state.zoom,
|
||||
);
|
||||
|
||||
this.maybeClearSelectionWhenHittingElement(
|
||||
@ -2544,7 +2600,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
private handleLinearElementOnPointerDown = (
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
elementType: "draw" | "line" | "arrow",
|
||||
elementType: ExcalidrawLinearElement["type"],
|
||||
pointerDownState: PointerDownState,
|
||||
): void => {
|
||||
if (this.state.multiElement) {
|
||||
@ -2616,6 +2672,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
mutateElement(element, {
|
||||
points: [...element.points, [0, 0]],
|
||||
});
|
||||
const boundElement = getHoveredElementForBinding(
|
||||
pointerDownState.origin,
|
||||
this.scene,
|
||||
);
|
||||
this.scene.replaceAllElements([
|
||||
...this.scene.getElementsIncludingDeleted(),
|
||||
element,
|
||||
@ -2623,6 +2683,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.setState({
|
||||
draggingElement: element,
|
||||
editingElement: element,
|
||||
startBoundElement: boundElement,
|
||||
suggestedBindings: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -2690,33 +2752,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointerDownState.scrollbars.isOverHorizontal) {
|
||||
const x = event.clientX;
|
||||
const dx = x - pointerDownState.lastCoords.x;
|
||||
this.setState({
|
||||
scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
|
||||
});
|
||||
pointerDownState.lastCoords.x = x;
|
||||
if (this.handlePointerMoveOverScrollbars(event, pointerDownState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointerDownState.scrollbars.isOverVertical) {
|
||||
const y = event.clientY;
|
||||
const dy = y - pointerDownState.lastCoords.y;
|
||||
this.setState({
|
||||
scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
|
||||
});
|
||||
pointerDownState.lastCoords.y = y;
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y } = viewportCoordsToSceneCoords(
|
||||
const pointerCoords = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.state,
|
||||
this.canvas,
|
||||
window.devicePixelRatio,
|
||||
);
|
||||
const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize);
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
this.state.gridSize,
|
||||
);
|
||||
|
||||
// for arrows/lines, don't start dragging until a given threshold
|
||||
// to ensure we don't create a 2-point arrow by mistake when
|
||||
@ -2729,8 +2779,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
) {
|
||||
if (
|
||||
distance2d(
|
||||
x,
|
||||
y,
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
) < DRAGGING_THRESHOLD
|
||||
@ -2753,8 +2803,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
isRotating: resizeHandle === "rotation",
|
||||
});
|
||||
const [resizeX, resizeY] = getGridPoint(
|
||||
x - pointerDownState.resize.offset.x,
|
||||
y - pointerDownState.resize.offset.y,
|
||||
pointerCoords.x - pointerDownState.resize.offset.x,
|
||||
pointerCoords.y - pointerDownState.resize.offset.y,
|
||||
this.state.gridSize,
|
||||
);
|
||||
if (
|
||||
@ -2775,6 +2825,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
pointerDownState.resize.originalElements,
|
||||
)
|
||||
) {
|
||||
this.maybeSuggestBindingForAll(selectedElements);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -2783,13 +2834,20 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
const didDrag = LinearElementEditor.handlePointDragging(
|
||||
this.state,
|
||||
(appState) => this.setState(appState),
|
||||
x,
|
||||
y,
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
(element, startOrEnd) => {
|
||||
this.maybeSuggestBindingForLinearElementAtCursor(
|
||||
element,
|
||||
startOrEnd,
|
||||
pointerCoords,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (didDrag) {
|
||||
pointerDownState.lastCoords.x = x;
|
||||
pointerDownState.lastCoords.y = y;
|
||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -2805,11 +2863,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
);
|
||||
if (selectedElements.length > 0) {
|
||||
const [dragX, dragY] = getGridPoint(
|
||||
x - pointerDownState.drag.offset.x,
|
||||
y - pointerDownState.drag.offset.y,
|
||||
pointerCoords.x - pointerDownState.drag.offset.x,
|
||||
pointerCoords.y - pointerDownState.drag.offset.y,
|
||||
this.state.gridSize,
|
||||
);
|
||||
dragSelectedElements(selectedElements, dragX, dragY);
|
||||
dragSelectedElements(selectedElements, dragX, dragY, this.scene);
|
||||
this.maybeSuggestBindingForAll(selectedElements);
|
||||
|
||||
// We duplicate the selected element if alt is pressed on pointer move
|
||||
if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
|
||||
@ -2822,6 +2881,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
const nextElements = [];
|
||||
const elementsToAppend = [];
|
||||
const groupIdMap = new Map();
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
for (const element of this.scene.getElementsIncludingDeleted()) {
|
||||
if (
|
||||
this.state.selectedElementIds[element.id] ||
|
||||
@ -2846,14 +2906,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
});
|
||||
nextElements.push(duplicatedElement);
|
||||
elementsToAppend.push(element);
|
||||
oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
|
||||
} else {
|
||||
nextElements.push(element);
|
||||
}
|
||||
}
|
||||
this.scene.replaceAllElements([
|
||||
...nextElements,
|
||||
...elementsToAppend,
|
||||
]);
|
||||
const nextSceneElements = [...nextElements, ...elementsToAppend];
|
||||
fixBindingsAfterDuplication(
|
||||
nextSceneElements,
|
||||
elementsToAppend,
|
||||
oldIdToDuplicatedId,
|
||||
"duplicatesServeAsOld",
|
||||
);
|
||||
this.scene.replaceAllElements(nextSceneElements);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@ -2872,8 +2937,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
let dx: number;
|
||||
let dy: number;
|
||||
if (draggingElement.type === "draw") {
|
||||
dx = x - draggingElement.x;
|
||||
dy = y - draggingElement.y;
|
||||
dx = pointerCoords.x - draggingElement.x;
|
||||
dy = pointerCoords.y - draggingElement.y;
|
||||
} else {
|
||||
dx = gridX - draggingElement.x;
|
||||
dy = gridY - draggingElement.y;
|
||||
@ -2903,16 +2968,25 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isBindingElement(draggingElement)) {
|
||||
// When creating a linear element by dragging
|
||||
this.maybeSuggestBindingForLinearElementAtCursor(
|
||||
draggingElement,
|
||||
"end",
|
||||
pointerCoords,
|
||||
this.state.startBoundElement,
|
||||
);
|
||||
}
|
||||
} else if (draggingElement.type === "selection") {
|
||||
dragNewElement(
|
||||
draggingElement,
|
||||
this.state.elementType,
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
x,
|
||||
y,
|
||||
distance(pointerDownState.origin.x, x),
|
||||
distance(pointerDownState.origin.y, y),
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
distance(pointerDownState.origin.x, pointerCoords.x),
|
||||
distance(pointerDownState.origin.y, pointerCoords.y),
|
||||
getResizeWithSidesSameLengthKey(event),
|
||||
getResizeCenterPointKey(event),
|
||||
);
|
||||
@ -2929,6 +3003,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
getResizeWithSidesSameLengthKey(event),
|
||||
getResizeCenterPointKey(event),
|
||||
);
|
||||
this.maybeSuggestBindingForAll([draggingElement]);
|
||||
}
|
||||
|
||||
if (this.state.elementType === "selection") {
|
||||
@ -2963,6 +3038,33 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
});
|
||||
}
|
||||
|
||||
// Returns whether the pointer move happened over either scrollbar
|
||||
private handlePointerMoveOverScrollbars(
|
||||
event: PointerEvent,
|
||||
pointerDownState: PointerDownState,
|
||||
): boolean {
|
||||
if (pointerDownState.scrollbars.isOverHorizontal) {
|
||||
const x = event.clientX;
|
||||
const dx = x - pointerDownState.lastCoords.x;
|
||||
this.setState({
|
||||
scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
|
||||
});
|
||||
pointerDownState.lastCoords.x = x;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pointerDownState.scrollbars.isOverVertical) {
|
||||
const y = event.clientY;
|
||||
const dy = y - pointerDownState.lastCoords.y;
|
||||
this.setState({
|
||||
scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
|
||||
});
|
||||
pointerDownState.lastCoords.y = y;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private onPointerUpFromPointerDownHandler(
|
||||
pointerDownState: PointerDownState,
|
||||
): (event: PointerEvent) => void {
|
||||
@ -2973,6 +3075,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
multiElement,
|
||||
elementType,
|
||||
elementLocked,
|
||||
isResizing,
|
||||
} = this.state;
|
||||
|
||||
this.setState({
|
||||
@ -2991,14 +3094,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
|
||||
|
||||
// if moving start/end point towards start/end point within threshold,
|
||||
// close the loop
|
||||
// Handle end of dragging a point of a linear element, might close a loop
|
||||
// and sets binding element
|
||||
if (this.state.editingLinearElement) {
|
||||
const editingLinearElement = LinearElementEditor.handlePointerUp(
|
||||
childEvent,
|
||||
this.state.editingLinearElement,
|
||||
this.state,
|
||||
);
|
||||
if (editingLinearElement !== this.state.editingLinearElement) {
|
||||
this.setState({ editingLinearElement });
|
||||
this.setState({
|
||||
editingLinearElement,
|
||||
suggestedBindings: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -3021,21 +3129,24 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
if (draggingElement!.points.length > 1) {
|
||||
history.resumeRecording();
|
||||
}
|
||||
if (
|
||||
!pointerDownState.drag.hasOccurred &&
|
||||
draggingElement &&
|
||||
!multiElement
|
||||
) {
|
||||
const { x, y } = viewportCoordsToSceneCoords(
|
||||
const pointerCoords = viewportCoordsToSceneCoords(
|
||||
childEvent,
|
||||
this.state,
|
||||
this.canvas,
|
||||
window.devicePixelRatio,
|
||||
);
|
||||
if (
|
||||
!pointerDownState.drag.hasOccurred &&
|
||||
draggingElement &&
|
||||
!multiElement
|
||||
) {
|
||||
mutateElement(draggingElement, {
|
||||
points: [
|
||||
...draggingElement.points,
|
||||
[x - draggingElement.x, y - draggingElement.y],
|
||||
[
|
||||
pointerCoords.x - draggingElement.x,
|
||||
pointerCoords.y - draggingElement.y,
|
||||
],
|
||||
],
|
||||
});
|
||||
this.setState({
|
||||
@ -3043,6 +3154,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
editingElement: this.state.draggingElement,
|
||||
});
|
||||
} else if (pointerDownState.drag.hasOccurred && !multiElement) {
|
||||
if (
|
||||
isBindingEnabled(this.state) &&
|
||||
isBindingElement(draggingElement)
|
||||
) {
|
||||
maybeBindLinearElement(
|
||||
draggingElement,
|
||||
this.state,
|
||||
this.scene,
|
||||
pointerCoords,
|
||||
);
|
||||
}
|
||||
this.setState({ suggestedBindings: [], startBoundElement: null });
|
||||
if (!elementLocked) {
|
||||
resetCursor();
|
||||
this.setState((prevState) => ({
|
||||
@ -3086,6 +3209,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
draggingElement,
|
||||
getNormalizedDimensions(draggingElement),
|
||||
);
|
||||
|
||||
if (
|
||||
isBindingEnabled(this.state) &&
|
||||
isBindableElement(draggingElement)
|
||||
) {
|
||||
maybeBindBindableElement(draggingElement);
|
||||
}
|
||||
}
|
||||
|
||||
if (resizingElement) {
|
||||
@ -3155,20 +3285,80 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
history.resumeRecording();
|
||||
}
|
||||
|
||||
if (pointerDownState.drag.hasOccurred || isResizing) {
|
||||
(isBindingEnabled(this.state)
|
||||
? bindOrUnbindSelectedElements
|
||||
: unbindLinearElements)(
|
||||
getSelectedElements(this.scene.getElements(), this.state),
|
||||
);
|
||||
}
|
||||
|
||||
if (!elementLocked) {
|
||||
resetCursor();
|
||||
this.setState({
|
||||
draggingElement: null,
|
||||
suggestedBindings: [],
|
||||
elementType: "selection",
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
draggingElement: null,
|
||||
suggestedBindings: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private maybeSuggestBindingAtCursor = (pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): void => {
|
||||
const hoveredBindableElement = getHoveredElementForBinding(
|
||||
pointerCoords,
|
||||
this.scene,
|
||||
);
|
||||
this.setState({
|
||||
suggestedBindings:
|
||||
hoveredBindableElement != null ? [hoveredBindableElement] : [],
|
||||
});
|
||||
};
|
||||
|
||||
private maybeSuggestBindingForLinearElementAtCursor = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
// During line creation the start binding hasn't been written yet
|
||||
// into `linearElement`
|
||||
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
|
||||
): void => {
|
||||
const hoveredBindableElement = getElligibleElementForBindingElementAtCoors(
|
||||
linearElement,
|
||||
startOrEnd,
|
||||
pointerCoords,
|
||||
);
|
||||
this.setState({
|
||||
suggestedBindings:
|
||||
hoveredBindableElement != null &&
|
||||
!isLinearElementSimpleAndAlreadyBound(
|
||||
linearElement,
|
||||
oppositeBindingBoundElement?.id,
|
||||
hoveredBindableElement,
|
||||
)
|
||||
? [hoveredBindableElement]
|
||||
: [],
|
||||
});
|
||||
};
|
||||
|
||||
private maybeSuggestBindingForAll(
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
): void {
|
||||
const suggestedBindings = getEligibleElementsForBinding(selectedElements);
|
||||
this.setState({ suggestedBindings });
|
||||
}
|
||||
|
||||
private maybeClearSelectionWhenHittingElement(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
hitElement: ExcalidrawElement | null,
|
||||
@ -3291,13 +3481,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
);
|
||||
|
||||
const elements = this.scene.getElements();
|
||||
const element = getElementAtPosition(
|
||||
elements,
|
||||
this.state,
|
||||
x,
|
||||
y,
|
||||
this.state.zoom,
|
||||
);
|
||||
const element = this.getElementAtPosition(x, y);
|
||||
if (!element) {
|
||||
ContextMenu.push({
|
||||
options: [
|
||||
|
@ -590,7 +590,13 @@ const LayerUI = ({
|
||||
|
||||
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
||||
const { cursorX, cursorY, ...ret } = appState;
|
||||
const {
|
||||
cursorX,
|
||||
cursorY,
|
||||
suggestedBindings,
|
||||
startBoundElement: boundElement,
|
||||
...ret
|
||||
} = appState;
|
||||
return ret;
|
||||
};
|
||||
const prevAppState = getNecessaryObj(prev.appState);
|
||||
|
@ -48,7 +48,8 @@ function migrateElementWithProperties<T extends ExcalidrawElement>(
|
||||
width: element.width || 0,
|
||||
height: element.height || 0,
|
||||
seed: element.seed ?? 1,
|
||||
groupIds: element.groupIds || [],
|
||||
groupIds: element.groupIds ?? [],
|
||||
boundElementIds: element.boundElementIds ?? [],
|
||||
};
|
||||
|
||||
return {
|
||||
@ -85,6 +86,8 @@ const migrateElement = (
|
||||
case "line":
|
||||
case "arrow": {
|
||||
return migrateElementWithProperties(element, {
|
||||
startBinding: element.startBinding,
|
||||
endBinding: element.endBinding,
|
||||
points:
|
||||
// migrate old arrow model to new one
|
||||
!Array.isArray(element.points) || element.points.length < 2
|
||||
@ -98,7 +101,9 @@ const migrateElement = (
|
||||
}
|
||||
// generic elements
|
||||
case "ellipse":
|
||||
return migrateElementWithProperties(element, {});
|
||||
case "rectangle":
|
||||
return migrateElementWithProperties(element, {});
|
||||
case "diamond":
|
||||
return migrateElementWithProperties(element, {});
|
||||
|
||||
|
674
src/element/binding.ts
Normal file
674
src/element/binding.ts
Normal file
@ -0,0 +1,674 @@
|
||||
import {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawBindableElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
PointBinding,
|
||||
ExcalidrawElement,
|
||||
} from "./types";
|
||||
import { getElementAtPosition } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { isBindableElement, isBindingElement } from "./typeChecks";
|
||||
import {
|
||||
bindingBorderTest,
|
||||
distanceToBindableElement,
|
||||
maxBindingGap,
|
||||
determineFocusDistance,
|
||||
intersectElementWithLine,
|
||||
determineFocusPoint,
|
||||
} from "./collision";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import Scene from "../scene/Scene";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { tupleToCoors } from "../utils";
|
||||
|
||||
export type SuggestedBinding =
|
||||
| NonDeleted<ExcalidrawBindableElement>
|
||||
| SuggestedPointBinding;
|
||||
|
||||
export type SuggestedPointBinding = [
|
||||
NonDeleted<ExcalidrawLinearElement>,
|
||||
"start" | "end" | "both",
|
||||
NonDeleted<ExcalidrawBindableElement>,
|
||||
];
|
||||
|
||||
export const isBindingEnabled = (appState: AppState): boolean => {
|
||||
return appState.isBindingEnabled;
|
||||
};
|
||||
|
||||
export const bindOrUnbindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startBindingElement: ExcalidrawBindableElement | null | "keep",
|
||||
endBindingElement: ExcalidrawBindableElement | null | "keep",
|
||||
): void => {
|
||||
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
||||
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
||||
bindOrUnbindLinearElementEdge(
|
||||
linearElement,
|
||||
startBindingElement,
|
||||
"start",
|
||||
boundToElementIds,
|
||||
unboundFromElementIds,
|
||||
);
|
||||
bindOrUnbindLinearElementEdge(
|
||||
linearElement,
|
||||
endBindingElement,
|
||||
"end",
|
||||
boundToElementIds,
|
||||
unboundFromElementIds,
|
||||
);
|
||||
|
||||
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
||||
(id) => !boundToElementIds.has(id),
|
||||
);
|
||||
Scene.getScene(linearElement)!
|
||||
.getNonDeletedElements(onlyUnbound)
|
||||
.forEach((element) => {
|
||||
mutateElement(element, {
|
||||
boundElementIds: element.boundElementIds?.filter(
|
||||
(id) => id !== linearElement.id,
|
||||
),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const bindOrUnbindLinearElementEdge = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
bindableElement: ExcalidrawBindableElement | null | "keep",
|
||||
startOrEnd: "start" | "end",
|
||||
// Is mutated
|
||||
boundToElementIds: Set<ExcalidrawBindableElement["id"]>,
|
||||
// Is mutated
|
||||
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
|
||||
): void => {
|
||||
if (bindableElement !== "keep") {
|
||||
if (bindableElement != null) {
|
||||
bindLinearElement(linearElement, bindableElement, startOrEnd);
|
||||
boundToElementIds.add(bindableElement.id);
|
||||
} else {
|
||||
const unbound = unbindLinearElement(linearElement, startOrEnd);
|
||||
if (unbound != null) {
|
||||
unboundFromElementIds.add(unbound);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const bindOrUnbindSelectedElements = (
|
||||
elements: NonDeleted<ExcalidrawElement>[],
|
||||
): void => {
|
||||
elements.forEach((element) => {
|
||||
if (isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
getElligibleElementForBindingElement(element, "start"),
|
||||
getElligibleElementForBindingElement(element, "end"),
|
||||
);
|
||||
} else if (isBindableElement(element)) {
|
||||
maybeBindBindableElement(element);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const maybeBindBindableElement = (
|
||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||
): void => {
|
||||
getElligibleElementsForBindableElementAndWhere(
|
||||
bindableElement,
|
||||
).forEach(([linearElement, where]) =>
|
||||
bindOrUnbindLinearElement(
|
||||
linearElement,
|
||||
where === "end" ? "keep" : bindableElement,
|
||||
where === "start" ? "keep" : bindableElement,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const maybeBindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
pointerCoords: { x: number; y: number },
|
||||
): void => {
|
||||
if (appState.startBoundElement != null) {
|
||||
bindLinearElement(linearElement, appState.startBoundElement, "start");
|
||||
}
|
||||
const hoveredElement = getHoveredElementForBinding(pointerCoords, scene);
|
||||
if (hoveredElement != null) {
|
||||
bindLinearElement(linearElement, hoveredElement, "end");
|
||||
}
|
||||
};
|
||||
|
||||
const bindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
): void => {
|
||||
if (
|
||||
isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
mutateElement(linearElement, {
|
||||
[startOrEnd === "start" ? "startBinding" : "endBinding"]: {
|
||||
elementId: hoveredElement.id,
|
||||
...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd),
|
||||
} as PointBinding,
|
||||
});
|
||||
mutateElement(hoveredElement, {
|
||||
boundElementIds: [
|
||||
...new Set([...(hoveredElement.boundElementIds ?? []), linearElement.id]),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
// Don't bind both ends of a simple segment
|
||||
const isLinearElementSimpleAndAlreadyBoundOnOppositeEdge = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
): boolean => {
|
||||
const otherBinding =
|
||||
linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"];
|
||||
return isLinearElementSimpleAndAlreadyBound(
|
||||
linearElement,
|
||||
otherBinding?.elementId,
|
||||
bindableElement,
|
||||
);
|
||||
};
|
||||
|
||||
export const isLinearElementSimpleAndAlreadyBound = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
): boolean => {
|
||||
return (
|
||||
alreadyBoundToId === bindableElement.id && linearElement.points.length < 3
|
||||
);
|
||||
};
|
||||
|
||||
export const unbindLinearElements = (
|
||||
elements: NonDeleted<ExcalidrawElement>[],
|
||||
): void => {
|
||||
elements.forEach((element) => {
|
||||
if (isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(element, null, null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const unbindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
): ExcalidrawBindableElement["id"] | null => {
|
||||
const field = startOrEnd === "start" ? "startBinding" : "endBinding";
|
||||
const binding = linearElement[field];
|
||||
if (binding == null) {
|
||||
return null;
|
||||
}
|
||||
mutateElement(linearElement, { [field]: null });
|
||||
return binding.elementId;
|
||||
};
|
||||
|
||||
export const getHoveredElementForBinding = (
|
||||
pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
scene: Scene,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const hoveredElement = getElementAtPosition(
|
||||
scene.getElements(),
|
||||
(element) =>
|
||||
isBindableElement(element) && bindingBorderTest(element, pointerCoords),
|
||||
);
|
||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||
};
|
||||
|
||||
const calculateFocusAndGap = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
): { focus: number; gap: number } => {
|
||||
const direction = startOrEnd === "start" ? -1 : 1;
|
||||
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
|
||||
const adjacentPointIndex = edgePointIndex - direction;
|
||||
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
edgePointIndex,
|
||||
);
|
||||
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
adjacentPointIndex,
|
||||
);
|
||||
return {
|
||||
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
|
||||
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
|
||||
};
|
||||
};
|
||||
|
||||
// Supports translating, rotating and scaling `changedElement` with bound
|
||||
// linear elements.
|
||||
// Because scaling involves moving the focus points as well, it is
|
||||
// done before the `changedElement` is updated, and the `newSize` is passed
|
||||
// in explicitly.
|
||||
export const updateBoundElements = (
|
||||
changedElement: NonDeletedExcalidrawElement,
|
||||
options?: {
|
||||
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||
newSize?: { width: number; height: number };
|
||||
},
|
||||
) => {
|
||||
const boundElementIds = changedElement.boundElementIds ?? [];
|
||||
if (boundElementIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { newSize, simultaneouslyUpdated } = options ?? {};
|
||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||
simultaneouslyUpdated,
|
||||
);
|
||||
(Scene.getScene(changedElement)!.getNonDeletedElements(
|
||||
boundElementIds,
|
||||
) as NonDeleted<ExcalidrawLinearElement>[]).forEach((linearElement) => {
|
||||
const bindableElement = changedElement as ExcalidrawBindableElement;
|
||||
// In case the boundElementIds are stale
|
||||
if (!doesNeedUpdate(linearElement, bindableElement)) {
|
||||
return;
|
||||
}
|
||||
const startBinding = maybeCalculateNewGapWhenScaling(
|
||||
bindableElement,
|
||||
linearElement.startBinding,
|
||||
newSize,
|
||||
);
|
||||
const endBinding = maybeCalculateNewGapWhenScaling(
|
||||
bindableElement,
|
||||
linearElement.endBinding,
|
||||
newSize,
|
||||
);
|
||||
// `linearElement` is being moved/scaled already, just update the binding
|
||||
if (simultaneouslyUpdatedElementIds.has(linearElement.id)) {
|
||||
mutateElement(linearElement, { startBinding, endBinding });
|
||||
return;
|
||||
}
|
||||
updateBoundPoint(
|
||||
linearElement,
|
||||
"start",
|
||||
startBinding,
|
||||
changedElement as ExcalidrawBindableElement,
|
||||
);
|
||||
updateBoundPoint(
|
||||
linearElement,
|
||||
"end",
|
||||
endBinding,
|
||||
changedElement as ExcalidrawBindableElement,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const doesNeedUpdate = (
|
||||
boundElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
changedElement: ExcalidrawBindableElement,
|
||||
) => {
|
||||
return (
|
||||
boundElement.startBinding?.elementId === changedElement.id ||
|
||||
boundElement.endBinding?.elementId === changedElement.id
|
||||
);
|
||||
};
|
||||
|
||||
const getSimultaneouslyUpdatedElementIds = (
|
||||
simultaneouslyUpdated: readonly ExcalidrawElement[] | undefined,
|
||||
): Set<ExcalidrawElement["id"]> => {
|
||||
return new Set((simultaneouslyUpdated || []).map((element) => element.id));
|
||||
};
|
||||
|
||||
const updateBoundPoint = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
binding: PointBinding | null | undefined,
|
||||
changedElement: ExcalidrawBindableElement,
|
||||
): void => {
|
||||
if (
|
||||
binding == null ||
|
||||
// We only need to update the other end if this is a 2 point line element
|
||||
(binding.elementId !== changedElement.id && linearElement.points.length > 2)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const bindingElement = Scene.getScene(linearElement)!.getElement(
|
||||
binding.elementId,
|
||||
) as ExcalidrawBindableElement | null;
|
||||
if (bindingElement == null) {
|
||||
// We're not cleaning up after deleted elements atm., so handle this case
|
||||
return;
|
||||
}
|
||||
const direction = startOrEnd === "start" ? -1 : 1;
|
||||
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
|
||||
const adjacentPointIndex = edgePointIndex - direction;
|
||||
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
adjacentPointIndex,
|
||||
);
|
||||
const focusPointAbsolute = determineFocusPoint(
|
||||
bindingElement,
|
||||
binding.focus,
|
||||
adjacentPoint,
|
||||
);
|
||||
let newEdgePoint;
|
||||
// The linear element was not originally pointing inside the bound shape,
|
||||
// we can point directly at the focus point
|
||||
if (binding.gap === 0) {
|
||||
newEdgePoint = focusPointAbsolute;
|
||||
} else {
|
||||
const intersections = intersectElementWithLine(
|
||||
bindingElement,
|
||||
adjacentPoint,
|
||||
focusPointAbsolute,
|
||||
binding.gap,
|
||||
);
|
||||
if (intersections.length === 0) {
|
||||
// This should never happen, since focusPoint should always be
|
||||
// inside the element, but just in case, bail out
|
||||
newEdgePoint = focusPointAbsolute;
|
||||
} else {
|
||||
// Guaranteed to intersect because focusPoint is always inside the shape
|
||||
newEdgePoint = intersections[0];
|
||||
}
|
||||
}
|
||||
LinearElementEditor.movePoint(
|
||||
linearElement,
|
||||
edgePointIndex,
|
||||
LinearElementEditor.pointFromAbsoluteCoords(linearElement, newEdgePoint),
|
||||
{ [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding },
|
||||
);
|
||||
};
|
||||
|
||||
const maybeCalculateNewGapWhenScaling = (
|
||||
changedElement: ExcalidrawBindableElement,
|
||||
currentBinding: PointBinding | null | undefined,
|
||||
newSize: { width: number; height: number } | undefined,
|
||||
): PointBinding | null | undefined => {
|
||||
if (currentBinding == null || newSize == null) {
|
||||
return currentBinding;
|
||||
}
|
||||
const { gap, focus, elementId } = currentBinding;
|
||||
const { width: newWidth, height: newHeight } = newSize;
|
||||
const { width, height } = changedElement;
|
||||
const newGap = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
maxBindingGap(changedElement, newWidth, newHeight),
|
||||
gap * (newWidth < newHeight ? newWidth / width : newHeight / height),
|
||||
),
|
||||
);
|
||||
return { elementId, gap: newGap, focus };
|
||||
};
|
||||
|
||||
export const getEligibleElementsForBinding = (
|
||||
elements: NonDeleted<ExcalidrawElement>[],
|
||||
): SuggestedBinding[] => {
|
||||
const includedElementIds = new Set(elements.map(({ id }) => id));
|
||||
return elements.flatMap((element) =>
|
||||
isBindingElement(element)
|
||||
? (getElligibleElementsForBindingElement(
|
||||
element as NonDeleted<ExcalidrawLinearElement>,
|
||||
).filter(
|
||||
(element) => !includedElementIds.has(element.id),
|
||||
) as SuggestedBinding[])
|
||||
: isBindableElement(element)
|
||||
? getElligibleElementsForBindableElementAndWhere(element).filter(
|
||||
(binding) => !includedElementIds.has(binding[0].id),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
};
|
||||
|
||||
const getElligibleElementsForBindingElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||
return [
|
||||
getElligibleElementForBindingElement(linearElement, "start"),
|
||||
getElligibleElementForBindingElement(linearElement, "end"),
|
||||
].filter(
|
||||
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||
element != null,
|
||||
);
|
||||
};
|
||||
|
||||
const getElligibleElementForBindingElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
return getElligibleElementForBindingElementAtCoors(
|
||||
linearElement,
|
||||
startOrEnd,
|
||||
getLinearElementEdgeCoors(linearElement, startOrEnd),
|
||||
);
|
||||
};
|
||||
|
||||
export const getElligibleElementForBindingElementAtCoors = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const bindableElement = getHoveredElementForBinding(
|
||||
pointerCoords,
|
||||
Scene.getScene(linearElement)!,
|
||||
);
|
||||
if (bindableElement == null) {
|
||||
return null;
|
||||
}
|
||||
// Note: We could push this check inside a version of
|
||||
// `getHoveredElementForBinding`, but it's unlikely this is needed.
|
||||
if (
|
||||
isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
||||
linearElement,
|
||||
bindableElement,
|
||||
startOrEnd,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return bindableElement;
|
||||
};
|
||||
|
||||
const getLinearElementEdgeCoors = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
): { x: number; y: number } => {
|
||||
const index = startOrEnd === "start" ? 0 : -1;
|
||||
return tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(linearElement, index),
|
||||
);
|
||||
};
|
||||
|
||||
const getElligibleElementsForBindableElementAndWhere = (
|
||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||
): SuggestedPointBinding[] => {
|
||||
return Scene.getScene(bindableElement)!
|
||||
.getElements()
|
||||
.map((element) => {
|
||||
if (!isBindingElement(element)) {
|
||||
return null;
|
||||
}
|
||||
const canBindStart = isLinearElementEligibleForNewBindingByBindable(
|
||||
element,
|
||||
"start",
|
||||
bindableElement,
|
||||
);
|
||||
const canBindEnd = isLinearElementEligibleForNewBindingByBindable(
|
||||
element,
|
||||
"end",
|
||||
bindableElement,
|
||||
);
|
||||
if (!canBindStart && !canBindEnd) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
element,
|
||||
canBindStart && canBindEnd ? "both" : canBindStart ? "start" : "end",
|
||||
bindableElement,
|
||||
];
|
||||
})
|
||||
.filter((maybeElement) => maybeElement != null) as SuggestedPointBinding[];
|
||||
};
|
||||
|
||||
const isLinearElementEligibleForNewBindingByBindable = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||
): boolean => {
|
||||
const existingBinding =
|
||||
linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"];
|
||||
return (
|
||||
existingBinding == null &&
|
||||
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
||||
linearElement,
|
||||
bindableElement,
|
||||
startOrEnd,
|
||||
) &&
|
||||
bindingBorderTest(
|
||||
bindableElement,
|
||||
getLinearElementEdgeCoors(linearElement, startOrEnd),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// We need to:
|
||||
// 1: Update elements not selected to point to duplicated elements
|
||||
// 2: Update duplicated elements to point to other duplicated elements
|
||||
export const fixBindingsAfterDuplication = (
|
||||
sceneElements: readonly ExcalidrawElement[],
|
||||
oldElements: readonly ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
// There are three copying mechanisms: Copy-paste, duplication and alt-drag.
|
||||
// Only when alt-dragging the new "duplicates" act as the "old", while
|
||||
// the "old" elements act as the "new copy" - essentially working reverse
|
||||
// to the other two.
|
||||
duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined,
|
||||
): void => {
|
||||
// First collect all the binding/bindable elements, so we only update
|
||||
// each once, regardless of whether they were duplicated or not.
|
||||
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
|
||||
oldElements.forEach((oldElement) => {
|
||||
const { boundElementIds } = oldElement;
|
||||
if (boundElementIds != null && boundElementIds.length > 0) {
|
||||
boundElementIds.forEach((boundElementId) => {
|
||||
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElementId)) {
|
||||
allBoundElementIds.add(boundElementId);
|
||||
}
|
||||
});
|
||||
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
|
||||
}
|
||||
if (isBindingElement(oldElement)) {
|
||||
if (oldElement.startBinding != null) {
|
||||
const { elementId } = oldElement.startBinding;
|
||||
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
|
||||
allBindableElementIds.add(elementId);
|
||||
}
|
||||
}
|
||||
if (oldElement.endBinding != null) {
|
||||
const { elementId } = oldElement.endBinding;
|
||||
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
|
||||
allBindableElementIds.add(elementId);
|
||||
}
|
||||
}
|
||||
if (oldElement.startBinding != null || oldElement.endBinding != null) {
|
||||
allBoundElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update the linear elements
|
||||
(sceneElements.filter(({ id }) =>
|
||||
allBoundElementIds.has(id),
|
||||
) as ExcalidrawLinearElement[]).forEach((element) => {
|
||||
const { startBinding, endBinding } = element;
|
||||
mutateElement(element, {
|
||||
startBinding: newBindingAfterDuplication(
|
||||
startBinding,
|
||||
oldIdToDuplicatedId,
|
||||
),
|
||||
endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId),
|
||||
});
|
||||
});
|
||||
|
||||
// Update the bindable shapes
|
||||
sceneElements
|
||||
.filter(({ id }) => allBindableElementIds.has(id))
|
||||
.forEach((bindableElement) => {
|
||||
const { boundElementIds } = bindableElement;
|
||||
if (boundElementIds != null && boundElementIds.length > 0) {
|
||||
mutateElement(bindableElement, {
|
||||
boundElementIds: boundElementIds.map(
|
||||
(boundElementId) =>
|
||||
oldIdToDuplicatedId.get(boundElementId) ?? boundElementId,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const newBindingAfterDuplication = (
|
||||
binding: PointBinding | null,
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
): PointBinding | null => {
|
||||
if (binding == null) {
|
||||
return null;
|
||||
}
|
||||
const { elementId, focus, gap } = binding;
|
||||
return {
|
||||
focus,
|
||||
gap,
|
||||
elementId: oldIdToDuplicatedId.get(elementId) ?? elementId,
|
||||
};
|
||||
};
|
||||
|
||||
export const fixBindingsAfterDeletion = (
|
||||
sceneElements: readonly ExcalidrawElement[],
|
||||
deletedElements: readonly ExcalidrawElement[],
|
||||
): void => {
|
||||
const deletedElementIds = new Set(
|
||||
deletedElements.map((element) => element.id),
|
||||
);
|
||||
// Non deleted and need an update
|
||||
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||
deletedElements.forEach((deletedElement) => {
|
||||
if (isBindableElement(deletedElement)) {
|
||||
deletedElement.boundElementIds?.forEach((id) => {
|
||||
if (!deletedElementIds.has(id)) {
|
||||
boundElementIds.add(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
(sceneElements.filter(({ id }) =>
|
||||
boundElementIds.has(id),
|
||||
) as ExcalidrawLinearElement[]).forEach(
|
||||
(element: ExcalidrawLinearElement) => {
|
||||
const { startBinding, endBinding } = element;
|
||||
mutateElement(element, {
|
||||
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
|
||||
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const newBindingAfterDeletion = (
|
||||
binding: PointBinding | null,
|
||||
deletedElementIds: Set<ExcalidrawElement["id"]>,
|
||||
): PointBinding | null => {
|
||||
if (binding == null || deletedElementIds.has(binding.elementId)) {
|
||||
return null;
|
||||
}
|
||||
return binding;
|
||||
};
|
@ -10,11 +10,14 @@ import {
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
import { rescalePoints } from "../points";
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
export type Bounds = readonly [number, number, number, number];
|
||||
|
||||
// If the element is created from right to left, the width is going to be negative
|
||||
// This set of functions retrieves the absolute position of the 4 points.
|
||||
export const getElementAbsoluteCoords = (
|
||||
element: ExcalidrawElement,
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
if (isLinearElement(element)) {
|
||||
return getLinearElementAbsoluteCoords(element);
|
||||
}
|
||||
@ -26,6 +29,13 @@ export const getElementAbsoluteCoords = (
|
||||
];
|
||||
};
|
||||
|
||||
export const pointRelativeTo = (
|
||||
element: ExcalidrawElement,
|
||||
absoluteCoords: Point,
|
||||
): Point => {
|
||||
return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
|
||||
};
|
||||
|
||||
export const getDiamondPoints = (element: ExcalidrawElement) => {
|
||||
// Here we add +1 to avoid these numbers to be 0
|
||||
// otherwise rough.js will throw an error complaining about it
|
||||
@ -35,7 +45,7 @@ export const getDiamondPoints = (element: ExcalidrawElement) => {
|
||||
const rightY = Math.floor(element.height / 2) + 1;
|
||||
const bottomX = topX;
|
||||
const bottomY = element.height;
|
||||
const leftX = topY;
|
||||
const leftX = 0;
|
||||
const leftY = rightY;
|
||||
|
||||
return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
|
||||
|
@ -1,23 +1,28 @@
|
||||
import {
|
||||
distanceBetweenPointAndSegment,
|
||||
isPathALoop,
|
||||
rotate,
|
||||
isPointInPolygon,
|
||||
} from "../math";
|
||||
import * as GA from "../ga";
|
||||
import * as GAPoint from "../gapoints";
|
||||
import * as GADirection from "../gadirections";
|
||||
import * as GALine from "../galines";
|
||||
import * as GATransform from "../gatransforms";
|
||||
|
||||
import { isPathALoop, isPointInPolygon, rotate } from "../math";
|
||||
import { pointsOnBezierCurves } from "points-on-curve";
|
||||
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
|
||||
import {
|
||||
getDiamondPoints,
|
||||
getElementAbsoluteCoords,
|
||||
getCurvePathOps,
|
||||
} from "./bounds";
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawEllipseElement,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||
import { Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
@ -40,30 +45,138 @@ export const hitTest = (
|
||||
appState: AppState,
|
||||
x: number,
|
||||
y: number,
|
||||
zoom: number,
|
||||
): boolean => {
|
||||
// For shapes that are composed of lines, we only enable point-selection when the distance
|
||||
// of the click is less than x pixels of any of the lines that the shape is composed of
|
||||
const lineThreshold = 10 / zoom;
|
||||
// How many pixels off the shape boundary we still consider a hit
|
||||
const threshold = 10 / appState.zoom;
|
||||
const check = isElementDraggableFromInside(element, appState)
|
||||
? isInsideCheck
|
||||
: isNearCheck;
|
||||
const point: Point = [x, y];
|
||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||
};
|
||||
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
// reverse rotate the pointer
|
||||
[x, y] = rotate(x, y, cx, cy, -element.angle);
|
||||
export const bindingBorderTest = (
|
||||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
{ x, y }: { x: number; y: number },
|
||||
): boolean => {
|
||||
const threshold = maxBindingGap(element, element.width, element.height);
|
||||
const check = isOutsideCheck;
|
||||
const point: Point = [x, y];
|
||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||
};
|
||||
|
||||
if (element.type === "ellipse") {
|
||||
// https://stackoverflow.com/a/46007540/232122
|
||||
const px = Math.abs(x - element.x - element.width / 2);
|
||||
const py = Math.abs(y - element.y - element.height / 2);
|
||||
export const maxBindingGap = (
|
||||
element: ExcalidrawElement,
|
||||
elementWidth: number,
|
||||
elementHeight: number,
|
||||
): number => {
|
||||
// Aligns diamonds with rectangles
|
||||
const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
|
||||
const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
|
||||
// We make the bindable boundary bigger for bigger elements
|
||||
return Math.max(15, Math.min(0.25 * smallerDimension, 80));
|
||||
};
|
||||
|
||||
type HitTestArgs = {
|
||||
element: NonDeletedExcalidrawElement;
|
||||
point: Point;
|
||||
threshold: number;
|
||||
check: (distance: number, threshold: number) => boolean;
|
||||
};
|
||||
|
||||
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
switch (args.element.type) {
|
||||
case "rectangle":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
const distance = distanceToBindableElement(args.element, args.point);
|
||||
return args.check(distance, args.threshold);
|
||||
case "arrow":
|
||||
case "line":
|
||||
case "draw":
|
||||
return hitTestLinear(args);
|
||||
case "selection":
|
||||
console.warn(
|
||||
"This should not happen, we need to investigate why it does.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const distanceToBindableElement = (
|
||||
element: ExcalidrawBindableElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "text":
|
||||
return distanceToRectangle(element, point);
|
||||
case "diamond":
|
||||
return distanceToDiamond(element, point);
|
||||
case "ellipse":
|
||||
return distanceToEllipse(element, point);
|
||||
}
|
||||
};
|
||||
|
||||
const isInsideCheck = (distance: number, threshold: number): boolean => {
|
||||
return distance < threshold;
|
||||
};
|
||||
|
||||
const isNearCheck = (distance: number, threshold: number): boolean => {
|
||||
return Math.abs(distance) < threshold;
|
||||
};
|
||||
|
||||
const isOutsideCheck = (distance: number, threshold: number): boolean => {
|
||||
return 0 <= distance && distance < threshold;
|
||||
};
|
||||
|
||||
const distanceToRectangle = (
|
||||
element: ExcalidrawRectangleElement | ExcalidrawTextElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
const nearSide =
|
||||
GAPoint.distanceToLine(pointRel, GALine.vector(hwidth, hheight)) > 0
|
||||
? GALine.equation(0, 1, -hheight)
|
||||
: GALine.equation(1, 0, -hwidth);
|
||||
return GAPoint.distanceToLine(pointRel, nearSide);
|
||||
};
|
||||
|
||||
const distanceToDiamond = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
const side = GALine.equation(hheight, hwidth, -hheight * hwidth);
|
||||
return GAPoint.distanceToLine(pointRel, side);
|
||||
};
|
||||
|
||||
const distanceToEllipse = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [pointRel, tangent] = ellipseParamsForTest(element, point);
|
||||
return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent);
|
||||
};
|
||||
|
||||
const ellipseParamsForTest = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
point: Point,
|
||||
): [GA.Point, GA.Line] => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
const [px, py] = GAPoint.toTuple(pointRel);
|
||||
|
||||
// We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)`
|
||||
let tx = 0.707;
|
||||
let ty = 0.707;
|
||||
|
||||
const a = Math.abs(element.width) / 2;
|
||||
const b = Math.abs(element.height) / 2;
|
||||
const a = hwidth;
|
||||
const b = hheight;
|
||||
|
||||
[0, 1, 2, 3].forEach((x) => {
|
||||
// This is a numerical method to find the params tx, ty at which
|
||||
// the ellipse has the closest point to the given point
|
||||
[0, 1, 2, 3].forEach((_) => {
|
||||
const xx = a * tx;
|
||||
const yy = b * ty;
|
||||
|
||||
@ -86,133 +199,421 @@ export const hitTest = (
|
||||
ty /= t;
|
||||
});
|
||||
|
||||
if (isElementDraggableFromInside(element, appState)) {
|
||||
return (
|
||||
a * tx - (px - lineThreshold) >= 0 && b * ty - (py - lineThreshold) >= 0
|
||||
);
|
||||
}
|
||||
return Math.hypot(a * tx - px, b * ty - py) < lineThreshold;
|
||||
} else if (element.type === "rectangle") {
|
||||
if (isElementDraggableFromInside(element, appState)) {
|
||||
return (
|
||||
x > x1 - lineThreshold &&
|
||||
x < x2 + lineThreshold &&
|
||||
y > y1 - lineThreshold &&
|
||||
y < y2 + lineThreshold
|
||||
);
|
||||
}
|
||||
const closestPoint = GA.point(a * tx, b * ty);
|
||||
|
||||
// (x1, y1) --A-- (x2, y1)
|
||||
// |D |B
|
||||
// (x1, y2) --C-- (x2, y2)
|
||||
return (
|
||||
distanceBetweenPointAndSegment(x, y, x1, y1, x2, y1) < lineThreshold || // A
|
||||
distanceBetweenPointAndSegment(x, y, x2, y1, x2, y2) < lineThreshold || // B
|
||||
distanceBetweenPointAndSegment(x, y, x2, y2, x1, y2) < lineThreshold || // C
|
||||
distanceBetweenPointAndSegment(x, y, x1, y2, x1, y1) < lineThreshold // D
|
||||
);
|
||||
} else if (element.type === "diamond") {
|
||||
x -= element.x;
|
||||
y -= element.y;
|
||||
let [
|
||||
topX,
|
||||
topY,
|
||||
rightX,
|
||||
rightY,
|
||||
bottomX,
|
||||
bottomY,
|
||||
leftX,
|
||||
leftY,
|
||||
] = getDiamondPoints(element);
|
||||
const tangent = GALine.orthogonalThrough(pointRel, closestPoint);
|
||||
return [pointRel, tangent];
|
||||
};
|
||||
|
||||
if (isElementDraggableFromInside(element, appState)) {
|
||||
// TODO: remove this when we normalize coordinates globally
|
||||
if (topY > bottomY) {
|
||||
[bottomY, topY] = [topY, bottomY];
|
||||
}
|
||||
if (rightX < leftX) {
|
||||
[leftX, rightX] = [rightX, leftX];
|
||||
}
|
||||
|
||||
topY -= lineThreshold;
|
||||
bottomY += lineThreshold;
|
||||
leftX -= lineThreshold;
|
||||
rightX += lineThreshold;
|
||||
|
||||
// all deltas should be < 0. Delta > 0 indicates it's on the outside side
|
||||
// of the line.
|
||||
//
|
||||
// (topX, topY)
|
||||
// D / \ A
|
||||
// / \
|
||||
// (leftX, leftY) (rightX, rightY)
|
||||
// C \ / B
|
||||
// \ /
|
||||
// (bottomX, bottomY)
|
||||
//
|
||||
// https://stackoverflow.com/a/2752753/927631
|
||||
return (
|
||||
// delta from line D
|
||||
(leftX - topX) * (y - leftY) - (leftX - x) * (topY - leftY) <= 0 &&
|
||||
// delta from line A
|
||||
(topX - rightX) * (y - rightY) - (x - rightX) * (topY - rightY) <= 0 &&
|
||||
// delta from line B
|
||||
(rightX - bottomX) * (y - bottomY) -
|
||||
(x - bottomX) * (rightY - bottomY) <=
|
||||
0 &&
|
||||
// delta from line C
|
||||
(bottomX - leftX) * (y - leftY) - (x - leftX) * (bottomY - leftY) <= 0
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
distanceBetweenPointAndSegment(x, y, topX, topY, rightX, rightY) <
|
||||
lineThreshold ||
|
||||
distanceBetweenPointAndSegment(x, y, rightX, rightY, bottomX, bottomY) <
|
||||
lineThreshold ||
|
||||
distanceBetweenPointAndSegment(x, y, bottomX, bottomY, leftX, leftY) <
|
||||
lineThreshold ||
|
||||
distanceBetweenPointAndSegment(x, y, leftX, leftY, topX, topY) <
|
||||
lineThreshold
|
||||
);
|
||||
} else if (isLinearElement(element)) {
|
||||
const hitTestLinear = (args: HitTestArgs): boolean => {
|
||||
const { element, threshold } = args;
|
||||
if (!getShapeForElement(element)) {
|
||||
return false;
|
||||
}
|
||||
const shape = getShapeForElement(element) as Drawable[];
|
||||
|
||||
const [point, pointAbs, hwidth, hheight] = pointRelativeToElement(
|
||||
args.element,
|
||||
args.point,
|
||||
);
|
||||
const side1 = GALine.equation(0, 1, -hheight);
|
||||
const side2 = GALine.equation(1, 0, -hwidth);
|
||||
if (
|
||||
x < x1 - lineThreshold ||
|
||||
y < y1 - lineThreshold ||
|
||||
x > x2 + lineThreshold ||
|
||||
y > y2 + lineThreshold
|
||||
!isInsideCheck(GAPoint.distanceToLine(pointAbs, side1), threshold) ||
|
||||
!isInsideCheck(GAPoint.distanceToLine(pointAbs, side2), threshold)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const [relX, relY] = GAPoint.toTuple(point);
|
||||
|
||||
const relX = x - element.x;
|
||||
const relY = y - element.y;
|
||||
const shape = getShapeForElement(element) as Drawable[];
|
||||
|
||||
if (isElementDraggableFromInside(element, appState)) {
|
||||
if (args.check === isInsideCheck) {
|
||||
const hit = shape.some((subshape) =>
|
||||
hitTestCurveInside(subshape, relX, relY, lineThreshold),
|
||||
hitTestCurveInside(subshape, relX, relY, threshold),
|
||||
);
|
||||
if (hit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// hit thest all "subshapes" of the linear element
|
||||
// hit test all "subshapes" of the linear element
|
||||
return shape.some((subshape) =>
|
||||
hitTestRoughShape(subshape, relX, relY, lineThreshold),
|
||||
hitTestRoughShape(subshape, relX, relY, threshold),
|
||||
);
|
||||
} else if (element.type === "text") {
|
||||
return x >= x1 && x <= x2 && y >= y1 && y <= y2;
|
||||
} else if (element.type === "selection") {
|
||||
console.warn("This should not happen, we need to investigate why it does.");
|
||||
return false;
|
||||
};
|
||||
|
||||
// Returns:
|
||||
// 1. the point relative to the elements (x, y) position
|
||||
// 2. the point relative to the element's center with positive (x, y)
|
||||
// 3. half element width
|
||||
// 4. half element height
|
||||
//
|
||||
// Note that for linear elements the (x, y) position is not at the
|
||||
// top right corner of their boundary.
|
||||
//
|
||||
// Rectangles, diamonds and ellipses are symmetrical over axes,
|
||||
// and other elements have a rectangular boundary,
|
||||
// so we only need to perform hit tests for the positive quadrant.
|
||||
const pointRelativeToElement = (
|
||||
element: ExcalidrawElement,
|
||||
pointTuple: Point,
|
||||
): [GA.Point, GA.Point, number, number] => {
|
||||
const point = GAPoint.from(pointTuple);
|
||||
const elementCoords = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter(elementCoords);
|
||||
// GA has angle orientation opposite to `rotate`
|
||||
const rotate = GATransform.rotation(center, element.angle);
|
||||
const pointRotated = GATransform.apply(rotate, point);
|
||||
const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
|
||||
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
||||
const elementPos = GA.offset(element.x, element.y);
|
||||
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
||||
const [ax, ay, bx, by] = elementCoords;
|
||||
const halfWidth = (bx - ax) / 2;
|
||||
const halfHeight = (by - ay) / 2;
|
||||
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
|
||||
};
|
||||
|
||||
// Returns point in absolute coordinates
|
||||
export const pointInAbsoluteCoords = (
|
||||
element: ExcalidrawElement,
|
||||
// Point relative to the element position
|
||||
point: Point,
|
||||
): Point => {
|
||||
const [x, y] = point;
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x2 - x1) / 2;
|
||||
const cy = (y2 - y1) / 2;
|
||||
const [rotatedX, rotatedY] = rotate(x, y, cx, cy, element.angle);
|
||||
return [element.x + rotatedX, element.y + rotatedY];
|
||||
};
|
||||
|
||||
const relativizationToElementCenter = (
|
||||
element: ExcalidrawElement,
|
||||
): GA.Transform => {
|
||||
const elementCoords = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter(elementCoords);
|
||||
// GA has angle orientation opposite to `rotate`
|
||||
const rotate = GATransform.rotation(center, element.angle);
|
||||
const translate = GA.reverse(
|
||||
GATransform.translation(GADirection.from(center)),
|
||||
);
|
||||
return GATransform.compose(rotate, translate);
|
||||
};
|
||||
|
||||
const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => {
|
||||
return GA.point((ax + bx) / 2, (ay + by) / 2);
|
||||
};
|
||||
|
||||
// The focus distance is the oriented ratio between the size of
|
||||
// the `element` and the "focus image" of the element on which
|
||||
// all focus points lie, so it's a number between -1 and 1.
|
||||
// The line going through `a` and `b` is a tangent to the "focus image"
|
||||
// of the element.
|
||||
export const determineFocusDistance = (
|
||||
element: ExcalidrawBindableElement,
|
||||
// Point on the line, in absolute coordinates
|
||||
a: Point,
|
||||
// Another point on the line, in absolute coordinates (closer to element)
|
||||
b: Point,
|
||||
): number => {
|
||||
const relateToCenter = relativizationToElementCenter(element);
|
||||
const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
|
||||
const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
|
||||
const line = GALine.through(aRel, bRel);
|
||||
const q = element.height / element.width;
|
||||
const hwidth = element.width / 2;
|
||||
const hheight = element.height / 2;
|
||||
const n = line[2];
|
||||
const m = line[3];
|
||||
const c = line[1];
|
||||
const mabs = Math.abs(m);
|
||||
const nabs = Math.abs(n);
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "text":
|
||||
return c / (hwidth * (nabs + q * mabs));
|
||||
case "diamond":
|
||||
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||
case "ellipse":
|
||||
return c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
|
||||
}
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
};
|
||||
|
||||
export const determineFocusPoint = (
|
||||
element: ExcalidrawBindableElement,
|
||||
// The oriented, relative distance from the center of `element` of the
|
||||
// returned focusPoint
|
||||
focus: number,
|
||||
adjecentPoint: Point,
|
||||
): Point => {
|
||||
if (focus === 0) {
|
||||
const elementCoords = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter(elementCoords);
|
||||
return GAPoint.toTuple(center);
|
||||
}
|
||||
const relateToCenter = relativizationToElementCenter(element);
|
||||
const adjecentPointRel = GATransform.apply(
|
||||
relateToCenter,
|
||||
GAPoint.from(adjecentPoint),
|
||||
);
|
||||
const reverseRelateToCenter = GA.reverse(relateToCenter);
|
||||
let point;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "text":
|
||||
case "diamond":
|
||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||
break;
|
||||
case "ellipse":
|
||||
point = findFocusPointForEllipse(element, focus, adjecentPointRel);
|
||||
break;
|
||||
}
|
||||
return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point));
|
||||
};
|
||||
|
||||
// Returns 2 or 0 intersection points between line going through `a` and `b`
|
||||
// and the `element`, in ascending order of distance from `a`.
|
||||
export const intersectElementWithLine = (
|
||||
element: ExcalidrawBindableElement,
|
||||
// Point on the line, in absolute coordinates
|
||||
a: Point,
|
||||
// Another point on the line, in absolute coordinates
|
||||
b: Point,
|
||||
// If given, the element is inflated by this value
|
||||
gap: number = 0,
|
||||
): Point[] => {
|
||||
const relateToCenter = relativizationToElementCenter(element);
|
||||
const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
|
||||
const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
|
||||
const line = GALine.through(aRel, bRel);
|
||||
const reverseRelateToCenter = GA.reverse(relateToCenter);
|
||||
const intersections = getSortedElementLineIntersections(
|
||||
element,
|
||||
line,
|
||||
aRel,
|
||||
gap,
|
||||
);
|
||||
return intersections.map((point) =>
|
||||
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
|
||||
);
|
||||
};
|
||||
|
||||
const getSortedElementLineIntersections = (
|
||||
element: ExcalidrawBindableElement,
|
||||
// Relative to element center
|
||||
line: GA.Line,
|
||||
// Relative to element center
|
||||
nearPoint: GA.Point,
|
||||
gap: number = 0,
|
||||
): GA.Point[] => {
|
||||
let intersections: GA.Point[];
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "text":
|
||||
case "diamond":
|
||||
const corners = getCorners(element);
|
||||
intersections = corners
|
||||
.flatMap((point, i) => {
|
||||
const edge: [GA.Point, GA.Point] = [point, corners[(i + 1) % 4]];
|
||||
return intersectSegment(line, offsetSegment(edge, gap));
|
||||
})
|
||||
.concat(
|
||||
corners.flatMap((point) => getCircleIntersections(point, gap, line)),
|
||||
);
|
||||
break;
|
||||
case "ellipse":
|
||||
intersections = getEllipseIntersections(element, gap, line);
|
||||
break;
|
||||
}
|
||||
if (intersections.length < 2) {
|
||||
// Ignore the "edge" case of only intersecting with a single corner
|
||||
return [];
|
||||
}
|
||||
const sortedIntersections = intersections.sort(
|
||||
(i1, i2) =>
|
||||
GAPoint.distance(i1, nearPoint) - GAPoint.distance(i2, nearPoint),
|
||||
);
|
||||
return [
|
||||
sortedIntersections[0],
|
||||
sortedIntersections[sortedIntersections.length - 1],
|
||||
];
|
||||
};
|
||||
|
||||
const getCorners = (
|
||||
element:
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
scale: number = 1,
|
||||
): GA.Point[] => {
|
||||
const hx = (scale * element.width) / 2;
|
||||
const hy = (scale * element.height) / 2;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "text":
|
||||
return [
|
||||
GA.point(hx, hy),
|
||||
GA.point(hx, -hy),
|
||||
GA.point(-hx, -hy),
|
||||
GA.point(-hx, hy),
|
||||
];
|
||||
case "diamond":
|
||||
return [
|
||||
GA.point(0, hy),
|
||||
GA.point(hx, 0),
|
||||
GA.point(0, -hy),
|
||||
GA.point(-hx, 0),
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
// Returns intersection of `line` with `segment`, with `segment` moved by
|
||||
// `gap` in its polar direction.
|
||||
// If intersection conincides with second segment point returns empty array.
|
||||
const intersectSegment = (
|
||||
line: GA.Line,
|
||||
segment: [GA.Point, GA.Point],
|
||||
): GA.Point[] => {
|
||||
const [a, b] = segment;
|
||||
const aDist = GAPoint.distanceToLine(a, line);
|
||||
const bDist = GAPoint.distanceToLine(b, line);
|
||||
if (aDist * bDist >= 0) {
|
||||
// The intersection is outside segment `(a, b)`
|
||||
return [];
|
||||
}
|
||||
return [GAPoint.intersect(line, GALine.through(a, b))];
|
||||
};
|
||||
|
||||
const offsetSegment = (
|
||||
segment: [GA.Point, GA.Point],
|
||||
distance: number,
|
||||
): [GA.Point, GA.Point] => {
|
||||
const [a, b] = segment;
|
||||
const offset = GATransform.translationOrthogonal(
|
||||
GADirection.fromTo(a, b),
|
||||
distance,
|
||||
);
|
||||
return [GATransform.apply(offset, a), GATransform.apply(offset, b)];
|
||||
};
|
||||
|
||||
const getEllipseIntersections = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
gap: number,
|
||||
line: GA.Line,
|
||||
): GA.Point[] => {
|
||||
const a = element.width / 2 + gap;
|
||||
const b = element.height / 2 + gap;
|
||||
const m = line[2];
|
||||
const n = line[3];
|
||||
const c = line[1];
|
||||
const squares = a * a * m * m + b * b * n * n;
|
||||
const discr = squares - c * c;
|
||||
if (squares === 0 || discr <= 0) {
|
||||
return [];
|
||||
}
|
||||
const discrRoot = Math.sqrt(discr);
|
||||
const xn = -a * a * m * c;
|
||||
const yn = -b * b * n * c;
|
||||
return [
|
||||
GA.point(
|
||||
(xn + a * b * n * discrRoot) / squares,
|
||||
(yn - a * b * m * discrRoot) / squares,
|
||||
),
|
||||
GA.point(
|
||||
(xn - a * b * n * discrRoot) / squares,
|
||||
(yn + a * b * m * discrRoot) / squares,
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
export const getCircleIntersections = (
|
||||
center: GA.Point,
|
||||
radius: number,
|
||||
line: GA.Line,
|
||||
): GA.Point[] => {
|
||||
if (radius === 0) {
|
||||
return GAPoint.distanceToLine(line, center) === 0 ? [center] : [];
|
||||
}
|
||||
const m = line[2];
|
||||
const n = line[3];
|
||||
const c = line[1];
|
||||
const [a, b] = GAPoint.toTuple(center);
|
||||
const r = radius;
|
||||
const squares = m * m + n * n;
|
||||
const discr = r * r * squares - (m * a + n * b + c) ** 2;
|
||||
if (squares === 0 || discr <= 0) {
|
||||
return [];
|
||||
}
|
||||
const discrRoot = Math.sqrt(discr);
|
||||
const xn = a * n * n - b * m * n - m * c;
|
||||
const yn = b * m * m - a * m * n - n * c;
|
||||
|
||||
return [
|
||||
GA.point((xn + n * discrRoot) / squares, (yn - m * discrRoot) / squares),
|
||||
GA.point((xn - n * discrRoot) / squares, (yn + m * discrRoot) / squares),
|
||||
];
|
||||
};
|
||||
|
||||
// The focus point is the tangent point of the "focus image" of the
|
||||
// `element`, where the tangent goes through `point`.
|
||||
export const findFocusPointForEllipse = (
|
||||
ellipse: ExcalidrawEllipseElement,
|
||||
// Between -1 and 1 (not 0) the relative size of the "focus image" of
|
||||
// the element on which the focus point lies
|
||||
relativeDistance: number,
|
||||
// The point for which we're trying to find the focus point, relative
|
||||
// to the ellipse center.
|
||||
point: GA.Point,
|
||||
): GA.Point => {
|
||||
const relativeDistanceAbs = Math.abs(relativeDistance);
|
||||
const a = (ellipse.width * relativeDistanceAbs) / 2;
|
||||
const b = (ellipse.height * relativeDistanceAbs) / 2;
|
||||
|
||||
const orientation = Math.sign(relativeDistance);
|
||||
const [px, pyo] = GAPoint.toTuple(point);
|
||||
|
||||
// The calculation below can't handle py = 0
|
||||
const py = pyo === 0 ? 0.0001 : pyo;
|
||||
|
||||
const squares = px ** 2 * b ** 2 + py ** 2 * a ** 2;
|
||||
// Tangent mx + ny + 1 = 0
|
||||
const m =
|
||||
(-px * b ** 2 +
|
||||
orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
|
||||
squares;
|
||||
|
||||
const n = (-m * px - 1) / py;
|
||||
|
||||
const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2);
|
||||
return GA.point(x, (-m * x - 1) / n);
|
||||
};
|
||||
|
||||
export const findFocusPointForRectangulars = (
|
||||
element:
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
// Between -1 and 1 for how far away should the focus point be relative
|
||||
// to the size of the element. Sign determines orientation.
|
||||
relativeDistance: number,
|
||||
// The point for which we're trying to find the focus point, relative
|
||||
// to the element center.
|
||||
point: GA.Point,
|
||||
): GA.Point => {
|
||||
const relativeDistanceAbs = Math.abs(relativeDistance);
|
||||
const orientation = Math.sign(relativeDistance);
|
||||
const corners = getCorners(element, relativeDistanceAbs);
|
||||
|
||||
let maxDistance = 0;
|
||||
let tangentPoint: null | GA.Point = null;
|
||||
corners.forEach((corner) => {
|
||||
const distance = orientation * GALine.through(point, corner)[1];
|
||||
if (distance > maxDistance) {
|
||||
maxDistance = distance;
|
||||
tangentPoint = corner;
|
||||
}
|
||||
});
|
||||
return tangentPoint!;
|
||||
};
|
||||
|
||||
const pointInBezierEquation = (
|
||||
|
@ -1,20 +1,25 @@
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import Scene from "../scene/Scene";
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const [x1, y1] = getCommonBounds(selectedElements);
|
||||
const offset = { x: pointerX - x1, y: pointerY - y1 };
|
||||
selectedElements.forEach((element) => {
|
||||
mutateElement(element, {
|
||||
x: pointerX + element.x - x1,
|
||||
y: pointerY + element.y - y1,
|
||||
x: element.x + offset.x,
|
||||
y: element.y + offset.y,
|
||||
});
|
||||
updateBoundElements(element, { simultaneouslyUpdated: selectedElements });
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -1,10 +1,14 @@
|
||||
import { ExcalidrawElement, PointerType } from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { getElementAbsoluteCoords, Bounds } from "./bounds";
|
||||
import { rotate } from "../math";
|
||||
|
||||
type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se" | "rotation";
|
||||
|
||||
export type Handlers = Partial<
|
||||
{ [T in Sides]: [number, number, number, number] }
|
||||
>;
|
||||
|
||||
const handleSizes: { [k in PointerType]: number } = {
|
||||
mouse: 8,
|
||||
pen: 16,
|
||||
@ -61,12 +65,12 @@ const generateHandler = (
|
||||
};
|
||||
|
||||
export const handlerRectanglesFromCoords = (
|
||||
[x1, y1, x2, y2]: [number, number, number, number],
|
||||
[x1, y1, x2, y2]: Bounds,
|
||||
angle: number,
|
||||
zoom: number,
|
||||
pointerType: PointerType = "mouse",
|
||||
omitSides: { [T in Sides]?: boolean } = {},
|
||||
): Partial<{ [T in Sides]: [number, number, number, number] }> => {
|
||||
): Handlers => {
|
||||
const size = handleSizes[pointerType];
|
||||
const handlerWidth = size / zoom;
|
||||
const handlerHeight = size / zoom;
|
||||
|
@ -2,6 +2,8 @@ import {
|
||||
NonDeleted,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawElement,
|
||||
PointBinding,
|
||||
ExcalidrawBindableElement,
|
||||
} from "./types";
|
||||
import { distance2d, rotate, isPathALoop, getGridPoint } from "../math";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
@ -11,6 +13,13 @@ import { mutateElement } from "./mutateElement";
|
||||
import { SceneHistory } from "../history";
|
||||
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
bindOrUnbindLinearElement,
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
} from "./binding";
|
||||
import { tupleToCoors } from "../utils";
|
||||
import { isBindingElement } from "./typeChecks";
|
||||
|
||||
export class LinearElementEditor {
|
||||
public elementId: ExcalidrawElement["id"] & {
|
||||
@ -21,6 +30,8 @@ export class LinearElementEditor {
|
||||
public isDragging: boolean;
|
||||
public lastUncommittedPoint: Point | null;
|
||||
public pointerOffset: { x: number; y: number };
|
||||
public startBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
|
||||
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
|
||||
this.elementId = element.id as string & {
|
||||
@ -33,6 +44,8 @@ export class LinearElementEditor {
|
||||
this.lastUncommittedPoint = null;
|
||||
this.isDragging = false;
|
||||
this.pointerOffset = { x: 0, y: 0 };
|
||||
this.startBindingElement = "keep";
|
||||
this.endBindingElement = "keep";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -59,6 +72,10 @@ export class LinearElementEditor {
|
||||
setState: React.Component<any, AppState>["setState"],
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
maybeSuggestBinding: (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
) => void,
|
||||
): boolean {
|
||||
if (!appState.editingLinearElement) {
|
||||
return false;
|
||||
@ -88,13 +105,18 @@ export class LinearElementEditor {
|
||||
appState.gridSize,
|
||||
);
|
||||
LinearElementEditor.movePoint(element, activePointIndex, newPoint);
|
||||
if (isBindingElement(element)) {
|
||||
maybeSuggestBinding(element, activePointIndex === 0 ? "start" : "end");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static handlePointerUp(
|
||||
event: PointerEvent,
|
||||
editingLinearElement: LinearElementEditor,
|
||||
appState: AppState,
|
||||
): LinearElementEditor {
|
||||
const { elementId, activePointIndex, isDragging } = editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
@ -102,12 +124,12 @@ export class LinearElementEditor {
|
||||
return editingLinearElement;
|
||||
}
|
||||
|
||||
let binding = {};
|
||||
if (
|
||||
isDragging &&
|
||||
(activePointIndex === 0 ||
|
||||
activePointIndex === element.points.length - 1) &&
|
||||
isPathALoop(element.points)
|
||||
(activePointIndex === 0 || activePointIndex === element.points.length - 1)
|
||||
) {
|
||||
if (isPathALoop(element.points)) {
|
||||
LinearElementEditor.movePoint(
|
||||
element,
|
||||
activePointIndex,
|
||||
@ -116,8 +138,26 @@ export class LinearElementEditor {
|
||||
: element.points[0],
|
||||
);
|
||||
}
|
||||
const bindingElement = isBindingEnabled(appState)
|
||||
? getHoveredElementForBinding(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
activePointIndex!,
|
||||
),
|
||||
),
|
||||
Scene.getScene(element)!,
|
||||
)
|
||||
: null;
|
||||
binding = {
|
||||
[activePointIndex === 0
|
||||
? "startBindingElement"
|
||||
: "endBindingElement"]: bindingElement,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...editingLinearElement,
|
||||
...binding,
|
||||
isDragging: false,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
};
|
||||
@ -128,8 +168,7 @@ export class LinearElementEditor {
|
||||
appState: AppState,
|
||||
setState: React.Component<any, AppState>["setState"],
|
||||
history: SceneHistory,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
scenePointer: { x: number; y: number },
|
||||
): {
|
||||
didAddPoint: boolean;
|
||||
hitElement: ExcalidrawElement | null;
|
||||
@ -151,14 +190,14 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
if (event.altKey) {
|
||||
if (!appState.editingLinearElement.lastUncommittedPoint) {
|
||||
if (appState.editingLinearElement.lastUncommittedPoint == null) {
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
...element.points,
|
||||
LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
appState.gridSize,
|
||||
),
|
||||
],
|
||||
@ -170,6 +209,10 @@ export class LinearElementEditor {
|
||||
...appState.editingLinearElement,
|
||||
activePointIndex: element.points.length - 1,
|
||||
lastUncommittedPoint: null,
|
||||
endBindingElement: getHoveredElementForBinding(
|
||||
scenePointer,
|
||||
Scene.getScene(element)!,
|
||||
),
|
||||
},
|
||||
});
|
||||
ret.didAddPoint = true;
|
||||
@ -179,14 +222,31 @@ export class LinearElementEditor {
|
||||
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||
element,
|
||||
appState.zoom,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
|
||||
// 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;
|
||||
} else {
|
||||
// You might be wandering why we are storing the binding elements on
|
||||
// LinearElementEditor and passing them in, insted of calculating them
|
||||
// from the end points of the `linearElement` - this is to allow disabling
|
||||
// binding (which needs to happen at the point the user finishes moving
|
||||
// the point).
|
||||
const {
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
if (isBindingEnabled(appState) && isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
@ -208,8 +268,8 @@ export class LinearElementEditor {
|
||||
activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null,
|
||||
pointerOffset: targetPoint
|
||||
? {
|
||||
x: scenePointerX - targetPoint[0],
|
||||
y: scenePointerY - targetPoint[1],
|
||||
x: scenePointer.x - targetPoint[0],
|
||||
y: scenePointer.y - targetPoint[1],
|
||||
}
|
||||
: { x: 0, y: 0 },
|
||||
},
|
||||
@ -237,7 +297,7 @@ export class LinearElementEditor {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoint(element, points.length - 1, "delete");
|
||||
}
|
||||
return editingLinearElement;
|
||||
return { ...editingLinearElement, lastUncommittedPoint: null };
|
||||
}
|
||||
|
||||
const newPoint = LinearElementEditor.createPointAt(
|
||||
@ -276,6 +336,40 @@ export class LinearElementEditor {
|
||||
});
|
||||
}
|
||||
|
||||
static getPointAtIndexGlobalCoordinates(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
indexMaybeFromEnd: number, // -1 for last element
|
||||
): Point {
|
||||
const index =
|
||||
indexMaybeFromEnd < 0
|
||||
? element.points.length + indexMaybeFromEnd
|
||||
: indexMaybeFromEnd;
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
|
||||
const point = element.points[index];
|
||||
const { x, y } = element;
|
||||
return rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
||||
}
|
||||
|
||||
static pointFromAbsoluteCoords(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
absoluteCoords: Point,
|
||||
): Point {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const [x, y] = rotate(
|
||||
absoluteCoords[0],
|
||||
absoluteCoords[1],
|
||||
cx,
|
||||
cy,
|
||||
-element.angle,
|
||||
);
|
||||
return [x - element.x, y - element.y];
|
||||
}
|
||||
|
||||
static getPointIndexUnderCursor(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
zoom: AppState["zoom"],
|
||||
@ -343,10 +437,23 @@ export class LinearElementEditor {
|
||||
});
|
||||
}
|
||||
|
||||
static movePointByOffset(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointIndex: number,
|
||||
offset: { x: number; y: number },
|
||||
) {
|
||||
const [x, y] = element.points[pointIndex];
|
||||
LinearElementEditor.movePoint(element, pointIndex, [
|
||||
x + offset.x,
|
||||
y + offset.y,
|
||||
]);
|
||||
}
|
||||
|
||||
static movePoint(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointIndex: number | "new",
|
||||
targetPosition: Point | "delete",
|
||||
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
|
||||
) {
|
||||
const { points } = element;
|
||||
|
||||
@ -412,6 +519,7 @@ export class LinearElementEditor {
|
||||
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
|
||||
|
||||
mutateElement(element, {
|
||||
...otherUpdates,
|
||||
points: nextPoints,
|
||||
x: element.x + rotated[0],
|
||||
y: element.y + rotated[1],
|
||||
|
@ -24,6 +24,7 @@ type ElementConstructorOpts = MarkOptional<
|
||||
| "height"
|
||||
| "angle"
|
||||
| "groupIds"
|
||||
| "boundElementIds"
|
||||
| "seed"
|
||||
| "version"
|
||||
| "versionNonce"
|
||||
@ -45,6 +46,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
height = 0,
|
||||
angle = 0,
|
||||
groupIds = [],
|
||||
boundElementIds = null,
|
||||
...rest
|
||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||
) => ({
|
||||
@ -67,6 +69,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
version: rest.version || 1,
|
||||
versionNonce: rest.versionNonce ?? 0,
|
||||
isDeleted: false as false,
|
||||
boundElementIds,
|
||||
});
|
||||
|
||||
export const newElement = (
|
||||
@ -215,6 +218,8 @@ export const newLinearElement = (
|
||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||
points: [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -22,6 +22,7 @@ import {
|
||||
normalizeResizeHandle,
|
||||
} from "./resizeTest";
|
||||
import { measureText, getFontString } from "../utils";
|
||||
import { updateBoundElements } from "./binding";
|
||||
|
||||
const normalizeAngle = (angle: number): number => {
|
||||
if (angle >= 2 * Math.PI) {
|
||||
@ -32,6 +33,7 @@ const normalizeAngle = (angle: number): number => {
|
||||
|
||||
type ResizeTestType = ReturnType<typeof resizeTest>;
|
||||
|
||||
// Returns true when a resize (scaling/rotation) happened
|
||||
export const resizeElements = (
|
||||
resizeHandle: ResizeTestType,
|
||||
setResizeHandle: (nextResizeHandle: ResizeTestType) => void,
|
||||
@ -55,6 +57,7 @@ export const resizeElements = (
|
||||
pointerY,
|
||||
isRotateWithDiscreteAngle,
|
||||
);
|
||||
updateBoundElements(element);
|
||||
} else if (
|
||||
isLinearElement(element) &&
|
||||
element.points.length === 2 &&
|
||||
@ -404,6 +407,9 @@ const resizeSingleElement = (
|
||||
const deltaX2 = (x2 - nextX2) / 2;
|
||||
const deltaY2 = (y2 - nextY2) / 2;
|
||||
const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight);
|
||||
updateBoundElements(element, {
|
||||
newSize: { width: nextWidth, height: nextHeight },
|
||||
});
|
||||
const [finalX1, finalY1, finalX2, finalY2] = getResizedElementAbsoluteCoords(
|
||||
{
|
||||
...element,
|
||||
@ -530,6 +536,10 @@ const resizeMultipleElements = (
|
||||
}
|
||||
const origCoords = getElementAbsoluteCoords(element);
|
||||
const rescaledPoints = rescalePointsInElement(element, width, height);
|
||||
updateBoundElements(element, {
|
||||
newSize: { width, height },
|
||||
simultaneouslyUpdated: elements,
|
||||
});
|
||||
const finalCoords = getResizedElementAbsoluteCoords(
|
||||
{
|
||||
...element,
|
||||
|
@ -2,6 +2,7 @@ import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawBindableElement,
|
||||
} from "./types";
|
||||
|
||||
export const isTextElement = (
|
||||
@ -13,11 +14,38 @@ export const isTextElement = (
|
||||
export const isLinearElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
return element != null && isLinearElementType(element.type);
|
||||
};
|
||||
|
||||
export const isLinearElementType = (
|
||||
elementType: ExcalidrawElement["type"],
|
||||
): boolean => {
|
||||
return (
|
||||
elementType === "arrow" || elementType === "line" || elementType === "draw"
|
||||
);
|
||||
};
|
||||
|
||||
export const isBindingElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
return element != null && isBindingElementType(element.type);
|
||||
};
|
||||
|
||||
export const isBindingElementType = (
|
||||
elementType: ExcalidrawElement["type"],
|
||||
): boolean => {
|
||||
return elementType === "arrow";
|
||||
};
|
||||
|
||||
export const isBindableElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "arrow" ||
|
||||
element.type === "line" ||
|
||||
element.type === "draw")
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "text")
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -22,19 +22,33 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
versionNonce: number;
|
||||
isDeleted: boolean;
|
||||
groupIds: readonly GroupId[];
|
||||
boundElementIds: readonly ExcalidrawLinearElement["id"][] | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
||||
type: "selection";
|
||||
};
|
||||
|
||||
export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
|
||||
type: "rectangle";
|
||||
};
|
||||
|
||||
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
|
||||
type: "diamond";
|
||||
};
|
||||
|
||||
export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
||||
type: "ellipse";
|
||||
};
|
||||
|
||||
/**
|
||||
* These are elements that don't have any additional properties.
|
||||
*/
|
||||
export type ExcalidrawGenericElement =
|
||||
| ExcalidrawSelectionElement
|
||||
| (_ExcalidrawElementBase & {
|
||||
type: "rectangle" | "diamond" | "ellipse";
|
||||
});
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
/**
|
||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||
@ -63,11 +77,25 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
verticalAlign: VerticalAlign;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawBindableElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement;
|
||||
|
||||
export type PointBinding = {
|
||||
elementId: ExcalidrawBindableElement["id"];
|
||||
focus: number;
|
||||
gap: number;
|
||||
};
|
||||
|
||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "arrow" | "line" | "draw";
|
||||
points: readonly Point[];
|
||||
lastCommittedPoint: Point | null;
|
||||
startBinding: PointBinding | null;
|
||||
endBinding: PointBinding | null;
|
||||
}>;
|
||||
|
||||
export type PointerType = "mouse" | "pen" | "touch";
|
||||
|
340
src/ga.ts
Normal file
340
src/ga.ts
Normal file
@ -0,0 +1,340 @@
|
||||
/**
|
||||
* This is a 2D Projective Geometric Algebra implementation.
|
||||
*
|
||||
* For wider context on geometric algebra visit see https://bivector.net.
|
||||
*
|
||||
* For this specific algebra see cheatsheet https://bivector.net/2DPGA.pdf.
|
||||
*
|
||||
* Converted from generator written by enki, with a ton of added on top.
|
||||
*
|
||||
* This library uses 8-vectors to represent points, directions and lines
|
||||
* in 2D space.
|
||||
*
|
||||
* An array `[a, b, c, d, e, f, g, h]` represents a n(8)vector:
|
||||
* a + b*e0 + c*e1 + d*e2 + e*e01 + f*e20 + g*e12 + h*e012
|
||||
*
|
||||
* See GAPoint, GALine, GADirection and GATransform modules for common
|
||||
* operations.
|
||||
*/
|
||||
|
||||
export type Point = NVector;
|
||||
export type Direction = NVector;
|
||||
export type Line = NVector;
|
||||
export type Transform = NVector;
|
||||
|
||||
export function point(x: number, y: number): Point {
|
||||
return [0, 0, 0, 0, y, x, 1, 0];
|
||||
}
|
||||
|
||||
export function origin(): Point {
|
||||
return [0, 0, 0, 0, 0, 0, 1, 0];
|
||||
}
|
||||
|
||||
export function direction(x: number, y: number): Direction {
|
||||
const norm = Math.hypot(x, y); // same as `inorm(direction(x, y))`
|
||||
return [0, 0, 0, 0, y / norm, x / norm, 0, 0];
|
||||
}
|
||||
|
||||
export function offset(x: number, y: number): Direction {
|
||||
return [0, 0, 0, 0, y, x, 0, 0];
|
||||
}
|
||||
|
||||
/// This is the "implementation" part of the library
|
||||
|
||||
type NVector = readonly [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
|
||||
// These are labels for what each number in an nvector represents
|
||||
const NVECTOR_BASE = ["1", "e0", "e1", "e2", "e01", "e20", "e12", "e012"];
|
||||
|
||||
// Used to represent points, lines and transformations
|
||||
export function nvector(value: number = 0, index: number = 0): NVector {
|
||||
const result = [0, 0, 0, 0, 0, 0, 0, 0];
|
||||
if (index < 0 || index > 7) {
|
||||
throw new Error(`Expected \`index\` betwen 0 and 7, got \`${index}\``);
|
||||
}
|
||||
if (value !== 0) {
|
||||
result[index] = value;
|
||||
}
|
||||
return (result as unknown) as NVector;
|
||||
}
|
||||
|
||||
const STRING_EPSILON = 0.000001;
|
||||
export function toString(nvector: NVector): string {
|
||||
const result = nvector
|
||||
.map((value, index) =>
|
||||
Math.abs(value) > STRING_EPSILON
|
||||
? value.toFixed(7).replace(/(\.|0+)$/, "") +
|
||||
(index > 0 ? NVECTOR_BASE[index] : "")
|
||||
: null,
|
||||
)
|
||||
.filter((representation) => representation != null)
|
||||
.join(" + ");
|
||||
return result === "" ? "0" : result;
|
||||
}
|
||||
|
||||
// Reverse the order of the basis blades.
|
||||
export function reverse(nvector: NVector): NVector {
|
||||
return [
|
||||
nvector[0],
|
||||
nvector[1],
|
||||
nvector[2],
|
||||
nvector[3],
|
||||
-nvector[4],
|
||||
-nvector[5],
|
||||
-nvector[6],
|
||||
-nvector[7],
|
||||
];
|
||||
}
|
||||
|
||||
// Poincare duality operator.
|
||||
export function dual(nvector: NVector): NVector {
|
||||
return [
|
||||
nvector[7],
|
||||
nvector[6],
|
||||
nvector[5],
|
||||
nvector[4],
|
||||
nvector[3],
|
||||
nvector[2],
|
||||
nvector[1],
|
||||
nvector[0],
|
||||
];
|
||||
}
|
||||
|
||||
// Clifford Conjugation
|
||||
export function conjugate(nvector: NVector): NVector {
|
||||
return [
|
||||
nvector[0],
|
||||
-nvector[1],
|
||||
-nvector[2],
|
||||
-nvector[3],
|
||||
-nvector[4],
|
||||
-nvector[5],
|
||||
-nvector[6],
|
||||
nvector[7],
|
||||
];
|
||||
}
|
||||
|
||||
// Main involution
|
||||
export function involute(nvector: NVector): NVector {
|
||||
return [
|
||||
nvector[0],
|
||||
-nvector[1],
|
||||
-nvector[2],
|
||||
-nvector[3],
|
||||
nvector[4],
|
||||
nvector[5],
|
||||
nvector[6],
|
||||
-nvector[7],
|
||||
];
|
||||
}
|
||||
|
||||
// Multivector addition
|
||||
export function add(a: NVector, b: NVector | number): NVector {
|
||||
if (isNumber(b)) {
|
||||
return [a[0] + b, a[1], a[2], a[3], a[4], a[5], a[6], a[7]];
|
||||
}
|
||||
return [
|
||||
a[0] + b[0],
|
||||
a[1] + b[1],
|
||||
a[2] + b[2],
|
||||
a[3] + b[3],
|
||||
a[4] + b[4],
|
||||
a[5] + b[5],
|
||||
a[6] + b[6],
|
||||
a[7] + b[7],
|
||||
];
|
||||
}
|
||||
|
||||
// Multivector subtraction
|
||||
export function sub(a: NVector, b: NVector | number): NVector {
|
||||
if (isNumber(b)) {
|
||||
return [a[0] - b, a[1], a[2], a[3], a[4], a[5], a[6], a[7]];
|
||||
}
|
||||
return [
|
||||
a[0] - b[0],
|
||||
a[1] - b[1],
|
||||
a[2] - b[2],
|
||||
a[3] - b[3],
|
||||
a[4] - b[4],
|
||||
a[5] - b[5],
|
||||
a[6] - b[6],
|
||||
a[7] - b[7],
|
||||
];
|
||||
}
|
||||
|
||||
// The geometric product.
|
||||
export function mul(a: NVector, b: NVector | number): NVector {
|
||||
if (isNumber(b)) {
|
||||
return [
|
||||
a[0] * b,
|
||||
a[1] * b,
|
||||
a[2] * b,
|
||||
a[3] * b,
|
||||
a[4] * b,
|
||||
a[5] * b,
|
||||
a[6] * b,
|
||||
a[7] * b,
|
||||
];
|
||||
}
|
||||
return [
|
||||
mulScalar(a, b),
|
||||
b[1] * a[0] +
|
||||
b[0] * a[1] -
|
||||
b[4] * a[2] +
|
||||
b[5] * a[3] +
|
||||
b[2] * a[4] -
|
||||
b[3] * a[5] -
|
||||
b[7] * a[6] -
|
||||
b[6] * a[7],
|
||||
b[2] * a[0] + b[0] * a[2] - b[6] * a[3] + b[3] * a[6],
|
||||
b[3] * a[0] + b[6] * a[2] + b[0] * a[3] - b[2] * a[6],
|
||||
b[4] * a[0] +
|
||||
b[2] * a[1] -
|
||||
b[1] * a[2] +
|
||||
b[7] * a[3] +
|
||||
b[0] * a[4] +
|
||||
b[6] * a[5] -
|
||||
b[5] * a[6] +
|
||||
b[3] * a[7],
|
||||
b[5] * a[0] -
|
||||
b[3] * a[1] +
|
||||
b[7] * a[2] +
|
||||
b[1] * a[3] -
|
||||
b[6] * a[4] +
|
||||
b[0] * a[5] +
|
||||
b[4] * a[6] +
|
||||
b[2] * a[7],
|
||||
b[6] * a[0] + b[3] * a[2] - b[2] * a[3] + b[0] * a[6],
|
||||
b[7] * a[0] +
|
||||
b[6] * a[1] +
|
||||
b[5] * a[2] +
|
||||
b[4] * a[3] +
|
||||
b[3] * a[4] +
|
||||
b[2] * a[5] +
|
||||
b[1] * a[6] +
|
||||
b[0] * a[7],
|
||||
];
|
||||
}
|
||||
|
||||
export function mulScalar(a: NVector, b: NVector): number {
|
||||
return b[0] * a[0] + b[2] * a[2] + b[3] * a[3] - b[6] * a[6];
|
||||
}
|
||||
|
||||
// The outer/exterior/wedge product.
|
||||
export function meet(a: NVector, b: NVector): NVector {
|
||||
return [
|
||||
b[0] * a[0],
|
||||
b[1] * a[0] + b[0] * a[1],
|
||||
b[2] * a[0] + b[0] * a[2],
|
||||
b[3] * a[0] + b[0] * a[3],
|
||||
b[4] * a[0] + b[2] * a[1] - b[1] * a[2] + b[0] * a[4],
|
||||
b[5] * a[0] - b[3] * a[1] + b[1] * a[3] + b[0] * a[5],
|
||||
b[6] * a[0] + b[3] * a[2] - b[2] * a[3] + b[0] * a[6],
|
||||
b[7] * a[0] +
|
||||
b[6] * a[1] +
|
||||
b[5] * a[2] +
|
||||
b[4] * a[3] +
|
||||
b[3] * a[4] +
|
||||
b[2] * a[5] +
|
||||
b[1] * a[6],
|
||||
];
|
||||
}
|
||||
|
||||
// The regressive product.
|
||||
export function join(a: NVector, b: NVector): NVector {
|
||||
return [
|
||||
joinScalar(a, b),
|
||||
a[1] * b[7] + a[4] * b[5] - a[5] * b[4] + a[7] * b[1],
|
||||
a[2] * b[7] - a[4] * b[6] + a[6] * b[4] + a[7] * b[2],
|
||||
a[3] * b[7] + a[5] * b[6] - a[6] * b[5] + a[7] * b[3],
|
||||
a[4] * b[7] + a[7] * b[4],
|
||||
a[5] * b[7] + a[7] * b[5],
|
||||
a[6] * b[7] + a[7] * b[6],
|
||||
a[7] * b[7],
|
||||
];
|
||||
}
|
||||
|
||||
export function joinScalar(a: NVector, b: NVector): number {
|
||||
return (
|
||||
a[0] * b[7] +
|
||||
a[1] * b[6] +
|
||||
a[2] * b[5] +
|
||||
a[3] * b[4] +
|
||||
a[4] * b[3] +
|
||||
a[5] * b[2] +
|
||||
a[6] * b[1] +
|
||||
a[7] * b[0]
|
||||
);
|
||||
}
|
||||
|
||||
// The inner product.
|
||||
export function dot(a: NVector, b: NVector): NVector {
|
||||
return [
|
||||
b[0] * a[0] + b[2] * a[2] + b[3] * a[3] - b[6] * a[6],
|
||||
b[1] * a[0] +
|
||||
b[0] * a[1] -
|
||||
b[4] * a[2] +
|
||||
b[5] * a[3] +
|
||||
b[2] * a[4] -
|
||||
b[3] * a[5] -
|
||||
b[7] * a[6] -
|
||||
b[6] * a[7],
|
||||
b[2] * a[0] + b[0] * a[2] - b[6] * a[3] + b[3] * a[6],
|
||||
b[3] * a[0] + b[6] * a[2] + b[0] * a[3] - b[2] * a[6],
|
||||
b[4] * a[0] + b[7] * a[3] + b[0] * a[4] + b[3] * a[7],
|
||||
b[5] * a[0] + b[7] * a[2] + b[0] * a[5] + b[2] * a[7],
|
||||
b[6] * a[0] + b[0] * a[6],
|
||||
b[7] * a[0] + b[0] * a[7],
|
||||
];
|
||||
}
|
||||
|
||||
export function norm(a: NVector): number {
|
||||
return Math.sqrt(
|
||||
Math.abs(a[0] * a[0] - a[2] * a[2] - a[3] * a[3] + a[6] * a[6]),
|
||||
);
|
||||
}
|
||||
|
||||
export function inorm(a: NVector): number {
|
||||
return Math.sqrt(
|
||||
Math.abs(a[7] * a[7] - a[5] * a[5] - a[4] * a[4] + a[1] * a[1]),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalized(a: NVector): NVector {
|
||||
const n = norm(a);
|
||||
if (n === 0 || n === 1) {
|
||||
return a;
|
||||
}
|
||||
const sign = a[6] < 0 ? -1 : 1;
|
||||
return mul(a, sign / n);
|
||||
}
|
||||
|
||||
export function inormalized(a: NVector): NVector {
|
||||
const n = inorm(a);
|
||||
if (n === 0 || n === 1) {
|
||||
return a;
|
||||
}
|
||||
return mul(a, 1 / n);
|
||||
}
|
||||
|
||||
function isNumber(a: any): a is number {
|
||||
return typeof a === "number";
|
||||
}
|
||||
|
||||
export const E0: NVector = nvector(1, 1);
|
||||
export const E1: NVector = nvector(1, 2);
|
||||
export const E2: NVector = nvector(1, 3);
|
||||
export const E01: NVector = nvector(1, 4);
|
||||
export const E20: NVector = nvector(1, 5);
|
||||
export const E12: NVector = nvector(1, 6);
|
||||
export const E012: NVector = nvector(1, 7);
|
||||
export const I = E012;
|
23
src/gadirections.ts
Normal file
23
src/gadirections.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import * as GA from "./ga";
|
||||
import { Line, Direction, Point } from "./ga";
|
||||
|
||||
/**
|
||||
* A direction is stored as an array `[0, 0, 0, 0, y, x, 0, 0]` representing
|
||||
* vector `(x, y)`.
|
||||
*/
|
||||
|
||||
export function from(point: Point): Point {
|
||||
return [0, 0, 0, 0, point[4], point[5], 0, 0];
|
||||
}
|
||||
|
||||
export function fromTo(from: Point, to: Point): Direction {
|
||||
return GA.inormalized([0, 0, 0, 0, to[4] - from[4], to[5] - from[5], 0, 0]);
|
||||
}
|
||||
|
||||
export function orthogonal(direction: Direction): Direction {
|
||||
return GA.inormalized([0, 0, 0, 0, -direction[5], direction[4], 0, 0]);
|
||||
}
|
||||
|
||||
export function orthogonalToLine(line: Line): Direction {
|
||||
return GA.mul(line, GA.I);
|
||||
}
|
62
src/galines.ts
Normal file
62
src/galines.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import * as GA from "./ga";
|
||||
import { Line, Point } from "./ga";
|
||||
|
||||
/**
|
||||
* A line is stored as an array `[0, c, a, b, 0, 0, 0, 0]` representing:
|
||||
* c * e0 + a * e1 + b*e2
|
||||
*
|
||||
* This maps to a standard formula `a * x + b * y + c`.
|
||||
*
|
||||
* `(-b, a)` correponds to a 2D vector parallel to the line. The lines
|
||||
* have a natural orientation, corresponding to that vector.
|
||||
*
|
||||
* The magnitude ("norm") of the line is `sqrt(a ^ 2 + b ^ 2)`.
|
||||
* `c / norm(line)` is the oriented distance from line to origin.
|
||||
*/
|
||||
|
||||
// Returns line with direction (x, y) through origin
|
||||
export function vector(x: number, y: number): Line {
|
||||
return GA.normalized([0, 0, -y, x, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
// For equation ax + by + c = 0.
|
||||
export function equation(a: number, b: number, c: number): Line {
|
||||
return GA.normalized([0, c, a, b, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
export function through(from: Point, to: Point): Line {
|
||||
return GA.normalized(GA.join(to, from));
|
||||
}
|
||||
|
||||
export function orthogonal(line: Line, point: Point): Line {
|
||||
return GA.dot(line, point);
|
||||
}
|
||||
|
||||
// Returns a line perpendicular to the line through `against` and `intersection`
|
||||
// going through `intersection`.
|
||||
export function orthogonalThrough(against: Point, intersection: Point): Line {
|
||||
return orthogonal(through(against, intersection), intersection);
|
||||
}
|
||||
|
||||
export function parallel(line: Line, distance: number): Line {
|
||||
const result = line.slice();
|
||||
result[1] -= distance;
|
||||
return (result as unknown) as Line;
|
||||
}
|
||||
|
||||
export function parallelThrough(line: Line, point: Point): Line {
|
||||
return orthogonal(orthogonal(point, line), point);
|
||||
}
|
||||
|
||||
export function distance(line1: Line, line2: Line): number {
|
||||
return GA.inorm(GA.meet(line1, line2));
|
||||
}
|
||||
|
||||
export function angle(line1: Line, line2: Line): number {
|
||||
return Math.acos(GA.dot(line1, line2)[0]);
|
||||
}
|
||||
|
||||
// The orientation of the line
|
||||
export function sign(line: Line): number {
|
||||
return Math.sign(line[1]);
|
||||
}
|
37
src/gapoints.ts
Normal file
37
src/gapoints.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import * as GA from "./ga";
|
||||
import * as GALine from "./galines";
|
||||
import { Point, Line, join } from "./ga";
|
||||
|
||||
/**
|
||||
* TODO: docs
|
||||
*/
|
||||
|
||||
export function from([x, y]: readonly [number, number]): Point {
|
||||
return [0, 0, 0, 0, y, x, 1, 0];
|
||||
}
|
||||
|
||||
export function toTuple(point: Point): [number, number] {
|
||||
return [point[5], point[4]];
|
||||
}
|
||||
|
||||
export function abs(point: Point): Point {
|
||||
return [0, 0, 0, 0, Math.abs(point[4]), Math.abs(point[5]), 1, 0];
|
||||
}
|
||||
|
||||
export function intersect(line1: Line, line2: Line): Point {
|
||||
return GA.normalized(GA.meet(line1, line2));
|
||||
}
|
||||
|
||||
// Projects `point` onto the `line`.
|
||||
// The returned point is the closest point on the `line` to the `point`.
|
||||
export function project(point: Point, line: Line): Point {
|
||||
return intersect(GALine.orthogonal(line, point), line);
|
||||
}
|
||||
|
||||
export function distance(point1: Point, point2: Point): number {
|
||||
return GA.norm(join(point1, point2));
|
||||
}
|
||||
|
||||
export function distanceToLine(point: Point, line: Line): number {
|
||||
return GA.joinScalar(point, line);
|
||||
}
|
38
src/gatransforms.ts
Normal file
38
src/gatransforms.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import * as GA from "./ga";
|
||||
import { Line, Direction, Point, Transform } from "./ga";
|
||||
import * as GADirection from "./gadirections";
|
||||
|
||||
/**
|
||||
* TODO: docs
|
||||
*/
|
||||
|
||||
export function rotation(pivot: Point, angle: number): Transform {
|
||||
return GA.add(GA.mul(pivot, Math.sin(angle / 2)), Math.cos(angle / 2));
|
||||
}
|
||||
|
||||
export function translation(direction: Direction): Transform {
|
||||
return [1, 0, 0, 0, -(0.5 * direction[5]), 0.5 * direction[4], 0, 0];
|
||||
}
|
||||
|
||||
export function translationOrthogonal(
|
||||
direction: Direction,
|
||||
distance: number,
|
||||
): Transform {
|
||||
const scale = 0.5 * distance;
|
||||
return [1, 0, 0, 0, scale * direction[4], scale * direction[5], 0, 0];
|
||||
}
|
||||
|
||||
export function translationAlong(line: Line, distance: number): Transform {
|
||||
return GA.add(GA.mul(GADirection.orthogonalToLine(line), 0.5 * distance), 1);
|
||||
}
|
||||
|
||||
export function compose(motor1: Transform, motor2: Transform): Transform {
|
||||
return GA.mul(motor2, motor1);
|
||||
}
|
||||
|
||||
export function apply(
|
||||
motor: Transform,
|
||||
nvector: Point | Direction | Line,
|
||||
): Point | Direction | Line {
|
||||
return GA.normalized(GA.mul(GA.mul(motor, nvector), GA.reverse(motor)));
|
||||
}
|
76
src/math.ts
76
src/math.ts
@ -2,45 +2,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 = (
|
||||
x: number,
|
||||
y: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
) => {
|
||||
const A = x - x1;
|
||||
const B = y - y1;
|
||||
const C = x2 - x1;
|
||||
const D = y2 - y1;
|
||||
|
||||
const dot = A * C + B * D;
|
||||
const lenSquare = C * C + D * D;
|
||||
let param = -1;
|
||||
if (lenSquare !== 0) {
|
||||
// in case of 0 length line
|
||||
param = dot / lenSquare;
|
||||
}
|
||||
|
||||
let xx, yy;
|
||||
if (param < 0) {
|
||||
xx = x1;
|
||||
yy = y1;
|
||||
} else if (param > 1) {
|
||||
xx = x2;
|
||||
yy = y2;
|
||||
} else {
|
||||
xx = x1 + param * C;
|
||||
yy = y1 + param * D;
|
||||
}
|
||||
|
||||
const dx = x - xx;
|
||||
const dy = y - yy;
|
||||
return Math.hypot(dx, dy);
|
||||
};
|
||||
|
||||
export const rotate = (
|
||||
x1: number,
|
||||
y1: number,
|
||||
@ -230,6 +191,10 @@ export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
|
||||
return Math.hypot(xd, yd);
|
||||
};
|
||||
|
||||
export const centerPoint = (a: Point, b: Point): Point => {
|
||||
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
};
|
||||
|
||||
// Checks if the first and last point are close enough
|
||||
// to be considered a loop
|
||||
export const isPathALoop = (
|
||||
@ -265,9 +230,9 @@ export const isPointInPolygon = (
|
||||
for (let i = 0; i < vertices; i++) {
|
||||
const current = points[i];
|
||||
const next = points[(i + 1) % vertices];
|
||||
if (doIntersect(current, next, p, extreme)) {
|
||||
if (orientation(current, p, next) === 0) {
|
||||
return onSegment(current, p, next);
|
||||
if (doSegmentsIntersect(current, next, p, extreme)) {
|
||||
if (orderedColinearOrientation(current, p, next) === 0) {
|
||||
return isPointWithinBounds(current, p, next);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
@ -276,8 +241,9 @@ export const isPointInPolygon = (
|
||||
return count % 2 === 1;
|
||||
};
|
||||
|
||||
// Check if q lies on the line segment pr
|
||||
const onSegment = (p: Point, q: Point, r: Point) => {
|
||||
// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
|
||||
// This is an approximation to "does `q` lie on a segment `pr`" check.
|
||||
const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
|
||||
return (
|
||||
q[0] <= Math.max(p[0], r[0]) &&
|
||||
q[0] >= Math.min(p[0], r[0]) &&
|
||||
@ -287,10 +253,10 @@ const onSegment = (p: Point, q: Point, r: Point) => {
|
||||
};
|
||||
|
||||
// For the ordered points p, q, r, return
|
||||
// 0 if p, q, r are collinear
|
||||
// 0 if p, q, r are colinear
|
||||
// 1 if Clockwise
|
||||
// 2 if counterclickwise
|
||||
const orientation = (p: Point, q: Point, r: Point) => {
|
||||
const orderedColinearOrientation = (p: Point, q: Point, r: Point) => {
|
||||
const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]);
|
||||
if (val === 0) {
|
||||
return 0;
|
||||
@ -299,33 +265,33 @@ const orientation = (p: Point, q: Point, r: Point) => {
|
||||
};
|
||||
|
||||
// Check is p1q1 intersects with p2q2
|
||||
const doIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
|
||||
const o1 = orientation(p1, q1, p2);
|
||||
const o2 = orientation(p1, q1, q2);
|
||||
const o3 = orientation(p2, q2, p1);
|
||||
const o4 = orientation(p2, q2, q1);
|
||||
const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
|
||||
const o1 = orderedColinearOrientation(p1, q1, p2);
|
||||
const o2 = orderedColinearOrientation(p1, q1, q2);
|
||||
const o3 = orderedColinearOrientation(p2, q2, p1);
|
||||
const o4 = orderedColinearOrientation(p2, q2, q1);
|
||||
|
||||
if (o1 !== o2 && o3 !== o4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// p1, q1 and p2 are colinear and p2 lies on segment p1q1
|
||||
if (o1 === 0 && onSegment(p1, p2, q1)) {
|
||||
if (o1 === 0 && isPointWithinBounds(p1, p2, q1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// p1, q1 and p2 are colinear and q2 lies on segment p1q1
|
||||
if (o2 === 0 && onSegment(p1, q2, q1)) {
|
||||
if (o2 === 0 && isPointWithinBounds(p1, q2, q1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// p2, q2 and p1 are colinear and p1 lies on segment p2q2
|
||||
if (o3 === 0 && onSegment(p2, p1, q2)) {
|
||||
if (o3 === 0 && isPointWithinBounds(p2, p1, q2)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// p2, q2 and q1 are colinear and q1 lies on segment p2q2
|
||||
if (o4 === 0 && onSegment(p2, q1, q2)) {
|
||||
if (o4 === 0 && isPointWithinBounds(p2, q1, q2)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
ExcalidrawLinearElement,
|
||||
NonDeleted,
|
||||
GroupId,
|
||||
ExcalidrawBindableElement,
|
||||
} from "../element/types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
@ -36,6 +37,13 @@ import {
|
||||
getSelectedGroupIds,
|
||||
getElementsInGroup,
|
||||
} from "../groups";
|
||||
import { maxBindingGap } from "../element/collision";
|
||||
import {
|
||||
SuggestedBinding,
|
||||
SuggestedPointBinding,
|
||||
isBindingEnabled,
|
||||
} from "../element/binding";
|
||||
import { Handlers } from "../element/handlerRectangles";
|
||||
|
||||
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
|
||||
|
||||
@ -48,7 +56,7 @@ const strokeRectWithRotation = (
|
||||
cx: number,
|
||||
cy: number,
|
||||
angle: number,
|
||||
fill?: boolean,
|
||||
fill: boolean = false,
|
||||
) => {
|
||||
context.translate(cx, cy);
|
||||
context.rotate(angle);
|
||||
@ -60,15 +68,48 @@ const strokeRectWithRotation = (
|
||||
context.translate(-cx, -cy);
|
||||
};
|
||||
|
||||
const strokeCircle = (
|
||||
const strokeDiamondWithRotation = (
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
angle: number,
|
||||
) => {
|
||||
context.translate(cx, cy);
|
||||
context.rotate(angle);
|
||||
context.beginPath();
|
||||
context.moveTo(0, height / 2);
|
||||
context.lineTo(width / 2, 0);
|
||||
context.lineTo(0, -height / 2);
|
||||
context.lineTo(-width / 2, 0);
|
||||
context.closePath();
|
||||
context.stroke();
|
||||
context.rotate(-angle);
|
||||
context.translate(-cx, -cy);
|
||||
};
|
||||
|
||||
const strokeEllipseWithRotation = (
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
angle: number,
|
||||
) => {
|
||||
context.beginPath();
|
||||
context.arc(x + width / 2, y + height / 2, width / 2, 0, Math.PI * 2);
|
||||
context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
};
|
||||
|
||||
const fillCircle = (
|
||||
context: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
) => {
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
};
|
||||
@ -116,12 +157,11 @@ const renderLinearPointHandles = (
|
||||
? "rgba(255, 127, 127, 0.9)"
|
||||
: "rgba(255, 255, 255, 0.9)";
|
||||
const { POINT_HANDLE_SIZE } = LinearElementEditor;
|
||||
strokeCircle(
|
||||
fillCircle(
|
||||
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,
|
||||
point[0],
|
||||
point[1],
|
||||
POINT_HANDLE_SIZE / 2 / sceneState.zoom,
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -241,14 +281,20 @@ export const renderScene = (
|
||||
);
|
||||
}
|
||||
|
||||
if (isBindingEnabled(appState)) {
|
||||
appState.suggestedBindings
|
||||
.filter((binding) => binding != null)
|
||||
.forEach((suggestedBinding) => {
|
||||
renderBindingHighlight(context, sceneState, suggestedBinding!);
|
||||
});
|
||||
}
|
||||
|
||||
// Paint selected elements
|
||||
if (
|
||||
renderSelection &&
|
||||
!appState.multiElement &&
|
||||
!appState.editingLinearElement
|
||||
) {
|
||||
context.translate(sceneState.scrollX, sceneState.scrollY);
|
||||
|
||||
const selections = elements.reduce((acc, element) => {
|
||||
const selectionColors = [];
|
||||
// local user
|
||||
@ -310,99 +356,28 @@ export const renderScene = (
|
||||
addSelectionForGroupId(appState.editingGroupId);
|
||||
}
|
||||
|
||||
selections.forEach(
|
||||
({
|
||||
angle,
|
||||
elementX1,
|
||||
elementY1,
|
||||
elementX2,
|
||||
elementY2,
|
||||
selectionColors,
|
||||
}) => {
|
||||
const elementWidth = elementX2 - elementX1;
|
||||
const elementHeight = elementY2 - elementY1;
|
||||
|
||||
const initialLineDash = context.getLineDash();
|
||||
const lineWidth = context.lineWidth;
|
||||
const lineDashOffset = context.lineDashOffset;
|
||||
const strokeStyle = context.strokeStyle;
|
||||
|
||||
const dashedLinePadding = 4 / sceneState.zoom;
|
||||
const dashWidth = 8 / sceneState.zoom;
|
||||
const spaceWidth = 4 / sceneState.zoom;
|
||||
|
||||
context.lineWidth = 1 / sceneState.zoom;
|
||||
|
||||
const count = selectionColors.length;
|
||||
for (var i = 0; i < count; ++i) {
|
||||
context.strokeStyle = selectionColors[i];
|
||||
context.setLineDash([
|
||||
dashWidth,
|
||||
spaceWidth + (dashWidth + spaceWidth) * (count - 1),
|
||||
]);
|
||||
context.lineDashOffset = (dashWidth + spaceWidth) * i;
|
||||
strokeRectWithRotation(
|
||||
context,
|
||||
elementX1 - dashedLinePadding,
|
||||
elementY1 - dashedLinePadding,
|
||||
elementWidth + dashedLinePadding * 2,
|
||||
elementHeight + dashedLinePadding * 2,
|
||||
elementX1 + elementWidth / 2,
|
||||
elementY1 + elementHeight / 2,
|
||||
angle,
|
||||
selections.forEach((selection) =>
|
||||
renderSelectionBorder(context, sceneState, selection),
|
||||
);
|
||||
}
|
||||
context.lineDashOffset = lineDashOffset;
|
||||
context.strokeStyle = strokeStyle;
|
||||
context.lineWidth = lineWidth;
|
||||
context.setLineDash(initialLineDash);
|
||||
},
|
||||
);
|
||||
context.translate(-sceneState.scrollX, -sceneState.scrollY);
|
||||
|
||||
const locallySelectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
// Paint resize handlers
|
||||
if (locallySelectedElements.length === 1) {
|
||||
context.translate(sceneState.scrollX, sceneState.scrollY);
|
||||
if (locallySelectedElements.length === 1) {
|
||||
context.fillStyle = oc.white;
|
||||
const handlers = handlerRectangles(
|
||||
locallySelectedElements[0],
|
||||
sceneState.zoom,
|
||||
);
|
||||
Object.keys(handlers).forEach((key) => {
|
||||
const handler = handlers[key as HandlerRectanglesRet];
|
||||
if (handler !== undefined) {
|
||||
const lineWidth = context.lineWidth;
|
||||
context.lineWidth = 1 / sceneState.zoom;
|
||||
if (key === "rotation") {
|
||||
strokeCircle(
|
||||
renderHandlers(
|
||||
context,
|
||||
handler[0],
|
||||
handler[1],
|
||||
handler[2],
|
||||
handler[3],
|
||||
);
|
||||
} else {
|
||||
strokeRectWithRotation(
|
||||
context,
|
||||
handler[0],
|
||||
handler[1],
|
||||
handler[2],
|
||||
handler[3],
|
||||
handler[0] + handler[2] / 2,
|
||||
handler[1] + handler[3] / 2,
|
||||
sceneState,
|
||||
handlers,
|
||||
locallySelectedElements[0].angle,
|
||||
true, // fill before stroke
|
||||
);
|
||||
}
|
||||
context.lineWidth = lineWidth;
|
||||
}
|
||||
});
|
||||
context.translate(-sceneState.scrollX, -sceneState.scrollY);
|
||||
} else if (locallySelectedElements.length > 1 && !appState.isRotating) {
|
||||
const dashedLinePadding = 4 / sceneState.zoom;
|
||||
context.translate(sceneState.scrollX, sceneState.scrollY);
|
||||
context.fillStyle = oc.white;
|
||||
const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
|
||||
const initialLineDash = context.getLineDash();
|
||||
@ -428,38 +403,10 @@ export const renderScene = (
|
||||
undefined,
|
||||
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
||||
);
|
||||
Object.keys(handlers).forEach((key) => {
|
||||
const handler = handlers[key as HandlerRectanglesRet];
|
||||
if (handler !== undefined) {
|
||||
const lineWidth = context.lineWidth;
|
||||
context.lineWidth = 1 / sceneState.zoom;
|
||||
if (key === "rotation") {
|
||||
strokeCircle(
|
||||
context,
|
||||
handler[0],
|
||||
handler[1],
|
||||
handler[2],
|
||||
handler[3],
|
||||
);
|
||||
} else {
|
||||
strokeRectWithRotation(
|
||||
context,
|
||||
handler[0],
|
||||
handler[1],
|
||||
handler[2],
|
||||
handler[3],
|
||||
handler[0] + handler[2] / 2,
|
||||
handler[1] + handler[3] / 2,
|
||||
0,
|
||||
true, // fill before stroke
|
||||
);
|
||||
renderHandlers(context, sceneState, handlers, 0);
|
||||
}
|
||||
context.lineWidth = lineWidth;
|
||||
}
|
||||
});
|
||||
context.translate(-sceneState.scrollX, -sceneState.scrollY);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset zoom
|
||||
context.scale(1 / sceneState.zoom, 1 / sceneState.zoom);
|
||||
@ -598,6 +545,207 @@ export const renderScene = (
|
||||
return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
|
||||
};
|
||||
|
||||
const renderHandlers = (
|
||||
context: CanvasRenderingContext2D,
|
||||
sceneState: SceneState,
|
||||
handlers: Handlers,
|
||||
angle: number,
|
||||
): void => {
|
||||
Object.keys(handlers).forEach((key) => {
|
||||
const handler = handlers[key as HandlerRectanglesRet];
|
||||
if (handler !== undefined) {
|
||||
const lineWidth = context.lineWidth;
|
||||
context.lineWidth = 1 / sceneState.zoom;
|
||||
if (key === "rotation") {
|
||||
fillCircle(
|
||||
context,
|
||||
handler[0] + handler[2] / 2,
|
||||
handler[1] + handler[3] / 2,
|
||||
handler[2] / 2,
|
||||
);
|
||||
} else {
|
||||
strokeRectWithRotation(
|
||||
context,
|
||||
handler[0],
|
||||
handler[1],
|
||||
handler[2],
|
||||
handler[3],
|
||||
handler[0] + handler[2] / 2,
|
||||
handler[1] + handler[3] / 2,
|
||||
angle,
|
||||
true, // fill before stroke
|
||||
);
|
||||
}
|
||||
context.lineWidth = lineWidth;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const renderSelectionBorder = (
|
||||
context: CanvasRenderingContext2D,
|
||||
sceneState: SceneState,
|
||||
elementProperties: {
|
||||
angle: number;
|
||||
elementX1: number;
|
||||
elementY1: number;
|
||||
elementX2: number;
|
||||
elementY2: number;
|
||||
selectionColors: string[];
|
||||
},
|
||||
) => {
|
||||
const {
|
||||
angle,
|
||||
elementX1,
|
||||
elementY1,
|
||||
elementX2,
|
||||
elementY2,
|
||||
selectionColors,
|
||||
} = elementProperties;
|
||||
const elementWidth = elementX2 - elementX1;
|
||||
const elementHeight = elementY2 - elementY1;
|
||||
|
||||
const initialLineDash = context.getLineDash();
|
||||
const lineWidth = context.lineWidth;
|
||||
const lineDashOffset = context.lineDashOffset;
|
||||
const strokeStyle = context.strokeStyle;
|
||||
|
||||
const dashedLinePadding = 4 / sceneState.zoom;
|
||||
const dashWidth = 8 / sceneState.zoom;
|
||||
const spaceWidth = 4 / sceneState.zoom;
|
||||
|
||||
context.lineWidth = 1 / sceneState.zoom;
|
||||
|
||||
context.translate(sceneState.scrollX, sceneState.scrollY);
|
||||
|
||||
const count = selectionColors.length;
|
||||
for (var i = 0; i < count; ++i) {
|
||||
context.strokeStyle = selectionColors[i];
|
||||
context.setLineDash([
|
||||
dashWidth,
|
||||
spaceWidth + (dashWidth + spaceWidth) * (count - 1),
|
||||
]);
|
||||
context.lineDashOffset = (dashWidth + spaceWidth) * i;
|
||||
strokeRectWithRotation(
|
||||
context,
|
||||
elementX1 - dashedLinePadding,
|
||||
elementY1 - dashedLinePadding,
|
||||
elementWidth + dashedLinePadding * 2,
|
||||
elementHeight + dashedLinePadding * 2,
|
||||
elementX1 + elementWidth / 2,
|
||||
elementY1 + elementHeight / 2,
|
||||
angle,
|
||||
);
|
||||
}
|
||||
context.lineDashOffset = lineDashOffset;
|
||||
context.strokeStyle = strokeStyle;
|
||||
context.lineWidth = lineWidth;
|
||||
context.setLineDash(initialLineDash);
|
||||
context.translate(-sceneState.scrollX, -sceneState.scrollY);
|
||||
};
|
||||
|
||||
const renderBindingHighlight = (
|
||||
context: CanvasRenderingContext2D,
|
||||
sceneState: SceneState,
|
||||
suggestedBinding: SuggestedBinding,
|
||||
) => {
|
||||
// preserve context settings to restore later
|
||||
const originalStrokeStyle = context.strokeStyle;
|
||||
const originalLineWidth = context.lineWidth;
|
||||
|
||||
const renderHighlight = Array.isArray(suggestedBinding)
|
||||
? renderBindingHighlightForSuggestedPointBinding
|
||||
: renderBindingHighlightForBindableElement;
|
||||
|
||||
context.translate(sceneState.scrollX, sceneState.scrollY);
|
||||
renderHighlight(context, suggestedBinding as any);
|
||||
|
||||
// restore context settings
|
||||
context.strokeStyle = originalStrokeStyle;
|
||||
context.lineWidth = originalLineWidth;
|
||||
context.translate(-sceneState.scrollX, -sceneState.scrollY);
|
||||
};
|
||||
|
||||
const renderBindingHighlightForBindableElement = (
|
||||
context: CanvasRenderingContext2D,
|
||||
element: ExcalidrawBindableElement,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
const threshold = maxBindingGap(element, width, height);
|
||||
|
||||
// So that we don't overlap the element itself
|
||||
const strokeOffset = 4;
|
||||
context.strokeStyle = "rgba(0,0,0,.05)";
|
||||
context.lineWidth = threshold - strokeOffset;
|
||||
const padding = strokeOffset + threshold / 2;
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "text":
|
||||
strokeRectWithRotation(
|
||||
context,
|
||||
x1 - padding,
|
||||
y1 - padding,
|
||||
width + padding * 2,
|
||||
height + padding * 2,
|
||||
x1 + width / 2,
|
||||
y1 + height / 2,
|
||||
element.angle,
|
||||
);
|
||||
break;
|
||||
case "diamond":
|
||||
const side = Math.hypot(width, height);
|
||||
const wPadding = (padding * side) / height;
|
||||
const hPadding = (padding * side) / width;
|
||||
strokeDiamondWithRotation(
|
||||
context,
|
||||
width + wPadding * 2,
|
||||
height + hPadding * 2,
|
||||
x1 + width / 2,
|
||||
y1 + height / 2,
|
||||
element.angle,
|
||||
);
|
||||
break;
|
||||
case "ellipse":
|
||||
strokeEllipseWithRotation(
|
||||
context,
|
||||
width + padding * 2,
|
||||
height + padding * 2,
|
||||
x1 + width / 2,
|
||||
y1 + height / 2,
|
||||
element.angle,
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const renderBindingHighlightForSuggestedPointBinding = (
|
||||
context: CanvasRenderingContext2D,
|
||||
suggestedBinding: SuggestedPointBinding,
|
||||
) => {
|
||||
const [element, startOrEnd, bindableElement] = suggestedBinding;
|
||||
|
||||
const threshold = maxBindingGap(
|
||||
bindableElement,
|
||||
bindableElement.width,
|
||||
bindableElement.height,
|
||||
);
|
||||
|
||||
context.strokeStyle = "rgba(0,0,0,0)";
|
||||
context.fillStyle = "rgba(0,0,0,.05)";
|
||||
|
||||
const pointIndices =
|
||||
startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1];
|
||||
pointIndices.forEach((index) => {
|
||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
index,
|
||||
);
|
||||
fillCircle(context, x, y, threshold);
|
||||
});
|
||||
};
|
||||
|
||||
const isVisibleElement = (
|
||||
element: ExcalidrawElement,
|
||||
viewportWidth: number,
|
||||
|
@ -52,10 +52,12 @@ class Scene {
|
||||
private elements: readonly ExcalidrawElement[] = [];
|
||||
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
|
||||
|
||||
// TODO: getAllElementsIncludingDeleted
|
||||
getElementsIncludingDeleted() {
|
||||
return this.elements;
|
||||
}
|
||||
|
||||
// TODO: getAllNonDeletedElements
|
||||
getElements(): readonly NonDeletedExcalidrawElement[] {
|
||||
return this.nonDeletedElements;
|
||||
}
|
||||
@ -74,6 +76,20 @@ class Scene {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Rename methods here, this is confusing
|
||||
getNonDeletedElements(
|
||||
ids: readonly ExcalidrawElement["id"][],
|
||||
): NonDeleted<ExcalidrawElement>[] {
|
||||
const result: NonDeleted<ExcalidrawElement>[] = [];
|
||||
ids.forEach((id) => {
|
||||
const element = this.getNonDeletedElement(id);
|
||||
if (element != null) {
|
||||
result.push(element);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
|
||||
this.elements = nextElements;
|
||||
this.elementsMap.clear();
|
||||
|
@ -3,8 +3,7 @@ import {
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
|
||||
import { getElementAbsoluteCoords, hitTest } from "../element";
|
||||
import { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from "../element";
|
||||
|
||||
export const hasBackground = (type: string) =>
|
||||
type === "rectangle" ||
|
||||
@ -25,19 +24,17 @@ export const hasText = (type: string) => type === "text";
|
||||
|
||||
export const getElementAtPosition = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
x: number,
|
||||
y: number,
|
||||
zoom: number,
|
||||
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
|
||||
) => {
|
||||
let hitElement = null;
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
for (let i = elements.length - 1; i >= 0; --i) {
|
||||
if (elements[i].isDeleted) {
|
||||
const element = elements[i];
|
||||
if (element.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
if (hitTest(elements[i], appState, x, y, zoom)) {
|
||||
hitElement = elements[i];
|
||||
if (isAtPositionFn(element)) {
|
||||
hitElement = element;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,8 @@ exports[`add element to the scene when pointer dragging long enough arrow 2`] =
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
@ -25,6 +27,7 @@ Object {
|
||||
],
|
||||
"roughness": 1,
|
||||
"seed": 337897,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#000000",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
@ -43,6 +46,7 @@ exports[`add element to the scene when pointer dragging long enough diamond 2`]
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
@ -69,6 +73,7 @@ exports[`add element to the scene when pointer dragging long enough ellipse 2`]
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
@ -93,6 +98,8 @@ exports[`add element to the scene when pointer dragging long enough line 1`] = `
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
@ -112,6 +119,7 @@ Object {
|
||||
],
|
||||
"roughness": 1,
|
||||
"seed": 337897,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#000000",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
@ -130,6 +138,7 @@ exports[`add element to the scene when pointer dragging long enough rectangle 2`
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
|
@ -4,6 +4,7 @@ exports[`duplicate element on move when ALT is clicked rectangle 1`] = `
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
@ -28,6 +29,7 @@ exports[`duplicate element on move when ALT is clicked rectangle 2`] = `
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
@ -52,6 +54,7 @@ exports[`move element rectangle 1`] = `
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
|
@ -4,6 +4,8 @@ exports[`multi point mode in linear elements arrow 1`] = `
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 110,
|
||||
@ -30,6 +32,7 @@ Object {
|
||||
],
|
||||
"roughness": 1,
|
||||
"seed": 337897,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#000000",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
@ -46,6 +49,8 @@ exports[`multi point mode in linear elements line 1`] = `
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 110,
|
||||
@ -72,6 +77,7 @@ Object {
|
||||
],
|
||||
"roughness": 1,
|
||||
"seed": 337897,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#000000",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@ exports[`resize element rectangle 1`] = `
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
@ -28,6 +29,7 @@ exports[`resize element with aspect ratio when SHIFT is clicked rectangle 1`] =
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
|
@ -4,6 +4,8 @@ exports[`select single element on the scene arrow 1`] = `
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
@ -23,6 +25,7 @@ Object {
|
||||
],
|
||||
"roughness": 1,
|
||||
"seed": 337897,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#000000",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
@ -39,6 +42,8 @@ exports[`select single element on the scene arrow escape 1`] = `
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
@ -58,6 +63,7 @@ Object {
|
||||
],
|
||||
"roughness": 1,
|
||||
"seed": 337897,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#000000",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
@ -74,6 +80,7 @@ exports[`select single element on the scene diamond 1`] = `
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
@ -98,6 +105,7 @@ exports[`select single element on the scene ellipse 1`] = `
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
@ -122,6 +130,7 @@ exports[`select single element on the scene rectangle 1`] = `
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElementIds": null,
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
|
70
src/tests/geometricAlgebra.test.ts
Normal file
70
src/tests/geometricAlgebra.test.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import * as GA from "../ga";
|
||||
import { point, toString, direction, offset } from "../ga";
|
||||
import * as GAPoint from "../gapoints";
|
||||
import * as GALine from "../galines";
|
||||
import * as GATransform from "../gatransforms";
|
||||
|
||||
describe("geometric algebra", () => {
|
||||
describe("points", () => {
|
||||
it("distanceToLine", () => {
|
||||
const point = GA.point(3, 3);
|
||||
const line = GALine.equation(0, 1, -1);
|
||||
expect(GAPoint.distanceToLine(point, line)).toEqual(2);
|
||||
});
|
||||
|
||||
it("distanceToLine neg", () => {
|
||||
const point = GA.point(-3, -3);
|
||||
const line = GALine.equation(0, 1, -1);
|
||||
expect(GAPoint.distanceToLine(point, line)).toEqual(-4);
|
||||
});
|
||||
});
|
||||
describe("lines", () => {
|
||||
it("through", () => {
|
||||
const a = GA.point(0, 0);
|
||||
const b = GA.point(2, 0);
|
||||
expect(toString(GALine.through(a, b))).toEqual(
|
||||
toString(GALine.equation(0, 2, 0)),
|
||||
);
|
||||
});
|
||||
it("parallel", () => {
|
||||
const point = GA.point(3, 3);
|
||||
const line = GALine.equation(0, 1, -1);
|
||||
const parallel = GALine.parallel(line, 2);
|
||||
expect(GAPoint.distanceToLine(point, parallel)).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translation", () => {
|
||||
it("points", () => {
|
||||
const start = point(2, 2);
|
||||
const move = GATransform.translation(direction(0, 1));
|
||||
const end = GATransform.apply(move, start);
|
||||
expect(toString(end)).toEqual(toString(point(2, 3)));
|
||||
});
|
||||
|
||||
it("points 2", () => {
|
||||
const start = point(2, 2);
|
||||
const move = GATransform.translation(offset(3, 4));
|
||||
const end = GATransform.apply(move, start);
|
||||
expect(toString(end)).toEqual(toString(point(5, 6)));
|
||||
});
|
||||
|
||||
it("lines", () => {
|
||||
const original = GALine.through(point(2, 2), point(3, 4));
|
||||
const move = GATransform.translation(offset(3, 4));
|
||||
const parallel = GATransform.apply(move, original);
|
||||
expect(toString(parallel)).toEqual(
|
||||
toString(GALine.through(point(5, 6), point(6, 8))),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("rotation", () => {
|
||||
it("points", () => {
|
||||
const start = point(2, 2);
|
||||
const pivot = point(1, 1);
|
||||
const rotate = GATransform.rotation(pivot, Math.PI / 2);
|
||||
const end = GATransform.apply(rotate, start);
|
||||
expect(toString(end)).toEqual(toString(point(2, 0)));
|
||||
});
|
||||
});
|
||||
});
|
@ -83,7 +83,9 @@ describe("duplicate element on move when ALT is clicked", () => {
|
||||
fireEvent.pointerMove(canvas, { clientX: 10, clientY: 60 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(4);
|
||||
// TODO: This used to be 4, but binding made it go up to 5. Do we need
|
||||
// that additional render?
|
||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(2);
|
||||
|
||||
|
@ -259,40 +259,40 @@ afterEach(() => {
|
||||
describe("regression tests", () => {
|
||||
it("draw every type of shape", () => {
|
||||
clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(10, 10);
|
||||
mouse.down(10, -10);
|
||||
mouse.up(20, 10);
|
||||
|
||||
clickTool("diamond");
|
||||
mouse.down(10, -10);
|
||||
mouse.up(10, 10);
|
||||
mouse.up(20, 10);
|
||||
|
||||
clickTool("ellipse");
|
||||
mouse.down(10, -10);
|
||||
mouse.up(10, 10);
|
||||
mouse.up(20, 10);
|
||||
|
||||
clickTool("arrow");
|
||||
mouse.down(10, -10);
|
||||
mouse.up(10, 10);
|
||||
mouse.down(40, -10);
|
||||
mouse.up(50, 10);
|
||||
|
||||
clickTool("line");
|
||||
mouse.down(10, -10);
|
||||
mouse.up(10, 10);
|
||||
mouse.down(40, -10);
|
||||
mouse.up(50, 10);
|
||||
|
||||
clickTool("arrow");
|
||||
mouse.click(10, -10);
|
||||
mouse.click(10, 10);
|
||||
mouse.click(-10, 10);
|
||||
mouse.click(40, -10);
|
||||
mouse.click(50, 10);
|
||||
mouse.click(30, 10);
|
||||
hotkeyPress("ENTER");
|
||||
|
||||
clickTool("line");
|
||||
mouse.click(10, -20);
|
||||
mouse.click(10, 10);
|
||||
mouse.click(-10, 10);
|
||||
mouse.click(40, -20);
|
||||
mouse.click(50, 10);
|
||||
mouse.click(30, 10);
|
||||
hotkeyPress("ENTER");
|
||||
|
||||
clickTool("draw");
|
||||
mouse.down(10, -20);
|
||||
mouse.up(10, 10);
|
||||
mouse.down(40, -20);
|
||||
mouse.up(50, 10);
|
||||
|
||||
expect(h.elements.map((element) => element.type)).toEqual([
|
||||
"rectangle",
|
||||
@ -569,17 +569,17 @@ describe("regression tests", () => {
|
||||
|
||||
it("undo/redo drawing an element", () => {
|
||||
clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(10, 10);
|
||||
mouse.down(10, -10);
|
||||
mouse.up(20, 10);
|
||||
|
||||
clickTool("rectangle");
|
||||
mouse.down(10, -10);
|
||||
mouse.up(10, 10);
|
||||
mouse.down(10, 0);
|
||||
mouse.up(30, 20);
|
||||
|
||||
clickTool("arrow");
|
||||
mouse.click(10, -10);
|
||||
mouse.click(10, 10);
|
||||
mouse.click(-10, 10);
|
||||
mouse.click(60, -10);
|
||||
mouse.click(60, 10);
|
||||
mouse.click(40, 10);
|
||||
hotkeyPress("ENTER");
|
||||
|
||||
expect(h.elements.filter((element) => !element.isDeleted).length).toBe(3);
|
||||
|
@ -7,11 +7,13 @@ import {
|
||||
ExcalidrawElement,
|
||||
FontFamily,
|
||||
GroupId,
|
||||
ExcalidrawBindableElement,
|
||||
} from "./element/types";
|
||||
import { SHAPES } from "./shapes";
|
||||
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
import { SocketUpdateDataSource } from "./data";
|
||||
import { LinearElementEditor } from "./element/linearElementEditor";
|
||||
import { SuggestedBinding } from "./element/binding";
|
||||
|
||||
export type FlooredNumber = number & { _brand: "FlooredNumber" };
|
||||
export type Point = Readonly<RoughPoint>;
|
||||
@ -33,6 +35,9 @@ export type AppState = {
|
||||
resizingElement: NonDeletedExcalidrawElement | null;
|
||||
multiElement: NonDeleted<ExcalidrawLinearElement> | null;
|
||||
selectionElement: NonDeletedExcalidrawElement | null;
|
||||
isBindingEnabled: boolean;
|
||||
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
|
||||
suggestedBindings: SuggestedBinding[];
|
||||
// element being edited, but not necessarily added to elements array yet
|
||||
// (e.g. text element when typing into the input)
|
||||
editingElement: NonDeletedExcalidrawElement | null;
|
||||
|
@ -241,7 +241,7 @@ export const isRTL = (text: string) => {
|
||||
};
|
||||
|
||||
export function tupleToCoors(
|
||||
xyTuple: [number, number],
|
||||
xyTuple: readonly [number, number],
|
||||
): { x: number; y: number } {
|
||||
const [x, y] = xyTuple;
|
||||
return { x, y };
|
||||
|
Loading…
x
Reference in New Issue
Block a user